From f86746c8007a2dc95faf40d78cd5467e59792086 Mon Sep 17 00:00:00 2001 From: Eric Provencher Date: Mon, 8 Jun 2026 23:39:47 -0400 Subject: [PATCH 1/2] Isolate core runtime and add headless foundation --- .../references/validation-matrix.md | 2 + .github/workflows/ci.yml | 6 + AGENTS.md | 57 +- Makefile | 37 +- Package.swift | 106 +- README.md | 19 + .../shared-runtime-headless-reviewed.sha256 | 51 + Scripts/conductor.py | 204 +- Scripts/core_boundary_guardrails.sh | 160 ++ Scripts/install_headless_cli.sh | 311 +++ Scripts/package_headless.sh | 70 + Scripts/release.sh | 3 + Scripts/shared_runtime_headless_baseline.py | 158 ++ Scripts/smoke_headless_mcp.sh | 423 ++++ Scripts/source_layout_guardrails.sh | 290 ++- Scripts/swift_style.sh | 3 + Scripts/sync_mcp_cli_version.sh | 81 +- Scripts/test_conductor_lifecycle.py | 199 +- Scripts/test_core_boundary_guardrails.py | 128 ++ Scripts/test_release_tooling.py | 301 ++- .../test_shared_runtime_headless_baseline.py | 93 + ..._shared_runtime_phase0_characterization.py | 139 ++ .../test_shared_runtime_phase1_boundaries.py | 196 ++ .../test_shared_runtime_phase2_boundaries.py | 773 +++++++ ...shared_runtime_phase2_slice1_boundaries.py | 193 ++ Sources/RepoPrompt/App/AppDelegate.swift | 10 +- .../RepoPrompt/App/ApplicationSecurity.swift | 4 +- .../CodeMapExtractor+AppAdapters.swift | 77 + .../EmbeddedPartitionStoreEventAdapter.swift | 23 + ...EmbeddedWorkspaceFileMutationBackend.swift | 113 ++ ...orkspaceRepositoryDiagnosticsAdapter.swift | 111 + .../EmbeddedWorkspaceRepositoryFactory.swift | 34 + .../FileSystemService+AppPublication.swift | 44 + .../FileViewModel+CoreSearch.swift | 107 + .../PathMatchingViewModelAdapters.swift | 24 + .../RepoPromptCoreRuntimeAliases.swift | 209 ++ ...orkspaceManagerSearchReadinessSource.swift | 64 + .../WorkspaceRuntimeDiagnosticsAdapter.swift | 11 + .../WorkspaceSessionObservationBridge.swift | 25 + Sources/RepoPrompt/App/RepoPromptApp.swift | 2 +- .../App/RepoPromptAppCoreContainer.swift | 50 + .../RepoPromptAppSessionAdapterRegistry.swift | 77 + ...romptEmbeddedWorkspaceRuntimeFactory.swift | 117 ++ .../App/ViewModels/ContentViewModel.swift | 2 +- .../RepoPrompt/App/WindowContentView.swift | 7 +- Sources/RepoPrompt/App/WindowState.swift | 64 +- .../App/WindowStateComposition.swift | 82 +- .../RepoPrompt/App/WindowStateManager.swift | 98 +- .../AgentPermissionSecureStore.swift | 13 +- .../Services/AgentContextExportResolver.swift | 1 + .../AgentProviderContextBuilder.swift | 17 +- .../RecommendationWizardViewModel.swift | 2 +- .../Features/CodeMap/CodeMapExtractor.swift | 61 +- .../Features/CodeMap/CodeMapPerfStats.swift | 702 ------- .../CodeMap/FileTreeSelectionSnapshot.swift | 67 - .../Features/CodeMap/Models/FileAPI.swift | 296 --- .../ContextBuilderAgentViewModel.swift | 217 +- .../Views/ContextBuilderPromptsOverlay.swift | 9 +- .../Core/BenchmarkDiffParserBridge.swift | 6 +- ...er+DebugDiagnosticsReadSearchLatency.swift | 1 + ...ionManager+DebugDiagnosticsWorkspace.swift | 2 +- .../Models/Copy/CopyCustomizations.swift | 114 -- .../Prompt/Models/Copy/CopyPreset.swift | 7 - .../Features/Prompt/Models/FilesTab.swift | 19 +- .../Prompt/Models/PromptAssemblyBuilder.swift | 71 - .../Models/PromptSection+DisplayName.swift | 13 + .../PromptContextAccountingService.swift | 790 +------- .../Services/PromptPackagingService.swift | 625 +++--- .../WorkspacePromptProjectionAdapter.swift | 532 +++++ ...romptViewModel+PromptSnapshotEntries.swift | 130 +- .../Prompt/ViewModels/PromptViewModel.swift | 1058 +++++----- .../ViewModels/TokenCountingViewModel.swift | 876 ++++---- .../ViewModels/APISettingsViewModel.swift | 15 +- .../Views/PromptOrderingSettingsMenu.swift | 1 + .../Models/FileSystemItems.swift | 49 - .../ViewModels/FileViewModel.swift | 15 - .../ViewModels/WorkspaceFilesViewModel.swift | 211 +- .../WorkspaceManagerViewModel.swift | 1797 ++++++----------- .../ViewModels/WorkspaceSaveDiagnostics.swift | 138 +- .../WorkspaceDuplicateCleanupModels.swift | 77 + .../Features/Workspaces/WorkspaceModel.swift | 601 +----- .../AI/ACP/ACPAgentSessionController.swift | 2 + .../Infrastructure/AI/AIMessage.swift | 242 ++- .../AI/Models/AIProviderInputProjection.swift | 210 ++ .../AI/Providers/AIProviderFactory.swift | 5 + .../AIProviderInputProjectionCapability.swift | 17 + .../ClaudeCodeCompatibleBackendStore.swift | 9 +- ...ClaudeNativeProcessSessionController.swift | 2 + .../AppServer/CodexAppServerClient.swift | 2 + .../Core/RepoPromptCoreHost.swift | 188 ++ .../Infrastructure/Diffing/EditFlowPerf.swift | 58 + .../FileSystem/FileContentSnapshot.swift | 105 - .../FileSystemService+FileOperations.swift | 450 ----- .../MacOS/MacOSFileSystemPublishPerf.swift | 36 + ...MacOSBootstrapAcceptedTransportLease.swift | 196 ++ ...cOSBootstrapSocketConnectionManager.swift} | 98 +- .../MacOSBootstrapSocketServer.swift} | 233 +-- .../MacOSUnixSocketMCPTransport.swift} | 3 +- .../MCP/AppSettingsMCPService.swift | 1 + .../MCP/AppShared/MCPConstants.swift | 3 +- .../AppShared/MCPFilesystemConstants.swift | 7 +- .../MCP/MCPConnectionManager.swift | 659 +++--- .../MCP/MCPExternalEventsMonitor.swift | 1 + .../MCP/MCPRuntimeSessionRegistry.swift | 248 +++ .../Infrastructure/MCP/MCPService.swift | 168 +- .../MCP/MCPToolCatalogReadiness.swift | 43 +- .../MCP/MCPToolNameCanonicalizer.swift | 11 + .../Infrastructure/MCP/ServerController.swift | 29 +- .../Infrastructure/MCP/ServiceRegistry.swift | 319 ++- .../MCPServerViewModel+SelectionReply.swift | 4 +- .../MCP/ViewModels/MCPServerViewModel.swift | 81 +- .../MCP/WindowRoutingService.swift | 46 +- ...OSRepoPromptCorePlatformDependencies.swift | 13 + .../Process/CLIProcessRunner.swift | 6 +- .../Process/ProcessRegistry.swift | 1 + .../Infrastructure/Security/KeyManager.swift | 13 +- .../AppSecureKeyValueStorageFactory.swift | 130 ++ .../Security/RuntimeCodeSigningPolicy.swift | 52 +- .../Security/SecureKeyService.swift | 100 - .../SecureKeyValueStorageBackend.swift | 70 - .../Security/SecureStorageRepairService.swift | 20 +- .../Utilities/StringExtensions.swift | 20 +- .../Infrastructure/VCS/GitService.swift | 1 + .../Infrastructure/VCS/JJCommandRunner.swift | 1 + .../Models/WorkspaceFileContextModels.swift | 490 ----- .../WorkspaceSelectionCoordinator.swift | 147 +- .../TokenCalculationSnapshot.swift | 26 - .../Support/RepoPrompt-Bridging-Header.h | 69 - .../ThirdParty/SwiftPCRE2/PCRE2Error.swift | 56 - .../ThirdParty/SwiftPCRE2/PCRE2JIT.swift | 22 - .../SwiftPCRE2/PCRE2LiteralEscaping.swift | 13 - .../ThirdParty/SwiftPCRE2/PCRE2Match.swift | 9 - .../ThirdParty/SwiftPCRE2/PCRE2Options.swift | 47 - .../ThirdParty/SwiftPCRE2/PCRE2Regex.swift | 1200 ----------- Sources/RepoPromptC/include/descriptor_path.h | 3 + .../include/string_extensions_wrapper.h | 4 + Sources/RepoPromptC/include/wildmatch.h | 18 + .../RepoPromptC/src/Utils/descriptor_path.c | 7 + .../src/Utils/string_extensions_wrapper.c | 8 + .../src/wildmatch/repo_wildmatch_wrapper.c | 8 - .../CodeMap/CodeMapCacheManager.swift | 70 +- .../CodeMap/CodeMapCaptureIndex.swift | 20 +- .../CodeMap/CodeMapExtractionMemo.swift | 16 +- .../CodeMap/CodeMapExtractor.swift | 146 ++ .../CodeMap/CodeMapGenerator.swift | 59 +- .../CodeMap/CodeMapPCRE2Regex.swift | 24 +- .../CodeMap/CodeMapPerfStats.swift | 700 +++++++ .../CodeMap/CodeMapRuntimeDiagnostics.swift | 14 + .../CodeMap/CodeScanActor.swift | 144 +- .../CodeMap/FileTreeSelectionSnapshot.swift | 90 + .../CodeMap/FileTreeSnapshotRenderer.swift} | 92 +- .../CodeMap/JSTSSignatureExtractor.swift | 8 +- .../SwiftCodeMapStrategy.swift | 10 +- .../TypeScriptCodeMapStrategy.swift | 12 +- .../CodeMap/LanguageTypeExtractor.swift | 98 +- .../CodeMap/Models/FileAPI.swift | 323 +++ .../CodeMap/ReferencedTypesAccumulator.swift | 14 +- .../CodeMap/SwiftSignatureParser.swift | 4 +- .../CodeMap/TopLevelScanner.swift | 30 +- .../CodeMap/TypeCleaner.swift | 22 +- .../FileSystem/FileContentSnapshot.swift | 58 + .../FileSystemDeltaPublicationHub.swift | 71 + .../FileSystem/FileSystemItems.swift | 50 + .../FileSystem/FileSystemProviding.swift | 0 .../FileSystemRuntimeDiagnostics.swift | 9 + .../FileSystemService+ContentLoading.swift | 350 ++-- ...leSystemService+DirectoryEnumeration.swift | 48 +- ...ystemService+DirectoryListingBackend.swift | 62 + .../FileSystemService+IgnoreRules.swift | 71 +- .../FileSystemService+Metadata.swift | 15 + .../FileSystemService+MutationCoherence.swift | 194 ++ .../FileSystemService+PathUtilities.swift | 6 +- .../FileSystemService+Testing.swift | 128 +- .../FileSystemService+Watching.swift} | 535 ++--- .../FileSystem/FileSystemService.swift | 220 +- .../FileSystem/FileSystemServiceTypes.swift | 109 +- .../FileSystemWatcherIngressMailbox.swift | 73 +- .../FileSystem/GitignoreCompiler.swift | 49 +- .../HierarchicalIgnoreEvaluator.swift | 14 +- .../FileSystem/IgnoreCacheStore.swift | 34 +- .../IgnoreDebugMetricsRecorder.swift | 79 +- .../FileSystem/IgnoreRules.swift | 40 +- .../FileSystem/IgnoreRulesManager.swift | 102 +- .../FileSystem/LRUCache.swift | 18 +- .../FileSystem/PathComponentsCache.swift | 6 +- .../FileSystem/PatternPool.swift | 6 +- .../WorkspaceDirectoryListingBackend.swift | 60 + .../WorkspaceFileMutationBackend.swift | 12 + .../Platform/BundledHelperPeerVerifying.swift | 16 + .../MCPAppProxyTransportBoundary.swift | 78 + .../Platform/ProcessAncestryInspecting.swift | 4 + .../MCP/Session/MCPSessionIdentifiers.swift | 18 + .../Session/MCPSessionToolVocabulary.swift | 99 + .../MCP/Session/ToolCapabilityPolicy.swift | 41 + .../Platform/FileSystemWatching.swift | 73 + .../Platform/ProcessLaunching.swift | 37 + .../RepoPromptCorePlatformDependencies.swift | 19 + .../SecureKeyValueStorageBackend.swift | 73 + .../Prompt/PromptAssemblyBuilder.swift | 135 ++ .../PromptContextAccountingService.swift | 642 ++++++ .../Prompt/PromptRenderPolicy.swift | 15 + .../Prompt/PromptRenderingService.swift | 207 ++ .../Prompt/PromptRenderingValues.swift | 142 ++ .../RepoPromptCore/Prompt/PromptSection.swift | 15 + Sources/RepoPromptCore/Regex/PCRE2Error.swift | 57 + Sources/RepoPromptCore/Regex/PCRE2JIT.swift | 24 + .../Regex/PCRE2LiteralEscaping.swift | 14 + Sources/RepoPromptCore/Regex/PCRE2Match.swift | 9 + .../RepoPromptCore/Regex/PCRE2Options.swift | 49 + Sources/RepoPromptCore/Regex/PCRE2Regex.swift | 1214 +++++++++++ .../Regex/PCRE2RegexAdapter.swift | 62 +- .../Regex/RegexToolkit.swift | 4 +- .../EphemeralSecureKeyValueStore.swift | 26 +- .../Security/SecureKeyService.swift | 94 + .../SyntaxParsing/Queries/DartQueries.swift | 4 +- .../SyntaxParsing/Queries/GoQueries.swift | 4 +- .../SyntaxParsing/Queries/JavaQueries.swift | 4 +- .../Queries/JavaScriptQueries.swift | 4 +- .../SyntaxParsing/Queries/PythonQueries.swift | 4 +- .../SyntaxParsing/Queries/RubyQueries.swift | 4 +- .../SyntaxParsing/Queries/RustQueries.swift | 4 +- .../SyntaxParsing/Queries/SwiftQueries.swift | 4 +- .../SyntaxParsing/Queries/cQueries.swift | 4 +- .../SyntaxParsing/Queries/cSharpQueries.swift | 4 +- .../SyntaxParsing/Queries/cppQueries.swift | 4 +- .../SyntaxParsing/Queries/phpQueries.swift | 8 +- .../SyntaxParsing/Queries/typeScript.swift | 4 +- .../SyntaxParsing/QueryResourceLoader.swift | 6 +- .../SyntaxParsing/SyntaxManager.swift | 59 +- .../Utilities/RelativePath.swift | 6 +- .../Utilities/StandardizedPath.swift | 30 +- .../RepoPromptCore/Utilities/StringFNV.swift | 9 + .../Utilities/StringLineEndingUtilities.swift | 56 + .../Utilities/WorkspaceTaskSemaphore.swift | 37 + .../Indexing/DeferredReplayBufferActor.swift | 78 +- .../DeltaReplayPreparationActor.swift | 124 +- .../Models/FilePathDisplay.swift | 4 + .../Models/WorkspaceFileContextModels.swift | 595 ++++++ .../PathLookup/PathCharPolicy.swift | 12 +- .../PathLookup/PathMatchTypes.swift | 111 +- .../PathLookup/PathMatchWorker.swift | 26 +- .../PathLookup/PathMatcher.swift | 20 +- .../PathLookup/PathMatchingInterfaces.swift | 23 +- .../AgentSupportDirectoryCatalog.swift | 73 +- .../PathResolution/CreatePathPreflight.swift | 28 +- .../PathResolution/MovePathResolver.swift | 12 +- .../WorkspaceExternalFileReading.swift | 67 + .../PathResolution/WorkspacePathPolicy.swift | 44 +- .../Projection/CodeStructureProjection.swift | 155 ++ .../CodeStructureProjectionService.swift | 222 ++ .../Projection/TokenProjection.swift | 81 + .../Projection/TokenProjectionService.swift | 314 +++ .../WorkspaceContextProjection.swift | 258 +++ .../WorkspaceContextProjectionService.swift | 742 +++++++ .../WorkspaceSelectionProjection.swift | 309 +++ .../WorkspaceSelectionProjectionService.swift | 271 +++ .../Search/FileSearchContentSnapshot.swift | 28 + .../Search/PathSearchIndex.swift | 78 +- .../Search/RepoSearchBatchScorer.swift | 22 +- .../Search/RepoSearchQuery.swift | 16 +- .../Search/SearchMatch.swift | 437 ++-- .../Search/SearchPathFiltering.swift | 74 +- .../Search/StoreBackedWorkspaceSearch.swift | 116 +- .../StoreBackedWorkspaceSearchLane.swift | 112 +- .../WorkspaceSearchReadinessSource.swift | 33 + .../Search/WorkspaceSearchService.swift | 30 +- .../WorkspaceSelectionController.swift | 328 +++ .../WorkspaceSelectionMutationService.swift | 125 +- .../WorkspaceContext/Slices/LineRange.swift | 12 +- .../Slices/PartitionStore.swift | 99 +- .../Slices/SelectionSliceCoordinator.swift | 36 +- .../Slices/SliceAssembly.swift | 47 +- .../Slices/SliceRangeMath.swift | 8 +- .../Slices/SliceRebaseEngine.swift | 20 +- .../TokenCalculationService.swift | 317 +-- .../TokenCalculationSnapshot.swift | 60 + .../WorkspaceFileContextStore.swift | 1194 +++++++---- .../WorkspaceFileManagerError.swift | 24 + ...orkspaceFileSystemIngressCoordinator.swift | 32 +- .../WorkspaceReadableFileService.swift | 118 +- .../WorkspaceRuntimeDebugDiagnostics.swift | 40 + .../WorkspaceRuntimeDependencies.swift | 57 + .../WorkspaceRuntimeDiagnostics.swift | 42 + .../WorkspaceRuntimePerf.swift | 905 +++++++++ .../WorkspaceSearchDecodedContentCache.swift | 0 .../Workspaces/CodeMapUsage.swift | 9 + .../Workspaces/CopyCustomizations.swift | 55 + .../Workspaces/EmbeddedWorkspaceCodecV1.swift | 20 + .../Workspaces/FileTreeOption.swift | 12 + .../RepoPromptCore/Workspaces/FilesTab.swift | 18 + .../Workspaces/GitInclusion.swift | 7 + .../Workspaces/WorkspaceAccessPolicy.swift | 20 + .../Workspaces/WorkspaceDocumentCodec.swift | 61 + .../WorkspaceLegacyMigrationContracts.swift | 40 + .../Workspaces/WorkspaceModel.swift | 375 ++++ .../WorkspacePersistenceWriter.swift | 472 +++++ .../Workspaces/WorkspaceRepository.swift | 298 +++ .../WorkspaceRepositoryContracts.swift | 130 ++ .../Workspaces/WorkspaceRootActions.swift | 8 +- .../Workspaces/WorkspaceSaveMetadata.swift | 143 ++ .../WorkspaceSessionController.swift | 501 +++++ .../FileSystem/MacOSFSEventsWatcher.swift | 451 +++++ .../MacOSFileContentSnapshotReader.swift | 64 + ...acOSWorkspaceDirectoryListingBackend.swift | 138 ++ .../MacOSWorkspaceExternalFileReader.swift | 189 ++ .../MacOSBundledHelperPeerVerifier.swift | 27 + .../MacOSProcessAncestryInspector.swift | 17 + .../Process/FDWriteSupport.swift | 10 +- .../Process/POSIXProcessLauncher.swift} | 54 +- .../Security/KeychainService.swift | 127 +- .../Security/RuntimeCodeSigningDetector.swift | 124 +- .../RepoPromptHeadless/CLI/HeadlessCLI.swift | 303 +++ .../CLI/HeadlessCommandError.swift | 15 + .../Configuration/HeadlessConfiguration.swift | 122 ++ .../HeadlessConfigurationStore.swift | 71 + .../Configuration/HeadlessFileLock.swift | 52 + .../HeadlessRootAccessPolicy.swift | 115 ++ .../HeadlessStateFileSecurity.swift | 421 ++++ .../Configuration/HeadlessStatePaths.swift | 86 + .../RepoPromptHeadless/HeadlessVersion.swift | 12 + .../MCP/HeadlessJSONRPC.swift | 92 + .../MCP/HeadlessMCPServer.swift | 349 ++++ .../MCP/HeadlessNewlineFrameDecoder.swift | 74 + .../MCP/HeadlessStdioTransport.swift | 107 + .../MCP/HeadlessStdoutWriter.swift | 24 + .../MCP/HeadlessToolRegistry.swift | 232 +++ .../MCP/HeadlessToolSupport.swift | 151 ++ .../MCP/Tools/HeadlessFileTools.swift | 176 ++ .../MCP/Tools/HeadlessPromptTools.swift | 139 ++ .../MCP/Tools/HeadlessSelectionTools.swift | 238 +++ .../MCP/Tools/HeadlessWorkspaceTools.swift | 109 + .../HeadlessCodeStructureService.swift | 146 ++ .../Runtime/HeadlessExportWriter.swift | 91 + .../Runtime/HeadlessFileCatalog.swift | 236 +++ .../Runtime/HeadlessHost.swift | 252 +++ .../Runtime/HeadlessPathResolver.swift | 147 ++ .../Runtime/HeadlessReadFileSlicer.swift | 104 + .../Runtime/HeadlessSearchService.swift | 488 +++++ .../Runtime/HeadlessSecureFileAccess.swift | 166 ++ .../Runtime/HeadlessWorkspaceModels.swift | 184 ++ .../Runtime/HeadlessWorkspaceStore.swift | 129 ++ .../HeadlessExternalExportFileSecurity.swift | 190 ++ .../Security/HeadlessSecureStorage.swift | 10 + .../Support/HeadlessOutput.swift | 42 + Sources/RepoPromptHeadless/main.swift | 11 + .../InteractiveMCPClientSession.swift | 1 + .../Shared/MCPBootstrapMessages.swift | 113 -- .../Shared/MCPFilesystemConstants.swift | 7 +- .../BootstrapSocketMCPTransport.swift | 1 + Sources/RepoPromptMCP/main.swift | 1 + .../Descriptors/POSIXDescriptorSupport.swift | 73 + .../POSIXFileContentSnapshotSupport.swift | 82 + .../MCP}/MCPBootstrapMessages.swift | 6 +- .../MCP/MCPFilesystemIdentity.swift | 5 +- .../MCP/POSIXDescriptorSupport.swift | 39 - .../RepoPromptSyntaxCBridge.c | 1 + .../include/RepoPromptSyntaxCBridge.h | 21 + .../MacOSFSEventsWatcherTests.swift | 636 ++++++ .../MacOSFileContentSnapshotReaderTests.swift | 66 + ...acOSWorkspaceExternalFileReaderTests.swift | 181 ++ .../RepoPromptCoreMacOSTests.swift | 5 + .../CodeMap/CodeMapGoldenTests.swift | 2 +- .../CodeMap/Fixtures/c/smoke.c | 0 .../CodeMap/Fixtures/cpp/edge_methods.cpp | 0 .../CodeMap/Fixtures/dart/smoke.dart | 0 .../CodeMap/Fixtures/go/smoke.go | 0 .../CodeMap/Fixtures/java/smoke.java | 0 .../CodeMap/Fixtures/js/smoke.js | 0 .../CodeMap/Fixtures/php/edge_namespaces.php | 0 .../CodeMap/Fixtures/py/smoke.py | 0 .../CodeMap/Fixtures/rb/smoke.rb | 0 .../CodeMap/Fixtures/rs/smoke.rs | 0 .../CodeMap/Fixtures/swift/smoke.swift | 0 .../CodeMap/Fixtures/ts/smoke.ts | 0 .../CodeMap/Fixtures/tsx/component.tsx | 0 .../CodeMap/Goldens/c_smoke.codemap.txt | 0 .../Goldens/cpp_edge_methods.codemap.txt | 0 .../CodeMap/Goldens/dart_smoke.codemap.txt | 0 .../CodeMap/Goldens/fixture-tree.txt | 0 .../CodeMap/Goldens/go_smoke.codemap.txt | 0 .../CodeMap/Goldens/java_smoke.codemap.txt | 0 .../CodeMap/Goldens/js_smoke.codemap.txt | 0 .../Goldens/php_edge_namespaces.codemap.txt | 0 .../CodeMap/Goldens/py_smoke.codemap.txt | 0 .../CodeMap/Goldens/rb_smoke.codemap.txt | 0 .../CodeMap/Goldens/rs_smoke.codemap.txt | 0 .../CodeMap/Goldens/swift_smoke.codemap.txt | 0 .../CodeMap/Goldens/ts_smoke.codemap.txt | 0 .../CodeMap/Goldens/tsx_component.codemap.txt | 0 .../Helpers/CodeMapFixtureRunner.swift | 4 +- ...ileSystemAcceptedIngressBarrierTests.swift | 138 +- ...leSystemServiceEventPathMappingTests.swift | 2 +- ...FileSystemServiceIgnoreRecoveryTests.swift | 2 +- .../FileSystemServiceRecoveryTests.swift | 2 +- .../FileSystem/IgnoreRulesRecoveryTests.swift | 2 +- .../Phase1CoreBoundaryContractTests.swift | 83 + .../Prompt/PromptAssemblyBuilderTests.swift | 89 + .../PromptContextAccountingServiceTests.swift | 562 ++++++ .../Prompt/PromptRenderingServiceTests.swift | 204 ++ .../Support/FileSystemTestSupport.swift | 54 + .../Support/RepoRoot.swift | 53 + .../Support/TestWorkspaceRuntime.swift | 270 +++ .../AgentSupportDirectoryCatalogTests.swift | 42 + ...ortDirectoryContainmentSecurityTests.swift | 58 + .../PathMatchingRecoveryTests.swift | 2 +- .../CodeStructureProjectionTests.swift | 259 +++ .../Projection/TokenProjectionTests.swift | 441 ++++ ...rkspaceContextProjectionServiceTests.swift | 883 ++++++++ ...spaceSelectionProjectionServiceTests.swift | 415 ++++ .../Search/PathSearchIndexRecoveryTests.swift | 2 +- .../Search/RepoSearchQueryRecoveryTests.swift | 2 +- .../Search/SearchPathFilteringTests.swift | 2 +- ...orkspaceSearchConcurrencyMatrixTests.swift | 6 +- .../StoreBackedWorkspaceSearchLaneTests.swift | 2 +- .../StoreBackedWorkspaceSearchTests.swift | 186 +- .../Search/WorkspaceSearchServiceTests.swift | 2 +- .../WorkspaceSelectionControllerTests.swift | 146 ++ ...ectionSlicePersistenceAndRebaseTests.swift | 2 +- .../TokenCalculationServiceTests.swift | 579 ++++++ .../EmbeddedWorkspaceCodecV1Tests.swift | 99 + .../WorkspacePersistenceWriterTests.swift | 170 ++ .../Workspaces/WorkspaceRepositoryTests.swift | 186 ++ .../WorkspaceSelectionPersistenceTests.swift | 177 ++ .../WorkspaceSessionControllerTests.swift | 239 +++ .../Workspaces/WorkspaceTestSupport.swift | 81 + .../HeadlessExportWriterTests.swift | 105 + .../HeadlessMCPServerLifecycleTests.swift | 417 ++++ .../HeadlessNewlineFrameDecoderTests.swift | 140 ++ .../HeadlessReadFileSlicerTests.swift | 42 + .../HeadlessSearchServiceTests.swift | 333 +++ .../HeadlessSecureFileAccessTests.swift | 117 ++ .../HeadlessSelectionToolsTests.swift | 170 ++ .../HeadlessStateFileSecurityTests.swift | 258 +++ .../HeadlessStateTransactionTests.swift | 144 ++ .../HeadlessStdioTransportTests.swift | 273 +++ .../HeadlessWorkspaceStoreTests.swift | 148 ++ .../Helpers/RepoRoot.swift | 23 + ...ePhase0HeadlessCharacterizationTests.swift | 376 ++++ .../POSIXDescriptorSupportTests.swift | 70 + .../AgentContextExportResolverTests.swift | 1 + .../AgentModeChatSwitchActivationTests.swift | 37 +- .../AgentProviderContextBuilderTests.swift | 59 + ...RenderingParityCharacterizationTests.swift | 250 +++ .../RepoPromptCoreHostLifecycleTests.swift | 86 + ...rkspaceSessionObservationBridgeTests.swift | 51 + .../App/WorkspaceSlice1CompositionTests.swift | 149 ++ .../ContextBuilderNestedMCPFailureTests.swift | 10 +- .../ContextBuilderRunLifecycleTests.swift | 3 +- .../Helpers/TestWorkspaceRuntime.swift | 113 ++ .../BootstrapSocketOwnershipTests.swift | 8 +- ...PAppProxyAcceptedTransportLeaseTests.swift | 113 ++ ...otstrapContractCharacterizationTests.swift | 134 ++ ...sponseSendDeadlineConfigurationTests.swift | 51 +- .../MCPSocketDescriptorHardeningTests.swift | 55 +- ...oolExecutionWatchdogIntegrationTests.swift | 8 +- ...tAgentModeMCPReadFileConnectionTests.swift | 44 +- ...CPDistinctConnectionConcurrencyTests.swift | 1109 +++++++--- ...ssLauncherDescriptorInheritanceTests.swift | 4 +- .../ServerControllerAdmissionTests.swift | 70 +- .../MCP/MCPFilesystemIdentityTests.swift | 21 +- ...adSearchLatencyDiagnosticsGuardTests.swift | 412 ++-- ...RenderingParityCharacterizationTests.swift | 540 +++++ .../MCP/MCPRuntimeRegistryTests.swift | 300 +++ .../MCP/MCPServiceParticipationTests.swift | 261 +++ ...edRuntimePhase0CharacterizationTests.swift | 276 +++ .../MCP/TabContextRoutingTests.swift | 173 +- .../MCP/ToolCatalogSnapshotTests.swift | 11 + .../AIMessagePromptAssemblyParityTests.swift | 429 ++++ ...oviderInputProjectionFoundationTests.swift | 295 +++ .../PromptContextAccountingServiceTests.swift | 191 -- ...PromptContextPreAssemblyServiceTests.swift | 5 +- .../Prompt/PromptMigrationRemovalTests.swift | 260 +++ ...RenderingParityCharacterizationTests.swift | 743 +++++++ .../PromptTokenEstimateParityTests.swift | 273 +++ ...okenCountingViewModelProjectionTests.swift | 842 ++++++++ ...orkspacePromptProjectionAdapterTests.swift | 1001 +++++++++ .../AgentPermissionSecureStoreTests.swift | 29 +- ...DebugSecureStorageRuntimePolicyTests.swift | 2 + .../Security/KeychainServiceTests.swift | 10 +- .../LocalSigningIdentityRegistryTests.swift | 1 + .../SecureStorageAccountCatalogTests.swift | 14 +- .../SecureStorageRepairServiceTests.swift | 19 +- .../SecureStorageRepairViewModelTests.swift | 9 +- ...SystemContentLoadingConcurrencyTests.swift | 26 +- .../IgnoreDebugMetricsRecorderTests.swift | 1 + .../WorkspaceFileContextStoreTests.swift | 393 +++- ...orkspaceLoadingDiagnosticsGuardTests.swift | 2 +- .../WorkspaceSelectionCoordinatorTests.swift | 286 +-- .../WorkspaceSelectionPersistenceTests.swift | 117 +- .../WorkspaceRootSyncTests.swift | 6 +- .../workspace.json | 36 + .../App/WorkspaceV1/workspacesIndex.json | 8 + .../Phase0/App/app-characterization.json | 347 ++++ .../22222222-2222-2222-2222-222222222222.json | 14 + .../Phase0/Headless/ProfileV1/config.json | 19 + .../Headless/headless-characterization.json | 953 +++++++++ .../Phase0/differential-ledger.json | 20 + .../Phase0/manifest.json | 34 + docs/architecture/headless-core.md | 390 ++++ docs/architecture/source-layout.md | 95 +- .../shared-runtime-phase0-2026-06-05.md | 113 ++ .../shared-runtime-phase1-2026-06-05.md | 57 + ...shared-runtime-phase2-slice1-2026-06-05.md | 85 + ...shared-runtime-phase2-slice2-2026-06-05.md | 89 + ...time-phase2-slice3-rendering-2026-06-06.md | 50 + minimalXcodeFreeSetup.md | 411 ---- 506 files changed, 51011 insertions(+), 14410 deletions(-) create mode 100644 Scripts/Fixtures/shared-runtime-headless-reviewed.sha256 create mode 100755 Scripts/core_boundary_guardrails.sh create mode 100755 Scripts/install_headless_cli.sh create mode 100755 Scripts/package_headless.sh create mode 100644 Scripts/shared_runtime_headless_baseline.py create mode 100755 Scripts/smoke_headless_mcp.sh create mode 100644 Scripts/test_core_boundary_guardrails.py create mode 100644 Scripts/test_shared_runtime_headless_baseline.py create mode 100644 Scripts/test_shared_runtime_phase0_characterization.py create mode 100755 Scripts/test_shared_runtime_phase1_boundaries.py create mode 100644 Scripts/test_shared_runtime_phase2_boundaries.py create mode 100644 Scripts/test_shared_runtime_phase2_slice1_boundaries.py create mode 100644 Sources/RepoPrompt/App/CoreAdapters/CodeMapExtractor+AppAdapters.swift create mode 100644 Sources/RepoPrompt/App/CoreAdapters/EmbeddedPartitionStoreEventAdapter.swift create mode 100644 Sources/RepoPrompt/App/CoreAdapters/EmbeddedWorkspaceFileMutationBackend.swift create mode 100644 Sources/RepoPrompt/App/CoreAdapters/EmbeddedWorkspaceRepositoryDiagnosticsAdapter.swift create mode 100644 Sources/RepoPrompt/App/CoreAdapters/EmbeddedWorkspaceRepositoryFactory.swift create mode 100644 Sources/RepoPrompt/App/CoreAdapters/FileSystemService+AppPublication.swift create mode 100644 Sources/RepoPrompt/App/CoreAdapters/FileViewModel+CoreSearch.swift create mode 100644 Sources/RepoPrompt/App/CoreAdapters/PathMatchingViewModelAdapters.swift create mode 100644 Sources/RepoPrompt/App/CoreAdapters/RepoPromptCoreRuntimeAliases.swift create mode 100644 Sources/RepoPrompt/App/CoreAdapters/WorkspaceManagerSearchReadinessSource.swift create mode 100644 Sources/RepoPrompt/App/CoreAdapters/WorkspaceRuntimeDiagnosticsAdapter.swift create mode 100644 Sources/RepoPrompt/App/CoreAdapters/WorkspaceSessionObservationBridge.swift create mode 100644 Sources/RepoPrompt/App/RepoPromptAppCoreContainer.swift create mode 100644 Sources/RepoPrompt/App/RepoPromptAppSessionAdapterRegistry.swift create mode 100644 Sources/RepoPrompt/App/RepoPromptEmbeddedWorkspaceRuntimeFactory.swift delete mode 100644 Sources/RepoPrompt/Features/CodeMap/CodeMapPerfStats.swift delete mode 100644 Sources/RepoPrompt/Features/CodeMap/FileTreeSelectionSnapshot.swift delete mode 100644 Sources/RepoPrompt/Features/CodeMap/Models/FileAPI.swift delete mode 100644 Sources/RepoPrompt/Features/Prompt/Models/Copy/CopyCustomizations.swift delete mode 100644 Sources/RepoPrompt/Features/Prompt/Models/PromptAssemblyBuilder.swift create mode 100644 Sources/RepoPrompt/Features/Prompt/Models/PromptSection+DisplayName.swift create mode 100644 Sources/RepoPrompt/Features/Prompt/Services/WorkspacePromptProjectionAdapter.swift create mode 100644 Sources/RepoPrompt/Features/Workspaces/WorkspaceDuplicateCleanupModels.swift create mode 100644 Sources/RepoPrompt/Infrastructure/AI/Models/AIProviderInputProjection.swift create mode 100644 Sources/RepoPrompt/Infrastructure/AI/Providers/AIProviderInputProjectionCapability.swift create mode 100644 Sources/RepoPrompt/Infrastructure/Core/RepoPromptCoreHost.swift delete mode 100644 Sources/RepoPrompt/Infrastructure/FileSystem/FileContentSnapshot.swift delete mode 100644 Sources/RepoPrompt/Infrastructure/FileSystem/FileSystemService+FileOperations.swift create mode 100644 Sources/RepoPrompt/Infrastructure/FileSystem/MacOS/MacOSFileSystemPublishPerf.swift create mode 100644 Sources/RepoPrompt/Infrastructure/MCP/AppProxy/MacOSBootstrapAcceptedTransportLease.swift rename Sources/RepoPrompt/Infrastructure/MCP/{BootstrapSocketConnectionManager.swift => AppProxy/MacOSBootstrapSocketConnectionManager.swift} (80%) rename Sources/RepoPrompt/Infrastructure/MCP/{BootstrapSocketServer.swift => AppProxy/MacOSBootstrapSocketServer.swift} (83%) rename Sources/RepoPrompt/Infrastructure/MCP/{UnixSocketMCPTransport.swift => AppProxy/MacOSUnixSocketMCPTransport.swift} (99%) create mode 100644 Sources/RepoPrompt/Infrastructure/MCP/MCPRuntimeSessionRegistry.swift create mode 100644 Sources/RepoPrompt/Infrastructure/MCP/MCPToolNameCanonicalizer.swift create mode 100644 Sources/RepoPrompt/Infrastructure/MacOS/MacOSRepoPromptCorePlatformDependencies.swift create mode 100644 Sources/RepoPrompt/Infrastructure/Security/MacOS/AppSecureKeyValueStorageFactory.swift delete mode 100644 Sources/RepoPrompt/Infrastructure/Security/SecureKeyService.swift delete mode 100644 Sources/RepoPrompt/Infrastructure/Security/SecureKeyValueStorageBackend.swift delete mode 100644 Sources/RepoPrompt/Infrastructure/WorkspaceContext/Models/WorkspaceFileContextModels.swift delete mode 100644 Sources/RepoPrompt/Infrastructure/WorkspaceContext/TokenAccounting/TokenCalculationSnapshot.swift delete mode 100644 Sources/RepoPrompt/Support/RepoPrompt-Bridging-Header.h delete mode 100644 Sources/RepoPrompt/ThirdParty/SwiftPCRE2/PCRE2Error.swift delete mode 100644 Sources/RepoPrompt/ThirdParty/SwiftPCRE2/PCRE2JIT.swift delete mode 100644 Sources/RepoPrompt/ThirdParty/SwiftPCRE2/PCRE2LiteralEscaping.swift delete mode 100644 Sources/RepoPrompt/ThirdParty/SwiftPCRE2/PCRE2Match.swift delete mode 100644 Sources/RepoPrompt/ThirdParty/SwiftPCRE2/PCRE2Options.swift delete mode 100644 Sources/RepoPrompt/ThirdParty/SwiftPCRE2/PCRE2Regex.swift create mode 100644 Sources/RepoPromptC/include/descriptor_path.h create mode 100644 Sources/RepoPromptC/src/Utils/descriptor_path.c rename Sources/{RepoPrompt/Features => RepoPromptCore}/CodeMap/CodeMapCacheManager.swift (88%) rename Sources/{RepoPrompt/Features => RepoPromptCore}/CodeMap/CodeMapCaptureIndex.swift (88%) rename Sources/{RepoPrompt/Features => RepoPromptCore}/CodeMap/CodeMapExtractionMemo.swift (91%) create mode 100644 Sources/RepoPromptCore/CodeMap/CodeMapExtractor.swift rename Sources/{RepoPrompt/Features => RepoPromptCore}/CodeMap/CodeMapGenerator.swift (98%) rename Sources/{RepoPrompt/Features => RepoPromptCore}/CodeMap/CodeMapPCRE2Regex.swift (84%) create mode 100644 Sources/RepoPromptCore/CodeMap/CodeMapPerfStats.swift create mode 100644 Sources/RepoPromptCore/CodeMap/CodeMapRuntimeDiagnostics.swift rename Sources/{RepoPrompt/Features => RepoPromptCore}/CodeMap/CodeScanActor.swift (92%) create mode 100644 Sources/RepoPromptCore/CodeMap/FileTreeSelectionSnapshot.swift rename Sources/{RepoPrompt/Features/CodeMap/CodeMapExtractor+Snapshots.swift => RepoPromptCore/CodeMap/FileTreeSnapshotRenderer.swift} (87%) rename Sources/{RepoPrompt/Features => RepoPromptCore}/CodeMap/JSTSSignatureExtractor.swift (98%) rename Sources/{RepoPrompt/Features => RepoPromptCore}/CodeMap/LanguageStrategies/SwiftCodeMapStrategy.swift (99%) rename Sources/{RepoPrompt/Features => RepoPromptCore}/CodeMap/LanguageStrategies/TypeScriptCodeMapStrategy.swift (99%) rename Sources/{RepoPrompt/Features => RepoPromptCore}/CodeMap/LanguageTypeExtractor.swift (94%) create mode 100644 Sources/RepoPromptCore/CodeMap/Models/FileAPI.swift rename Sources/{RepoPrompt/Features => RepoPromptCore}/CodeMap/ReferencedTypesAccumulator.swift (91%) rename Sources/{RepoPrompt/Features => RepoPromptCore}/CodeMap/SwiftSignatureParser.swift (95%) rename Sources/{RepoPrompt/Features => RepoPromptCore}/CodeMap/TopLevelScanner.swift (88%) rename Sources/{RepoPrompt/Features => RepoPromptCore}/CodeMap/TypeCleaner.swift (98%) create mode 100644 Sources/RepoPromptCore/FileSystem/FileContentSnapshot.swift create mode 100644 Sources/RepoPromptCore/FileSystem/FileSystemDeltaPublicationHub.swift create mode 100644 Sources/RepoPromptCore/FileSystem/FileSystemItems.swift rename Sources/{RepoPrompt/Infrastructure => RepoPromptCore}/FileSystem/FileSystemProviding.swift (100%) create mode 100644 Sources/RepoPromptCore/FileSystem/FileSystemRuntimeDiagnostics.swift rename Sources/{RepoPrompt/Infrastructure => RepoPromptCore}/FileSystem/FileSystemService+ContentLoading.swift (83%) rename Sources/{RepoPrompt/Infrastructure => RepoPromptCore}/FileSystem/FileSystemService+DirectoryEnumeration.swift (96%) create mode 100644 Sources/RepoPromptCore/FileSystem/FileSystemService+DirectoryListingBackend.swift rename Sources/{RepoPrompt/Infrastructure => RepoPromptCore}/FileSystem/FileSystemService+IgnoreRules.swift (88%) create mode 100644 Sources/RepoPromptCore/FileSystem/FileSystemService+Metadata.swift create mode 100644 Sources/RepoPromptCore/FileSystem/FileSystemService+MutationCoherence.swift rename Sources/{RepoPrompt/Infrastructure => RepoPromptCore}/FileSystem/FileSystemService+PathUtilities.swift (98%) rename Sources/{RepoPrompt/Infrastructure => RepoPromptCore}/FileSystem/FileSystemService+Testing.swift (51%) rename Sources/{RepoPrompt/Infrastructure/FileSystem/FileSystemService+FSEvents.swift => RepoPromptCore/FileSystem/FileSystemService+Watching.swift} (73%) rename Sources/{RepoPrompt/Infrastructure => RepoPromptCore}/FileSystem/FileSystemService.swift (57%) rename Sources/{RepoPrompt/Infrastructure => RepoPromptCore}/FileSystem/FileSystemServiceTypes.swift (61%) rename Sources/{RepoPrompt/Infrastructure => RepoPromptCore}/FileSystem/FileSystemWatcherIngressMailbox.swift (76%) rename Sources/{RepoPrompt/Infrastructure => RepoPromptCore}/FileSystem/GitignoreCompiler.swift (95%) rename Sources/{RepoPrompt/Infrastructure => RepoPromptCore}/FileSystem/HierarchicalIgnoreEvaluator.swift (91%) rename Sources/{RepoPrompt/Infrastructure => RepoPromptCore}/FileSystem/IgnoreCacheStore.swift (92%) rename Sources/{RepoPrompt/Infrastructure => RepoPromptCore}/FileSystem/IgnoreDebugMetricsRecorder.swift (73%) rename Sources/{RepoPrompt/Infrastructure => RepoPromptCore}/FileSystem/IgnoreRules.swift (88%) rename Sources/{RepoPrompt/Infrastructure => RepoPromptCore}/FileSystem/IgnoreRulesManager.swift (66%) rename Sources/{RepoPrompt/Infrastructure => RepoPromptCore}/FileSystem/LRUCache.swift (89%) rename Sources/{RepoPrompt/Infrastructure => RepoPromptCore}/FileSystem/PathComponentsCache.swift (82%) rename Sources/{RepoPrompt/Infrastructure => RepoPromptCore}/FileSystem/PatternPool.swift (93%) create mode 100644 Sources/RepoPromptCore/FileSystem/WorkspaceDirectoryListingBackend.swift create mode 100644 Sources/RepoPromptCore/FileSystem/WorkspaceFileMutationBackend.swift create mode 100644 Sources/RepoPromptCore/MCP/Platform/BundledHelperPeerVerifying.swift create mode 100644 Sources/RepoPromptCore/MCP/Platform/MCPAppProxyTransportBoundary.swift create mode 100644 Sources/RepoPromptCore/MCP/Platform/ProcessAncestryInspecting.swift create mode 100644 Sources/RepoPromptCore/MCP/Session/MCPSessionIdentifiers.swift create mode 100644 Sources/RepoPromptCore/MCP/Session/MCPSessionToolVocabulary.swift create mode 100644 Sources/RepoPromptCore/MCP/Session/ToolCapabilityPolicy.swift create mode 100644 Sources/RepoPromptCore/Platform/FileSystemWatching.swift create mode 100644 Sources/RepoPromptCore/Platform/ProcessLaunching.swift create mode 100644 Sources/RepoPromptCore/Platform/RepoPromptCorePlatformDependencies.swift create mode 100644 Sources/RepoPromptCore/Platform/SecureKeyValueStorageBackend.swift create mode 100644 Sources/RepoPromptCore/Prompt/PromptAssemblyBuilder.swift create mode 100644 Sources/RepoPromptCore/Prompt/PromptContextAccountingService.swift create mode 100644 Sources/RepoPromptCore/Prompt/PromptRenderPolicy.swift create mode 100644 Sources/RepoPromptCore/Prompt/PromptRenderingService.swift create mode 100644 Sources/RepoPromptCore/Prompt/PromptRenderingValues.swift create mode 100644 Sources/RepoPromptCore/Prompt/PromptSection.swift create mode 100644 Sources/RepoPromptCore/Regex/PCRE2Error.swift create mode 100644 Sources/RepoPromptCore/Regex/PCRE2JIT.swift create mode 100644 Sources/RepoPromptCore/Regex/PCRE2LiteralEscaping.swift create mode 100644 Sources/RepoPromptCore/Regex/PCRE2Match.swift create mode 100644 Sources/RepoPromptCore/Regex/PCRE2Options.swift create mode 100644 Sources/RepoPromptCore/Regex/PCRE2Regex.swift rename Sources/{RepoPrompt/Infrastructure => RepoPromptCore}/Regex/PCRE2RegexAdapter.swift (88%) rename Sources/{RepoPrompt/Infrastructure => RepoPromptCore}/Regex/RegexToolkit.swift (99%) rename Sources/{RepoPrompt/Infrastructure => RepoPromptCore}/Security/EphemeralSecureKeyValueStore.swift (63%) create mode 100644 Sources/RepoPromptCore/Security/SecureKeyService.swift rename Sources/{RepoPrompt/Infrastructure => RepoPromptCore}/SyntaxParsing/Queries/DartQueries.swift (98%) rename Sources/{RepoPrompt/Infrastructure => RepoPromptCore}/SyntaxParsing/Queries/GoQueries.swift (98%) rename Sources/{RepoPrompt/Infrastructure => RepoPromptCore}/SyntaxParsing/Queries/JavaQueries.swift (98%) rename Sources/{RepoPrompt/Infrastructure => RepoPromptCore}/SyntaxParsing/Queries/JavaScriptQueries.swift (99%) rename Sources/{RepoPrompt/Infrastructure => RepoPromptCore}/SyntaxParsing/Queries/PythonQueries.swift (98%) rename Sources/{RepoPrompt/Infrastructure => RepoPromptCore}/SyntaxParsing/Queries/RubyQueries.swift (96%) rename Sources/{RepoPrompt/Infrastructure => RepoPromptCore}/SyntaxParsing/Queries/RustQueries.swift (98%) rename Sources/{RepoPrompt/Infrastructure => RepoPromptCore}/SyntaxParsing/Queries/SwiftQueries.swift (99%) rename Sources/{RepoPrompt/Infrastructure => RepoPromptCore}/SyntaxParsing/Queries/cQueries.swift (98%) rename Sources/{RepoPrompt/Infrastructure => RepoPromptCore}/SyntaxParsing/Queries/cSharpQueries.swift (98%) rename Sources/{RepoPrompt/Infrastructure => RepoPromptCore}/SyntaxParsing/Queries/cppQueries.swift (98%) rename Sources/{RepoPrompt/Infrastructure => RepoPromptCore}/SyntaxParsing/Queries/phpQueries.swift (97%) rename Sources/{RepoPrompt/Infrastructure => RepoPromptCore}/SyntaxParsing/Queries/typeScript.swift (99%) rename Sources/{RepoPrompt/Infrastructure => RepoPromptCore}/SyntaxParsing/QueryResourceLoader.swift (89%) rename Sources/{RepoPrompt/Infrastructure => RepoPromptCore}/SyntaxParsing/SyntaxManager.swift (94%) rename Sources/{RepoPrompt/Infrastructure => RepoPromptCore}/Utilities/RelativePath.swift (85%) rename Sources/{RepoPrompt/Infrastructure => RepoPromptCore}/Utilities/StandardizedPath.swift (82%) create mode 100644 Sources/RepoPromptCore/Utilities/StringFNV.swift create mode 100644 Sources/RepoPromptCore/Utilities/StringLineEndingUtilities.swift create mode 100644 Sources/RepoPromptCore/Utilities/WorkspaceTaskSemaphore.swift rename Sources/{RepoPrompt/Infrastructure => RepoPromptCore}/WorkspaceContext/Indexing/DeferredReplayBufferActor.swift (83%) rename Sources/{RepoPrompt/Infrastructure => RepoPromptCore}/WorkspaceContext/Indexing/DeltaReplayPreparationActor.swift (76%) create mode 100644 Sources/RepoPromptCore/WorkspaceContext/Models/FilePathDisplay.swift create mode 100644 Sources/RepoPromptCore/WorkspaceContext/Models/WorkspaceFileContextModels.swift rename Sources/{RepoPrompt/Infrastructure => RepoPromptCore}/WorkspaceContext/PathLookup/PathCharPolicy.swift (93%) rename Sources/{RepoPrompt/Infrastructure => RepoPromptCore}/WorkspaceContext/PathLookup/PathMatchTypes.swift (83%) rename Sources/{RepoPrompt/Infrastructure => RepoPromptCore}/WorkspaceContext/PathLookup/PathMatchWorker.swift (94%) rename Sources/{RepoPrompt/Infrastructure => RepoPromptCore}/WorkspaceContext/PathLookup/PathMatcher.swift (99%) rename Sources/{RepoPrompt/Infrastructure => RepoPromptCore}/WorkspaceContext/PathLookup/PathMatchingInterfaces.swift (75%) rename Sources/{RepoPrompt/Infrastructure => RepoPromptCore}/WorkspaceContext/PathResolution/AgentSupportDirectoryCatalog.swift (65%) rename Sources/{RepoPrompt/Infrastructure => RepoPromptCore}/WorkspaceContext/PathResolution/CreatePathPreflight.swift (82%) rename Sources/{RepoPrompt/Infrastructure => RepoPromptCore}/WorkspaceContext/PathResolution/MovePathResolver.swift (94%) create mode 100644 Sources/RepoPromptCore/WorkspaceContext/PathResolution/WorkspaceExternalFileReading.swift rename Sources/{RepoPrompt/Infrastructure => RepoPromptCore}/WorkspaceContext/PathResolution/WorkspacePathPolicy.swift (88%) create mode 100644 Sources/RepoPromptCore/WorkspaceContext/Projection/CodeStructureProjection.swift create mode 100644 Sources/RepoPromptCore/WorkspaceContext/Projection/CodeStructureProjectionService.swift create mode 100644 Sources/RepoPromptCore/WorkspaceContext/Projection/TokenProjection.swift create mode 100644 Sources/RepoPromptCore/WorkspaceContext/Projection/TokenProjectionService.swift create mode 100644 Sources/RepoPromptCore/WorkspaceContext/Projection/WorkspaceContextProjection.swift create mode 100644 Sources/RepoPromptCore/WorkspaceContext/Projection/WorkspaceContextProjectionService.swift create mode 100644 Sources/RepoPromptCore/WorkspaceContext/Projection/WorkspaceSelectionProjection.swift create mode 100644 Sources/RepoPromptCore/WorkspaceContext/Projection/WorkspaceSelectionProjectionService.swift create mode 100644 Sources/RepoPromptCore/WorkspaceContext/Search/FileSearchContentSnapshot.swift rename Sources/{RepoPrompt/Infrastructure => RepoPromptCore}/WorkspaceContext/Search/PathSearchIndex.swift (68%) rename Sources/{RepoPrompt/Infrastructure => RepoPromptCore}/WorkspaceContext/Search/RepoSearchBatchScorer.swift (87%) rename Sources/{RepoPrompt/Infrastructure => RepoPromptCore}/WorkspaceContext/Search/RepoSearchQuery.swift (79%) rename Sources/{RepoPrompt/Features => RepoPromptCore/WorkspaceContext}/Search/SearchMatch.swift (91%) rename Sources/{RepoPrompt/Features => RepoPromptCore/WorkspaceContext}/Search/SearchPathFiltering.swift (84%) rename Sources/{RepoPrompt/Features => RepoPromptCore/WorkspaceContext}/Search/StoreBackedWorkspaceSearch.swift (82%) rename Sources/{RepoPrompt/Features => RepoPromptCore/WorkspaceContext}/Search/StoreBackedWorkspaceSearchLane.swift (85%) create mode 100644 Sources/RepoPromptCore/WorkspaceContext/Search/WorkspaceSearchReadinessSource.swift rename Sources/{RepoPrompt/Infrastructure => RepoPromptCore}/WorkspaceContext/Search/WorkspaceSearchService.swift (94%) create mode 100644 Sources/RepoPromptCore/WorkspaceContext/Selection/WorkspaceSelectionController.swift rename Sources/{RepoPrompt/Infrastructure => RepoPromptCore}/WorkspaceContext/Selection/WorkspaceSelectionMutationService.swift (89%) rename Sources/{RepoPrompt/Infrastructure => RepoPromptCore}/WorkspaceContext/Slices/LineRange.swift (58%) rename Sources/{RepoPrompt/Infrastructure => RepoPromptCore}/WorkspaceContext/Slices/PartitionStore.swift (82%) rename Sources/{RepoPrompt/Infrastructure => RepoPromptCore}/WorkspaceContext/Slices/SelectionSliceCoordinator.swift (94%) rename Sources/{RepoPrompt/Infrastructure => RepoPromptCore}/WorkspaceContext/Slices/SliceAssembly.swift (77%) rename Sources/{RepoPrompt/Infrastructure => RepoPromptCore}/WorkspaceContext/Slices/SliceRangeMath.swift (91%) rename Sources/{RepoPrompt/Infrastructure => RepoPromptCore}/WorkspaceContext/Slices/SliceRebaseEngine.swift (95%) rename Sources/{RepoPrompt/Infrastructure => RepoPromptCore}/WorkspaceContext/TokenAccounting/TokenCalculationService.swift (66%) create mode 100644 Sources/RepoPromptCore/WorkspaceContext/TokenAccounting/TokenCalculationSnapshot.swift rename Sources/{RepoPrompt/Infrastructure => RepoPromptCore}/WorkspaceContext/WorkspaceFileContextStore.swift (79%) create mode 100644 Sources/RepoPromptCore/WorkspaceContext/WorkspaceFileManagerError.swift rename Sources/{RepoPrompt/Infrastructure => RepoPromptCore}/WorkspaceContext/WorkspaceFileSystemIngressCoordinator.swift (89%) rename Sources/{RepoPrompt/Infrastructure => RepoPromptCore}/WorkspaceContext/WorkspaceReadableFileService.swift (60%) create mode 100644 Sources/RepoPromptCore/WorkspaceContext/WorkspaceRuntimeDebugDiagnostics.swift create mode 100644 Sources/RepoPromptCore/WorkspaceContext/WorkspaceRuntimeDependencies.swift create mode 100644 Sources/RepoPromptCore/WorkspaceContext/WorkspaceRuntimeDiagnostics.swift create mode 100644 Sources/RepoPromptCore/WorkspaceContext/WorkspaceRuntimePerf.swift rename Sources/{RepoPrompt/Infrastructure => RepoPromptCore}/WorkspaceContext/WorkspaceSearchDecodedContentCache.swift (100%) create mode 100644 Sources/RepoPromptCore/Workspaces/CodeMapUsage.swift create mode 100644 Sources/RepoPromptCore/Workspaces/CopyCustomizations.swift create mode 100644 Sources/RepoPromptCore/Workspaces/EmbeddedWorkspaceCodecV1.swift create mode 100644 Sources/RepoPromptCore/Workspaces/FileTreeOption.swift create mode 100644 Sources/RepoPromptCore/Workspaces/FilesTab.swift create mode 100644 Sources/RepoPromptCore/Workspaces/GitInclusion.swift create mode 100644 Sources/RepoPromptCore/Workspaces/WorkspaceAccessPolicy.swift create mode 100644 Sources/RepoPromptCore/Workspaces/WorkspaceDocumentCodec.swift create mode 100644 Sources/RepoPromptCore/Workspaces/WorkspaceLegacyMigrationContracts.swift create mode 100644 Sources/RepoPromptCore/Workspaces/WorkspaceModel.swift create mode 100644 Sources/RepoPromptCore/Workspaces/WorkspacePersistenceWriter.swift create mode 100644 Sources/RepoPromptCore/Workspaces/WorkspaceRepository.swift create mode 100644 Sources/RepoPromptCore/Workspaces/WorkspaceRepositoryContracts.swift rename Sources/{RepoPrompt/Features => RepoPromptCore}/Workspaces/WorkspaceRootActions.swift (91%) create mode 100644 Sources/RepoPromptCore/Workspaces/WorkspaceSaveMetadata.swift create mode 100644 Sources/RepoPromptCore/Workspaces/WorkspaceSessionController.swift create mode 100644 Sources/RepoPromptCoreMacOS/FileSystem/MacOSFSEventsWatcher.swift create mode 100644 Sources/RepoPromptCoreMacOS/FileSystem/MacOSFileContentSnapshotReader.swift create mode 100644 Sources/RepoPromptCoreMacOS/FileSystem/MacOSWorkspaceDirectoryListingBackend.swift create mode 100644 Sources/RepoPromptCoreMacOS/FileSystem/MacOSWorkspaceExternalFileReader.swift create mode 100644 Sources/RepoPromptCoreMacOS/MCP/PeerVerification/MacOSBundledHelperPeerVerifier.swift create mode 100644 Sources/RepoPromptCoreMacOS/MCP/PeerVerification/MacOSProcessAncestryInspector.swift rename Sources/{RepoPrompt/Infrastructure => RepoPromptCoreMacOS}/Process/FDWriteSupport.swift (87%) rename Sources/{RepoPrompt/Infrastructure/Process/ProcessLauncher.swift => RepoPromptCoreMacOS/Process/POSIXProcessLauncher.swift} (90%) rename Sources/{RepoPrompt/Infrastructure => RepoPromptCoreMacOS}/Security/KeychainService.swift (59%) rename Sources/{RepoPrompt/Infrastructure => RepoPromptCoreMacOS}/Security/RuntimeCodeSigningDetector.swift (56%) create mode 100644 Sources/RepoPromptHeadless/CLI/HeadlessCLI.swift create mode 100644 Sources/RepoPromptHeadless/CLI/HeadlessCommandError.swift create mode 100644 Sources/RepoPromptHeadless/Configuration/HeadlessConfiguration.swift create mode 100644 Sources/RepoPromptHeadless/Configuration/HeadlessConfigurationStore.swift create mode 100644 Sources/RepoPromptHeadless/Configuration/HeadlessFileLock.swift create mode 100644 Sources/RepoPromptHeadless/Configuration/HeadlessRootAccessPolicy.swift create mode 100644 Sources/RepoPromptHeadless/Configuration/HeadlessStateFileSecurity.swift create mode 100644 Sources/RepoPromptHeadless/Configuration/HeadlessStatePaths.swift create mode 100644 Sources/RepoPromptHeadless/HeadlessVersion.swift create mode 100644 Sources/RepoPromptHeadless/MCP/HeadlessJSONRPC.swift create mode 100644 Sources/RepoPromptHeadless/MCP/HeadlessMCPServer.swift create mode 100644 Sources/RepoPromptHeadless/MCP/HeadlessNewlineFrameDecoder.swift create mode 100644 Sources/RepoPromptHeadless/MCP/HeadlessStdioTransport.swift create mode 100644 Sources/RepoPromptHeadless/MCP/HeadlessStdoutWriter.swift create mode 100644 Sources/RepoPromptHeadless/MCP/HeadlessToolRegistry.swift create mode 100644 Sources/RepoPromptHeadless/MCP/HeadlessToolSupport.swift create mode 100644 Sources/RepoPromptHeadless/MCP/Tools/HeadlessFileTools.swift create mode 100644 Sources/RepoPromptHeadless/MCP/Tools/HeadlessPromptTools.swift create mode 100644 Sources/RepoPromptHeadless/MCP/Tools/HeadlessSelectionTools.swift create mode 100644 Sources/RepoPromptHeadless/MCP/Tools/HeadlessWorkspaceTools.swift create mode 100644 Sources/RepoPromptHeadless/Runtime/HeadlessCodeStructureService.swift create mode 100644 Sources/RepoPromptHeadless/Runtime/HeadlessExportWriter.swift create mode 100644 Sources/RepoPromptHeadless/Runtime/HeadlessFileCatalog.swift create mode 100644 Sources/RepoPromptHeadless/Runtime/HeadlessHost.swift create mode 100644 Sources/RepoPromptHeadless/Runtime/HeadlessPathResolver.swift create mode 100644 Sources/RepoPromptHeadless/Runtime/HeadlessReadFileSlicer.swift create mode 100644 Sources/RepoPromptHeadless/Runtime/HeadlessSearchService.swift create mode 100644 Sources/RepoPromptHeadless/Runtime/HeadlessSecureFileAccess.swift create mode 100644 Sources/RepoPromptHeadless/Runtime/HeadlessWorkspaceModels.swift create mode 100644 Sources/RepoPromptHeadless/Runtime/HeadlessWorkspaceStore.swift create mode 100644 Sources/RepoPromptHeadless/Security/HeadlessExternalExportFileSecurity.swift create mode 100644 Sources/RepoPromptHeadless/Security/HeadlessSecureStorage.swift create mode 100644 Sources/RepoPromptHeadless/Support/HeadlessOutput.swift create mode 100644 Sources/RepoPromptHeadless/main.swift delete mode 100644 Sources/RepoPromptMCP/Shared/MCPBootstrapMessages.swift create mode 100644 Sources/RepoPromptPOSIXSupport/Descriptors/POSIXDescriptorSupport.swift create mode 100644 Sources/RepoPromptPOSIXSupport/Descriptors/POSIXFileContentSnapshotSupport.swift rename Sources/{RepoPrompt/Infrastructure/MCP/AppShared => RepoPromptShared/MCP}/MCPBootstrapMessages.swift (96%) delete mode 100644 Sources/RepoPromptShared/MCP/POSIXDescriptorSupport.swift create mode 100644 Sources/RepoPromptSyntaxCBridge/RepoPromptSyntaxCBridge.c create mode 100644 Sources/RepoPromptSyntaxCBridge/include/RepoPromptSyntaxCBridge.h create mode 100644 Tests/RepoPromptCoreMacOSTests/FileSystem/MacOSFSEventsWatcherTests.swift create mode 100644 Tests/RepoPromptCoreMacOSTests/FileSystem/MacOSFileContentSnapshotReaderTests.swift create mode 100644 Tests/RepoPromptCoreMacOSTests/FileSystem/MacOSWorkspaceExternalFileReaderTests.swift create mode 100644 Tests/RepoPromptCoreMacOSTests/RepoPromptCoreMacOSTests.swift rename Tests/{RepoPromptTests => RepoPromptCoreTests}/CodeMap/CodeMapGoldenTests.swift (99%) rename Tests/{RepoPromptTests => RepoPromptCoreTests}/CodeMap/Fixtures/c/smoke.c (100%) rename Tests/{RepoPromptTests => RepoPromptCoreTests}/CodeMap/Fixtures/cpp/edge_methods.cpp (100%) rename Tests/{RepoPromptTests => RepoPromptCoreTests}/CodeMap/Fixtures/dart/smoke.dart (100%) rename Tests/{RepoPromptTests => RepoPromptCoreTests}/CodeMap/Fixtures/go/smoke.go (100%) rename Tests/{RepoPromptTests => RepoPromptCoreTests}/CodeMap/Fixtures/java/smoke.java (100%) rename Tests/{RepoPromptTests => RepoPromptCoreTests}/CodeMap/Fixtures/js/smoke.js (100%) rename Tests/{RepoPromptTests => RepoPromptCoreTests}/CodeMap/Fixtures/php/edge_namespaces.php (100%) rename Tests/{RepoPromptTests => RepoPromptCoreTests}/CodeMap/Fixtures/py/smoke.py (100%) rename Tests/{RepoPromptTests => RepoPromptCoreTests}/CodeMap/Fixtures/rb/smoke.rb (100%) rename Tests/{RepoPromptTests => RepoPromptCoreTests}/CodeMap/Fixtures/rs/smoke.rs (100%) rename Tests/{RepoPromptTests => RepoPromptCoreTests}/CodeMap/Fixtures/swift/smoke.swift (100%) rename Tests/{RepoPromptTests => RepoPromptCoreTests}/CodeMap/Fixtures/ts/smoke.ts (100%) rename Tests/{RepoPromptTests => RepoPromptCoreTests}/CodeMap/Fixtures/tsx/component.tsx (100%) rename Tests/{RepoPromptTests => RepoPromptCoreTests}/CodeMap/Goldens/c_smoke.codemap.txt (100%) rename Tests/{RepoPromptTests => RepoPromptCoreTests}/CodeMap/Goldens/cpp_edge_methods.codemap.txt (100%) rename Tests/{RepoPromptTests => RepoPromptCoreTests}/CodeMap/Goldens/dart_smoke.codemap.txt (100%) rename Tests/{RepoPromptTests => RepoPromptCoreTests}/CodeMap/Goldens/fixture-tree.txt (100%) rename Tests/{RepoPromptTests => RepoPromptCoreTests}/CodeMap/Goldens/go_smoke.codemap.txt (100%) rename Tests/{RepoPromptTests => RepoPromptCoreTests}/CodeMap/Goldens/java_smoke.codemap.txt (100%) rename Tests/{RepoPromptTests => RepoPromptCoreTests}/CodeMap/Goldens/js_smoke.codemap.txt (100%) rename Tests/{RepoPromptTests => RepoPromptCoreTests}/CodeMap/Goldens/php_edge_namespaces.codemap.txt (100%) rename Tests/{RepoPromptTests => RepoPromptCoreTests}/CodeMap/Goldens/py_smoke.codemap.txt (100%) rename Tests/{RepoPromptTests => RepoPromptCoreTests}/CodeMap/Goldens/rb_smoke.codemap.txt (100%) rename Tests/{RepoPromptTests => RepoPromptCoreTests}/CodeMap/Goldens/rs_smoke.codemap.txt (100%) rename Tests/{RepoPromptTests => RepoPromptCoreTests}/CodeMap/Goldens/swift_smoke.codemap.txt (100%) rename Tests/{RepoPromptTests => RepoPromptCoreTests}/CodeMap/Goldens/ts_smoke.codemap.txt (100%) rename Tests/{RepoPromptTests => RepoPromptCoreTests}/CodeMap/Goldens/tsx_component.codemap.txt (100%) rename Tests/{RepoPromptTests => RepoPromptCoreTests}/CodeMap/Helpers/CodeMapFixtureRunner.swift (98%) rename Tests/{RepoPromptTests/Services => RepoPromptCoreTests}/FileSystem/FileSystemAcceptedIngressBarrierTests.swift (71%) rename Tests/{RepoPromptTests/Services => RepoPromptCoreTests}/FileSystem/FileSystemServiceEventPathMappingTests.swift (98%) rename Tests/{RepoPromptTests/Services => RepoPromptCoreTests}/FileSystem/FileSystemServiceIgnoreRecoveryTests.swift (98%) rename Tests/{RepoPromptTests/Services => RepoPromptCoreTests}/FileSystem/FileSystemServiceRecoveryTests.swift (97%) rename Tests/{RepoPromptTests/Services => RepoPromptCoreTests}/FileSystem/IgnoreRulesRecoveryTests.swift (96%) create mode 100644 Tests/RepoPromptCoreTests/Phase1CoreBoundaryContractTests.swift create mode 100644 Tests/RepoPromptCoreTests/Prompt/PromptAssemblyBuilderTests.swift create mode 100644 Tests/RepoPromptCoreTests/Prompt/PromptContextAccountingServiceTests.swift create mode 100644 Tests/RepoPromptCoreTests/Prompt/PromptRenderingServiceTests.swift create mode 100644 Tests/RepoPromptCoreTests/Support/FileSystemTestSupport.swift create mode 100644 Tests/RepoPromptCoreTests/Support/RepoRoot.swift create mode 100644 Tests/RepoPromptCoreTests/Support/TestWorkspaceRuntime.swift create mode 100644 Tests/RepoPromptCoreTests/WorkspaceContext/AgentSupportDirectoryCatalogTests.swift create mode 100644 Tests/RepoPromptCoreTests/WorkspaceContext/AgentSupportDirectoryContainmentSecurityTests.swift rename Tests/{RepoPromptTests => RepoPromptCoreTests}/WorkspaceContext/PathMatching/PathMatchingRecoveryTests.swift (99%) create mode 100644 Tests/RepoPromptCoreTests/WorkspaceContext/Projection/CodeStructureProjectionTests.swift create mode 100644 Tests/RepoPromptCoreTests/WorkspaceContext/Projection/TokenProjectionTests.swift create mode 100644 Tests/RepoPromptCoreTests/WorkspaceContext/Projection/WorkspaceContextProjectionServiceTests.swift create mode 100644 Tests/RepoPromptCoreTests/WorkspaceContext/Projection/WorkspaceSelectionProjectionServiceTests.swift rename Tests/{RepoPromptTests => RepoPromptCoreTests}/WorkspaceContext/Search/PathSearchIndexRecoveryTests.swift (98%) rename Tests/{RepoPromptTests => RepoPromptCoreTests}/WorkspaceContext/Search/RepoSearchQueryRecoveryTests.swift (97%) rename Tests/{RepoPromptTests => RepoPromptCoreTests}/WorkspaceContext/Search/SearchPathFilteringTests.swift (99%) rename Tests/{RepoPromptTests => RepoPromptCoreTests}/WorkspaceContext/Search/StoreBackedWorkspaceSearchConcurrencyMatrixTests.swift (99%) rename Tests/{RepoPromptTests => RepoPromptCoreTests}/WorkspaceContext/Search/StoreBackedWorkspaceSearchLaneTests.swift (99%) rename Tests/{RepoPromptTests => RepoPromptCoreTests}/WorkspaceContext/Search/StoreBackedWorkspaceSearchTests.swift (89%) rename Tests/{RepoPromptTests => RepoPromptCoreTests}/WorkspaceContext/Search/WorkspaceSearchServiceTests.swift (99%) create mode 100644 Tests/RepoPromptCoreTests/WorkspaceContext/Selection/WorkspaceSelectionControllerTests.swift rename Tests/{RepoPromptTests => RepoPromptCoreTests}/WorkspaceContext/Slices/SelectionSlicePersistenceAndRebaseTests.swift (99%) create mode 100644 Tests/RepoPromptCoreTests/WorkspaceContext/TokenAccounting/TokenCalculationServiceTests.swift create mode 100644 Tests/RepoPromptCoreTests/Workspaces/EmbeddedWorkspaceCodecV1Tests.swift create mode 100644 Tests/RepoPromptCoreTests/Workspaces/WorkspacePersistenceWriterTests.swift create mode 100644 Tests/RepoPromptCoreTests/Workspaces/WorkspaceRepositoryTests.swift create mode 100644 Tests/RepoPromptCoreTests/Workspaces/WorkspaceSelectionPersistenceTests.swift create mode 100644 Tests/RepoPromptCoreTests/Workspaces/WorkspaceSessionControllerTests.swift create mode 100644 Tests/RepoPromptCoreTests/Workspaces/WorkspaceTestSupport.swift create mode 100644 Tests/RepoPromptHeadlessTests/HeadlessExportWriterTests.swift create mode 100644 Tests/RepoPromptHeadlessTests/HeadlessMCPServerLifecycleTests.swift create mode 100644 Tests/RepoPromptHeadlessTests/HeadlessNewlineFrameDecoderTests.swift create mode 100644 Tests/RepoPromptHeadlessTests/HeadlessReadFileSlicerTests.swift create mode 100644 Tests/RepoPromptHeadlessTests/HeadlessSearchServiceTests.swift create mode 100644 Tests/RepoPromptHeadlessTests/HeadlessSecureFileAccessTests.swift create mode 100644 Tests/RepoPromptHeadlessTests/HeadlessSelectionToolsTests.swift create mode 100644 Tests/RepoPromptHeadlessTests/HeadlessStateFileSecurityTests.swift create mode 100644 Tests/RepoPromptHeadlessTests/HeadlessStateTransactionTests.swift create mode 100644 Tests/RepoPromptHeadlessTests/HeadlessStdioTransportTests.swift create mode 100644 Tests/RepoPromptHeadlessTests/HeadlessWorkspaceStoreTests.swift create mode 100644 Tests/RepoPromptHeadlessTests/Helpers/RepoRoot.swift create mode 100644 Tests/RepoPromptHeadlessTests/SharedRuntimePhase0HeadlessCharacterizationTests.swift create mode 100644 Tests/RepoPromptPOSIXSupportTests/POSIXDescriptorSupportTests.swift create mode 100644 Tests/RepoPromptTests/AgentMode/ContextBuilderRenderingParityCharacterizationTests.swift create mode 100644 Tests/RepoPromptTests/App/RepoPromptCoreHostLifecycleTests.swift create mode 100644 Tests/RepoPromptTests/App/WorkspaceSessionObservationBridgeTests.swift create mode 100644 Tests/RepoPromptTests/App/WorkspaceSlice1CompositionTests.swift create mode 100644 Tests/RepoPromptTests/Helpers/TestWorkspaceRuntime.swift create mode 100644 Tests/RepoPromptTests/MCP/Control/MCPAppProxyAcceptedTransportLeaseTests.swift create mode 100644 Tests/RepoPromptTests/MCP/Control/MCPBootstrapContractCharacterizationTests.swift create mode 100644 Tests/RepoPromptTests/MCP/MCPRenderingParityCharacterizationTests.swift create mode 100644 Tests/RepoPromptTests/MCP/MCPRuntimeRegistryTests.swift create mode 100644 Tests/RepoPromptTests/MCP/MCPServiceParticipationTests.swift create mode 100644 Tests/RepoPromptTests/MCP/SharedRuntimePhase0CharacterizationTests.swift create mode 100644 Tests/RepoPromptTests/Prompt/AIMessagePromptAssemblyParityTests.swift create mode 100644 Tests/RepoPromptTests/Prompt/AIProviderInputProjectionFoundationTests.swift delete mode 100644 Tests/RepoPromptTests/Prompt/PromptContextAccountingServiceTests.swift create mode 100644 Tests/RepoPromptTests/Prompt/PromptRenderingParityCharacterizationTests.swift create mode 100644 Tests/RepoPromptTests/Prompt/PromptTokenEstimateParityTests.swift create mode 100644 Tests/RepoPromptTests/Prompt/TokenCountingViewModelProjectionTests.swift create mode 100644 Tests/RepoPromptTests/Prompt/WorkspacePromptProjectionAdapterTests.swift create mode 100644 Tests/SharedRuntimeConvergenceFixtures/Phase0/App/WorkspaceV1/Workspace-Phase 0 App V1-AAAAAAAA-AAAA-AAAA-AAAA-AAAAAAAAAAAA/workspace.json create mode 100644 Tests/SharedRuntimeConvergenceFixtures/Phase0/App/WorkspaceV1/workspacesIndex.json create mode 100644 Tests/SharedRuntimeConvergenceFixtures/Phase0/App/app-characterization.json create mode 100644 Tests/SharedRuntimeConvergenceFixtures/Phase0/Headless/ProfileV1/Workspaces/22222222-2222-2222-2222-222222222222.json create mode 100644 Tests/SharedRuntimeConvergenceFixtures/Phase0/Headless/ProfileV1/config.json create mode 100644 Tests/SharedRuntimeConvergenceFixtures/Phase0/Headless/headless-characterization.json create mode 100644 Tests/SharedRuntimeConvergenceFixtures/Phase0/differential-ledger.json create mode 100644 Tests/SharedRuntimeConvergenceFixtures/Phase0/manifest.json create mode 100644 docs/architecture/headless-core.md create mode 100644 docs/characterization/shared-runtime-phase0-2026-06-05.md create mode 100644 docs/characterization/shared-runtime-phase1-2026-06-05.md create mode 100644 docs/characterization/shared-runtime-phase2-slice1-2026-06-05.md create mode 100644 docs/characterization/shared-runtime-phase2-slice2-2026-06-05.md create mode 100644 docs/characterization/shared-runtime-phase2-slice3-rendering-2026-06-06.md delete mode 100644 minimalXcodeFreeSetup.md diff --git a/.agents/skills/rpce-contribution-check/references/validation-matrix.md b/.agents/skills/rpce-contribution-check/references/validation-matrix.md index 22182b599..4a969d5a6 100644 --- a/.agents/skills/rpce-contribution-check/references/validation-matrix.md +++ b/.agents/skills/rpce-contribution-check/references/validation-matrix.md @@ -11,6 +11,8 @@ Use this after the scripted preflight when the touched boundary needs focused ev | Provider package source or tests | `make dev-provider-test` | | `Sources/RepoPrompt/**` | `make dev-swift-build PRODUCT=RepoPrompt` | | `Sources/RepoPromptMCP/**` or `Sources/RepoPromptShared/**` | `make dev-swift-build PRODUCT=repoprompt-mcp` | +| `Sources/RepoPromptCore/**`, `Sources/RepoPromptCoreMacOS/**`, or `Sources/RepoPromptPOSIXSupport/**` | Build the affected target(s) and run the smallest focused Core test target(s) | +| `Sources/RepoPromptHeadless/**` or headless packaging/install/smoke scripts | `make dev-swift-build PRODUCT=repoprompt-headless` plus the non-visible headless smoke lane when available | | Packaging, MCP CLI/server, Agent Mode, or running-app-sensitive paths | Record non-disruptive `make dev-smoke`; request approval before `make dev-smoke-launch`, `make dev-run`, or relaunching the visible app | | History rewrite, branch deletion, fork deletion, force-push, credential rotation, other GitHub-visible destructive mutation, visible app launch/relaunch, or visible app stop | Obtain explicit user approval immediately before the destructive command; redact secret values from output | diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e2af049ae..cb7f6078c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -42,6 +42,12 @@ jobs: - name: Build MCP product run: swift build --product repoprompt-mcp + - name: Build headless MCP product + run: swift build --product repoprompt-headless + + - name: Run headless MCP smoke + run: ./Scripts/smoke_headless_mcp.sh --skip-package + - name: Run app tests run: swift test diff --git a/AGENTS.md b/AGENTS.md index a1e941b6b..b104f9ba1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -59,7 +59,7 @@ SwiftPM’s architecture-specific build output is usually under: ## Debug CLI / MCP -Use the CE-specific debug CLI when testing this app. The production `rp-cli` / `rp-cli-debug` connection is only an analogue and may talk to the non-CE app. +Use the CE-specific debug CLI when testing this app. The production `rp-cli` / `rp-cli-debug` connection is only an analogue and may talk to the non-CE app. `rpce-cli[-debug]` is the **app proxy** command family: it talks to the running app through the bundled `repoprompt-mcp` helper. Use `rpce-headless[-debug]` for the standalone direct-stdio host instead. Install or inspect the debug CLI: @@ -111,11 +111,56 @@ rpce-cli-debug -w 1 -c app_settings -j '{"op":"set","key":"agent_mode.perf_diagn These settings are intentionally DEBUG-only. If a key is unavailable, confirm `rpce-cli-debug --version` is resolving to the current CE debug build before falling back to lower-level defaults. +## Headless CLI / MCP + +`rpce-headless[-debug]` is the standalone direct-stdio MCP host. It does **not** launch `RepoPrompt.app`, does not connect to the app-proxy socket, and uses separate headless state and secure storage: + +```text +~/Library/Application Support/RepoPrompt CE/Headless/ +``` + +Package, install, inspect, and smoke the debug standalone host with: + +```bash +make package-headless # stages HeadlessTools/Debug/repoprompt-headless +make headless-debug-status +make install-debug-headless # installs /usr/local/bin/rpce-headless-debug when writable/sudo is available +make headless-smoke # direct stdio initialize/tools/list/read/search/permission/shutdown smoke + +# coordinated equivalents: +make dev-package-headless +make dev-headless-debug-status +make dev-install-debug-headless +make dev-headless-smoke +``` + +Managed links are intentionally separate from the app proxy: + +```text +/usr/local/bin/rpce-headless-debug + -> ~/Library/Application Support/RepoPrompt CE/repoprompt_headless_debug + -> ~/Library/Application Support/RepoPrompt CE/HeadlessTools/Debug/repoprompt-headless + +/usr/local/bin/rpce-headless + -> ~/Library/Application Support/RepoPrompt CE/repoprompt_headless + -> ~/Library/Application Support/RepoPrompt CE/HeadlessTools/Release/repoprompt-headless +``` + +Standalone usage starts fail-closed until roots are configured: + +```bash +rpce-headless-debug --state-dir /tmp/rpce-headless-demo config roots add /path/to/repo --name Repo +rpce-headless-debug --state-dir /tmp/rpce-headless-demo doctor +rpce-headless-debug --state-dir /tmp/rpce-headless-demo serve +``` + +Do not use `rpce-cli[-debug]` as evidence for standalone behavior; it validates the app-bundled proxy. Do not use `rpce-headless[-debug]` as evidence for live app/window/Agent Mode behavior; the first standalone profile is read-oriented and omits app-only, write, VCS, oracle, Context Builder, Agent Mode, and settings tools. + ## Developer daemon / coordinated validation Prefer the developer daemon as the default way to build, run, and validate. Two properties are the whole reason it exists — and the reason to reach for it instead of a bare `swift build` / `swift test`: -- **Lane-serialized job queue** — every job claims named lanes (`build`, `debugArtifact`, `liveApp`, `release`, `style`); the daemon runs jobs that share a lane one at a time while letting unrelated lanes proceed concurrently. That serial queue is what stops multiple agents from building, launching, or running style tooling over each other and corrupting `.build` or the live app. +- **Lane-serialized job queue** — every job claims named lanes (`build`, `debugArtifact`, `headlessArtifact`, `headlessSmoke`, `liveApp`, `release`, `style`); the daemon runs jobs that share a lane one at a time while letting unrelated lanes proceed concurrently. That serial queue is what stops multiple agents from building, packaging headless tools, launching, or running style tooling over each other and corrupting `.build`, managed artifacts, or the live app. - **Tickets + async jobs** — every job gets a ticket and can run detached (`--async`). Fire a build, keep working, and query or wait on it later (`job status` / `job wait`) instead of blocking on a long compile. Jobs survive reconnects and are reusable by `--request-key`. `conductor` is repo-internal developer tooling for this checkout; the daemon auto-starts on first use. @@ -125,13 +170,14 @@ Happy path — daemon aliases: ```bash make dev-status make dev-build -make dev-swift-build PRODUCT=repoprompt-mcp # focused product build (PRODUCT=RepoPrompt|repoprompt-mcp|all, default all) +make dev-swift-build PRODUCT=repoprompt-mcp # focused product build (PRODUCT=RepoPrompt|repoprompt-mcp|repoprompt-headless|all, default all) make dev-run make dev-test # full coordinated test suite make dev-test FILTER=WorkspaceFileContextStoreTests # focused coordinated test run make dev-provider-test # RepoPromptAgentProviders package tests (FILTER= also supported) make dev-smoke # non-disruptive: requires an already-running CE debug app and installed debug CLI make dev-smoke-launch # builds/launches the debug app, then runs the smoke flow +make dev-headless-smoke # packages repoprompt-headless and runs direct-stdio smoke without app launch make dev-format-check # non-mutating coordinated SwiftFormat check make dev-lint # non-mutating coordinated format-check + SwiftLint strict make dev-format # mutates first-party Swift files; run only when intended @@ -163,6 +209,7 @@ Behavior notes: - `make dev-smoke` is the non-disruptive live-only check: it assumes the CE debug app is already running and the debug CLI is installed/resolvable. - `make dev-smoke-launch` (or `./conductor smoke --launch`) builds/packages and launches the debug app before smoke validation. - `./conductor smoke --agent-run` is opt-in, for when provider credentials and model access are available. +- `make dev-headless-smoke` / `./conductor headless-smoke` packages `repoprompt-headless` and runs only the standalone direct-stdio smoke; it does not claim `liveApp` and must not be used as app-proxy evidence. - Style checks (`make dev-format-check`, `make dev-lint`) are non-mutating and do not auto-install tools; `make dev-install-format-tools` is the explicit install path. - Do not run `make dev-format` unless formatting mutation is intended. If a format job is canceled after starting, inspect `git diff` and rerun format or restore files as needed. @@ -244,13 +291,15 @@ make dev-test FILTER=CodexIntegrationConfigurationTests make dev-test FILTER=WorkspaceFileContextStoreTests make dev-swift-build PRODUCT=RepoPrompt make dev-swift-build PRODUCT=repoprompt-mcp +make dev-swift-build PRODUCT=repoprompt-headless +make dev-headless-smoke make dev-provider-test make guardrails make doctor make dev-build ``` -Run the smallest relevant daemon build/test command above to validate a change. If the change affects packaging, the MCP server, the MCP CLI, Agent Mode, or any feature that depends on the running app, follow it with the live CE MCP smoke flow above. +Run the smallest relevant daemon build/test command above to validate a change. If the change affects standalone headless packaging or safe read-oriented tools, run `make dev-headless-smoke`. If the change affects app packaging, the app-proxy MCP server, the app-proxy MCP CLI, Agent Mode, or any feature that depends on the running app, follow it with the live CE MCP smoke flow above. Direct `swift test --filter ` and `swift build --product ` still work and produce the same result, but they are uncoordinated — use them only when the daemon is unavailable (for example, no `python3`), and avoid them when other agents may be building. diff --git a/Makefile b/Makefile index eec2dcf4f..584c1e772 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,7 @@ -.PHONY: doctor setup install-format-tools format-tools-status format format-check lint install-debug-cli uninstall-debug-cli debug-cli-status resolve build run test guardrails conductor-selftest release-selftest release-sync-cli-version release-preflight release-artifact install-local-production dev-status dev-build dev-swift-build dev-run dev-test dev-provider-test dev-smoke dev-smoke-launch dev-format dev-format-check dev-lint dev-format-tools-status dev-check-format-tools dev-install-format-tools dev-release-preflight dev-release-artifact dev-install-local-production dev-stop-app dev-daemon-stop clean +.PHONY: doctor setup install-format-tools format-tools-status format format-check lint install-debug-cli uninstall-debug-cli debug-cli-status package-headless install-debug-headless uninstall-debug-headless headless-debug-status headless-smoke resolve build run test guardrails conductor-selftest release-selftest release-sync-cli-version release-preflight release-artifact install-local-production dev-status dev-guardrails dev-build dev-swift-build dev-run dev-test dev-provider-test dev-smoke dev-smoke-launch dev-package-headless dev-install-debug-headless dev-headless-debug-status dev-headless-smoke dev-format dev-format-check dev-lint dev-format-tools-status dev-check-format-tools dev-install-format-tools dev-release-preflight dev-release-artifact dev-install-local-production dev-stop-app dev-daemon-stop clean PRODUCT ?= all +HEADLESS_CONFIGURATION ?= debug doctor: ./Scripts/doctor.sh @@ -34,6 +35,21 @@ uninstall-debug-cli: debug-cli-status: ./Scripts/install_debug_cli.sh status +package-headless: + ./Scripts/package_headless.sh $(HEADLESS_CONFIGURATION) + +install-debug-headless: + ./Scripts/install_headless_cli.sh install --configuration debug --build + +uninstall-debug-headless: + ./Scripts/install_headless_cli.sh uninstall --configuration debug + +headless-debug-status: + ./Scripts/install_headless_cli.sh status --configuration debug + +headless-smoke: + ./Scripts/smoke_headless_mcp.sh --configuration $(HEADLESS_CONFIGURATION) + resolve: swift package resolve @@ -48,6 +64,10 @@ test: guardrails: ./Scripts/source_layout_guardrails.sh + bash ./Scripts/core_boundary_guardrails.sh + python3 ./Scripts/test_core_boundary_guardrails.py + python3 ./Scripts/test_shared_runtime_headless_baseline.py + python3 ./Scripts/test_shared_runtime_phase2_boundaries.py ./Scripts/contributor_allowlist_guardrails.sh ./Scripts/swiftpm_notice_guardrails.sh @@ -76,6 +96,9 @@ install-local-production: dev-status: ./conductor status +dev-guardrails: + ./conductor guardrails + dev-build: ./conductor build @@ -97,6 +120,18 @@ dev-smoke: dev-smoke-launch: ./conductor smoke --launch +dev-package-headless: + ./conductor package-headless $(HEADLESS_CONFIGURATION) + +dev-install-debug-headless: + ./conductor install-headless-debug + +dev-headless-debug-status: + ./conductor headless-debug-status + +dev-headless-smoke: + ./conductor headless-smoke --configuration $(HEADLESS_CONFIGURATION) + dev-format: ./conductor format diff --git a/Package.swift b/Package.swift index 5850c84d2..d8ca15a30 100644 --- a/Package.swift +++ b/Package.swift @@ -6,7 +6,8 @@ let package = Package( platforms: [.macOS(.v14)], products: [ .executable(name: "RepoPrompt", targets: ["RepoPrompt"]), - .executable(name: "repoprompt-mcp", targets: ["RepoPromptMCP"]) + .executable(name: "repoprompt-mcp", targets: ["RepoPromptMCP"]), + .executable(name: "repoprompt-headless", targets: ["RepoPromptHeadless"]) ], dependencies: [ .package(url: "https://github.com/apple/swift-log.git", exact: "1.6.3"), @@ -43,8 +44,8 @@ let package = Package( .executableTarget( name: "RepoPrompt", dependencies: [ - "RepoPromptShared", - "RepoPromptC", "CSwiftPCRE2", "TreeSitterScannerSupport", + "RepoPromptShared", "RepoPromptPOSIXSupport", "RepoPromptCore", "RepoPromptCoreMacOS", "RepoPromptSyntaxCBridge", + "RepoPromptC", "CSwiftPCRE2", "Sparkle", .product(name: "Logging", package: "swift-log"), .product(name: "KeyboardShortcuts", package: "KeyboardShortcuts"), @@ -53,19 +54,6 @@ let package = Package( .product(name: "SwiftyJSON", package: "SwiftyJSON"), .product(name: "MCP", package: "swift-sdk"), .product(name: "SwiftTreeSitter", package: "SwiftTreeSitter"), - .product(name: "TreeSitterC", package: "tree-sitter-c"), - .product(name: "TreeSitterDart", package: "tree-sitter-dart"), - .product(name: "TreeSitterGo", package: "tree-sitter-go"), - .product(name: "TreeSitterJava", package: "tree-sitter-java"), - .product(name: "TreeSitterJavaScript", package: "tree-sitter-javascript"), - .product(name: "TreeSitterPython", package: "tree-sitter-python"), - .product(name: "TreeSitterRust", package: "tree-sitter-rust"), - .product(name: "TreeSitterTypeScript", package: "tree-sitter-typescript"), - .product(name: "TreeSitterRuby", package: "tree-sitter-ruby"), - .product(name: "TreeSitterSwift", package: "tree-sitter-swift"), - .product(name: "TreeSitterCSharp", package: "tree-sitter-c-sharp"), - .product(name: "TreeSitterCPP", package: "tree-sitter-cpp"), - .product(name: "TreeSitterPHP", package: "tree-sitter-php"), .product(name: "SwiftAnthropic", package: "SwiftAnthropic"), .product(name: "SwiftOpenAI", package: "SwiftOpenAI"), .product(name: "Neon", package: "Neon"), @@ -78,34 +66,104 @@ let package = Package( path: "Sources/RepoPrompt", swiftSettings: [ .define("DEBUG", .when(configuration: .debug)), - .enableUpcomingFeature("BareSlashRegexLiterals"), - .unsafeFlags([ - "-import-objc-header", "Sources/RepoPrompt/Support/RepoPrompt-Bridging-Header.h", - "-disable-bridging-pch" - ]) + .enableUpcomingFeature("BareSlashRegexLiterals") ] ), .executableTarget( name: "RepoPromptMCP", - dependencies: ["RepoPromptShared", .product(name: "Logging", package: "swift-log"), .product(name: "MCP", package: "swift-sdk"), .product(name: "ServiceLifecycle", package: "swift-service-lifecycle"), .product(name: "SystemPackage", package: "swift-system")], + dependencies: ["RepoPromptShared", "RepoPromptPOSIXSupport", .product(name: "Logging", package: "swift-log"), .product(name: "MCP", package: "swift-sdk"), .product(name: "ServiceLifecycle", package: "swift-service-lifecycle"), .product(name: "SystemPackage", package: "swift-system")], path: "Sources/RepoPromptMCP", swiftSettings: [.define("DEBUG", .when(configuration: .debug))] ), + .executableTarget( + name: "RepoPromptHeadless", + dependencies: [ + "RepoPromptShared", + "RepoPromptCore", + "RepoPromptCoreMacOS", + .product(name: "Logging", package: "swift-log") + ], + path: "Sources/RepoPromptHeadless", + swiftSettings: [.define("DEBUG", .when(configuration: .debug))] + ), .target(name: "RepoPromptShared", path: "Sources/RepoPromptShared"), + .target( + name: "RepoPromptPOSIXSupport", + dependencies: ["RepoPromptC"], + path: "Sources/RepoPromptPOSIXSupport" + ), + .target( + name: "RepoPromptCore", + dependencies: [ + "RepoPromptC", + "CSwiftPCRE2", + "RepoPromptSyntaxCBridge", + .product(name: "SwiftTreeSitter", package: "SwiftTreeSitter"), + .product(name: "UniversalCharsetDetection", package: "UniversalCharsetDetection"), + .product(name: "Cuchardet", package: "UniversalCharsetDetection") + ], + path: "Sources/RepoPromptCore" + ), + .target( + name: "RepoPromptCoreMacOS", + dependencies: ["RepoPromptCore", "RepoPromptPOSIXSupport"], + path: "Sources/RepoPromptCoreMacOS" + ), .target(name: "CSwiftPCRE2", path: "Sources/CSwiftPCRE2", exclude: ["deps/sljit/sljit_src/sljitNativeARM_64.c", "deps/sljit/sljit_src/sljitSerialize.c", "deps/sljit/sljit_src/sljitUtils.c", "deps/sljit/sljit_src/sljitNativeX86_common.c", "deps/sljit/sljit_src/sljitNativeX86_64.c", "deps/sljit/sljit_src/sljitNativeX86_32.c", "deps/sljit/sljit_src/allocator_src/sljitWXExecAllocatorPosix.c", "deps/sljit/sljit_src/allocator_src/sljitProtExecAllocatorPosix.c", "deps/sljit/sljit_src/allocator_src/sljitExecAllocatorPosix.c", "deps/sljit/sljit_src/allocator_src/sljitExecAllocatorCore.c", "deps/sljit/sljit_src/allocator_src/sljitExecAllocatorApple.c"], publicHeadersPath: "include", cSettings: [.headerSearchPath("include"), .headerSearchPath("src"), .define("PCRE2_CODE_UNIT_WIDTH", to: "8"), .define("HAVE_CONFIG_H")]), .target(name: "RepoPromptC", path: "Sources/RepoPromptC", publicHeadersPath: "include", cSettings: [.headerSearchPath("include")]), // Exact-snapshot scanner ABI fallback for upstream JavaScript/Python products. // See docs/architecture/source-layout.md and ThirdPartyLicenses/tree-sitter/README.md. .target(name: "TreeSitterScannerSupport", path: "Sources/TreeSitterScannerSupport", sources: ["src/javascript/scanner.c", "src/python/scanner.c"], publicHeadersPath: "include"), + .target( + name: "RepoPromptSyntaxCBridge", + dependencies: [ + "TreeSitterScannerSupport", + .product(name: "TreeSitterC", package: "tree-sitter-c"), + .product(name: "TreeSitterDart", package: "tree-sitter-dart"), + .product(name: "TreeSitterGo", package: "tree-sitter-go"), + .product(name: "TreeSitterJava", package: "tree-sitter-java"), + .product(name: "TreeSitterJavaScript", package: "tree-sitter-javascript"), + .product(name: "TreeSitterPython", package: "tree-sitter-python"), + .product(name: "TreeSitterRust", package: "tree-sitter-rust"), + .product(name: "TreeSitterTypeScript", package: "tree-sitter-typescript"), + .product(name: "TreeSitterRuby", package: "tree-sitter-ruby"), + .product(name: "TreeSitterSwift", package: "tree-sitter-swift"), + .product(name: "TreeSitterCSharp", package: "tree-sitter-c-sharp"), + .product(name: "TreeSitterCPP", package: "tree-sitter-cpp"), + .product(name: "TreeSitterPHP", package: "tree-sitter-php") + ], + path: "Sources/RepoPromptSyntaxCBridge", + publicHeadersPath: "include" + ), .binaryTarget(name: "Sparkle", path: "Vendor/Sparkle/Sparkle.xcframework"), .testTarget( name: "RepoPromptTests", - dependencies: ["RepoPrompt", "RepoPromptMCP", "RepoPromptShared"], - path: "Tests/RepoPromptTests", + dependencies: ["RepoPrompt", "RepoPromptMCP", "RepoPromptShared", "RepoPromptPOSIXSupport", "RepoPromptCore", "RepoPromptCoreMacOS"], + path: "Tests/RepoPromptTests" + ), + .testTarget( + name: "RepoPromptHeadlessTests", + dependencies: ["RepoPromptHeadless"], + path: "Tests/RepoPromptHeadlessTests" + ), + .testTarget( + name: "RepoPromptCoreTests", + dependencies: ["RepoPromptCore"], + path: "Tests/RepoPromptCoreTests", resources: [ .copy("CodeMap/Fixtures"), .copy("CodeMap/Goldens") ] + ), + .testTarget( + name: "RepoPromptCoreMacOSTests", + dependencies: ["RepoPromptCoreMacOS", "RepoPromptCore"], + path: "Tests/RepoPromptCoreMacOSTests" + ), + .testTarget( + name: "RepoPromptPOSIXSupportTests", + dependencies: ["RepoPromptPOSIXSupport"], + path: "Tests/RepoPromptPOSIXSupportTests" ) ], swiftLanguageModes: [.v5] diff --git a/README.md b/README.md index f45671273..0f2ee2e0f 100644 --- a/README.md +++ b/README.md @@ -93,6 +93,23 @@ another Mac or redistributed. - macOS 26 or later - Xcode 26, or matching Command Line Tools with the macOS 26 SDK +### MCP command surfaces + +RepoPrompt CE has two MCP command families: + +- `rpce-cli` / `rpce-cli-debug` are app-proxy commands. They use the `repoprompt-mcp` helper bundled inside `RepoPrompt.app` and require the app/bootstrap socket for live windows, app approvals, Agent Mode, and app workspace state. +- `rpce-headless` / `rpce-headless-debug` are standalone direct-stdio commands. They use the independently packaged `repoprompt-headless` host, do not launch the app, and keep separate fail-closed state under `~/Library/Application Support/RepoPrompt CE/Headless/`. + +Standalone headless setup starts by adding explicit allowed roots: + +```bash +rpce-headless config roots add /path/to/repo --name Repo +rpce-headless doctor +rpce-headless serve +``` + +See [`docs/architecture/headless-core.md`](docs/architecture/headless-core.md) for the current safe read-oriented tool profile and packaging boundaries. + ## Features - **Context engineering**: Build dense, reviewable prompts with the files and @@ -131,6 +148,8 @@ third-party notices in source ownership and placement rules - [`docs/architecture/provider-plugins.md`](docs/architecture/provider-plugins.md): Agent Mode provider architecture +- [`docs/architecture/headless-core.md`](docs/architecture/headless-core.md): + app-proxy versus standalone headless MCP architecture and boundaries - [`docs/releasing.md`](docs/releasing.md): release-candidate and publishing workflows - [`docs/open-source-readiness.md`](docs/open-source-readiness.md): public diff --git a/Scripts/Fixtures/shared-runtime-headless-reviewed.sha256 b/Scripts/Fixtures/shared-runtime-headless-reviewed.sha256 new file mode 100644 index 000000000..05dd3ed8d --- /dev/null +++ b/Scripts/Fixtures/shared-runtime-headless-reviewed.sha256 @@ -0,0 +1,51 @@ +# Reviewed hardened headless source/test baseline. +# Regenerate only after explicit review of the complete headless trees: +# python3 Scripts/shared_runtime_headless_baseline.py --write +# Format: +cfbd42b95ef397ceff6104bd2dc08584f24fed7e136c84be34bb0f1a0a7b4f51 Sources/RepoPromptHeadless/CLI/HeadlessCLI.swift +abb7a888debfbbf1285eecccb1fd7be0d0d8e2b518616d09df4aa240ed5a1a44 Sources/RepoPromptHeadless/CLI/HeadlessCommandError.swift +f09888d26b50744a56b0eab0987a3e797a31f85bcf65603d5aab43e5b6911e79 Sources/RepoPromptHeadless/Configuration/HeadlessConfiguration.swift +6e265c0274e2422c519f4ee1982dc28134bbb9bbfbb458182f6e7f2514a78fa0 Sources/RepoPromptHeadless/Configuration/HeadlessConfigurationStore.swift +b6a930b2dd09efd059674aaaf2f30e2ee919a681d31d9b1ba53b7e89d8254686 Sources/RepoPromptHeadless/Configuration/HeadlessFileLock.swift +5488394736e3be2c549977c8347d7f7e856a02dfa03a61de8ed1fa32cebec8ce Sources/RepoPromptHeadless/Configuration/HeadlessRootAccessPolicy.swift +69b4bd4a3ee634e246d5fc98282ac41632a8c6dd7059b04cfc6695e9c4ed1e29 Sources/RepoPromptHeadless/Configuration/HeadlessStateFileSecurity.swift +9f653f677c65f0e0d3fe016421ab3426e6c781cf674386d4c9d15aec30c06127 Sources/RepoPromptHeadless/Configuration/HeadlessStatePaths.swift +7a1c11234c93b96146f06b9931a5414168d2102a778c997b42cbf45aeffffeaf Sources/RepoPromptHeadless/HeadlessVersion.swift +05ff9c2371f604b2f73386da306b8f0ba0f1339d3e147c86f872eb22f8ed23d9 Sources/RepoPromptHeadless/MCP/HeadlessJSONRPC.swift +e6aeb73a4e5c424f6771f138d8740806cbd0c358a80a907284dd2e9e4079187e Sources/RepoPromptHeadless/MCP/HeadlessMCPServer.swift +58051c7016ff29340e1e05e4b8e1aa64bb66b4210801d96f1fed1737f51920be Sources/RepoPromptHeadless/MCP/HeadlessNewlineFrameDecoder.swift +733f7e1e51b9a54dcfb5c92f9bdf8f55223c764127147c80b03ba048be63311d Sources/RepoPromptHeadless/MCP/HeadlessStdioTransport.swift +c8eb45c84e154271c594fcfaddce481e0fb64fe24279e54b94343ad545b4c61a Sources/RepoPromptHeadless/MCP/HeadlessStdoutWriter.swift +033f9aa58e5988450f8d944b8d35202a084c7e5faa8aca216436e0fa63b80ad3 Sources/RepoPromptHeadless/MCP/HeadlessToolRegistry.swift +4e046b4c2acc8e89874ba6e100136c0026a218f54a16572198f9bcea1c4fc1d7 Sources/RepoPromptHeadless/MCP/HeadlessToolSupport.swift +a75c325cbc14352b73da18de7bfc9b1292b6b85ae9df26de229185e13c968912 Sources/RepoPromptHeadless/MCP/Tools/HeadlessFileTools.swift +a51d24c48c0ea8d1ad429bb72a3043df0f6d3382c50159bec214e8a1f376b57e Sources/RepoPromptHeadless/MCP/Tools/HeadlessPromptTools.swift +9cfb55dd14a965fc121fad183dff866f37d29c05a9bda310b7cb2124c02b1bb8 Sources/RepoPromptHeadless/MCP/Tools/HeadlessSelectionTools.swift +05ccf42d549c8d135c048a67bbf9d858efefddf71ad165f037f1b06f83afeea9 Sources/RepoPromptHeadless/MCP/Tools/HeadlessWorkspaceTools.swift +0995322d2a983396f0c0086dd0a6811a29dca8a4dd3d7d7dcb3c99bcce2b350b Sources/RepoPromptHeadless/Runtime/HeadlessCodeStructureService.swift +4cfffa46c9b93014150a29b2c168268ebec4d6e2b3c8528688323d894f11933d Sources/RepoPromptHeadless/Runtime/HeadlessExportWriter.swift +81b5d30b61db55e3c4729783fedb66de34b9aa47e4f970d4c853e18a548494b4 Sources/RepoPromptHeadless/Runtime/HeadlessFileCatalog.swift +8b1d5b3052e71d55b2e48a604e9965cb13cd055757cea6dd44ffc1a188738d96 Sources/RepoPromptHeadless/Runtime/HeadlessHost.swift +ebbe89f25bc9d9ec448b4f968cb96340b5b5c6709020cb8a6973072b1c2f6a57 Sources/RepoPromptHeadless/Runtime/HeadlessPathResolver.swift +f0a2412d79d805a31c6bb990a8459caa252f0c10601a34adb30d489a60bc0282 Sources/RepoPromptHeadless/Runtime/HeadlessReadFileSlicer.swift +0979ad9b043f86522cef1a64c52ce336dbf31cb65ac5bf205d243a78d80a9a50 Sources/RepoPromptHeadless/Runtime/HeadlessSearchService.swift +a59c644ae5ce93d0aec056580365af27e92f11427f648de00fc6cfcb1e101044 Sources/RepoPromptHeadless/Runtime/HeadlessSecureFileAccess.swift +17f452d00404290090bbe7317ed0bfbac05a5ae615f72c668f870e4eb25f6907 Sources/RepoPromptHeadless/Runtime/HeadlessWorkspaceModels.swift +216c1a1d7a7b76bb92f6ec447bceff14719b0367cc90650a37311dfb91e11d32 Sources/RepoPromptHeadless/Runtime/HeadlessWorkspaceStore.swift +79a8ba58bd8b80804891302387dc14454230be98862a1721306b7882ed64b86e Sources/RepoPromptHeadless/Security/HeadlessExternalExportFileSecurity.swift +7268168ce6b2e72938937b3285038365863b3caaac92f7da2135cfd1920e1015 Sources/RepoPromptHeadless/Security/HeadlessSecureStorage.swift +8af6de2deec433f609b2169fc8275d7d2188bf3c8387ad26d079d89c8e84e4b1 Sources/RepoPromptHeadless/Support/HeadlessOutput.swift +3c164f4d6fc620a1df0645dad1b166c5c1f333af7dc7bedd6c6312919fd73090 Sources/RepoPromptHeadless/main.swift +3e6872af2464a5ca1f896d812c5cdc410b4eb4d24af4140c4707186d99b8147f Tests/RepoPromptHeadlessTests/HeadlessExportWriterTests.swift +d816c211b5cb3e0ca8cf73f046223bd1abfcdeb8cbb33dfc7539e021d009d843 Tests/RepoPromptHeadlessTests/HeadlessMCPServerLifecycleTests.swift +b4f27227566034d8cb640b234171a301edaaf87d163e3981553081bcacf1e4bf Tests/RepoPromptHeadlessTests/HeadlessNewlineFrameDecoderTests.swift +af48012d3b5686ba87288b5a34b487f7f106ab3870580dbe542701d84ea1a52d Tests/RepoPromptHeadlessTests/HeadlessReadFileSlicerTests.swift +76cc0584f2bb20ff6c9fc56bc3ec85be36af3ee82180cf68488ee937d61b7b93 Tests/RepoPromptHeadlessTests/HeadlessSearchServiceTests.swift +a91616c9837362bf6d8317f7505b3484f8c6430d9a4075e504ae4f31c1827431 Tests/RepoPromptHeadlessTests/HeadlessSecureFileAccessTests.swift +072bf3434256992eb1bb7a2f8437b056a1c3a8428bf6195cde2a3c0608ecb0d6 Tests/RepoPromptHeadlessTests/HeadlessSelectionToolsTests.swift +a87fd72ef2c23dc2c68cea1553d9145e011bb41aa9a83018087c910104f51762 Tests/RepoPromptHeadlessTests/HeadlessStateFileSecurityTests.swift +1f4da718b638780a2141d96f555f302b08b83d6dcb18741725118fcddbe1a985 Tests/RepoPromptHeadlessTests/HeadlessStateTransactionTests.swift +28b80cac7cd970043499e3d1ef602d052a52c4466967c3296defc7b6b4d1e908 Tests/RepoPromptHeadlessTests/HeadlessStdioTransportTests.swift +e8b0851ce71d37201ee0fe6682f661001f53ee104e0d4dce4d1dec0d34da1436 Tests/RepoPromptHeadlessTests/HeadlessWorkspaceStoreTests.swift +f964b922853f8189db8f809861e496de65af708c91e9d0cf029b01aef0784ec6 Tests/RepoPromptHeadlessTests/Helpers/RepoRoot.swift +52cb8598d0b0a454cd1d150798dc2cd6a64c5dfc768346843ab770d0b1ae4773 Tests/RepoPromptHeadlessTests/SharedRuntimePhase0HeadlessCharacterizationTests.swift diff --git a/Scripts/conductor.py b/Scripts/conductor.py index ea025569b..e1371df90 100755 --- a/Scripts/conductor.py +++ b/Scripts/conductor.py @@ -2,7 +2,7 @@ """RepoPrompt CE developer daemon. Implements repo-internal daemon/job mechanics, fake sleep validation support, -and delegated build/package/test/debug-app/live-smoke/release operation +and delegated build/package/test/debug-app/live-smoke/headless-smoke/release operation families. Synchronous jobs print concise summaries by default and preserve raw logs under the daemon jobs directory. """ @@ -31,9 +31,11 @@ from pathlib import Path from typing import Any, Deque, Dict, List, Optional, Sequence, Tuple +ProcessSnapshot = Dict[int, Tuple[int, str]] + PROTOCOL_VERSION = 4 TERMINAL_STATES = {"completed", "failed", "canceled"} -LANE_NAMES = {"build", "debugArtifact", "liveApp", "release", "style"} +LANE_NAMES = {"build", "debugArtifact", "headlessArtifact", "headlessSmoke", "liveApp", "release", "style"} LOG_TAIL_LINES = 30 SUMMARY_VERSION = 1 SUMMARY_SUCCESS_MAX_LINES = 25 @@ -77,6 +79,10 @@ "provider-test", "install-debug-cli", "debug-cli-status", + "package-headless", + "install-headless-debug", + "headless-debug-status", + "headless-smoke", "run", "app", "smoke", @@ -114,13 +120,17 @@ ./conductor format-tools-status # inspect SwiftFormat/SwiftLint availability ./conductor check-format-tools # fail if style tools are missing ./conductor install-format-tools # explicit Homebrew install of missing style tools - ./conductor swift-build --product RepoPrompt|repoprompt-mcp|all + ./conductor swift-build --product RepoPrompt|repoprompt-mcp|repoprompt-headless|all ./conductor build ./conductor package debug|release + ./conductor package-headless [debug|release] ./conductor test [--filter ] ./conductor provider-test [--filter ] ./conductor install-debug-cli ./conductor debug-cli-status + ./conductor install-headless-debug + ./conductor headless-debug-status + ./conductor headless-smoke [--configuration debug|release] # standalone direct-stdio MCP smoke; no app launch ./conductor run [-- ] # FIFO coordinated run ./conductor app status ./conductor app stop # latest interactive stop intent @@ -133,7 +143,7 @@ Foundation validation operation: ./conductor sleep [--lane ]... [--message ] [--exit-code ] ./conductor fake-sleep [same options] - valid lanes: build, debugArtifact, liveApp, release, style + valid lanes: build, debugArtifact, headlessArtifact, headlessSmoke, liveApp, release, style Global operation flags: --async enqueue and return a ticket immediately @@ -316,6 +326,44 @@ def process_command(pid: int) -> str: return completed.stdout.strip() +def process_snapshot() -> Optional[ProcessSnapshot]: + try: + completed = subprocess.run( + ["ps", "-axo", "pid=,ppid=,lstart="], + text=True, + capture_output=True, + timeout=2.0, + ) + except (OSError, subprocess.TimeoutExpired): + return None + if completed.returncode != 0: + return None + snapshot: ProcessSnapshot = {} + for line in completed.stdout.splitlines(): + fields = line.strip().split(maxsplit=2) + if len(fields) != 3: + continue + try: + pid = int(fields[0]) + ppid = int(fields[1]) + except ValueError: + continue + snapshot[pid] = (ppid, fields[2]) + return snapshot + + +def descendant_process_ids(root_pids: Sequence[int], snapshot: ProcessSnapshot) -> set[int]: + descendants = set(root_pids) + changed = True + while changed: + changed = False + for pid, (ppid, _identity) in snapshot.items(): + if pid not in descendants and ppid in descendants: + descendants.add(pid) + changed = True + return descendants + + def write_daemon_metadata(paths: Paths) -> None: payload = { "pid": os.getpid(), @@ -714,6 +762,9 @@ class Job: finished_at: Optional[float] = None process_pid: Optional[int] = None process_pgid: Optional[int] = None + process_identity: Optional[str] = None + descendant_process_identities: Dict[int, str] = dataclasses.field(default_factory=dict) + termination_requested_at: Optional[float] = None exit_code: Optional[int] = None error: Optional[str] = None result_summary: Optional[str] = None @@ -781,6 +832,13 @@ class OperationRegistry: "REPOPROMPT_DEBUG_APP_BUNDLE", "REPOPROMPT_DEBUG_CLI_INSTALL_PATH", ] + HEADLESS_ENV_KEYS = [ + "REPOPROMPT_HEADLESS_TOOLS_ROOT", + "REPOPROMPT_HEADLESS_DEBUG_INSTALL_PATH", + "REPOPROMPT_HEADLESS_INSTALL_PATH", + "REPOPROMPT_HEADLESS_BINARY", + "REPOPROMPT_HEADLESS_STATE_DIR", + ] BUILD_ENV_KEYS = [ "PATH", "DEVELOPER_DIR", @@ -805,7 +863,7 @@ class OperationRegistry: "HOMEBREW_NO_INSTALL_CLEANUP", "HOMEBREW_CACHE", ] - PASSTHROUGH_ENV_KEYS = sorted(set(SIGNING_ENV_KEYS + DEBUG_ENV_KEYS + BUILD_ENV_KEYS + STYLE_ENV_KEYS)) + PASSTHROUGH_ENV_KEYS = sorted(set(SIGNING_ENV_KEYS + DEBUG_ENV_KEYS + HEADLESS_ENV_KEYS + BUILD_ENV_KEYS + STYLE_ENV_KEYS)) def __init__(self, repo_root: Path) -> None: self.repo_root = repo_root @@ -860,7 +918,7 @@ def prepare(self, request: Dict[str, Any]) -> Tuple[List[str], List[str], Path, if operation == "doctor": return [script("doctor.sh")], lanes, cwd, env, effective_timeout if operation == "guardrails": - return [script("source_layout_guardrails.sh")], lanes, cwd, env, effective_timeout + return ["make", "guardrails"], lanes, cwd, env, effective_timeout if operation == "format": return [script("swift_style.sh"), "format"], ["style", "build"], cwd, env, effective_timeout if operation == "format-check": @@ -899,6 +957,14 @@ def prepare(self, request: Dict[str, Any]) -> Tuple[List[str], List[str], Path, return [script("install_debug_cli.sh"), "install", "--build"], ["build", "debugArtifact"], cwd, env, effective_timeout if operation == "debug-cli-status": return [script("install_debug_cli.sh"), "status"], lanes, cwd, env, effective_timeout + if operation == "package-headless": + return [script("package_headless.sh"), str(args.get("config") or "debug")], ["build", "headlessArtifact"], cwd, env, effective_timeout + if operation == "install-headless-debug": + return [script("install_headless_cli.sh"), "install", "--configuration", "debug", "--build"], ["build", "headlessArtifact"], cwd, env, effective_timeout + if operation == "headless-debug-status": + return [script("install_headless_cli.sh"), "status", "--configuration", "debug"], lanes, cwd, env, effective_timeout + if operation == "headless-smoke": + return [script("smoke_headless_mcp.sh"), "--configuration", str(args.get("config") or "debug")], ["build", "headlessArtifact", "headlessSmoke"], cwd, env, effective_timeout if operation == "run": return [script("run.sh"), *[str(arg) for arg in args.get("appArgs") or []]], ["build", "debugArtifact", "liveApp"], cwd, env, effective_timeout if operation == "app": @@ -980,7 +1046,7 @@ def _internal_argv(self, kind: str, args: Dict[str, Any]) -> List[str]: return [sys.executable, "-u", str(self.script_path), "__operation_runner", json_dumps(payload)] def _default_timeout(self, operation: Any, args: Dict[str, Any]) -> float: - if operation in {"doctor", "guardrails", "debug-cli-status", "format-tools-status", "check-format-tools"}: + if operation in {"doctor", "guardrails", "debug-cli-status", "headless-debug-status", "format-tools-status", "check-format-tools"}: return SHORT_TIMEOUT_SECONDS if operation == "app" and args.get("subcommand") in {"status", "stop"}: return SHORT_TIMEOUT_SECONDS @@ -1209,12 +1275,8 @@ def _escalate_canceled_job_after_grace(self, ticket: str, reason: str, terminati return if not termination_sent: self._terminate_process_group_locked(job, reason=reason) - deadline = now() + TERMINATE_GRACE_SECONDS - while job.state == "running" and now() < deadline: - self.condition.wait(timeout=min(0.1, max(0.0, deadline - now()))) - if job.state == "running": - self._kill_process_group_locked(job, reason=f"{reason}; SIGKILL after grace period") - self.condition.notify_all() + self._finish_process_tree_termination_locked(job, reason=f"{reason}; SIGKILL after grace period") + self.condition.notify_all() def list_jobs(self, state_filter: Optional[str]) -> Dict[str, Any]: with self.lock: @@ -1336,7 +1398,7 @@ def _force_shutdown_when_canceled(self, tickets: List[str]) -> None: with self.condition: for ticket in tickets: job = self.jobs.get(ticket) - if job and job.state == "running": + if job and self._process_tree_alive_locked(job): self._kill_process_group_locked(job, reason="daemon stop --force; SIGKILL after grace period") self.condition.notify_all() time.sleep(0.2) @@ -1410,6 +1472,11 @@ def _run_job(self, ticket: str) -> None: ) with self.condition: job.process_pid = process.pid + snapshot = process_snapshot() + if snapshot is not None: + process_info = snapshot.get(process.pid) + if process_info is not None: + job.process_identity = process_info[1] with contextlib.suppress(OSError): job.process_pgid = os.getpgid(process.pid) self._write_running_processes_locked() @@ -1437,8 +1504,13 @@ def _run_job(self, ticket: str) -> None: with self.condition: self._kill_process_group_locked(job, reason="SIGKILL after timeout grace period") exit_code = process.wait() + with self.condition: + self._finish_process_tree_termination_locked(job, reason="SIGKILL after timeout grace period") if exit_code == 0: exit_code = 124 + if job.cancel_requested: + with self.condition: + self._finish_process_tree_termination_locked(job, reason="SIGKILL after cancellation grace period") reader.join(timeout=2.0) with self.condition: if job.cancel_requested: @@ -1555,29 +1627,89 @@ def _cancel_running_job_locked(self, job: Job, reason: str) -> None: if job.state != "running": return self._terminate_process_group_locked(job, reason=reason) - term_deadline = now() + TERMINATE_GRACE_SECONDS - while job.state == "running" and now() < term_deadline: - self.condition.wait(timeout=0.1) - if job.state != "running": - return - self._kill_process_group_locked(job, reason=f"{reason}; SIGKILL after grace period") - kill_deadline = now() + 2.0 - while job.state == "running" and now() < kill_deadline: - self.condition.wait(timeout=0.1) + self._finish_process_tree_termination_locked(job, reason=f"{reason}; SIGKILL after grace period") def _terminate_process_group_locked(self, job: Job, reason: str) -> None: self._append_system_line_locked(job, f"terminating process group: {reason}\n") - pgid = job.process_pgid or job.process_pid - if pgid: - with contextlib.suppress(ProcessLookupError, PermissionError, OSError): - os.killpg(pgid, signal.SIGTERM) + if job.termination_requested_at is None: + job.termination_requested_at = now() + self._signal_process_tree_locked(job, signal.SIGTERM) def _kill_process_group_locked(self, job: Job, reason: str) -> None: self._append_system_line_locked(job, f"killing process group: {reason}\n") + self._signal_process_tree_locked(job, signal.SIGKILL) + + def _finish_process_tree_termination_locked(self, job: Job, reason: str) -> None: + deadline = (job.termination_requested_at or now()) + TERMINATE_GRACE_SECONDS + process_tree_alive = self._process_tree_alive_locked(job) + while process_tree_alive and now() < deadline: + self.condition.wait(timeout=min(0.1, max(0.0, deadline - now()))) + process_tree_alive = self._process_tree_alive_locked(job) + if not process_tree_alive: + return + self._kill_process_group_locked(job, reason=reason) + kill_deadline = now() + 2.0 + while now() < kill_deadline: + if not self._process_tree_alive_locked(job): + break + self.condition.wait(timeout=0.05) + + def _signal_process_tree_locked(self, job: Job, process_signal: signal.Signals) -> None: + snapshot = process_snapshot() + descendant_identities: Dict[int, str] = {} + if snapshot is not None: + self._capture_descendants_locked(job, snapshot) + descendant_identities = { + pid: identity + for pid, identity in job.descendant_process_identities.items() + if snapshot.get(pid) is not None and snapshot[pid][1] == identity + } + pgid = job.process_pgid or job.process_pid if pgid: with contextlib.suppress(ProcessLookupError, PermissionError, OSError): - os.killpg(pgid, signal.SIGKILL) + os.killpg(pgid, process_signal) + + for pid, identity in descendant_identities.items(): + if pid == job.process_pid or process_start_token(pid) != identity: + continue + with contextlib.suppress(ProcessLookupError, PermissionError, OSError): + os.kill(pid, process_signal) + + def _process_tree_alive_locked(self, job: Job) -> bool: + snapshot = process_snapshot() + if snapshot is None: + return job.state == "running" + root_matches = self._capture_descendants_locked(job, snapshot) + if root_matches: + return True + return any( + snapshot.get(pid) is not None and snapshot[pid][1] == identity + for pid, identity in job.descendant_process_identities.items() + ) + + def _capture_descendants_locked(self, job: Job, snapshot: ProcessSnapshot) -> bool: + root_pid = job.process_pid + if root_pid is None: + return False + root_info = snapshot.get(root_pid) + root_matches = root_info is not None + if root_matches and job.process_identity is None: + job.process_identity = root_info[1] + if root_matches and root_info[1] != job.process_identity: + root_matches = False + tracked_roots = [ + pid + for pid, identity in job.descendant_process_identities.items() + if snapshot.get(pid) is not None and snapshot[pid][1] == identity + ] + if root_matches: + tracked_roots.append(root_pid) + for pid in descendant_process_ids(tracked_roots, snapshot): + process_info = snapshot.get(pid) + if process_info is not None: + job.descendant_process_identities[pid] = process_info[1] + return root_matches def _retention_pass_locked(self) -> None: cutoff = now() - TERMINAL_RETENTION_SECONDS @@ -2652,7 +2784,7 @@ def operation_app_stop(repo_root: Path, args: Dict[str, Any]) -> int: def operation_swift_build_all(repo_root: Path) -> int: - for product in ["RepoPrompt", "repoprompt-mcp"]: + for product in ["RepoPrompt", "repoprompt-mcp", "repoprompt-headless"]: code, _stdout, _stderr = run_operation_command(f"swift build --product {product}", ["swift", "build", "--product", product], repo_root) if code != 0: return code @@ -2887,6 +3019,8 @@ def handle_real_operation(paths: Paths, operation: str, argv: List[str]) -> int: "build", "install-debug-cli", "debug-cli-status", + "install-headless-debug", + "headless-debug-status", "format", "format-check", "lint", @@ -2897,7 +3031,7 @@ def handle_real_operation(paths: Paths, operation: str, argv: List[str]) -> int: parse_no_args(f"conductor {operation}", rest) elif operation == "swift-build": parser = argparse.ArgumentParser(prog="conductor swift-build") - parser.add_argument("--product", required=True, choices=["RepoPrompt", "repoprompt-mcp", "all"]) + parser.add_argument("--product", required=True, choices=["RepoPrompt", "repoprompt-mcp", "repoprompt-headless", "all"]) ns = parser.parse_args(rest) args["product"] = ns.product elif operation == "package": @@ -2905,6 +3039,16 @@ def handle_real_operation(paths: Paths, operation: str, argv: List[str]) -> int: parser.add_argument("config", choices=["debug", "release"]) ns = parser.parse_args(rest) args["config"] = ns.config + elif operation == "package-headless": + parser = argparse.ArgumentParser(prog="conductor package-headless") + parser.add_argument("config", nargs="?", default="debug", choices=["debug", "release"]) + ns = parser.parse_args(rest) + args["config"] = ns.config + elif operation == "headless-smoke": + parser = argparse.ArgumentParser(prog="conductor headless-smoke") + parser.add_argument("--configuration", default="debug", choices=["debug", "release"]) + ns = parser.parse_args(rest) + args["config"] = ns.configuration elif operation == "test": parser = argparse.ArgumentParser(prog="conductor test") parser.add_argument("--filter") diff --git a/Scripts/core_boundary_guardrails.sh b/Scripts/core_boundary_guardrails.sh new file mode 100755 index 000000000..581eed3a5 --- /dev/null +++ b/Scripts/core_boundary_guardrails.sh @@ -0,0 +1,160 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$ROOT" + +CORE_ROOT="Sources/RepoPromptCore" +MACOS_ROOT="Sources/RepoPromptCoreMacOS" +POSIX_ROOT="Sources/RepoPromptPOSIXSupport" +SHARED_ROOT="Sources/RepoPromptShared" +SYNTAX_BRIDGE_ROOT="Sources/RepoPromptSyntaxCBridge" +failures=0 + +fail() { + printf 'ERROR: %s\n' "$*" >&2 + failures=$((failures + 1)) +} + +report_matches() { + local label="$1" + local pattern="$2" + shift 2 + local output status + + set +e + output="$(grep -n -E -- "$pattern" "$@" 2>&1)" + status=$? + set -e + + if [[ "$status" -eq 0 ]]; then + fail "$label" + printf '%s\n' "$output" >&2 + elif [[ "$status" -ne 1 ]]; then + printf 'ERROR: core boundary grep failed while checking: %s\n' "$label" >&2 + printf '%s\n' "$output" >&2 + exit "$status" + fi +} + +swift_files_under() { + find "$1" -type f -name '*.swift' -print | sort +} + +for required_root in "$CORE_ROOT" "$MACOS_ROOT" "$POSIX_ROOT" "$SHARED_ROOT" "$SYNTAX_BRIDGE_ROOT"; do + if [[ ! -d "$required_root" ]]; then + fail "required boundary source root missing: $required_root" + fi +done + +shared_swift_files=() +while IFS= read -r file; do + shared_swift_files+=("$file") +done < <(swift_files_under "$SHARED_ROOT") +if [[ "${#shared_swift_files[@]}" -eq 0 ]]; then + fail "$SHARED_ROOT contains no Swift files" +else + shared_non_foundation_imports="$( + grep -H -n -E '^[[:space:]]*import[[:space:]]+' "${shared_swift_files[@]}" \ + | grep -v -E 'import[[:space:]]+Foundation$' \ + | grep -v -E '^Sources/RepoPromptShared/MCP/JSONRPCBridgeLedger[.]swift:[0-9]+:import[[:space:]]+CryptoKit$' \ + || true + )" + if [[ -n "$shared_non_foundation_imports" ]]; then + fail "$SHARED_ROOT imports must be Foundation, except CryptoKit in MCP/JSONRPCBridgeLedger.swift" + printf '%s\n' "$shared_non_foundation_imports" >&2 + fi + report_matches \ + "POSIX descriptor/socket ownership leaked back into $SHARED_ROOT" \ + 'POSIXDescriptor|fcntl|FD_CLOEXEC|SHUT_RDWR|sockaddr|Darwin|Glibc|SystemPackage' \ + "${shared_swift_files[@]}" +fi + +core_swift_files=() +while IFS= read -r file; do + core_swift_files+=("$file") +done < <(swift_files_under "$CORE_ROOT") +if [[ "${#core_swift_files[@]}" -eq 0 ]]; then + fail "$CORE_ROOT contains no Swift files" +else + report_matches \ + "forbidden Apple UI/native import found under $CORE_ROOT" \ + '^[[:space:]]*(@[[:alnum:]_]+[[:space:]]+)*import([[:space:]]+(class|struct|enum|protocol|func|var|let|typealias))?[[:space:]]+(AppKit|SwiftUI|Cocoa|Sparkle|KeyboardShortcuts|CoreServices|Security|Darwin|Glibc|SystemPackage|OSLog|os|RepoPromptShared|RepoPromptPOSIXSupport|RepoPromptCoreMacOS)([.]|[[:space:]]|$)' \ + "${core_swift_files[@]}" + report_matches \ + "app-owned runtime or embedded-policy reference found under $CORE_ROOT" \ + '(^|[^[:alnum:]_])(WindowState|WindowStatesManager|NSApplication|NSWorkspace|SecureKeyValueStorageFactory|MacOSFSEventsWatcherFactory)([^[:alnum:]_]|$)|Bundle[.]main|UserDefaults[.]standard|applicationSupportDirectory' \ + "${core_swift_files[@]}" + report_matches \ + "Darwin-backed descriptor/socket type leaked into Core contracts" \ + 'POSIXDescriptorConfigurationError|connectedFileDescriptor|sockaddr|(^|[^[:alnum:]_])FileDescriptor([^[:alnum:]_]|$)|Darwin[.]|Glibc[.]' \ + "${core_swift_files[@]}" + report_matches \ + "Core owns Apple signpost instrumentation; keep counters and elapsed durations platform-neutral" \ + 'OSSignpost|OSSignposter|os_signpost|CODEMAP_PERF_SIGNPOSTS|(^|[^[:alnum:]_])signposts([^[:alnum:]_]|$)' \ + "${core_swift_files[@]}" + + if ! core_native_import_output="$(python3 <<'PY' +from pathlib import Path + +root = Path("Sources/RepoPromptCore") +contracts = { + "RepoPromptC": { + "FileSystem/GitignoreCompiler.swift", + "Utilities/StringFNV.swift", + "Utilities/StringLineEndingUtilities.swift", + "WorkspaceContext/Search/PathSearchIndex.swift", + "WorkspaceContext/Search/RepoSearchBatchScorer.swift", + "WorkspaceContext/Search/SearchMatch.swift", + "WorkspaceContext/Search/SearchPathFiltering.swift", + }, + "CSwiftPCRE2": { + "Regex/PCRE2Error.swift", + "Regex/PCRE2JIT.swift", + "Regex/PCRE2Options.swift", + "Regex/PCRE2Regex.swift", + }, + "RepoPromptSyntaxCBridge": {"SyntaxParsing/SyntaxManager.swift"}, + "SwiftTreeSitter": { + "CodeMap/CodeMapCaptureIndex.swift", + "CodeMap/CodeMapGenerator.swift", + "CodeMap/LanguageStrategies/SwiftCodeMapStrategy.swift", + "CodeMap/LanguageStrategies/TypeScriptCodeMapStrategy.swift", + "SyntaxParsing/SyntaxManager.swift", + }, + "Cuchardet": {"FileSystem/FileSystemService+ContentLoading.swift"}, + "UniversalCharsetDetection": {"FileSystem/FileSystemService+ContentLoading.swift"}, +} +errors = [] +for module, expected in contracts.items(): + actual = { + str(path.relative_to(root)) + for path in root.rglob("*.swift") + if f"import {module}" in path.read_text() + } + if actual != expected: + errors.append(f"{module} importer ownership drift: expected {sorted(expected)}, found {sorted(actual)}") +if errors: + raise SystemExit("\n".join(errors)) +PY +)"; then + fail "RepoPromptCore native/product imports escaped their moved importer ownership" + printf '%s\n' "$core_native_import_output" >&2 + fi +fi + +if [[ ! -f "$POSIX_ROOT/Descriptors/POSIXDescriptorSupport.swift" ]]; then + fail "POSIX descriptor support must be single-sourced under $POSIX_ROOT/Descriptors" +fi + +report_matches \ + "app packaging mentions a standalone headless command; keep headless independently packaged" \ + 'repoprompt-headless|rpce-headless' \ + Scripts/package_app.sh + +if [[ "$failures" -ne 0 ]]; then + printf 'Core boundary guardrails failed (%s issue%s).\n' "$failures" "$([[ "$failures" == 1 ]] && printf '' || printf 's')" >&2 + exit 1 +fi + +printf 'OK: enforced Core/Shared/POSIX boundary guardrails passed.\n' diff --git a/Scripts/install_headless_cli.sh b/Scripts/install_headless_cli.sh new file mode 100755 index 000000000..454eaf156 --- /dev/null +++ b/Scripts/install_headless_cli.sh @@ -0,0 +1,311 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="${REPOPROMPT_RELEASE_SOURCE_ROOT:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)}" +HEADLESS_TOOLS_ROOT="${REPOPROMPT_HEADLESS_TOOLS_ROOT:-$HOME/Library/Application Support/RepoPrompt CE/HeadlessTools}" +SUPPORT_ROOT="$HOME/Library/Application Support/RepoPrompt CE" +BINARY_NAME="repoprompt-headless" + +ACTION="status" +CONFIGURATION="all" +CONFIGURATION_EXPLICIT=0 +BUILD_FIRST=0 + +fail() { echo "ERROR: $*" >&2; exit 1; } + +usage() { + cat < $SUPPORT_ROOT/repoprompt_headless_debug + -> $HEADLESS_TOOLS_ROOT/Debug/$BINARY_NAME + +Managed release link: + ${REPOPROMPT_HEADLESS_INSTALL_PATH:-/usr/local/bin/rpce-headless} + -> $SUPPORT_ROOT/repoprompt_headless + -> $HEADLESS_TOOLS_ROOT/Release/$BINARY_NAME + +Options: + --configuration debug|release Select which command to install/uninstall (default: debug) + --build Package the selected headless binary before installing +EOF +} + +if (( $# > 0 )) && [[ "${1:-}" != --* ]]; then + ACTION="$1" + shift +fi + +while (( $# > 0 )); do + case "$1" in + --configuration) + shift + [[ $# -gt 0 ]] || fail "--configuration requires debug or release" + case "$1" in + debug|release) CONFIGURATION="$1"; CONFIGURATION_EXPLICIT=1 ;; + *) fail "--configuration must be debug or release, got '$1'" ;; + esac + ;; + --build) BUILD_FIRST=1 ;; + --help|-h) usage; exit 0 ;; + *) fail "Unknown option: $1" ;; + esac + shift +done + +case "$ACTION" in + status|install|uninstall) ;; + *) fail "Unknown action '$ACTION'. Expected status, install, or uninstall." ;; +esac + +if [[ "$ACTION" != "status" && "$CONFIGURATION" == "all" ]]; then + CONFIGURATION="debug" +fi + +config_label() { + case "$1" in + debug) printf 'Debug' ;; + release) printf 'Release' ;; + *) fail "Unknown configuration '$1'" ;; + esac +} + +binary_for() { + local config="$1" + printf '%s/%s/%s' "$HEADLESS_TOOLS_ROOT" "$(config_label "$config")" "$BINARY_NAME" +} + +user_link_for() { + case "$1" in + debug) printf '%s/repoprompt_headless_debug' "$SUPPORT_ROOT" ;; + release) printf '%s/repoprompt_headless' "$SUPPORT_ROOT" ;; + *) fail "Unknown configuration '$1'" ;; + esac +} + +path_link_for() { + case "$1" in + debug) printf '%s' "${REPOPROMPT_HEADLESS_DEBUG_INSTALL_PATH:-/usr/local/bin/rpce-headless-debug}" ;; + release) printf '%s' "${REPOPROMPT_HEADLESS_INSTALL_PATH:-/usr/local/bin/rpce-headless}" ;; + *) fail "Unknown configuration '$1'" ;; + esac +} + +command_name_for() { + basename "$(path_link_for "$1")" +} + +is_managed_path_link() { + local config="$1" path_link user_link binary target + path_link="$(path_link_for "$config")" + user_link="$(user_link_for "$config")" + binary="$(binary_for "$config")" + [[ -L "$path_link" ]] || return 1 + target="$(readlink "$path_link" 2>/dev/null || true)" + [[ "$target" == "$user_link" || "$target" == "$binary" ]] +} + +is_managed_user_link() { + local config="$1" user_link binary target + user_link="$(user_link_for "$config")" + binary="$(binary_for "$config")" + [[ -L "$user_link" ]] || return 1 + target="$(readlink "$user_link" 2>/dev/null || true)" + [[ "$target" == "$binary" ]] +} + +is_current_user_link() { + local config="$1" user_link + user_link="$(user_link_for "$config")" + is_managed_user_link "$config" && [[ -x "$user_link" ]] +} + +is_current_path_link() { + local config="$1" path_link user_link binary target + path_link="$(path_link_for "$config")" + user_link="$(user_link_for "$config")" + binary="$(binary_for "$config")" + [[ -L "$path_link" ]] || return 1 + target="$(readlink "$path_link" 2>/dev/null || true)" + if [[ "$target" == "$binary" && -x "$path_link" ]]; then + return 0 + fi + [[ "$target" == "$user_link" && -x "$path_link" ]] || return 1 + is_current_user_link "$config" +} + +ensure_binary() { + local config="$1" binary + binary="$(binary_for "$config")" + if (( BUILD_FIRST )); then + "$ROOT_DIR/Scripts/package_headless.sh" "$config" + fi + [[ -x "$binary" ]] || fail "Headless binary not found at '$binary'. Run './Scripts/package_headless.sh $config' first, or use '$0 install --configuration $config --build'." +} + +ensure_user_link() { + local config="$1" binary user_link link_dir + ensure_binary "$config" + binary="$(binary_for "$config")" + user_link="$(user_link_for "$config")" + link_dir="$(dirname "$user_link")" + mkdir -p "$link_dir" + if [[ -e "$user_link" && ! -L "$user_link" ]]; then + fail "User-space headless path exists but is not a symlink: $user_link" + fi + if [[ -L "$user_link" && "$(readlink "$user_link")" == "$binary" && -x "$user_link" ]]; then + return + fi + rm -f "$user_link" + ln -s "$binary" "$user_link" +} + +install_path_link() { + local config="$1" path_link user_link install_dir command_name + ensure_user_link "$config" + path_link="$(path_link_for "$config")" + user_link="$(user_link_for "$config")" + install_dir="$(dirname "$path_link")" + command_name="$(command_name_for "$config")" + [[ -d "$install_dir" ]] || fail "Install directory does not exist: $install_dir" + if [[ -e "$path_link" || -L "$path_link" ]]; then + if ! is_managed_path_link "$config"; then + fail "Refusing to replace unmanaged file at $path_link" + fi + fi + if [[ -w "$install_dir" ]]; then + rm -f "$path_link" + ln -s "$user_link" "$path_link" + else + if [[ ! -t 0 ]]; then + fail "$install_dir is not writable. Re-run from an interactive terminal so sudo can install $command_name." + fi + echo "Installing $command_name with administrator privileges..." + sudo rm -f "$path_link" + sudo ln -s "$user_link" "$path_link" + fi + echo "Installed: $path_link -> $user_link" + "$path_link" --version +} + +uninstall_path_link() { + local config="$1" path_link install_dir command_name + path_link="$(path_link_for "$config")" + install_dir="$(dirname "$path_link")" + command_name="$(command_name_for "$config")" + if [[ ! -e "$path_link" && ! -L "$path_link" ]]; then + echo "$command_name is not installed at $path_link" + return + fi + if ! is_managed_path_link "$config"; then + echo "ERROR: Refusing to remove unmanaged file at $path_link" >&2 + return 1 + fi + if [[ -w "$install_dir" ]]; then + rm -f "$path_link" + else + if [[ ! -t 0 ]]; then + fail "$install_dir is not writable. Re-run from an interactive terminal so sudo can remove $command_name." + fi + echo "Removing $command_name with administrator privileges..." + sudo rm -f "$path_link" + fi + echo "Removed: $path_link" +} + +uninstall_user_link() { + local config="$1" user_link + user_link="$(user_link_for "$config")" + if [[ ! -e "$user_link" && ! -L "$user_link" ]]; then + echo "User-space headless command is not installed at $user_link" + return + fi + if ! is_managed_user_link "$config"; then + echo "ERROR: Refusing to remove unmanaged file at $user_link" >&2 + return 1 + fi + rm -f "$user_link" + echo "Removed: $user_link" +} + +uninstall_configuration() { + local config="$1" status=0 + uninstall_path_link "$config" || status=$? + uninstall_user_link "$config" || status=$? + return "$status" +} + +print_one_status() { + local config="$1" label binary user_link path_link command_name target + label="$(config_label "$config")" + binary="$(binary_for "$config")" + user_link="$(user_link_for "$config")" + path_link="$(path_link_for "$config")" + command_name="$(command_name_for "$config")" + echo "RepoPrompt CE headless $config status" + echo " Staged binary: $binary" + if [[ -x "$binary" ]]; then + echo " Staged binary state: OK" + else + echo " Staged binary state: missing" + fi + if [[ -L "$user_link" ]]; then + target="$(readlink "$user_link" 2>/dev/null || true)" + if is_current_user_link "$config"; then + echo " User-space symlink: OK ($user_link -> $target)" + else + echo " User-space symlink: stale ($user_link -> $target)" + fi + else + echo " User-space symlink: missing ($user_link)" + fi + if [[ -L "$path_link" ]]; then + target="$(readlink "$path_link" 2>/dev/null || true)" + if is_current_path_link "$config"; then + echo " PATH command: OK ($path_link -> $target)" + elif is_managed_path_link "$config"; then + echo " PATH command: stale ($path_link -> $target)" + else + echo " PATH command: unmanaged symlink ($path_link -> $target)" + fi + elif [[ -e "$path_link" ]]; then + echo " PATH command: unmanaged file ($path_link)" + else + echo " PATH command: missing ($path_link)" + fi + if command -v "$command_name" >/dev/null 2>&1; then + echo " command -v $command_name: $(command -v "$command_name")" + elif [[ -x "$user_link" ]]; then + echo " Direct fallback: \"$user_link\" doctor" + fi + if [[ -x "$path_link" ]]; then + echo " Version: $("$path_link" --version 2>/dev/null || true)" + elif [[ -x "$user_link" ]]; then + echo " Version: $("$user_link" --version 2>/dev/null || true)" + elif [[ -x "$binary" ]]; then + echo " Version: $("$binary" --version 2>/dev/null || true)" + fi + if [[ "$label" == "Debug" ]]; then + echo " Install/update: make install-debug-headless" + else + echo " Install/update: ./Scripts/install_headless_cli.sh install --configuration release --build" + fi +} + +case "$ACTION" in + status) + if [[ "$CONFIGURATION" == "all" && "$CONFIGURATION_EXPLICIT" == "0" ]]; then + print_one_status debug + echo + print_one_status release + else + print_one_status "$CONFIGURATION" + fi + ;; + install) install_path_link "$CONFIGURATION" ;; + uninstall) uninstall_configuration "$CONFIGURATION" ;; +esac diff --git a/Scripts/package_headless.sh b/Scripts/package_headless.sh new file mode 100755 index 000000000..89c989eae --- /dev/null +++ b/Scripts/package_headless.sh @@ -0,0 +1,70 @@ +#!/usr/bin/env bash +set -euo pipefail + +CONF="${1:-debug}" +ROOT_DIR="${REPOPROMPT_RELEASE_SOURCE_ROOT:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)}" +CONTROL_PLANE_SCRIPTS_DIR="${REPOPROMPT_CONTROL_PLANE_SCRIPTS_DIR:-$ROOT_DIR/Scripts}" +RUN_WITHOUT_GITHUB_TOKENS="$CONTROL_PLANE_SCRIPTS_DIR/run_without_github_tokens.sh" +HEADLESS_TOOLS_ROOT="${REPOPROMPT_HEADLESS_TOOLS_ROOT:-$HOME/Library/Application Support/RepoPrompt CE/HeadlessTools}" +BINARY_NAME="repoprompt-headless" + +tmp_binary="" +cleanup() { + if [[ -n "$tmp_binary" ]]; then + rm -f "$tmp_binary" + fi +} +trap cleanup EXIT + +fail() { echo "ERROR: $*" >&2; exit 1; } +echo_cmd() { printf '+ '; printf '%q ' "$@"; printf '\n'; } +run() { echo_cmd "$@"; "$@"; } + +usage() { + cat < bool: + return any(relative.startswith(f"{root}/") for root in roots) + + +def collect_entries(repository_root: Path, roots: tuple[str, ...] = HEADLESS_ROOTS) -> dict[str, str]: + entries: dict[str, str] = {} + for root in roots: + source_root = repository_root / root + if not source_root.is_dir(): + raise ReviewedHeadlessBaselineError(f"Reviewed headless baseline root is missing: {root}") + for path in sorted(source_root.rglob("*")): + if path.is_symlink(): + relative = path.relative_to(repository_root).as_posix() + raise ReviewedHeadlessBaselineError( + f"Reviewed headless baseline does not permit symlinks: {relative}" + ) + if not path.is_file(): + continue + relative = path.relative_to(repository_root).as_posix() + if "\n" in relative: + raise ReviewedHeadlessBaselineError( + f"Reviewed headless baseline cannot encode a newline in a path: {relative!r}" + ) + entries[relative] = hashlib.sha256(path.read_bytes()).hexdigest() + if not entries: + raise ReviewedHeadlessBaselineError("Reviewed headless baseline contains no files") + return entries + + +def render_manifest(entries: dict[str, str]) -> str: + lines = [ + "# Reviewed hardened headless source/test baseline.", + "# Regenerate only after explicit review of the complete headless trees:", + "# python3 Scripts/shared_runtime_headless_baseline.py --write", + "# Format: ", + ] + lines.extend(f"{entries[path]} {path}" for path in sorted(entries)) + return "\n".join(lines) + "\n" + + +def parse_manifest( + manifest_path: Path, + roots: tuple[str, ...] = HEADLESS_ROOTS, +) -> dict[str, str]: + if not manifest_path.is_file(): + raise ReviewedHeadlessBaselineError( + f"Reviewed headless baseline manifest is missing: {manifest_path}" + ) + + entries: dict[str, str] = {} + manifest_text = manifest_path.read_bytes().decode("utf-8") + for line_number, line in enumerate(manifest_text.split("\n"), 1): + if not line or line.startswith("#"): + continue + digest, separator, relative = line.partition(" ") + if not separator or not DIGEST_PATTERN.fullmatch(digest) or not relative: + raise ReviewedHeadlessBaselineError( + f"Invalid reviewed headless baseline entry at {manifest_path}:{line_number}" + ) + if relative.startswith("/") or ".." in Path(relative).parts or not _is_in_scope(relative, roots): + raise ReviewedHeadlessBaselineError( + f"Reviewed headless baseline entry is outside the locked trees: {relative}" + ) + if relative in entries: + raise ReviewedHeadlessBaselineError( + f"Duplicate reviewed headless baseline entry: {relative}" + ) + entries[relative] = digest + + if not entries: + raise ReviewedHeadlessBaselineError("Reviewed headless baseline manifest contains no entries") + return entries + + +def verify_reviewed_headless_baseline( + repository_root: Path = ROOT, + manifest_path: Path = DEFAULT_MANIFEST, + roots: tuple[str, ...] = HEADLESS_ROOTS, +) -> None: + expected = parse_manifest(manifest_path, roots) + actual = collect_entries(repository_root, roots) + + expected_paths = set(expected) + actual_paths = set(actual) + added = sorted(actual_paths - expected_paths) + removed = sorted(expected_paths - actual_paths) + if added or removed: + raise ReviewedHeadlessBaselineError( + "Reviewed headless tree path set drifted: " + f"added={added}, removed={removed}" + ) + + changed = sorted(path for path in expected if actual[path] != expected[path]) + if changed: + raise ReviewedHeadlessBaselineError( + f"Reviewed headless file content drifted: {changed}" + ) + + +def write_reviewed_headless_baseline( + repository_root: Path = ROOT, + manifest_path: Path = DEFAULT_MANIFEST, + roots: tuple[str, ...] = HEADLESS_ROOTS, +) -> int: + entries = collect_entries(repository_root, roots) + manifest_path.parent.mkdir(parents=True, exist_ok=True) + manifest_path.write_text(render_manifest(entries), encoding="utf-8") + return len(entries) + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + action = parser.add_mutually_exclusive_group() + action.add_argument("--check", action="store_true", help="verify the reviewed baseline (default)") + action.add_argument("--write", action="store_true", help="rewrite the manifest from the complete trees") + args = parser.parse_args() + + try: + if args.write: + count = write_reviewed_headless_baseline() + print(f"Wrote reviewed headless baseline for {count} files: {DEFAULT_MANIFEST.relative_to(ROOT)}") + else: + verify_reviewed_headless_baseline() + print("OK: reviewed hardened headless source/test baseline passed.") + return 0 + except ReviewedHeadlessBaselineError as error: + print(f"ERROR: {error}", file=sys.stderr) + return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/Scripts/smoke_headless_mcp.sh b/Scripts/smoke_headless_mcp.sh new file mode 100755 index 000000000..c7b9a64f7 --- /dev/null +++ b/Scripts/smoke_headless_mcp.sh @@ -0,0 +1,423 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="${REPOPROMPT_RELEASE_SOURCE_ROOT:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)}" +CONTROL_PLANE_SCRIPTS_DIR="${REPOPROMPT_CONTROL_PLANE_SCRIPTS_DIR:-$ROOT_DIR/Scripts}" +RUN_WITHOUT_GITHUB_TOKENS="$CONTROL_PLANE_SCRIPTS_DIR/run_without_github_tokens.sh" +HEADLESS_TOOLS_ROOT="${REPOPROMPT_HEADLESS_TOOLS_ROOT:-$HOME/Library/Application Support/RepoPrompt CE/HeadlessTools}" +BINARY_NAME="repoprompt-headless" +CONF="debug" +PACKAGE_FIRST=1 +BINARY="${REPOPROMPT_HEADLESS_BINARY:-}" +SMOKE_ROOT="" + +fail() { echo "ERROR: $*" >&2; exit 1; } +echo_cmd() { printf '+ '; printf '%q ' "$@"; printf '\n'; } +run() { echo_cmd "$@"; "$@"; } + +usage() { + cat < 0 )); do + case "$1" in + --configuration) + shift + [[ $# -gt 0 ]] || fail "--configuration requires debug or release" + case "$1" in + debug|release) CONF="$1" ;; + *) fail "--configuration must be debug or release, got '$1'" ;; + esac + ;; + --binary) + shift + [[ $# -gt 0 ]] || fail "--binary requires a path" + BINARY="$1" + PACKAGE_FIRST=0 + ;; + --skip-package) PACKAGE_FIRST=0 ;; + --help|-h) usage; exit 0 ;; + *) fail "Unknown option: $1" ;; + esac + shift +done + +config_label() { + case "$1" in + debug) printf 'Debug' ;; + release) printf 'Release' ;; + *) fail "Unknown configuration '$1'" ;; + esac +} + +cleanup() { + if [[ -n "$SMOKE_ROOT" ]]; then + rm -rf "$SMOKE_ROOT" + fi +} +trap cleanup EXIT + +cd "$ROOT_DIR" + +if [[ -z "$BINARY" ]]; then + if (( PACKAGE_FIRST )); then + run "$ROOT_DIR/Scripts/package_headless.sh" "$CONF" + BINARY="$HEADLESS_TOOLS_ROOT/$(config_label "$CONF")/$BINARY_NAME" + else + echo_cmd "$RUN_WITHOUT_GITHUB_TOKENS" swift build -c "$CONF" --show-bin-path + BUILD_DIR="$("$RUN_WITHOUT_GITHUB_TOKENS" swift build -c "$CONF" --show-bin-path)" + BINARY="$BUILD_DIR/$BINARY_NAME" + fi +fi + +[[ -x "$BINARY" ]] || fail "Headless binary is not executable: $BINARY" + +SMOKE_ROOT="$(mktemp -d "${REPOPROMPT_HEADLESS_SMOKE_TMPDIR:-/tmp}/rphs.XXXXXX")" +STATE_DIR="$SMOKE_ROOT/state" +FIXTURE_DIR="$SMOKE_ROOT/fixture-root" +OUTSIDE_EXPORT="$SMOKE_ROOT/outside-workspace-context.md" +SYMLINK_EXPORT_TARGET="$SMOKE_ROOT/outside-export-target" +mkdir -p "$STATE_DIR" "$STATE_DIR/Exports" "$FIXTURE_DIR/Sources" "$SYMLINK_EXPORT_TARGET" +ln -s "$SYMLINK_EXPORT_TARGET" "$STATE_DIR/Exports/escape-link" +cat > "$FIXTURE_DIR/README.md" <<'EOF' +# Headless Smoke Fixture + +This fixture contains headless-smoke-token for content search. +EOF +cat > "$FIXTURE_DIR/Sources/Sample.swift" <<'EOF' +struct HeadlessSmokeSample { + let marker = "headless-smoke-token" +} +EOF +: > "$FIXTURE_DIR/Empty.txt" +mkdir -p "$FIXTURE_DIR/Search" "$FIXTURE_DIR/LinkedTarget" +printf 'linked\n' > "$FIXTURE_DIR/LinkedTarget/value.txt" +ln -s "$FIXTURE_DIR/README.md" "$FIXTURE_DIR/leaf-link.md" +ln -s "$FIXTURE_DIR/LinkedTarget" "$FIXTURE_DIR/intermediate-link" +mkfifo "$FIXTURE_DIR/read-fifo" +for index in 1 2 3 4; do + printf 'needle\n' > "$FIXTURE_DIR/Search/needle-$index.txt" +done + +run "$BINARY" --state-dir "$STATE_DIR" config roots add "$FIXTURE_DIR" --name Fixture +run "$BINARY" --state-dir "$STATE_DIR" doctor +run "$BINARY" --state-dir "$STATE_DIR" config permissions list + +REPOPROMPT_HEADLESS_SMOKE_BINARY="$BINARY" \ +REPOPROMPT_HEADLESS_SMOKE_STATE_DIR="$STATE_DIR" \ +REPOPROMPT_HEADLESS_SMOKE_OUTSIDE_EXPORT="$OUTSIDE_EXPORT" \ +REPOPROMPT_HEADLESS_SMOKE_FIXTURE_DIR="$FIXTURE_DIR" \ +python3 - <<'PY' +import json +import os +import socket +import subprocess +import sys +import tempfile +from concurrent.futures import ThreadPoolExecutor + +binary = os.environ["REPOPROMPT_HEADLESS_SMOKE_BINARY"] +state_dir = os.environ["REPOPROMPT_HEADLESS_SMOKE_STATE_DIR"] +outside_export = os.environ["REPOPROMPT_HEADLESS_SMOKE_OUTSIDE_EXPORT"] +fixture_dir = os.environ["REPOPROMPT_HEADLESS_SMOKE_FIXTURE_DIR"] + +def encode(message): + return json.dumps(message, separators=(",", ":")).encode() + +def run_raw(payload): + # Use files instead of bidirectional pipes because the server can respond + # before the parent has finished supplying an oversized input frame. + with tempfile.TemporaryFile() as stdin_file, \ + tempfile.TemporaryFile() as stdout_file, \ + tempfile.TemporaryFile() as stderr_file: + stdin_file.write(payload) + stdin_file.seek(0) + completed = subprocess.run( + [binary, "--state-dir", state_dir, "serve"], + stdin=stdin_file, + stdout=stdout_file, + stderr=stderr_file, + timeout=30, + ) + stdout_file.seek(0) + stderr_file.seek(0) + stdout = stdout_file.read() + stderr = stderr_file.read() + completed.stdout = stdout + completed.stderr = stderr + if completed.returncode != 0: + sys.stderr.write(stderr.decode(errors="replace")) + raise SystemExit(f"headless serve exited with {completed.returncode}") + responses = [] + for line in stdout.splitlines(): + if line.strip(): + responses.append(json.loads(line)) + return responses, completed + +def run_messages(messages, suffix=b"\n"): + payload = b"\n".join(encode(message) for message in messages) + suffix + return run_raw(payload)[0] + +def by_id(responses): + return {response.get("id"): response for response in responses if "id" in response} + +def require_rpc_error(response, label): + if not isinstance(response, dict) or "error" not in response: + raise SystemExit(f"{label} should be a JSON-RPC error: {response}") + +def require_tool_error(response, label): + result = response.get("result", {}) + if result.get("isError") is not True: + raise SystemExit(f"{label} should be a tool error: {response}") + +initialize = lambda request_id: { + "jsonrpc": "2.0", "id": request_id, "method": "initialize", + "params": {"protocolVersion": "2024-11-05", "capabilities": {}, "clientInfo": {"name": "headless-smoke", "version": "1"}}, +} +initialized = {"jsonrpc": "2.0", "method": "notifications/initialized", "params": {}} +exit_notification = {"jsonrpc": "2.0", "method": "exit"} + +# Lifecycle and request/notification contracts. +lifecycle = [ + {"jsonrpc": "2.0", "id": 201, "method": "tools/list", "params": {}}, + initialize(202), + {"jsonrpc": "2.0", "id": 203, "method": "tools/list", "params": {}}, + initialized, + initialize(204), + {"jsonrpc": "2.0", "id": 205, "method": "notifications/initialized", "params": {}}, + {"jsonrpc": "2.0", "method": "tools/call", "params": {"name": "prompt", "arguments": {"op": "set", "text": "notification-must-not-run"}}}, + {"jsonrpc": "2.0", "id": 208, "method": "tools/call", "params": {"name": "prompt", "arguments": {"op": "get"}}}, + {"jsonrpc": "2.0", "id": 206, "method": "shutdown", "params": {}}, + {"jsonrpc": "2.0", "id": 207, "method": "ping", "params": {}}, + exit_notification, +] +lifecycle_responses = by_id(run_messages(lifecycle)) +for request_id in (201, 203, 204, 205, 207): + require_rpc_error(lifecycle_responses.get(request_id), f"lifecycle request {request_id}") +if lifecycle_responses.get(202, {}).get("result", {}).get("serverInfo", {}).get("name") != "RepoPrompt Headless": + raise SystemExit(f"initialize failed: {lifecycle_responses.get(202)}") +if lifecycle_responses.get(208, {}).get("result", {}).get("structuredContent", {}).get("prompt") != "": + raise SystemExit("request-only tools/call notification was executed") +if lifecycle_responses.get(206, {}).get("result", "not-null") is not None: + raise SystemExit(f"shutdown result should be null: {lifecycle_responses.get(206)}") + +# Unterminated frames are never dispatched; whitespace residual EOF is ignored. +unterminated = [initialize(301), initialized] +unterminated_payload = b"\n".join(encode(message) for message in unterminated) + b"\n" + encode({ + "jsonrpc": "2.0", "id": 302, "method": "tools/call", + "params": {"name": "prompt", "arguments": {"op": "set", "text": "unterminated-must-not-run"}}, +}) +unterminated_responses, _ = run_raw(unterminated_payload) +if any(response.get("id") == 302 for response in unterminated_responses): + raise SystemExit("unterminated EOF frame was dispatched") +if not any(response.get("id") is None and response.get("error", {}).get("code") == -32700 for response in unterminated_responses): + raise SystemExit(f"unterminated non-whitespace EOF did not report a parse error: {unterminated_responses}") + +whitespace_responses, _ = run_raw(encode(initialize(311)) + b"\n" + encode(initialized) + b"\n \t\r") +if any("error" in response for response in whitespace_responses): + raise SystemExit(f"whitespace-only EOF residual should be ignored: {whitespace_responses}") +garbage_responses, _ = run_raw(encode(initialize(312)) + b"\n" + encode(initialized) + b"\nnot-json") +if not any(response.get("id") is None and response.get("error", {}).get("code") == -32700 for response in garbage_responses): + raise SystemExit(f"garbage EOF residual did not report a parse error: {garbage_responses}") + +# A single oversized frame is rejected, while following frames still work. +oversized = encode({"jsonrpc": "2.0", "method": "unknown/oversized", "params": {"data": "x" * (1024 * 1024 + 128)}}) +oversized_payload = b"\n".join([ + encode(initialize(321)), encode(initialized), oversized, + encode({"jsonrpc": "2.0", "id": 322, "method": "ping"}), + encode({"jsonrpc": "2.0", "id": 323, "method": "shutdown"}), encode(exit_notification), b"", +]) +oversized_responses, _ = run_raw(oversized_payload) +if not any(response.get("id") is None and response.get("error", {}).get("code") == -32700 for response in oversized_responses): + raise SystemExit("oversized frame was not rejected") +if by_id(oversized_responses).get(322, {}).get("result") != {}: + raise SystemExit(f"transport did not recover after oversized frame: {oversized_responses}") + +# Two individually valid large frames may arrive together even when their aggregate exceeds the limit. +large_notice = lambda marker: {"jsonrpc": "2.0", "method": "unknown/large", "params": {"marker": marker, "data": "y" * 600000}} +aggregate_responses = run_messages([ + initialize(331), initialized, large_notice(1), large_notice(2), + {"jsonrpc": "2.0", "id": 332, "method": "ping"}, + {"jsonrpc": "2.0", "id": 333, "method": "shutdown"}, exit_notification, +]) +if any(response.get("error", {}).get("code") == -32700 for response in aggregate_responses): + raise SystemExit("aggregate buffer size was incorrectly treated as a frame limit") +if by_id(aggregate_responses).get(332, {}).get("result") != {}: + raise SystemExit("valid frame after aggregate large notifications was lost") + +socket_path = os.path.join(fixture_dir, "read-socket") +unix_socket = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) +unix_socket.bind(socket_path) +unix_socket.listen(1) + +requests = [ + initialize(1), initialized, + {"jsonrpc": "2.0", "id": 2, "method": "tools/list", "params": {}}, + {"jsonrpc": "2.0", "id": 3, "method": "tools/call", "params": {"name": "read_file", "arguments": {"path": "Fixture/README.md", "start_line": 1, "limit": 5}}}, + {"jsonrpc": "2.0", "id": 4, "method": "tools/call", "params": {"name": "read_file", "arguments": {"path": "Fixture/README.md", "start_line": 3, "limit": 1}}}, + {"jsonrpc": "2.0", "id": 5, "method": "tools/call", "params": {"name": "read_file", "arguments": {"path": "Fixture/README.md", "start_line": 2, "limit": 0}}}, + {"jsonrpc": "2.0", "id": 6, "method": "tools/call", "params": {"name": "read_file", "arguments": {"path": "Fixture/README.md", "start_line": 50}}}, + {"jsonrpc": "2.0", "id": 7, "method": "tools/call", "params": {"name": "read_file", "arguments": {"path": "Fixture/Empty.txt", "start_line": 50}}}, + {"jsonrpc": "2.0", "id": 8, "method": "tools/call", "params": {"name": "read_file", "arguments": {"path": "Fixture/README.md", "start_line": 0}}}, + {"jsonrpc": "2.0", "id": 9, "method": "tools/call", "params": {"name": "read_file", "arguments": {"path": "Fixture/README.md", "start_line": -1, "limit": 1}}}, + {"jsonrpc": "2.0", "id": 10, "method": "tools/call", "params": {"name": "read_file", "arguments": {"path": "Fixture/leaf-link.md"}}}, + {"jsonrpc": "2.0", "id": 11, "method": "tools/call", "params": {"name": "read_file", "arguments": {"path": "Fixture/intermediate-link/value.txt"}}}, + {"jsonrpc": "2.0", "id": 12, "method": "tools/call", "params": {"name": "read_file", "arguments": {"path": "Fixture/read-fifo"}}}, + {"jsonrpc": "2.0", "id": 13, "method": "tools/call", "params": {"name": "read_file", "arguments": {"path": "Fixture/read-socket"}}}, + {"jsonrpc": "2.0", "id": 14, "method": "tools/call", "params": {"name": "file_search", "arguments": {"pattern": "needle", "mode": "both", "path": "Fixture/Search", "max_results": 2, "regex": False}}}, + {"jsonrpc": "2.0", "id": 15, "method": "tools/call", "params": {"name": "file_search", "arguments": {"pattern": "needle", "mode": "both", "path": "Fixture/Search", "max_results": 2, "count_only": True, "regex": False}}}, + {"jsonrpc": "2.0", "id": 16, "method": "tools/call", "params": {"name": "workspace_context", "arguments": {"op": "export", "include": ["prompt", "selection", "tokens"], "path": outside_export}}}, + {"jsonrpc": "2.0", "id": 17, "method": "tools/call", "params": {"name": "workspace_context", "arguments": {"op": "export", "include": ["prompt", "selection", "tokens"], "path": "escape-link/escaped.md"}}}, + {"jsonrpc": "2.0", "id": 18, "method": "tools/call", "params": {"name": "apply_edits", "arguments": {}}}, + {"jsonrpc": "2.0", "id": 19, "method": "tools/call", "params": {"name": "manage_selection", "arguments": {"op": "add", "slices": []}}}, + {"jsonrpc": "2.0", "id": 20, "method": "tools/call", "params": {"name": "manage_selection", "arguments": {"op": "add", "mode": "slices", "paths": ["Fixture/Sources/Sample.swift"]}}}, + {"jsonrpc": "2.0", "id": 21, "method": "tools/call", "params": {"name": "manage_selection", "arguments": {"op": "set", "slices": [{"path": "Fixture/Sources/Sample.swift", "ranges": [{"start_line": 1, "end_line": 5, "description": "smoke"}]}]}}}, + {"jsonrpc": "2.0", "id": 22, "method": "tools/call", "params": {"name": "manage_selection", "arguments": {"op": "remove", "slices": [{"path": "Fixture/Sources/Sample.swift", "ranges": [{"start_line": 3, "end_line": 3}]}]}}}, + {"jsonrpc": "2.0", "id": 23, "method": "tools/call", "params": {"name": "manage_selection", "arguments": {"op": "get"}}}, + {"jsonrpc": "2.0", "id": 99, "method": "shutdown", "params": {}}, + exit_notification, +] +responses = run_messages(requests) +unix_socket.close() +by_id_map = by_id(responses) +expected_ids = set(range(1, 24)) | {99} +missing = expected_ids - set(by_id_map) +if missing: + raise SystemExit(f"missing JSON-RPC response id(s): {sorted(missing)}\nresponses={responses}") + +init = by_id_map[1]["result"] +if init.get("serverInfo", {}).get("name") != "RepoPrompt Headless": + raise SystemExit(f"unexpected serverInfo: {init.get('serverInfo')}") +if init.get("headless", {}).get("configuredRootCount") != 1: + raise SystemExit(f"expected one configured root, got {init.get('headless')}") + +tools = [tool.get("name") for tool in by_id_map[2]["result"].get("tools", [])] +required_tools = {"bind_context", "manage_workspaces", "manage_selection", "workspace_context", "get_file_tree", "get_code_structure", "read_file", "file_search", "prompt"} +if set(tools) != required_tools: + raise SystemExit(f"safe tool catalog drifted: {tools}") + +read_result = by_id_map[3]["result"] +if read_result.get("isError") is not False: + raise SystemExit(f"read_file unexpectedly failed: {read_result}") +if "headless-smoke-token" not in read_result.get("structuredContent", {}).get("content", ""): + raise SystemExit("read_file did not return fixture token") + +single_line_result = by_id_map[4]["result"] +if single_line_result.get("isError") is not False: + raise SystemExit(f"single-line read_file unexpectedly failed: {single_line_result}") +single_line_structured = single_line_result.get("structuredContent", {}) +expected_line = "This fixture contains headless-smoke-token for content search." +if single_line_structured.get("content") != expected_line + "\n": + raise SystemExit(f"single-line read_file returned unexpected content: {single_line_structured}") +if single_line_structured.get("first_line") != 3 or single_line_structured.get("last_line") != 3: + raise SystemExit(f"single-line read_file reported wrong bounds: {single_line_structured}") + +zero_limit = by_id_map[5]["result"].get("structuredContent", {}) +if zero_limit.get("content") != "" or zero_limit.get("first_line") != 2 or zero_limit.get("last_line") != 1: + raise SystemExit(f"limit=0 parity failed: {zero_limit}") +beyond = by_id_map[6]["result"].get("structuredContent", {}) +if beyond.get("content") != "" or beyond.get("message") != "Requested start_line exceeds file length.": + raise SystemExit(f"beyond-EOF parity failed: {beyond}") +empty = by_id_map[7]["result"].get("structuredContent", {}) +if (empty.get("total_lines"), empty.get("first_line"), empty.get("last_line"), empty.get("message")) != (0, 0, 0, None): + raise SystemExit(f"empty-file parity failed: {empty}") +for request_id, label in ((8, "start_line=0"), (9, "negative start with limit"), (10, "leaf symlink"), (11, "intermediate symlink"), (12, "FIFO"), (13, "socket")): + require_tool_error(by_id_map[request_id], label) + +search = by_id_map[14]["result"].get("structuredContent", {}) +if (search.get("total_path_matches"), search.get("total_content_matches"), search.get("total_matches")) != (4, 4, 8): + raise SystemExit(f"search totals are inaccurate: {search}") +if search.get("returned_matches") != 2 or search.get("omitted") != 6: + raise SystemExit(f"search shared-budget accounting is inaccurate: {search}") +count_only = by_id_map[15]["result"].get("structuredContent", {}) +if count_only.get("count_only") is not True or count_only.get("returned_matches") != 0 or count_only.get("total_matches") != 8 or count_only.get("omitted") != 6: + raise SystemExit(f"count_only accounting is inaccurate: {count_only}") +if count_only.get("path_matches") or count_only.get("content_matches"): + raise SystemExit(f"count_only materialized results: {count_only}") + +export_result = by_id_map[16]["result"] +if export_result.get("isError") is not True: + raise SystemExit(f"workspace_context export outside state should be rejected: {export_result}") +export_text = "\n".join(item.get("text", "") for item in export_result.get("content", [])) +if "export_outside_state_directory is false" not in export_text: + raise SystemExit(f"workspace_context rejection did not mention export permission: {export_text}") + +symlink_export_result = by_id_map[17]["result"] +if symlink_export_result.get("isError") is not True: + raise SystemExit(f"workspace_context export through Exports symlink should be rejected: {symlink_export_result}") +symlink_export_text = "\n".join(item.get("text", "") for item in symlink_export_result.get("content", [])) +if "export_outside_state_directory is false" not in symlink_export_text and "escapes the headless Exports directory" not in symlink_export_text: + raise SystemExit(f"symlink export rejection did not mention containment policy: {symlink_export_text}") + +policy_result = by_id_map[18]["result"] +if policy_result.get("isError") is not True: + raise SystemExit(f"gated apply_edits call should be rejected: {policy_result}") +policy_text = "\n".join(item.get("text", "") for item in policy_result.get("content", [])) +if "not available in RepoPrompt Headless v1" not in policy_text: + raise SystemExit(f"gated tool rejection message drifted: {policy_text}") + +require_tool_error(by_id_map[19], "empty slices") +require_tool_error(by_id_map[20], "slice mode paths without ranges") +for request_id in (21, 22, 23): + if by_id_map[request_id].get("result", {}).get("isError") is not False: + raise SystemExit(f"selection request {request_id} failed: {by_id_map[request_id]}") +selection = by_id_map[23]["result"].get("structuredContent", {}).get("files", []) +sample = next((entry for entry in selection if entry.get("relative_path") == "Sources/Sample.swift"), None) +if sample is None: + raise SystemExit(f"slice selection missing after range removal: {selection}") +expected_ranges = [(1, 2, "smoke"), (4, 5, "smoke")] +actual_ranges = [(item.get("start_line"), item.get("end_line"), item.get("description")) for item in sample.get("ranges", [])] +if actual_ranges != expected_ranges: + raise SystemExit(f"range-level removal widened or removed the selection: {actual_ranges}") + +if by_id_map[99].get("result", "not-null") is not None: + raise SystemExit(f"shutdown result should be null: {by_id_map[99]}") + +# Verify the prior unterminated notification did not mutate persisted state. +verify = by_id(run_messages([ + initialize(401), initialized, + {"jsonrpc": "2.0", "id": 402, "method": "tools/call", "params": {"name": "prompt", "arguments": {"op": "get"}}}, + {"jsonrpc": "2.0", "id": 403, "method": "shutdown"}, exit_notification, +])) +if verify[402]["result"].get("structuredContent", {}).get("prompt") != "": + raise SystemExit("unterminated mutation frame changed persisted prompt state") + +# Separate processes must serialize workspace load-modify-save transactions. +def append_prompt(index): + responses = by_id(run_messages([ + initialize(5000 + index * 10), initialized, + {"jsonrpc": "2.0", "id": 5001 + index * 10, "method": "tools/call", "params": {"name": "prompt", "arguments": {"op": "append", "text": "x"}}}, + {"jsonrpc": "2.0", "id": 5002 + index * 10, "method": "shutdown"}, exit_notification, + ])) + result = responses[5001 + index * 10].get("result", {}) + if result.get("isError") is not False: + raise RuntimeError(f"concurrent prompt append failed: {result}") + +with ThreadPoolExecutor(max_workers=8) as executor: + list(executor.map(append_prompt, range(16))) + +locked_verify = by_id(run_messages([ + initialize(7001), initialized, + {"jsonrpc": "2.0", "id": 7002, "method": "tools/call", "params": {"name": "prompt", "arguments": {"op": "get"}}}, + {"jsonrpc": "2.0", "id": 7003, "method": "shutdown"}, exit_notification, +])) +if locked_verify[7002]["result"].get("structuredContent", {}).get("prompt") != "x" * 16: + raise SystemExit(f"cross-process workspace updates were lost: {locked_verify[7002]}") + +print("Headless MCP smoke passed") +PY + +printf 'Headless smoke binary: %s\n' "$BINARY" +printf 'Headless smoke state: %s\n' "$STATE_DIR" +printf 'Headless smoke fixture: %s\n' "$FIXTURE_DIR" diff --git a/Scripts/source_layout_guardrails.sh b/Scripts/source_layout_guardrails.sh index 4caf49fd0..5c579a754 100755 --- a/Scripts/source_layout_guardrails.sh +++ b/Scripts/source_layout_guardrails.sh @@ -25,7 +25,12 @@ print_matches() { required_dirs=( "Sources/RepoPrompt/Features" "Sources/RepoPrompt/Infrastructure" - "Sources/RepoPrompt/Infrastructure/SyntaxParsing" + "Sources/RepoPromptCore" + "Sources/RepoPromptCore/SyntaxParsing" + "Sources/RepoPromptCoreMacOS" + "Sources/RepoPromptPOSIXSupport/Descriptors" + "Sources/RepoPromptHeadless" + "Sources/RepoPromptSyntaxCBridge/include" "Sources/RepoPromptShared/MCP" "Tests/RepoPromptTests" ) @@ -39,12 +44,125 @@ shared_mcp_required_files=( "Sources/RepoPromptShared/MCP/MCPControlMessages.swift" "Sources/RepoPromptShared/MCP/MCPFilesystemIdentity.swift" "Sources/RepoPromptShared/MCP/MCPExternalClientEvent.swift" + "Sources/RepoPromptShared/MCP/MCPBootstrapMessages.swift" ) for file in "${shared_mcp_required_files[@]}"; do if [[ ! -f "$file" ]]; then fail "required shared MCP file missing: $file" fi done +if [[ ! -f "Sources/RepoPromptPOSIXSupport/Descriptors/POSIXDescriptorSupport.swift" ]]; then + fail "required package-internal POSIX descriptor support file missing" +fi + +if [[ ! -f "docs/architecture/headless-core.md" ]]; then + fail "required headless-core architecture lock document missing" +fi + +syntax_bridge_files=( + "Sources/RepoPromptSyntaxCBridge/include/RepoPromptSyntaxCBridge.h" + "Sources/RepoPromptSyntaxCBridge/RepoPromptSyntaxCBridge.c" +) +for file in "${syntax_bridge_files[@]}"; do + if [[ ! -f "$file" ]]; then + fail "required narrow syntax-bridge file missing: $file" + fi +done +if [[ -d "Sources/RepoPromptSyntaxCBridge" ]]; then + unexpected_syntax_bridge_files="$(find Sources/RepoPromptSyntaxCBridge -type f \ + ! -path 'Sources/RepoPromptSyntaxCBridge/include/RepoPromptSyntaxCBridge.h' \ + ! -path 'Sources/RepoPromptSyntaxCBridge/RepoPromptSyntaxCBridge.c' \ + -print)" + if [[ -n "$unexpected_syntax_bridge_files" ]]; then + fail "unexpected file found under narrow RepoPromptSyntaxCBridge target" + printf '%s\n' "$unexpected_syntax_bridge_files" >&2 + fi +fi +if [[ -e "Sources/RepoPrompt/Support/RepoPrompt-Bridging-Header.h" ]]; then + fail "retired app target-wide bridging header still exists" +fi +if grep -n -E -- '-import-objc-header|-disable-bridging-pch' Package.swift >/dev/null 2>&1; then + fail "Package.swift still contains retired app target-wide bridging-header flags" +fi +if ! grep -n -E -- '\.executable\(name: "repoprompt-headless", targets: \["RepoPromptHeadless"\]\)' Package.swift >/dev/null 2>&1; then + fail "Package.swift must declare the standalone repoprompt-headless executable product" +fi +if ! grep -n -E -- 'name: "RepoPromptHeadless"' Package.swift >/dev/null 2>&1; then + fail "Package.swift must declare the RepoPromptHeadless executable target" +fi +if [[ -d "Sources/RepoPromptHeadless" ]]; then + headless_swift_files=() + while IFS= read -r file; do + headless_swift_files+=("$file") + done < <(find Sources/RepoPromptHeadless -type f -name '*.swift' -print | sort) + if [[ "${#headless_swift_files[@]}" -eq 0 ]]; then + fail "Sources/RepoPromptHeadless must contain Swift source files" + else + print_matches \ + "standalone headless host references app UI, app bundle policy, or app-proxy socket behavior" \ + grep -n -E '^[[:space:]]*(@[[:alnum:]_]+[[:space:]]+)*import([[:space:]]+(class|struct|enum|protocol|func|var|let|typealias))?[[:space:]]+(AppKit|SwiftUI|Cocoa|Sparkle|KeyboardShortcuts)([.]|[[:space:]]|$)|Bundle[.]main|RepoPrompt[.]app|BootstrapSocketProxy|bootstrapSocketURL|NSApplication|MCPBackgroundModeCoordinator|UserDefaults[.]standard' \ + "${headless_swift_files[@]}" + fi +fi + +# Item 5 physically moved these files into narrow package owners. Fail if a legacy +# app-target copy or a duplicate compatibility copy reappears anywhere under Sources. +assert_single_source_file() { + local filename="$1" + local expected_path="$2" + local matches=() + while IFS= read -r file; do + matches+=("$file") + done < <(find Sources -name "$filename" -type f -print | sort) + if [[ "${#matches[@]}" -ne 1 || "${matches[0]:-}" != "$expected_path" ]]; then + fail "$filename must exist only at $expected_path" + printf '%s\n' "${matches[@]}" >&2 + fi +} + +assert_single_source_file "FileSystemWatching.swift" "Sources/RepoPromptCore/Platform/FileSystemWatching.swift" +assert_single_source_file "ProcessLaunching.swift" "Sources/RepoPromptCore/Platform/ProcessLaunching.swift" +assert_single_source_file "POSIXDescriptorSupport.swift" "Sources/RepoPromptPOSIXSupport/Descriptors/POSIXDescriptorSupport.swift" +assert_single_source_file "RepoPromptCorePlatformDependencies.swift" "Sources/RepoPromptCore/Platform/RepoPromptCorePlatformDependencies.swift" +assert_single_source_file "SecureKeyValueStorageBackend.swift" "Sources/RepoPromptCore/Platform/SecureKeyValueStorageBackend.swift" +assert_single_source_file "BundledHelperPeerVerifying.swift" "Sources/RepoPromptCore/MCP/Platform/BundledHelperPeerVerifying.swift" +assert_single_source_file "MCPAppProxyTransportBoundary.swift" "Sources/RepoPromptCore/MCP/Platform/MCPAppProxyTransportBoundary.swift" +assert_single_source_file "ProcessAncestryInspecting.swift" "Sources/RepoPromptCore/MCP/Platform/ProcessAncestryInspecting.swift" +assert_single_source_file "WorkspaceAccessPolicy.swift" "Sources/RepoPromptCore/Workspaces/WorkspaceAccessPolicy.swift" +assert_single_source_file "WorkspaceRootActions.swift" "Sources/RepoPromptCore/Workspaces/WorkspaceRootActions.swift" +assert_single_source_file "EphemeralSecureKeyValueStore.swift" "Sources/RepoPromptCore/Security/EphemeralSecureKeyValueStore.swift" +assert_single_source_file "SecureKeyService.swift" "Sources/RepoPromptCore/Security/SecureKeyService.swift" +assert_single_source_file "MacOSFSEventsWatcher.swift" "Sources/RepoPromptCoreMacOS/FileSystem/MacOSFSEventsWatcher.swift" +assert_single_source_file "POSIXProcessLauncher.swift" "Sources/RepoPromptCoreMacOS/Process/POSIXProcessLauncher.swift" +assert_single_source_file "FDWriteSupport.swift" "Sources/RepoPromptCoreMacOS/Process/FDWriteSupport.swift" +assert_single_source_file "KeychainService.swift" "Sources/RepoPromptCoreMacOS/Security/KeychainService.swift" +assert_single_source_file "RuntimeCodeSigningDetector.swift" "Sources/RepoPromptCoreMacOS/Security/RuntimeCodeSigningDetector.swift" +assert_single_source_file "MacOSBundledHelperPeerVerifier.swift" "Sources/RepoPromptCoreMacOS/MCP/PeerVerification/MacOSBundledHelperPeerVerifier.swift" +assert_single_source_file "MacOSProcessAncestryInspector.swift" "Sources/RepoPromptCoreMacOS/MCP/PeerVerification/MacOSProcessAncestryInspector.swift" + +# Phase 2 canonical runtime/model owners must not regain app-target implementations. +# App integration stays in explicitly named adapters rather than same-named copies. +assert_single_source_file "CodeMapGenerator.swift" "Sources/RepoPromptCore/CodeMap/CodeMapGenerator.swift" +assert_single_source_file "AgentSupportDirectoryCatalog.swift" "Sources/RepoPromptCore/WorkspaceContext/PathResolution/AgentSupportDirectoryCatalog.swift" +assert_single_source_file "WorkspaceReadableFileService.swift" "Sources/RepoPromptCore/WorkspaceContext/WorkspaceReadableFileService.swift" +assert_single_source_file "WorkspaceSessionController.swift" "Sources/RepoPromptCore/Workspaces/WorkspaceSessionController.swift" +assert_single_source_file "WorkspaceSelectionProjection.swift" "Sources/RepoPromptCore/WorkspaceContext/Projection/WorkspaceSelectionProjection.swift" +assert_single_source_file "WorkspaceSelectionProjectionService.swift" "Sources/RepoPromptCore/WorkspaceContext/Projection/WorkspaceSelectionProjectionService.swift" +assert_single_source_file "TokenProjection.swift" "Sources/RepoPromptCore/WorkspaceContext/Projection/TokenProjection.swift" +assert_single_source_file "TokenProjectionService.swift" "Sources/RepoPromptCore/WorkspaceContext/Projection/TokenProjectionService.swift" +assert_single_source_file "WorkspaceContextProjection.swift" "Sources/RepoPromptCore/WorkspaceContext/Projection/WorkspaceContextProjection.swift" +assert_single_source_file "WorkspaceContextProjectionService.swift" "Sources/RepoPromptCore/WorkspaceContext/Projection/WorkspaceContextProjectionService.swift" + +required_core_adapter_files=( + "Sources/RepoPrompt/App/CoreAdapters/WorkspaceSessionObservationBridge.swift" + "Sources/RepoPrompt/Features/Prompt/Services/WorkspacePromptProjectionAdapter.swift" + "Sources/RepoPrompt/Features/Workspaces/WorkspaceModel.swift" +) +for file in "${required_core_adapter_files[@]}"; do + if [[ ! -f "$file" ]]; then + fail "required app adapter over canonical Core state missing: $file" + fi +done # Exact-snapshot Tree-sitter scanner support must remain narrow and reproducible. # Remove this block together with the support target only after validated upstream @@ -91,6 +209,47 @@ if [[ -f "ThirdPartyLicenses/tree-sitter/scanner-support.sha256" ]]; then fi fi +if ! syntax_bridge_header_output="$(python3 <<'PY' +import re +from pathlib import Path + +expected = [ + "tree_sitter_javascript", + "tree_sitter_python", + "tree_sitter_c_sharp", + "tree_sitter_swift", + "tree_sitter_c", + "tree_sitter_cpp", + "tree_sitter_rust", + "tree_sitter_go", + "tree_sitter_java", + "tree_sitter_dart", + "tree_sitter_php", + "tree_sitter_ruby", + "tree_sitter_typescript", + "tree_sitter_tsx", +] +text = Path("Sources/RepoPromptSyntaxCBridge/include/RepoPromptSyntaxCBridge.h").read_text() +pattern = re.compile(r"^const TSLanguage \* (tree_sitter_[a-z_]+)\(void\);$", re.MULTILINE) +declarations = pattern.findall(text) +unexpected_semicolon_lines = [ + line for line in text.splitlines() + if line.strip().endswith(";") + and line.strip() != "typedef struct TSLanguage TSLanguage;" + and pattern.fullmatch(line) is None +] +if declarations != expected or unexpected_semicolon_lines: + raise SystemExit( + "RepoPromptSyntaxCBridge header must contain exactly the curated fourteen declarations" + f"\nfound declarations: {declarations}" + f"\nunexpected declaration lines: {unexpected_semicolon_lines}" + ) +PY +)"; then + fail "RepoPromptSyntaxCBridge declaration shim drifted" + printf '%s\n' "$syntax_bridge_header_output" >&2 +fi + if ! tree_sitter_scanner_support_manifest_output="$(python3 <<'PY' import json import subprocess @@ -104,6 +263,12 @@ expected_packages = { "tree-sitter-javascript": ("https://github.com/tree-sitter/tree-sitter-javascript", "39798e26b6d4dbcee8e522b8db83f8b2df33a5ea", "TreeSitterJavaScript"), "tree-sitter-python": ("https://github.com/tree-sitter/tree-sitter-python", "c5fca1a186e8e528115196178c28eefa8d86b0b0", "TreeSitterPython"), "tree-sitter-rust": ("https://github.com/tree-sitter/tree-sitter-rust", "2eaf126458a4d6a69401089b6ba78c5e5d6c1ced", "TreeSitterRust"), + "tree-sitter-typescript": ("https://github.com/tree-sitter/tree-sitter-typescript", "75b3874edb2dc714fb1fd77a32013d0f8699989f", "TreeSitterTypeScript"), + "tree-sitter-ruby": ("https://github.com/tree-sitter/tree-sitter-ruby", "7a010836b74351855148818d5cb8170dc4df8e6a", "TreeSitterRuby"), + "tree-sitter-swift": ("https://github.com/alex-pinkus/tree-sitter-swift", "9253825dd2570430b53fa128cbb40cb62498e75d", "TreeSitterSwift"), + "tree-sitter-c-sharp": ("https://github.com/tree-sitter/tree-sitter-c-sharp.git", "b27b091bfdc5f16d0ef76421ea5609c82a57dff0", "TreeSitterCSharp"), + "tree-sitter-cpp": ("https://github.com/tree-sitter/tree-sitter-cpp", "e5cea0ec884c5c3d2d1e41a741a66ce13da4d945", "TreeSitterCPP"), + "tree-sitter-php": ("https://github.com/provencher/tree-sitter-php", "0a99deca13c4af1fb9adcb03c958bfc9f4c740a9", "TreeSitterPHP"), } errors = [] manifest_text = Path("Package.swift").read_text() @@ -113,11 +278,28 @@ package = json.loads(subprocess.check_output(["swift", "package", "dump-package" targets = {target["name"]: target for target in package["targets"]} repo_prompt = targets.get("RepoPrompt", {}) repo_prompt_dependencies = repo_prompt.get("dependencies", []) +repo_prompt_mcp = targets.get("RepoPromptMCP", {}) +repo_prompt_mcp_dependencies = repo_prompt_mcp.get("dependencies", []) +core = targets.get("RepoPromptCore", {}) +core_dependencies = core.get("dependencies", []) +macos = targets.get("RepoPromptCoreMacOS", {}) +macos_dependencies = macos.get("dependencies", []) +syntax_bridge = targets.get("RepoPromptSyntaxCBridge", {}) +syntax_bridge_dependencies = syntax_bridge.get("dependencies", []) +syntax_bridge_products = { + (dependency["product"][0], dependency["product"][1]) + for dependency in syntax_bridge_dependencies + if "product" in dependency +} repo_prompt_products = { (dependency["product"][0], dependency["product"][1]) for dependency in repo_prompt_dependencies if "product" in dependency } +expected_syntax_bridge_products = { + (product, identity) + for identity, (_, _, product) in expected_packages.items() +} for identity, (url, revision, product) in expected_packages.items(): manifest_pin = f'.package(url: "{url}", revision: "{revision}")' @@ -128,8 +310,14 @@ for identity, (url, revision, product) in expected_packages.items(): errors.append(f"Package.resolved missing pin: {identity}") elif pin.get("location") != url or pin.get("state", {}).get("revision") != revision: errors.append(f"Package.resolved pin drift: {identity}") - if (product, identity) not in repo_prompt_products: - errors.append(f"RepoPrompt missing upstream grammar product dependency: {product} ({identity})") + if (product, identity) not in syntax_bridge_products: + errors.append(f"RepoPromptSyntaxCBridge missing upstream grammar product dependency: {product} ({identity})") + +if syntax_bridge_products != expected_syntax_bridge_products: + errors.append("RepoPromptSyntaxCBridge grammar product dependencies must remain exactly the curated set") +unexpected_repo_prompt_grammar_products = sorted(repo_prompt_products & expected_syntax_bridge_products) +if unexpected_repo_prompt_grammar_products: + errors.append(f"RepoPrompt must not directly depend on Tree-sitter grammar products: {unexpected_repo_prompt_grammar_products}") support = targets.get("TreeSitterScannerSupport") if support is None: @@ -140,8 +328,74 @@ else: expected_sources = ["src/javascript/scanner.c", "src/python/scanner.c"] if sorted(support.get("sources", [])) != expected_sources: errors.append("TreeSitterScannerSupport sources must remain exactly JavaScript/Python scanner.c") -if not any(dependency.get("byName", [None])[0] == "TreeSitterScannerSupport" for dependency in repo_prompt_dependencies): - errors.append("RepoPrompt must directly depend on TreeSitterScannerSupport") +def has_by_name(dependencies, name): + return any(dependency.get("byName", [None])[0] == name for dependency in dependencies) + +if syntax_bridge.get("path") != "Sources/RepoPromptSyntaxCBridge": + errors.append("RepoPromptSyntaxCBridge target path drifted") +syntax_bridge_by_name_dependencies = sorted( + dependency["byName"][0] + for dependency in syntax_bridge_dependencies + if "byName" in dependency +) +if syntax_bridge_by_name_dependencies != ["TreeSitterScannerSupport"]: + errors.append("RepoPromptSyntaxCBridge must directly depend only on TreeSitterScannerSupport plus the curated grammar products") +if has_by_name(repo_prompt_dependencies, "TreeSitterScannerSupport"): + errors.append("RepoPrompt must not directly depend on TreeSitterScannerSupport") +core_by_name_dependencies = { + dependency["byName"][0] + for dependency in core_dependencies + if "byName" in dependency +} +core_product_dependencies = { + dependency["product"][0] + for dependency in core_dependencies + if "product" in dependency +} +expected_core_by_name_dependencies = {"RepoPromptC", "CSwiftPCRE2", "RepoPromptSyntaxCBridge"} +expected_core_product_dependencies = {"SwiftTreeSitter", "UniversalCharsetDetection", "Cuchardet"} +if core_by_name_dependencies != expected_core_by_name_dependencies: + errors.append( + "RepoPromptCore by-name dependencies must match moved native importers: " + f"expected {sorted(expected_core_by_name_dependencies)}, found {sorted(core_by_name_dependencies)}" + ) +if core_product_dependencies != expected_core_product_dependencies: + errors.append( + "RepoPromptCore product dependencies must match moved syntax/content importers: " + f"expected {sorted(expected_core_product_dependencies)}, found {sorted(core_product_dependencies)}" + ) +core_import_contract = { + "RepoPromptC": "import RepoPromptC", + "CSwiftPCRE2": "import CSwiftPCRE2", + "RepoPromptSyntaxCBridge": "import RepoPromptSyntaxCBridge", + "SwiftTreeSitter": "import SwiftTreeSitter", + "UniversalCharsetDetection": "import UniversalCharsetDetection", + "Cuchardet": "import Cuchardet", +} +core_sources = "\n".join(path.read_text() for path in Path("Sources/RepoPromptCore").rglob("*.swift")) +for dependency, import_line in core_import_contract.items(): + if import_line not in core_sources: + errors.append(f"RepoPromptCore dependency is not importer-backed: {dependency}") +if not has_by_name(repo_prompt_dependencies, "RepoPromptCore") or not has_by_name(repo_prompt_dependencies, "RepoPromptCoreMacOS"): + errors.append("RepoPrompt must directly depend on RepoPromptCore and RepoPromptCoreMacOS") +if not has_by_name(repo_prompt_dependencies, "RepoPromptC"): + errors.append("RepoPrompt must retain RepoPromptC while app StringExtensions imports it") +if not any(dependency.get("product", [None])[0] == "SwiftTreeSitter" for dependency in repo_prompt_dependencies): + errors.append("RepoPrompt must retain SwiftTreeSitter while app UI/file view importers remain") +if not has_by_name(repo_prompt_dependencies, "RepoPromptPOSIXSupport"): + errors.append("RepoPrompt must directly depend on RepoPromptPOSIXSupport while app socket sources import it") +if not has_by_name(repo_prompt_mcp_dependencies, "RepoPromptPOSIXSupport"): + errors.append("RepoPromptMCP must directly depend on RepoPromptPOSIXSupport") +if not has_by_name(macos_dependencies, "RepoPromptCore"): + errors.append("RepoPromptCoreMacOS must directly depend on RepoPromptCore") +if not has_by_name(macos_dependencies, "RepoPromptPOSIXSupport"): + errors.append("RepoPromptCoreMacOS must directly depend on RepoPromptPOSIXSupport") +if has_by_name(macos_dependencies, "RepoPromptShared"): + errors.append("RepoPromptCoreMacOS must not depend on RepoPromptShared") + +product_names = [product["name"] for product in package["products"]] +if product_names != ["RepoPrompt", "repoprompt-mcp", "repoprompt-headless"]: + errors.append(f"SwiftPM products must expose exactly the three executables, found: {product_names}") if errors: raise SystemExit("\n".join(errors)) @@ -219,13 +473,23 @@ if [[ "$mcp_event_declarations" != "Sources/RepoPromptShared/MCP/MCPExternalClie printf '%s\n' "$mcp_event_declarations" >&2 fi -# 4. Parser fixtures and sample parser inputs must not live in app source. +# 3b. MCPBootstrapMessages.swift has exactly one source of truth. +mcp_bootstrap_message_files=() +while IFS= read -r file; do + mcp_bootstrap_message_files+=("$file") +done < <(find Sources -name MCPBootstrapMessages.swift -type f -print | sort) +if [[ "${#mcp_bootstrap_message_files[@]}" -ne 1 || "${mcp_bootstrap_message_files[0]:-}" != "Sources/RepoPromptShared/MCP/MCPBootstrapMessages.swift" ]]; then + fail "MCPBootstrapMessages.swift must exist only at Sources/RepoPromptShared/MCP/MCPBootstrapMessages.swift" + printf '%s\n' "${mcp_bootstrap_message_files[@]}" >&2 +fi + +# 4. Parser fixtures and sample parser inputs must not live in Core syntax source. print_matches \ - "parser fixture/test directory found under app syntax parsing source" \ - find Sources/RepoPrompt/Infrastructure/SyntaxParsing -type d \( -iname '*fixture*' -o -iname '*test*' \) -print + "parser fixture/test directory found under Core syntax parsing source" \ + find Sources/RepoPromptCore/SyntaxParsing -type d \( -iname '*fixture*' -o -iname '*test*' \) -print print_matches \ - "parser fixture-like sample input found under app syntax parsing source" \ - find Sources/RepoPrompt/Infrastructure/SyntaxParsing -type f \( \ + "parser fixture-like sample input found under Core syntax parsing source" \ + find Sources/RepoPromptCore/SyntaxParsing -type f \( \ -iname '*fixture*' -o -iname '*test*' -o \ -name '*.dart' -o -name '*.go' -o -name '*.java' -o -name '*.js' -o -name '*.jsx' -o \ -name '*.py' -o -name '*.rb' -o -name '*.rs' -o -name '*.ts' -o -name '*.tsx' -o \ @@ -289,8 +553,14 @@ print_matches \ # 8. Agent-authored reports and working notes stay local unless explicitly # promoted into the contributor-facing documentation set. allowed_tracked_docs=( + "docs/architecture/headless-core.md" "docs/architecture/provider-plugins.md" "docs/architecture/source-layout.md" + "docs/characterization/shared-runtime-phase0-2026-06-05.md" + "docs/characterization/shared-runtime-phase1-2026-06-05.md" + "docs/characterization/shared-runtime-phase2-slice1-2026-06-05.md" + "docs/characterization/shared-runtime-phase2-slice2-2026-06-05.md" + "docs/characterization/shared-runtime-phase2-slice3-rendering-2026-06-06.md" "docs/open-source-readiness.md" "docs/releasing.md" "docs/worktrees.md" diff --git a/Scripts/swift_style.sh b/Scripts/swift_style.sh index c65c3ba09..2a7196dd7 100755 --- a/Scripts/swift_style.sh +++ b/Scripts/swift_style.sh @@ -27,6 +27,9 @@ fi STYLE_PATHS=( "Package.swift" "Sources/RepoPrompt" + "Sources/RepoPromptCore" + "Sources/RepoPromptCoreMacOS" + "Sources/RepoPromptHeadless" "Sources/RepoPromptMCP" "Sources/RepoPromptShared" "Tests/RepoPromptTests" diff --git a/Scripts/sync_mcp_cli_version.sh b/Scripts/sync_mcp_cli_version.sh index 6e4a4bf76..83deeb989 100755 --- a/Scripts/sync_mcp_cli_version.sh +++ b/Scripts/sync_mcp_cli_version.sh @@ -5,40 +5,81 @@ MODE="${1:-sync}" SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" ROOT_DIR="${REPOPROMPT_RELEASE_SOURCE_ROOT:-$(cd "$SCRIPT_DIR/.." && pwd)}" CLI_SOURCE="$ROOT_DIR/Sources/RepoPromptMCP/main.swift" +HEADLESS_VERSION_SOURCE="$ROOT_DIR/Sources/RepoPromptHeadless/HeadlessVersion.swift" source "$SCRIPT_DIR/load_release_metadata.sh" load_release_metadata "$ROOT_DIR" -python3 - "$MODE" "$CLI_SOURCE" "$MARKETING_VERSION" <<'PYTHON' +python3 - "$MODE" "$CLI_SOURCE" "$HEADLESS_VERSION_SOURCE" "$MARKETING_VERSION" "$BUILD_NUMBER" <<'PYTHON' import re import sys from pathlib import Path -mode, source_path, marketing_version = sys.argv[1:] -source = Path(source_path) -text = source.read_text(encoding="utf-8") -pattern = re.compile(r'^let CLI_VERSION = "[^"]+"$', re.MULTILINE) -matches = pattern.findall(text) -if len(matches) != 1: - raise SystemExit( - f"ERROR: expected exactly one MCP CLI version declaration in {source}, found {len(matches)}" - ) - -current = matches[0] -expected = f'let CLI_VERSION = "{marketing_version}"' +mode, cli_source_path, headless_source_path, marketing_version, build_number = sys.argv[1:] +cli_source = Path(cli_source_path) +headless_source = Path(headless_source_path) + +def replace_exactly_one(text: str, pattern: re.Pattern[str], expected: str, label: str) -> tuple[str, bool, str]: + matches = pattern.findall(text) + if len(matches) != 1: + raise SystemExit(f"ERROR: expected exactly one {label} declaration, found {len(matches)}") + current = matches[0] + return text.replace(current, expected, 1), current != expected, current + +cli_text = cli_source.read_text(encoding="utf-8") +cli_pattern = re.compile(r'^let CLI_VERSION = "[^"]+"$', re.MULTILINE) +expected_cli = f'let CLI_VERSION = "{marketing_version}"' +cli_synced_text, cli_changed, current_cli = replace_exactly_one(cli_text, cli_pattern, expected_cli, f"MCP CLI version in {cli_source}") + +headless_text = headless_source.read_text(encoding="utf-8") +headless_marketing_pattern = re.compile(r'^ static let marketingVersion = "[^"]+"$', re.MULTILINE) +headless_build_pattern = re.compile(r'^ static let buildNumber = "[^"]+"$', re.MULTILINE) +expected_headless_marketing = f' static let marketingVersion = "{marketing_version}"' +expected_headless_build = f' static let buildNumber = "{build_number}"' +headless_synced_text, headless_marketing_changed, current_headless_marketing = replace_exactly_one( + headless_text, + headless_marketing_pattern, + expected_headless_marketing, + f"Headless marketing version in {headless_source}", +) +headless_synced_text, headless_build_changed, current_headless_build = replace_exactly_one( + headless_synced_text, + headless_build_pattern, + expected_headless_build, + f"Headless build number in {headless_source}", +) + if mode == "--check": - if current != expected: + mismatches = [] + if cli_changed: + mismatches.append(f"MCP CLI has {current_cli}, expected {expected_cli}") + if headless_marketing_changed: + mismatches.append(f"Headless has {current_headless_marketing.strip()}, expected {expected_headless_marketing.strip()}") + if headless_build_changed: + mismatches.append(f"Headless has {current_headless_build.strip()}, expected {expected_headless_build.strip()}") + if mismatches: raise SystemExit( - "ERROR: MCP CLI version is out of sync with version.env. " - "Run ./Scripts/release.sh sync-cli-version after updating version.env." + "ERROR: MCP CLI/headless versions are out of sync with version.env. " + "Run ./Scripts/release.sh sync-cli-version after updating version.env.\n- " + + "\n- ".join(mismatches) ) - print(f"OK: MCP CLI version matches release metadata ({marketing_version}).") + print(f"OK: MCP CLI and headless versions match release metadata ({marketing_version}, build {build_number}).") elif mode == "sync": - if current == expected: + changed = False + if cli_changed: + cli_source.write_text(cli_synced_text, encoding="utf-8") + print(f"Updated MCP CLI version to {marketing_version}: {cli_source}") + changed = True + else: print(f"OK: MCP CLI version already matches release metadata ({marketing_version}).") + if headless_marketing_changed or headless_build_changed: + headless_source.write_text(headless_synced_text, encoding="utf-8") + print(f"Updated Headless version to {marketing_version} (build {build_number}): {headless_source}") + changed = True else: - source.write_text(text.replace(current, expected, 1), encoding="utf-8") - print(f"Updated MCP CLI version to {marketing_version}: {source}") + print(f"OK: Headless version already matches release metadata ({marketing_version}, build {build_number}).") + if not changed: + print("OK: all executable versions already match release metadata.") else: raise SystemExit(f"ERROR: usage: {sys.argv[0]} [sync|--check]") PYTHON diff --git a/Scripts/test_conductor_lifecycle.py b/Scripts/test_conductor_lifecycle.py index fec8e421d..2a1e5f970 100644 --- a/Scripts/test_conductor_lifecycle.py +++ b/Scripts/test_conductor_lifecycle.py @@ -7,6 +7,7 @@ import io import os import shutil +import signal import subprocess import sys import tempfile @@ -199,6 +200,15 @@ def test_app_relaunch_delegates_run_script_with_run_lanes_and_timeout(self) -> N self.assertEqual(guarded_env["REPOPROMPT_GUARD_DELAYED_LAUNCH"], "1") self.assertEqual(conductor.operation_display_name("app", {"subcommand": "relaunch"}), "app relaunch") + def test_guardrails_delegate_to_make_aggregate_without_claiming_lanes(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + registry = conductor.OperationRegistry(Path(tmp)) + argv, lanes, _cwd, _env, timeout = registry.prepare({"operation": "guardrails"}) + + self.assertEqual(argv, ["make", "guardrails"]) + self.assertEqual(lanes, []) + self.assertEqual(timeout, conductor.SHORT_TIMEOUT_SECONDS) + def test_release_artifact_delegates_release_script_with_release_lanes_and_timeout(self) -> None: tmp, state = self.make_state() self.addCleanup(tmp.cleanup) @@ -279,8 +289,8 @@ def test_daemon_run_job_launches_process_with_devnull_stdin(self) -> None: fake_process.wait.return_value = 0 with mock.patch.object(conductor.subprocess, "Popen", return_value=fake_process) as popen, mock.patch.object( - state, "_schedule_locked" - ), mock.patch.object(state, "_refresh_output_summary"): + conductor, "process_snapshot", return_value=None + ), mock.patch.object(state, "_schedule_locked"), mock.patch.object(state, "_refresh_output_summary"): state._run_job(job.ticket) self.assertEqual(popen.call_args.kwargs["stdin"], subprocess.DEVNULL) @@ -374,6 +384,57 @@ def test_release_local_install_job_succeeds_with_closed_parent_fd0(self) -> None self.assertEqual(result.returncode, 0, result.stdout + result.stderr) self.assertIn("CLOSED_FD_REGRESSION_OK", result.stdout) + def test_headless_operations_delegate_without_live_app_lane(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + registry = conductor.OperationRegistry(Path(tmp)) + package_argv, package_lanes, _cwd, _env, package_timeout = registry.prepare( + {"operation": "package-headless", "args": {"config": "debug"}} + ) + install_argv, install_lanes, _cwd, _env, _install_timeout = registry.prepare( + {"operation": "install-headless-debug"} + ) + status_argv, status_lanes, _cwd, _env, status_timeout = registry.prepare( + {"operation": "headless-debug-status"} + ) + smoke_argv, smoke_lanes, _cwd, _env, _smoke_timeout = registry.prepare( + {"operation": "headless-smoke", "args": {"config": "release"}} + ) + + self.assertEqual(Path(package_argv[0]).name, "package_headless.sh") + self.assertEqual(package_argv[1], "debug") + self.assertEqual(package_lanes, ["build", "headlessArtifact"]) + self.assertEqual(Path(install_argv[0]).name, "install_headless_cli.sh") + self.assertEqual(install_argv[1:], ["install", "--configuration", "debug", "--build"]) + self.assertEqual(install_lanes, ["build", "headlessArtifact"]) + self.assertEqual(Path(status_argv[0]).name, "install_headless_cli.sh") + self.assertEqual(status_argv[1:], ["status", "--configuration", "debug"]) + self.assertEqual(status_lanes, []) + self.assertEqual(status_timeout, conductor.SHORT_TIMEOUT_SECONDS) + self.assertEqual(Path(smoke_argv[0]).name, "smoke_headless_mcp.sh") + self.assertEqual(smoke_argv[1:], ["--configuration", "release"]) + self.assertEqual(smoke_lanes, ["build", "headlessArtifact", "headlessSmoke"]) + for lane_set in (package_lanes, install_lanes, status_lanes, smoke_lanes): + self.assertNotIn("liveApp", lane_set) + self.assertEqual(package_timeout, conductor.MEDIUM_TIMEOUT_SECONDS) + + def test_swift_build_accepts_headless_product_choice(self) -> None: + tmp, state = self.make_state() + self.addCleanup(tmp.cleanup) + with mock.patch.object(conductor, "enqueue_and_maybe_wait", return_value=0) as enqueue: + code = conductor.handle_real_operation(state.paths, "swift-build", ["--product", "repoprompt-headless"]) + + self.assertEqual(code, 0) + self.assertEqual(enqueue.call_args.args[2], {"product": "repoprompt-headless"}) + + def test_headless_smoke_cli_accepts_configuration(self) -> None: + tmp, state = self.make_state() + self.addCleanup(tmp.cleanup) + with mock.patch.object(conductor, "enqueue_and_maybe_wait", return_value=0) as enqueue: + code = conductor.handle_real_operation(state.paths, "headless-smoke", ["--configuration", "release"]) + + self.assertEqual(code, 0) + self.assertEqual(enqueue.call_args.args[2], {"config": "release"}) + def test_app_stop_supersedes_queued_live_app_but_not_build_only_work(self) -> None: tmp, state = self.make_state() self.addCleanup(tmp.cleanup) @@ -435,7 +496,9 @@ def test_superseded_job_without_pid_is_signaled_after_delayed_assignment_then_es with mock.patch.object(state, "_terminate_process_group_locked") as terminate, mock.patch.object( state, "_kill_process_group_locked" - ) as kill, mock.patch.object(state, "_schedule_locked"), mock.patch.object( + ) as kill, mock.patch.object( + state, "_process_tree_alive_locked", side_effect=[True, True, False] + ), mock.patch.object(state, "_schedule_locked"), mock.patch.object( conductor, "TERMINATE_GRACE_SECONDS", 0.01 ), mock.patch.object(conductor.threading, "Thread") as thread_factory: state.enqueue({"operation": "app", "args": {"subcommand": "stop"}}) @@ -550,6 +613,136 @@ def test_queued_payload_identifies_active_lane_blocker(self) -> None: self.assertEqual(payload["blockedBy"][0]["conflictingLanes"], ["build"]) +class ProcessTreeTerminationTests(LifecycleTestCase): + def test_process_group_signal_is_supplemented_by_identity_checked_descendants(self) -> None: + tmp, state = self.make_state() + self.addCleanup(tmp.cleanup) + job = self.make_job(state, "tree", "sleep", {}, ["build"], "running") + job.process_pid = 100 + job.process_pgid = 100 + job.process_identity = "root-start" + snapshot = { + 100: (1, "root-start"), + 200: (100, "child-start"), + 201: (100, "reused-start"), + 300: (1, "unrelated-start"), + } + events: list[str] = [] + + def snapshot_processes() -> conductor.ProcessSnapshot: + events.append("snapshot") + return snapshot + + def signal_group(pgid: int, process_signal: signal.Signals) -> None: + events.append(f"group:{pgid}:{process_signal.value}") + + def current_identity(pid: int) -> str | None: + events.append(f"identity:{pid}") + return {200: "child-start", 201: "replacement-start"}.get(pid) + + with mock.patch.object(conductor, "process_snapshot", side_effect=snapshot_processes), mock.patch.object( + conductor.os, "killpg", side_effect=signal_group + ), mock.patch.object(conductor, "process_start_token", side_effect=current_identity), mock.patch.object( + conductor.os, "kill" + ) as signal_pid: + state._signal_process_tree_locked(job, signal.SIGTERM) + + self.assertEqual(events[0], "snapshot") + self.assertEqual(events[1], f"group:100:{signal.SIGTERM.value}") + signal_pid.assert_called_once_with(200, signal.SIGTERM) + + @staticmethod + def tree_command(pid_path: Path) -> list[str]: + code = ( + "import subprocess,sys,time\n" + "child=subprocess.Popen([sys.executable,'-c','import time; time.sleep(60)']," + "start_new_session=True,stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL)\n" + "with open(sys.argv[1],'w',encoding='utf-8') as handle:\n" + " handle.write(str(child.pid))\n" + "while True: time.sleep(1)\n" + ) + return [sys.executable, "-u", "-c", code, str(pid_path)] + + @staticmethod + def wait_for_child_pid(pid_path: Path) -> int: + deadline = time.time() + 5.0 + while time.time() < deadline: + try: + return int(pid_path.read_text(encoding="utf-8")) + except (FileNotFoundError, ValueError): + time.sleep(0.02) + raise AssertionError("separate-process-group child PID was not written") + + @staticmethod + def wait_for_pid_exit(pid: int) -> None: + deadline = time.time() + 5.0 + while time.time() < deadline: + if not conductor.pid_alive(pid): + return + time.sleep(0.02) + raise AssertionError(f"descendant process {pid} survived conductor termination") + + @staticmethod + def stop_process(process: subprocess.Popen[bytes]) -> None: + if process.poll() is not None: + return + process.terminate() + try: + process.wait(timeout=1.0) + except subprocess.TimeoutExpired: + process.kill() + process.wait(timeout=1.0) + + @staticmethod + def kill_pid(pid: int, identity: str | None) -> None: + if identity is None or conductor.process_start_token(pid) != identity: + return + with contextlib.suppress(ProcessLookupError): + os.kill(pid, 9) + + def run_separate_group_job(self, *, cancel: bool) -> None: + tmp, state = self.make_state() + self.addCleanup(tmp.cleanup) + child_pid_path = state.paths.state_dir / "separate-group-child.pid" + unrelated = subprocess.Popen( + [sys.executable, "-c", "import time; time.sleep(60)"], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + start_new_session=True, + ) + self.addCleanup(self.stop_process, unrelated) + timeout = 30.0 if cancel else 0.2 + prepared = ( + self.tree_command(child_pid_path), + ["build"], + state.paths.repo_root, + os.environ.copy(), + timeout, + ) + + with mock.patch.object(state.registry, "prepare", return_value=prepared): + payload = state.enqueue({"operation": "sleep", "args": {"seconds": 60}, "timeout": timeout}) + child_pid = self.wait_for_child_pid(child_pid_path) + child_identity = conductor.process_start_token(child_pid) + self.addCleanup(self.kill_pid, child_pid, child_identity) + self.assertEqual(os.getpgid(child_pid), child_pid) + self.assertNotEqual(os.getpgid(child_pid), state.jobs[payload["ticket"]].process_pgid) + if cancel: + state.job_cancel(payload["ticket"], None) + result = state.job_wait(payload["ticket"], None, 10.0) + + self.assertEqual(result["state"], "canceled" if cancel else "failed") + self.assertEqual(result["exitCode"], 130 if cancel else 124) + self.wait_for_pid_exit(child_pid) + self.assertIsNone(unrelated.poll()) + + def test_cancellation_terminates_descendant_in_separate_process_group(self) -> None: + self.run_separate_group_job(cancel=True) + + def test_timeout_terminates_descendant_in_separate_process_group(self) -> None: + self.run_separate_group_job(cancel=False) + + class SmokeOperationTests(unittest.TestCase): def test_manage_worktree_list_stage_runs_after_tree_roots_before_agent_manage(self) -> None: calls: list[tuple[str, list[str]]] = [] diff --git a/Scripts/test_core_boundary_guardrails.py b/Scripts/test_core_boundary_guardrails.py new file mode 100644 index 000000000..4d41b483d --- /dev/null +++ b/Scripts/test_core_boundary_guardrails.py @@ -0,0 +1,128 @@ +#!/usr/bin/env python3 +"""Focused behavioral coverage for the Core/Shared boundary guardrail.""" + +from __future__ import annotations + +import shutil +import subprocess +import tempfile +import unittest +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] +GUARDRAIL = ROOT / "Scripts/core_boundary_guardrails.sh" + + +class CoreBoundaryGuardrailTests(unittest.TestCase): + def setUp(self) -> None: + self.temp_dir = tempfile.TemporaryDirectory() + self.addCleanup(self.temp_dir.cleanup) + self.root = Path(self.temp_dir.name) + + scripts = self.root / "Scripts" + scripts.mkdir() + shutil.copy2(GUARDRAIL, scripts / GUARDRAIL.name) + (scripts / "package_app.sh").write_text("#!/usr/bin/env bash\n", encoding="utf-8") + + core_root = self.root / "Sources/RepoPromptCore" + core_root.mkdir(parents=True) + (core_root / "Core.swift").write_text("import Foundation\n", encoding="utf-8") + native_importers = { + "RepoPromptC": ( + "FileSystem/GitignoreCompiler.swift", + "Utilities/StringFNV.swift", + "Utilities/StringLineEndingUtilities.swift", + "WorkspaceContext/Search/PathSearchIndex.swift", + "WorkspaceContext/Search/RepoSearchBatchScorer.swift", + "WorkspaceContext/Search/SearchMatch.swift", + "WorkspaceContext/Search/SearchPathFiltering.swift", + ), + "CSwiftPCRE2": ( + "Regex/PCRE2Error.swift", + "Regex/PCRE2JIT.swift", + "Regex/PCRE2Options.swift", + "Regex/PCRE2Regex.swift", + ), + "RepoPromptSyntaxCBridge": ("SyntaxParsing/SyntaxManager.swift",), + "SwiftTreeSitter": ( + "CodeMap/CodeMapCaptureIndex.swift", + "CodeMap/CodeMapGenerator.swift", + "CodeMap/LanguageStrategies/SwiftCodeMapStrategy.swift", + "CodeMap/LanguageStrategies/TypeScriptCodeMapStrategy.swift", + "SyntaxParsing/SyntaxManager.swift", + ), + "Cuchardet": ("FileSystem/FileSystemService+ContentLoading.swift",), + "UniversalCharsetDetection": ("FileSystem/FileSystemService+ContentLoading.swift",), + } + source_imports: dict[str, list[str]] = {} + for module, paths in native_importers.items(): + for path in paths: + source_imports.setdefault(path, []).append(module) + for path, modules in source_imports.items(): + source = core_root / path + source.parent.mkdir(parents=True, exist_ok=True) + source.write_text("".join(f"import {module}\n" for module in modules), encoding="utf-8") + + (self.root / "Sources/RepoPromptCoreMacOS").mkdir(parents=True) + (self.root / "Sources/RepoPromptPOSIXSupport/Descriptors").mkdir(parents=True) + (self.root / "Sources/RepoPromptPOSIXSupport/Descriptors/POSIXDescriptorSupport.swift").write_text( + "import Foundation\n", + encoding="utf-8", + ) + (self.root / "Sources/RepoPromptSyntaxCBridge").mkdir(parents=True) + self.shared_mcp = self.root / "Sources/RepoPromptShared/MCP" + self.shared_mcp.mkdir(parents=True) + (self.shared_mcp / "JSONRPCBridgeLedger.swift").write_text( + "import CryptoKit\nimport Foundation\n", + encoding="utf-8", + ) + (self.shared_mcp / "Other.swift").write_text("import Foundation\n", encoding="utf-8") + + def run_guardrail(self) -> subprocess.CompletedProcess[str]: + return subprocess.run( + ["bash", "Scripts/core_boundary_guardrails.sh"], + cwd=self.root, + text=True, + capture_output=True, + check=False, + ) + + def test_allows_cryptokit_only_in_jsonrpc_bridge_ledger(self) -> None: + result = self.run_guardrail() + + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + + def test_rejects_cryptokit_in_other_shared_file(self) -> None: + (self.shared_mcp / "Other.swift").write_text("import CryptoKit\n", encoding="utf-8") + + result = self.run_guardrail() + + self.assertNotEqual(result.returncode, 0) + self.assertIn("Other.swift:1:import CryptoKit", result.stderr) + + def test_rejects_all_darwin_and_posix_imports_in_shared(self) -> None: + cases = ( + ("Other.swift", "Darwin"), + ("JSONRPCBridgeLedger.swift", "Darwin"), + ("Other.swift", "Glibc"), + ("Other.swift", "SystemPackage"), + ("Other.swift", "RepoPromptPOSIXSupport"), + ) + for filename, module in cases: + with self.subTest(filename=filename, module=module): + (self.shared_mcp / "JSONRPCBridgeLedger.swift").write_text( + "import CryptoKit\nimport Foundation\n", + encoding="utf-8", + ) + (self.shared_mcp / "Other.swift").write_text("import Foundation\n", encoding="utf-8") + (self.shared_mcp / filename).write_text(f"import {module}\n", encoding="utf-8") + + result = self.run_guardrail() + + self.assertNotEqual(result.returncode, 0) + self.assertIn(f"{filename}:1:import {module}", result.stderr) + + +if __name__ == "__main__": + unittest.main() diff --git a/Scripts/test_release_tooling.py b/Scripts/test_release_tooling.py index 2673cdfc1..c4e2193c9 100644 --- a/Scripts/test_release_tooling.py +++ b/Scripts/test_release_tooling.py @@ -995,7 +995,261 @@ def test_embedded_mcp_helper_layout_validator_accepts_canonical_layout(self) -> self.assertEqual(result.returncode, 0, result.stderr) self.assertIn("matches the embedded MCP helper layout policy", result.stdout) + def test_app_packaging_keeps_headless_host_outside_embedded_proxy_layout(self) -> None: + package_script = (SCRIPT_DIR / "package_app.sh").read_text(encoding="utf-8") + + self.assertIn('for exe in "$APP_NAME" repoprompt-mcp; do', package_script) + self.assertNotIn("repoprompt-headless", package_script) + self.assertNotIn("rpce-headless", package_script) + + def test_headless_package_install_and_smoke_scripts_are_independent_from_app_proxy(self) -> None: + package_script = (SCRIPT_DIR / "package_headless.sh").read_text(encoding="utf-8") + install_script = (SCRIPT_DIR / "install_headless_cli.sh").read_text(encoding="utf-8") + smoke_script = (SCRIPT_DIR / "smoke_headless_mcp.sh").read_text(encoding="utf-8") + + self.assertIn('BINARY_NAME="repoprompt-headless"', package_script) + self.assertIn("HeadlessTools", package_script) + self.assertIn('swift build -c "$CONF" --product "$BINARY_NAME"', package_script) + candidate_validation = package_script.index('"$tmp_binary" --version') + candidate_install = package_script.index('mv -f "$tmp_binary" "$TARGET_BINARY"') + self.assertLess(candidate_validation, candidate_install) + self.assertIn('Created: %s\\n', package_script) + self.assertNotIn("RepoPrompt.app", package_script) + self.assertNotIn("repoprompt-mcp", package_script) + self.assertNotIn("rpce-cli", package_script) + + self.assertIn("rpce-headless-debug", install_script) + self.assertIn("rpce-headless", install_script) + self.assertIn("repoprompt_headless_debug", install_script) + self.assertIn("repoprompt_headless", install_script) + self.assertIn("package_headless.sh", install_script) + self.assertNotIn("RepoPrompt.app", install_script) + self.assertNotIn("repoprompt-mcp", install_script) + self.assertNotIn("rpce-cli", install_script) + + self.assertIn("tools/list", smoke_script) + self.assertIn("read_file", smoke_script) + self.assertIn("file_search", smoke_script) + self.assertIn("export_outside_state_directory is false", smoke_script) + self.assertIn("shutdown", smoke_script) + self.assertNotIn("rpce-cli-debug", smoke_script) + self.assertNotIn("repoprompt-mcp", smoke_script) + + def test_headless_package_preserves_staged_binary_when_candidate_validation_fails(self) -> None: + temp_dir = Path(tempfile.mkdtemp()) + self.addCleanup(shutil.rmtree, temp_dir, True) + source_root = temp_dir / "source" + tools_root = temp_dir / "tools" + build_dir = temp_dir / "build" + fake_bin = temp_dir / "fake-bin" + source_root.mkdir() + build_dir.mkdir() + fake_bin.mkdir() + + staged_binary = tools_root / "Debug" / "repoprompt-headless" + staged_binary.parent.mkdir(parents=True) + known_good = "#!/usr/bin/env bash\necho 'known-good 1.0.0'\n" + staged_binary.write_text(known_good, encoding="utf-8") + staged_binary.chmod(0o755) + + candidate = build_dir / "repoprompt-headless" + candidate.write_text("#!/usr/bin/env bash\nexit 42\n", encoding="utf-8") + candidate.chmod(0o755) + fake_swift = fake_bin / "swift" + fake_swift.write_text( + "#!/usr/bin/env bash\n" + "if [[ \" $* \" == *\" --show-bin-path \"* ]]; then\n" + " printf '%s\\n' \"$FAKE_SWIFT_BUILD_DIR\"\n" + "fi\n", + encoding="utf-8", + ) + fake_swift.chmod(0o755) + + env = os.environ.copy() + env.update( + { + "PATH": f"{fake_bin}:{env['PATH']}", + "FAKE_SWIFT_BUILD_DIR": str(build_dir), + "REPOPROMPT_RELEASE_SOURCE_ROOT": str(source_root), + "REPOPROMPT_CONTROL_PLANE_SCRIPTS_DIR": str(SCRIPT_DIR), + "REPOPROMPT_HEADLESS_TOOLS_ROOT": str(tools_root), + } + ) + + result = subprocess.run( + [str(SCRIPT_DIR / "package_headless.sh"), "debug"], + env=env, + text=True, + capture_output=True, + ) + + self.assertNotEqual(result.returncode, 0) + self.assertEqual(staged_binary.read_text(encoding="utf-8"), known_good) + self.assertEqual(list(staged_binary.parent.glob(".repoprompt-headless.tmp.*")), []) + + def test_headless_install_script_manages_debug_link_without_app_bundle(self) -> None: + temp_dir = Path(tempfile.mkdtemp()) + self.addCleanup(shutil.rmtree, temp_dir, True) + home = temp_dir / "home" + tools_root = temp_dir / "tools" + bin_dir = temp_dir / "bin" + bin_dir.mkdir(parents=True) + binary = tools_root / "Debug" / "repoprompt-headless" + binary.parent.mkdir(parents=True) + binary.write_text("#!/usr/bin/env bash\necho 'repoprompt-headless 1.0.0'\n", encoding="utf-8") + binary.chmod(0o755) + path_link = bin_dir / "rpce-headless-debug" + user_link = home / "Library" / "Application Support" / "RepoPrompt CE" / "repoprompt_headless_debug" + env = os.environ.copy() + env.update( + { + "HOME": str(home), + "REPOPROMPT_HEADLESS_TOOLS_ROOT": str(tools_root), + "REPOPROMPT_HEADLESS_DEBUG_INSTALL_PATH": str(path_link), + } + ) + + installed = subprocess.run( + [str(SCRIPT_DIR / "install_headless_cli.sh"), "install", "--configuration", "debug"], + env=env, + text=True, + capture_output=True, + ) + self.assertEqual(installed.returncode, 0, installed.stderr) + self.assertIn("Installed:", installed.stdout) + self.assertEqual(os.readlink(path_link), str(user_link)) + self.assertEqual(os.readlink(user_link), str(binary)) + + status = subprocess.run( + [str(SCRIPT_DIR / "install_headless_cli.sh"), "status", "--configuration", "debug"], + env=env, + text=True, + capture_output=True, + ) + self.assertEqual(status.returncode, 0, status.stderr) + self.assertIn("PATH command: OK", status.stdout) + self.assertIn("repoprompt-headless 1.0.0", status.stdout) + + uninstalled = subprocess.run( + [str(SCRIPT_DIR / "install_headless_cli.sh"), "uninstall", "--configuration", "debug"], + env=env, + text=True, + capture_output=True, + ) + self.assertEqual(uninstalled.returncode, 0, uninstalled.stderr) + self.assertFalse(os.path.lexists(path_link)) + self.assertFalse(os.path.lexists(user_link)) + self.assertNotIn("RepoPrompt.app", installed.stdout + status.stdout + uninstalled.stdout) + + def test_headless_uninstall_removes_release_links_without_touching_debug_links(self) -> None: + temp_dir = Path(tempfile.mkdtemp()) + self.addCleanup(shutil.rmtree, temp_dir, True) + home = temp_dir / "home" + tools_root = temp_dir / "tools" + bin_dir = temp_dir / "bin" + support_root = home / "Library" / "Application Support" / "RepoPrompt CE" + bin_dir.mkdir(parents=True) + support_root.mkdir(parents=True) + + debug_binary = tools_root / "Debug" / "repoprompt-headless" + release_binary = tools_root / "Release" / "repoprompt-headless" + for binary in (debug_binary, release_binary): + binary.parent.mkdir(parents=True) + binary.write_text("#!/usr/bin/env bash\necho fixture\n", encoding="utf-8") + binary.chmod(0o755) + + debug_user_link = support_root / "repoprompt_headless_debug" + release_user_link = support_root / "repoprompt_headless" + debug_path_link = bin_dir / "rpce-headless-debug" + release_path_link = bin_dir / "rpce-headless" + debug_user_link.symlink_to(debug_binary) + release_user_link.symlink_to(release_binary) + debug_path_link.symlink_to(debug_user_link) + release_path_link.symlink_to(release_user_link) + env = os.environ.copy() + env.update( + { + "HOME": str(home), + "REPOPROMPT_HEADLESS_TOOLS_ROOT": str(tools_root), + "REPOPROMPT_HEADLESS_DEBUG_INSTALL_PATH": str(debug_path_link), + "REPOPROMPT_HEADLESS_INSTALL_PATH": str(release_path_link), + } + ) + + result = subprocess.run( + [str(SCRIPT_DIR / "install_headless_cli.sh"), "uninstall", "--configuration", "release"], + env=env, + text=True, + capture_output=True, + ) + + self.assertEqual(result.returncode, 0, result.stderr) + self.assertFalse(os.path.lexists(release_path_link)) + self.assertFalse(os.path.lexists(release_user_link)) + self.assertEqual(os.readlink(debug_path_link), str(debug_user_link)) + self.assertEqual(os.readlink(debug_user_link), str(debug_binary)) + + def test_headless_uninstall_preserves_unmanaged_paths_while_removing_managed_links(self) -> None: + temp_dir = Path(tempfile.mkdtemp()) + self.addCleanup(shutil.rmtree, temp_dir, True) + home = temp_dir / "home" + tools_root = temp_dir / "tools" + bin_dir = temp_dir / "bin" + support_root = home / "Library" / "Application Support" / "RepoPrompt CE" + bin_dir.mkdir(parents=True) + support_root.mkdir(parents=True) + binary = tools_root / "Debug" / "repoprompt-headless" + binary.parent.mkdir(parents=True) + binary.write_text("#!/usr/bin/env bash\necho fixture\n", encoding="utf-8") + binary.chmod(0o755) + path_link = bin_dir / "rpce-headless-debug" + user_link = support_root / "repoprompt_headless_debug" + env = os.environ.copy() + env.update( + { + "HOME": str(home), + "REPOPROMPT_HEADLESS_TOOLS_ROOT": str(tools_root), + "REPOPROMPT_HEADLESS_DEBUG_INSTALL_PATH": str(path_link), + } + ) + + path_link.write_text("unmanaged path file\n", encoding="utf-8") + user_link.symlink_to(binary) + unmanaged_path_result = subprocess.run( + [str(SCRIPT_DIR / "install_headless_cli.sh"), "uninstall", "--configuration", "debug"], + env=env, + text=True, + capture_output=True, + ) + self.assertNotEqual(unmanaged_path_result.returncode, 0) + self.assertEqual(path_link.read_text(encoding="utf-8"), "unmanaged path file\n") + self.assertFalse(os.path.lexists(user_link)) + + path_link.unlink() + unrelated_target = temp_dir / "unrelated" + unrelated_target.write_text("#!/usr/bin/env bash\necho unrelated\n", encoding="utf-8") + unrelated_target.chmod(0o755) + user_link.symlink_to(unrelated_target) + path_link.symlink_to(user_link) + unmanaged_user_result = subprocess.run( + [str(SCRIPT_DIR / "install_headless_cli.sh"), "uninstall", "--configuration", "debug"], + env=env, + text=True, + capture_output=True, + ) + self.assertNotEqual(unmanaged_user_result.returncode, 0) + self.assertFalse(os.path.lexists(path_link)) + self.assertEqual(os.readlink(user_link), str(unrelated_target)) + def test_embedded_mcp_helper_layout_validator_rejects_invalid_metadata(self) -> None: + def missing_helper(app: Path) -> None: + (app / "Contents" / "MacOS" / "repoprompt-mcp").unlink() + + def helper_directory(app: Path) -> None: + helper = app / "Contents" / "MacOS" / "repoprompt-mcp" + helper.unlink() + helper.mkdir() + def helper_symlink(app: Path) -> None: helper = app / "Contents" / "MacOS" / "repoprompt-mcp" helper.unlink() @@ -1010,17 +1264,37 @@ def missing_resources_link(app: Path) -> None: def missing_bin_link(app: Path) -> None: (app / "Contents" / "Resources" / "bin" / "repoprompt-mcp").unlink() - def alternate_in_app_target(app: Path) -> None: + def resources_regular_file(app: Path) -> None: + link = app / "Contents" / "Resources" / "repoprompt-mcp" + link.unlink() + link.write_text("not a symlink\n", encoding="utf-8") + + def bin_regular_file(app: Path) -> None: + link = app / "Contents" / "Resources" / "bin" / "repoprompt-mcp" + link.unlink() + link.write_text("not a symlink\n", encoding="utf-8") + + def alternate_resources_target(app: Path) -> None: link = app / "Contents" / "Resources" / "repoprompt-mcp" link.unlink() link.symlink_to("../MacOS/RepoPrompt") + def alternate_bin_target(app: Path) -> None: + link = app / "Contents" / "Resources" / "bin" / "repoprompt-mcp" + link.unlink() + link.symlink_to("../../MacOS/RepoPrompt") + for label, mutate in ( + ("missing helper", missing_helper), + ("helper directory", helper_directory), ("helper symlink", helper_symlink), ("non-executable helper", non_executable_helper), ("missing resources link", missing_resources_link), ("missing bin link", missing_bin_link), - ("alternate in-app target", alternate_in_app_target), + ("resources regular file", resources_regular_file), + ("bin regular file", bin_regular_file), + ("alternate resources target", alternate_resources_target), + ("alternate bin target", alternate_bin_target), ): with self.subTest(label=label): app = self.make_embedded_helper_layout() @@ -1553,6 +1827,17 @@ def test_mcp_cli_version_sync_updates_source_and_check_detects_drift(self) -> No source = root / "Sources" / "RepoPromptMCP" / "main.swift" source.parent.mkdir(parents=True) source.write_text('let CLI_VERSION = "9.9.9"\n', encoding="utf-8") + headless_source = root / "Sources" / "RepoPromptHeadless" / "HeadlessVersion.swift" + headless_source.parent.mkdir(parents=True) + headless_source.write_text( + """\ +enum HeadlessVersion { + static let marketingVersion = "9.9.9" + static let buildNumber = "999" +} +""", + encoding="utf-8", + ) env = os.environ.copy() env["REPOPROMPT_RELEASE_SOURCE_ROOT"] = str(root) helper = SCRIPT_DIR / "sync_mcp_cli_version.sh" @@ -1565,11 +1850,23 @@ def test_mcp_cli_version_sync_updates_source_and_check_detects_drift(self) -> No self.assertIn("Run ./Scripts/release.sh sync-cli-version", rejected.stderr) self.assertEqual(synced.returncode, 0, synced.stderr) self.assertEqual(source.read_text(encoding="utf-8"), 'let CLI_VERSION = "1.0.0"\n') + self.assertEqual( + headless_source.read_text(encoding="utf-8"), + """\ +enum HeadlessVersion { + static let marketingVersion = "1.0.0" + static let buildNumber = "1" +} +""", + ) self.assertEqual(accepted.returncode, 0, accepted.stderr) def test_release_preflight_requires_synchronized_mcp_cli_version(self) -> None: release_script = (SCRIPT_DIR / "release.sh").read_text(encoding="utf-8") + self.assertIn('require_file "$CONTROL_PLANE_SCRIPTS_DIR/package_headless.sh"', release_script) + self.assertIn('require_file "$CONTROL_PLANE_SCRIPTS_DIR/install_headless_cli.sh"', release_script) + self.assertIn('require_file "$CONTROL_PLANE_SCRIPTS_DIR/smoke_headless_mcp.sh"', release_script) self.assertIn('require_file "$CONTROL_PLANE_SCRIPTS_DIR/sync_mcp_cli_version.sh"', release_script) self.assertIn('"$CONTROL_PLANE_SCRIPTS_DIR/sync_mcp_cli_version.sh" --check', release_script) self.assertIn("sync-cli-version) sync_mcp_cli_version", release_script) diff --git a/Scripts/test_shared_runtime_headless_baseline.py b/Scripts/test_shared_runtime_headless_baseline.py new file mode 100644 index 000000000..b7389ba3f --- /dev/null +++ b/Scripts/test_shared_runtime_headless_baseline.py @@ -0,0 +1,93 @@ +#!/usr/bin/env python3 +"""Focused behavioral coverage for the reviewed headless baseline guardrail.""" + +from __future__ import annotations + +import tempfile +import unittest +from pathlib import Path + +from shared_runtime_headless_baseline import ( + DEFAULT_MANIFEST, + HEADLESS_ROOTS, + ROOT, + ReviewedHeadlessBaselineError, + render_manifest, + verify_reviewed_headless_baseline, + write_reviewed_headless_baseline, +) + + +class ReviewedHeadlessBaselineTests(unittest.TestCase): + def setUp(self) -> None: + self.temp_dir = tempfile.TemporaryDirectory() + self.addCleanup(self.temp_dir.cleanup) + self.root = Path(self.temp_dir.name) + self.source_file = self.root / HEADLESS_ROOTS[0] / "Configuration/State.swift" + self.test_file = self.root / HEADLESS_ROOTS[1] / "StateTests.swift" + self.source_file.parent.mkdir(parents=True) + self.test_file.parent.mkdir(parents=True) + self.source_file.write_text("source\n", encoding="utf-8") + self.test_file.write_text("tests\n", encoding="utf-8") + self.manifest = self.root / "baseline.sha256" + write_reviewed_headless_baseline(self.root, self.manifest) + + def test_repository_manifest_matches_complete_reviewed_headless_trees(self) -> None: + verify_reviewed_headless_baseline(ROOT, DEFAULT_MANIFEST) + + def test_manifest_generation_is_deterministic_and_sorted(self) -> None: + first = self.manifest.read_text(encoding="utf-8") + write_reviewed_headless_baseline(self.root, self.manifest) + second = self.manifest.read_text(encoding="utf-8") + + self.assertEqual(first, second) + data_lines = [line for line in second.splitlines() if line and not line.startswith("#")] + paths = [line.split(" ", 1)[1] for line in data_lines] + self.assertEqual(paths, sorted(paths)) + + def test_round_trips_a_path_with_trailing_whitespace(self) -> None: + trailing = self.source_file.parent / "Trailing.swift " + trailing.write_text("trailing\n", encoding="utf-8") + write_reviewed_headless_baseline(self.root, self.manifest) + + verify_reviewed_headless_baseline(self.root, self.manifest) + self.assertIn("Trailing.swift ", self.manifest.read_text(encoding="utf-8")) + + def test_rejects_a_path_with_a_newline(self) -> None: + newline = self.source_file.parent / "Newline\n.swift" + newline.write_text("newline\n", encoding="utf-8") + + with self.assertRaisesRegex(ReviewedHeadlessBaselineError, "cannot encode a newline"): + write_reviewed_headless_baseline(self.root, self.manifest) + + def test_rejects_content_drift(self) -> None: + self.source_file.write_text("changed\n", encoding="utf-8") + + with self.assertRaisesRegex(ReviewedHeadlessBaselineError, "content drifted"): + verify_reviewed_headless_baseline(self.root, self.manifest) + + def test_rejects_added_file(self) -> None: + added = self.source_file.parent / "Added.swift" + added.write_text("added\n", encoding="utf-8") + + with self.assertRaisesRegex(ReviewedHeadlessBaselineError, "path set drifted"): + verify_reviewed_headless_baseline(self.root, self.manifest) + + def test_rejects_removed_file(self) -> None: + self.test_file.unlink() + + with self.assertRaisesRegex(ReviewedHeadlessBaselineError, "path set drifted"): + verify_reviewed_headless_baseline(self.root, self.manifest) + + def test_rejects_manifest_entries_outside_the_locked_trees(self) -> None: + self.manifest.write_text( + render_manifest({"Sources/RepoPromptCore/Unexpected.swift": "0" * 64}), + encoding="utf-8", + ) + + with self.assertRaisesRegex(ReviewedHeadlessBaselineError, "outside the locked trees"): + verify_reviewed_headless_baseline(self.root, self.manifest) + + +if __name__ == "__main__": + unittest.main() diff --git a/Scripts/test_shared_runtime_phase0_characterization.py b/Scripts/test_shared_runtime_phase0_characterization.py new file mode 100644 index 000000000..9247fb463 --- /dev/null +++ b/Scripts/test_shared_runtime_phase0_characterization.py @@ -0,0 +1,139 @@ +#!/usr/bin/env python3 +"""Validate Phase 0 frozen baselines, characterization coverage, and package separation.""" + +from __future__ import annotations + +import json +import subprocess +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] +PHASE0 = ROOT / "Tests/SharedRuntimeConvergenceFixtures/Phase0" +TOOLS = [ + "bind_context", + "manage_workspaces", + "manage_selection", + "workspace_context", + "get_file_tree", + "get_code_structure", + "read_file", + "file_search", + "prompt", +] +APP_TOOL_ORDER = [ + "bind_context", + "manage_workspaces", + "manage_selection", + "get_code_structure", + "get_file_tree", + "read_file", + "file_search", + "workspace_context", + "prompt", +] +BASELINES = { + "packaging": "2b350916d52809dd036331a746d888132019ce75", + "app_mcp": "042a500b03b39d04237ec5544811696cf6b2f2f9", + "headless": "487cd71d892dbc3104689cc42fdb39f6c038e8fb", +} +ALLOWED_DIFFERENCES = { + "initialize and product/profile metadata", + "profile and state-root paths", + "unsupported capability omissions", + "standalone initialization and configuration instructions", +} + + +def load_json(path: Path): + with path.open(encoding="utf-8") as handle: + return json.load(handle) + + +def git(*args: str) -> str: + return subprocess.check_output( + ["git", *args], cwd=ROOT, text=True, stderr=subprocess.STDOUT + ).strip() + + +def assert_tool_records(records, label: str) -> None: + names = [record["tool"] for record in records] + assert names == TOOLS, f"{label} must cover the exact ordered nine-tool overlap: {names}" + + +def validate_characterization(path: Path, runtime: str) -> None: + snapshot = load_json(path) + assert snapshot["format_version"] == 1 + assert snapshot["runtime"] == runtime + expected_order = APP_TOOL_ORDER if runtime == "app-v1" else TOOLS + assert snapshot["tool_order"] == expected_order + descriptor_names = [descriptor["name"] for descriptor in snapshot["descriptors"]] + assert descriptor_names == expected_order, f"{runtime} descriptor order drifted: {descriptor_names}" + if runtime == "app-v1": + assert [record["tool"] for record in snapshot["normalized_arguments"]] == TOOLS + assert [record["tool"] for record in snapshot["responses"]] == APP_TOOL_ORDER + else: + assert_tool_records(snapshot["argument_coercion"], "headless argument coercion") + initialize = snapshot["initialize"] + assert initialize["headless"]["stateDirectory"] == "$STATE" + assert initialize["headless"]["safeToolsEnabled"] is True + assert_tool_records(snapshot["responses"], f"{runtime} responses") + + +def main() -> None: + manifest = load_json(PHASE0 / "manifest.json") + assert manifest["branch"] == "core_split" + assert manifest["freeze_head"] == BASELINES["headless"] + assert manifest["baselines"] == BASELINES + assert manifest["overlapping_tools"] == TOOLS + assert set(manifest["allowed_product_differences"]) == ALLOWED_DIFFERENCES + assert manifest["phase_1_or_later_blockers"], "Phase 1 blockers must remain explicit" + + ledger = load_json(PHASE0 / "differential-ledger.json") + assert set(ledger["allowed_product_differences"]) == ALLOWED_DIFFERENCES + assert [entry["name"] for entry in ledger["tools"]] == TOOLS + for entry in ledger["tools"]: + assert entry["descriptor"] == "phase_1_blocker" + assert entry["arguments"] == "phase_1_blocker" + assert entry["structured_text_response"] == "phase_1_blocker" + assert entry["allowed"] == [] + + for commit in BASELINES.values(): + assert git("cat-file", "-t", commit) == "commit" + assert git("rev-parse", f"{BASELINES['app_mcp']}^") == BASELINES["packaging"] + assert git("rev-parse", f"{BASELINES['headless']}^") == BASELINES["app_mcp"] + subprocess.check_call( + ["git", "merge-base", "--is-ancestor", BASELINES["headless"], "HEAD"], cwd=ROOT + ) + + validate_characterization(PHASE0 / "App/app-characterization.json", "app-v1") + validate_characterization(PHASE0 / "Headless/headless-characterization.json", "headless-v1") + + app_workspace = PHASE0 / ( + "App/WorkspaceV1/Workspace-Phase 0 App V1-AAAAAAAA-AAAA-AAAA-AAAA-AAAAAAAAAAAA/workspace.json" + ) + headless_workspace = PHASE0 / "Headless/ProfileV1/Workspaces/22222222-2222-2222-2222-222222222222.json" + assert app_workspace.is_file() + assert headless_workspace.is_file() + assert load_json(app_workspace)["schemaVersion"] == 1 + assert load_json(headless_workspace)["schema_version"] == 1 + + package_app = (ROOT / "Scripts/package_app.sh").read_text(encoding="utf-8") + package_headless = (ROOT / "Scripts/package_headless.sh").read_text(encoding="utf-8") + smoke_headless = (ROOT / "Scripts/smoke_headless_mcp.sh").read_text(encoding="utf-8") + assert "repoprompt-mcp" in package_app + assert "repoprompt-headless" not in package_app + assert "rpce-headless" not in package_app + assert "repoprompt-headless" in package_headless + assert "repoprompt-mcp" not in package_headless + assert "RepoPrompt.app" not in package_headless + assert " serve" in smoke_headless or "serve\n" in smoke_headless + assert "without launching RepoPrompt.app" in smoke_headless + assert "package_app.sh" not in smoke_headless + assert "open -a" not in smoke_headless + + print("shared runtime Phase 0 characterization: ok") + + +if __name__ == "__main__": + main() diff --git a/Scripts/test_shared_runtime_phase1_boundaries.py b/Scripts/test_shared_runtime_phase1_boundaries.py new file mode 100755 index 000000000..f9814d693 --- /dev/null +++ b/Scripts/test_shared_runtime_phase1_boundaries.py @@ -0,0 +1,196 @@ +#!/usr/bin/env python3 +"""Phase 1 dependency-boundary and frozen-fixture characterization checks.""" + +from __future__ import annotations + +import json +import re +import subprocess +import sys +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] +PHASE0_ARTIFACT_BASELINE = "48a335e" +PHASE0_PREFIX = "Tests/SharedRuntimeConvergenceFixtures/Phase0/" +FROZEN_FILES = [ + "docs/characterization/shared-runtime-phase0-2026-06-05.md", + "Scripts/test_shared_runtime_phase0_characterization.py", +] + + +def fail(message: str) -> None: + raise AssertionError(message) + + +def git_bytes(revision: str, path: str) -> bytes: + return subprocess.check_output(["git", "show", f"{revision}:{path}"], cwd=ROOT) + + +def by_name_dependencies(target: dict[str, object]) -> set[str]: + names: set[str] = set() + for dependency in target.get("dependencies", []): + if "byName" in dependency: + names.add(dependency["byName"][0]) + return names + + +def swift_imports(root: Path) -> dict[Path, list[str]]: + result: dict[Path, list[str]] = {} + pattern = re.compile( + r"^\s*(?:(?:@[_A-Za-z0-9]+(?:\([^)]*\))?)\s+)*" + r"import(?:\s+(?:typealias|struct|class|enum|protocol|let|var|func))?" + r"\s+([A-Za-z_][A-Za-z0-9_]*)", + re.MULTILINE, + ) + for source in sorted(root.rglob("*.swift")): + result[source] = pattern.findall(source.read_text()) + return result + + +def assert_frozen_phase0_artifacts() -> None: + fixture_paths = subprocess.check_output( + ["git", "ls-tree", "-r", "--name-only", PHASE0_ARTIFACT_BASELINE, PHASE0_PREFIX], + cwd=ROOT, + text=True, + ).splitlines() + if not fixture_paths: + fail(f"No Phase 0 fixtures found at {PHASE0_ARTIFACT_BASELINE}") + + current_fixture_paths = sorted( + path.relative_to(ROOT).as_posix() + for path in (ROOT / PHASE0_PREFIX).rglob("*") + if path.is_file() + ) + if current_fixture_paths != fixture_paths: + fail( + "Frozen Phase 0 fixture path set changed relative to " + f"{PHASE0_ARTIFACT_BASELINE}: baseline={fixture_paths}, current={current_fixture_paths}" + ) + + for relative in [*fixture_paths, *FROZEN_FILES]: + current = (ROOT / relative).read_bytes() + baseline = git_bytes(PHASE0_ARTIFACT_BASELINE, relative) + if current != baseline: + fail( + "Frozen Phase 0 artifact changed relative to " + f"{PHASE0_ARTIFACT_BASELINE}: {relative}" + ) + + +def main() -> int: + package = json.loads( + subprocess.check_output(["swift", "package", "dump-package"], cwd=ROOT, text=True) + ) + products = [(product["name"], product["type"]) for product in package["products"]] + expected_names = ["RepoPrompt", "repoprompt-mcp", "repoprompt-headless"] + if [name for name, _ in products] != expected_names: + fail(f"Expected executable-only products {expected_names}, found {products}") + if any("executable" not in product_type for _, product_type in products): + fail(f"Every advertised product must be executable, found {products}") + + targets = {target["name"]: target for target in package["targets"]} + expected_target_paths = { + "RepoPromptShared": "Sources/RepoPromptShared", + "RepoPromptPOSIXSupport": "Sources/RepoPromptPOSIXSupport", + "RepoPromptCore": "Sources/RepoPromptCore", + "RepoPromptCoreMacOS": "Sources/RepoPromptCoreMacOS", + } + for name, path in expected_target_paths.items(): + if targets.get(name, {}).get("path") != path: + fail(f"Target {name} must remain at {path}") + + exact_by_name_dependencies = { + "RepoPrompt": { + "RepoPromptShared", + "RepoPromptPOSIXSupport", + "RepoPromptCore", + "RepoPromptCoreMacOS", + "RepoPromptSyntaxCBridge", + "RepoPromptC", + "CSwiftPCRE2", + "Sparkle", + }, + "RepoPromptMCP": {"RepoPromptShared", "RepoPromptPOSIXSupport"}, + "RepoPromptCoreMacOS": {"RepoPromptCore", "RepoPromptPOSIXSupport"}, + "RepoPromptHeadless": {"RepoPromptShared", "RepoPromptCore", "RepoPromptCoreMacOS"}, + } + for target_name, expected in exact_by_name_dependencies.items(): + actual = by_name_dependencies(targets[target_name]) + if actual != expected: + fail( + f"{target_name} target dependencies differ from Phase 1: " + f"expected={sorted(expected)}, actual={sorted(actual)}" + ) + + core_dependency_records = targets["RepoPromptCore"].get("dependencies", []) + core_dependencies = by_name_dependencies(targets["RepoPromptCore"]) + if core_dependency_records: + fail(f"RepoPromptCore must have no dependency records in Phase 1: {core_dependency_records}") + if core_dependencies: + fail(f"RepoPromptCore must have no premature target edges in Phase 1: {sorted(core_dependencies)}") + + core_macos_product_dependencies = [ + dependency for dependency in targets["RepoPromptCoreMacOS"].get("dependencies", []) if "product" in dependency + ] + if core_macos_product_dependencies: + fail(f"RepoPromptCoreMacOS has unexpected product dependencies: {core_macos_product_dependencies}") + + shared_imports = swift_imports(ROOT / "Sources/RepoPromptShared") + for source, imports in shared_imports.items(): + unexpected = [module for module in imports if module != "Foundation"] + if unexpected: + fail(f"RepoPromptShared must be Foundation-only: {source.relative_to(ROOT)} imports {unexpected}") + + core_imports = swift_imports(ROOT / "Sources/RepoPromptCore") + for source, imports in core_imports.items(): + unexpected = [module for module in imports if module != "Foundation"] + if unexpected: + fail(f"RepoPromptCore Phase 1 contracts must be Foundation-only: {source.relative_to(ROOT)} imports {unexpected}") + + posix_files = list((ROOT / "Sources").rglob("POSIXDescriptorSupport.swift")) + expected_posix = ROOT / "Sources/RepoPromptPOSIXSupport/Descriptors/POSIXDescriptorSupport.swift" + if posix_files != [expected_posix]: + fail(f"POSIXDescriptorSupport.swift must be single-sourced at {expected_posix.relative_to(ROOT)}") + + core_text = "\n".join(path.read_text() for path in sorted((ROOT / "Sources/RepoPromptCore").rglob("*.swift"))) + forbidden_core_tokens = [ + "POSIXDescriptorConfigurationError", + "connectedFileDescriptor", + "import Darwin", + "import Glibc", + "import SystemPackage", + "import RepoPromptShared", + "import RepoPromptPOSIXSupport", + ] + for token in forbidden_core_tokens: + if token in core_text: + fail(f"Darwin/POSIX-backed Core boundary token remains: {token}") + + boundary = (ROOT / "Sources/RepoPromptCore/MCP/Platform/MCPAppProxyTransportBoundary.swift").read_text() + for required in [ + "MCPAppProxyAcceptedTransport", + "MCPAppProxyAcceptedTransportLease", + "reserveForAdmission", + "transfer(", + "rollback()", + ]: + if required not in boundary: + fail(f"Opaque accepted-transport boundary missing {required}") + + process_contract = (ROOT / "Sources/RepoPromptCore/Platform/ProcessLaunching.swift").read_text() + for required in ["operation: String", "label: String", "fd: Int32", "errno: Int32"]: + if required not in process_contract: + fail(f"Neutral process error missing field {required}") + + assert_frozen_phase0_artifacts() + print("OK: shared runtime Phase 1 boundary characterization passed.") + return 0 + + +if __name__ == "__main__": + try: + raise SystemExit(main()) + except (AssertionError, subprocess.CalledProcessError) as error: + print(f"ERROR: {error}", file=sys.stderr) + raise SystemExit(1) diff --git a/Scripts/test_shared_runtime_phase2_boundaries.py b/Scripts/test_shared_runtime_phase2_boundaries.py new file mode 100644 index 000000000..1fced8598 --- /dev/null +++ b/Scripts/test_shared_runtime_phase2_boundaries.py @@ -0,0 +1,773 @@ +#!/usr/bin/env python3 +"""Phase 2 runtime, prompt-assembly, and reviewed-headless boundary checks.""" + +from __future__ import annotations + +import json +import re +import subprocess +import sys +from pathlib import Path + +from shared_runtime_headless_baseline import verify_reviewed_headless_baseline + + +ROOT = Path(__file__).resolve().parents[1] +PHASE0_ARTIFACT_BASELINE = "48a335e" +PHASE0_ROOT = "Tests/SharedRuntimeConvergenceFixtures/Phase0" +PHASE0_FROZEN_FILES = ( + "docs/characterization/shared-runtime-phase0-2026-06-05.md", + "Scripts/test_shared_runtime_phase0_characterization.py", +) + +REQUIRED_RUNTIME_PATHS = ( + "Sources/RepoPromptCore/FileSystem/FileSystemService.swift", + "Sources/RepoPromptCore/Regex/PCRE2Regex.swift", + "Sources/RepoPromptCore/SyntaxParsing/SyntaxManager.swift", + "Sources/RepoPromptCore/CodeMap/CodeMapGenerator.swift", + "Sources/RepoPromptCore/WorkspaceContext/WorkspaceRuntimeDependencies.swift", + "Sources/RepoPromptCore/WorkspaceContext/WorkspaceFileContextStore.swift", + "Sources/RepoPromptCore/WorkspaceContext/WorkspaceFileSystemIngressCoordinator.swift", + "Sources/RepoPromptCore/WorkspaceContext/Search/WorkspaceSearchService.swift", + "Sources/RepoPromptCore/WorkspaceContext/Selection/WorkspaceSelectionController.swift", + "Sources/RepoPromptCore/WorkspaceContext/Slices/SelectionSliceCoordinator.swift", + "Sources/RepoPromptCore/WorkspaceContext/TokenAccounting/TokenCalculationService.swift", + "Sources/RepoPromptCoreMacOS/FileSystem/MacOSWorkspaceDirectoryListingBackend.swift", + "Sources/RepoPrompt/App/RepoPromptEmbeddedWorkspaceRuntimeFactory.swift", +) + +RETIRED_RUNTIME_PATHS = ( + "Sources/RepoPrompt/Infrastructure/FileSystem/FileSystemService.swift", + "Sources/RepoPrompt/Infrastructure/Regex/PCRE2Regex.swift", + "Sources/RepoPrompt/Infrastructure/SyntaxParsing/SyntaxManager.swift", + "Sources/RepoPrompt/Features/CodeMap/CodeMapGenerator.swift", + "Sources/RepoPrompt/Infrastructure/WorkspaceContext/WorkspaceFileContextStore.swift", + "Sources/RepoPrompt/Infrastructure/WorkspaceContext/WorkspaceFileSystemIngressCoordinator.swift", + "Sources/RepoPrompt/Infrastructure/WorkspaceContext/Search/WorkspaceSearchService.swift", + "Sources/RepoPrompt/Infrastructure/WorkspaceContext/Selection/WorkspaceSelectionController.swift", + "Sources/RepoPrompt/Infrastructure/WorkspaceContext/Slices/SelectionSliceCoordinator.swift", + "Sources/RepoPrompt/App/CoreAdapters/WorkspaceSessionSelectionForwarder.swift", +) + +REQUIRED_PROMPT_ASSEMBLY_PATHS = ( + "Sources/RepoPromptCore/Prompt/PromptAssemblyBuilder.swift", + "Sources/RepoPromptCore/Prompt/PromptContextAccountingService.swift", + "Sources/RepoPromptCore/Prompt/PromptRenderingService.swift", + "Sources/RepoPromptCore/Prompt/PromptRenderingValues.swift", + "Sources/RepoPromptCore/Prompt/PromptRenderPolicy.swift", + "Sources/RepoPromptCore/WorkspaceContext/Projection/WorkspaceSelectionProjection.swift", + "Sources/RepoPromptCore/WorkspaceContext/Projection/WorkspaceSelectionProjectionService.swift", + "Sources/RepoPromptCore/WorkspaceContext/Projection/TokenProjection.swift", + "Sources/RepoPromptCore/WorkspaceContext/Projection/TokenProjectionService.swift", + "Sources/RepoPromptCore/WorkspaceContext/Projection/WorkspaceContextProjection.swift", + "Sources/RepoPromptCore/WorkspaceContext/Projection/WorkspaceContextProjectionService.swift", + "Sources/RepoPromptCore/Prompt/PromptSection.swift", + "Sources/RepoPrompt/Features/Prompt/Models/PromptSection+DisplayName.swift", + "Sources/RepoPrompt/Features/Prompt/Services/PromptContextAccountingService.swift", +) + +RETIRED_PROMPT_ASSEMBLY_PATHS = ( + "Sources/RepoPrompt/Features/Prompt/Models/PromptAssemblyBuilder.swift", +) + +REQUIRED_PROVIDER_ACCOUNTING_PATHS = ( + "Sources/RepoPrompt/Infrastructure/AI/Models/AIProviderInputProjection.swift", + "Sources/RepoPrompt/Infrastructure/AI/Providers/AIProviderInputProjectionCapability.swift", +) + +CORE_IMPORTERS = { + "RepoPromptC": { + "FileSystem/GitignoreCompiler.swift", + "Utilities/StringFNV.swift", + "Utilities/StringLineEndingUtilities.swift", + "WorkspaceContext/Search/PathSearchIndex.swift", + "WorkspaceContext/Search/RepoSearchBatchScorer.swift", + "WorkspaceContext/Search/SearchMatch.swift", + "WorkspaceContext/Search/SearchPathFiltering.swift", + }, + "CSwiftPCRE2": { + "Regex/PCRE2Error.swift", + "Regex/PCRE2JIT.swift", + "Regex/PCRE2Options.swift", + "Regex/PCRE2Regex.swift", + }, + "RepoPromptSyntaxCBridge": {"SyntaxParsing/SyntaxManager.swift"}, + "SwiftTreeSitter": { + "CodeMap/CodeMapCaptureIndex.swift", + "CodeMap/CodeMapGenerator.swift", + "CodeMap/LanguageStrategies/SwiftCodeMapStrategy.swift", + "CodeMap/LanguageStrategies/TypeScriptCodeMapStrategy.swift", + "SyntaxParsing/SyntaxManager.swift", + }, + "UniversalCharsetDetection": {"FileSystem/FileSystemService+ContentLoading.swift"}, + "Cuchardet": {"FileSystem/FileSystemService+ContentLoading.swift"}, +} + + +def fail(message: str) -> None: + raise AssertionError(message) + + +def git_paths(revision: str, root: str) -> list[str]: + return subprocess.check_output( + ["git", "ls-tree", "-r", "--name-only", revision, root], cwd=ROOT, text=True + ).splitlines() + + +def git_bytes(revision: str, path: str) -> bytes: + return subprocess.check_output(["git", "show", f"{revision}:{path}"], cwd=ROOT) + + +def assert_phase0_artifacts_unchanged() -> None: + baseline_paths = git_paths(PHASE0_ARTIFACT_BASELINE, PHASE0_ROOT) + if not baseline_paths: + fail(f"No Phase 0 fixtures found at {PHASE0_ARTIFACT_BASELINE}") + current_paths = sorted( + path.relative_to(ROOT).as_posix() + for path in (ROOT / PHASE0_ROOT).rglob("*") + if path.is_file() + ) + if current_paths != baseline_paths: + fail( + "Frozen Phase 0 fixture path set changed relative to " + f"{PHASE0_ARTIFACT_BASELINE}: baseline={baseline_paths}, current={current_paths}" + ) + for relative in [*baseline_paths, *PHASE0_FROZEN_FILES]: + if (ROOT / relative).read_bytes() != git_bytes(PHASE0_ARTIFACT_BASELINE, relative): + fail( + "Frozen Phase 0 artifact changed relative to " + f"{PHASE0_ARTIFACT_BASELINE}: {relative}" + ) + + +def swift_imports(source: Path) -> list[str]: + pattern = re.compile( + r"^\s*(?:(?:@[_A-Za-z0-9]+(?:\([^)]*\))?)\s+)*" + r"import(?:\s+(?:typealias|struct|class|enum|protocol|let|var|func))?" + r"\s+([A-Za-z_][A-Za-z0-9_]*)", + re.MULTILINE, + ) + return pattern.findall(source.read_text()) + + +def dependency_names(target: dict[str, object], kind: str) -> set[str]: + return { + dependency[kind][0] + for dependency in target.get("dependencies", []) + if kind in dependency + } + + +def importer_paths(module: str) -> set[str]: + core_root = ROOT / "Sources/RepoPromptCore" + return { + source.relative_to(core_root).as_posix() + for source in core_root.rglob("*.swift") + if module in swift_imports(source) + } + + +def token_files(token: str, root: Path) -> list[str]: + return sorted( + source.relative_to(ROOT).as_posix() + for source in root.rglob("*.swift") + if token in source.read_text() + ) + + +def assert_single_source_file(filename: str, expected: str) -> None: + actual = sorted( + source.relative_to(ROOT).as_posix() + for source in (ROOT / "Sources").rglob(filename) + if source.is_file() + ) + if actual != [expected]: + fail(f"{filename} canonical ownership drift: expected={[expected]}, actual={actual}") + + +def main() -> int: + package = json.loads( + subprocess.check_output(["swift", "package", "dump-package"], cwd=ROOT, text=True) + ) + products = [(product["name"], product["type"]) for product in package["products"]] + expected_products = ["RepoPrompt", "repoprompt-mcp", "repoprompt-headless"] + if [name for name, _ in products] != expected_products: + fail(f"Expected executable-only products {expected_products}, found {products}") + if any("executable" not in product_type for _, product_type in products): + fail(f"Every advertised product must remain executable: {products}") + + targets = {target["name"]: target for target in package["targets"]} + expected_target_paths = { + "RepoPromptShared": "Sources/RepoPromptShared", + "RepoPromptPOSIXSupport": "Sources/RepoPromptPOSIXSupport", + "RepoPromptCore": "Sources/RepoPromptCore", + "RepoPromptCoreMacOS": "Sources/RepoPromptCoreMacOS", + } + for name, path in expected_target_paths.items(): + if targets.get(name, {}).get("path") != path: + fail(f"Target {name} must remain at {path}") + + core_target = targets["RepoPromptCore"] + expected_by_name = {"RepoPromptC", "CSwiftPCRE2", "RepoPromptSyntaxCBridge"} + expected_products = {"SwiftTreeSitter", "UniversalCharsetDetection", "Cuchardet"} + actual_by_name = dependency_names(core_target, "byName") + actual_products = dependency_names(core_target, "product") + if actual_by_name != expected_by_name: + fail( + "RepoPromptCore by-name dependencies must match importer-backed native edges: " + f"expected={sorted(expected_by_name)}, actual={sorted(actual_by_name)}" + ) + if actual_products != expected_products: + fail( + "RepoPromptCore product dependencies must match importer-backed native edges: " + f"expected={sorted(expected_products)}, actual={sorted(actual_products)}" + ) + if len(core_target.get("dependencies", [])) != len(expected_by_name) + len(expected_products): + fail(f"RepoPromptCore has an unsupported dependency record: {core_target.get('dependencies', [])}") + + for module, expected in CORE_IMPORTERS.items(): + actual = importer_paths(module) + if actual != expected: + fail( + f"RepoPromptCore {module} importer ownership drift: " + f"expected={sorted(expected)}, actual={sorted(actual)}" + ) + + direct_grammar_products = sorted( + product + for product in actual_products + if product.startswith("TreeSitter") and product != "SwiftTreeSitter" + ) + if direct_grammar_products: + fail(f"RepoPromptCore must not depend directly on grammar products: {direct_grammar_products}") + + for relative in REQUIRED_RUNTIME_PATHS: + if not (ROOT / relative).is_file(): + fail(f"Required Phase 2 Slice 2 runtime owner missing: {relative}") + for relative in RETIRED_RUNTIME_PATHS: + if (ROOT / relative).exists(): + fail(f"Retired app runtime owner still exists: {relative}") + for relative in REQUIRED_PROMPT_ASSEMBLY_PATHS: + if not (ROOT / relative).is_file(): + fail(f"Required Slice 3 prompt assembly owner missing: {relative}") + for relative in RETIRED_PROMPT_ASSEMBLY_PATHS: + if (ROOT / relative).exists(): + fail(f"Retired app prompt assembly owner still exists: {relative}") + for relative in REQUIRED_PROVIDER_ACCOUNTING_PATHS: + if not (ROOT / relative).is_file(): + fail(f"Required provider-aware accounting foundation missing: {relative}") + + canonical_source_owners = { + "CodeMapGenerator.swift": "Sources/RepoPromptCore/CodeMap/CodeMapGenerator.swift", + "AgentSupportDirectoryCatalog.swift": "Sources/RepoPromptCore/WorkspaceContext/PathResolution/AgentSupportDirectoryCatalog.swift", + "WorkspaceReadableFileService.swift": "Sources/RepoPromptCore/WorkspaceContext/WorkspaceReadableFileService.swift", + "WorkspaceSessionController.swift": "Sources/RepoPromptCore/Workspaces/WorkspaceSessionController.swift", + "WorkspaceSelectionProjection.swift": "Sources/RepoPromptCore/WorkspaceContext/Projection/WorkspaceSelectionProjection.swift", + "WorkspaceSelectionProjectionService.swift": "Sources/RepoPromptCore/WorkspaceContext/Projection/WorkspaceSelectionProjectionService.swift", + "TokenProjection.swift": "Sources/RepoPromptCore/WorkspaceContext/Projection/TokenProjection.swift", + "TokenProjectionService.swift": "Sources/RepoPromptCore/WorkspaceContext/Projection/TokenProjectionService.swift", + "WorkspaceContextProjection.swift": "Sources/RepoPromptCore/WorkspaceContext/Projection/WorkspaceContextProjection.swift", + "WorkspaceContextProjectionService.swift": "Sources/RepoPromptCore/WorkspaceContext/Projection/WorkspaceContextProjectionService.swift", + } + for filename, expected in canonical_source_owners.items(): + assert_single_source_file(filename, expected) + + workspace_files_source = ( + ROOT / "Sources/RepoPrompt/Features/WorkspaceFiles/ViewModels/WorkspaceFilesViewModel.swift" + ).read_text() + for duplicate_token in ( + "struct ExternalReadableFile", + "enum ReadableFileHandle", + "resolveReadableFileForUserInput(", + "readAlwaysReadableExternalFile(", + "alwaysReadableHomeDirectoryURL", + ): + if duplicate_token in workspace_files_source: + fail(f"WorkspaceFilesViewModel retains duplicate Core readable-file state: {duplicate_token}") + + workspace_manager_source = ( + ROOT / "Sources/RepoPrompt/Features/Workspaces/ViewModels/WorkspaceManagerViewModel.swift" + ).read_text() + for duplicate_token in ( + "struct ComposeTabBindingCandidate", + "func bindingCandidate(forContextID", + "func bindingCandidates(matchingWorkingDirs", + "normalizeBindingPath(", + ): + if duplicate_token in workspace_manager_source: + fail(f"WorkspaceManagerViewModel retains duplicate Core session binding state: {duplicate_token}") + + window_routing_source = ( + ROOT / "Sources/RepoPrompt/Infrastructure/MCP/WindowRoutingService.swift" + ).read_text() + if ( + "window.coreSessionHandle.session.workspaceSessionController" not in window_routing_source + or ".bindingCandidate(forContextID: contextID)" not in window_routing_source + ): + fail("Window context binding must query the canonical Core session controller") + + workspace_model_adapter_source = ( + ROOT / "Sources/RepoPrompt/Features/Workspaces/WorkspaceModel.swift" + ).read_text() + if "typealias WorkspaceModel = RepoPromptCore.WorkspaceModel" not in workspace_model_adapter_source: + fail("App workspace model compatibility file must alias canonical Core state") + for declaration in ("struct WorkspaceModel", "class WorkspaceModel", "enum WorkspaceModel"): + if declaration in workspace_model_adapter_source: + fail(f"App workspace model compatibility file redeclares Core state: {declaration}") + + observation_bridge_source = ( + ROOT / "Sources/RepoPrompt/App/CoreAdapters/WorkspaceSessionObservationBridge.swift" + ).read_text() + if "controller.observe" not in observation_bridge_source or "@Published" not in observation_bridge_source: + fail("WorkspaceSessionObservationBridge must remain an app observation adapter over Core") + prompt_projection_adapter_source = ( + ROOT / "Sources/RepoPrompt/Features/Prompt/Services/WorkspacePromptProjectionAdapter.swift" + ).read_text() + if "WorkspaceContextProjectionService(" not in prompt_projection_adapter_source: + fail("WorkspacePromptProjectionAdapter must delegate canonical projection to Core") + + agent_support_catalog_source = ( + ROOT / "Sources/RepoPromptCore/WorkspaceContext/PathResolution/AgentSupportDirectoryCatalog.swift" + ).read_text() + if "case globalCodexPrompts" in agent_support_catalog_source: + fail("Codex prompts readable-root access requires a separate explicit product/security policy change") + built_in_body = agent_support_catalog_source.split("package static func builtInAlwaysReadableDirectories", 1)[1].split("package static func effectiveAlwaysReadableDirectories", 1)[0] + if "roots.codexPrompts" in built_in_body: + fail("~/.codex/prompts must not become always-readable without explicit policy approval") + + core_accounting_source = ( + ROOT / "Sources/RepoPromptCore/Prompt/PromptContextAccountingService.swift" + ).read_text() + app_accounting_source = ( + ROOT / "Sources/RepoPrompt/Features/Prompt/Services/PromptContextAccountingService.swift" + ).read_text() + for declaration in ( + "package struct PromptContextAccountingRequest", + "package struct PromptContextAccountingResolution", + "package struct PromptContextAccountingResult", + "package actor PromptContextAccountingService", + ): + if declaration not in core_accounting_source: + fail(f"Canonical Core prompt accounting declaration missing: {declaration}") + if declaration in app_accounting_source: + fail(f"App prompt accounting facade redeclares Core ownership: {declaration}") + if "private let core = RepoPromptCore.PromptContextAccountingService()" not in app_accounting_source: + fail("App prompt accounting compatibility owner must delegate to RepoPromptCore") + + core_rendering_values_source = ( + ROOT / "Sources/RepoPromptCore/Prompt/PromptRenderingValues.swift" + ).read_text() + core_rendering_service_source = ( + ROOT / "Sources/RepoPromptCore/Prompt/PromptRenderingService.swift" + ).read_text() + core_assembly_source = ( + ROOT / "Sources/RepoPromptCore/Prompt/PromptAssemblyBuilder.swift" + ).read_text() + app_packaging_source = ( + ROOT / "Sources/RepoPrompt/Features/Prompt/Services/PromptPackagingService.swift" + ).read_text() + app_ai_message_source = ( + ROOT / "Sources/RepoPrompt/Infrastructure/AI/AIMessage.swift" + ).read_text() + app_prompt_view_model_source = ( + ROOT / "Sources/RepoPrompt/Features/Prompt/ViewModels/PromptViewModel.swift" + ).read_text() + app_provider_projection_source = ( + ROOT / "Sources/RepoPrompt/Infrastructure/AI/Models/AIProviderInputProjection.swift" + ).read_text() + app_provider_factory_source = ( + ROOT / "Sources/RepoPrompt/Infrastructure/AI/Providers/AIProviderFactory.swift" + ).read_text() + app_provider_capability_source = ( + ROOT + / "Sources/RepoPrompt/Infrastructure/AI/Providers/AIProviderInputProjectionCapability.swift" + ).read_text() + app_queries_source = ( + ROOT / "Sources/RepoPrompt/Infrastructure/AI/AIQueriesService.swift" + ).read_text() + for declaration in ( + "package struct PromptRenderingFileValue", + "package struct PromptRenderingDiffValue", + "package struct PromptRenderedFileBlock", + "package struct PromptPartitionedFileBlocks", + "package struct PromptRenderedFactualSnippets", + ): + if declaration not in core_rendering_values_source: + fail(f"Canonical Core prompt rendering value missing: {declaration}") + if "package enum PromptRenderingService" not in core_rendering_service_source: + fail("Canonical Core prompt rendering service missing") + for delegation in ( + "PromptRenderingService.codeFenceStart", + "PromptRenderingService.renderFileBlocks", + "PromptRenderingService.renderPartitionedFileBlocks", + "PromptRenderingService.renderDiffParts", + "PromptRenderingService.renderSelectedDiffText", + "PromptRenderingService.renderFactualSnippets", + ): + if delegation not in app_packaging_source: + fail(f"App prompt packaging facade must delegate factual rendering: {delegation}") + expected_core_standard_chat_owners = [ + "Sources/RepoPrompt/Features/Prompt/ViewModels/PromptViewModel.swift", + "Sources/RepoPrompt/Infrastructure/AI/AIMessage.swift", + ] + actual_core_standard_chat_owners = token_files("coreStandardChat", ROOT / "Sources") + if actual_core_standard_chat_owners != expected_core_standard_chat_owners: + fail( + "Core standard chat opt-in must remain limited to AIMessage and the standard " + "PromptViewModel path: " + f"expected={expected_core_standard_chat_owners}, " + f"actual={actual_core_standard_chat_owners}" + ) + for required_ai_message_adapter in ( + "enum TailAssemblyStrategy", + "struct PreparedOpenAIChatInput", + "struct PreparedOpenAIResponsesInput", + "func preparedOpenAIChatInput(embedSystemPrompt: Bool)", + "func preparedOpenAIResponsesInput()", + "preparedOpenAIChatInput(embedSystemPrompt: embedSystemPrompt).messages.map", + "let prepared = preparedOpenAIResponsesInput()", + "case legacy", + "case coreStandardChat", + "envelopePolicy: .chatStyleTree", + "layout: .blankLineSeparatedFragments", + "disabledPromptSections.union([.userInstructions])", + "duplicateUserInstructionsAtTop: false", + 'return [tail, "", systemPrompt].joined(separator: "\\n\\n")', + "var fileTreeXML: String", + "var fileBlocksXML: String", + "var gitDiffXML: String", + "var combinedXML: String", + "private let renderedFactualSnippets: PromptRenderedFactualSnippets", + ): + if required_ai_message_adapter not in app_ai_message_source: + fail(f"AIMessage standard-chat compatibility adapter missing: {required_ai_message_adapter}") + for required_packaging_adapter in ( + "tailAssemblyStrategy: AIMessage.TailAssemblyStrategy = .legacy", + "tailAssemblyStrategy: tailAssemblyStrategy", + "exactRenderedPayload(renderedChatPayload(for: message)", + ): + if required_packaging_adapter not in app_packaging_source: + fail(f"Prompt packaging standard-chat adapter missing: {required_packaging_adapter}") + if app_prompt_view_model_source.count("tailAssemblyStrategy: .coreStandardChat") != 1: + fail("Exactly one standard PromptViewModel packagePromptResult path must opt into Core assembly") + if "exactChatPayload(for: message, source: tokenSource)" not in app_prompt_view_model_source: + fail("Standard chat exact accounting must continue to derive from the packaged AIMessage") + + for required_projection_foundation in ( + "struct AIProviderInputProjection", + "struct ChatInputTokenEstimate", + "enum AIProviderInputProjectionResolver", + "enum RouteResolution", + "case unresolved", + "case preflightResolved", + "case providerResolved", + "private init(", + "fragments: fragments(for: input)", + "TokenProjectionService.renderedPayloadEstimate", + ): + if required_projection_foundation not in app_provider_projection_source: + fail(f"Provider-aware accounting foundation missing: {required_projection_foundation}") + if "TokenProjectionService.exactRenderedPayload" in app_provider_projection_source: + fail("App-content projections must never claim exact rendered payload provenance") + for required_preflight_boundary in ( + "case .openAI:", + "case .openRouter:", + "case .azure, .customProvider:", + "case .anthropic, .ollama, .gemini, .deepseek,", + ): + if required_preflight_boundary not in app_provider_projection_source: + fail(f"Narrow preflight boundary changed: {required_preflight_boundary}") + if "isKnownOpenAITransport" in app_provider_projection_source: + fail("Preflight must preserve AIModel.providerType routing for legacy Azure-backed models") + if "AIProviderInputProjection" in core_rendering_values_source or "AIProviderInputProjection" in core_rendering_service_source: + fail("Provider-aware accounting DTOs must remain app-owned") + if "func streamMessageWithInputProjection(" not in app_provider_factory_source: + fail("AIProvider protocol projection seam missing") + for required_provider_seam in ( + "struct AIProviderStreamStart", + "func streamMessageWithInputProjection(", + "inputProjection: nil", + ): + if required_provider_seam not in app_provider_capability_source: + fail(f"Additive provider projection seam missing: {required_provider_seam}") + if "streamMessageWithInputProjection" in app_queries_source: + fail("Checkpoint 1 must not change AIQueriesService lazy send lifecycle") + projection_seam_owners = token_files("streamMessageWithInputProjection", ROOT / "Sources") + if projection_seam_owners != [ + "Sources/RepoPrompt/Infrastructure/AI/Providers/AIProviderFactory.swift", + "Sources/RepoPrompt/Infrastructure/AI/Providers/AIProviderInputProjectionCapability.swift", + ]: + fail( + "Checkpoint 1 must keep the provider projection seam defaulted and unused; " + f"found concrete adaptations: {projection_seam_owners}" + ) + + provider_compatibility_tokens = { + "Sources/RepoPrompt/Infrastructure/AI/Providers/AnthropicProvider.swift": ( + "aiMessage.buildTail(embedSystemPrompt: false)", + ), + "Sources/RepoPrompt/Infrastructure/AI/Providers/AzureOpenAIProvider.swift": ( + "let embedSystemPrompt = baseModel == .o1Mini || baseModel == .o1Preview", + "message.openAIChatMessages(embedSystemPrompt: embedSystemPrompt)", + "message.openAIResponsesInput()", + ), + "Sources/RepoPrompt/Infrastructure/AI/Providers/ClaudeCodeProvider.swift": ( + "aiMessage.buildTail(embedSystemPrompt: false)", + ), + "Sources/RepoPrompt/Infrastructure/AI/Providers/Codex/CodexCLIProvider.swift": ( + "aiMessage.buildTail(embedSystemPrompt: false)", + ), + "Sources/RepoPrompt/Infrastructure/AI/Providers/Cursor/CursorCLIProvider.swift": ( + "aiMessage.buildTail(embedSystemPrompt: false)", + ), + "Sources/RepoPrompt/Infrastructure/AI/Providers/CustomOpenai/CustomOpenAIProvider.swift": ( + "aiMessage.fileTreeXML", + "aiMessage.fileBlocksXML", + "aiMessage.metaPrompts", + ), + "Sources/RepoPrompt/Infrastructure/AI/Providers/OpenAIProvider.swift": ( + "let isO1PreviewOrMini = (effectiveModel == .o1Mini || effectiveModel == .o1Preview)", + "aiMessage.openAIChatMessages(embedSystemPrompt: isO1PreviewOrMini)", + "aiMessage.openAIResponsesInput()", + ), + "Sources/RepoPrompt/Infrastructure/AI/Providers/OpenCode/OpenCodeCLIProvider.swift": ( + "aiMessage.buildTail(embedSystemPrompt: false)", + ), + "Sources/RepoPrompt/Infrastructure/AI/Providers/OpenRouterProvider.swift": ( + "aiMessage.openAIChatMessages(embedSystemPrompt: false)", + ), + } + for relative, required_tokens in provider_compatibility_tokens.items(): + source = (ROOT / relative).read_text() + for required_token in required_tokens: + if required_token not in source: + fail(f"Provider compatibility call changed in {relative}: {required_token}") + + for retained_app_token in ("enum PromptGitDiffArtifactClassifier", "_git_data"): + if retained_app_token not in app_packaging_source: + fail(f"App prompt packaging policy owner missing: {retained_app_token}") + if retained_app_token in core_rendering_values_source or retained_app_token in core_rendering_service_source: + fail(f"Core prompt rendering must not own app classification policy: {retained_app_token}") + for forbidden_core_token in ( + "FileViewModel", + "PromptFileEntry", + "ResolvedPromptFileEntry", + "FileAPI", + "WorkspaceCodemapSnapshot", + "CopyPreset", + "AIMessage", + "ConversationEntry", + "embedSystemPrompt", + "systemPrompt", + "MCP", + "Worktree", + "UserDefaults", + "NSPasteboard", + "DateFormatter", + "Diagnostics", + ): + if any( + forbidden_core_token in source + for source in ( + core_rendering_values_source, + core_rendering_service_source, + core_assembly_source, + ) + ): + fail(f"Core prompt rendering/assembly leaks app/product policy: {forbidden_core_token}") + core_selection_projection_source = ( + ROOT + / "Sources/RepoPromptCore/WorkspaceContext/Projection/WorkspaceSelectionProjection.swift" + ).read_text() + core_selection_projection_service_source = ( + ROOT + / "Sources/RepoPromptCore/WorkspaceContext/Projection/WorkspaceSelectionProjectionService.swift" + ).read_text() + for declaration in ( + "package struct WorkspaceSelectionProjection", + "package struct WorkspaceSelectionProjectionRequest", + ): + if declaration not in core_selection_projection_source: + fail(f"Canonical Core selection projection declaration missing: {declaration}") + if "package enum WorkspaceSelectionProjectionService" not in core_selection_projection_service_source: + fail("Canonical Core selection projection service missing") + for forbidden_projection_token in ( + "ToolResultDTO", + "CopyPreset", + "PromptViewModel", + "PromptContextResolved", + "FileViewModel", + "WorkspaceRootBindingProjection", + "AgentSessionWorktreeBinding", + "MCP", + ): + if ( + forbidden_projection_token in core_selection_projection_source + or forbidden_projection_token in core_selection_projection_service_source + ): + fail( + "Core selection projection leaks app/product policy: " + f"{forbidden_projection_token}" + ) + for forbidden_service_token in ("Task", "async", "await", "actor"): + if forbidden_service_token in core_selection_projection_service_source: + fail( + "Core selection projection service must remain synchronous and request-scoped: " + f"{forbidden_service_token}" + ) + + core_token_projection_source = ( + ROOT / "Sources/RepoPromptCore/WorkspaceContext/Projection/TokenProjection.swift" + ).read_text() + core_token_projection_service_source = ( + ROOT / "Sources/RepoPromptCore/WorkspaceContext/Projection/TokenProjectionService.swift" + ).read_text() + core_context_projection_source = ( + ROOT / "Sources/RepoPromptCore/WorkspaceContext/Projection/WorkspaceContextProjection.swift" + ).read_text() + core_context_projection_service_source = ( + ROOT / "Sources/RepoPromptCore/WorkspaceContext/Projection/WorkspaceContextProjectionService.swift" + ).read_text() + app_projection_adapter_source = ( + ROOT / "Sources/RepoPrompt/Features/Prompt/Services/WorkspacePromptProjectionAdapter.swift" + ).read_text() + app_token_recount_source = ( + ROOT / "Sources/RepoPrompt/Features/Prompt/ViewModels/TokenCountingViewModel.swift" + ).read_text() + if token_files("package struct TokenProjection", ROOT / "Sources") != [ + "Sources/RepoPromptCore/WorkspaceContext/Projection/TokenProjection.swift" + ]: + fail("Canonical TokenProjection ownership must remain in RepoPromptCore") + if token_files("package enum TokenProjectionService", ROOT / "Sources") != [ + "Sources/RepoPromptCore/WorkspaceContext/Projection/TokenProjectionService.swift" + ]: + fail("Canonical TokenProjectionService ownership must remain in RepoPromptCore") + if "AIProviderInputProjection" in core_token_projection_source + core_token_projection_service_source: + fail("Core token projection must remain provider-neutral") + if "package enum WorkspaceTokenProjectionInput" not in core_context_projection_source: + fail("Typed workspace token projection input must remain Core-owned") + if "TokenProjectionService.activeLiveWorkspaceEstimates" not in core_context_projection_service_source: + fail("Workspace context projection must delegate active-live repair to TokenProjectionService") + if "tokenProjectionInput: WorkspaceTokenProjectionInput" not in app_projection_adapter_source: + fail("App workspace projection adapter must forward the typed Core token input") + if "tokenProjectionInput: .activeLive" not in app_token_recount_source: + fail("Active recount must request canonical active-live projection semantics") + if ".virtualRecomputed" not in app_token_recount_source: + fail("Light recount must retain virtual recomputation provenance") + for forbidden_app_recount_token in ( + "private let tokenCalculationService", + "normalizedTotal - normalizedFiles", + "max(userComponentSum, replacementTotal)", + ): + if forbidden_app_recount_token in app_token_recount_source or forbidden_app_recount_token in app_projection_adapter_source: + fail(f"App recount reconstructs canonical token arithmetic: {forbidden_app_recount_token}") + for required_core_token in ( + "package struct TokenProjection", + "package enum TokenProjectionService", + "case renderedPayloadEstimate", + "package static func renderedPayloadEstimate", + "activeLiveWorkspaceEstimates", + ): + if required_core_token not in core_token_projection_source + core_token_projection_service_source: + fail(f"Canonical Core token projection declaration missing: {required_core_token}") + + for retired_app_helper in ( + "private static func renderFullFileBlock", + "private static func renderSliceFileBlock", + "private static func renderFileBlock", + "private static func formatRange", + "SliceAssemblyBuilder.build(", + "URL(fileURLWithPath:", + ): + if retired_app_helper in app_packaging_source: + fail(f"App prompt packaging retains duplicate factual renderer: {retired_app_helper}") + + core_root = ROOT / "Sources/RepoPromptCore" + forbidden_imports = { + "AppKit", + "SwiftUI", + "Combine", + "Cocoa", + "Sparkle", + "KeyboardShortcuts", + "CoreServices", + "Security", + "Darwin", + "Glibc", + "SystemPackage", + "OSLog", + "os", + "RepoPromptShared", + "RepoPromptPOSIXSupport", + "RepoPromptCoreMacOS", + } + for source in sorted(core_root.rglob("*.swift")): + leaked = sorted(set(swift_imports(source)) & forbidden_imports) + if leaked: + fail(f"Core app/platform import leakage: {source.relative_to(ROOT)} imports {leaked}") + + core_text = "\n".join(source.read_text() for source in sorted(core_root.rglob("*.swift"))) + for token in ("OSSignpost", "OSSignposter", "os_signpost", "CODEMAP_PERF_SIGNPOSTS", "signposts"): + if token in core_text: + fail(f"Core owns Apple signpost instrumentation token: {token}") + forbidden_tokens = ( + "UserDefaults.standard", + "Bundle.main", + "Notification.Name", + "applicationSupportDirectory", + "WindowState", + "WindowStatesManager", + "NSApplication", + "NSWorkspace", + ) + for token in forbidden_tokens: + if token in core_text: + fail(f"Core app/platform ownership token remains: {token}") + + all_sources_text = "\n".join( + source.read_text() for source in sorted((ROOT / "Sources").rglob("*.swift")) + ) + for token in ("WorkspaceSessionSelectionForwarder", "WorkspaceSelectionHost"): + if token in all_sources_text: + fail(f"Obsolete Slice 1 runtime bridge remains: {token}") + + factory_path = "Sources/RepoPrompt/App/RepoPromptEmbeddedWorkspaceRuntimeFactory.swift" + constructor_owners = { + "WorkspaceRuntimeDependencies(": [factory_path], + "WorkspaceFileContextStore(runtimeDependencies:": [factory_path], + "WorkspaceSearchService()": [factory_path], + "SelectionSliceCoordinator(store:": [factory_path], + } + for token, expected in constructor_owners.items(): + actual = token_files(token, ROOT / "Sources") + if actual != expected: + fail(f"Core runtime construction ownership changed for {token}: expected={expected}, actual={actual}") + + headless_text = "\n".join( + source.read_text() + for root in (ROOT / "Sources/RepoPromptHeadless", ROOT / "Tests/RepoPromptHeadlessTests") + for source in sorted(root.rglob("*.swift")) + ) + for token in ( + "RepoPromptEmbeddedWorkspaceRuntimeFactory", + "WorkspaceRuntimeDependencies(", + "WorkspaceFileContextStore(", + "WorkspaceSelectionController(", + "WorkspaceSearchService(", + ): + if token in headless_text: + fail(f"Reviewed headless surface constructs the Phase 2 runtime: {token}") + + assert_phase0_artifacts_unchanged() + verify_reviewed_headless_baseline(ROOT) + + print("OK: shared runtime Phase 2 boundaries passed.") + return 0 + + +if __name__ == "__main__": + try: + raise SystemExit(main()) + except (AssertionError, subprocess.CalledProcessError) as error: + print(f"ERROR: {error}", file=sys.stderr) + raise SystemExit(1) diff --git a/Scripts/test_shared_runtime_phase2_slice1_boundaries.py b/Scripts/test_shared_runtime_phase2_slice1_boundaries.py new file mode 100644 index 000000000..dca43647f --- /dev/null +++ b/Scripts/test_shared_runtime_phase2_slice1_boundaries.py @@ -0,0 +1,193 @@ +#!/usr/bin/env python3 +"""Historical Phase 2 Slice 1 workspace-authority and Phase 0 boundary checks.""" + +from __future__ import annotations + +import re +import subprocess +import sys +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] +PHASE0_ARTIFACT_BASELINE = "48a335e" +PHASE0_ROOT = "Tests/SharedRuntimeConvergenceFixtures/Phase0" + +CORE_FILES = [ + "Sources/RepoPromptCore/WorkspaceContext/Slices/LineRange.swift", + "Sources/RepoPromptCore/Workspaces/CodeMapUsage.swift", + "Sources/RepoPromptCore/Workspaces/CopyCustomizations.swift", + "Sources/RepoPromptCore/Workspaces/EmbeddedWorkspaceCodecV1.swift", + "Sources/RepoPromptCore/Workspaces/FileTreeOption.swift", + "Sources/RepoPromptCore/Workspaces/FilesTab.swift", + "Sources/RepoPromptCore/Workspaces/GitInclusion.swift", + "Sources/RepoPromptCore/Workspaces/WorkspaceModel.swift", + "Sources/RepoPromptCore/Workspaces/WorkspacePersistenceWriter.swift", + "Sources/RepoPromptCore/Workspaces/WorkspaceRepository.swift", + "Sources/RepoPromptCore/Workspaces/WorkspaceSaveMetadata.swift", + "Sources/RepoPromptCore/Workspaces/WorkspaceSessionController.swift", +] + +APP_ADAPTER_FILES = [ + "Sources/RepoPrompt/App/CoreAdapters/EmbeddedWorkspaceRepositoryDiagnosticsAdapter.swift", + "Sources/RepoPrompt/App/CoreAdapters/EmbeddedWorkspaceRepositoryFactory.swift", + "Sources/RepoPrompt/App/CoreAdapters/WorkspaceSessionObservationBridge.swift", + "Sources/RepoPrompt/App/CoreAdapters/WorkspaceSessionSelectionForwarder.swift", +] + +REMOVED_FILES = [ + "Sources/RepoPrompt/Features/Workspaces/Core/WorkspaceRepository.swift", + "Sources/RepoPrompt/Features/Workspaces/Core/WorkspaceSessionController.swift", + "Sources/RepoPrompt/Features/Prompt/Models/Copy/CopyCustomizations.swift", + "Sources/RepoPrompt/Infrastructure/WorkspaceContext/Slices/LineRange.swift", +] + + +def fail(message: str) -> None: + raise AssertionError(message) + + +def git_paths(revision: str, root: str) -> list[str]: + return subprocess.check_output( + ["git", "ls-tree", "-r", "--name-only", revision, root], cwd=ROOT, text=True + ).splitlines() + + +def git_bytes(revision: str, path: str) -> bytes: + return subprocess.check_output(["git", "show", f"{revision}:{path}"], cwd=ROOT) + + +def assert_phase0_fixtures_unchanged() -> None: + baseline_paths = git_paths(PHASE0_ARTIFACT_BASELINE, PHASE0_ROOT) + current_root = ROOT / PHASE0_ROOT + current_paths = sorted( + path.relative_to(ROOT).as_posix() for path in current_root.rglob("*") if path.is_file() + ) + if current_paths != baseline_paths: + fail( + "Frozen Phase 0 fixture path set changed relative to " + f"{PHASE0_ARTIFACT_BASELINE}: baseline={baseline_paths}, current={current_paths}" + ) + for relative in baseline_paths: + if (ROOT / relative).read_bytes() != git_bytes(PHASE0_ARTIFACT_BASELINE, relative): + fail( + "Frozen Phase 0 fixture changed relative to " + f"{PHASE0_ARTIFACT_BASELINE}: {relative}" + ) + + +def swift_sources(root: Path) -> list[Path]: + return sorted(root.rglob("*.swift")) + + +def constructor_files(token: str) -> list[str]: + matches: list[str] = [] + for source in swift_sources(ROOT / "Sources"): + if token in source.read_text(): + matches.append(source.relative_to(ROOT).as_posix()) + return matches + + +def main() -> int: + for relative in [*CORE_FILES, *APP_ADAPTER_FILES]: + if not (ROOT / relative).is_file(): + fail(f"Required Slice 1 file missing: {relative}") + for relative in REMOVED_FILES: + if (ROOT / relative).exists(): + fail(f"Retired pre-Slice 1 owner still exists: {relative}") + + core_workspace_text = "\n".join((ROOT / path).read_text() for path in CORE_FILES) + forbidden_core_tokens = [ + "import AppKit", + "import SwiftUI", + "import Combine", + "import Cocoa", + "import OSLog", + "import os", + "import Darwin", + "import Glibc", + "UserDefaults", + "Application Support", + "Bundle.main", + "Notification.Name", + "WorkspaceManagerViewModel", + ] + for token in forbidden_core_tokens: + if token in core_workspace_text: + fail(f"Core workspace authority contains app/platform token: {token}") + + all_core_text = "\n".join(path.read_text() for path in swift_sources(ROOT / "Sources/RepoPromptCore")) + if "CanonicalWorkspaceCodecV2(" in all_core_text: + fail("Canonical v2 codec is selected or constructed during Slice 1") + + expected_constructors = { + "WorkspacePersistenceWriter(": [ + "Sources/RepoPrompt/App/CoreAdapters/EmbeddedWorkspaceRepositoryFactory.swift" + ], + "WorkspaceRepository(": [ + "Sources/RepoPrompt/App/CoreAdapters/EmbeddedWorkspaceRepositoryFactory.swift" + ], + "WorkspaceSessionController(": [ + "Sources/RepoPrompt/Infrastructure/Core/RepoPromptCoreHost.swift" + ], + } + for token, expected in expected_constructors.items(): + actual = constructor_files(token) + if actual != expected: + fail(f"Production constructor ownership changed for {token}: expected={expected}, actual={actual}") + + manager_path = ROOT / "Sources/RepoPrompt/Features/Workspaces/ViewModels/WorkspaceManagerViewModel.swift" + manager = manager_path.read_text() + forbidden_manager_patterns = [ + r"@Published\s+(?:private\(set\)\s+)?var\s+workspaces\b", + r"@Published\s+(?:private\(set\)\s+)?var\s+activeWorkspaceID\b", + r"\bvar\s+workspaces\s*:\s*\[WorkspaceModel\]\s*=", + r"\bvar\s+activeWorkspaceID\s*:\s*UUID\?\s*=", + r"WorkspaceDiskWriter", + r"func\s+saveWorkspaceToFile\s*\(", + r"func\s+saveWorkspaceIndex\s*\(", + r"writeNormalizationIfUnchanged", + r"normalizationWriteback", + r"normalizationRequiresSave", + ] + for pattern in forbidden_manager_patterns: + if re.search(pattern, manager): + fail(f"WorkspaceManagerViewModel regained forbidden authority/read-write behavior: {pattern}") + required_manager_patterns = [ + r"var\s+workspaces\s*:\s*\[WorkspaceModel\]\s*\{\s*sessionController\.workspaces\s*\}", + r"var\s+activeWorkspaceID\s*:\s*UUID\?\s*\{\s*sessionController\.activeWorkspaceID\s*\}", + r"func\s+workspaceTransaction\s*\(", + r"func\s+mutateWorkspace\s*\(", + r"func\s+mutateComposeTab\s*\(", + ] + for pattern in required_manager_patterns: + if not re.search(pattern, manager, re.DOTALL): + fail(f"WorkspaceManagerViewModel missing controller projection/operation: {pattern}") + + direct_mutation = re.compile( + r"workspaceManager\.workspaces(?:\[[^\]]+\])?(?:\.[A-Za-z_][A-Za-z0-9_]*)?\s*=" + r"|workspaceManager\.workspaces\.(?:append|insert|remove|removeAll|swapAt)\s*\(" + ) + for source in swift_sources(ROOT / "Sources/RepoPrompt"): + if direct_mutation.search(source.read_text()): + fail(f"Direct app workspace mutation bypasses the controller: {source.relative_to(ROOT)}") + + forwarder = (ROOT / APP_ADAPTER_FILES[-1]).read_text() + if "Temporary Slice 1 bridge" not in forwarder or "Slice 2 deletes" not in forwarder: + fail("Temporary selection forwarder must carry its explicit Slice 2 deletion marker") + + phase0_baseline = git_paths(PHASE0_ARTIFACT_BASELINE, PHASE0_ROOT) + if not phase0_baseline: + fail(f"No Phase 0 fixtures found at {PHASE0_ARTIFACT_BASELINE}") + assert_phase0_fixtures_unchanged() + + print("OK: shared runtime Phase 2 Slice 1 workspace-authority boundaries passed.") + return 0 + + +if __name__ == "__main__": + try: + raise SystemExit(main()) + except (AssertionError, subprocess.CalledProcessError) as error: + print(f"ERROR: {error}", file=sys.stderr) + raise SystemExit(1) diff --git a/Sources/RepoPrompt/App/AppDelegate.swift b/Sources/RepoPrompt/App/AppDelegate.swift index ab220708d..b4a38b1a7 100644 --- a/Sources/RepoPrompt/App/AppDelegate.swift +++ b/Sources/RepoPrompt/App/AppDelegate.swift @@ -78,14 +78,17 @@ class AppDelegate: NSObject, ObservableObject, NSApplicationDelegate { // ─────────────────────────────────────────────────── // Register global MCP app-wide helpers + let networkManager = ServerNetworkManager.shared let appSettingsMCPService = AppSettingsMCPService() - ServiceRegistry.register(appSettingsMCPService) + networkManager.serviceRegistry.register(appSettingsMCPService) self.appSettingsMCPService = appSettingsMCPService // Register global MCP window-routing helpers windowRoutingService = WindowRoutingService( windowStates: WindowStatesManager.shared, - networkMgr: ServerNetworkManager.shared + networkMgr: networkManager, + serviceRegistry: networkManager.serviceRegistry, + workspaceRepository: RepoPromptAppCoreContainer.shared.workspaceRepository ) if !launchConfiguration.suppressesNonessentialLaunchSideEffects { // Request notification authorization @@ -163,6 +166,8 @@ class AppDelegate: NSObject, ObservableObject, NSApplicationDelegate { // so child processes are terminated and reaped rather than orphaned on quit. await WindowStatesManager.shared.shutdownAllAgentSessions() await WindowStatesManager.shared.stopAllServers() + await WindowStatesManager.shared.awaitDrainingWindowFinalizers() + RepoPromptAppCoreContainer.shared.shutdownForAppTermination() sender.reply(toApplicationShouldTerminate: true) } @@ -177,6 +182,7 @@ class AppDelegate: NSObject, ObservableObject, NSApplicationDelegate { MCPBackgroundModeCoordinator.shared.resetForTermination() WindowStatesManager.shared.signalTermination() ProcessTermination.beginAppTerminationFastPath() + RepoPromptAppCoreContainer.shared.shutdownForAppTermination() if !AppLaunchConfiguration.current.suppressesWindowPersistence { WindowStatesManager.shared.persistWindowSession(reason: "appWillTerminate") } diff --git a/Sources/RepoPrompt/App/ApplicationSecurity.swift b/Sources/RepoPrompt/App/ApplicationSecurity.swift index 68df5f49d..e22c0dae1 100644 --- a/Sources/RepoPrompt/App/ApplicationSecurity.swift +++ b/Sources/RepoPrompt/App/ApplicationSecurity.swift @@ -6,6 +6,7 @@ // import Cocoa +import Darwin import Foundation import MachO import os.lock @@ -13,6 +14,7 @@ import os.lock /// This class handles application security by monitoring the environment /// for potential tampering or unauthorized access. class ApplicationSecurity { + private static let denyAttachRequest: Int32 = 31 // Singleton instance private static let shared = ApplicationSecurity() private let stateQueue = DispatchQueue(label: "com.repoprompt.security.state") @@ -187,7 +189,7 @@ class ApplicationSecurity { /// Prevent external attachment - uses ptrace to deny debugger attachment private func preventExternalAttachment() { #if !DEBUG - ptrace(PT_DENY_ATTACH, 0, nil, 0) + ptrace(Self.denyAttachRequest, 0, nil, 0) #endif } diff --git a/Sources/RepoPrompt/App/CoreAdapters/CodeMapExtractor+AppAdapters.swift b/Sources/RepoPrompt/App/CoreAdapters/CodeMapExtractor+AppAdapters.swift new file mode 100644 index 000000000..941c6b2d2 --- /dev/null +++ b/Sources/RepoPrompt/App/CoreAdapters/CodeMapExtractor+AppAdapters.swift @@ -0,0 +1,77 @@ +import Foundation +import RepoPromptCore + +extension CodeMapExtractor { + @MainActor + static func makeFileTreeSnapshot(using context: FileTreeSelectionContext) -> FileTreeSelectionSnapshot { + var roots: [FileTreeFolderSnapshot] = [] + roots.reserveCapacity(context.rootFolders.count) + for root in context.rootFolders { + var visited = Set() + if let snapshot = appSnapshot( + folder: root, + rootStandardizedPath: root.standardizedFullPath, + visited: &visited + ) { + roots.append(snapshot) + } + } + let mode = switch context.option { + case .none: "none" + case .selected: "selected" + case .files: "full" + case .auto: "auto" + } + return FileTreeSelectionSnapshot( + roots: roots, + selectedFileIDs: context.selectedFileIDs, + mode: mode, + showFullPaths: context.filePathDisplay == .full, + onlyIncludeRootsWithSelectedFiles: context.onlyIncludeRootsWithSelectedFiles, + includeLegend: context.includeLegend, + showCodeMapMarkers: context.showCodeMapMarkers + ) + } + + static func generateFileTree(using snapshot: FileTreeSelectionSnapshot) -> String { + FileTreeSnapshotRenderer.generateFileTree(using: snapshot) + } + + @MainActor + private static func appSnapshot( + folder: FolderViewModel, + rootStandardizedPath: String, + visited: inout Set + ) -> FileTreeFolderSnapshot? { + guard visited.insert(folder.id).inserted else { return nil } + var children: [FileTreeNodeSnapshot] = [] + children.reserveCapacity(folder.children.count) + for child in folder.children { + switch child { + case let .folder(subfolder): + if let childSnapshot = appSnapshot( + folder: subfolder, + rootStandardizedPath: rootStandardizedPath, + visited: &visited + ) { + children.append(.folder(childSnapshot)) + } + case let .file(file): + children.append(.file(FileTreeFileSnapshot( + id: file.id, + name: file.name, + fileExtension: file.fileExtension, + hasCodeMap: file.hasAcceptedCodeMap + ))) + } + } + return FileTreeFolderSnapshot( + id: folder.id, + name: folder.name, + fullPath: folder.fullPath, + standardizedFullPath: folder.standardizedFullPath, + standardizedRootPath: rootStandardizedPath, + children: children + ) + } +} diff --git a/Sources/RepoPrompt/App/CoreAdapters/EmbeddedPartitionStoreEventAdapter.swift b/Sources/RepoPrompt/App/CoreAdapters/EmbeddedPartitionStoreEventAdapter.swift new file mode 100644 index 000000000..33d3b0e84 --- /dev/null +++ b/Sources/RepoPrompt/App/CoreAdapters/EmbeddedPartitionStoreEventAdapter.swift @@ -0,0 +1,23 @@ +import Foundation +import RepoPromptCore + +enum EmbeddedPartitionStoreEventAdapter { + static let didSaveNotification = Notification.Name("RepoPrompt.PartitionStoreDidSave") + static let rootPathKey = "rootPath" + static let workspaceIDKey = "workspaceID" + static let tabIDKey = "tabID" + static let sourceIDKey = "sourceID" + + static let sink: PartitionStoreSaveEventSink = { event in + NotificationCenter.default.post( + name: didSaveNotification, + object: nil, + userInfo: [ + rootPathKey: event.rootPath, + workspaceIDKey: event.scope.workspaceID, + tabIDKey: event.scope.tabID as Any, + sourceIDKey: event.sourceID + ] + ) + } +} diff --git a/Sources/RepoPrompt/App/CoreAdapters/EmbeddedWorkspaceFileMutationBackend.swift b/Sources/RepoPrompt/App/CoreAdapters/EmbeddedWorkspaceFileMutationBackend.swift new file mode 100644 index 000000000..5a873b6fc --- /dev/null +++ b/Sources/RepoPrompt/App/CoreAdapters/EmbeddedWorkspaceFileMutationBackend.swift @@ -0,0 +1,113 @@ +import Darwin +import Foundation +import RepoPromptCore + +struct EmbeddedWorkspaceFileMutationBackend: WorkspaceFileMutationBackend { + func createDirectory(at url: URL) throws { + try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true) + } + + func createFile(at url: URL, contents: Data?) throws { + try writeRobust(contents ?? Data(), to: url, atomically: true) + } + + func write(_ data: Data, to url: URL, atomically: Bool) throws { + try writeRobust(data, to: url, atomically: atomically) + } + + func moveItem(at sourceURL: URL, to destinationURL: URL) throws { + try FileManager.default.moveItem(at: sourceURL, to: destinationURL) + } + + func removeItem(at url: URL) throws { + try FileManager.default.removeItem(at: url) + } + + func trashItem(at url: URL) throws { + var resultingItemURL: NSURL? + try FileManager.default.trashItem(at: url, resultingItemURL: &resultingItemURL) + } + + func fileExists(atPath path: String, isDirectory: inout Bool) -> Bool { + var value = ObjCBool(isDirectory) + let exists = FileManager.default.fileExists(atPath: path, isDirectory: &value) + isDirectory = value.boolValue + return exists + } + + func modificationDate(at url: URL) throws -> Date { + let attributes = try FileManager.default.attributesOfItem(atPath: url.path) + return attributes[.modificationDate] as? Date ?? Date() + } + + private func writeRobust(_ data: Data, to url: URL, atomically: Bool) throws { + if !atomically { + try data.write(to: url) + return + } + + do { + try data.write(to: url, options: .atomic) + return + } catch { + // External and network volumes can reject Foundation replacement semantics. + } + + let fileManager = FileManager.default + let temporaryURL = url.deletingLastPathComponent() + .appendingPathComponent(".repoprompt.tmp.\(UUID().uuidString)") + do { + try data.write(to: temporaryURL) + if fileManager.fileExists(atPath: url.path) { + try? fileManager.removeItem(at: url) + } + try fileManager.moveItem(at: temporaryURL, to: url) + return + } catch { + try? fileManager.removeItem(at: temporaryURL) + } + + try writePOSIX(data, to: url) + } + + private func writePOSIX(_ data: Data, to url: URL) throws { + let path = url.path + let descriptor = open(path, O_WRONLY | O_CREAT | O_TRUNC, 0o644) + guard descriptor >= 0 else { + throw posixError(operation: "open", path: path, code: errno) + } + + var operationError: Int32? + data.withUnsafeBytes { buffer in + guard var baseAddress = buffer.baseAddress?.assumingMemoryBound(to: UInt8.self) else { return } + var remaining = buffer.count + while remaining > 0 { + let written = Darwin.write(descriptor, baseAddress, remaining) + if written < 0 { + operationError = errno + break + } + remaining -= written + baseAddress = baseAddress.advanced(by: written) + } + } + if operationError == nil, fsync(descriptor) != 0 { + operationError = errno + } + let closeResult = close(descriptor) + if let operationError { + throw posixError(operation: "write/fsync", path: path, code: operationError) + } + if closeResult != 0 { + throw posixError(operation: "close", path: path, code: errno) + } + } + + private func posixError(operation: String, path: String, code: Int32) -> NSError { + NSError( + domain: NSPOSIXErrorDomain, + code: Int(code), + userInfo: [NSLocalizedDescriptionKey: "\(operation) failed for \(path) (\(code))"] + ) + } +} diff --git a/Sources/RepoPrompt/App/CoreAdapters/EmbeddedWorkspaceRepositoryDiagnosticsAdapter.swift b/Sources/RepoPrompt/App/CoreAdapters/EmbeddedWorkspaceRepositoryDiagnosticsAdapter.swift new file mode 100644 index 000000000..436974625 --- /dev/null +++ b/Sources/RepoPrompt/App/CoreAdapters/EmbeddedWorkspaceRepositoryDiagnosticsAdapter.swift @@ -0,0 +1,111 @@ +import Foundation +import RepoPromptCore + +final class EmbeddedWorkspaceRepositoryDiagnosticsAdapter: WorkspaceRepositoryDiagnosticsSink, @unchecked Sendable { + #if DEBUG + private enum OperationKind: String { + case flush + case write + } + + private struct OperationKey: Hashable { + let kind: OperationKind + let urlID: String + let sequence: String + } + + private let lock = NSLock() + private var enqueueCorrelations: [OperationKey: EditFlowPerf.LifecycleCorrelation] = [:] + private var activeCorrelations: [OperationKey: EditFlowPerf.LifecycleCorrelation] = [:] + private var activeIntervals: [OperationKey: EditFlowPerf.IntervalState] = [:] + #endif + + func record(_ diagnostic: WorkspaceRepositoryDiagnostic) { + #if DEBUG + switch diagnostic { + case let .warning(code, message): + WorkspaceRestorePerfLog.event("workspaceRepository.warning", fields: ["code": code, "message": message]) + case let .recovery(code, message): + WorkspaceRestorePerfLog.event("workspaceRepository.recovery", fields: ["code": code, "message": message]) + case let .event(name, fields): + recordEditFlowEvent(name: name, fields: fields) + WorkspaceRestorePerfLog.event(name, fields: fields) + } + #endif + } + + #if DEBUG + private func recordEditFlowEvent(name: String, fields: [String: String]) { + guard let urlID = fields["urlID"], let sequence = fields["sequence"] else { return } + + switch name { + case "workspaceSave.enqueue": + guard let correlation = EditFlowPerf.currentLifecycleCorrelation else { return } + lock.lock() + enqueueCorrelations[OperationKey(kind: .write, urlID: urlID, sequence: sequence)] = correlation + lock.unlock() + + case "workspaceSave.flush.begin": + begin( + key: OperationKey(kind: .flush, urlID: urlID, sequence: sequence), + stage: EditFlowPerf.Stage.WorkspaceDurability.flushWait, + lifecycle: EditFlowPerf.Lifecycle.WorkspaceDurability.flushBegan, + correlation: EditFlowPerf.currentLifecycleCorrelation + ) + + case "workspaceSave.flush.end": + end( + key: OperationKey(kind: .flush, urlID: urlID, sequence: sequence), + stage: EditFlowPerf.Stage.WorkspaceDurability.flushWait, + lifecycle: EditFlowPerf.Lifecycle.WorkspaceDurability.flushEnded + ) + + case "workspaceSave.write.begin": + let key = OperationKey(kind: .write, urlID: urlID, sequence: sequence) + lock.lock() + let correlation = enqueueCorrelations[key] + lock.unlock() + begin( + key: key, + stage: EditFlowPerf.Stage.WorkspaceDurability.atomicWrite, + lifecycle: EditFlowPerf.Lifecycle.WorkspaceDurability.writeBegan, + correlation: correlation + ) + + case "workspaceSave.write.end": + end( + key: OperationKey(kind: .write, urlID: urlID, sequence: sequence), + stage: EditFlowPerf.Stage.WorkspaceDurability.atomicWrite, + lifecycle: EditFlowPerf.Lifecycle.WorkspaceDurability.writeEnded + ) + + default: + break + } + } + + private func begin( + key: OperationKey, + stage: StaticString, + lifecycle: StaticString, + correlation: EditFlowPerf.LifecycleCorrelation? + ) { + let interval = EditFlowPerf.begin(stage) + EditFlowPerf.lifecycleEvent(lifecycle, correlation: correlation) + lock.lock() + if let correlation { activeCorrelations[key] = correlation } + if let interval { activeIntervals[key] = interval } + lock.unlock() + } + + private func end(key: OperationKey, stage: StaticString, lifecycle: StaticString) { + lock.lock() + let correlation = activeCorrelations.removeValue(forKey: key) + let interval = activeIntervals.removeValue(forKey: key) + enqueueCorrelations.removeValue(forKey: key) + lock.unlock() + EditFlowPerf.lifecycleEvent(lifecycle, correlation: correlation) + EditFlowPerf.end(stage, interval) + } + #endif +} diff --git a/Sources/RepoPrompt/App/CoreAdapters/EmbeddedWorkspaceRepositoryFactory.swift b/Sources/RepoPrompt/App/CoreAdapters/EmbeddedWorkspaceRepositoryFactory.swift new file mode 100644 index 000000000..858498e7a --- /dev/null +++ b/Sources/RepoPrompt/App/CoreAdapters/EmbeddedWorkspaceRepositoryFactory.swift @@ -0,0 +1,34 @@ +import Foundation +import RepoPromptCore + +@MainActor +final class EmbeddedWorkspaceRepositoryRootProvider: WorkspaceRepositoryRootProviding { + func repositoryRoot() async -> URL { + if let path = UserDefaults.standard.string(forKey: "GlobalCustomStorageURL") { + return URL(fileURLWithPath: path, isDirectory: true) + } + return WorkspaceStoragePaths.defaultRoot + } +} + +@MainActor +enum EmbeddedWorkspaceRepositoryFactory { + struct Graph { + let writer: WorkspacePersistenceWriter + let repository: WorkspaceRepository + } + + static func make() -> Graph { + let codec = EmbeddedWorkspaceCodecV1() + let diagnostics = EmbeddedWorkspaceRepositoryDiagnosticsAdapter() + let writer = WorkspacePersistenceWriter(codec: codec, diagnostics: diagnostics) + let repository = WorkspaceRepository( + rootProvider: EmbeddedWorkspaceRepositoryRootProvider(), + codec: codec, + writer: writer, + diagnostics: diagnostics, + migrationService: NoopWorkspaceLegacyMigrationService() + ) + return Graph(writer: writer, repository: repository) + } +} diff --git a/Sources/RepoPrompt/App/CoreAdapters/FileSystemService+AppPublication.swift b/Sources/RepoPrompt/App/CoreAdapters/FileSystemService+AppPublication.swift new file mode 100644 index 000000000..720ed2d52 --- /dev/null +++ b/Sources/RepoPrompt/App/CoreAdapters/FileSystemService+AppPublication.swift @@ -0,0 +1,44 @@ +import Combine +import RepoPromptCore + +private final class FileSystemDeltaPublisherBridge: @unchecked Sendable { + let subject = PassthroughSubject() + var subscription: FileSystemDeltaPublicationSubscription? + + func receive(_ publication: FileSystemDeltaPublication) -> Bool { + guard let correlationID = publication.correlationID else { + subject.send(publication) + return true + } + let active = EditFlowPerf.makeLifecycleCorrelationIfActive() + let correlation = EditFlowPerf.LifecycleCorrelation( + id: correlationID, + captureEpoch: active?.captureEpoch + ) + EditFlowPerf.$currentFileSystemPublicationCorrelation.withValue(correlation) { + subject.send(publication) + } + return true + } + + func cancel() { + subscription?.cancel() + subscription = nil + } +} + +extension FileSystemService { + /// App-only Combine and EditFlowPerf adaptation over Core's synchronous publication seam. + nonisolated func publisherForChanges() -> AnyPublisher { + let bridge = FileSystemDeltaPublisherBridge() + bridge.subscription = subscribeToChanges { [bridge] publication in + bridge.receive(publication) + } + return bridge.subject + .handleEvents( + receiveCancel: { [bridge] in bridge.cancel() }, + receiveRequest: { [bridge] _ in _ = bridge } + ) + .eraseToAnyPublisher() + } +} diff --git a/Sources/RepoPrompt/App/CoreAdapters/FileViewModel+CoreSearch.swift b/Sources/RepoPrompt/App/CoreAdapters/FileViewModel+CoreSearch.swift new file mode 100644 index 000000000..0d4dfb69e --- /dev/null +++ b/Sources/RepoPrompt/App/CoreAdapters/FileViewModel+CoreSearch.swift @@ -0,0 +1,107 @@ +import Foundation + +private extension FileViewModel { + var coreSearchDescriptor: SearchFileDescriptor { + SearchFileDescriptor( + id: id, + name: name, + relativePath: relativePath, + standardizedRelativePath: standardizedRelativePath, + fullPath: fullPath, + standardizedFullPath: standardizedFullPath, + standardizedRootFolderPath: standardizedRootFolderPath, + fileExtension: fileExtension, + contentSnapshot: { [self] policy in + await searchContentSnapshot(freshnessPolicy: policy) + } + ) + } +} + +extension FileSearchActor { + func search( + pattern: String, + isRegex: Bool = false, + wasAutoCorrected: inout Bool?, + options: SearchOptions = SearchOptions(), + in files: [FileViewModel] + ) async throws -> [SearchMatch] { + try await search( + pattern: pattern, + isRegex: isRegex, + wasAutoCorrected: &wasAutoCorrected, + options: options, + in: files.map(\.coreSearchDescriptor) + ) + } + + func search( + pattern: String, + isRegex: Bool = false, + options: SearchOptions = SearchOptions(), + in files: [FileViewModel] + ) async throws -> [SearchMatch] { + var wasAutoCorrected: Bool? = nil + return try await search( + pattern: pattern, + isRegex: isRegex, + wasAutoCorrected: &wasAutoCorrected, + options: options, + in: files + ) + } + + func searchPaths( + pattern: String, + limit: Int = 100, + in files: [FileViewModel], + caseInsensitive: Bool = true, + isRegex: Bool = false, + aliasByRootPath: [String: String]? = nil + ) async throws -> [String] { + try await searchPaths( + pattern: pattern, + limit: limit, + in: files.map(\.coreSearchDescriptor), + caseInsensitive: caseInsensitive, + isRegex: isRegex, + aliasByRootPath: aliasByRootPath + ) + } + + func searchUnified( + pattern: String, + isRegex: Bool = false, + wasAutoCorrected: inout Bool?, + options: SearchOptions = SearchOptions(), + in files: [FileViewModel], + aliasByRootPath: [String: String]? = nil + ) async throws -> SearchResults { + try await searchUnified( + pattern: pattern, + isRegex: isRegex, + wasAutoCorrected: &wasAutoCorrected, + options: options, + in: files.map(\.coreSearchDescriptor), + aliasByRootPath: aliasByRootPath + ) + } + + func searchUnified( + pattern: String, + isRegex: Bool = false, + options: SearchOptions = SearchOptions(), + in files: [FileViewModel], + aliasByRootPath: [String: String]? = nil + ) async throws -> SearchResults { + var wasAutoCorrected: Bool? = nil + return try await searchUnified( + pattern: pattern, + isRegex: isRegex, + wasAutoCorrected: &wasAutoCorrected, + options: options, + in: files, + aliasByRootPath: aliasByRootPath + ) + } +} diff --git a/Sources/RepoPrompt/App/CoreAdapters/PathMatchingViewModelAdapters.swift b/Sources/RepoPrompt/App/CoreAdapters/PathMatchingViewModelAdapters.swift new file mode 100644 index 000000000..97d4b6fd5 --- /dev/null +++ b/Sources/RepoPrompt/App/CoreAdapters/PathMatchingViewModelAdapters.swift @@ -0,0 +1,24 @@ +import RepoPromptCore + +extension FrozenFileRecord { + init(from viewModel: FileViewModel) { + self.init( + name: viewModel.name, + relativePath: viewModel.relativePath, + fullPath: viewModel.standardizedFullPath, + rootFolderPath: viewModel.standardizedRootFolderPath + ) + } +} + +extension FrozenFolderRecord { + init(from viewModel: FolderViewModel) { + self.init( + name: viewModel.name, + relativePath: viewModel.relativePath, + fullPath: viewModel.standardizedFullPath, + rootPath: viewModel.rootPath, + displayName: viewModel.name + ) + } +} diff --git a/Sources/RepoPrompt/App/CoreAdapters/RepoPromptCoreRuntimeAliases.swift b/Sources/RepoPrompt/App/CoreAdapters/RepoPromptCoreRuntimeAliases.swift new file mode 100644 index 000000000..faffa2e00 --- /dev/null +++ b/Sources/RepoPrompt/App/CoreAdapters/RepoPromptCoreRuntimeAliases.swift @@ -0,0 +1,209 @@ +import RepoPromptCore + +// Compatibility names for app-owned adapters while Slice 2 moves the neutral runtime +// implementation into RepoPromptCore. These aliases do not create a second owner. +typealias InterfaceInfo = RepoPromptCore.InterfaceInfo +typealias TypeAliasInfo = RepoPromptCore.TypeAliasInfo +typealias ClassInfo = RepoPromptCore.ClassInfo +typealias FunctionInfo = RepoPromptCore.FunctionInfo +typealias ParameterInfo = RepoPromptCore.ParameterInfo +typealias PropertyInfo = RepoPromptCore.PropertyInfo +typealias VariableInfo = RepoPromptCore.VariableInfo +typealias EnumInfo = RepoPromptCore.EnumInfo +typealias FileAPI = RepoPromptCore.FileAPI + +typealias LanguageType = RepoPromptCore.LanguageType +typealias SyntaxManager = RepoPromptCore.SyntaxManager + +typealias CodeMapGenerator = RepoPromptCore.CodeMapGenerator +typealias CodeMapPerfOptions = RepoPromptCore.CodeMapPerfOptions +typealias CodeMapSyntaxStartupPerfStats = RepoPromptCore.CodeMapSyntaxStartupPerfStats +typealias CodeMapSyntaxPerfStats = RepoPromptCore.CodeMapSyntaxPerfStats +typealias CodeMapPipelinePerfSnapshot = RepoPromptCore.CodeMapPipelinePerfSnapshot +typealias CodeMapPipelinePerfStats = RepoPromptCore.CodeMapPipelinePerfStats +typealias CodeMapPerfRuntime = RepoPromptCore.CodeMapPerfRuntime +typealias CodeMapPerfStats = RepoPromptCore.CodeMapPerfStats +typealias CodeScanActor = RepoPromptCore.CodeScanActor + +typealias FileTreeSelectionSnapshot = RepoPromptCore.FileTreeSelectionSnapshot +typealias FileTreeFolderSnapshot = RepoPromptCore.FileTreeFolderSnapshot +typealias FileTreeFileSnapshot = RepoPromptCore.FileTreeFileSnapshot +typealias FileTreeNodeSnapshot = RepoPromptCore.FileTreeNodeSnapshot +typealias FilePathDisplay = RepoPromptCore.FilePathDisplay +typealias FrozenFileRecord = RepoPromptCore.FrozenFileRecord +typealias FrozenFolderRecord = RepoPromptCore.FrozenFolderRecord +typealias FileSystemItem = RepoPromptCore.FileSystemItem +typealias File = RepoPromptCore.File +typealias Folder = RepoPromptCore.Folder +typealias FileSystemProviding = RepoPromptCore.FileSystemProviding +typealias RelativePath = RepoPromptCore.RelativePath + +typealias WorkspaceSliceSegment = RepoPromptCore.WorkspaceSliceSegment +typealias WorkspaceSliceAssembly = RepoPromptCore.WorkspaceSliceAssembly +typealias SliceAssemblyBuilder = RepoPromptCore.SliceAssemblyBuilder +typealias SliceRangeMath = RepoPromptCore.SliceRangeMath + +typealias PromptFileEntrySnapshot = RepoPromptCore.PromptFileEntrySnapshot +typealias TokenCalculationFileTreeInput = RepoPromptCore.TokenCalculationFileTreeInput +typealias TokenCalculationSnapshot = RepoPromptCore.TokenCalculationSnapshot +typealias TokenInfo = RepoPromptCore.TokenInfo +typealias TokenCalculationResult = RepoPromptCore.TokenCalculationResult +typealias TokenComponentBreakdown = RepoPromptCore.TokenComponentBreakdown +typealias PromptEntriesEvaluation = RepoPromptCore.PromptEntriesEvaluation +typealias TokenCalculationService = RepoPromptCore.TokenCalculationService +typealias PromptContextAccountingRequest = RepoPromptCore.PromptContextAccountingRequest +typealias PromptContextAccountingResolution = RepoPromptCore.PromptContextAccountingResolution +typealias PromptContextAccountingResult = RepoPromptCore.PromptContextAccountingResult + +typealias RegexPatternFailure = RepoPromptCore.RegexPatternFailure +typealias RegexToolkit = RepoPromptCore.RegexToolkit +typealias SearchPatternError = RepoPromptCore.SearchPatternError +typealias SearchPatternErrorFormatter = RepoPromptCore.SearchPatternErrorFormatter +typealias RepoPromptPCRE2CompileRequest = RepoPromptCore.RepoPromptPCRE2CompileRequest +typealias RepoPromptPCRE2CompileResult = RepoPromptCore.RepoPromptPCRE2CompileResult +typealias RepoPromptPCRE2Adapter = RepoPromptCore.RepoPromptPCRE2Adapter +typealias RepoPromptPCRE2MatchPolicy = RepoPromptCore.RepoPromptPCRE2MatchPolicy +typealias PCRE2Regex = RepoPromptCore.PCRE2Regex +typealias PCRE2Error = RepoPromptCore.PCRE2Error +typealias PCRE2LinePrefilter = RepoPromptCore.PCRE2LinePrefilter +typealias PCRE2LineScanOptions = RepoPromptCore.PCRE2LineScanOptions +typealias PCRE2ASCIIMarkerLinePattern = RepoPromptCore.PCRE2ASCIIMarkerLinePattern +typealias PCRE2ASCIIWholeWordLiteral = RepoPromptCore.PCRE2ASCIIWholeWordLiteral +typealias PCRE2AnchoredDeclarationLinePattern = RepoPromptCore.PCRE2AnchoredDeclarationLinePattern +typealias PCRE2PathSuffixPattern = RepoPromptCore.PCRE2PathSuffixPattern + +// Canonical workspace runtime compatibility names. +typealias AgentSupportDirectoryCatalog = RepoPromptCore.AgentSupportDirectoryCatalog +typealias CatalogRegularFileIneligibilityReason = RepoPromptCore.CatalogRegularFileIneligibilityReason +typealias ClientPathFormatter = RepoPromptCore.ClientPathFormatter +typealias CreatePathPreflight = RepoPromptCore.CreatePathPreflight +typealias CreationResolutionMode = RepoPromptCore.CreationResolutionMode +typealias DeferredReplayBufferDiagnostics = RepoPromptCore.DeferredReplayBufferDiagnostics +typealias DeferredReplayIngressResult = RepoPromptCore.DeferredReplayIngressResult +typealias DeltaReplayPreparationActor = RepoPromptCore.DeltaReplayPreparationActor +typealias FileCreationResult = RepoPromptCore.FileCreationResult +typealias FileSystemDelta = RepoPromptCore.FileSystemDelta +typealias FileSystemDeltaPreparation = RepoPromptCore.FileSystemDeltaPreparation +typealias FileSystemError = RepoPromptCore.FileSystemError +typealias FileManagerError = RepoPromptCore.FileManagerError +typealias FileSystemService = RepoPromptCore.FileSystemService +typealias ContentReadAsyncLimiter = RepoPromptCore.ContentReadAsyncLimiter +typealias GitDiffPathNormalization = RepoPromptCore.GitDiffPathNormalization +typealias GitignoreCompiler = RepoPromptCore.GitignoreCompiler +typealias IgnoreSettingsDefaults = RepoPromptCore.IgnoreSettingsDefaults +typealias MovePathResolver = RepoPromptCore.MovePathResolver +typealias PartitionScope = RepoPromptCore.PartitionScope +typealias PartitionStore = RepoPromptCore.PartitionStore +typealias PathLocateProfile = RepoPromptCore.PathLocateProfile +typealias PathResolutionIssue = RepoPromptCore.PathResolutionIssue +typealias PathResolutionIssueRenderer = RepoPromptCore.PathResolutionIssueRenderer +typealias PathSearchIndex = RepoPromptCore.PathSearchIndex +typealias PreparedFileSystemDelta = RepoPromptCore.PreparedFileSystemDelta +typealias PreparedFileSystemReplayBatch = RepoPromptCore.PreparedFileSystemReplayBatch +typealias PreparedFileSystemReplayChunk = RepoPromptCore.PreparedFileSystemReplayChunk +typealias RepoSearchBatchScorer = RepoPromptCore.RepoSearchBatchScorer +typealias RepoSearchQuery = RepoPromptCore.RepoSearchQuery +typealias RepoSearchQueryFactory = RepoPromptCore.RepoSearchQueryFactory +typealias ResolvedPromptFileBlockRecord = RepoPromptCore.ResolvedPromptFileBlockRecord +typealias ResolvedPromptFileEntry = RepoPromptCore.ResolvedPromptFileEntry +typealias ResolvedPromptFileEntryID = RepoPromptCore.ResolvedPromptFileEntryID +typealias RootAliasOptions = RepoPromptCore.RootAliasOptions +typealias RootAliasResolution = RepoPromptCore.RootAliasResolution +typealias SelectionSliceCoordinator = RepoPromptCore.SelectionSliceCoordinator +typealias WorkspaceSelectionController = RepoPromptCore.WorkspaceSelectionController +typealias WorkspaceSelectionObservationToken = RepoPromptCore.WorkspaceSelectionObservationToken +typealias WorkspaceSelectionMutationService = RepoPromptCore.WorkspaceSelectionMutationService +typealias WorkspaceSelectionSliceInput = RepoPromptCore.WorkspaceSelectionSliceInput +typealias WorkspaceBuildSelectionResult = RepoPromptCore.WorkspaceBuildSelectionResult +typealias WorkspaceAddSelectionResult = RepoPromptCore.WorkspaceAddSelectionResult +typealias WorkspaceRemoveSelectionResult = RepoPromptCore.WorkspaceRemoveSelectionResult +typealias WorkspaceDemoteSelectionResult = RepoPromptCore.WorkspaceDemoteSelectionResult +typealias WorkspaceSliceSelectionMutationResult = RepoPromptCore.WorkspaceSliceSelectionMutationResult +typealias SliceAnchor = RepoPromptCore.SliceAnchor +typealias SliceMutationMode = RepoPromptCore.SliceMutationMode +typealias SliceRebaseEngine = RepoPromptCore.SliceRebaseEngine +typealias StandardizedPath = RepoPromptCore.StandardizedPath +typealias StoredSelectionPathNormalization = RepoPromptCore.StoredSelectionPathNormalization +typealias WorkspaceAliasResolver = RepoPromptCore.WorkspaceAliasResolver +typealias WorkspaceAppliedIndexBatchEvent = RepoPromptCore.WorkspaceAppliedIndexBatchEvent +typealias WorkspaceCatalogDiagnostics = RepoPromptCore.WorkspaceCatalogDiagnostics +typealias WorkspaceCodemapOnlyCandidates = RepoPromptCore.WorkspaceCodemapOnlyCandidates +typealias WorkspaceCodemapSnapshot = RepoPromptCore.WorkspaceCodemapSnapshot +typealias WorkspaceCodemapUpdateEvent = RepoPromptCore.WorkspaceCodemapUpdateEvent +typealias WorkspaceExternalReadableFile = RepoPromptCore.WorkspaceExternalReadableFile +typealias WorkspaceFileCatalogMaterializationResult = RepoPromptCore.WorkspaceFileCatalogMaterializationResult +typealias WorkspaceFileContextStore = RepoPromptCore.WorkspaceFileContextStore +typealias WorkspaceFileRecord = RepoPromptCore.WorkspaceFileRecord +typealias WorkspaceFileSystemDeltaEvent = RepoPromptCore.WorkspaceFileSystemDeltaEvent +typealias WorkspaceFileTreeSnapshotMode = RepoPromptCore.WorkspaceFileTreeSnapshotMode +typealias WorkspaceFileTreeSnapshotRequest = RepoPromptCore.WorkspaceFileTreeSnapshotRequest +typealias WorkspaceFolderRecord = RepoPromptCore.WorkspaceFolderRecord +typealias WorkspaceIngressBarrierSample = RepoPromptCore.WorkspaceIngressBarrierSample +typealias WorkspaceLookupRootScope = RepoPromptCore.WorkspaceLookupRootScope +typealias WorkspaceObservedCodemapResult = RepoPromptCore.WorkspaceObservedCodemapResult +typealias WorkspacePathLocation = RepoPromptCore.WorkspacePathLocation +typealias WorkspacePathLookupRequest = RepoPromptCore.WorkspacePathLookupRequest +typealias WorkspacePathLookupResult = RepoPromptCore.WorkspacePathLookupResult +typealias WorkspaceReadableFileHandle = RepoPromptCore.WorkspaceReadableFileHandle +typealias WorkspaceReadableFileService = RepoPromptCore.WorkspaceReadableFileService +typealias WorkspaceResolvedCandidates = RepoPromptCore.WorkspaceResolvedCandidates +typealias WorkspaceRootKind = RepoPromptCore.WorkspaceRootKind +typealias WorkspaceRootLoadFailure = RepoPromptCore.WorkspaceRootLoadFailure +typealias WorkspaceRootRecord = RepoPromptCore.WorkspaceRootRecord +typealias WorkspaceRootRef = RepoPromptCore.WorkspaceRootRef +typealias WorkspaceSearchCatalogEntry = RepoPromptCore.WorkspaceSearchCatalogEntry +typealias WorkspaceSearchCatalogSnapshot = RepoPromptCore.WorkspaceSearchCatalogSnapshot +typealias WorkspaceSearchReadinessPhase = RepoPromptCore.WorkspaceSearchReadinessPhase +typealias WorkspaceSearchReadinessSnapshot = RepoPromptCore.WorkspaceSearchReadinessSnapshot +typealias WorkspaceSearchReadinessSource = RepoPromptCore.WorkspaceSearchReadinessSource +typealias WorkspaceSearchReadinessState = RepoPromptCore.WorkspaceSearchReadinessState +typealias WorkspaceSearchService = RepoPromptCore.WorkspaceSearchService + +typealias FileContentFreshnessPolicy = RepoPromptCore.FileContentFreshnessPolicy +typealias FileSearchContentSnapshot = RepoPromptCore.FileSearchContentSnapshot +typealias SearchFileDescriptor = RepoPromptCore.SearchFileDescriptor +typealias SearchMatch = RepoPromptCore.SearchMatch +typealias SearchMode = RepoPromptCore.SearchMode +typealias SearchOptions = RepoPromptCore.SearchOptions +typealias PatternErrorInfo = RepoPromptCore.PatternErrorInfo +typealias PerFileError = RepoPromptCore.PerFileError +typealias SearchResults = RepoPromptCore.SearchResults +typealias FileSearchActor = RepoPromptCore.FileSearchActor +typealias SearchPathClause = RepoPromptCore.SearchPathClause +typealias SearchPathFilterSpec = RepoPromptCore.SearchPathFilterSpec +typealias FileSearchPathSnapshot = RepoPromptCore.FileSearchPathSnapshot +typealias FileSearchPathFilterResult = RepoPromptCore.FileSearchPathFilterResult +typealias FileSearchPathIndexFilterResult = RepoPromptCore.FileSearchPathIndexFilterResult +typealias SearchFolderSuffixIndexEntry = RepoPromptCore.SearchFolderSuffixIndexEntry +typealias SearchFolderSuffixIndex = RepoPromptCore.SearchFolderSuffixIndex +typealias StoreBackedWorkspaceSearch = RepoPromptCore.StoreBackedWorkspaceSearch +typealias StoreBackedWorkspaceSearchError = RepoPromptCore.StoreBackedWorkspaceSearchError +typealias BroadSearchAdmissionClass = RepoPromptCore.BroadSearchAdmissionClass +typealias StoreBackedWorkspaceSearchAdmissionError = RepoPromptCore.StoreBackedWorkspaceSearchAdmissionError +typealias StoreBackedWorkspaceSearchLane = RepoPromptCore.StoreBackedWorkspaceSearchLane + +typealias RepoFileReplayPerf = RepoPromptCore.WorkspaceRuntimePerf + +func buildFolderSuffixIndex( + in foldersByFullPath: [String: T], + relativePath: (T) -> String, + caseInsensitive: Bool = true +) -> SearchFolderSuffixIndex { + RepoPromptCore.buildFolderSuffixIndex( + in: foldersByFullPath, + relativePath: relativePath, + caseInsensitive: caseInsensitive + ) +} + +func resolveFoldersBySuffixFragment( + _ fragment: String, + using suffixIndex: SearchFolderSuffixIndex, + caseInsensitive: Bool = true +) -> [T] { + RepoPromptCore.resolveFoldersBySuffixFragment( + fragment, + using: suffixIndex, + caseInsensitive: caseInsensitive + ) +} diff --git a/Sources/RepoPrompt/App/CoreAdapters/WorkspaceManagerSearchReadinessSource.swift b/Sources/RepoPrompt/App/CoreAdapters/WorkspaceManagerSearchReadinessSource.swift new file mode 100644 index 000000000..a60024ba8 --- /dev/null +++ b/Sources/RepoPrompt/App/CoreAdapters/WorkspaceManagerSearchReadinessSource.swift @@ -0,0 +1,64 @@ +import Foundation + +final class WorkspaceManagerSearchReadinessSource: WorkspaceSearchReadinessSource, @unchecked Sendable { + private weak var workspaceManager: WorkspaceManagerViewModel? + + @MainActor + init(_ workspaceManager: WorkspaceManagerViewModel) { + self.workspaceManager = workspaceManager + } + + func readinessSnapshot() async -> WorkspaceSearchReadinessSnapshot { + await MainActor.run { + guard let workspaceManager else { + return WorkspaceSearchReadinessSnapshot( + workspaceID: nil, + phase: .idle, + generation: 0 + ) + } + + switch workspaceManager.workspaceSearchReadinessState { + case .idle: + return WorkspaceSearchReadinessSnapshot( + workspaceID: nil, + phase: .idle, + generation: 0 + ) + case let .activating(workspaceID, generation): + return WorkspaceSearchReadinessSnapshot( + workspaceID: workspaceID, + phase: .activating, + generation: generation + ) + case let .loadingCatalog(workspaceID, generation, _, _, failures): + return WorkspaceSearchReadinessSnapshot( + workspaceID: workspaceID, + phase: .loadingCatalog, + generation: generation, + failureCount: failures.count + ) + case let .buildingIndexes(workspaceID, generation, _, failures): + return WorkspaceSearchReadinessSnapshot( + workspaceID: workspaceID, + phase: .buildingIndexes, + generation: generation, + failureCount: failures.count + ) + case let .ready(workspaceID, generation, _, _, _): + return WorkspaceSearchReadinessSnapshot( + workspaceID: workspaceID, + phase: .ready, + generation: generation + ) + case let .degraded(workspaceID, generation, _, _, failures, _): + return WorkspaceSearchReadinessSnapshot( + workspaceID: workspaceID, + phase: .degraded, + generation: generation, + failureCount: failures.count + ) + } + } + } +} diff --git a/Sources/RepoPrompt/App/CoreAdapters/WorkspaceRuntimeDiagnosticsAdapter.swift b/Sources/RepoPrompt/App/CoreAdapters/WorkspaceRuntimeDiagnosticsAdapter.swift new file mode 100644 index 000000000..611948f45 --- /dev/null +++ b/Sources/RepoPrompt/App/CoreAdapters/WorkspaceRuntimeDiagnosticsAdapter.swift @@ -0,0 +1,11 @@ +import RepoPromptCore + +@inline(__always) +func withEmbeddedWorkspaceRuntimeDiagnostics( + _ operation: () async throws -> T +) async rethrows -> T { + try await WorkspaceRuntimePerf.withLifecycleCorrelation( + id: EditFlowPerf.currentLifecycleCorrelation?.id, + operation: operation + ) +} diff --git a/Sources/RepoPrompt/App/CoreAdapters/WorkspaceSessionObservationBridge.swift b/Sources/RepoPrompt/App/CoreAdapters/WorkspaceSessionObservationBridge.swift new file mode 100644 index 000000000..6270edb7b --- /dev/null +++ b/Sources/RepoPrompt/App/CoreAdapters/WorkspaceSessionObservationBridge.swift @@ -0,0 +1,25 @@ +import Combine +import Foundation +import RepoPromptCore + +@MainActor +final class WorkspaceSessionObservationBridge: ObservableObject { + @Published private(set) var snapshot: WorkspaceSessionSnapshot + @Published private(set) var workspaces: [WorkspaceModel] + @Published private(set) var activeWorkspaceID: UUID? + + private var observationToken: WorkspaceSessionObservationToken? + + init(controller: WorkspaceSessionController) { + let initial = controller.snapshot + snapshot = initial + workspaces = initial.workspaces + activeWorkspaceID = initial.activeWorkspaceID + observationToken = controller.observe { [weak self] snapshot in + guard let self, snapshot.generation >= self.snapshot.generation else { return } + self.snapshot = snapshot + workspaces = snapshot.workspaces + activeWorkspaceID = snapshot.activeWorkspaceID + } + } +} diff --git a/Sources/RepoPrompt/App/RepoPromptApp.swift b/Sources/RepoPrompt/App/RepoPromptApp.swift index fcc667666..dc3dd9f85 100644 --- a/Sources/RepoPrompt/App/RepoPromptApp.swift +++ b/Sources/RepoPrompt/App/RepoPromptApp.swift @@ -106,7 +106,7 @@ struct RepoPromptApp: App { WindowGroup(id: "main") { // IMPORTANT: Each time a new SwiftUI window/scene is created, // we instantiate a fresh WindowContentView (and thus a new WindowState) - WindowContentView() + WindowContentView(coreContainer: .shared) .environmentObject(versionManager) .environmentObject(appDelegate.sparkleManager) .environmentObject(windowStatesManager) diff --git a/Sources/RepoPrompt/App/RepoPromptAppCoreContainer.swift b/Sources/RepoPrompt/App/RepoPromptAppCoreContainer.swift new file mode 100644 index 000000000..3c8d33ad0 --- /dev/null +++ b/Sources/RepoPrompt/App/RepoPromptAppCoreContainer.swift @@ -0,0 +1,50 @@ +import Foundation +import RepoPromptCore + +/// App-shell composition container for the staged reusable core host. +/// +/// This remains inside the monolithic app target until Item 5 creates physical +/// SwiftPM boundaries. App-only listener and adapter ownership intentionally stay +/// here rather than leaking into the reusable host API. +@MainActor +final class RepoPromptAppCoreContainer { + static let shared = RepoPromptAppCoreContainer( + networkManager: .shared, + appSessionAdapters: .shared + ) + + let workspacePersistenceWriter: WorkspacePersistenceWriter + let workspaceRepository: WorkspaceRepository + let workspaceAccessPolicy: any WorkspaceAccessPolicy + let appSessionAdapters: RepoPromptAppSessionAdapterRegistry + let runtimeFactory: RepoPromptEmbeddedWorkspaceRuntimeFactory + let mcpService: MCPService + let coreHost: RepoPromptCoreHost + + private init( + networkManager: ServerNetworkManager, + appSessionAdapters: RepoPromptAppSessionAdapterRegistry + ) { + let workspaceGraph = EmbeddedWorkspaceRepositoryFactory.make() + let workspaceRepository = workspaceGraph.repository + let workspaceAccessPolicy = UnrestrictedWorkspaceAccessPolicy() + workspacePersistenceWriter = workspaceGraph.writer + self.workspaceRepository = workspaceRepository + self.workspaceAccessPolicy = workspaceAccessPolicy + self.appSessionAdapters = appSessionAdapters + runtimeFactory = RepoPromptEmbeddedWorkspaceRuntimeFactory() + mcpService = MCPService(networkManager: networkManager) + coreHost = RepoPromptCoreHost( + workspaceRepository: workspaceRepository, + workspacePersistenceWriter: workspaceGraph.writer, + workspaceAccessPolicy: workspaceAccessPolicy, + runtimeSessionRegistry: networkManager.runtimeSessionRegistry, + runtimeFactory: runtimeFactory + ) + } + + func shutdownForAppTermination() { + coreHost.shutdownForAppTermination() + appSessionAdapters.removeAll() + } +} diff --git a/Sources/RepoPrompt/App/RepoPromptAppSessionAdapterRegistry.swift b/Sources/RepoPrompt/App/RepoPromptAppSessionAdapterRegistry.swift new file mode 100644 index 000000000..bb7f445f8 --- /dev/null +++ b/Sources/RepoPrompt/App/RepoPromptAppSessionAdapterRegistry.swift @@ -0,0 +1,77 @@ +import Foundation + +/// App-owned weak adapter lookup for compatibility routing sessions. +/// +/// MCP route eligibility belongs to `MCPRuntimeSessionRegistry`. This registry is +/// deliberately app-only and resolves UI adapters needed for AppKit activation, +/// approvals, Agent Mode, and transitional MCP tool execution. +@MainActor +final class RepoPromptAppSessionAdapterRegistry { + nonisolated static let shared = RepoPromptAppSessionAdapterRegistry() + + private enum Lifecycle { + case active + case draining + } + + private final class Entry { + let windowID: Int + weak var windowState: WindowState? + var lifecycle: Lifecycle = .active + + init(windowState: WindowState) { + windowID = windowState.windowID + self.windowState = windowState + } + } + + private var entriesByID: [Int: Entry] = [:] + private var orderedIDs: [Int] = [] + private var retiredIDs: Set = [] + + private nonisolated init() {} + + func register(windowState: WindowState) { + let windowID = windowState.windowID + guard !retiredIDs.contains(windowID) else { return } + if let existing = entriesByID[windowID] { + existing.windowState = windowState + existing.lifecycle = .active + return + } + entriesByID[windowID] = Entry(windowState: windowState) + orderedIDs.append(windowID) + } + + func beginDraining(windowID: Int) { + guard let entry = entriesByID[windowID] else { return } + entry.lifecycle = .draining + } + + func remove(windowID: Int) { + entriesByID.removeValue(forKey: windowID) + orderedIDs.removeAll { $0 == windowID } + retiredIDs.insert(windowID) + } + + func removeAll() { + for windowID in orderedIDs { + retiredIDs.insert(windowID) + } + entriesByID.removeAll() + orderedIDs.removeAll() + } + + func window(withID windowID: Int, includeDraining: Bool = false) -> WindowState? { + guard let entry = entriesByID[windowID], + includeDraining || entry.lifecycle == .active + else { + return nil + } + return entry.windowState + } + + func windowStates(includeDraining: Bool = false) -> [WindowState] { + orderedIDs.compactMap { window(withID: $0, includeDraining: includeDraining) } + } +} diff --git a/Sources/RepoPrompt/App/RepoPromptEmbeddedWorkspaceRuntimeFactory.swift b/Sources/RepoPrompt/App/RepoPromptEmbeddedWorkspaceRuntimeFactory.swift new file mode 100644 index 000000000..ebadba597 --- /dev/null +++ b/Sources/RepoPrompt/App/RepoPromptEmbeddedWorkspaceRuntimeFactory.swift @@ -0,0 +1,117 @@ +import Foundation +import os +import RepoPromptCore +import RepoPromptCoreMacOS + +struct RepoPromptEmbeddedWorkspaceRuntime { + let dependencies: WorkspaceRuntimeDependencies + let workspaceFileContextStore: WorkspaceFileContextStore + let workspaceSearchService: WorkspaceSearchService + let selectionMutationService: WorkspaceSelectionMutationService + let selectionSliceCoordinator: SelectionSliceCoordinator +} + +private final class EmbeddedWorkspaceRuntimeDiagnosticsState: @unchecked Sendable { + private let lock = NSLock() + private var intervals: [UUID: EditFlowPerf.ExternalIntervalState] = [:] + + func store(_ interval: EditFlowPerf.ExternalIntervalState?, id: UUID) { + guard let interval else { return } + lock.lock() + intervals[id] = interval + lock.unlock() + } + + func take(id: UUID) -> EditFlowPerf.ExternalIntervalState? { + lock.lock() + defer { lock.unlock() } + return intervals.removeValue(forKey: id) + } +} + +struct EmbeddedWorkspaceRuntimeDiagnosticsSink: WorkspaceRuntimeDiagnosticsSink { + private let logger = Logger(subsystem: "com.pvncher.repoprompt", category: "WorkspaceRuntime") + private let state = EmbeddedWorkspaceRuntimeDiagnosticsState() + + func record(_ event: WorkspaceRuntimeDiagnosticEvent) { + let correlationID = event.correlationID ?? EditFlowPerf.currentLifecycleCorrelation?.id + let correlation = correlationID?.uuidString ?? "none" + let fields = event.fields + .sorted { $0.key < $1.key } + .map { "\($0.key)=\($0.value)" } + .joined(separator: " ") + logger.debug( + "\(event.subsystem, privacy: .public).\(event.name, privacy: .public) kind=\(event.kind.rawValue, privacy: .public) correlation=\(correlation, privacy: .public) \(fields, privacy: .public)" + ) + + let dimensions = event.fields["dimensions"] ?? fields + switch event.kind { + case .intervalBegan: + guard let intervalID = event.intervalID else { return } + state.store( + EditFlowPerf.beginExternalInterval( + stageName: event.name, + sanitizedDimensions: dimensions + ), + id: intervalID + ) + case .intervalEnded: + guard let intervalID = event.intervalID else { return } + EditFlowPerf.endExternalInterval( + state.take(id: intervalID), + sanitizedDimensions: dimensions + ) + case .lifecycle, .counter: + EditFlowPerf.recordExternalLifecycleEvent( + eventName: event.name, + correlationID: correlationID, + sanitizedDimensions: dimensions + ) + } + } +} + +@MainActor +final class RepoPromptEmbeddedWorkspaceRuntimeFactory { + private let dependencies: WorkspaceRuntimeDependencies + + init(settingsStore: GlobalSettingsStore? = nil) { + WorkspaceExternalFileReaderProvider.install { MacOSWorkspaceExternalFileReader() } + let settingsStore = settingsStore ?? GlobalSettingsStore.shared + let fileManager = FileManager.default + let applicationSupport = fileManager.urls(for: .applicationSupportDirectory, in: .userDomainMask).first + ?? fileManager.homeDirectoryForCurrentUser + let legacyRuntimeRoot = applicationSupport.appendingPathComponent("RepoPrompt", isDirectory: true) + let diagnostics = EmbeddedWorkspaceRuntimeDiagnosticsSink() + WorkspaceRuntimePerf.installProcessSink(diagnostics) + dependencies = WorkspaceRuntimeDependencies( + watcherFactory: MacOSFSEventsWatcherFactory(), + directoryListingBackend: MacOSWorkspaceDirectoryListingBackend(), + fileContentSnapshotReader: MacOSFileContentSnapshotReader(), + mutationBackend: EmbeddedWorkspaceFileMutationBackend(), + partitionRoot: legacyRuntimeRoot.appendingPathComponent("Partitions", isDirectory: true), + partitionSaveEventSink: EmbeddedPartitionStoreEventAdapter.sink, + codeMapCacheRoot: legacyRuntimeRoot.appendingPathComponent("CodeMapCaches", isDirectory: true), + configuration: WorkspaceRuntimeConfiguration( + agentSupportRoot: fileManager.homeDirectoryForCurrentUser.appendingPathComponent(".agents", isDirectory: true), + globalIgnoreDefaults: settingsStore.globalIgnoreDefaults() + ), + diagnostics: diagnostics + ) + } + + func makeRuntime() -> RepoPromptEmbeddedWorkspaceRuntime { + let partitionStore = PartitionStore( + baseURL: dependencies.partitionRoot, + saveEventSink: dependencies.partitionSaveEventSink + ) + let workspaceFileContextStore = WorkspaceFileContextStore(runtimeDependencies: dependencies) + return RepoPromptEmbeddedWorkspaceRuntime( + dependencies: dependencies, + workspaceFileContextStore: workspaceFileContextStore, + workspaceSearchService: WorkspaceSearchService(), + selectionMutationService: WorkspaceSelectionMutationService(store: workspaceFileContextStore), + selectionSliceCoordinator: SelectionSliceCoordinator(store: partitionStore) + ) + } +} diff --git a/Sources/RepoPrompt/App/ViewModels/ContentViewModel.swift b/Sources/RepoPrompt/App/ViewModels/ContentViewModel.swift index e26c96ba0..ead872f14 100644 --- a/Sources/RepoPrompt/App/ViewModels/ContentViewModel.swift +++ b/Sources/RepoPrompt/App/ViewModels/ContentViewModel.swift @@ -49,7 +49,7 @@ class ContentViewModel: ObservableObject { self.state = state // Sync workspace changes to drive routing - state.workspaceManager.$activeWorkspaceID + state.workspaceManager.workspaceObservation.$activeWorkspaceID .receive(on: RunLoop.main) .sink { [weak self] _ in self?.syncRouteWithWorkspaceState() diff --git a/Sources/RepoPrompt/App/WindowContentView.swift b/Sources/RepoPrompt/App/WindowContentView.swift index b770b0a7d..f52eb65ca 100644 --- a/Sources/RepoPrompt/App/WindowContentView.swift +++ b/Sources/RepoPrompt/App/WindowContentView.swift @@ -16,7 +16,11 @@ struct WindowContentView: View { @Environment(\.openWindow) private var openWindow /// The WindowState itself (your big manager of fileManager, promptManager, etc.) - @StateObject private var windowState = WindowState() + @StateObject private var windowState: WindowState + + init(coreContainer: RepoPromptAppCoreContainer) { + _windowState = StateObject(wrappedValue: WindowState(coreContainer: coreContainer)) + } var body: some View { ContentView(windowState: windowState) @@ -58,7 +62,6 @@ struct WindowContentView: View { } windowStatesManager.unregisterWindowState(windowState) - Task { await windowState.tearDown() } } // Example sheets or popups .sheet(isPresented: $versionManager.shouldShowWelcomeView) { diff --git a/Sources/RepoPrompt/App/WindowState.swift b/Sources/RepoPrompt/App/WindowState.swift index 161564bc0..a187254aa 100644 --- a/Sources/RepoPrompt/App/WindowState.swift +++ b/Sources/RepoPrompt/App/WindowState.swift @@ -1,6 +1,7 @@ import AppKit import Combine import Foundation +import RepoPromptCore import SwiftUI enum WindowKind: String, Codable { @@ -92,8 +93,8 @@ struct AppCommand { class WindowState: ObservableObject { // MARK: - Shared Services - /// Single shared MCP service instance across all windows - private static let sharedMCPService = MCPService() + let coreContainer: RepoPromptAppCoreContainer + let coreSessionHandle: RepoPromptCoreSessionHandle // MARK: - Window identification @@ -108,6 +109,8 @@ class WindowState: ObservableObject { /// Per-window teardown guard. Not @Published (we don't want SwiftUI updates during teardown). private(set) var isClosing: Bool = false + private var tearDownTask: Task? + private var didCompleteTearDown = false private var focusCancellables = Set() private weak var focusObservedWindow: NSWindow? @@ -123,6 +126,7 @@ class WindowState: ObservableObject { let workspaceFileContextStore: WorkspaceFileContextStore let workspaceSearchService: WorkspaceSearchService let selectionCoordinator: WorkspaceSelectionCoordinator + let workspaceObservation: WorkspaceSessionObservationBridge let workspaceFilesViewModel: WorkspaceFilesViewModel let settingsManager: WindowSettingsManager let promptManager: PromptViewModel @@ -286,16 +290,24 @@ class WindowState: ObservableObject { // MARK: - Initialization convenience init() { - self.init(contextBuilderProviderFactory: nil) + self.init(coreContainer: .shared, contextBuilderProviderFactory: nil) } #if DEBUG convenience init(contextBuilderProviderFactory: @escaping ContextBuilderAgentViewModel.ProviderFactory) { - self.init(contextBuilderProviderFactory: Optional(contextBuilderProviderFactory)) + self.init(coreContainer: .shared, contextBuilderProviderFactory: contextBuilderProviderFactory) } #endif - private init(contextBuilderProviderFactory: ContextBuilderAgentViewModel.ProviderFactory?) { + convenience init(coreContainer: RepoPromptAppCoreContainer) { + self.init(coreContainer: coreContainer, contextBuilderProviderFactory: nil) + } + + private init( + coreContainer: RepoPromptAppCoreContainer, + contextBuilderProviderFactory: ContextBuilderAgentViewModel.ProviderFactory? + ) { + self.coreContainer = coreContainer // Assign a unique window ID WindowState.windowCounter += 1 windowID = WindowState.windowCounter @@ -312,13 +324,15 @@ class WindowState: ObservableObject { let composition = WindowStateCompositionFactory.make( windowID: windowID, deferredInitialAgentSystemWorkspaceRefresh: deferredInitialAgentSystemWorkspaceRefresh, - sharedMCPService: Self.sharedMCPService, + coreContainer: coreContainer, contextBuilderProviderFactory: contextBuilderProviderFactory ) + coreSessionHandle = composition.coreSessionHandle workspaceFileContextStore = composition.workspaceFileContextStore workspaceSearchService = composition.workspaceSearchService selectionCoordinator = composition.selectionCoordinator + workspaceObservation = composition.workspaceObservation workspaceFilesViewModel = composition.workspaceFilesViewModel settingsManager = composition.settingsManager promptManager = composition.promptManager @@ -335,6 +349,7 @@ class WindowState: ObservableObject { aiQueriesService = composition.aiQueriesService chatDataService = composition.chatDataService workspaceManager = composition.workspaceManager + closeCoordinator.windowState = self // Set up additional actions setupSendPromptAction() @@ -723,11 +738,11 @@ class WindowState: ObservableObject { /// ------------------------------------------------------------------ func startMCPServer() { - Task { try? await WindowState.sharedMCPService.join(windowID: windowID) } + Task { try? await coreContainer.mcpService.join(windowID: windowID) } } func stopMCPServer() { - Task { await WindowState.sharedMCPService.leave(windowID: windowID) } + Task { await coreContainer.mcpService.leave(windowID: windowID) } } // MARK: - Command handling @@ -1113,9 +1128,7 @@ class WindowState: ObservableObject { }) { // If ephemeral == true, mark existing workspace ephemeral (edge case) if shouldBeEphemeral { - if let index = workspaceManager.workspaces.firstIndex(where: { $0.id == existingWorkspace.id }) { - workspaceManager.workspaces[index].isEphemeral = true - } + workspaceManager.setWorkspaceEphemeral(true, workspaceID: existingWorkspace.id) } // If focus == true, attempt to bring up an existing window @@ -1153,9 +1166,7 @@ class WindowState: ObservableObject { if let existing = workspaceManager.workspaces.first(where: { $0.name == workspaceName }) { // If ephemeral == true, mark that workspace ephemeral if shouldBeEphemeral { - if let index = workspaceManager.workspaces.firstIndex(where: { $0.id == existing.id }) { - workspaceManager.workspaces[index].isEphemeral = true - } + workspaceManager.setWorkspaceEphemeral(true, workspaceID: existing.id) } // If focus == true, attempt to bring up existing window @@ -1220,6 +1231,22 @@ class WindowState: ObservableObject { // MARK: - Teardown func tearDown() async { + if didCompleteTearDown { return } + if let tearDownTask { + await tearDownTask.value + return + } + let task = Task { @MainActor [weak self] in + guard let self else { return } + await performTearDown() + } + tearDownTask = task + await task.value + tearDownTask = nil + didCompleteTearDown = true + } + + private func performTearDown() async { let isAppTermination = WindowStatesManager.shared.isTerminating #if DEBUG agentChatStressHarness?.pause() @@ -1232,13 +1259,8 @@ class WindowState: ObservableObject { } } - // App-level termination already coordinates agent/session and MCP shutdown. - // Skip duplicate per-window teardown work on quit so close latency stays bounded. - if isAppTermination { - aiQueriesService.cancelQuery() - return - } - + // Safety-critical cancellation always runs, including when a normal close + // overlaps app termination. UI-observed mutations remain suppressed below. await workspaceManager.cancelActiveSessions() await agentModeViewModel.prepareForWindowClose() WorkspaceApprovalManager.shared.cancelPending(forWindowID: windowID) diff --git a/Sources/RepoPrompt/App/WindowStateComposition.swift b/Sources/RepoPrompt/App/WindowStateComposition.swift index ef7bc05e9..8f0077163 100644 --- a/Sources/RepoPrompt/App/WindowStateComposition.swift +++ b/Sources/RepoPrompt/App/WindowStateComposition.swift @@ -1,10 +1,13 @@ import Foundation +import RepoPromptCore @MainActor struct WindowStateComposition { + let coreSessionHandle: RepoPromptCoreSessionHandle let workspaceFileContextStore: WorkspaceFileContextStore let workspaceSearchService: WorkspaceSearchService let selectionCoordinator: WorkspaceSelectionCoordinator + let workspaceObservation: WorkspaceSessionObservationBridge let workspaceFilesViewModel: WorkspaceFilesViewModel let settingsManager: WindowSettingsManager let promptManager: PromptViewModel @@ -28,13 +31,20 @@ enum WindowStateCompositionFactory { static func make( windowID: Int, deferredInitialAgentSystemWorkspaceRefresh: Bool, - sharedMCPService: MCPService, + coreContainer: RepoPromptAppCoreContainer, contextBuilderProviderFactory: ContextBuilderAgentViewModel.ProviderFactory? = nil ) -> WindowStateComposition { - // 1) Workspace file context store + visible file-tree UI adapter - let workspaceFileContextStore = WorkspaceFileContextStore() - let workspaceSearchService = WorkspaceSearchService() - let workspaceFilesViewModel = WorkspaceFilesViewModel(workspaceFileContextStore: workspaceFileContextStore) + // 1) Reusable session graph + visible file-tree UI adapter + let coreSessionHandle = coreContainer.coreHost.makeEmbeddedSession( + routingSessionID: MCPRoutingSessionID(rawValue: windowID) + ) + let coreSession = coreSessionHandle.session + let workspaceFileContextStore = coreSession.workspaceFileContextStore + let workspaceSearchService = coreSession.workspaceSearchService + let workspaceFilesViewModel = WorkspaceFilesViewModel( + workspaceFileContextStore: workspaceFileContextStore, + selectionSliceCoordinator: coreSession.selectionSliceCoordinator + ) // 2) AI queries let keyManager = KeyManager() @@ -55,16 +65,24 @@ enum WindowStateCompositionFactory { settingsManager: settingsManager ) - // 7) Create the workspace manager + // 7) Create the workspace adapter over the authoritative Core session controller. + let workspaceObservation = WorkspaceSessionObservationBridge( + controller: coreSession.workspaceSessionController + ) let workspaceManager = WorkspaceManagerViewModel( fileManager: workspaceFilesViewModel, promptViewModel: promptManager, - workspaceSearchService: workspaceSearchService + workspaceSearchService: workspaceSearchService, + workspaceRepository: coreContainer.workspaceRepository, + sessionController: coreSession.workspaceSessionController, + workspaceObservation: workspaceObservation ) + let searchReadinessSource = WorkspaceManagerSearchReadinessSource(workspaceManager) let selectionCoordinator = WorkspaceSelectionCoordinator( - workspaceManager: workspaceManager, + controller: coreSession.workspaceSelectionController, store: workspaceFileContextStore ) + selectionCoordinator.attachWorkspaceManager(workspaceManager) workspaceFilesViewModel.attachSelectionCoordinator(selectionCoordinator) workspaceManager.attachSelectionCoordinator(selectionCoordinator) promptManager.attachSelectionCoordinator(selectionCoordinator) @@ -81,31 +99,35 @@ enum WindowStateCompositionFactory { // 11) MCP server (one listener app-wide, this window may be owner) let applyEditsApprovalStore = ApplyEditsApprovalStore.shared let mcpServer = MCPServerViewModel( - service: sharedMCPService, + service: coreContainer.mcpService, promptVM: promptManager, oracleVM: oracleViewModel, workspaceManager: workspaceManager, selectionCoordinator: selectionCoordinator, + coreSessionHandle: coreSessionHandle, + appSessionAdapters: coreContainer.appSessionAdapters, windowID: windowID, - workspaceSearch: { [store = workspaceFileContextStore, workspaceManager] pattern, mode, isRegex, caseInsensitive, maxPaths, maxMatches, paths, includeExtensions, excludePatterns, contextLines, wholeWord, countOnly, fuzzySpaceMatching, rootScope in - try await StoreBackedWorkspaceSearch.search( - pattern: pattern, - mode: mode, - isRegex: isRegex, - caseInsensitive: caseInsensitive, - maxPaths: maxPaths, - maxMatches: maxMatches, - paths: paths, - includeExtensions: includeExtensions, - excludePatterns: excludePatterns, - contextLines: contextLines, - wholeWord: wholeWord, - countOnly: countOnly, - fuzzySpaceMatching: fuzzySpaceMatching, - rootScope: rootScope, - store: store, - workspaceManager: workspaceManager - ) + workspaceSearch: { [store = workspaceFileContextStore, searchReadinessSource] pattern, mode, isRegex, caseInsensitive, maxPaths, maxMatches, paths, includeExtensions, excludePatterns, contextLines, wholeWord, countOnly, fuzzySpaceMatching, rootScope in + try await withEmbeddedWorkspaceRuntimeDiagnostics { + try await StoreBackedWorkspaceSearch.search( + pattern: pattern, + mode: mode, + isRegex: isRegex, + caseInsensitive: caseInsensitive, + maxPaths: maxPaths, + maxMatches: maxMatches, + paths: paths, + includeExtensions: includeExtensions, + excludePatterns: excludePatterns, + contextLines: contextLines, + wholeWord: wholeWord, + countOnly: countOnly, + fuzzySpaceMatching: fuzzySpaceMatching, + rootScope: rootScope, + store: store, + readinessSource: searchReadinessSource + ) + } }, ensureGitDataRootLoaded: { [fileManager = workspaceFilesViewModel] workspace, workspaceManager in guard let workspace, let workspaceManager else { return } @@ -171,9 +193,11 @@ enum WindowStateCompositionFactory { #if DEBUG return WindowStateComposition( + coreSessionHandle: coreSessionHandle, workspaceFileContextStore: workspaceFileContextStore, workspaceSearchService: workspaceSearchService, selectionCoordinator: selectionCoordinator, + workspaceObservation: workspaceObservation, workspaceFilesViewModel: workspaceFilesViewModel, settingsManager: settingsManager, promptManager: promptManager, @@ -191,9 +215,11 @@ enum WindowStateCompositionFactory { ) #else return WindowStateComposition( + coreSessionHandle: coreSessionHandle, workspaceFileContextStore: workspaceFileContextStore, workspaceSearchService: workspaceSearchService, selectionCoordinator: selectionCoordinator, + workspaceObservation: workspaceObservation, workspaceFilesViewModel: workspaceFilesViewModel, settingsManager: settingsManager, promptManager: promptManager, diff --git a/Sources/RepoPrompt/App/WindowStateManager.swift b/Sources/RepoPrompt/App/WindowStateManager.swift index 9cc8f73ac..853cf1cc2 100644 --- a/Sources/RepoPrompt/App/WindowStateManager.swift +++ b/Sources/RepoPrompt/App/WindowStateManager.swift @@ -184,6 +184,9 @@ class WindowStatesManager: ObservableObject { /// We keep references to the focus-change cancellables (if we needed direct Combine usage), /// but in this example we rely on a callback approach from each WindowState. private var focusCancellables = Set() + private var closeFinalizerTasks: [Int: Task] = [:] + private var drainingWindowStates: [Int: WindowState] = [:] + private var retiredWindowIDs: Set = [] private static let autoRestoreDefaultsKey = "autoRestoreWorkspacesEnabled_v2" @@ -539,13 +542,20 @@ class WindowStatesManager: ObservableObject { } func registerWindowState(_ state: WindowState) { - // Prevent duplicate registration - guard !allWindows.contains(where: { $0 === state }) else { return } + // Prevent duplicate registration and stale SwiftUI reappearance after drain/removal. + guard !allWindows.contains(where: { $0 === state }), + closeFinalizerTasks[state.windowID] == nil, + !retiredWindowIDs.contains(state.windowID), + state.coreSessionHandle.snapshot.lifecycle == .created + else { return } #if DEBUG let registerStartMS = WorkspaceRestorePerfLog.timestampMSIfEnabled() #endif allWindows.append(state) + state.coreContainer.appSessionAdapters.register(windowState: state) + state.coreContainer.coreHost.activate(state.coreSessionHandle) + state.mcpServer.windowDidRegister() #if DEBUG WorkspaceRestorePerfLog.log( "restore.window registered windowID=\(state.windowID) registeredWindows=\(allWindows.count) pendingRestoreEntries=\(restoreQueue.count)" @@ -609,28 +619,52 @@ class WindowStatesManager: ObservableObject { } func unregisterWindowState(_ state: WindowState) { + guard closeFinalizerTasks[state.windowID] == nil, + !retiredWindowIDs.contains(state.windowID) + else { return } + + drainingWindowStates[state.windowID] = state + state.coreContainer.coreHost.beginDraining(state.coreSessionHandle) + state.coreContainer.appSessionAdapters.beginDraining(windowID: state.windowID) + state.mcpServer.windowWillUnregister() if let idx = allWindows.firstIndex(where: { $0 === state }) { allWindows.remove(at: idx) } explicitlyClosingWindowIDs.remove(state.windowID) - // Skip notifications and updates during termination to prevent observation crashes - guard !isTerminating else { return } + if !isTerminating { + // Notify that window count changed + NotificationCenter.default.post(name: .windowCountDidChange, object: nil) - // Notify that window count changed - NotificationCenter.default.post(name: .windowCountDidChange, object: nil) + // Then update shortcuts in case we lost a focused window + updateKeyboardShortcutsState() - // Inform the network manager that this window is gone - Task { await ServerNetworkManager.shared.clearWindowSelectionIfClosed(state.windowID) } + // Clear instance assignment for this window without reusing numbers + clearInstanceAssignment(forWindowID: state.windowID) - // Then update shortcuts in case we lost a focused window - updateKeyboardShortcutsState() + Task { @MainActor in + await self.persistWindowSessionImmediately(reason: "unregisterWindow:\(state.windowID)") + } + } - // Clear instance assignment for this window without reusing numbers - clearInstanceAssignment(forWindowID: state.windowID) + // Keep the draining app adapter alive for cleanup paths, then retire the + // reusable session only after both network affinity cleanup and teardown finish. + closeFinalizerTasks[state.windowID] = Task { @MainActor [weak self, state] in + async let networkCleanup: Void = ServerNetworkManager.shared.clearWindowSelectionIfClosed(state.windowID) + async let teardown: Void = state.tearDown() + _ = await (networkCleanup, teardown) + state.coreContainer.coreHost.remove(state.coreSessionHandle) + state.coreContainer.appSessionAdapters.remove(windowID: state.windowID) + self?.drainingWindowStates.removeValue(forKey: state.windowID) + self?.retiredWindowIDs.insert(state.windowID) + self?.closeFinalizerTasks.removeValue(forKey: state.windowID) + } + } - Task { @MainActor in - await self.persistWindowSessionImmediately(reason: "unregisterWindow:\(state.windowID)") + func unregisterWindowStateAndWait(_ state: WindowState) async { + unregisterWindowState(state) + if let task = closeFinalizerTasks[state.windowID] { + await task.value } } @@ -771,15 +805,21 @@ class WindowStatesManager: ObservableObject { // MARK: - MCP Server Coordination - /// Stops MCP servers in **all** windows (unconditionally). + private func lifecycleWindowStates() -> [WindowState] { + var seenWindowIDs: Set = [] + return (allWindows + Array(drainingWindowStates.values)).filter { state in + seenWindowIDs.insert(state.windowID).inserted + } + } + + /// Stops MCP servers in **all** active or draining windows (unconditionally). /// During teardown, "running" state can be stale; we just want tools off. func stopAllServers() async { - let windowIDs = allWindows.map(\.windowID) + let windows = lifecycleWindowStates() await withTaskGroup(of: Void.self) { group in - for windowID in windowIDs { - group.addTask { @MainActor [weak self] in - guard let self, let ws = window(withID: windowID) else { return } - await ws.mcpServer.stopServer() + for window in windows { + group.addTask { @MainActor [weak window] in + await window?.mcpServer.stopServer() } } } @@ -790,12 +830,11 @@ class WindowStatesManager: ObservableObject { /// Safe to call after `signalTermination()` — only performs cancellation and process teardown, /// no UI-observed state mutations. func shutdownAllAgentSessions() async { - let windowIDs = allWindows.map(\.windowID) + let windows = lifecycleWindowStates() await withTaskGroup(of: Void.self) { group in - for windowID in windowIDs { - group.addTask { @MainActor [weak self] in - guard let self, let ws = window(withID: windowID) else { return } - await ws.agentModeViewModel.prepareForWindowClose() + for window in windows { + group.addTask { @MainActor [weak window] in + await window?.agentModeViewModel.prepareForWindowClose() } } } @@ -805,6 +844,15 @@ class WindowStatesManager: ObservableObject { await CursorACPModelPollingService.shared.shutdown() } + /// Waits for windows that began a normal close before termination to complete + /// their safety-critical teardown before the host removes remaining sessions. + func awaitDrainingWindowFinalizers() async { + let tasks = Array(closeFinalizerTasks.values) + for task in tasks { + await task.value + } + } + // MARK: - Instance Number Management /// Records that a window switched to a given workspace and assigns a new sticky instance number. diff --git a/Sources/RepoPrompt/Features/AgentMode/Runtime/ProviderBindings/AgentPermissionSecureStore.swift b/Sources/RepoPrompt/Features/AgentMode/Runtime/ProviderBindings/AgentPermissionSecureStore.swift index 1f9b10e8f..0a2021640 100644 --- a/Sources/RepoPrompt/Features/AgentMode/Runtime/ProviderBindings/AgentPermissionSecureStore.swift +++ b/Sources/RepoPrompt/Features/AgentMode/Runtime/ProviderBindings/AgentPermissionSecureStore.swift @@ -1,4 +1,5 @@ import Foundation +import RepoPromptCore // SEARCH-HELPER: Secure Agent Permission Storage, Keychain-backed permission documents, fail-closed permissions @@ -276,7 +277,9 @@ struct SecureCursorPermissionDocument: Codable, Equatable { } final class AgentPermissionSecureStore { - static let shared = AgentPermissionSecureStore(secureStrings: SecureKeysService()) + static let shared = AgentPermissionSecureStore( + secureStrings: SecureKeysService(secureStorage: SecureKeyValueStorageFactory.defaultBackend()) + ) private let secureStrings: SecurePlainStringStoring private let lock = NSRecursiveLock() @@ -291,7 +294,7 @@ final class AgentPermissionSecureStore { private var openCodeCache: SecureOpenCodePermissionDocument? private var cursorCache: SecureCursorPermissionDocument? private var diagnosticsByDomain: [AgentPermissionSecureDomain: AgentPermissionStorageDiagnostic] = [:] - private let permissionDecisionAccessMode: KeychainAccessMode = .nonInteractive(reason: .permissionDecision) + private let permissionDecisionAccessMode: SecureStorageAccessMode = .nonInteractive(reason: .permissionDecision) private struct DeferredSideEffects { private var requestedDiagnosticsDomains: Set = [] @@ -743,7 +746,7 @@ final class AgentPermissionSecureStore { private func saveDocument( _ document: some Codable, domain: AgentPermissionSecureDomain, - accessMode: KeychainAccessMode = .interactive + accessMode: SecureStorageAccessMode = .interactive ) throws { let data = try encoder.encode(document) guard let payload = String(data: data, encoding: .utf8) else { @@ -932,7 +935,7 @@ final class AgentPermissionSecureStore { } private func isAccessDeniedFailure(_ error: Error) -> Bool { - guard let keychainError = error as? KeychainService.KeychainError else { + guard let keychainError = error as? SecureStorageError else { return false } switch keychainError { @@ -947,7 +950,7 @@ final class AgentPermissionSecureStore { for error: Error, fallback: AgentPermissionStorageDiagnostic.Kind ) -> AgentPermissionStorageDiagnostic.Kind { - guard let keychainError = error as? KeychainService.KeychainError else { + guard let keychainError = error as? SecureStorageError else { return fallback } switch keychainError { diff --git a/Sources/RepoPrompt/Features/AgentMode/Services/AgentContextExportResolver.swift b/Sources/RepoPrompt/Features/AgentMode/Services/AgentContextExportResolver.swift index ec0f51210..df19a7c42 100644 --- a/Sources/RepoPrompt/Features/AgentMode/Services/AgentContextExportResolver.swift +++ b/Sources/RepoPrompt/Features/AgentMode/Services/AgentContextExportResolver.swift @@ -1,4 +1,5 @@ import Foundation +import RepoPromptCore struct AgentContextExportSource: Equatable { let tabID: UUID? diff --git a/Sources/RepoPrompt/Features/AgentMode/Services/AgentProviderContextBuilder.swift b/Sources/RepoPrompt/Features/AgentMode/Services/AgentProviderContextBuilder.swift index 7957a70ed..ebaec15e6 100644 --- a/Sources/RepoPrompt/Features/AgentMode/Services/AgentProviderContextBuilder.swift +++ b/Sources/RepoPrompt/Features/AgentMode/Services/AgentProviderContextBuilder.swift @@ -1,6 +1,8 @@ import Foundation enum AgentProviderContextBuilder { + typealias AccountingOperation = (PromptContextAccountingRequest, WorkspaceFileContextStore) async throws -> PromptContextAccountingResult + static func initialFileTree( selection logicalSelection: StoredSelection, store: WorkspaceFileContextStore, @@ -31,10 +33,10 @@ enum AgentProviderContextBuilder { tokenCap: Int, store: WorkspaceFileContextStore, lookupContext: WorkspaceLookupContext, + accountingOperation: AccountingOperation? = nil, overTokenCapSummaryProvider: ((StoredSelection, WorkspaceLookupContext) async -> String?)? = nil ) async -> String { let physicalSelection = lookupContext.physicalizeSelection(logicalSelection) - let accountingService = PromptContextAccountingService() let request = PromptContextAccountingRequest( selection: physicalSelection, codeMapUsage: .auto, @@ -42,7 +44,18 @@ enum AgentProviderContextBuilder { rootScope: lookupContext.rootScope, pathLocateProfile: .uiAssisted ) - let accounting = await accountingService.calculatePromptStats(request: request, store: store) + let accounting: PromptContextAccountingResult + do { + if let accountingOperation { + accounting = try await accountingOperation(request, store) + } else { + let accountingService = PromptContextAccountingService() + accounting = try await accountingService.calculatePromptStats(request: request, store: store) + } + } catch { + return "" + } + let entries = accounting.resolvedEntries let selectionTokens = accounting.tokenResult.totalTokenCountFilesOnly let codemapSnapshots = await store.codemapSnapshotDictionary() diff --git a/Sources/RepoPrompt/Features/AgentMode/ViewModels/Recommendations/RecommendationWizardViewModel.swift b/Sources/RepoPrompt/Features/AgentMode/ViewModels/Recommendations/RecommendationWizardViewModel.swift index fb4d9ea6d..03e88a9ca 100644 --- a/Sources/RepoPrompt/Features/AgentMode/ViewModels/Recommendations/RecommendationWizardViewModel.swift +++ b/Sources/RepoPrompt/Features/AgentMode/ViewModels/Recommendations/RecommendationWizardViewModel.swift @@ -240,7 +240,7 @@ final class RecommendationWizardViewModel: ObservableObject { /// Subscribe to changes that affect recommendations. private func setupSubscriptions() { // Subscribe to workspace changes - new workspace should start at intro - workspaceManager?.$activeWorkspaceID + workspaceManager?.workspaceObservation.$activeWorkspaceID .debounce(for: .milliseconds(100), scheduler: RunLoop.main) .sink { [weak self] _ in self?.refresh(navigation: .resetToIntro) diff --git a/Sources/RepoPrompt/Features/CodeMap/CodeMapExtractor.swift b/Sources/RepoPrompt/Features/CodeMap/CodeMapExtractor.swift index 5c36d2310..d008b3fbd 100644 --- a/Sources/RepoPrompt/Features/CodeMap/CodeMapExtractor.swift +++ b/Sources/RepoPrompt/Features/CodeMap/CodeMapExtractor.swift @@ -1,16 +1,6 @@ import Foundation import SwiftUI -/// Determines how CodeMap definitions are inserted. -enum CodeMapUsage: String, CaseIterable, Codable { - case auto - case complete - /// Include code-map for selected files only (handled at injection sites; - /// returning it here would duplicate). - case selected - case none -} - /// A small struct returning code-map text + the number of files included in that text. struct DefinitionBlockResult { let text: String @@ -638,6 +628,7 @@ enum CodeMapExtractor { } } + /// Slice 3 keeps definition-block rendering app-owned, including this record-to-render-input projection. private static func acceptedFileAPIs(from files: [WorkspaceFileRecord], allFileAPIs: [FileAPI]) -> [FileAPI] { guard !files.isEmpty, !allFileAPIs.isEmpty else { return [] } #if DEBUG || EDIT_FLOW_PERF @@ -841,56 +832,6 @@ enum CodeMapExtractor { return ordered } - static func resolveReferencedFilePaths( - from selectedFiles: [WorkspaceFileRecord], - among allFileAPIs: [FileAPI] - ) -> [String] { - guard !selectedFiles.isEmpty else { return [] } - let acceptedFileAPIFilter = EditFlowPerf.begin(EditFlowPerf.Stage.ReadFile.AutoSelect.acceptedFileAPIFilter) - let selectedAPIs = acceptedFileAPIs(from: selectedFiles, allFileAPIs: allFileAPIs) - EditFlowPerf.end(EditFlowPerf.Stage.ReadFile.AutoSelect.acceptedFileAPIFilter, acceptedFileAPIFilter) - return resolveReferencedFilePaths(from: selectedFiles, selectedAPIs: selectedAPIs, among: allFileAPIs) - } - - static func resolveReferencedFilePaths( - from selectedFiles: [WorkspaceFileRecord], - among allFileAPIs: [FileAPI], - firstFileAPIByStandardizedNestedPath: [String: FileAPI] - ) -> [String] { - guard !selectedFiles.isEmpty else { return [] } - let acceptedFileAPIFilter = EditFlowPerf.begin(EditFlowPerf.Stage.ReadFile.AutoSelect.acceptedFileAPIFilter) - let selectedAPIs = acceptedFileAPIs( - from: selectedFiles, - firstFileAPIByStandardizedNestedPath: firstFileAPIByStandardizedNestedPath - ) - EditFlowPerf.end(EditFlowPerf.Stage.ReadFile.AutoSelect.acceptedFileAPIFilter, acceptedFileAPIFilter) - return resolveReferencedFilePaths(from: selectedFiles, selectedAPIs: selectedAPIs, among: allFileAPIs) - } - - private static func resolveReferencedFilePaths( - from selectedFiles: [WorkspaceFileRecord], - selectedAPIs: [FileAPI], - among allFileAPIs: [FileAPI] - ) -> [String] { - guard !selectedAPIs.isEmpty else { return [] } - - let selectedPaths = Set(selectedFiles.map(\.standardizedFullPath)) - let unselectedAPIs = allFileAPIs.filter { !selectedPaths.contains(standardizedAPIFilePath($0)) } - let autoReferencedAPIComputation = EditFlowPerf.begin(EditFlowPerf.Stage.ReadFile.AutoSelect.autoReferencedAPIComputation) - let referencedAPIs = getAutoReferencedAPIs(selectedAPIs: selectedAPIs, unselectedAPIs: unselectedAPIs) - EditFlowPerf.end(EditFlowPerf.Stage.ReadFile.AutoSelect.autoReferencedAPIComputation, autoReferencedAPIComputation) - - var seen = Set() - var ordered: [String] = [] - for api in referencedAPIs { - let standardized = standardizedAPIFilePath(api) - if seen.insert(standardized).inserted { - ordered.append(standardized) - } - } - return ordered - } - /// Returns the list of file paths that have codemaps based on the specified mode. /// This centralizes the logic for determining which files get codemaps. /// Paths are displayed according to filePathDisplay (full or relative with root aliasing). diff --git a/Sources/RepoPrompt/Features/CodeMap/CodeMapPerfStats.swift b/Sources/RepoPrompt/Features/CodeMap/CodeMapPerfStats.swift deleted file mode 100644 index a465ecfb3..000000000 --- a/Sources/RepoPrompt/Features/CodeMap/CodeMapPerfStats.swift +++ /dev/null @@ -1,702 +0,0 @@ -// -// CodeMapPerfStats.swift -// RepoPrompt -// -// Lightweight counters for codemap performance analysis. -// These are expected to be used on a single thread per file scan. -// - -import Foundation - -struct CodeMapPerfOptions { - let enabled: Bool - let signposts: Bool - let collectCounters: Bool - - static let disabled = CodeMapPerfOptions(enabled: false, signposts: false, collectCounters: false) - static let countersOnly = CodeMapPerfOptions(enabled: true, signposts: false, collectCounters: true) - static let full = CodeMapPerfOptions(enabled: true, signposts: true, collectCounters: true) -} - -struct CodeMapSyntaxStartupPerfStats { - var primeDuration: TimeInterval = 0 - var warmCacheDuration: TimeInterval = 0 - var warmCodeMapQueriesDuration: TimeInterval = 0 - var languageConfigCreateDuration: TimeInterval = 0 - var languagePointerDuration: TimeInterval = 0 - var highlightQueryDataDuration: TimeInterval = 0 - var highlightQueryCompileDuration: TimeInterval = 0 - var codeMapQueryDataDuration: TimeInterval = 0 - var codeMapQueryCompileDuration: TimeInterval = 0 - - var warmCacheLanguageCount = 0 - var languageConfigCreateCount = 0 - var languageConfigSuccessCount = 0 - var languageConfigFailureCount = 0 - var highlightQueryCompileSuccessCount = 0 - var highlightQueryCompileFailureCount = 0 - var warmCodeMapQueryLanguageCount = 0 - var codeMapQueryPrecomputeSuccessCount = 0 - var codeMapQueryPrecomputeFailureCount = 0 - var codeMapQueryPrecomputeSkippedCount = 0 -} - -struct CodeMapSyntaxPerfStats { - var languageLookupDuration: TimeInterval = 0 - var oversizeGuardDuration: TimeInterval = 0 - var parserCreateDuration: TimeInterval = 0 - var setLanguageDuration: TimeInterval = 0 - var parseDuration: TimeInterval = 0 - var codeMapQueryLookupDuration: TimeInterval = 0 - var queryExecuteDuration: TimeInterval = 0 - var captureMaterializationDuration: TimeInterval = 0 - - var calls = 0 - var unsupported = 0 - var oversized = 0 - var parseNilTree = 0 - var parseNilRoot = 0 - var parserCreates = 0 - var queryExecutes = 0 - var captures = 0 - var codeMapQueryCacheHits = 0 - var codeMapQueryCacheMisses = 0 -} - -struct CodeMapPipelinePerfSnapshot: Equatable { - var snapshotBuildDuration: TimeInterval = 0 - var requestBuildDuration: TimeInterval = 0 - var contentLoadDuration: TimeInterval = 0 - var actorRequestIngestDuration: TimeInterval = 0 - var actorCachePrefetchDuration: TimeInterval = 0 - var actorCacheCheckDuration: TimeInterval = 0 - var actorQueueWaitDuration: TimeInterval = 0 - var parseAndQueryDuration: TimeInterval = 0 - var generatorDuration: TimeInterval = 0 - var batchApplyDuration: TimeInterval = 0 - var syntaxManagerPrimeDuration: TimeInterval = 0 - var syntaxWarmCacheDuration: TimeInterval = 0 - var syntaxWarmCodeMapQueriesDuration: TimeInterval = 0 - var syntaxLanguageConfigCreateDuration: TimeInterval = 0 - var syntaxLanguagePointerDuration: TimeInterval = 0 - var syntaxHighlightQueryDataDuration: TimeInterval = 0 - var syntaxHighlightQueryCompileDuration: TimeInterval = 0 - var syntaxCodeMapQueryDataDuration: TimeInterval = 0 - var syntaxCodeMapQueryCompileDuration: TimeInterval = 0 - var syntaxLanguageLookupDuration: TimeInterval = 0 - var syntaxOversizeGuardDuration: TimeInterval = 0 - var syntaxParserCreateDuration: TimeInterval = 0 - var syntaxSetLanguageDuration: TimeInterval = 0 - var syntaxParseDuration: TimeInterval = 0 - var syntaxCodeMapQueryLookupDuration: TimeInterval = 0 - var syntaxQueryExecuteDuration: TimeInterval = 0 - var syntaxCaptureMaterializationDuration: TimeInterval = 0 - var generatorCaptureIndexDuration: TimeInterval = 0 - var generatorSwiftContextDuration: TimeInterval = 0 - var generatorTSContextDuration: TimeInterval = 0 - var generatorCaptureLoopDuration: TimeInterval = 0 - var generatorCaptureLoopLineAdvanceDuration: TimeInterval = 0 - var generatorCaptureLoopSwiftStrategyDuration: TimeInterval = 0 - var generatorCaptureLoopTSStrategyDuration: TimeInterval = 0 - var generatorCaptureLoopInterfaceHeuristicDuration: TimeInterval = 0 - var generatorCaptureLoopImportExportDuration: TimeInterval = 0 - var generatorCaptureLoopTypeAliasDuration: TimeInterval = 0 - var generatorCaptureLoopEnumMacroDuration: TimeInterval = 0 - var generatorCaptureLoopFunctionDuration: TimeInterval = 0 - var generatorCaptureLoopVariableDuration: TimeInterval = 0 - var generatorCaptureLoopSkippedDuration: TimeInterval = 0 - var generatorCaptureLoopUnclassifiedDuration: TimeInterval = 0 - var generatorSwiftStrategyFunctionSignatureDuration: TimeInterval = 0 - var generatorSwiftStrategyFunctionNameLookupDuration: TimeInterval = 0 - var generatorSwiftStrategyParameterExtractionDuration: TimeInterval = 0 - var generatorSwiftStrategyReturnTypeExtractionDuration: TimeInterval = 0 - var generatorSwiftStrategyPropertyDeclarationDuration: TimeInterval = 0 - var generatorSwiftStrategyPropertyTypeExtractionDuration: TimeInterval = 0 - var generatorSwiftStrategyEnclosingTypeLookupDuration: TimeInterval = 0 - var generatorSwiftStrategyModelInsertionDuration: TimeInterval = 0 - var generatorSwiftStrategyContextOnlyDuration: TimeInterval = 0 - var generatorFallbackFunctionDeclarationDuration: TimeInterval = 0 - var generatorFallbackFunctionJSTSSignatureDuration: TimeInterval = 0 - var generatorFallbackFunctionNameExtractionDuration: TimeInterval = 0 - var generatorFallbackFunctionLTEParseDuration: TimeInterval = 0 - var generatorFallbackFunctionTSFastPathDuration: TimeInterval = 0 - var generatorFallbackFunctionReferencedTypesDuration: TimeInterval = 0 - var generatorFallbackFunctionRoutingDuration: TimeInterval = 0 - var generatorFallbackFunctionModelInsertionDuration: TimeInterval = 0 - var generatorFallbackFunctionSkippedDuration: TimeInterval = 0 - var generatorDeclarationExtractionDuration: TimeInterval = 0 - var generatorJSTSSignatureDuration: TimeInterval = 0 - var generatorLanguageTypeExtractorFunctionDuration: TimeInterval = 0 - var generatorLanguageTypeExtractorVariableDuration: TimeInterval = 0 - var generatorTypeCleanerDuration: TimeInterval = 0 - var generatorTypeCleanerSwiftDuration: TimeInterval = 0 - var generatorTypeCleanerTSDuration: TimeInterval = 0 - var generatorTypeCleanerTSXDuration: TimeInterval = 0 - var generatorTypeCleanerJSDuration: TimeInterval = 0 - var generatorTypeCleanerOtherLanguageDuration: TimeInterval = 0 - var generatorTypeCleanerPrecleanDuration: TimeInterval = 0 - var generatorTypeCleanerTSLogicDuration: TimeInterval = 0 - var generatorTypeCleanerNonTSLogicDuration: TimeInterval = 0 - var generatorTypeCleanerTSObjectLiteralDuration: TimeInterval = 0 - var generatorTypeCleanerFilterDuration: TimeInterval = 0 - var generatorTypeCleanerDedupDuration: TimeInterval = 0 - var generatorReferencedTypesFinalizeDuration: TimeInterval = 0 - var generatorFileAPIInitDuration: TimeInterval = 0 - - var requestsBuilt = 0 - var requestsEnqueued = 0 - var cacheHits = 0 - var cacheMisses = 0 - var oversizedSkips = 0 - var parseFailures = 0 - var generatedAPIs = 0 - var nilAPIs = 0 - var codeMapQueryCacheHits = 0 - var codeMapQueryCacheMisses = 0 - var syntaxWarmCacheLanguageCount = 0 - var syntaxLanguageConfigCreateCount = 0 - var syntaxLanguageConfigSuccessCount = 0 - var syntaxLanguageConfigFailureCount = 0 - var syntaxHighlightQueryCompileSuccessCount = 0 - var syntaxHighlightQueryCompileFailureCount = 0 - var syntaxWarmCodeMapQueryLanguageCount = 0 - var syntaxCodeMapQueryPrecomputeSuccessCount = 0 - var syntaxCodeMapQueryPrecomputeFailureCount = 0 - var syntaxCodeMapQueryPrecomputeSkippedCount = 0 - var syntaxCodeMapCalls = 0 - var syntaxUnsupportedExtensionCount = 0 - var syntaxOversizedSkipCount = 0 - var syntaxParseNilTreeCount = 0 - var syntaxParseNilRootCount = 0 - var syntaxParserCreateCount = 0 - var syntaxQueryExecuteCount = 0 - var syntaxCaptureCount = 0 - var capturesProcessed = 0 - var swiftStrategyHandled = 0 - var tsStrategyHandled = 0 - var fallbackHandled = 0 - var generatorCaptureLoopLineAdvanceCount = 0 - var generatorCaptureLoopSwiftStrategyCount = 0 - var generatorCaptureLoopTSStrategyCount = 0 - var generatorCaptureLoopInterfaceHeuristicCount = 0 - var generatorCaptureLoopImportExportCount = 0 - var generatorCaptureLoopTypeAliasCount = 0 - var generatorCaptureLoopEnumMacroCount = 0 - var generatorCaptureLoopFunctionCount = 0 - var generatorCaptureLoopVariableCount = 0 - var generatorCaptureLoopSkippedCount = 0 - var generatorCaptureLoopUnclassifiedCount = 0 - var generatorSwiftStrategyFunctionSignatureCount = 0 - var generatorSwiftStrategyFunctionNameLookupCount = 0 - var generatorSwiftStrategyParameterExtractionCount = 0 - var generatorSwiftStrategyReturnTypeExtractionCount = 0 - var generatorSwiftStrategyPropertyDeclarationCount = 0 - var generatorSwiftStrategyPropertyTypeExtractionCount = 0 - var generatorSwiftStrategyEnclosingTypeLookupCount = 0 - var generatorSwiftStrategyModelInsertionCount = 0 - var generatorSwiftStrategyContextOnlyCount = 0 - var generatorSwiftStrategyHandledFunctionCount = 0 - var generatorSwiftStrategyHandledPropertyCount = 0 - var generatorFallbackFunctionDeclarationCount = 0 - var generatorFallbackFunctionJSTSSignatureCount = 0 - var generatorFallbackFunctionNameExtractionCount = 0 - var generatorFallbackFunctionLTEParseCount = 0 - var generatorFallbackFunctionTSFastPathCount = 0 - var generatorFallbackFunctionReferencedTypesCount = 0 - var generatorFallbackFunctionRoutingCount = 0 - var generatorFallbackFunctionModelInsertionCount = 0 - var generatorFallbackFunctionSkippedCount = 0 - var generatorFallbackFunctionLightweightCount = 0 - var generatorFallbackFunctionHeavyweightCount = 0 - var generatorFallbackFunctionGlobalInsertCount = 0 - var generatorFallbackFunctionMethodInsertCount = 0 - var generatorFallbackFunctionInterfaceInsertCount = 0 - var captureDeclarationCalls = 0 - var jstsSignatureCallsFunctionLike = 0 - var jstsSignatureCallsStatementLike = 0 - var lteMatchAnyFunctionCalls = 0 - var lteMatchAnyVariableCalls = 0 - var typeCleanerExtractCalls = 0 - var typeCleanerCacheHits = 0 - var typeCleanerCacheMisses = 0 - var typeCleanerSwiftCalls = 0 - var typeCleanerTSCalls = 0 - var typeCleanerTSXCalls = 0 - var typeCleanerJSCalls = 0 - var typeCleanerOtherLanguageCalls = 0 - var typeCleanerPrecleanCount = 0 - var typeCleanerTSLogicCount = 0 - var typeCleanerNonTSLogicCount = 0 - var typeCleanerTSObjectLiteralCount = 0 - var typeCleanerFilterCount = 0 - var typeCleanerDedupCount = 0 - var referencedTypesRawInsertions = 0 - var referencedTypesPrefilterSkips = 0 - var referencedTypesEmptyResults = 0 - var referencedTypesOutputTypeCount = 0 - var extractionMemoJSTSHits = 0 - var extractionMemoJSTSMisses = 0 - var extractionMemoFunctionHits = 0 - var extractionMemoFunctionMisses = 0 - var extractionMemoFunctionParsedHits = 0 - var extractionMemoFunctionParsedMisses = 0 - var extractionMemoVariableHits = 0 - var extractionMemoVariableMisses = 0 - var extractionMemoTSFastPathHits = 0 - var extractionMemoTSFastPathMisses = 0 - - var resultBatchCount = 0 - var maxResultBatchSize = 0 -} - -final class CodeMapPipelinePerfStats: @unchecked Sendable { - private let lock = NSLock() - private var storage = CodeMapPipelinePerfSnapshot() - - var snapshot: CodeMapPipelinePerfSnapshot { - lock.withLock { storage } - } - - func addDuration(_ keyPath: WritableKeyPath, _ duration: TimeInterval) { - lock.withLock { - storage[keyPath: keyPath] += duration - } - } - - func increment(_ keyPath: WritableKeyPath, by amount: Int = 1) { - guard amount != 0 else { return } - lock.withLock { - storage[keyPath: keyPath] += amount - } - } - - func recordResultBatch(size: Int) { - lock.withLock { - storage.resultBatchCount += 1 - storage.maxResultBatchSize = max(storage.maxResultBatchSize, size) - } - } - - func mergeSyntaxManagerStartupStats(_ stats: CodeMapSyntaxStartupPerfStats) { - lock.withLock { - storage.syntaxManagerPrimeDuration += stats.primeDuration - storage.syntaxWarmCacheDuration += stats.warmCacheDuration - storage.syntaxWarmCodeMapQueriesDuration += stats.warmCodeMapQueriesDuration - storage.syntaxLanguageConfigCreateDuration += stats.languageConfigCreateDuration - storage.syntaxLanguagePointerDuration += stats.languagePointerDuration - storage.syntaxHighlightQueryDataDuration += stats.highlightQueryDataDuration - storage.syntaxHighlightQueryCompileDuration += stats.highlightQueryCompileDuration - storage.syntaxCodeMapQueryDataDuration += stats.codeMapQueryDataDuration - storage.syntaxCodeMapQueryCompileDuration += stats.codeMapQueryCompileDuration - - storage.syntaxWarmCacheLanguageCount += stats.warmCacheLanguageCount - storage.syntaxLanguageConfigCreateCount += stats.languageConfigCreateCount - storage.syntaxLanguageConfigSuccessCount += stats.languageConfigSuccessCount - storage.syntaxLanguageConfigFailureCount += stats.languageConfigFailureCount - storage.syntaxHighlightQueryCompileSuccessCount += stats.highlightQueryCompileSuccessCount - storage.syntaxHighlightQueryCompileFailureCount += stats.highlightQueryCompileFailureCount - storage.syntaxWarmCodeMapQueryLanguageCount += stats.warmCodeMapQueryLanguageCount - storage.syntaxCodeMapQueryPrecomputeSuccessCount += stats.codeMapQueryPrecomputeSuccessCount - storage.syntaxCodeMapQueryPrecomputeFailureCount += stats.codeMapQueryPrecomputeFailureCount - storage.syntaxCodeMapQueryPrecomputeSkippedCount += stats.codeMapQueryPrecomputeSkippedCount - } - } - - func mergeSyntaxCodeMapStats(_ stats: CodeMapSyntaxPerfStats) { - lock.withLock { - storage.syntaxLanguageLookupDuration += stats.languageLookupDuration - storage.syntaxOversizeGuardDuration += stats.oversizeGuardDuration - storage.syntaxParserCreateDuration += stats.parserCreateDuration - storage.syntaxSetLanguageDuration += stats.setLanguageDuration - storage.syntaxParseDuration += stats.parseDuration - storage.syntaxCodeMapQueryLookupDuration += stats.codeMapQueryLookupDuration - storage.syntaxQueryExecuteDuration += stats.queryExecuteDuration - storage.syntaxCaptureMaterializationDuration += stats.captureMaterializationDuration - - storage.syntaxCodeMapCalls += stats.calls - storage.syntaxUnsupportedExtensionCount += stats.unsupported - storage.syntaxOversizedSkipCount += stats.oversized - storage.syntaxParseNilTreeCount += stats.parseNilTree - storage.syntaxParseNilRootCount += stats.parseNilRoot - storage.syntaxParserCreateCount += stats.parserCreates - storage.syntaxQueryExecuteCount += stats.queryExecutes - storage.syntaxCaptureCount += stats.captures - storage.codeMapQueryCacheHits += stats.codeMapQueryCacheHits - storage.codeMapQueryCacheMisses += stats.codeMapQueryCacheMisses - } - } - - func mergeGeneratorStats(_ stats: CodeMapPerfStats) { - lock.withLock { - storage.generatorCaptureIndexDuration += stats.captureIndexDuration - storage.generatorSwiftContextDuration += stats.swiftContextDuration - storage.generatorTSContextDuration += stats.tsContextDuration - storage.generatorCaptureLoopDuration += stats.captureLoopDuration - storage.generatorCaptureLoopLineAdvanceDuration += stats.captureLoopLineAdvanceDuration - storage.generatorCaptureLoopSwiftStrategyDuration += stats.captureLoopSwiftStrategyDuration - storage.generatorCaptureLoopTSStrategyDuration += stats.captureLoopTSStrategyDuration - storage.generatorCaptureLoopInterfaceHeuristicDuration += stats.captureLoopInterfaceHeuristicDuration - storage.generatorCaptureLoopImportExportDuration += stats.captureLoopImportExportDuration - storage.generatorCaptureLoopTypeAliasDuration += stats.captureLoopTypeAliasDuration - storage.generatorCaptureLoopEnumMacroDuration += stats.captureLoopEnumMacroDuration - storage.generatorCaptureLoopFunctionDuration += stats.captureLoopFunctionDuration - storage.generatorCaptureLoopVariableDuration += stats.captureLoopVariableDuration - storage.generatorCaptureLoopSkippedDuration += stats.captureLoopSkippedDuration - storage.generatorCaptureLoopUnclassifiedDuration += stats.captureLoopUnclassifiedDuration - storage.generatorSwiftStrategyFunctionSignatureDuration += stats.swiftStrategyFunctionSignatureDuration - storage.generatorSwiftStrategyFunctionNameLookupDuration += stats.swiftStrategyFunctionNameLookupDuration - storage.generatorSwiftStrategyParameterExtractionDuration += stats.swiftStrategyParameterExtractionDuration - storage.generatorSwiftStrategyReturnTypeExtractionDuration += stats.swiftStrategyReturnTypeExtractionDuration - storage.generatorSwiftStrategyPropertyDeclarationDuration += stats.swiftStrategyPropertyDeclarationDuration - storage.generatorSwiftStrategyPropertyTypeExtractionDuration += stats.swiftStrategyPropertyTypeExtractionDuration - storage.generatorSwiftStrategyEnclosingTypeLookupDuration += stats.swiftStrategyEnclosingTypeLookupDuration - storage.generatorSwiftStrategyModelInsertionDuration += stats.swiftStrategyModelInsertionDuration - storage.generatorSwiftStrategyContextOnlyDuration += stats.swiftStrategyContextOnlyDuration - storage.generatorFallbackFunctionDeclarationDuration += stats.fallbackFunctionDeclarationDuration - storage.generatorFallbackFunctionJSTSSignatureDuration += stats.fallbackFunctionJSTSSignatureDuration - storage.generatorFallbackFunctionNameExtractionDuration += stats.fallbackFunctionNameExtractionDuration - storage.generatorFallbackFunctionLTEParseDuration += stats.fallbackFunctionLTEParseDuration - storage.generatorFallbackFunctionTSFastPathDuration += stats.fallbackFunctionTSFastPathDuration - storage.generatorFallbackFunctionReferencedTypesDuration += stats.fallbackFunctionReferencedTypesDuration - storage.generatorFallbackFunctionRoutingDuration += stats.fallbackFunctionRoutingDuration - storage.generatorFallbackFunctionModelInsertionDuration += stats.fallbackFunctionModelInsertionDuration - storage.generatorFallbackFunctionSkippedDuration += stats.fallbackFunctionSkippedDuration - storage.generatorDeclarationExtractionDuration += stats.captureDeclarationDuration - storage.generatorJSTSSignatureDuration += stats.jstsSignatureDuration - storage.generatorLanguageTypeExtractorFunctionDuration += stats.languageTypeExtractorFunctionDuration - storage.generatorLanguageTypeExtractorVariableDuration += stats.languageTypeExtractorVariableDuration - storage.generatorTypeCleanerDuration += stats.typeCleanerDuration - storage.generatorTypeCleanerSwiftDuration += stats.typeCleanerSwiftDuration - storage.generatorTypeCleanerTSDuration += stats.typeCleanerTSDuration - storage.generatorTypeCleanerTSXDuration += stats.typeCleanerTSXDuration - storage.generatorTypeCleanerJSDuration += stats.typeCleanerJSDuration - storage.generatorTypeCleanerOtherLanguageDuration += stats.typeCleanerOtherLanguageDuration - storage.generatorTypeCleanerPrecleanDuration += stats.typeCleanerPrecleanDuration - storage.generatorTypeCleanerTSLogicDuration += stats.typeCleanerTSLogicDuration - storage.generatorTypeCleanerNonTSLogicDuration += stats.typeCleanerNonTSLogicDuration - storage.generatorTypeCleanerTSObjectLiteralDuration += stats.typeCleanerTSObjectLiteralDuration - storage.generatorTypeCleanerFilterDuration += stats.typeCleanerFilterDuration - storage.generatorTypeCleanerDedupDuration += stats.typeCleanerDedupDuration - storage.generatorReferencedTypesFinalizeDuration += stats.referencedTypesFinalizeDuration - storage.generatorFileAPIInitDuration += stats.fileAPIInitDuration - - storage.capturesProcessed += stats.capturesProcessed - storage.swiftStrategyHandled += stats.swiftStrategyHandled - storage.tsStrategyHandled += stats.tsStrategyHandled - storage.fallbackHandled += stats.fallbackHandled - storage.generatorCaptureLoopLineAdvanceCount += stats.captureLoopLineAdvanceCount - storage.generatorCaptureLoopSwiftStrategyCount += stats.captureLoopSwiftStrategyCount - storage.generatorCaptureLoopTSStrategyCount += stats.captureLoopTSStrategyCount - storage.generatorCaptureLoopInterfaceHeuristicCount += stats.captureLoopInterfaceHeuristicCount - storage.generatorCaptureLoopImportExportCount += stats.captureLoopImportExportCount - storage.generatorCaptureLoopTypeAliasCount += stats.captureLoopTypeAliasCount - storage.generatorCaptureLoopEnumMacroCount += stats.captureLoopEnumMacroCount - storage.generatorCaptureLoopFunctionCount += stats.captureLoopFunctionCount - storage.generatorCaptureLoopVariableCount += stats.captureLoopVariableCount - storage.generatorCaptureLoopSkippedCount += stats.captureLoopSkippedCount - storage.generatorCaptureLoopUnclassifiedCount += stats.captureLoopUnclassifiedCount - storage.generatorSwiftStrategyFunctionSignatureCount += stats.swiftStrategyFunctionSignatureCount - storage.generatorSwiftStrategyFunctionNameLookupCount += stats.swiftStrategyFunctionNameLookupCount - storage.generatorSwiftStrategyParameterExtractionCount += stats.swiftStrategyParameterExtractionCount - storage.generatorSwiftStrategyReturnTypeExtractionCount += stats.swiftStrategyReturnTypeExtractionCount - storage.generatorSwiftStrategyPropertyDeclarationCount += stats.swiftStrategyPropertyDeclarationCount - storage.generatorSwiftStrategyPropertyTypeExtractionCount += stats.swiftStrategyPropertyTypeExtractionCount - storage.generatorSwiftStrategyEnclosingTypeLookupCount += stats.swiftStrategyEnclosingTypeLookupCount - storage.generatorSwiftStrategyModelInsertionCount += stats.swiftStrategyModelInsertionCount - storage.generatorSwiftStrategyContextOnlyCount += stats.swiftStrategyContextOnlyCount - storage.generatorSwiftStrategyHandledFunctionCount += stats.swiftStrategyHandledFunctionCount - storage.generatorSwiftStrategyHandledPropertyCount += stats.swiftStrategyHandledPropertyCount - storage.generatorFallbackFunctionDeclarationCount += stats.fallbackFunctionDeclarationCount - storage.generatorFallbackFunctionJSTSSignatureCount += stats.fallbackFunctionJSTSSignatureCount - storage.generatorFallbackFunctionNameExtractionCount += stats.fallbackFunctionNameExtractionCount - storage.generatorFallbackFunctionLTEParseCount += stats.fallbackFunctionLTEParseCount - storage.generatorFallbackFunctionTSFastPathCount += stats.fallbackFunctionTSFastPathCount - storage.generatorFallbackFunctionReferencedTypesCount += stats.fallbackFunctionReferencedTypesCount - storage.generatorFallbackFunctionRoutingCount += stats.fallbackFunctionRoutingCount - storage.generatorFallbackFunctionModelInsertionCount += stats.fallbackFunctionModelInsertionCount - storage.generatorFallbackFunctionSkippedCount += stats.fallbackFunctionSkippedCount - storage.generatorFallbackFunctionLightweightCount += stats.fallbackFunctionLightweightCount - storage.generatorFallbackFunctionHeavyweightCount += stats.fallbackFunctionHeavyweightCount - storage.generatorFallbackFunctionGlobalInsertCount += stats.fallbackFunctionGlobalInsertCount - storage.generatorFallbackFunctionMethodInsertCount += stats.fallbackFunctionMethodInsertCount - storage.generatorFallbackFunctionInterfaceInsertCount += stats.fallbackFunctionInterfaceInsertCount - storage.captureDeclarationCalls += stats.captureDeclarationCalls - storage.jstsSignatureCallsFunctionLike += stats.jstsSignatureCallsFunctionLike - storage.jstsSignatureCallsStatementLike += stats.jstsSignatureCallsStatementLike - storage.lteMatchAnyFunctionCalls += stats.lteMatchAnyFunctionCalls - storage.lteMatchAnyVariableCalls += stats.lteMatchAnyVariableCalls - storage.typeCleanerExtractCalls += stats.typeCleanerExtractCalls - storage.typeCleanerCacheHits += stats.typeCleanerCacheHits - storage.typeCleanerCacheMisses += stats.typeCleanerCacheMisses - storage.typeCleanerSwiftCalls += stats.typeCleanerSwiftCalls - storage.typeCleanerTSCalls += stats.typeCleanerTSCalls - storage.typeCleanerTSXCalls += stats.typeCleanerTSXCalls - storage.typeCleanerJSCalls += stats.typeCleanerJSCalls - storage.typeCleanerOtherLanguageCalls += stats.typeCleanerOtherLanguageCalls - storage.typeCleanerPrecleanCount += stats.typeCleanerPrecleanCount - storage.typeCleanerTSLogicCount += stats.typeCleanerTSLogicCount - storage.typeCleanerNonTSLogicCount += stats.typeCleanerNonTSLogicCount - storage.typeCleanerTSObjectLiteralCount += stats.typeCleanerTSObjectLiteralCount - storage.typeCleanerFilterCount += stats.typeCleanerFilterCount - storage.typeCleanerDedupCount += stats.typeCleanerDedupCount - storage.referencedTypesRawInsertions += stats.referencedTypesRawInsertions - storage.referencedTypesPrefilterSkips += stats.referencedTypesPrefilterSkips - storage.referencedTypesEmptyResults += stats.referencedTypesEmptyResults - storage.referencedTypesOutputTypeCount += stats.referencedTypesOutputTypeCount - storage.extractionMemoJSTSHits += stats.extractionMemoJSTSHits - storage.extractionMemoJSTSMisses += stats.extractionMemoJSTSMisses - storage.extractionMemoFunctionHits += stats.extractionMemoFunctionHits - storage.extractionMemoFunctionMisses += stats.extractionMemoFunctionMisses - storage.extractionMemoFunctionParsedHits += stats.extractionMemoFunctionParsedHits - storage.extractionMemoFunctionParsedMisses += stats.extractionMemoFunctionParsedMisses - storage.extractionMemoVariableHits += stats.extractionMemoVariableHits - storage.extractionMemoVariableMisses += stats.extractionMemoVariableMisses - storage.extractionMemoTSFastPathHits += stats.extractionMemoTSFastPathHits - storage.extractionMemoTSFastPathMisses += stats.extractionMemoTSFastPathMisses - } - } -} - -enum CodeMapPerfRuntime { - static let instrumentationEnvironmentKey = "REPOPROMPT_CODEMAP_PERF" - static let benchmarkEnvironmentKey = "REPOPROMPT_RUN_CODEMAP_BENCHMARKS" - static let benchmarkIterationsEnvironmentKey = "REPOPROMPT_CODEMAP_BENCHMARK_ITERATIONS" - static let benchmarkMarkerPath = "/tmp/repoprompt-run-codemap-benchmarks" - - #if DEBUG || CODEMAP_PERF - static let isCompiledIn = true - #else - static let isCompiledIn = false - #endif - - private static var benchmarkMarkerEnabled: Bool { - guard isCompiledIn else { return false } - return !isRunningInCI && FileManager.default.fileExists(atPath: benchmarkMarkerPath) - } - - private static var benchmarkRequested: Bool { - guard isCompiledIn else { return false } - return environmentFlagEnabled(benchmarkEnvironmentKey) - || CommandLine.arguments.contains("--run-codemap-benchmarks") - || benchmarkMarkerEnabled - } - - static let isEnabled: Bool = { - guard isCompiledIn else { return false } - return environmentFlagEnabled(instrumentationEnvironmentKey) || benchmarkRequested - }() - - static let sharedPipelineStats: CodeMapPipelinePerfStats? = isEnabled ? CodeMapPipelinePerfStats() : nil - - static func makeGeneratorOptions() -> CodeMapPerfOptions { - isEnabled ? .countersOnly : .disabled - } - - static func makeGeneratorStats() -> CodeMapPerfStats? { - isEnabled ? CodeMapPerfStats() : nil - } - - @inline(__always) - static func activeOptions(_ options: CodeMapPerfOptions) -> CodeMapPerfOptions { - #if DEBUG || CODEMAP_PERF - return options - #else - return .disabled - #endif - } - - @inline(__always) - static func activeStats(_ stats: CodeMapPerfStats?) -> CodeMapPerfStats? { - #if DEBUG || CODEMAP_PERF - return stats - #else - return nil - #endif - } - - static var shouldRunBenchmarks: Bool { - benchmarkRequested - } - - static var isRunningInCI: Bool { - ["CI", "GITHUB_ACTIONS", "BUILDKITE", "JENKINS_URL", "TEAMCITY_VERSION"].contains { key in - ProcessInfo.processInfo.environment[key] != nil - } - } - - static func environmentFlagEnabled(_ name: String) -> Bool { - guard let rawValue = ProcessInfo.processInfo.environment[name] else { - return false - } - switch rawValue.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() { - case "1", "true", "yes", "on", "enabled", "enable", "run": - return true - default: - return false - } - } - - static func currentTime() -> DispatchTime { - DispatchTime.now() - } - - static func durationSince(_ start: DispatchTime) -> TimeInterval { - Double(DispatchTime.now().uptimeNanoseconds - start.uptimeNanoseconds) / 1_000_000_000.0 - } -} - -final class CodeMapPerfStats { - // Capture loop - var capturesProcessed = 0 - var swiftStrategyHandled = 0 - var tsStrategyHandled = 0 - var fallbackHandled = 0 - var captureLoopLineAdvanceCount = 0 - var captureLoopSwiftStrategyCount = 0 - var captureLoopTSStrategyCount = 0 - var captureLoopInterfaceHeuristicCount = 0 - var captureLoopImportExportCount = 0 - var captureLoopTypeAliasCount = 0 - var captureLoopEnumMacroCount = 0 - var captureLoopFunctionCount = 0 - var captureLoopVariableCount = 0 - var captureLoopSkippedCount = 0 - var captureLoopUnclassifiedCount = 0 - var swiftStrategyFunctionSignatureCount = 0 - var swiftStrategyFunctionNameLookupCount = 0 - var swiftStrategyParameterExtractionCount = 0 - var swiftStrategyReturnTypeExtractionCount = 0 - var swiftStrategyPropertyDeclarationCount = 0 - var swiftStrategyPropertyTypeExtractionCount = 0 - var swiftStrategyEnclosingTypeLookupCount = 0 - var swiftStrategyModelInsertionCount = 0 - var swiftStrategyContextOnlyCount = 0 - var swiftStrategyHandledFunctionCount = 0 - var swiftStrategyHandledPropertyCount = 0 - var fallbackFunctionDeclarationCount = 0 - var fallbackFunctionJSTSSignatureCount = 0 - var fallbackFunctionNameExtractionCount = 0 - var fallbackFunctionLTEParseCount = 0 - var fallbackFunctionTSFastPathCount = 0 - var fallbackFunctionReferencedTypesCount = 0 - var fallbackFunctionRoutingCount = 0 - var fallbackFunctionModelInsertionCount = 0 - var fallbackFunctionSkippedCount = 0 - var fallbackFunctionLightweightCount = 0 - var fallbackFunctionHeavyweightCount = 0 - var fallbackFunctionGlobalInsertCount = 0 - var fallbackFunctionMethodInsertCount = 0 - var fallbackFunctionInterfaceInsertCount = 0 - - // Declaration capture + JS/TS signature extraction - var captureDeclarationCalls = 0 - var jstsSignatureCallsFunctionLike = 0 - var jstsSignatureCallsStatementLike = 0 - - // LanguageTypeExtractor - var lteMatchAnyFunctionCalls = 0 - var lteMatchAnyVariableCalls = 0 - var tsConstructorMatches = 0 - var tsAccessorMatches = 0 - var tsClassMethodMatches = 0 - var tsClassArrowMatches = 0 - var tsClassArrowNoParensMatches = 0 - var tsArrowFunctionMatches = 0 - var tsArrowFunctionParamsReturnMatches = 0 - var tsxConstructorMatches = 0 - var tsxAccessorMatches = 0 - var tsxClassMethodMatches = 0 - var tsxClassArrowMatches = 0 - var tsxClassArrowNoParensMatches = 0 - var tsxArrowFunctionMatches = 0 - var tsxArrowFunctionParamsReturnMatches = 0 - var swiftReturnTypeFastPathHits = 0 - var tsReturnTypeFastPathHits = 0 - var tsTypeAnnotationFastPathHits = 0 - var tsTypeAliasRhsFastPathHits = 0 - - // TypeCleaner - var typeCleanerExtractCalls = 0 - var typeCleanerCacheHits = 0 - var typeCleanerCacheMisses = 0 - var typeCleanerSwiftCalls = 0 - var typeCleanerTSCalls = 0 - var typeCleanerTSXCalls = 0 - var typeCleanerJSCalls = 0 - var typeCleanerOtherLanguageCalls = 0 - var typeCleanerPrecleanCount = 0 - var typeCleanerTSLogicCount = 0 - var typeCleanerNonTSLogicCount = 0 - var typeCleanerTSObjectLiteralCount = 0 - var typeCleanerFilterCount = 0 - var typeCleanerDedupCount = 0 - var referencedTypesRawInsertions = 0 - var referencedTypesPrefilterSkips = 0 - var referencedTypesEmptyResults = 0 - var referencedTypesOutputTypeCount = 0 - - // Extraction memo - var extractionMemoJSTSHits = 0 - var extractionMemoJSTSMisses = 0 - var extractionMemoFunctionHits = 0 - var extractionMemoFunctionMisses = 0 - var extractionMemoFunctionParsedHits = 0 - var extractionMemoFunctionParsedMisses = 0 - var extractionMemoVariableHits = 0 - var extractionMemoVariableMisses = 0 - var extractionMemoTSFastPathHits = 0 - var extractionMemoTSFastPathMisses = 0 - - // Durations - var captureIndexDuration: TimeInterval = 0 - var swiftContextDuration: TimeInterval = 0 - var tsContextDuration: TimeInterval = 0 - var captureLoopDuration: TimeInterval = 0 - var captureLoopLineAdvanceDuration: TimeInterval = 0 - var captureLoopSwiftStrategyDuration: TimeInterval = 0 - var captureLoopTSStrategyDuration: TimeInterval = 0 - var captureLoopInterfaceHeuristicDuration: TimeInterval = 0 - var captureLoopImportExportDuration: TimeInterval = 0 - var captureLoopTypeAliasDuration: TimeInterval = 0 - var captureLoopEnumMacroDuration: TimeInterval = 0 - var captureLoopFunctionDuration: TimeInterval = 0 - var captureLoopVariableDuration: TimeInterval = 0 - var captureLoopSkippedDuration: TimeInterval = 0 - var captureLoopUnclassifiedDuration: TimeInterval = 0 - var swiftStrategyFunctionSignatureDuration: TimeInterval = 0 - var swiftStrategyFunctionNameLookupDuration: TimeInterval = 0 - var swiftStrategyParameterExtractionDuration: TimeInterval = 0 - var swiftStrategyReturnTypeExtractionDuration: TimeInterval = 0 - var swiftStrategyPropertyDeclarationDuration: TimeInterval = 0 - var swiftStrategyPropertyTypeExtractionDuration: TimeInterval = 0 - var swiftStrategyEnclosingTypeLookupDuration: TimeInterval = 0 - var swiftStrategyModelInsertionDuration: TimeInterval = 0 - var swiftStrategyContextOnlyDuration: TimeInterval = 0 - var fallbackFunctionDeclarationDuration: TimeInterval = 0 - var fallbackFunctionJSTSSignatureDuration: TimeInterval = 0 - var fallbackFunctionNameExtractionDuration: TimeInterval = 0 - var fallbackFunctionLTEParseDuration: TimeInterval = 0 - var fallbackFunctionTSFastPathDuration: TimeInterval = 0 - var fallbackFunctionReferencedTypesDuration: TimeInterval = 0 - var fallbackFunctionRoutingDuration: TimeInterval = 0 - var fallbackFunctionModelInsertionDuration: TimeInterval = 0 - var fallbackFunctionSkippedDuration: TimeInterval = 0 - var captureDeclarationDuration: TimeInterval = 0 - var jstsSignatureDuration: TimeInterval = 0 - var languageTypeExtractorFunctionDuration: TimeInterval = 0 - var languageTypeExtractorVariableDuration: TimeInterval = 0 - var typeCleanerDuration: TimeInterval = 0 - var typeCleanerSwiftDuration: TimeInterval = 0 - var typeCleanerTSDuration: TimeInterval = 0 - var typeCleanerTSXDuration: TimeInterval = 0 - var typeCleanerJSDuration: TimeInterval = 0 - var typeCleanerOtherLanguageDuration: TimeInterval = 0 - var typeCleanerPrecleanDuration: TimeInterval = 0 - var typeCleanerTSLogicDuration: TimeInterval = 0 - var typeCleanerNonTSLogicDuration: TimeInterval = 0 - var typeCleanerTSObjectLiteralDuration: TimeInterval = 0 - var typeCleanerFilterDuration: TimeInterval = 0 - var typeCleanerDedupDuration: TimeInterval = 0 - var referencedTypesFinalizeDuration: TimeInterval = 0 - var fileAPIInitDuration: TimeInterval = 0 -} diff --git a/Sources/RepoPrompt/Features/CodeMap/FileTreeSelectionSnapshot.swift b/Sources/RepoPrompt/Features/CodeMap/FileTreeSelectionSnapshot.swift deleted file mode 100644 index c82376375..000000000 --- a/Sources/RepoPrompt/Features/CodeMap/FileTreeSelectionSnapshot.swift +++ /dev/null @@ -1,67 +0,0 @@ -import Foundation - -struct FileTreeSelectionSnapshot { - let roots: [FileTreeFolderSnapshot] - let selectedFileIDs: Set - let mode: String - let showFullPaths: Bool - let onlyIncludeRootsWithSelectedFiles: Bool - let includeLegend: Bool - let showCodeMapMarkers: Bool - let maxDepth: Int? - - init( - roots: [FileTreeFolderSnapshot], - selectedFileIDs: Set, - mode: String, - showFullPaths: Bool, - onlyIncludeRootsWithSelectedFiles: Bool, - includeLegend: Bool, - showCodeMapMarkers: Bool = true, - maxDepth: Int? = nil - ) { - self.roots = roots - self.selectedFileIDs = selectedFileIDs - self.mode = mode - self.showFullPaths = showFullPaths - self.onlyIncludeRootsWithSelectedFiles = onlyIncludeRootsWithSelectedFiles - self.includeLegend = includeLegend - self.showCodeMapMarkers = showCodeMapMarkers - self.maxDepth = maxDepth - } -} - -struct FileTreeFolderSnapshot: Hashable { - let id: UUID - let name: String - let fullPath: String - let standardizedFullPath: String - let standardizedRootPath: String - let children: [FileTreeNodeSnapshot] -} - -struct FileTreeFileSnapshot: Hashable { - let id: UUID - let name: String - let fileExtension: String? - let hasCodeMap: Bool -} - -indirect enum FileTreeNodeSnapshot: Hashable { - case folder(FileTreeFolderSnapshot) - case file(FileTreeFileSnapshot) - - var id: UUID { - switch self { - case let .folder(folder): folder.id - case let .file(file): file.id - } - } - - var name: String { - switch self { - case let .folder(folder): folder.name - case let .file(file): file.name - } - } -} diff --git a/Sources/RepoPrompt/Features/CodeMap/Models/FileAPI.swift b/Sources/RepoPrompt/Features/CodeMap/Models/FileAPI.swift deleted file mode 100644 index 2ca13dc8a..000000000 --- a/Sources/RepoPrompt/Features/CodeMap/Models/FileAPI.swift +++ /dev/null @@ -1,296 +0,0 @@ -import Foundation - -// MARK: - Supporting Types - -struct InterfaceInfo: Codable { - let name: String - var properties: [PropertyInfo] = [] - var methods: [FunctionInfo] = [] -} - -struct TypeAliasInfo: Codable { - let name: String - let definitionLine: String -} - -struct ClassInfo: Codable { - let name: String - var methods: [FunctionInfo] - var properties: [PropertyInfo] -} - -struct FunctionInfo: Codable { - let name: String - var parameters: [ParameterInfo] - var returnType: String? - let definitionLine: String - let lineNumber: Int? -} - -struct ParameterInfo: Codable { - let externalName: String? - let localName: String - var typeName: String? -} - -struct PropertyInfo: Codable { - let name: String - let typeName: String? -} - -struct VariableInfo: Codable { - let name: String - let typeName: String? - let definitionLine: String -} - -struct EnumInfo: Codable { - let name: String - var cases: [String] -} - -/// Represents a structured "API surface" for a file. -struct FileAPI: Codable { - // MARK: - Codable Stored Properties - - let filePath: String - var imports: [String] - var exports: [String] - var classes: [ClassInfo] - var interfaces: [InterfaceInfo] - var aliases: [TypeAliasInfo] - var literalUnions: [String] - var functions: [FunctionInfo] - var enums: [EnumInfo] - var globalVars: [VariableInfo] - var macros: [String] - let referencedTypes: [String] - - // MARK: - Computed-on-Init Properties - - let apiDescription: String - let definedTypeNames: Set - let pathAndImportsDescription: String - let apiTokenCount: Int - - // MARK: - CodingKeys - - enum CodingKeys: String, CodingKey { - case filePath, imports, exports, classes, interfaces, aliases, - literalUnions, functions, enums, globalVars, macros, referencedTypes - } - - // MARK: - Init - - init( - filePath: String, - imports: [String], - exports: [String] = [], - classes: [ClassInfo], - interfaces: [InterfaceInfo] = [], - aliases: [TypeAliasInfo] = [], - literalUnions: [String] = [], - functions: [FunctionInfo], - enums: [EnumInfo], - globalVars: [VariableInfo], - macros: [String], - referencedTypes: [String] - ) { - self.filePath = filePath - self.imports = imports - self.exports = exports - self.classes = classes - self.interfaces = interfaces - self.aliases = aliases - self.literalUnions = literalUnions - self.functions = functions - self.enums = enums - self.globalVars = globalVars - self.macros = macros - self.referencedTypes = referencedTypes - - // ------------------------------------------------------------ - // Build the human-readable API description string - // ------------------------------------------------------------ - var lines = ["---"] - - func formatFunctionLine(_ fn: FunctionInfo) -> String { - if let line = fn.lineNumber { - return "L\(line): \(fn.definitionLine)" - } - return fn.definitionLine - } - - func formatPropertyLine(_ name: String, typeName: String?) -> String { - guard let typeName, !typeName.isEmpty else { return name } - if name.contains(":") { return name } - return "\(name): \(typeName)" - } - - if !classes.isEmpty { - lines.append("Classes:") - for c in classes { - lines.append(" - \(c.name)") - if !c.methods.isEmpty { - lines.append(" Methods:") - for m in c.methods { - lines.append(" - \(formatFunctionLine(m))") - } - } - if !c.properties.isEmpty { - lines.append(" Properties:") - for p in c.properties { - lines.append(" - \(formatPropertyLine(p.name, typeName: p.typeName))") - } - } - } - } - if !interfaces.isEmpty { - lines.append("") - lines.append("Interfaces:") - for i in interfaces { - lines.append(" - \(i.name)") - if !i.methods.isEmpty { - lines.append(" Methods:") - for m in i.methods { - lines.append(" - \(formatFunctionLine(m))") - } - } - if !i.properties.isEmpty { - lines.append(" Properties:") - for p in i.properties { - lines.append(" - \(formatPropertyLine(p.name, typeName: p.typeName))") - } - } - } - } - if !aliases.isEmpty { - lines.append("") - lines.append("Type-aliases:") - for a in aliases { - lines.append(" - \(a.name)") - } - } - if !literalUnions.isEmpty { - lines.append("") - lines.append("Literal-union aliases:") - for u in literalUnions { - lines.append(" - \(u)") - } - } - if !functions.isEmpty { - lines.append("") - lines.append("Functions:") - for f in functions { - lines.append(" - \(formatFunctionLine(f))") - } - } - if !enums.isEmpty { - lines.append("") - lines.append("Enums:") - for e in enums { - lines.append(" - \(e.name)") - } - } - if !globalVars.isEmpty { - lines.append("") - lines.append("Global vars:") - for v in globalVars { - lines.append(" - \(formatPropertyLine(v.name, typeName: v.typeName))") - } - } - if !exports.isEmpty { - lines.append("") - lines.append("Exports:") - for e in exports { - lines.append(" - \(e)") - } - } - if !macros.isEmpty { - lines.append("") - lines.append("Macros:") - for m in macros { - lines.append(" - \(m)") - } - } - lines.append("---") - - apiDescription = "\n" + lines.joined(separator: "\n") + "\n" - - // Defined type names (classes + interfaces + enums + aliases) - definedTypeNames = Set(classes.map(\.name)) - .union(interfaces.map(\.name)) - .union(aliases.map(\.name)) - .union(enums.map(\.name)) - - // Path + import lines - pathAndImportsDescription = Self.pathAndImportsBlock(displayPath: filePath, imports: imports) - - // Cache token count for performance - apiTokenCount = TokenCalculationService.estimateTokens(for: apiDescription) - } - - // MARK: - Codable - - func encode(to encoder: Encoder) throws { - var c = encoder.container(keyedBy: CodingKeys.self) - try c.encode(filePath, forKey: .filePath) - try c.encode(imports, forKey: .imports) - try c.encode(exports, forKey: .exports) - try c.encode(classes, forKey: .classes) - try c.encode(interfaces, forKey: .interfaces) - try c.encode(aliases, forKey: .aliases) - try c.encode(literalUnions, forKey: .literalUnions) - try c.encode(functions, forKey: .functions) - try c.encode(enums, forKey: .enums) - try c.encode(globalVars, forKey: .globalVars) - try c.encode(macros, forKey: .macros) - try c.encode(referencedTypes, forKey: .referencedTypes) - } - - init(from decoder: Decoder) throws { - let c = try decoder.container(keyedBy: CodingKeys.self) - try self.init( - filePath: c.decode(String.self, forKey: .filePath), - imports: c.decode([String].self, forKey: .imports), - exports: c.decodeIfPresent([String].self, forKey: .exports) ?? [], - classes: c.decode([ClassInfo].self, forKey: .classes), - interfaces: c.decodeIfPresent([InterfaceInfo].self, forKey: .interfaces) ?? [], - aliases: c.decodeIfPresent([TypeAliasInfo].self, forKey: .aliases) ?? [], - literalUnions: c.decodeIfPresent([String].self, forKey: .literalUnions) ?? [], - functions: c.decode([FunctionInfo].self, forKey: .functions), - enums: c.decode([EnumInfo].self, forKey: .enums), - globalVars: c.decode([VariableInfo].self, forKey: .globalVars), - macros: c.decode([String].self, forKey: .macros), - referencedTypes: c.decode([String].self, forKey: .referencedTypes) - ) - } - - // MARK: - Utilities - - func getFullAPIDescription() -> String { - getFullAPIDescription(displayPath: filePath) - } - - /// Returns the complete API description with a caller-specified display path. - /// This avoids downstream string replacement when switching between Full/Relative paths. - func getFullAPIDescription(displayPath: String) -> String { - let pathAndImports = Self.pathAndImportsBlock(displayPath: displayPath, imports: imports) - return [pathAndImports, apiDescription].joined() - } - - /// Estimates the token count for the full rendered API description using the - /// same display-path-aware header as `getFullAPIDescription(displayPath:)`. - func estimatedFullAPIDescriptionTokens(displayPath: String) -> Int { - TokenCalculationService.estimateTokens(for: Self.pathAndImportsBlock(displayPath: displayPath, imports: imports)) + apiTokenCount - } - - /// Prints the captured API description. - func printAPI() { - print(apiDescription) - } - - private static func pathAndImportsBlock(displayPath: String, imports: [String]) -> String { - (["File: \(displayPath)", "Imports:"] + imports.map { " - \($0)" }).joined(separator: "\n") - } -} diff --git a/Sources/RepoPrompt/Features/ContextBuilder/ViewModels/ContextBuilderAgentViewModel.swift b/Sources/RepoPrompt/Features/ContextBuilder/ViewModels/ContextBuilderAgentViewModel.swift index 67e3ca393..e10cb3903 100644 --- a/Sources/RepoPrompt/Features/ContextBuilder/ViewModels/ContextBuilderAgentViewModel.swift +++ b/Sources/RepoPrompt/Features/ContextBuilder/ViewModels/ContextBuilderAgentViewModel.swift @@ -394,6 +394,19 @@ final class ContextBuilderAgentViewModel: ObservableObject { /// Owns active and terminal-cleanup Context Builder attempts. private let runRegistry = ContextBuilderRunRegistry() + struct UserMessageInput: Equatable { + let promptText: String + let selection: StoredSelection + let contextBuilderPromptIDs: Set + } + + enum UserMessageSource: Equatable { + case captured(UserMessageInput) + case workspace(UserMessageInput) + case live(contextBuilderPromptIDs: Set) + case unavailable(contextBuilderPromptIDs: Set) + } + #if DEBUG struct RunTestHooks { let beforeProcessingProviderEvent: ((_ result: AIStreamResult, _ runID: UUID) async -> Void)? @@ -2560,40 +2573,47 @@ final class ContextBuilderAgentViewModel: ObservableObject { } private func buildAgentUserMessage(for session: TabSession, adjustedBudget: Int) async -> String { - // Context builder prompt IDs captured at run start from viewmodel (always set by captureRunStartState) - let contextBuilderPromptIDs = session.runStartContextBuilderPromptIDs ?? [] + let source = Self.resolveUserMessageSource( + for: session, + workspaceSnapshot: { self.snapshotForTab(session.tabID) }, + isCurrentTab: session.tabID == currentTabID + ) - // PRIORITY 1: Use run-start captured state (prevents tab bleed) - if let promptText = session.runStartPromptText, - let selection = session.runStartSelection - { - let fileTree = await buildFileTree(from: selection) - debugLog("Using run-start captured state for tab=\(session.tabID)") - return makeUserMessage( - fileTree: fileTree, - userPrompt: promptText, - discoverInstructions: session.contextBuilderInstructions, + switch source { + case let .captured(input): + return await renderUserMessage( + input: input, + session: session, adjustedBudget: adjustedBudget, - contextBuilderPromptIDs: contextBuilderPromptIDs + fileTreeDidRender: { + self.debugLog("Using run-start captured state for tab=\(session.tabID)") + } ) - } - // PRIORITY 2: Workspace snapshot (fallback, may be slightly stale) - if let snapshot = snapshotForTab(session.tabID) { - let fileTree = await buildFileTree(from: snapshot.selection) - debugLog("Using workspace snapshot for tab=\(session.tabID)") + case let .workspace(input): + return await renderUserMessage( + input: input, + session: session, + adjustedBudget: adjustedBudget, + fileTreeDidRender: { + self.debugLog("Using workspace snapshot for tab=\(session.tabID)") + } + ) + + case let .live(contextBuilderPromptIDs): + debugLog("Using live UI state (tab still active) for tab=\(session.tabID)") + workspaceManager?.publishActiveComposeTabSnapshot(commitToMemory: true) + let liveSelection = snapshotForTab(session.tabID)?.selection ?? StoredSelection() + let fileTree = await buildFileTree(from: liveSelection) return makeUserMessage( fileTree: fileTree, - userPrompt: snapshot.promptText, + userPrompt: promptManager.promptText, discoverInstructions: session.contextBuilderInstructions, adjustedBudget: adjustedBudget, contextBuilderPromptIDs: contextBuilderPromptIDs ) - } - // PRIORITY 3: Live UI state ONLY if still on correct tab - // If the tab is no longer active and we have no captured state, something went wrong. - guard session.tabID == currentTabID else { + case let .unavailable(contextBuilderPromptIDs): debugLog("ERROR: Tab context unavailable - tab switched before state was captured") return makeUserMessage( fileTree: "", @@ -2603,17 +2623,78 @@ final class ContextBuilderAgentViewModel: ObservableObject { contextBuilderPromptIDs: contextBuilderPromptIDs ) } + } - debugLog("Using live UI state (tab still active) for tab=\(session.tabID)") - workspaceManager?.publishActiveComposeTabSnapshot(commitToMemory: true) - let liveSelection = snapshotForTab(session.tabID)?.selection ?? StoredSelection() - let fileTree = await buildFileTree(from: liveSelection) - return makeUserMessage( - fileTree: fileTree, - userPrompt: promptManager.promptText, - discoverInstructions: session.contextBuilderInstructions, + static func resolveUserMessageSource( + for session: TabSession, + workspaceSnapshot: () -> ComposeTabState?, + isCurrentTab: Bool + ) -> UserMessageSource { + let contextBuilderPromptIDs = session.runStartContextBuilderPromptIDs ?? [] + + if let promptText = session.runStartPromptText, + let selection = session.runStartSelection + { + return .captured(UserMessageInput( + promptText: promptText, + selection: selection, + contextBuilderPromptIDs: contextBuilderPromptIDs + )) + } + + if let snapshot = workspaceSnapshot() { + return .workspace(UserMessageInput( + promptText: snapshot.promptText, + selection: snapshot.selection, + contextBuilderPromptIDs: contextBuilderPromptIDs + )) + } + + if isCurrentTab { + return .live(contextBuilderPromptIDs: contextBuilderPromptIDs) + } + + return .unavailable(contextBuilderPromptIDs: contextBuilderPromptIDs) + } + + private func renderUserMessage( + input: UserMessageInput, + session: TabSession, + adjustedBudget: Int, + fileTreeDidRender: () -> Void + ) async -> String { + await Self.renderUserMessage( + input: input, adjustedBudget: adjustedBudget, - contextBuilderPromptIDs: contextBuilderPromptIDs + fileTreeRenderer: { selection in + await self.buildFileTree(from: selection) + }, + fileTreeDidRender: fileTreeDidRender, + discoverInstructions: { session.contextBuilderInstructions }, + customPromptRenderer: { contextBuilderPromptIDs in + ContextBuilderPromptStorage.shared.promptText(for: contextBuilderPromptIDs) + } + ) + } + + static func renderUserMessage( + input: UserMessageInput, + adjustedBudget: Int, + fileTreeRenderer: (StoredSelection) async -> String, + fileTreeDidRender: () -> Void = {}, + discoverInstructions: () -> String, + customPromptRenderer: (Set) -> String? + ) async -> String { + let fileTree = await fileTreeRenderer(input.selection) + fileTreeDidRender() + let instructions = discoverInstructions() + let customPromptText = customPromptRenderer(input.contextBuilderPromptIDs) + return renderUserMessageEnvelope( + fileTree: fileTree, + userPrompt: input.promptText, + customPromptText: customPromptText, + discoverInstructions: instructions, + adjustedBudget: adjustedBudget ) } @@ -2623,6 +2704,23 @@ final class ContextBuilderAgentViewModel: ObservableObject { discoverInstructions: String, adjustedBudget: Int, contextBuilderPromptIDs: Set = [] + ) -> String { + let customPromptText = ContextBuilderPromptStorage.shared.promptText(for: contextBuilderPromptIDs) + return Self.renderUserMessageEnvelope( + fileTree: fileTree, + userPrompt: userPrompt, + customPromptText: customPromptText, + discoverInstructions: discoverInstructions, + adjustedBudget: adjustedBudget + ) + } + + static func renderUserMessageEnvelope( + fileTree: String, + userPrompt: String, + customPromptText: String?, + discoverInstructions: String, + adjustedBudget: Int ) -> String { var message = "" @@ -2647,12 +2745,12 @@ final class ContextBuilderAgentViewModel: ObservableObject { } // Include context builder custom prompts (meta prompts) before user instructions - if let metaPromptText = ContextBuilderPromptStorage.shared.promptText(for: contextBuilderPromptIDs) { + if let customPromptText { message += """ - \(metaPromptText) + \(customPromptText) """ - print(metaPromptText) + print(customPromptText) } if !discoverInstructions.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { @@ -2716,33 +2814,52 @@ final class ContextBuilderAgentViewModel: ObservableObject { /// Captures the tab's prompt and selection state at discovery run start. /// This prevents tab bleed when user switches tabs during a run. private func captureRunStartState(for session: TabSession) { - // Capture context builder prompt IDs from the viewmodel (current UI state) - session.runStartContextBuilderPromptIDs = selectedContextBuilderPromptIDs - - // First try: workspace snapshot (most reliable source) - if session.tabID == currentTabID { + let capturedContextBuilderPromptIDs = selectedContextBuilderPromptIDs + let isCurrentTab = session.tabID == currentTabID + if isCurrentTab { workspaceManager?.publishActiveComposeTabSnapshot(commitToMemory: true) } - if let snapshot = workspaceManager?.composeTab(with: session.tabID) { - session.runStartPromptText = snapshot.promptText - session.runStartSelection = snapshot.selection + let workspaceSnapshot = workspaceManager?.composeTab(with: session.tabID) + Self.captureRunStartState( + for: session, + selectedContextBuilderPromptIDs: capturedContextBuilderPromptIDs, + workspaceSnapshot: workspaceSnapshot, + isCurrentTab: isCurrentTab, + livePromptText: { self.promptManager.promptText } + ) + + if workspaceSnapshot != nil { debugLog("Captured run-start state from workspace snapshot for tab=\(session.tabID)") + } else if isCurrentTab { + debugLog("Captured run-start prompt from live UI for tab=\(session.tabID); compose selection snapshot unavailable") + } else { + debugLog("WARNING: No snapshot and tab not active; run may have stale context") + } + } + + static func captureRunStartState( + for session: TabSession, + selectedContextBuilderPromptIDs: Set, + workspaceSnapshot: ComposeTabState?, + isCurrentTab: Bool, + livePromptText: () -> String + ) { + session.runStartContextBuilderPromptIDs = selectedContextBuilderPromptIDs + + if let workspaceSnapshot { + session.runStartPromptText = workspaceSnapshot.promptText + session.runStartSelection = workspaceSnapshot.selection return } - // Fallback: Only use live UI if this is still the active tab - // (safe because we haven't yielded yet, so no tab switch could have occurred) - guard session.tabID == currentTabID else { - debugLog("WARNING: No snapshot and tab not active; run may have stale context") + guard isCurrentTab else { session.runStartPromptText = "" session.runStartSelection = StoredSelection() return } - // Capture from live UI prompt text with an empty selection if no compose snapshot exists. - session.runStartPromptText = promptManager.promptText + session.runStartPromptText = livePromptText() session.runStartSelection = StoredSelection() - debugLog("Captured run-start prompt from live UI for tab=\(session.tabID); compose selection snapshot unavailable") } /// Clears the captured run-start state after a run completes or is cancelled. diff --git a/Sources/RepoPrompt/Features/ContextBuilder/Views/ContextBuilderPromptsOverlay.swift b/Sources/RepoPrompt/Features/ContextBuilder/Views/ContextBuilderPromptsOverlay.swift index 030cf38a8..d6fa07f11 100644 --- a/Sources/RepoPrompt/Features/ContextBuilder/Views/ContextBuilderPromptsOverlay.swift +++ b/Sources/RepoPrompt/Features/ContextBuilder/Views/ContextBuilderPromptsOverlay.swift @@ -152,7 +152,14 @@ class ContextBuilderPromptStorage: ObservableObject { /// Get prompt text for selected IDs in XML meta prompt format func promptText(for selectedIDs: Set) -> String? { - let selected = allPrompts.filter { selectedIDs.contains($0.id) } + Self.promptText(for: selectedIDs, in: allPrompts) + } + + static func promptText( + for selectedIDs: Set, + in orderedPrompts: [ContextBuilderPrompt] + ) -> String? { + let selected = orderedPrompts.filter { selectedIDs.contains($0.id) } guard !selected.isEmpty else { return nil } return selected diff --git a/Sources/RepoPrompt/Features/Diagnostics/Benchmark/Core/BenchmarkDiffParserBridge.swift b/Sources/RepoPrompt/Features/Diagnostics/Benchmark/Core/BenchmarkDiffParserBridge.swift index 7a64387d3..373aa3c54 100644 --- a/Sources/RepoPrompt/Features/Diagnostics/Benchmark/Core/BenchmarkDiffParserBridge.swift +++ b/Sources/RepoPrompt/Features/Diagnostics/Benchmark/Core/BenchmarkDiffParserBridge.swift @@ -7,7 +7,11 @@ final class BenchmarkWorkspaceFilesViewModel: WorkspaceFilesViewModel { init(baseline: BenchmarkMockFileSystemSnapshot) { self.baseline = baseline - super.init(workspaceFileContextStore: WorkspaceFileContextStore()) + let runtime = RepoPromptEmbeddedWorkspaceRuntimeFactory().makeRuntime() + super.init( + workspaceFileContextStore: runtime.workspaceFileContextStore, + selectionSliceCoordinator: runtime.selectionSliceCoordinator + ) } override func pathLocation( diff --git a/Sources/RepoPrompt/Features/Diagnostics/MCP/MCPConnectionManager+DebugDiagnosticsReadSearchLatency.swift b/Sources/RepoPrompt/Features/Diagnostics/MCP/MCPConnectionManager+DebugDiagnosticsReadSearchLatency.swift index e7704cecd..daf6c6f4c 100644 --- a/Sources/RepoPrompt/Features/Diagnostics/MCP/MCPConnectionManager+DebugDiagnosticsReadSearchLatency.swift +++ b/Sources/RepoPrompt/Features/Diagnostics/MCP/MCPConnectionManager+DebugDiagnosticsReadSearchLatency.swift @@ -2,6 +2,7 @@ import Foundation import MCP +import RepoPromptCore #if DEBUG extension ServerNetworkManager { diff --git a/Sources/RepoPrompt/Features/Diagnostics/MCP/MCPConnectionManager+DebugDiagnosticsWorkspace.swift b/Sources/RepoPrompt/Features/Diagnostics/MCP/MCPConnectionManager+DebugDiagnosticsWorkspace.swift index d8458bfa1..87a0e1627 100644 --- a/Sources/RepoPrompt/Features/Diagnostics/MCP/MCPConnectionManager+DebugDiagnosticsWorkspace.swift +++ b/Sources/RepoPrompt/Features/Diagnostics/MCP/MCPConnectionManager+DebugDiagnosticsWorkspace.swift @@ -175,7 +175,7 @@ import MCP fields: WorkspaceSelectionDebugSignature.unprefixedFields(for: window.workspaceManager.composeTab(with: flushed.tabID ?? UUID())?.selection ?? flushed.selection) ) let finalURL = try await window.workspaceManager.saveWorkspaceToFileAsync(activeWorkspace, source: .debugWorkspaceSelectionFixtureApply) - await WorkspaceManagerViewModel.WorkspaceDiskWriter.shared.flush(url: finalURL) + await window.workspaceManager.sessionController.persistenceWriter.flush(url: finalURL) extra["workspaceSaveFlushed"] = true if let diskSelection = Self.debugDiskActiveComposeTabSelection(window: window) { WorkspaceRestorePerfLog.event( diff --git a/Sources/RepoPrompt/Features/Prompt/Models/Copy/CopyCustomizations.swift b/Sources/RepoPrompt/Features/Prompt/Models/Copy/CopyCustomizations.swift deleted file mode 100644 index 4b30d621c..000000000 --- a/Sources/RepoPrompt/Features/Prompt/Models/Copy/CopyCustomizations.swift +++ /dev/null @@ -1,114 +0,0 @@ -import Foundation - -/// Workspace-specific customization overrides for a copy preset -/// These allow per-workspace deviations from the base preset configuration -struct CopyCustomizations: Equatable { - /// Meta prompts selection - var selectedPromptIDs: [UUID]? - - // Content configuration overrides - var fileTreeMode: FileTreeOption? - var codeMapUsage: CodeMapUsage? - var gitInclusion: GitInclusion? - - // Include flags overrides - var includeFiles: Bool? - var includeUserPrompt: Bool? - var includeMetaPrompts: Bool? - var includeFileTree: Bool? - - // MARK: - Initializer - - init( - selectedPromptIDs: [UUID]? = nil, - fileTreeMode: FileTreeOption? = nil, - codeMapUsage: CodeMapUsage? = nil, - gitInclusion: GitInclusion? = nil, - includeFiles: Bool? = nil, - includeUserPrompt: Bool? = nil, - includeMetaPrompts: Bool? = nil, - includeFileTree: Bool? = nil - ) { - self.selectedPromptIDs = selectedPromptIDs - self.fileTreeMode = fileTreeMode - self.codeMapUsage = codeMapUsage - self.gitInclusion = gitInclusion - self.includeFiles = includeFiles - self.includeUserPrompt = includeUserPrompt - self.includeMetaPrompts = includeMetaPrompts - self.includeFileTree = includeFileTree - } - - /// Check if any customizations are present - var hasCustomizations: Bool { - selectedPromptIDs != nil || - fileTreeMode != nil || - codeMapUsage != nil || - gitInclusion != nil || - includeFiles != nil || - includeUserPrompt != nil || - includeMetaPrompts != nil || - includeFileTree != nil - } - - /// Clear all customizations - mutating func clear() { - selectedPromptIDs = nil - fileTreeMode = nil - codeMapUsage = nil - gitInclusion = nil - includeFiles = nil - includeUserPrompt = nil - includeMetaPrompts = nil - includeFileTree = nil - } - - /// Returns a copy with only the codemap usage override cleared. - /// Used to collapse legacy Manual-mode duplicate state while preserving - /// all other customization fields. - func removingCodeMapUsageOverride() -> CopyCustomizations { - var copy = self - copy.codeMapUsage = nil - return copy - } -} - -// MARK: - Codable Conformance - -extension CopyCustomizations: Codable { - enum CodingKeys: String, CodingKey { - case selectedPromptIDs - case fileTreeMode - case codeMapUsage - case gitInclusion - case includeFiles - case includeUserPrompt - case includeMetaPrompts - case includeFileTree - } - - init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - selectedPromptIDs = try container.decodeIfPresent([UUID].self, forKey: .selectedPromptIDs) - - fileTreeMode = try container.decodeIfPresent(FileTreeOption.self, forKey: .fileTreeMode) - codeMapUsage = try container.decodeIfPresent(CodeMapUsage.self, forKey: .codeMapUsage) - gitInclusion = try container.decodeIfPresent(GitInclusion.self, forKey: .gitInclusion) - includeFiles = try container.decodeIfPresent(Bool.self, forKey: .includeFiles) - includeUserPrompt = try container.decodeIfPresent(Bool.self, forKey: .includeUserPrompt) - includeMetaPrompts = try container.decodeIfPresent(Bool.self, forKey: .includeMetaPrompts) - includeFileTree = try container.decodeIfPresent(Bool.self, forKey: .includeFileTree) - } - - func encode(to encoder: Encoder) throws { - var container = encoder.container(keyedBy: CodingKeys.self) - try container.encodeIfPresent(selectedPromptIDs, forKey: .selectedPromptIDs) - try container.encodeIfPresent(fileTreeMode, forKey: .fileTreeMode) - try container.encodeIfPresent(codeMapUsage, forKey: .codeMapUsage) - try container.encodeIfPresent(gitInclusion, forKey: .gitInclusion) - try container.encodeIfPresent(includeFiles, forKey: .includeFiles) - try container.encodeIfPresent(includeUserPrompt, forKey: .includeUserPrompt) - try container.encodeIfPresent(includeMetaPrompts, forKey: .includeMetaPrompts) - try container.encodeIfPresent(includeFileTree, forKey: .includeFileTree) - } -} diff --git a/Sources/RepoPrompt/Features/Prompt/Models/Copy/CopyPreset.swift b/Sources/RepoPrompt/Features/Prompt/Models/Copy/CopyPreset.swift index 41a79453a..bd22a529a 100644 --- a/Sources/RepoPrompt/Features/Prompt/Models/Copy/CopyPreset.swift +++ b/Sources/RepoPrompt/Features/Prompt/Models/Copy/CopyPreset.swift @@ -28,13 +28,6 @@ extension CopyPresetKind: Codable { } } -/// How to include git diff in the copy -enum GitInclusion: String, Codable, CaseIterable { - case none - case selected - case complete -} - // MARK: - Copy Preset Model /// Copy preset describes behavior at a high level. diff --git a/Sources/RepoPrompt/Features/Prompt/Models/FilesTab.swift b/Sources/RepoPrompt/Features/Prompt/Models/FilesTab.swift index abbf398d3..c4ecdc3e3 100644 --- a/Sources/RepoPrompt/Features/Prompt/Models/FilesTab.swift +++ b/Sources/RepoPrompt/Features/Prompt/Models/FilesTab.swift @@ -1,21 +1,4 @@ -import Foundation - -/// Persisted file-selection surface for workspace compose-tab state. -enum FilesTab: String, Codable { - case selected = "Selected Files" - case context = "Context Builder" - - private static let legacyApplyXMLRawValue = "Apply XML" - - init(from decoder: Decoder) throws { - let rawValue = try decoder.singleValueContainer().decode(String.self) - if rawValue == Self.legacyApplyXMLRawValue { - self = .context - return - } - self = FilesTab(rawValue: rawValue) ?? .context - } -} +import RepoPromptCore extension FilesTab { /// Default tab for CE builds. diff --git a/Sources/RepoPrompt/Features/Prompt/Models/PromptAssemblyBuilder.swift b/Sources/RepoPrompt/Features/Prompt/Models/PromptAssemblyBuilder.swift deleted file mode 100644 index bb598133d..000000000 --- a/Sources/RepoPrompt/Features/Prompt/Models/PromptAssemblyBuilder.swift +++ /dev/null @@ -1,71 +0,0 @@ -// -// PromptAssemblyBuilder.swift -// RepoPrompt -// -// Created by Eric Provencher on 2025-04-16. -// - -import Foundation - -/// All blocks that can appear in the final prompt, in *logical* order. -/// The raw value is persisted in UserDefaults, so never rename casually. -enum PromptSection: String, CaseIterable, Identifiable, Codable { - case fileMap, fileContents, metaPrompts, userInstructions, gitDiff - - var id: String { - rawValue - } - - var displayName: String { - switch self { - case .fileMap: "File Tree" - case .fileContents: "File Contents" - case .gitDiff: "Git Diff" - case .metaPrompts: "Meta Prompts" - case .userInstructions: "User Instructions" - } - } -} - -/// Combines independently‑produced snippets in a caller‑supplied order. -struct PromptAssemblyBuilder { - static let defaultSectionOrder: [PromptSection] = [.fileMap, .fileContents, .gitDiff, .metaPrompts, .userInstructions] - - let order: [PromptSection] - let disabled: Set // existing switches → pass in - let duplicateUserInstructionsAtTop: Bool // new toggle - let snippets: [PromptSection: String] // empty / missing → ignored - - func build() -> String { - var out = "" - // optional first User Instructions block - if duplicateUserInstructionsAtTop, - let user = snippets[.userInstructions], - user.isEmpty == false - { - out += user.hasSuffix("\n") ? user : (user + "\n") - } - - for section in order where !disabled.contains(section) { - guard let snip = snippets[section], snip.isEmpty == false else { continue } - out += snip - if !snip.hasSuffix("\n") { out += "\n" } - } - return out - } - - /// Convenience static wrapper. - static func build( - order: [PromptSection], - disabled: Set, - duplicateUserInstructionsAtTop: Bool, - snippets: [PromptSection: String] - ) -> String { - PromptAssemblyBuilder( - order: order, - disabled: disabled, - duplicateUserInstructionsAtTop: duplicateUserInstructionsAtTop, - snippets: snippets - ).build() - } -} diff --git a/Sources/RepoPrompt/Features/Prompt/Models/PromptSection+DisplayName.swift b/Sources/RepoPrompt/Features/Prompt/Models/PromptSection+DisplayName.swift new file mode 100644 index 000000000..51be0741f --- /dev/null +++ b/Sources/RepoPrompt/Features/Prompt/Models/PromptSection+DisplayName.swift @@ -0,0 +1,13 @@ +import RepoPromptCore + +extension PromptSection { + var displayName: String { + switch self { + case .fileMap: "File Tree" + case .fileContents: "File Contents" + case .gitDiff: "Git Diff" + case .metaPrompts: "Meta Prompts" + case .userInstructions: "User Instructions" + } + } +} diff --git a/Sources/RepoPrompt/Features/Prompt/Services/PromptContextAccountingService.swift b/Sources/RepoPrompt/Features/Prompt/Services/PromptContextAccountingService.swift index 832f32a95..2b3b082d6 100644 --- a/Sources/RepoPrompt/Features/Prompt/Services/PromptContextAccountingService.swift +++ b/Sources/RepoPrompt/Features/Prompt/Services/PromptContextAccountingService.swift @@ -1,205 +1,45 @@ import Foundation +import RepoPromptCore -/// Dormant value-based orchestration for resolving persisted workspace selections into prompt-entry -/// snapshots and token-accounting inputs. This service intentionally has no PromptViewModel or -/// TokenCountingViewModel dependencies so callers can opt in incrementally. -struct PromptContextAccountingRequest { - let selection: StoredSelection - let promptText: String - let selectedInstructionsText: String - let duplicateUserInstructionsAtTop: Bool - let fileTree: TokenCalculationFileTreeInput - let codeMapUsage: CodeMapUsage - let filePathDisplay: FilePathDisplay - let rootScope: WorkspaceLookupRootScope - let pathLocateProfile: PathLocateProfile - - init( - selection: StoredSelection, - promptText: String = "", - selectedInstructionsText: String = "", - duplicateUserInstructionsAtTop: Bool = false, - fileTree: TokenCalculationFileTreeInput = .none, - codeMapUsage: CodeMapUsage = .auto, - filePathDisplay: FilePathDisplay = .relative, - rootScope: WorkspaceLookupRootScope = .allLoaded, - pathLocateProfile: PathLocateProfile = .uiAssisted - ) { - self.selection = selection - self.promptText = promptText - self.selectedInstructionsText = selectedInstructionsText - self.duplicateUserInstructionsAtTop = duplicateUserInstructionsAtTop - self.fileTree = fileTree - self.codeMapUsage = codeMapUsage - self.filePathDisplay = filePathDisplay - self.rootScope = rootScope - self.pathLocateProfile = pathLocateProfile - } - - func withFileTree(_ fileTree: TokenCalculationFileTreeInput) -> PromptContextAccountingRequest { - PromptContextAccountingRequest( - selection: selection, - promptText: promptText, - selectedInstructionsText: selectedInstructionsText, - duplicateUserInstructionsAtTop: duplicateUserInstructionsAtTop, - fileTree: fileTree, - codeMapUsage: codeMapUsage, - filePathDisplay: filePathDisplay, - rootScope: rootScope, - pathLocateProfile: pathLocateProfile - ) - } -} - -struct PromptContextAccountingResult { - let tokenResult: TokenCalculationResult - let resolvedEntries: [ResolvedPromptFileEntry] - let promptFileEntrySnapshots: [PromptFileEntrySnapshot] - let tokenCalculationSnapshot: TokenCalculationSnapshot - let missingPaths: [String] - let invalidPaths: [String] - let codemapSnapshotsUsed: [UUID: WorkspaceCodemapSnapshot] -} - -private struct SelectedFileAccountingReadRequest { - let selectedPathIndex: Int - let selectedPath: String - let file: WorkspaceFileRecord -} - -private struct SelectedFileAccountingReadResult { - let selectedPathIndex: Int - let content: String? - let errorDescription: String? -} - +/// App diagnostics and compatibility facade over canonical Core prompt accounting. actor PromptContextAccountingService { - private static let selectedFileAccountingReadConcurrencyLimit = 4 - - private let tokenCalculationService: TokenCalculationService - - init(tokenCalculationService: TokenCalculationService = TokenCalculationService()) { - self.tokenCalculationService = tokenCalculationService - } + private let core = RepoPromptCore.PromptContextAccountingService() func calculatePromptStats( request: PromptContextAccountingRequest, store: WorkspaceFileContextStore, fileTreeSnapshotRequest: WorkspaceFileTreeSnapshotRequest - ) async -> PromptContextAccountingResult { - let snapshot = await store.makeFileTreeSelectionSnapshot( - selection: request.selection, - request: fileTreeSnapshotRequest, - profile: request.pathLocateProfile - ) - return await calculatePromptStats( - request: request.withFileTree(.snapshot(snapshot)), - store: store - ) + ) async throws -> PromptContextAccountingResult { + try await withDiagnostics(selection: request.selection, operation: "calculate") { + try await core.calculatePromptStats( + request: request, + store: store, + fileTreeSnapshotRequest: fileTreeSnapshotRequest + ) + } } func calculatePromptStats( request: PromptContextAccountingRequest, store: WorkspaceFileContextStore - ) async -> PromptContextAccountingResult { - #if DEBUG - let calculateStartMS = PromptTokenRecountDiagnostics.start() - var calculateBeginFields = PromptTokenRecountDiagnostics.selectionFields(request.selection) - calculateBeginFields["codeMapUsage"] = "\(request.codeMapUsage)" - calculateBeginFields["rootScope"] = "\(request.rootScope)" - PromptTokenRecountDiagnostics.event( - "tokenRecount.accounting.calculate.begin", - fields: calculateBeginFields - ) - let codemapStartMS = PromptTokenRecountDiagnostics.start() - #endif - let codemapSnapshots = await store.codemapSnapshotDictionary() - #if DEBUG - PromptTokenRecountDiagnostics.event( - "tokenRecount.accounting.codemapSnapshots.end", - fields: [ - "codemapSnapshots": "\(codemapSnapshots.count)", - "duration": codemapStartMS.map { PromptTokenRecountDiagnostics.formatElapsedMS(since: $0) } ?? "notMeasured" - ] - ) - let resolveStartMS = PromptTokenRecountDiagnostics.start() - #endif - let resolution = await resolveEntries( - selection: request.selection, - store: store, - rootScope: request.rootScope, - profile: request.pathLocateProfile, - codeMapUsage: request.codeMapUsage, - codemapSnapshots: codemapSnapshots - ) - #if DEBUG - PromptTokenRecountDiagnostics.event( - "tokenRecount.accounting.resolveEntries.end", - fields: [ - "entries": "\(resolution.entries.count)", - "missingPaths": "\(resolution.missingPaths.count)", - "invalidPaths": "\(resolution.invalidPaths.count)", - "duration": resolveStartMS.map { PromptTokenRecountDiagnostics.formatElapsedMS(since: $0) } ?? "notMeasured" - ] - ) - let snapshotStartMS = PromptTokenRecountDiagnostics.start() - #endif - let snapshots = makePromptFileEntrySnapshots(from: resolution.entries, codemapSnapshots: codemapSnapshots, filePathDisplay: request.filePathDisplay) - #if DEBUG - PromptTokenRecountDiagnostics.event( - "tokenRecount.accounting.promptSnapshots.end", - fields: [ - "promptEntries": "\(snapshots.count)", - "duration": snapshotStartMS.map { PromptTokenRecountDiagnostics.formatElapsedMS(since: $0) } ?? "notMeasured" - ] - ) - #endif - let calculationSnapshot = TokenCalculationSnapshot( - promptText: request.promptText, - selectedInstructionsText: request.selectedInstructionsText, - duplicateUserInstructionsAtTop: request.duplicateUserInstructionsAtTop, - promptEntries: snapshots, - fileTree: request.fileTree - ) - #if DEBUG - let tokenServiceStartMS = PromptTokenRecountDiagnostics.start() - PromptTokenRecountDiagnostics.event( - "tokenRecount.accounting.tokenService.begin", - fields: ["promptEntries": "\(calculationSnapshot.promptEntries.count)"] - ) - #endif - let tokenResult = await tokenCalculationService.calculatePromptStats(snapshot: calculationSnapshot) - #if DEBUG - PromptTokenRecountDiagnostics.event( - "tokenRecount.accounting.tokenService.end", - fields: [ - "totalTokens": "\(tokenResult.totalTokenCount)", - "fileTokens": "\(tokenResult.totalTokenCountFilesOnly)", - "duration": tokenServiceStartMS.map { PromptTokenRecountDiagnostics.formatElapsedMS(since: $0) } ?? "notMeasured" - ] - ) - #endif - let usedCodemaps = codemapSnapshots.filter { fileID, _ in - snapshots.contains { $0.fileID == fileID && $0.isCodemapRequested && $0.codeMapContent != nil } + ) async throws -> PromptContextAccountingResult { + try await withDiagnostics(selection: request.selection, operation: "calculate") { + try await core.calculatePromptStats(request: request, store: store) } - #if DEBUG - PromptTokenRecountDiagnostics.event( - "tokenRecount.accounting.calculate.end", - fields: [ - "usedCodemaps": "\(usedCodemaps.count)", - "duration": calculateStartMS.map { PromptTokenRecountDiagnostics.formatElapsedMS(since: $0) } ?? "notMeasured" - ] + } + + func calculatePromptStats( + request: PromptContextAccountingRequest, + store: WorkspaceFileContextStore, + capture: WorkspaceFileContextCapture + ) async throws -> PromptContextAccountingResult { + try await withDiagnostics(selection: request.selection, operation: "calculate") { + try await core.calculatePromptStats( + request: request, + store: store, + capture: capture ) - #endif - return PromptContextAccountingResult( - tokenResult: tokenResult, - resolvedEntries: resolution.entries, - promptFileEntrySnapshots: snapshots, - tokenCalculationSnapshot: calculationSnapshot, - missingPaths: resolution.missingPaths, - invalidPaths: resolution.invalidPaths, - codemapSnapshotsUsed: usedCodemaps - ) + } } func resolveEntries( @@ -208,563 +48,57 @@ actor PromptContextAccountingService { rootScope: WorkspaceLookupRootScope = .allLoaded, profile: PathLocateProfile = .uiAssisted, codeMapUsage: CodeMapUsage = .auto - ) async -> (entries: [ResolvedPromptFileEntry], missingPaths: [String], invalidPaths: [String]) { - let codemapSnapshots = await store.codemapSnapshotDictionary() - return await resolveEntries( - selection: selection, - store: store, - rootScope: rootScope, - profile: profile, - codeMapUsage: codeMapUsage, - codemapSnapshots: codemapSnapshots - ) + ) async -> PromptContextAccountingResolution { + do { + return try await withDiagnostics(selection: selection, operation: "resolveEntries") { + try await core.resolveEntries( + selection: selection, + store: store, + rootScope: rootScope, + profile: profile, + codeMapUsage: codeMapUsage + ) + } + } catch { + return .empty + } } - private func resolveEntries( + private func withDiagnostics( selection: StoredSelection, - store: WorkspaceFileContextStore, - rootScope: WorkspaceLookupRootScope, - profile: PathLocateProfile, - codeMapUsage: CodeMapUsage, - codemapSnapshots: [UUID: WorkspaceCodemapSnapshot] - ) async -> (entries: [ResolvedPromptFileEntry], missingPaths: [String], invalidPaths: [String]) { - #if DEBUG - let resolveStartMS = PromptTokenRecountDiagnostics.start() - var resolveBeginFields = PromptTokenRecountDiagnostics.selectionFields(selection) - resolveBeginFields["codemapSnapshots"] = "\(codemapSnapshots.count)" - resolveBeginFields["codeMapUsage"] = "\(codeMapUsage)" - PromptTokenRecountDiagnostics.event( - "tokenRecount.accounting.resolveEntries.begin", - fields: resolveBeginFields - ) - #endif - var entries: [ResolvedPromptFileEntry] = [] - var missingPaths: [String] = [] - var invalidPaths: [String] = [] - var seenIDs = Set() - var selectedFileIDs = Set() - + operation: String, + body: () async throws -> T + ) async throws -> T { #if DEBUG - let selectedPathsStartMS = PromptTokenRecountDiagnostics.start() - let selectedPathsDebugState = PromptTokenRecountDiagnostics.SelectedPathsState(selectedPathCount: selection.selectedPaths.count) - let selectedPathsWatchdog = Task { - try? await Task.sleep(nanoseconds: 12_000_000_000) - let snapshot = selectedPathsDebugState.snapshot() - guard snapshot["finished"] != "true" else { return } - PromptTokenRecountDiagnostics.event( - "tokenRecount.accounting.resolveEntries.selectedPaths.watchdog", - fields: snapshot - ) - } + let startMS = PromptTokenRecountDiagnostics.start() PromptTokenRecountDiagnostics.event( - "tokenRecount.accounting.resolveEntries.selectedPaths.begin", + "tokenRecount.accounting.\(operation).begin", fields: PromptTokenRecountDiagnostics.selectionFields(selection) ) #endif - #if DEBUG - selectedPathsDebugState.beginLookupBatch() - let selectedPathLookupBatchStartMS = PromptTokenRecountDiagnostics.start() - PromptTokenRecountDiagnostics.event( - "tokenRecount.accounting.resolveEntries.selectedPaths.lookupBatch.begin", - fields: [ - "selectedPaths": "\(selection.selectedPaths.count)", - "rootScope": "\(rootScope)", - "profile": "\(profile)" - ] - ) - #endif - let selectedPathLookupRequests = selection.selectedPaths.map { - WorkspacePathLookupRequest(userPath: $0, profile: profile, rootScope: rootScope) - } - let selectedPathLookupResults = await store.lookupPaths(selectedPathLookupRequests) - #if DEBUG - selectedPathsDebugState.finishLookupBatch() - let lookupResolvedFiles = selection.selectedPaths.reduce(into: 0) { count, path in - if selectedPathLookupResults[path]?.file != nil { - count += 1 - } - } - let lookupResolvedFolders = selection.selectedPaths.reduce(into: 0) { count, path in - if selectedPathLookupResults[path]?.folder != nil { - count += 1 - } - } - let lookupMissingResults = selection.selectedPaths.reduce(into: 0) { count, path in - if selectedPathLookupResults[path] == nil { - count += 1 - } - } - PromptTokenRecountDiagnostics.event( - "tokenRecount.accounting.resolveEntries.selectedPaths.lookupBatch.end", - fields: selectedPathsDebugState.snapshot().merging([ - "selectedPaths": "\(selection.selectedPaths.count)", - "requests": "\(selectedPathLookupRequests.count)", - "results": "\(selectedPathLookupResults.count)", - "resolvedFiles": "\(lookupResolvedFiles)", - "resolvedFolders": "\(lookupResolvedFolders)", - "missingLookupResults": "\(lookupMissingResults)", - "duration": selectedPathLookupBatchStartMS.map { PromptTokenRecountDiagnostics.formatElapsedMS(since: $0) } ?? "notMeasured" - ]) { current, _ in current } - ) - #endif - var selectedPathResultsByIndex: [Int: WorkspacePathLookupResult] = [:] - var selectedPathFallbackLookups = 0 - for (selectedPathIndex, path) in selection.selectedPaths.enumerated() { - if let result = selectedPathLookupResults[path] { - selectedPathResultsByIndex[selectedPathIndex] = result - } else if let result = await store.lookupPath(path, profile: profile, rootScope: rootScope) { - selectedPathResultsByIndex[selectedPathIndex] = result - selectedPathFallbackLookups += 1 - } - } - #if DEBUG - if selectedPathFallbackLookups > 0 { - PromptTokenRecountDiagnostics.event( - "tokenRecount.accounting.resolveEntries.selectedPaths.lookupBatch.fallback", - fields: selectedPathsDebugState.snapshot().merging([ - "fallbackLookups": "\(selectedPathFallbackLookups)", - "selectedPaths": "\(selection.selectedPaths.count)" - ]) { current, _ in current } - ) - } - #endif - - var selectedFileReadRequests: [SelectedFileAccountingReadRequest] = [] - var selectedCodemapReadSkips = 0 - for (selectedPathIndex, path) in selection.selectedPaths.enumerated() { + do { + let result = try await body() #if DEBUG - selectedPathsDebugState.beginPath(index: selectedPathIndex, path: path) PromptTokenRecountDiagnostics.event( - "tokenRecount.accounting.resolveEntries.selectedPath.begin", - fields: selectedPathsDebugState.snapshot() + "tokenRecount.accounting.\(operation).end", + fields: [ + "outcome": "completed", + "duration": startMS.map { PromptTokenRecountDiagnostics.formatElapsedMS(since: $0) } ?? "notMeasured" + ] ) #endif - guard let result = selectedPathResultsByIndex[selectedPathIndex] else { - #if DEBUG - let issue = await store.exactPathResolutionIssue(for: path, kind: .either, rootScope: rootScope) - selectedPathsDebugState.resolutionEnd(resolvedKind: PromptTokenRecountDiagnostics.SelectedPathsState.resolvedKind(for: issue)) - PromptTokenRecountDiagnostics.event( - "tokenRecount.accounting.resolveEntries.selectedPath.resolution.end", - fields: selectedPathsDebugState.snapshot() - ) - #endif - continue - } + return result + } catch { #if DEBUG - let resolvedKind = result.file != nil ? "file" : (result.folder != nil ? "folder" : "unresolved") - selectedPathsDebugState.resolutionEnd(resolvedKind: resolvedKind) PromptTokenRecountDiagnostics.event( - "tokenRecount.accounting.resolveEntries.selectedPath.resolution.end", - fields: selectedPathsDebugState.snapshot() + "tokenRecount.accounting.\(operation).end", + fields: [ + "outcome": error is CancellationError ? "cancelled" : "error", + "duration": startMS.map { PromptTokenRecountDiagnostics.formatElapsedMS(since: $0) } ?? "notMeasured" + ] ) #endif - if let file = result.file { - let useSelectedCodemap = codeMapUsage == .selected && codemapSnapshots[file.id]?.fileAPI != nil - if useSelectedCodemap { - selectedCodemapReadSkips += 1 - } else { - selectedFileReadRequests.append( - SelectedFileAccountingReadRequest( - selectedPathIndex: selectedPathIndex, - selectedPath: path, - file: file - ) - ) - } - } - } - - #if DEBUG - let selectedFileReadBatchStartMS = PromptTokenRecountDiagnostics.start() - selectedPathsDebugState.beginReadBatch( - scheduled: selectedFileReadRequests.count, - limit: Self.selectedFileAccountingReadConcurrencyLimit - ) - PromptTokenRecountDiagnostics.event( - "tokenRecount.accounting.resolveEntries.selectedPaths.readBatch.begin", - fields: selectedPathsDebugState.snapshot().merging([ - "scheduledReads": "\(selectedFileReadRequests.count)", - "concurrencyLimit": "\(Self.selectedFileAccountingReadConcurrencyLimit)", - "selectedFileResults": "\(selectedFileReadRequests.count + selectedCodemapReadSkips)", - "skippedCodemapReads": "\(selectedCodemapReadSkips)" - ]) { current, _ in current } - ) - #endif - let selectedFileReadResults = await withTaskGroup( - of: SelectedFileAccountingReadResult.self, - returning: [Int: SelectedFileAccountingReadResult].self - ) { group in - let concurrencyLimit = Self.selectedFileAccountingReadConcurrencyLimit - var iterator = selectedFileReadRequests.makeIterator() - var activeReads = 0 - var results: [Int: SelectedFileAccountingReadResult] = [:] - - func enqueueNextReadIfAvailable() { - guard activeReads < concurrencyLimit, let request = iterator.next() else { return } - activeReads += 1 - group.addTask { - #if DEBUG - selectedPathsDebugState.beginBatchRead(index: request.selectedPathIndex, path: request.selectedPath, file: request.file) - #endif - let content: String? - let errorDescription: String? - do { - content = try await store.readContent(rootID: request.file.rootID, relativePath: request.file.standardizedRelativePath) - errorDescription = nil - } catch { - content = nil - errorDescription = String(String(describing: error).prefix(120)) - } - #if DEBUG - selectedPathsDebugState.finishBatchRead(errorDescription: errorDescription) - if let errorDescription { - PromptTokenRecountDiagnostics.event( - "tokenRecount.accounting.resolveEntries.selectedPath.read.error", - fields: selectedPathsDebugState.snapshot().merging([ - "batch": "true", - "error": errorDescription - ]) { current, _ in current } - ) - } - if selectedPathsDebugState.shouldLogReadProgress() { - PromptTokenRecountDiagnostics.event( - "tokenRecount.accounting.resolveEntries.selectedPaths.read.progress", - fields: selectedPathsDebugState.snapshot().merging(["batch": "true"]) { current, _ in current } - ) - } - #endif - return SelectedFileAccountingReadResult( - selectedPathIndex: request.selectedPathIndex, - content: content, - errorDescription: errorDescription - ) - } - } - - for _ in 0 ..< concurrencyLimit { - enqueueNextReadIfAvailable() - } - - while let result = await group.next() { - activeReads -= 1 - results[result.selectedPathIndex] = result - enqueueNextReadIfAvailable() - } - - return results + throw error } - #if DEBUG - let readBatchErrors = selectedFileReadResults.values.reduce(into: 0) { count, result in - if result.errorDescription != nil { - count += 1 - } - } - PromptTokenRecountDiagnostics.event( - "tokenRecount.accounting.resolveEntries.selectedPaths.readBatch.end", - fields: selectedPathsDebugState.snapshot().merging([ - "scheduledReads": "\(selectedFileReadRequests.count)", - "completedReads": "\(selectedFileReadResults.count)", - "errorReads": "\(readBatchErrors)", - "concurrencyLimit": "\(Self.selectedFileAccountingReadConcurrencyLimit)", - "duration": selectedFileReadBatchStartMS.map { PromptTokenRecountDiagnostics.formatElapsedMS(since: $0) } ?? "notMeasured" - ]) { current, _ in current } - ) - #endif - - for (selectedPathIndex, path) in selection.selectedPaths.enumerated() { - guard let result = selectedPathResultsByIndex[selectedPathIndex] else { - #if DEBUG - selectedPathsDebugState.beginAssembly(index: selectedPathIndex, path: path, resolvedKind: "missing") - #endif - missingPaths.append(path) - #if DEBUG - PromptTokenRecountDiagnostics.event( - "tokenRecount.accounting.resolveEntries.selectedPath.end", - fields: selectedPathsDebugState.snapshot().merging(["outcome": "missing"]) { current, _ in current } - ) - #endif - continue - } - - if let file = result.file { - #if DEBUG - selectedPathsDebugState.beginAssembly(index: selectedPathIndex, path: path, resolvedKind: "file") - #endif - selectedFileIDs.insert(file.id) - let ranges = sliceRanges(for: path, file: file, location: result.location, in: selection.slices) - let useSelectedCodemap = codeMapUsage == .selected && codemapSnapshots[file.id]?.fileAPI != nil - let content = useSelectedCodemap ? nil : selectedFileReadResults[selectedPathIndex]?.content - let entry = ResolvedPromptFileEntry( - file: file, - isCodemap: useSelectedCodemap, - lineRanges: useSelectedCodemap ? nil : ranges, - mode: useSelectedCodemap ? .codemap : ((ranges?.isEmpty == false) ? .sliced : .fullFile), - loadedContent: content ?? nil, - rootFolderPath: result.location.rootPath - ) - append(entry, to: &entries, seenIDs: &seenIDs) - #if DEBUG - PromptTokenRecountDiagnostics.event( - "tokenRecount.accounting.resolveEntries.selectedPath.end", - fields: selectedPathsDebugState.snapshot().merging(["outcome": "file"]) { current, _ in current } - ) - #endif - } else if let folder = result.folder { - #if DEBUG - selectedPathsDebugState.beginAssembly(index: selectedPathIndex, path: path, resolvedKind: "folder") - selectedPathsDebugState.setPhase("folderList") - #endif - let files = await store.files(inRoot: folder.rootID) - let prefix = folder.standardizedRelativePath - #if DEBUG - let descendantCount = files.reduce(into: 0) { count, file in - if prefix.isEmpty || file.standardizedRelativePath == prefix || file.standardizedRelativePath.hasPrefix(prefix + "/") { - count += 1 - } - } - selectedPathsDebugState.folderExpansionEnd(descendantCount: descendantCount) - PromptTokenRecountDiagnostics.event( - "tokenRecount.accounting.resolveEntries.selectedPath.folderExpansion.end", - fields: selectedPathsDebugState.snapshot().merging(["descendantCount": "\(descendantCount)"]) { current, _ in current } - ) - #endif - for file in files where prefix.isEmpty || file.standardizedRelativePath == prefix || file.standardizedRelativePath.hasPrefix(prefix + "/") { - selectedFileIDs.insert(file.id) - let useSelectedCodemap = codeMapUsage == .selected && codemapSnapshots[file.id]?.fileAPI != nil - let content: String? - if useSelectedCodemap { - content = nil - } else { - #if DEBUG - selectedPathsDebugState.beginRead(file: file) - #endif - do { - content = try await store.readContent(rootID: file.rootID, relativePath: file.standardizedRelativePath) - } catch { - content = nil - #if DEBUG - PromptTokenRecountDiagnostics.event( - "tokenRecount.accounting.resolveEntries.selectedPath.read.error", - fields: selectedPathsDebugState.snapshot().merging(["error": String(String(describing: error).prefix(120))]) { current, _ in current } - ) - #endif - } - #if DEBUG - selectedPathsDebugState.finishRead() - if selectedPathsDebugState.shouldLogReadProgress() { - PromptTokenRecountDiagnostics.event( - "tokenRecount.accounting.resolveEntries.selectedPaths.read.progress", - fields: selectedPathsDebugState.snapshot() - ) - } - #endif - } - let entry = ResolvedPromptFileEntry( - file: file, - isCodemap: useSelectedCodemap, - mode: useSelectedCodemap ? .codemap : .fullFile, - loadedContent: content ?? nil, - rootFolderPath: result.location.rootPath - ) - append(entry, to: &entries, seenIDs: &seenIDs) - } - #if DEBUG - PromptTokenRecountDiagnostics.event( - "tokenRecount.accounting.resolveEntries.selectedPath.end", - fields: selectedPathsDebugState.snapshot().merging(["outcome": "folder"]) { current, _ in current } - ) - #endif - } else { - #if DEBUG - selectedPathsDebugState.beginAssembly(index: selectedPathIndex, path: path, resolvedKind: "unresolved") - #endif - invalidPaths.append(path) - #if DEBUG - PromptTokenRecountDiagnostics.event( - "tokenRecount.accounting.resolveEntries.selectedPath.end", - fields: selectedPathsDebugState.snapshot().merging(["outcome": "invalid"]) { current, _ in current } - ) - #endif - } - } - - #if DEBUG - selectedPathsDebugState.finish() - selectedPathsWatchdog.cancel() - var selectedPathsEndFields = selectedPathsDebugState.snapshot() - selectedPathsEndFields.merge(PromptTokenRecountDiagnostics.selectionFields(selection)) { current, _ in current } - selectedPathsEndFields.merge([ - "entries": "\(entries.count)", - "selectedFileIDs": "\(selectedFileIDs.count)", - "missingPaths": "\(missingPaths.count)", - "invalidPaths": "\(invalidPaths.count)", - "duration": selectedPathsStartMS.map { PromptTokenRecountDiagnostics.formatElapsedMS(since: $0) } ?? "notMeasured" - ]) { current, _ in current } - PromptTokenRecountDiagnostics.event( - "tokenRecount.accounting.resolveEntries.selectedPaths.end", - fields: selectedPathsEndFields - ) - let slicesStartMS = PromptTokenRecountDiagnostics.start() - PromptTokenRecountDiagnostics.event( - "tokenRecount.accounting.resolveEntries.slices.begin", - fields: ["sliceFiles": "\(selection.slices.count)"] - ) - #endif - for (path, ranges) in selection.slices { - guard let result = await store.lookupPath(path, profile: profile, rootScope: rootScope) else { - missingPaths.append(path) - continue - } - guard let file = result.file else { - invalidPaths.append(path) - continue - } - guard !selectedFileIDs.contains(file.id) else { continue } - selectedFileIDs.insert(file.id) - let content = try? await store.readContent(rootID: file.rootID, relativePath: file.standardizedRelativePath) - let entry = ResolvedPromptFileEntry(file: file, lineRanges: ranges, mode: .sliced, loadedContent: content ?? nil, rootFolderPath: result.location.rootPath) - append(entry, to: &entries, seenIDs: &seenIDs) - } - - #if DEBUG - PromptTokenRecountDiagnostics.event( - "tokenRecount.accounting.resolveEntries.slices.end", - fields: [ - "entries": "\(entries.count)", - "selectedFileIDs": "\(selectedFileIDs.count)", - "missingPaths": "\(missingPaths.count)", - "invalidPaths": "\(invalidPaths.count)", - "duration": slicesStartMS.map { PromptTokenRecountDiagnostics.formatElapsedMS(since: $0) } ?? "notMeasured" - ] - ) - #endif - let codemapPaths: [String] = switch codeMapUsage { - case .none, .selected: - [] - case .auto: - Array(selection.autoCodemapPaths) - case .complete: - codemapSnapshots.compactMap { fileID, snapshot in - guard !selectedFileIDs.contains(fileID), snapshot.fileAPI != nil else { return nil } - return snapshot.fullPath - } - } - - #if DEBUG - let codemapPathsStartMS = PromptTokenRecountDiagnostics.start() - PromptTokenRecountDiagnostics.event( - "tokenRecount.accounting.resolveEntries.codemapPaths.begin", - fields: ["codemapPaths": "\(codemapPaths.count)"] - ) - #endif - for path in codemapPaths { - guard let result = await store.lookupPath(path, profile: profile, rootScope: rootScope) else { - missingPaths.append(path) - continue - } - guard let file = result.file else { - invalidPaths.append(path) - continue - } - guard !selectedFileIDs.contains(file.id), codemapSnapshots[file.id]?.fileAPI != nil else { continue } - let entry = ResolvedPromptFileEntry(file: file, isCodemap: true, mode: .codemap, loadedContent: nil, rootFolderPath: result.location.rootPath) - append(entry, to: &entries, seenIDs: &seenIDs) - } - - #if DEBUG - PromptTokenRecountDiagnostics.event( - "tokenRecount.accounting.resolveEntries.codemapPaths.end", - fields: [ - "entries": "\(entries.count)", - "selectedFileIDs": "\(selectedFileIDs.count)", - "missingPaths": "\(missingPaths.count)", - "invalidPaths": "\(invalidPaths.count)", - "duration": codemapPathsStartMS.map { PromptTokenRecountDiagnostics.formatElapsedMS(since: $0) } ?? "notMeasured" - ] - ) - #endif - let uniqueMissingPaths = Array(Set(missingPaths)).sorted() - let uniqueInvalidPaths = Array(Set(invalidPaths)).sorted() - #if DEBUG - PromptTokenRecountDiagnostics.event( - "tokenRecount.accounting.resolveEntries.finish", - fields: [ - "entries": "\(entries.count)", - "selectedFileIDs": "\(selectedFileIDs.count)", - "missingPaths": "\(uniqueMissingPaths.count)", - "invalidPaths": "\(uniqueInvalidPaths.count)", - "duration": resolveStartMS.map { PromptTokenRecountDiagnostics.formatElapsedMS(since: $0) } ?? "notMeasured" - ] - ) - #endif - return (entries, uniqueMissingPaths, uniqueInvalidPaths) - } - - func makePromptFileEntrySnapshots( - from entries: [ResolvedPromptFileEntry], - codemapSnapshots: [UUID: WorkspaceCodemapSnapshot], - filePathDisplay: FilePathDisplay = .relative - ) -> [PromptFileEntrySnapshot] { - let hasMultipleRoots = Set(entries.map(\.file.rootID)).count > 1 - return entries.map { entry in - let codeMapContent: String? - let availableCodeMapTokenCount: Int - if let api = codemapSnapshots[entry.file.id]?.fileAPI { - availableCodeMapTokenCount = api.apiTokenCount - if entry.isCodemap { - let displayPath = Self.selectedPath(for: entry, filePathDisplay: filePathDisplay, hasMultipleRoots: hasMultipleRoots) - let description = api.getFullAPIDescription(displayPath: displayPath) - codeMapContent = description.isEmpty ? nil : description - } else { - codeMapContent = nil - } - } else { - availableCodeMapTokenCount = 0 - codeMapContent = nil - } - let cachedFullTokenCount = entry.loadedContent.map(TokenCalculationService.estimateTokens(for:)) - return PromptFileEntrySnapshot( - fileID: entry.file.id, - relativePath: entry.file.relativePath, - isCodemapRequested: entry.isCodemap, - ranges: entry.lineRanges, - cachedFullTokenCount: cachedFullTokenCount, - loadedContent: entry.loadedContent, - codeMapContent: codeMapContent, - availableCodeMapTokenCount: availableCodeMapTokenCount - ) - } - } - - private nonisolated static func selectedPath(for entry: ResolvedPromptFileEntry, filePathDisplay: FilePathDisplay, hasMultipleRoots: Bool) -> String { - if filePathDisplay == .relative { - if hasMultipleRoots, let rootFolderPath = entry.rootFolderPath, !rootFolderPath.isEmpty { - let rootFolderName = (StandardizedPath.absolute(rootFolderPath) as NSString).lastPathComponent - return rootFolderName.isEmpty ? entry.file.relativePath : "\(rootFolderName)/\(entry.file.relativePath)" - } - return entry.file.relativePath - } - return entry.file.fullPath - } - - private nonisolated func sliceRanges(for path: String, file: WorkspaceFileRecord, location: WorkspacePathLocation, in slices: [String: [LineRange]]) -> [LineRange]? { - let candidateKeys = [ - path, - StandardizedPath.absolute(path), - file.relativePath, - file.standardizedRelativePath, - file.fullPath, - file.standardizedFullPath, - location.absolutePath - ] - for key in candidateKeys { - if let ranges = slices[key] { return ranges } - } - return nil - } - - private func append(_ entry: ResolvedPromptFileEntry, to entries: inout [ResolvedPromptFileEntry], seenIDs: inout Set) { - guard seenIDs.insert(entry.id).inserted else { return } - entries.append(entry) } } diff --git a/Sources/RepoPrompt/Features/Prompt/Services/PromptPackagingService.swift b/Sources/RepoPrompt/Features/Prompt/Services/PromptPackagingService.swift index 86c672ff7..9d9773af7 100644 --- a/Sources/RepoPrompt/Features/Prompt/Services/PromptPackagingService.swift +++ b/Sources/RepoPrompt/Features/Prompt/Services/PromptPackagingService.swift @@ -1,16 +1,70 @@ import Foundation +import RepoPromptCore struct MetaInstruction { let title: String let content: String } +enum PromptGitDiffArtifactClassifier { + static let rootFolderName = "_git_data" + + static func isDiffArtifactPath(_ fullPath: String) -> Bool { + guard fullPath.contains("/\(rootFolderName)/") else { return false } + let lower = fullPath.lowercased() + guard lower.hasSuffix(".diff") || lower.hasSuffix(".patch") else { return false } + return lower.contains("/diff/") || lower.contains("/diffs/") + } +} + enum PromptPackagingService { + struct ExactRenderedPayload { + let text: String + let projection: TokenProjection + } + + static func exactRenderedPayload( + _ text: String, + source: TokenProjection.Source + ) -> ExactRenderedPayload { + ExactRenderedPayload( + text: text, + projection: TokenProjectionService.exactRenderedPayload( + text, + view: .userConfigured, + source: source + ) + ) + } + + static func exactChatPayload( + for message: AIMessage, + source: TokenProjection.Source + ) -> ExactRenderedPayload { + exactRenderedPayload(renderedChatPayload(for: message), source: source) + } + + static func renderedChatPayload(for message: AIMessage) -> String { + var contents: [String] = [] + if !message.systemPrompt.isEmpty { + contents.append(message.systemPrompt) + } + + let tail = message.buildTail(embedSystemPrompt: false) + let lastUserIndex = message.conversationMessages.lastIndex { $0.role == .user } + for (index, entry) in message.conversationMessages.enumerated() { + let text = entry.role == .user && index == lastUserIndex && !tail.isEmpty + ? tail + "\n" + entry.content + : entry.content + contents.append(text) + } + return contents.joined() + } + /// Returns the opening ``` fence, suffixed with the file extension (\"swift\", \"js\", …). @inline(__always) static func codeFenceStart(for fileName: String) -> String { - let ext = URL(fileURLWithPath: fileName).pathExtension // "swift", "m", "" - return ext.isEmpty ? "```" : "```\(ext)" + PromptRenderingService.codeFenceStart(for: fileName) } // NEW: Helpers for title snippet @@ -42,17 +96,6 @@ enum PromptPackagingService { """ } - private enum GitDiffArtifact { - static let rootFolderName = "_git_data" - - static func isDiffArtifactPath(_ fullPath: String) -> Bool { - guard fullPath.contains("/\(rootFolderName)/") else { return false } - let lower = fullPath.lowercased() - guard lower.hasSuffix(".diff") || lower.hasSuffix(".patch") else { return false } - return lower.contains("/diff/") || lower.contains("/diffs/") - } - } - static func partitionPromptEntriesForGitDiff( _ entries: [PromptFileEntry] ) -> (diffEntries: [PromptFileEntry], codeEntries: [PromptFileEntry]) { @@ -63,7 +106,7 @@ enum PromptPackagingService { codeEntries.reserveCapacity(entries.count) for entry in entries { - if GitDiffArtifact.isDiffArtifactPath(entry.file.fullPath) { + if PromptGitDiffArtifactClassifier.isDiffArtifactPath(entry.file.fullPath) { diffEntries.append(entry) } else { codeEntries.append(entry) @@ -75,9 +118,7 @@ enum PromptPackagingService { static func selectedGitDiffText( fromDiffEntries diffEntries: [PromptFileEntry] ) async -> String? { - guard !diffEntries.isEmpty else { return nil } - let rawParts = await generateRawFileTexts(diffEntries) - return rawParts.isEmpty ? nil : rawParts.joined(separator: "\n\n") + await PromptRenderingService.renderSelectedDiffText(renderingDiffValues(diffEntries)) } static func selectedGitDiffText( @@ -110,36 +151,7 @@ enum PromptPackagingService { static func generateRawFileTexts( _ entries: [PromptFileEntry] ) async -> [String] { - guard !entries.isEmpty else { return [] } - var blocks: [String] = [] - blocks.reserveCapacity(entries.count) - - for entry in entries { - let file = entry.file - - if let ranges = entry.ranges, - !ranges.isEmpty, - let assembly = await file.assembleContent(for: ranges) - { - if assembly.isFullFile { - if !assembly.combinedText.isEmpty { - blocks.append(assembly.combinedText) - } - } else { - let text = assembly.segments.map(\.text).joined(separator: "\n") - if !text.isEmpty { - blocks.append(text) - } - } - continue - } - - if let content = await file.latestContent, !content.isEmpty { - blocks.append(content) - } - } - - return blocks + await PromptRenderingService.renderDiffParts(renderingDiffValues(entries)) } /// Build an AIMessage that includes: @@ -157,7 +169,8 @@ enum PromptPackagingService { temperature: Double?, promptSectionsOrder: [PromptSection], disabledPromptSections: Set, - duplicateUserInstructionsAtTop: Bool = false + duplicateUserInstructionsAtTop: Bool = false, + tailAssemblyStrategy: AIMessage.TailAssemblyStrategy = .legacy ) -> AIMessage { // 1️⃣ Turn meta-instructions into prompt strings let metaPrompts: [String] = metaInstructions.map { meta in @@ -199,7 +212,8 @@ enum PromptPackagingService { temperature: temperature, promptSectionsOrder: promptSectionsOrder, disabledPromptSections: disabledPromptSections, - duplicateUserInstructionsAtTop: duplicateUserInstructionsAtTop + duplicateUserInstructionsAtTop: duplicateUserInstructionsAtTop, + tailAssemblyStrategy: tailAssemblyStrategy ) } @@ -214,7 +228,7 @@ enum PromptPackagingService { codeEntries.reserveCapacity(entries.count) for entry in entries { - if GitDiffArtifact.isDiffArtifactPath(entry.file.fullPath) { + if PromptGitDiffArtifactClassifier.isDiffArtifactPath(entry.file.fullPath) { diffEntries.append(entry) } else { codeEntries.append(entry) @@ -226,8 +240,7 @@ enum PromptPackagingService { static func selectedGitDiffText( fromDiffEntries diffEntries: [ResolvedPromptFileEntry] ) -> String? { - let rawParts = generateRawFileTexts(diffEntries) - return rawParts.isEmpty ? nil : rawParts.joined(separator: "\n\n") + PromptRenderingService.renderSelectedDiffText(renderingDiffValues(diffEntries)) } static func selectedGitDiffText( @@ -260,28 +273,7 @@ enum PromptPackagingService { static func generateRawFileTexts( _ entries: [ResolvedPromptFileEntry] ) -> [String] { - guard !entries.isEmpty else { return [] } - var blocks: [String] = [] - blocks.reserveCapacity(entries.count) - - for entry in entries { - guard let content = entry.loadedContent, !content.isEmpty else { continue } - if let ranges = entry.lineRanges, !ranges.isEmpty { - let assembly = SliceAssemblyBuilder.build(from: content, ranges: ranges) - if assembly.isFullFile { - blocks.append(assembly.combinedText) - } else { - let text = assembly.segments.map(\.text).joined(separator: "\n") - if !text.isEmpty { - blocks.append(text) - } - } - } else { - blocks.append(content) - } - } - - return blocks + PromptRenderingService.renderDiffParts(renderingDiffValues(entries)) } static func generateFileContents( @@ -301,20 +293,14 @@ enum PromptPackagingService { displayPathResolver: ((ResolvedPromptFileEntry) -> String?)? = nil ) -> (codemapBlocks: [String], contentBlocks: [String]) { let (_, codeEntries) = partitionPromptEntriesForGitDiff(files) - let detailed = generateFileBlocksDetailed(files: codeEntries, filePathDisplay: filePathDisplay, codemapSnapshots: codemapSnapshots, displayPathResolver: displayPathResolver) - var codemapBlocks: [String] = [] - var contentBlocks: [String] = [] - - for record in detailed { - if record.text.isEmpty { continue } - if record.isCodemap { - codemapBlocks.append(record.text) - } else { - contentBlocks.append(record.text) - } - } - - return (codemapBlocks, contentBlocks) + let values = renderingFileValues( + codeEntries, + filePathDisplay: filePathDisplay, + codemapSnapshots: codemapSnapshots, + displayPathResolver: displayPathResolver + ) + let partitioned = PromptRenderingService.renderPartitionedFileBlocks(values) + return (partitioned.codemapBlocks, partitioned.contentBlocks) } static func generateFileBlocksDetailed( @@ -323,37 +309,21 @@ enum PromptPackagingService { codemapSnapshots: [UUID: WorkspaceCodemapSnapshot] = [:], displayPathResolver: ((ResolvedPromptFileEntry) -> String?)? = nil ) -> [ResolvedPromptFileBlockRecord] { - var blocks: [ResolvedPromptFileBlockRecord] = [] - guard !files.isEmpty else { return blocks } - - let hasMultipleRoots = Set(files.map(\.file.rootID)).count > 1 - - for entry in files { - let file = entry.file - let selectedPath = displayPathResolver?(entry) - ?? selectedPath(for: entry, filePathDisplay: filePathDisplay, hasMultipleRoots: hasMultipleRoots) - - if entry.isCodemap { - if let api = codemapSnapshots[file.id]?.fileAPI { - let description = api.getFullAPIDescription(displayPath: selectedPath) - blocks.append(ResolvedPromptFileBlockRecord(entry: entry, file: file, text: description, isCodemap: true)) - continue - } - } - - guard let content = entry.loadedContent else { continue } - let startFence = codeFenceStart(for: file.name) - let text: String - if let ranges = entry.lineRanges, !ranges.isEmpty { - let assembly = SliceAssemblyBuilder.build(from: content, ranges: ranges) - text = renderFileBlock(selectedPath: selectedPath, startFence: startFence, content: content, assembly: assembly) - } else { - text = renderFullFileBlock(selectedPath: selectedPath, startFence: startFence, content: content) - } - blocks.append(ResolvedPromptFileBlockRecord(entry: entry, file: file, text: text, isCodemap: false)) + let values = renderingFileValues( + files, + filePathDisplay: filePathDisplay, + codemapSnapshots: codemapSnapshots, + displayPathResolver: displayPathResolver + ) + return PromptRenderingService.renderFileBlocks(values).map { block in + let entry = files[block.inputIndex] + return ResolvedPromptFileBlockRecord( + entry: entry, + file: entry.file, + text: block.text, + isCodemap: block.kind == .codemap + ) } - - return blocks } /// Produce file contents as an array of strings, each with the file path + raw content @@ -371,66 +341,19 @@ enum PromptPackagingService { filePathDisplay: FilePathDisplay ) async -> (codemapBlocks: [String], contentBlocks: [String]) { let (_, codeEntries) = partitionPromptEntriesForGitDiff(files) - let detailed = await generateFileBlocksDetailed(files: codeEntries, filePathDisplay: filePathDisplay) - var codemapBlocks: [String] = [] - var contentBlocks: [String] = [] - - for (_, text, isCodemap) in detailed { - if text.isEmpty { continue } - if isCodemap { - codemapBlocks.append(text) - } else { - contentBlocks.append(text) - } - } - - return (codemapBlocks, contentBlocks) + let values = await renderingFileValues(codeEntries, filePathDisplay: filePathDisplay) + let partitioned = PromptRenderingService.renderPartitionedFileBlocks(values) + return (partitioned.codemapBlocks, partitioned.contentBlocks) } static func generateFileBlocksDetailed( files: [PromptFileEntry], filePathDisplay: FilePathDisplay ) async -> [(file: FileViewModel, text: String, isCodemap: Bool)] { - var blocks: [(FileViewModel, String, Bool)] = [] - guard !files.isEmpty else { return blocks } - - let hasMultipleRoots = Set(files.map(\.file.rootFolderPath)).count > 1 - - for entry in files { - let file = entry.file - let selectedPath: String = if filePathDisplay == .relative { - hasMultipleRoots ? file.uniqueRelativePath : file.relativePath - } else { - file.fullPath - } - - if entry.isCodemap { - // Fallback: If codemap not available, fall through to full content - if let api = file.fileAPI { - let description = api.getFullAPIDescription(displayPath: selectedPath) - blocks.append((file, description, true)) - continue - } - // No codemap available, fall through to treat as full content entry - } - - let startFence = codeFenceStart(for: file.name) - - if let ranges = entry.ranges, - !ranges.isEmpty, - let assembly = await file.assembleContent(for: ranges) - { - let text = renderFileBlock(selectedPath: selectedPath, startFence: startFence, content: assembly.combinedText, assembly: assembly) - blocks.append((file, text, false)) - continue - } - - guard let content = await file.latestContent else { continue } - let text = renderFullFileBlock(selectedPath: selectedPath, startFence: startFence, content: content) - blocks.append((file, text, false)) + let values = await renderingFileValues(files, filePathDisplay: filePathDisplay) + return PromptRenderingService.renderFileBlocks(values).map { block in + (files[block.inputIndex].file, block.text, block.kind == .codemap) } - - return blocks } static func generatePrompt( @@ -442,6 +365,7 @@ enum PromptPackagingService { fileTreeContent: String?, // NEW simplified parameter for the file tree gitDiff: String? = nil, includeDatetimeInUserInstructions: Bool = false, + renderingDate: Date? = nil, // Add parameters needed by PromptAssemblyBuilder promptSectionsOrder: [PromptSection], disabledPromptSections: Set, @@ -453,34 +377,6 @@ enum PromptPackagingService { let (diffEntries, codeEntries) = partitionPromptEntriesForGitDiff(files) let (codemapBlocks, contentBlocks) = await generatePartitionedFileBlocks(codeEntries, filePathDisplay: filePathDisplay) - // File Map Snippet - CRITICAL: Check for codemaps OR tree - let codemapJoined = codemapBlocks.joined(separator: "\n\n") - let hasTree = fileTreeContent != nil && !fileTreeContent!.isEmpty - let hasCodemaps = !codemapJoined.isEmpty - - if hasTree || hasCodemaps { - let combinedMap = [fileTreeContent ?? "", codemapJoined] - .filter { !$0.isEmpty } - .joined(separator: "\n\n") - snippets[.fileMap] = """ - - \(combinedMap) - - - """ - } - - // File Contents Snippet - only content blocks - if !contentBlocks.isEmpty { - let snippet = """ - - \(contentBlocks.joined(separator: "\n\n")) - - - """ - snippets[.fileContents] = snippet - } - // Meta Prompts Snippet if let metaSnippet = buildMetaPromptsSnippet(metaInstructions) { snippets[.metaPrompts] = metaSnippet @@ -491,40 +387,21 @@ enum PromptPackagingService { ) { gitDiff } - - // Git Diff Snippet - if let diff = effectiveGitDiff, !diff.isEmpty { - let snippet = """ - - \(diff) - - - """ - snippets[.gitDiff] = snippet - } + let factualSnippets = PromptRenderingService.renderFactualSnippets( + fileTreeContent: fileTreeContent, + codemapBlocks: codemapBlocks, + contentBlocks: contentBlocks, + gitDiff: effectiveGitDiff + ) + applyFactualSnippets(factualSnippets, to: &snippets) // User Instructions Snippet if !userInstructions.isEmpty { - var snippet = "" - if includeDatetimeInUserInstructions { - let dateFormatter = DateFormatter() - dateFormatter.dateFormat = "yyyy-MM-dd'T'HH:mm" - let dateString = dateFormatter.string(from: Date()) - snippet += """ - - \(userInstructions) - - - """ - } else { - snippet += """ - - \(userInstructions) - - - """ - } - snippets[.userInstructions] = snippet + snippets[.userInstructions] = userInstructionsSnippet( + userInstructions, + includeDatetime: includeDatetimeInUserInstructions, + renderingDate: renderingDate + ) } // --- Build Final User Message --- @@ -553,6 +430,7 @@ enum PromptPackagingService { includeUserPrompt: Bool, filePathDisplay: FilePathDisplay, includeDatetimeInUserInstructions: Bool = false, + renderingDate: Date? = nil, promptSectionsOrder: [PromptSection], disabledPromptSections: Set, duplicateUserInstructionsAtTop: Bool, @@ -565,34 +443,6 @@ enum PromptPackagingService { let (diffEntries, codeEntries) = partitionPromptEntriesForGitDiff(files) let (codemapBlocks, contentBlocks) = await generatePartitionedFileBlocks(codeEntries, filePathDisplay: filePathDisplay) - // File Map Snippet - CRITICAL: Check for codemaps OR tree - let codemapJoined = codemapBlocks.joined(separator: "\n\n") - let hasTree = fileTreeContent != nil && !fileTreeContent!.isEmpty - let hasCodemaps = !codemapJoined.isEmpty - - if hasTree || hasCodemaps { - let combinedMap = [fileTreeContent ?? "", codemapJoined] - .filter { !$0.isEmpty } - .joined(separator: "\n\n") - snippets[.fileMap] = """ - - \(combinedMap) - - - """ - } - - // File Contents Snippet - only content blocks - if includeFiles, !contentBlocks.isEmpty { - let snippet = """ - - \(contentBlocks.joined(separator: "\n\n")) - - - """ - snippets[.fileContents] = snippet - } - // Meta Prompts Snippet if includeSavedPrompts, let metaSnippet = buildMetaPromptsSnippet(metaInstructions) { snippets[.metaPrompts] = metaSnippet @@ -603,40 +453,21 @@ enum PromptPackagingService { ) { gitDiff } - - // Git Diff Snippet - if let diff = effectiveGitDiff, !diff.isEmpty { - let snippet = """ - - \(diff) - - - """ - snippets[.gitDiff] = snippet - } + let factualSnippets = PromptRenderingService.renderFactualSnippets( + fileTreeContent: fileTreeContent, + codemapBlocks: codemapBlocks, + contentBlocks: includeFiles ? contentBlocks : [], + gitDiff: effectiveGitDiff + ) + applyFactualSnippets(factualSnippets, to: &snippets) // User Instructions Snippet if includeUserPrompt, !userInstructions.isEmpty { - var snippet = "" - if includeDatetimeInUserInstructions { - let dateFormatter = DateFormatter() - dateFormatter.dateFormat = "yyyy-MM-dd'T'HH:mm" - let dateString = dateFormatter.string(from: Date()) - snippet += """ - - \(userInstructions) - - - """ - } else { - snippet += """ - - \(userInstructions) - - - """ - } - snippets[.userInstructions] = snippet + snippets[.userInstructions] = userInstructionsSnippet( + userInstructions, + includeDatetime: includeDatetimeInUserInstructions, + renderingDate: renderingDate + ) } // --- Build Final String --- @@ -664,6 +495,7 @@ enum PromptPackagingService { filePathDisplay: FilePathDisplay, codemapSnapshots: [UUID: WorkspaceCodemapSnapshot] = [:], includeDatetimeInUserInstructions: Bool = false, + renderingDate: Date? = nil, promptSectionsOrder: [PromptSection], disabledPromptSections: Set, duplicateUserInstructionsAtTop: Bool, @@ -675,32 +507,6 @@ enum PromptPackagingService { let (diffEntries, codeEntries) = partitionPromptEntriesForGitDiff(files) let (codemapBlocks, contentBlocks) = generatePartitionedFileBlocks(codeEntries, filePathDisplay: filePathDisplay, codemapSnapshots: codemapSnapshots, displayPathResolver: displayPathResolver) - let codemapJoined = codemapBlocks.joined(separator: "\n\n") - let hasTree = fileTreeContent != nil && !fileTreeContent!.isEmpty - let hasCodemaps = !codemapJoined.isEmpty - - if hasTree || hasCodemaps { - let combinedMap = [fileTreeContent ?? "", codemapJoined] - .filter { !$0.isEmpty } - .joined(separator: "\n\n") - snippets[.fileMap] = """ - - \(combinedMap) - - - """ - } - - if includeFiles, !contentBlocks.isEmpty { - let snippet = """ - - \(contentBlocks.joined(separator: "\n\n")) - - - """ - snippets[.fileContents] = snippet - } - if includeSavedPrompts, let metaSnippet = buildMetaPromptsSnippet(metaInstructions) { snippets[.metaPrompts] = metaSnippet } @@ -710,38 +516,20 @@ enum PromptPackagingService { ) { gitDiff } - - if let diff = effectiveGitDiff, !diff.isEmpty { - let snippet = """ - - \(diff) - - - """ - snippets[.gitDiff] = snippet - } + let factualSnippets = PromptRenderingService.renderFactualSnippets( + fileTreeContent: fileTreeContent, + codemapBlocks: codemapBlocks, + contentBlocks: includeFiles ? contentBlocks : [], + gitDiff: effectiveGitDiff + ) + applyFactualSnippets(factualSnippets, to: &snippets) if includeUserPrompt, !userInstructions.isEmpty { - var snippet = "" - if includeDatetimeInUserInstructions { - let dateFormatter = DateFormatter() - dateFormatter.dateFormat = "yyyy-MM-dd'T'HH:mm" - let dateString = dateFormatter.string(from: Date()) - snippet += """ - - \(userInstructions) - - - """ - } else { - snippet += """ - - \(userInstructions) - - - """ - } - snippets[.userInstructions] = snippet + snippets[.userInstructions] = userInstructionsSnippet( + userInstructions, + includeDatetime: includeDatetimeInUserInstructions, + renderingDate: renderingDate + ) } let clipboardContent = PromptAssemblyBuilder.build( @@ -773,54 +561,129 @@ enum PromptPackagingService { return entry.file.fullPath } - private static func renderFullFileBlock(selectedPath: String, startFence: String, content: String) -> String { - let endFence = "```" - return """ - File: \(selectedPath) - \(startFence) - \(content) - \(endFence) - """ + private static func renderingDiffValues( + _ entries: [ResolvedPromptFileEntry] + ) -> [PromptRenderingDiffValue] { + entries.map { entry in + PromptRenderingDiffValue(content: entry.loadedContent, ranges: entry.lineRanges) + } + } + + private static func renderingDiffValues( + _ entries: [PromptFileEntry] + ) async -> [PromptRenderingDiffValue] { + var values: [PromptRenderingDiffValue] = [] + values.reserveCapacity(entries.count) + for entry in entries { + await values.append( + PromptRenderingDiffValue( + content: entry.file.latestContent, + ranges: entry.ranges + ) + ) + } + return values } - private static func renderSliceFileBlock(selectedPath: String, startFence: String, segments: [WorkspaceSliceSegment]) -> String { - let endFence = "```" - var sliceLines = ["File: \(selectedPath)"] - for (index, segment) in segments.enumerated() { - let label = formatRange(segment.range) - if let desc = segment.range.description, !desc.isEmpty { - sliceLines.append("(lines \(label): \(desc))") + private static func renderingFileValues( + _ entries: [ResolvedPromptFileEntry], + filePathDisplay: FilePathDisplay, + codemapSnapshots: [UUID: WorkspaceCodemapSnapshot], + displayPathResolver: ((ResolvedPromptFileEntry) -> String?)? + ) -> [PromptRenderingFileValue] { + let hasMultipleRoots = Set(entries.map(\.file.rootID)).count > 1 + return entries.map { entry in + let displayPath = displayPathResolver?(entry) + ?? selectedPath(for: entry, filePathDisplay: filePathDisplay, hasMultipleRoots: hasMultipleRoots) + let codemapText: String? = if entry.isCodemap, + let api = codemapSnapshots[entry.file.id]?.fileAPI + { + api.getFullAPIDescription(displayPath: displayPath) } else { - sliceLines.append("(lines \(label))") - } - sliceLines.append(startFence) - sliceLines.append(segment.text) - sliceLines.append(endFence) - if index != segments.count - 1 { - sliceLines.append("") + nil } + return PromptRenderingFileValue( + displayPath: displayPath, + fileName: entry.file.name, + content: entry.loadedContent, + ranges: entry.lineRanges, + codemapText: codemapText + ) } - return sliceLines.joined(separator: "\n") } - private static func renderFileBlock( - selectedPath: String, - startFence: String, - content: String, - assembly: WorkspaceSliceAssembly - ) -> String { - if assembly.isFullFile { - return renderFullFileBlock(selectedPath: selectedPath, startFence: startFence, content: assembly.combinedText) + private static func renderingFileValues( + _ entries: [PromptFileEntry], + filePathDisplay: FilePathDisplay + ) async -> [PromptRenderingFileValue] { + let hasMultipleRoots = Set(entries.map(\.file.rootFolderPath)).count > 1 + var values: [PromptRenderingFileValue] = [] + values.reserveCapacity(entries.count) + + for entry in entries { + let file = entry.file + let displayPath: String = if filePathDisplay == .relative { + hasMultipleRoots ? file.uniqueRelativePath : file.relativePath + } else { + file.fullPath + } + let codemapText: String? = if entry.isCodemap, let api = file.fileAPI { + api.getFullAPIDescription(displayPath: displayPath) + } else { + nil + } + let content = codemapText == nil ? await file.latestContent : nil + values.append( + PromptRenderingFileValue( + displayPath: displayPath, + fileName: file.name, + content: content, + ranges: entry.ranges, + codemapText: codemapText + ) + ) } - return renderSliceFileBlock(selectedPath: selectedPath, startFence: startFence, segments: assembly.segments) + + return values } - private static func escapeString(_ input: String) -> String { - input.escapedString() + private static func applyFactualSnippets( + _ factual: PromptRenderedFactualSnippets, + to snippets: inout [PromptSection: String] + ) { + if let fileMap = factual.fileMap { + snippets[.fileMap] = fileMap + } + if let fileContents = factual.fileContents { + snippets[.fileContents] = fileContents + } + if let gitDiff = factual.gitDiff { + snippets[.gitDiff] = gitDiff + } } - private static func formatRange(_ range: LineRange) -> String { - range.start == range.end ? "\(range.start)" : "\(range.start)-\(range.end)" + private static func userInstructionsSnippet( + _ userInstructions: String, + includeDatetime: Bool, + renderingDate: Date? + ) -> String { + if includeDatetime { + let dateFormatter = DateFormatter() + dateFormatter.dateFormat = "yyyy-MM-dd'T'HH:mm" + let dateString = dateFormatter.string(from: renderingDate ?? Date()) + return """ + + \(userInstructions) + + + """ + } + return """ + + \(userInstructions) + + + """ } // MARK: - Shared builder for blocks diff --git a/Sources/RepoPrompt/Features/Prompt/Services/WorkspacePromptProjectionAdapter.swift b/Sources/RepoPrompt/Features/Prompt/Services/WorkspacePromptProjectionAdapter.swift new file mode 100644 index 000000000..2d304288c --- /dev/null +++ b/Sources/RepoPrompt/Features/Prompt/Services/WorkspacePromptProjectionAdapter.swift @@ -0,0 +1,532 @@ +import Foundation +import RepoPromptCore + +struct WorkspacePromptProjectionAdapter { + enum Error: Swift.Error, Equatable { + case missingSelectionProjection + case missingTokenProjection + case projectionProvenanceMismatch + case missingTokenFacts(OccurrenceIdentity) + case unusedTokenFacts([OccurrenceIdentity]) + } + + struct OccurrenceIdentity: Equatable, Hashable { + enum Mode: Equatable, Hashable { + case full + case slice + case codemap + } + + let fileID: UUID + let standardizedPath: String + let mode: Mode + let ranges: [LineRange] + } + + struct Entry: Equatable { + let file: WorkspaceFileRecord + let metadata: WorkspaceSelectionProjection.PathMetadata + let mode: WorkspaceSelectionProjection.RenderMode + let ranges: [LineRange]? + let codemapOrigin: WorkspaceSelectionProjection.CodemapOrigin? + } + + struct Projection: Equatable { + let provenance: WorkspaceFileContextCapture.Provenance + let entries: [Entry] + } + + struct TokenAwareProjection: Equatable { + let provenance: WorkspaceFileContextCapture.Provenance + let selection: WorkspaceSelectionProjection + let tokens: WorkspaceContextProjection.TokenViews + } + + typealias CaptureOperation = @Sendable ( + _ selection: StoredSelection, + _ fileTreeRequest: WorkspaceFileTreeSnapshotRequest, + _ profile: PathLocateProfile, + _ coverage: WorkspaceFileContextCaptureCoverage + ) async throws -> WorkspaceFileContextCapture + + typealias TokenEvaluationOperation = @Sendable ([PromptFileEntrySnapshot]) async -> PromptEntriesEvaluation + + private struct SnapshotMatchKey: Hashable { + let fileID: UUID + let isCodemapRequested: Bool + let ranges: [LineRange] + } + + private struct TokenFactMatchKey: Hashable { + let identity: OccurrenceIdentity + let modificationDate: Date? + } + + private struct MatchedTokenEntry { + let identity: OccurrenceIdentity + let modificationDate: Date? + let snapshot: PromptFileEntrySnapshot + } + + private struct OccurrenceTokenFact { + let identity: OccurrenceIdentity + let modificationDate: Date? + let displayTokens: Int + let fullTokens: Int + } + + private let capture: CaptureOperation + private let evaluatePromptEntries: TokenEvaluationOperation + + init(store: WorkspaceFileContextStore) { + let tokenCalculationService = TokenCalculationService() + capture = { selection, fileTreeRequest, profile, coverage in + try await store.captureWorkspaceFileContext( + selection: selection, + fileTreeRequest: fileTreeRequest, + profile: profile, + coverage: coverage + ) + } + evaluatePromptEntries = { snapshots in + await tokenCalculationService.evaluatePromptEntries(snapshots) + } + } + + init(capture: @escaping CaptureOperation) { + let tokenCalculationService = TokenCalculationService() + self.capture = capture + evaluatePromptEntries = { snapshots in + await tokenCalculationService.evaluatePromptEntries(snapshots) + } + } + + init( + capture: @escaping CaptureOperation, + evaluatePromptEntries: @escaping TokenEvaluationOperation + ) { + self.capture = capture + self.evaluatePromptEntries = evaluatePromptEntries + } + + func project( + selection: StoredSelection, + codeMapUsage: CodeMapUsage, + filePathDisplay: FilePathDisplay + ) async throws -> Projection { + let capture = try await captureWorkspaceContext( + selection: selection, + codeMapUsage: codeMapUsage, + filePathDisplay: filePathDisplay + ) + return try await project( + capture: capture, + codeMapUsage: codeMapUsage, + filePathDisplay: filePathDisplay + ) + } + + private func project( + capture: WorkspaceFileContextCapture, + codeMapUsage: CodeMapUsage, + filePathDisplay: FilePathDisplay + ) async throws -> Projection { + let projection = try await projectContext( + capture: capture, + codeMapUsage: codeMapUsage, + filePathDisplay: filePathDisplay, + sections: [.selection], + materializer: { request in + try await Self.materializeSelectionProjection(request) + } + ) + guard let selectionProjection = projection.selection else { + throw Error.missingSelectionProjection + } + + return Projection( + provenance: selectionProjection.provenance, + entries: selectionProjection.value.files.compactMap { file in + guard file.mode != .hidden else { return nil } + return Entry( + file: file.file, + metadata: file.metadata, + mode: file.mode, + ranges: file.ranges, + codemapOrigin: file.codemapOrigin + ) + } + ) + } + + func projectTokens( + selection: StoredSelection, + codeMapUsage: CodeMapUsage, + filePathDisplay: FilePathDisplay, + alternatePolicy: WorkspaceSelectionProjectionRequest.AlternatePolicy?, + resolvedEntries: [ResolvedPromptFileEntry], + promptFileEntrySnapshots: [PromptFileEntrySnapshot], + tokenProjectionInput: WorkspaceTokenProjectionInput + ) async throws -> TokenAwareProjection { + let tokenFacts = try await makeOccurrenceTokenFacts( + resolvedEntries: resolvedEntries, + promptFileEntrySnapshots: promptFileEntrySnapshots + ) + let capture = try await captureWorkspaceContext( + selection: selection, + codeMapUsage: codeMapUsage, + filePathDisplay: filePathDisplay, + alternatePolicy: alternatePolicy + ) + return try await projectTokens( + capture: capture, + codeMapUsage: codeMapUsage, + filePathDisplay: filePathDisplay, + alternatePolicy: alternatePolicy, + tokenFacts: tokenFacts, + tokenProjectionInput: tokenProjectionInput + ) + } + + func captureWorkspaceContext( + selection: StoredSelection, + codeMapUsage: CodeMapUsage, + filePathDisplay: FilePathDisplay, + alternatePolicy: WorkspaceSelectionProjectionRequest.AlternatePolicy? = nil, + fileTreeRequest: WorkspaceFileTreeSnapshotRequest? = nil + ) async throws -> WorkspaceFileContextCapture { + let codemapCoverage: WorkspaceFileContextCaptureCoverage.CodemapCoverage = if codeMapUsage == .complete + || alternatePolicy?.codeMapUsage == .complete + { + .allAvailable + } else { + .referenced + } + return try await capture( + selection, + fileTreeRequest ?? WorkspaceFileTreeSnapshotRequest( + mode: .none, + filePathDisplay: filePathDisplay, + onlyIncludeRootsWithSelectedFiles: false, + includeLegend: false, + showCodeMapMarkers: false, + rootScope: .allLoaded + ), + .uiAssisted, + .projection(codemapCoverage: codemapCoverage) + ) + } + + func projectTokens( + capture: WorkspaceFileContextCapture, + codeMapUsage: CodeMapUsage, + filePathDisplay: FilePathDisplay, + alternatePolicy: WorkspaceSelectionProjectionRequest.AlternatePolicy?, + resolvedEntries: [ResolvedPromptFileEntry], + promptFileEntrySnapshots: [PromptFileEntrySnapshot], + tokenProjectionInput: WorkspaceTokenProjectionInput + ) async throws -> TokenAwareProjection { + let tokenFacts = try await makeOccurrenceTokenFacts( + resolvedEntries: resolvedEntries, + promptFileEntrySnapshots: promptFileEntrySnapshots + ) + return try await projectTokens( + capture: capture, + codeMapUsage: codeMapUsage, + filePathDisplay: filePathDisplay, + alternatePolicy: alternatePolicy, + tokenFacts: tokenFacts, + tokenProjectionInput: tokenProjectionInput + ) + } + + private func projectTokens( + capture: WorkspaceFileContextCapture, + codeMapUsage: CodeMapUsage, + filePathDisplay: FilePathDisplay, + alternatePolicy: WorkspaceSelectionProjectionRequest.AlternatePolicy?, + tokenFacts: [OccurrenceTokenFact], + tokenProjectionInput: WorkspaceTokenProjectionInput + ) async throws -> TokenAwareProjection { + let projection = try await projectContext( + capture: capture, + codeMapUsage: codeMapUsage, + filePathDisplay: filePathDisplay, + sections: [.selection, .tokens], + alternatePolicy: alternatePolicy, + tokenProjectionInput: tokenProjectionInput, + materializer: { request in + try Self.materializeTokenProjection(request, tokenFacts: tokenFacts) + } + ) + guard let selectionProjection = projection.selection else { + throw Error.missingSelectionProjection + } + guard let tokenProjection = projection.tokens else { + throw Error.missingTokenProjection + } + guard selectionProjection.provenance == tokenProjection.provenance else { + throw Error.projectionProvenanceMismatch + } + + return TokenAwareProjection( + provenance: selectionProjection.provenance, + selection: selectionProjection.value, + tokens: tokenProjection.value + ) + } + + @MainActor + func mapToLivePromptEntries( + _ projection: Projection, + resolveFile: (WorkspaceFileRecord) -> FileViewModel? + ) -> [PromptFileEntry] { + projection.entries.compactMap { entry in + guard let file = resolveFile(entry.file), + file.id == entry.file.id, + file.standardizedFullPath == entry.file.standardizedFullPath + else { return nil } + + return PromptFileEntry( + file: file, + isCodemap: entry.mode == .codemap, + ranges: entry.mode == .slice ? entry.ranges : nil + ) + } + } + + private func projectContext( + capture: WorkspaceFileContextCapture, + codeMapUsage: CodeMapUsage, + filePathDisplay: FilePathDisplay, + sections: WorkspaceContextProjectionRequest.Sections, + alternatePolicy: WorkspaceSelectionProjectionRequest.AlternatePolicy? = nil, + tokenProjectionInput: WorkspaceTokenProjectionInput = .emptyVirtual, + materializer: @escaping WorkspaceContextProjectionService.Materializer + ) async throws -> WorkspaceContextProjection { + let service = WorkspaceContextProjectionService( + capture: { + capture + }, + materializer: materializer + ) + return try await service.project(.init( + sections: sections, + filePathDisplay: filePathDisplay, + codeMapUsage: codeMapUsage, + alternatePolicy: alternatePolicy, + tokenProjectionInput: tokenProjectionInput + )) + } + + private func makeOccurrenceTokenFacts( + resolvedEntries: [ResolvedPromptFileEntry], + promptFileEntrySnapshots: [PromptFileEntrySnapshot] + ) async throws -> [OccurrenceTokenFact] { + var snapshotIndicesByKey: [SnapshotMatchKey: [Int]] = [:] + snapshotIndicesByKey.reserveCapacity(promptFileEntrySnapshots.count) + for (index, snapshot) in promptFileEntrySnapshots.enumerated() { + let key = SnapshotMatchKey( + fileID: snapshot.fileID, + isCodemapRequested: snapshot.isCodemapRequested, + ranges: snapshot.ranges ?? [] + ) + snapshotIndicesByKey[key, default: []].append(index) + } + + var snapshotCursorsByKey: [SnapshotMatchKey: Int] = [:] + var matchedEntries: [MatchedTokenEntry] = [] + matchedEntries.reserveCapacity(resolvedEntries.count) + var firstMissing: (index: Int, identity: OccurrenceIdentity)? + + for (index, entry) in resolvedEntries.enumerated() { + let identity = Self.occurrenceIdentity(for: entry) + let key = SnapshotMatchKey( + fileID: entry.file.id, + isCodemapRequested: entry.isCodemap, + ranges: identity.ranges + ) + let cursor = snapshotCursorsByKey[key, default: 0] + guard let indices = snapshotIndicesByKey[key], cursor < indices.count else { + firstMissing = (index, identity) + break + } + snapshotCursorsByKey[key] = cursor + 1 + matchedEntries.append(MatchedTokenEntry( + identity: identity, + modificationDate: entry.file.modificationDate, + snapshot: promptFileEntrySnapshots[indices[cursor]] + )) + } + + var batchIndices: [[Int]] = [] + var nextBatchByFileID: [UUID: Int] = [:] + for index in matchedEntries.indices { + let fileID = matchedEntries[index].snapshot.fileID + let batchIndex = nextBatchByFileID[fileID, default: 0] + nextBatchByFileID[fileID] = batchIndex + 1 + while batchIndices.count <= batchIndex { + batchIndices.append([]) + } + batchIndices[batchIndex].append(index) + } + + var results: [PromptEntriesEvaluation.EntryResult?] = Array( + repeating: nil, + count: matchedEntries.count + ) + for indices in batchIndices { + let evaluation = await evaluatePromptEntries(indices.map { matchedEntries[$0].snapshot }) + for index in indices { + let fileID = matchedEntries[index].snapshot.fileID + results[index] = evaluation.entryResultsByFileID[fileID] + } + } + + var facts: [OccurrenceTokenFact] = [] + facts.reserveCapacity(matchedEntries.count) + for index in resolvedEntries.indices { + if let firstMissing, firstMissing.index == index { + throw Error.missingTokenFacts(firstMissing.identity) + } + guard index < matchedEntries.count else { break } + let matched = matchedEntries[index] + guard let result = results[index], + result.renderMode == Self.evaluationMode(for: matched.identity.mode) + else { + throw Error.missingTokenFacts(matched.identity) + } + facts.append(OccurrenceTokenFact( + identity: matched.identity, + modificationDate: matched.modificationDate, + displayTokens: result.displayTokens, + fullTokens: result.fullTokens + )) + } + return facts + } + + private static func materializeTokenProjection( + _ request: WorkspaceContextProjectionMaterializationRequest, + tokenFacts: [OccurrenceTokenFact] + ) throws -> WorkspaceContextProjectionMaterialization { + var factIndicesByKey: [TokenFactMatchKey: [Int]] = [:] + factIndicesByKey.reserveCapacity(tokenFacts.count) + for (index, fact) in tokenFacts.enumerated() { + let key = TokenFactMatchKey( + identity: fact.identity, + modificationDate: fact.modificationDate + ) + factIndicesByKey[key, default: []].append(index) + } + + var factCursorsByKey: [TokenFactMatchKey: Int] = [:] + var consumedFacts = Array(repeating: false, count: tokenFacts.count) + var occurrences: [WorkspaceContextProjectionMaterialization.Occurrence] = [] + occurrences.reserveCapacity(request.occurrences.count) + + for occurrence in request.occurrences { + let identity = occurrenceIdentity(for: occurrence) + let key = TokenFactMatchKey( + identity: identity, + modificationDate: occurrence.file.modificationDate + ) + let cursor = factCursorsByKey[key, default: 0] + guard let indices = factIndicesByKey[key], cursor < indices.count else { + throw Error.missingTokenFacts(identity) + } + factCursorsByKey[key] = cursor + 1 + let factIndex = indices[cursor] + consumedFacts[factIndex] = true + let fact = tokenFacts[factIndex] + occurrences.append(.init( + id: occurrence.id, + content: nil, + tokenFacts: .init( + displayTokens: fact.displayTokens, + fullTokens: fact.fullTokens + ) + )) + } + + let unusedIdentities = tokenFacts.indices.compactMap { index in + consumedFacts[index] ? nil : tokenFacts[index].identity + } + guard unusedIdentities.isEmpty else { + throw Error.unusedTokenFacts(unusedIdentities) + } + return WorkspaceContextProjectionMaterialization( + provenance: request.provenance, + occurrences: occurrences + ) + } + + private static func occurrenceIdentity( + for entry: ResolvedPromptFileEntry + ) -> OccurrenceIdentity { + let mode: OccurrenceIdentity.Mode = switch entry.mode { + case .fullFile: + .full + case .sliced: + .slice + case .codemap: + .codemap + } + return OccurrenceIdentity( + fileID: entry.file.id, + standardizedPath: entry.file.standardizedFullPath, + mode: mode, + ranges: entry.lineRanges ?? [] + ) + } + + private static func occurrenceIdentity( + for occurrence: WorkspaceContextProjectionMaterializationRequest.Occurrence + ) -> OccurrenceIdentity { + let mode: OccurrenceIdentity.Mode = switch occurrence.mode { + case .full: + .full + case .slice: + .slice + case .codemap: + .codemap + } + return OccurrenceIdentity( + fileID: occurrence.file.id, + standardizedPath: occurrence.file.standardizedFullPath, + mode: mode, + ranges: occurrence.ranges + ) + } + + private static func evaluationMode( + for mode: OccurrenceIdentity.Mode + ) -> PromptEntriesEvaluation.RenderMode { + switch mode { + case .full: .full + case .slice: .slice + case .codemap: .codemap + } + } + + private static func materializeSelectionProjection( + _ request: WorkspaceContextProjectionMaterializationRequest + ) async throws -> WorkspaceContextProjectionMaterialization { + WorkspaceContextProjectionMaterialization( + provenance: request.provenance, + occurrences: request.occurrences.map { occurrence in + let displayTokens = occurrence.mode == .codemap + ? occurrence.codemap?.tokens ?? 0 + : 0 + return .init( + id: occurrence.id, + content: nil, + tokenFacts: .init( + displayTokens: displayTokens, + fullTokens: 0 + ) + ) + } + ) + } +} diff --git a/Sources/RepoPrompt/Features/Prompt/ViewModels/PromptViewModel+PromptSnapshotEntries.swift b/Sources/RepoPrompt/Features/Prompt/ViewModels/PromptViewModel+PromptSnapshotEntries.swift index d34f06206..b7a4c2fad 100644 --- a/Sources/RepoPrompt/Features/Prompt/ViewModels/PromptViewModel+PromptSnapshotEntries.swift +++ b/Sources/RepoPrompt/Features/Prompt/ViewModels/PromptViewModel+PromptSnapshotEntries.swift @@ -9,88 +9,96 @@ extension PromptViewModel { } @MainActor - func hasPromptSnapshotEntriesForChat() -> Bool { - let selectionCount = fileManager.selectedFiles.count - let codeMapUsage = effectiveCodeMapUsageForChatPromptEntries() - - switch codeMapUsage { - case .none, .selected: - return selectionCount > 0 - case .auto: - return selectionCount > 0 || !fileManager.autoCodemapFiles.isEmpty - case .complete: - return selectionCount > 0 || !chatCodemapFileAPIs.isEmpty - } - } - - @MainActor - func promptSnapshotEntriesForChatCached() -> [PromptFileEntry] { + private func chatPromptEntriesRequest() -> ( + key: ChatPromptEntriesCacheKey, + selection: StoredSelection, + codeMapUsage: CodeMapUsage, + filePathDisplay: FilePathDisplay + ) { + let selection = activeComposeTabStoredSelectionForPromptProjection() let codeMapUsage = effectiveCodeMapUsageForChatPromptEntries() + let filePathDisplay = filePathDisplayOption let key = ChatPromptEntriesCacheKey( + selection: selection, codeMapUsage: codeMapUsage, + filePathDisplay: filePathDisplay, selectionVersion: chatSelectionVersion, slicesVersion: chatSlicesVersion, autoCodemapVersion: chatAutoCodemapVersion, fileAPIsVersion: chatFileAPIsVersion ) + return (key, selection, codeMapUsage, filePathDisplay) + } + + @MainActor + func hasPromptSnapshotEntriesForChat() -> Bool { + !promptSnapshotEntriesForChatCached().isEmpty + } - if let cache = chatPromptEntriesCache, cache.key == key { + @MainActor + func promptSnapshotEntriesForChatCached() -> [PromptFileEntry] { + let request = chatPromptEntriesRequest() + if let cache = chatPromptEntriesCache, cache.key == request.key { return cache.entries } - let entries = buildPromptSnapshotEntriesForCurrentChatProjection(codeMapUsage: codeMapUsage) - chatPromptEntriesCache = (key: key, entries: entries) - return entries + refreshPromptSnapshotEntriesForChatIfNeeded(request) + return [] } @MainActor - private func buildPromptSnapshotEntriesForCurrentChatProjection(codeMapUsage: CodeMapUsage) -> [PromptFileEntry] { - let selectedFiles = fileManager.selectedFiles - let selectedIDs = Set(selectedFiles.map(\.id)) - var entries: [PromptFileEntry] = selectedFiles.map { file in - PromptFileEntry( - file: file, - isCodemap: false, - ranges: fileManager.selectionSlicesByFileID[file.id] - ) - } + private func refreshPromptSnapshotEntriesForChatIfNeeded( + _ request: ( + key: ChatPromptEntriesCacheKey, + selection: StoredSelection, + codeMapUsage: CodeMapUsage, + filePathDisplay: FilePathDisplay + ) + ) { + guard chatPromptEntriesProjectionKey != request.key else { return } - for file in fileManager.autoCodemapFiles where !selectedIDs.contains(file.id) { - entries.append(PromptFileEntry(file: file, isCodemap: true, ranges: nil)) - } + chatPromptEntriesProjectionGeneration &+= 1 + let generation = chatPromptEntriesProjectionGeneration + chatPromptEntriesProjectionTask?.cancel() + chatPromptEntriesProjectionKey = request.key - switch codeMapUsage { - case .none: - entries.removeAll { $0.isCodemap } - case .auto: - break - case .selected: - entries = entries.compactMap { entry in - guard selectedIDs.contains(entry.file.id) else { return nil } - let canCodemap = fileManager.validatedFileAPI(for: entry.file) != nil - return PromptFileEntry( - file: entry.file, - isCodemap: canCodemap, - ranges: canCodemap ? nil : entry.ranges + let adapter = WorkspacePromptProjectionAdapter(store: workspaceFileContextStore) + chatPromptEntriesProjectionTask = Task { [weak self] in + do { + let projection = try await adapter.project( + selection: request.selection, + codeMapUsage: request.codeMapUsage, + filePathDisplay: request.filePathDisplay ) - } - case .complete: - var existingPaths = Set(entries.map(\.file.standardizedFullPath)) - let selectedPaths = Set(selectedFiles.map(\.standardizedFullPath)) - - for api in fileManager.validatedCurrentFileAPIs(from: chatCodemapFileAPIs) { - let standardizedPath = StandardizedPath.absolute(api.filePath) - guard !selectedPaths.contains(standardizedPath), - !existingPaths.contains(standardizedPath), - let file = fileManager.findFileByFullPath(standardizedPath) - else { continue } + try Task.checkCancellation() + guard let self, + chatPromptEntriesProjectionGeneration == generation, + chatPromptEntriesProjectionKey == request.key, + chatPromptEntriesRequest().key == request.key + else { return } - entries.append(PromptFileEntry(file: file, isCodemap: true, ranges: nil)) - existingPaths.insert(standardizedPath) + let entries = adapter.mapToLivePromptEntries(projection) { projectedFile in + self.fileManager.findFileByFullPath(projectedFile.standardizedFullPath) + } + objectWillChange.send() + chatPromptEntriesCache = ( + key: request.key, + projection: projection, + entries: entries + ) + chatPromptEntriesProjectionTask = nil + chatPromptEntriesProjectionKey = nil + } catch is CancellationError { + return + } catch { + guard let self, + chatPromptEntriesProjectionGeneration == generation, + chatPromptEntriesProjectionKey == request.key + else { return } + chatPromptEntriesProjectionTask = nil + chatPromptEntriesProjectionKey = nil } } - - return entries } @MainActor diff --git a/Sources/RepoPrompt/Features/Prompt/ViewModels/PromptViewModel.swift b/Sources/RepoPrompt/Features/Prompt/ViewModels/PromptViewModel.swift index cdc76ef17..1cdaa747e 100644 --- a/Sources/RepoPrompt/Features/Prompt/ViewModels/PromptViewModel.swift +++ b/Sources/RepoPrompt/Features/Prompt/ViewModels/PromptViewModel.swift @@ -1,18 +1,8 @@ import Combine import Foundation +import RepoPromptCore import SwiftUI -enum FileTreeOption: String, CaseIterable, Identifiable, Codable { - case auto = "Auto" - case files = "Full" - case selected = "Selected" - case none = "None" - - var id: String { - rawValue - } -} - /// Errors that can occur when publishing git diff artifacts enum GitArtifactPublishError: LocalizedError { case noActiveWorkspace @@ -1095,97 +1085,53 @@ class PromptViewModel: ObservableObject { @Published private var isApplyingPresetOverrides = false private var isApplyingChatPreset = false - struct ChatPromptEntriesCacheKey: Hashable { + struct ChatPromptEntriesCacheKey: Equatable { + let selection: StoredSelection let codeMapUsage: CodeMapUsage + let filePathDisplay: FilePathDisplay let selectionVersion: UInt64 let slicesVersion: UInt64 let autoCodemapVersion: UInt64 let fileAPIsVersion: UInt64 } - private struct ChatPresetTokenBaselineKey: Equatable { - let id: UUID - let mode: ChatPresetMode - let modelPresetName: String? - let fileTreeMode: FileTreeOption? - let codeMapUsage: CodeMapUsage? - let gitInclusion: GitInclusion? - let storedPromptIds: [UUID] - let useStoredPromptsAsSystem: Bool + struct PackagedPromptResult { + let message: AIMessage + let exactPayload: PromptPackagingService.ExactRenderedPayload } - private struct PromptContextTokenBaselineKey: Equatable { + private struct ClipboardPackagingRequest { + let config: PromptContextResolved + let selection: StoredSelection + let promptText: String + let metaInstructions: [MetaInstruction] + let includeSavedPrompts: Bool let includeFiles: Bool let includeUserPrompt: Bool - let includeMetaPrompts: Bool - let includeFileTree: Bool - let fileTreeMode: FileTreeOption - let codeMapUsage: CodeMapUsage - let gitInclusion: GitInclusion - let storedPromptIds: [UUID] - } - - private struct StoredPromptTokenBaselineKey: Equatable { - let id: UUID - let title: String - let content: String - let isUserEdited: Bool - } - - private struct RootTokenBaselineKey: Equatable { - let id: UUID - let fullPath: String - let name: String - let isSystemRoot: Bool - } - - private struct ChatContextTokenBaselineCacheKey: Equatable { - let workspaceID: UUID? - let selectedChatPresetID: UUID? - let chatPreset: ChatPresetTokenBaselineKey - let resolvedContext: PromptContextTokenBaselineKey - let fileTreeOptionForChat: FileTreeOption - let codeMapUsageForChat: CodeMapUsage - let gitDiffInclusionModeForChat: GitDiffInclusionMode - let codeMapsGloballyDisabled: Bool - let filePathDisplayOption: FilePathDisplay - let selectedFilesSortMethod: SortMethod - let fileTreeSortMethod: SortMethod + let includeLocalDefinitionsInFileTree: Bool + let filePathDisplay: FilePathDisplay let onlyIncludeRootsWithSelectedFiles: Bool + let showCodeMapMarkers: Bool let includeDatetimeInUserInstructions: Bool let promptSectionsOrder: [PromptSection] - let disabledPromptSections: [PromptSection] + let disabledPromptSections: Set let duplicateUserInstructionsAtTop: Bool - let selectedPromptIDsForChat: [UUID] - let hasManualChatPromptSelection: Bool - let storedPrompts: [StoredPromptTokenBaselineKey] - let hierarchyGenerationSignature: UInt64 - let rootOrder: [RootTokenBaselineKey] - let selectionVersion: UInt64 - let slicesVersion: UInt64 - let autoCodemapVersion: UInt64 - let fileAPIsVersion: UInt64 - let fileSystemDeltaVersion: UInt64 - } - - private struct ChatContextTokenBaselineCache { - let key: ChatContextTokenBaselineCacheKey - let baseTokensWithoutPromptText: Int - /// The base token value only safely supports prompt deltas if it was derived - /// from a payload that actually contained a user-instructions prompt block. - let supportsPromptTextDeltas: Bool - /// Exact value for the empty-prompt shape when the cold miss observed it. - let emptyPromptTokenCount: Int? - } - - var chatPromptEntriesCache: (key: ChatPromptEntriesCacheKey, entries: [PromptFileEntry])? - var chatCodemapFileAPIs: [FileAPI] = [] + let tabTitle: String? + let renderingDate: Date + } + + var chatPromptEntriesCache: ( + key: ChatPromptEntriesCacheKey, + projection: WorkspacePromptProjectionAdapter.Projection, + entries: [PromptFileEntry] + )? + var chatPromptEntriesProjectionTask: Task? + var chatPromptEntriesProjectionKey: ChatPromptEntriesCacheKey? + var chatPromptEntriesProjectionGeneration: UInt64 = 0 var chatSelectionVersion: UInt64 = 0 var chatSlicesVersion: UInt64 = 0 var chatAutoCodemapVersion: UInt64 = 0 var chatFileAPIsVersion: UInt64 = 0 - private var chatFileSystemDeltaVersion: UInt64 = 0 - private var chatContextTokenBaselineCache: ChatContextTokenBaselineCache? // MARK: - Computed Properties for Token Counting (Legacy Support) @@ -2135,20 +2081,17 @@ class PromptViewModel: ObservableObject { self?.tokenCountingViewModel.markDirty(.codeMap) self?.workspaceManager?.markWorkspaceDirty() self?.updateActiveTabDirtyState() - self?.refreshChatCodemapFileAPIsFromStore() + self?.bumpChatPromptEntriesFileAPIsVersion() } .store(in: &cancellables) - refreshChatCodemapFileAPIsFromStore() - fileManager.fileSystemDeltasAppliedPublisher .receive(on: DispatchQueue.main) .sink { [weak self] _ in - // File content changes alter full-file prompt blocks even when selection and topology are unchanged. - // Track them in the cache key so an in-flight cold rebuild cannot re-store stale content. + // File changes can replace live view models even when selection is unchanged. + // Invalidate and cancel so an in-flight projection cannot publish stale identities. guard let self else { return } - chatFileSystemDeltaVersion &+= 1 - chatContextTokenBaselineCache = nil + invalidateChatPromptEntriesCache() } .store(in: &cancellables) @@ -2207,8 +2150,11 @@ class PromptViewModel: ObservableObject { } fileprivate func invalidateChatPromptEntriesCache() { + chatPromptEntriesProjectionGeneration &+= 1 + chatPromptEntriesProjectionTask?.cancel() + chatPromptEntriesProjectionTask = nil + chatPromptEntriesProjectionKey = nil chatPromptEntriesCache = nil - chatContextTokenBaselineCache = nil } private func bumpChatPromptEntriesSelectionVersion() { @@ -2231,18 +2177,6 @@ class PromptViewModel: ObservableObject { invalidateChatPromptEntriesCache() } - private func refreshChatCodemapFileAPIsFromStore() { - Task { [weak self] in - guard let self else { return } - let apis = await workspaceFileContextStore.allCodemapFileAPIs() - await MainActor.run { [weak self] in - guard let self else { return } - chatCodemapFileAPIs = apis - bumpChatPromptEntriesFileAPIsVersion() - } - } - } - // MARK: - Compose Tab Management enum ComposeTabCreationStrategy { @@ -2327,17 +2261,17 @@ class PromptViewModel: ObservableObject { in manager: WorkspaceManagerViewModel, workspaceIndex index: Int ) { - guard - let activeID = manager.workspaces[index].activeComposeTabID, - let activeIdx = manager.workspaces[index].composeTabs.firstIndex(where: { $0.id == activeID }) - else { return } + guard manager.workspaces.indices.contains(index) else { return } + let workspace = manager.workspaces[index] + guard let activeID = workspace.activeComposeTabID, + let activeTab = workspace.composeTabs.first(where: { $0.id == activeID }) else { return } - let currentName = manager.workspaces[index].composeTabs[activeIdx].name - let snapshot = manager.collectComposeTabSnapshot( - name: currentName, - base: manager.workspaces[index].composeTabs[activeIdx] - ) - manager.workspaces[index].composeTabs[activeIdx] = snapshot + let snapshot = manager.collectComposeTabSnapshot(name: activeTab.name, base: activeTab) + manager.mutateComposeTab( + workspaceID: workspace.id, + tabID: activeID, + touchDateModified: false + ) { $0 = snapshot } } /// Flush pending editor state and snapshot the active tab before transitioning away. @@ -2522,14 +2456,14 @@ class PromptViewModel: ObservableObject { flushAndSnapshotActiveTab(in: manager, workspaceIndex: index) } - manager.workspaces[index].composeTabs.append(newTab) - manager.workspaces[index].activeComposeTabID = newTab.id + guard let updatedWorkspace = manager.mutateWorkspace(id: workspace.id, { workspace in + workspace.composeTabs.append(newTab) + workspace.activeComposeTabID = newTab.id + }) else { return } activeComposeTabID = newTab.id dirtyTabIDs.remove(newTab.id) - manager.markWorkspaceDirty() - - loadComposeTabsFromWorkspace(manager.workspaces[index]) + loadComposeTabsFromWorkspace(updatedWorkspace) await withComposeTabSwitching(targetTabID: newTab.id) { await manager.applyComposeTabState(newTab) } @@ -2616,12 +2550,11 @@ class PromptViewModel: ObservableObject { flushAndSnapshotSourceTabIfNeeded(for: strategy, in: manager, workspaceIndex: index) guard let newTab = makeComposeTab(for: strategy, explicitName: name, workspaceIndex: index, manager: manager) else { return nil } - // Append but do NOT change activeComposeTabID - manager.workspaces[index].composeTabs.append(newTab) - // Keep existing active tab; just sync lists - loadComposeTabsFromWorkspace(manager.workspaces[index]) - - manager.markWorkspaceDirty() + // Append but do NOT change activeComposeTabID. + guard let updatedWorkspace = manager.mutateWorkspace(id: workspace.id, { + $0.composeTabs.append(newTab) + }) else { return nil } + loadComposeTabsFromWorkspace(updatedWorkspace) manager.pollAndSaveState() return newTab } @@ -2639,11 +2572,12 @@ class PromptViewModel: ObservableObject { // Flush pending editor state and snapshot current tab before switching flushAndSnapshotActiveTab(in: manager, workspaceIndex: index) - manager.workspaces[index].activeComposeTabID = id + guard let updatedWorkspace = manager.mutateWorkspace(id: workspace.id, { + $0.activeComposeTabID = id + }), let target = updatedWorkspace.composeTabs.first(where: { $0.id == id }) else { return } activeComposeTabID = id - loadComposeTabsFromWorkspace(manager.workspaces[index]) - guard let target = manager.workspaces[index].composeTabs.first(where: { $0.id == id }) else { return } + loadComposeTabsFromWorkspace(updatedWorkspace) activeTabApplyTask?.cancel() @@ -2663,14 +2597,13 @@ class PromptViewModel: ObservableObject { let trimmed = newName.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty, let manager = workspaceManager, - let workspace = manager.activeWorkspace, - let index = manager.workspaces.firstIndex(where: { $0.id == workspace.id }), - let tabIndex = manager.workspaces[index].composeTabs.firstIndex(where: { $0.id == id }) else { return } - manager.workspaces[index].composeTabs[tabIndex].name = trimmed - manager.workspaces[index].composeTabs[tabIndex].lastModified = Date() - manager.markWorkspaceDirty() + let workspace = manager.activeWorkspace else { return } + guard manager.mutateComposeTab(workspaceID: workspace.id, tabID: id, { + $0.name = trimmed + $0.lastModified = Date() + }) != nil, let updatedWorkspace = manager.workspace(withID: workspace.id) else { return } manager.pollAndSaveState() - loadComposeTabsFromWorkspace(manager.workspaces[index]) + loadComposeTabsFromWorkspace(updatedWorkspace) } @MainActor @@ -2715,16 +2648,12 @@ class PromptViewModel: ObservableObject { reason: ComposeTabRemovalReason = .close, expandCascade: Bool = true ) async { - guard !ids.isEmpty else { return } - guard - let manager = workspaceManager, - let workspace = manager.activeWorkspace, - let index = manager.workspaces.firstIndex(where: { $0.id == workspace.id }) + guard !ids.isEmpty, + let manager = workspaceManager, + let initialWorkspace = manager.activeWorkspace else { return } + let workspaceID = initialWorkspace.id - var tabs = manager.workspaces[index].composeTabs - let tabsBeforeClose = tabs - let originalCount = tabs.count var resolvedIDs = ids var stashedTabIDsToDelete: Set = [] if expandCascade, let composeTabCascadeResolver { @@ -2735,8 +2664,9 @@ class PromptViewModel: ObservableObject { } } - // Identify which tabs will actually be removed - let tabsBeingClosed = resolvedIDs.intersection(Set(tabs.map(\.id))) + guard let workspaceBeforeClose = manager.workspace(withID: workspaceID) else { return } + let tabsBeforeClose = workspaceBeforeClose.composeTabs + let tabsBeingClosed = resolvedIDs.intersection(Set(tabsBeforeClose.map(\.id))) guard !tabsBeingClosed.isEmpty else { if reason == .close, expandCascade, !stashedTabIDsToDelete.isEmpty { await deleteStashedTabs(withIDs: stashedTabIDsToDelete, expandCascade: false) @@ -2744,13 +2674,6 @@ class PromptViewModel: ObservableObject { return } - let fallbackActiveID: UUID? = { - guard let previousActiveID = manager.workspaces[index].activeComposeTabID, - tabsBeingClosed.contains(previousActiveID) else { return nil } - return adjacentTabID(afterClosing: previousActiveID, tabs: tabsBeforeClose, closingIDs: tabsBeingClosed) - }() - - // Notify listeners BEFORE mutation so they can cancel running tasks await notifyComposeTabsWillClose(tabsBeingClosed, reason: reason) await cleanupMCPStateForClosingTabs(tabsBeingClosed) #if DEBUG @@ -2762,63 +2685,53 @@ class PromptViewModel: ObservableObject { ) } #endif - if reason == .close { deleteGitDataForClosingTabs(tabIDs: tabsBeingClosed) } + guard let refreshedWorkspace = manager.workspace(withID: workspaceID) else { return } + let refreshedTabs = refreshedWorkspace.composeTabs + let actualClosingIDs = resolvedIDs.intersection(Set(refreshedTabs.map(\.id))) + guard !actualClosingIDs.isEmpty else { return } + let previousActiveID = refreshedWorkspace.activeComposeTabID + let fallbackActiveID: UUID? = { + guard let previousActiveID, actualClosingIDs.contains(previousActiveID) else { return nil } + return adjacentTabID( + afterClosing: previousActiveID, + tabs: refreshedTabs, + closingIDs: actualClosingIDs + ) + }() + + var tabs = refreshedTabs.filter { !actualClosingIDs.contains($0.id) } + var stashedTabs = refreshedWorkspace.stashedTabs if reason == .stash { - let refreshedTabs = manager.workspaces[index].composeTabs - for tabID in tabsBeingClosed { - guard let refreshedTab = refreshedTabs.first(where: { $0.id == tabID }) else { continue } - let stashedTab = StashedTab(tab: refreshedTab) - if let existingIndex = manager.workspaces[index].stashedTabs.firstIndex(where: { $0.tab.id == tabID }) { - manager.workspaces[index].stashedTabs[existingIndex] = stashedTab + for tab in refreshedTabs where actualClosingIDs.contains(tab.id) { + let stashed = StashedTab(tab: tab) + if let index = stashedTabs.firstIndex(where: { $0.tab.id == tab.id }) { + stashedTabs[index] = stashed } else { - manager.workspaces[index].stashedTabs.append(stashedTab) - } - } - tabs = refreshedTabs - } - - tabs.removeAll { resolvedIDs.contains($0.id) } - guard tabs.count != originalCount else { - if reason == .close, expandCascade, !stashedTabIDsToDelete.isEmpty { - await deleteStashedTabs(withIDs: stashedTabIDsToDelete, expandCascade: false) - } - return - } - - dirtyTabIDs.subtract(resolvedIDs) - - let previousActiveID = manager.workspaces[index].activeComposeTabID - manager.workspaces[index].composeTabs = tabs - - if tabs.isEmpty { - manager.workspaces[index].activeComposeTabID = nil - await appendReplacementBlankComposeTabIfNeeded(manager: manager, workspaceIndex: index) - loadComposeTabsFromWorkspace(manager.workspaces[index]) - #if DEBUG - for tabID in tabsBeingClosed { - AgentModePerfDiagnostics.markSidebarDeleteVisibleRemoved( - tabID: tabID, - source: "PromptViewModel.closeComposeTabs.currentComposeTabs", - fields: ["reason": String(describing: reason)] - ) + stashedTabs.append(stashed) } - #endif - manager.markWorkspaceDirty() - manager.pollAndSaveState() - if reason == .close, expandCascade, !stashedTabIDsToDelete.isEmpty { - await deleteStashedTabs(withIDs: stashedTabIDsToDelete, expandCascade: false) } - return } + dirtyTabIDs.subtract(actualClosingIDs) var newActiveID = previousActiveID - if let preferred = preferredActiveID, tabs.contains(where: { $0.id == preferred }) { - newActiveID = preferred - } else if let previousActiveID, resolvedIDs.contains(previousActiveID) { + if tabs.isEmpty { + guard let workspaceIndex = manager.workspaces.firstIndex(where: { $0.id == workspaceID }), + let blankTab = makeComposeTab( + for: .blank, + explicitName: nil, + workspaceIndex: workspaceIndex, + manager: manager + ) else { return } + tabs = [blankTab] + newActiveID = blankTab.id + dirtyTabIDs.remove(blankTab.id) + } else if let preferredActiveID, tabs.contains(where: { $0.id == preferredActiveID }) { + newActiveID = preferredActiveID + } else if let previousActiveID, actualClosingIDs.contains(previousActiveID) { if let fallbackActiveID, tabs.contains(where: { $0.id == fallbackActiveID }) { newActiveID = fallbackActiveID } else { @@ -2828,21 +2741,24 @@ class PromptViewModel: ObservableObject { newActiveID = tabs.first?.id } - manager.workspaces[index].activeComposeTabID = newActiveID + guard let updatedWorkspace = manager.mutateWorkspace(id: workspaceID, { workspace in + workspace.composeTabs = tabs + workspace.stashedTabs = stashedTabs + workspace.activeComposeTabID = newActiveID + }) else { return } activeComposeTabID = newActiveID - if newActiveID != previousActiveID, - let newActiveID, + if let newActiveID, + newActiveID != previousActiveID, let tab = tabs.first(where: { $0.id == newActiveID }) { await withComposeTabSwitching(targetTabID: newActiveID) { await manager.applyComposeTabState(tab) } } - - loadComposeTabsFromWorkspace(manager.workspaces[index]) + loadComposeTabsFromWorkspace(updatedWorkspace) #if DEBUG - for tabID in tabsBeingClosed { + for tabID in actualClosingIDs { AgentModePerfDiagnostics.markSidebarDeleteVisibleRemoved( tabID: tabID, source: "PromptViewModel.closeComposeTabs.currentComposeTabs", @@ -2850,7 +2766,6 @@ class PromptViewModel: ObservableObject { ) } #endif - manager.markWorkspaceDirty() manager.pollAndSaveState() if reason == .close, expandCascade, !stashedTabIDsToDelete.isEmpty { await deleteStashedTabs(withIDs: stashedTabIDsToDelete, expandCascade: false) @@ -2885,27 +2800,6 @@ class PromptViewModel: ObservableObject { return nil } - @MainActor - private func appendReplacementBlankComposeTabIfNeeded( - manager: WorkspaceManagerViewModel, - workspaceIndex: Int - ) async { - guard manager.workspaces[workspaceIndex].composeTabs.isEmpty else { return } - guard let blankTab = makeComposeTab( - for: .blank, - explicitName: nil, - workspaceIndex: workspaceIndex, - manager: manager - ) else { return } - manager.workspaces[workspaceIndex].composeTabs.append(blankTab) - manager.workspaces[workspaceIndex].activeComposeTabID = blankTab.id - activeComposeTabID = blankTab.id - dirtyTabIDs.remove(blankTab.id) - await withComposeTabSwitching(targetTabID: blankTab.id) { - await manager.applyComposeTabState(blankTab) - } - } - /// Deletes git diff snapshots associated with closing tabs (fire-and-forget to avoid UI blocking). /// Uses a single batch scan instead of per-tab scans for efficiency. @MainActor @@ -2937,9 +2831,10 @@ class PromptViewModel: ObservableObject { let clamped = max(0, min(destinationIndex, tabs.count - 1)) let item = tabs.remove(at: sourceIndex) tabs.insert(item, at: clamped) - manager.workspaces[index].composeTabs = tabs - loadComposeTabsFromWorkspace(manager.workspaces[index]) - manager.markWorkspaceDirty() + guard let updatedWorkspace = manager.mutateWorkspace(id: workspace.id, { + $0.composeTabs = tabs + }) else { return } + loadComposeTabsFromWorkspace(updatedWorkspace) manager.pollAndSaveState() } @@ -3096,48 +2991,41 @@ class PromptViewModel: ObservableObject { @MainActor func unstashTab(_ stashedTabID: UUID) async { - guard - let manager = workspaceManager, - let workspace = manager.activeWorkspace, - let index = manager.workspaces.firstIndex(where: { $0.id == workspace.id }) + guard let manager = workspaceManager, + let initialWorkspace = manager.activeWorkspace, + initialWorkspace.stashedTabs.contains(where: { $0.id == stashedTabID }), + let initialIndex = manager.workspaces.firstIndex(where: { $0.id == initialWorkspace.id }) else { return } - - // Find the stashed tab - guard let stashIndex = manager.workspaces[index].stashedTabs.firstIndex(where: { $0.id == stashedTabID }) else { return } + let workspaceID = initialWorkspace.id guard await ensureCapacityForNewComposeTab( in: manager, - workspaceIndex: index, + workspaceIndex: initialIndex, policy: .uiInteractive, - excluding: manager.workspaces[index].activeComposeTabID + excluding: initialWorkspace.activeComposeTabID ) else { return } + guard let refreshedIndex = manager.workspaces.firstIndex(where: { $0.id == workspaceID }) else { return } + flushAndSnapshotActiveTab(in: manager, workspaceIndex: refreshedIndex) - // Flush and snapshot current state before switching - flushAndSnapshotActiveTab(in: manager, workspaceIndex: index) - - let stashedTab = manager.workspaces[index].stashedTabs[stashIndex] + guard let refreshedWorkspace = manager.workspace(withID: workspaceID), + let stashedTab = refreshedWorkspace.stashedTabs.first(where: { $0.id == stashedTabID }) + else { return } var restoredTab = stashedTab.tab - guard !manager.workspaces[index].composeTabs.contains(where: { $0.id == restoredTab.id }) else { - return - } + guard !refreshedWorkspace.composeTabs.contains(where: { $0.id == restoredTab.id }) else { return } restoredTab.lastModified = Date() - // Remove from stashed tabs - manager.workspaces[index].stashedTabs.remove(at: stashIndex) - - // Add to compose tabs - manager.workspaces[index].composeTabs.append(restoredTab) - manager.workspaces[index].activeComposeTabID = restoredTab.id - manager.workspaces[index].dateModified = Date() - - // Reload state - loadComposeTabsFromWorkspace(manager.workspaces[index]) - currentStashedTabs = manager.workspaces[index].stashedTabs - - // Switch to the restored tab - await switchComposeTab(restoredTab.id) - - manager.markWorkspaceDirty() + guard let updatedWorkspace = manager.mutateWorkspace(id: workspaceID, { workspace in + workspace.stashedTabs.removeAll { $0.id == stashedTabID } + workspace.composeTabs.append(restoredTab) + workspace.activeComposeTabID = restoredTab.id + }) else { return } + activeComposeTabID = restoredTab.id + dirtyTabIDs.remove(restoredTab.id) + loadComposeTabsFromWorkspace(updatedWorkspace) + currentStashedTabs = updatedWorkspace.stashedTabs + await withComposeTabSwitching(targetTabID: restoredTab.id) { + await manager.applyComposeTabState(restoredTab) + } manager.pollAndSaveState() } @@ -3153,12 +3041,11 @@ class PromptViewModel: ObservableObject { @MainActor private func deleteStashedTabs(withIDs stashedTabIDs: Set, expandCascade: Bool) async { - guard !stashedTabIDs.isEmpty else { return } - guard - let manager = workspaceManager, - let workspace = manager.activeWorkspace, - let index = manager.workspaces.firstIndex(where: { $0.id == workspace.id }) + guard !stashedTabIDs.isEmpty, + let manager = workspaceManager, + let initialWorkspace = manager.activeWorkspace else { return } + let workspaceID = initialWorkspace.id var resolvedStashedTabIDs = stashedTabIDs var composeTabIDsToDelete: Set = [] @@ -3167,71 +3054,80 @@ class PromptViewModel: ObservableObject { resolvedStashedTabIDs.formUnion(cascadePlan.stashedTabIDs) composeTabIDsToDelete.formUnion(cascadePlan.composeTabIDs) } - if !composeTabIDsToDelete.isEmpty { - let composeTabsBeforeDelete = manager.workspaces[index].composeTabs - let composeTabIDsBeingDeleted = composeTabIDsToDelete.intersection(Set(composeTabsBeforeDelete.map(\.id))) + + if !composeTabIDsToDelete.isEmpty, + let workspaceBeforeDelete = manager.workspace(withID: workspaceID) + { + let tabsBeforeDelete = workspaceBeforeDelete.composeTabs + let composeTabIDsBeingDeleted = composeTabIDsToDelete.intersection(Set(tabsBeforeDelete.map(\.id))) if !composeTabIDsBeingDeleted.isEmpty { - let previousActiveID = manager.workspaces[index].activeComposeTabID - let fallbackActiveID: UUID? = { - guard let previousActiveID, - composeTabIDsBeingDeleted.contains(previousActiveID) - else { - return nil - } - return adjacentTabID( - afterClosing: previousActiveID, - tabs: composeTabsBeforeDelete, - closingIDs: composeTabIDsBeingDeleted - ) - }() await notifyComposeTabsWillClose(composeTabIDsBeingDeleted, reason: .close) await cleanupMCPStateForClosingTabs(composeTabIDsBeingDeleted) deleteGitDataForClosingTabs(tabIDs: composeTabIDsBeingDeleted) - var remainingComposeTabs = composeTabsBeforeDelete - remainingComposeTabs.removeAll { composeTabIDsBeingDeleted.contains($0.id) } - dirtyTabIDs.subtract(composeTabIDsBeingDeleted) - manager.workspaces[index].composeTabs = remainingComposeTabs - if remainingComposeTabs.isEmpty { - manager.workspaces[index].activeComposeTabID = nil - await appendReplacementBlankComposeTabIfNeeded(manager: manager, workspaceIndex: index) - } else { - var newActiveID = previousActiveID - if let previousActiveID, - composeTabIDsBeingDeleted.contains(previousActiveID) - { - if let fallbackActiveID, - remainingComposeTabs.contains(where: { $0.id == fallbackActiveID }) - { - newActiveID = fallbackActiveID - } else { - newActiveID = remainingComposeTabs.last?.id ?? remainingComposeTabs.first?.id - } - } else if newActiveID == nil { - newActiveID = remainingComposeTabs.first?.id + + guard let refreshedWorkspace = manager.workspace(withID: workspaceID) else { return } + let actualIDs = composeTabIDsToDelete.intersection(Set(refreshedWorkspace.composeTabs.map(\.id))) + let previousActiveID = refreshedWorkspace.activeComposeTabID + let fallbackActiveID = previousActiveID.flatMap { activeID in + actualIDs.contains(activeID) + ? adjacentTabID(afterClosing: activeID, tabs: refreshedWorkspace.composeTabs, closingIDs: actualIDs) + : nil + } + var remainingTabs = refreshedWorkspace.composeTabs.filter { !actualIDs.contains($0.id) } + dirtyTabIDs.subtract(actualIDs) + var newActiveID = previousActiveID + if remainingTabs.isEmpty { + guard let workspaceIndex = manager.workspaces.firstIndex(where: { $0.id == workspaceID }), + let blankTab = makeComposeTab( + for: .blank, + explicitName: nil, + workspaceIndex: workspaceIndex, + manager: manager + ) else { return } + remainingTabs = [blankTab] + newActiveID = blankTab.id + dirtyTabIDs.remove(blankTab.id) + } else if let previousActiveID, actualIDs.contains(previousActiveID) { + if let fallbackActiveID, remainingTabs.contains(where: { $0.id == fallbackActiveID }) { + newActiveID = fallbackActiveID + } else { + newActiveID = remainingTabs.last?.id ?? remainingTabs.first?.id } - manager.workspaces[index].activeComposeTabID = newActiveID - activeComposeTabID = newActiveID - if newActiveID != previousActiveID, - let newActiveID, - let tab = remainingComposeTabs.first(where: { $0.id == newActiveID }) - { - await withComposeTabSwitching(targetTabID: newActiveID) { - await manager.applyComposeTabState(tab) - } + } else if newActiveID == nil { + newActiveID = remainingTabs.first?.id + } + guard manager.mutateWorkspace(id: workspaceID, { workspace in + workspace.composeTabs = remainingTabs + workspace.activeComposeTabID = newActiveID + }) != nil else { return } + activeComposeTabID = newActiveID + if let newActiveID, + newActiveID != previousActiveID, + let tab = remainingTabs.first(where: { $0.id == newActiveID }) + { + await withComposeTabSwitching(targetTabID: newActiveID) { + await manager.applyComposeTabState(tab) } } } } - let stashedTabsToDelete = manager.workspaces[index].stashedTabs.filter { resolvedStashedTabIDs.contains($0.id) } - guard !stashedTabsToDelete.isEmpty else { return } - + guard let refreshedWorkspace = manager.workspace(withID: workspaceID) else { return } + let stashedTabsToDelete = refreshedWorkspace.stashedTabs.filter { resolvedStashedTabIDs.contains($0.id) } + guard !stashedTabsToDelete.isEmpty else { + if let updatedWorkspace = manager.workspace(withID: workspaceID) { + loadComposeTabsFromWorkspace(updatedWorkspace) + manager.pollAndSaveState() + } + return + } let tabIDs = Set(stashedTabsToDelete.map(\.tab.id)) await notifyComposeTabsWillClose(tabIDs, reason: .deleteStashed) deleteGitDataForClosingTabs(tabIDs: tabIDs) - manager.workspaces[index].stashedTabs.removeAll { resolvedStashedTabIDs.contains($0.id) } - loadComposeTabsFromWorkspace(manager.workspaces[index]) - manager.markWorkspaceDirty() + guard let updatedWorkspace = manager.mutateWorkspace(id: workspaceID, { workspace in + workspace.stashedTabs.removeAll { resolvedStashedTabIDs.contains($0.id) } + }) else { return } + loadComposeTabsFromWorkspace(updatedWorkspace) manager.pollAndSaveState() } @@ -3265,17 +3161,14 @@ class PromptViewModel: ObservableObject { @MainActor func setComposeTabPinned(_ pinned: Bool, for tabID: UUID) { - guard - let manager = workspaceManager, - let workspace = manager.activeWorkspace, - let index = manager.workspaces.firstIndex(where: { $0.id == workspace.id }), - let tabIndex = manager.workspaces[index].composeTabs.firstIndex(where: { $0.id == tabID }) - else { return } - guard manager.workspaces[index].composeTabs[tabIndex].isPinned != pinned else { return } + guard let manager = workspaceManager, + let workspace = manager.activeWorkspace, + workspace.composeTabs.first(where: { $0.id == tabID })?.isPinned != pinned else { return } + guard manager.mutateComposeTab(workspaceID: workspace.id, tabID: tabID, { + $0.isPinned = pinned + }) != nil, let updatedWorkspace = manager.workspace(withID: workspace.id) else { return } - manager.workspaces[index].composeTabs[tabIndex].isPinned = pinned - loadComposeTabsFromWorkspace(manager.workspaces[index]) - manager.markWorkspaceDirty() + loadComposeTabsFromWorkspace(updatedWorkspace) manager.pollAndSaveState() } @@ -3320,18 +3213,12 @@ class PromptViewModel: ObservableObject { func applyContextBuilderOverrides(_ overrides: ContextBuilderOverrides) async { guard let manager = workspaceManager, let workspace = manager.activeWorkspace, - let workspaceIndex = manager.workspaces.firstIndex(where: { $0.id == workspace.id }) else { return } - let activeTabID = manager.workspaces[workspaceIndex].activeComposeTabID ?? manager.workspaces[workspaceIndex].composeTabs.first?.id - guard - let tabID = activeTabID, - let tabIndex = manager.workspaces[workspaceIndex].composeTabs.firstIndex(where: { $0.id == tabID }) + let tabID = workspace.activeComposeTabID ?? workspace.composeTabs.first?.id, + workspace.composeTabs.first(where: { $0.id == tabID })?.contextOverrides != overrides else { return } - - guard manager.workspaces[workspaceIndex].composeTabs[tabIndex].contextOverrides != overrides else { return } - - manager.workspaces[workspaceIndex].composeTabs[tabIndex].contextOverrides = overrides - manager.workspaces[workspaceIndex].dateModified = Date() - manager.markWorkspaceDirty() + manager.mutateComposeTab(workspaceID: workspace.id, tabID: tabID) { + $0.contextOverrides = overrides + } } @MainActor @@ -3359,15 +3246,18 @@ class PromptViewModel: ObservableObject { await manager.createPreset(for: workspace, name: tab.name) - guard let presetIndex = manager.workspaces[index].presets.firstIndex(where: { $0.id == manager.workspaces[index].activePresetID }) else { return } - manager.workspaces[index].presets[presetIndex].capturesFileSelection = true - manager.workspaces[index].presets[presetIndex].capturesFileTreeExpansion = true - manager.workspaces[index].presets[presetIndex].capturesSelectedPrompts = true - manager.workspaces[index].presets[presetIndex].selectedFilePaths = tab.selection.selectedPaths - manager.workspaces[index].presets[presetIndex].expandedFolders = tab.expandedFolders - manager.workspaces[index].presets[presetIndex].selectedPromptIDs = tab.selectedMetaPromptIDs - manager.workspaces[index].presets[presetIndex].lastUpdated = Date() - manager.markWorkspaceDirty() + guard let updatedWorkspace = manager.workspace(withID: workspace.id), + let activePresetID = updatedWorkspace.activePresetID else { return } + manager.mutateWorkspace(id: workspace.id) { workspace in + guard let presetIndex = workspace.presets.firstIndex(where: { $0.id == activePresetID }) else { return } + workspace.presets[presetIndex].capturesFileSelection = true + workspace.presets[presetIndex].capturesFileTreeExpansion = true + workspace.presets[presetIndex].capturesSelectedPrompts = true + workspace.presets[presetIndex].selectedFilePaths = tab.selection.selectedPaths + workspace.presets[presetIndex].expandedFolders = tab.expandedFolders + workspace.presets[presetIndex].selectedPromptIDs = tab.selectedMetaPromptIDs + workspace.presets[presetIndex].lastUpdated = Date() + } manager.pollAndSaveState() } @@ -3882,6 +3772,10 @@ class PromptViewModel: ObservableObject { return workspaceManager.composeTab(with: activeTabID)?.selection } + func activeComposeTabStoredSelectionForPromptProjection() -> StoredSelection { + activeComposeTabStoredSelectionSnapshot() ?? StoredSelection() + } + private func legacyRFMSnapshotSelectionForPromptPackaging() -> StoredSelection { // Legacy/test-only fallback for PromptViewModel instances that are not bound // to a WorkspaceManager/compose tab. Normal active copy/chat packaging reads @@ -3930,11 +3824,6 @@ class PromptViewModel: ObservableObject { } } - private func estimateTokens(for text: String) -> Int { - // This is a simple estimation. For more accurate results, you might want to use a proper tokenizer. - Int(Double(text.count) / 4.0) - } - // MARK: - Prompt Section Order Methods static func resolvedPromptSectionOrder(raw: String) -> [PromptSection] { @@ -3966,62 +3855,11 @@ class PromptViewModel: ObservableObject { // MARK: - Clipboard Operations func copyToClipboard() { - _ = true - // Capture all necessary properties before Task to minimize actor hopping - let promptContext = resolvePromptContext() - let selectionSnapshot = activeComposeTabStoredSelectionForPromptPackaging() - let metaInstructions = metaInstructions - let promptText = promptText - let filePathDisplayOption = filePathDisplayOption - let includeSavedPrompts = includeSavedPromptsInClipboard - let includeUserPrompt = includeUserPromptInClipboard - let includeDatetime = includeDatetimeInUserInstructions - let promptSectionsOrder = promptSectionsOrder - let disabledPromptSections = disabledPromptSections - let duplicateUserInstructions = duplicateUserInstructionsAtTop - let includeFilesInClipboard = includeFilesInClipboard - - // NEW: Determine active compose tab title (fallback to empty if unavailable) - let tabTitleForClipboard: String = { - if let tabID = self.activeComposeTabID, - let snapshot = self.workspaceManager?.composeTabSnapshot(for: tabID) - { - return snapshot.name - } - return "" - }() - + let request = currentClipboardPackagingRequest() Task { - let preAssembly = await self.preAssemblePromptContext( - cfg: promptContext, - selection: selectionSnapshot, - lookupContext: self.allLoadedWorkspaceLookupContext() - ) - let includeFiles = includeFilesInClipboard && !preAssembly.entries.isEmpty - - // Use captured values inside the Task - let clipboardContent = await PromptPackagingService.generateClipboardContent( - metaInstructions: metaInstructions, - userInstructions: promptText, - files: preAssembly.entries, - fileTreeContent: preAssembly.fileTreeContent, - gitDiff: preAssembly.gitDiff, - includeSavedPrompts: includeSavedPrompts, - includeFiles: includeFiles, - includeUserPrompt: includeUserPrompt, - filePathDisplay: filePathDisplayOption, - codemapSnapshots: preAssembly.codemapSnapshots, - includeDatetimeInUserInstructions: includeDatetime, - promptSectionsOrder: promptSectionsOrder, - disabledPromptSections: disabledPromptSections, - duplicateUserInstructionsAtTop: duplicateUserInstructions, - tabTitle: tabTitleForClipboard - ) - - await MainActor.run { - NSPasteboard.general.clearContents() - NSPasteboard.general.setString(clipboardContent, forType: .string) - } + let payload = await buildClipboardPayload(for: request, source: .activeLive) + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(payload.text, forType: .string) } } @@ -4758,6 +4596,44 @@ class PromptViewModel: ObservableObject { selectionOverride: StoredSelection? = nil, lookupContextOverride: WorkspaceLookupContext? = nil ) async -> AIMessage { + let source: TokenProjection.Source = if overridePromptConfig == nil, + overrideChatPreset == nil, + overrideMode == nil, + gitInclusionOverride == nil, + gitBaseOverride == nil, + selectionOverride == nil, + lookupContextOverride == nil + { + .activeLive + } else { + .virtualRecomputed + } + return await packagePromptResult( + conversation: conversation, + overrideModel: overrideModel, + overridePromptConfig: overridePromptConfig, + overrideChatPreset: overrideChatPreset, + overrideMode: overrideMode, + gitInclusionOverride: gitInclusionOverride, + gitBaseOverride: gitBaseOverride, + selectionOverride: selectionOverride, + lookupContextOverride: lookupContextOverride, + tokenSource: source + ).message + } + + func packagePromptResult( + conversation: [ConversationEntry], + overrideModel: AIModel? = nil, + overridePromptConfig: PromptContextResolved? = nil, + overrideChatPreset: ChatPreset? = nil, + overrideMode: PlanActMode? = nil, + gitInclusionOverride: GitInclusion? = nil, + gitBaseOverride: String? = nil, + selectionOverride: StoredSelection? = nil, + lookupContextOverride: WorkspaceLookupContext? = nil, + tokenSource: TokenProjection.Source + ) async -> PackagedPromptResult { // Use pro file edit based on the specified or current chat preset let preset = overrideChatPreset ?? currentChatPreset() var resolvedConfig: PromptContextResolved = { @@ -4860,7 +4736,7 @@ class PromptViewModel: ObservableObject { return metaInstructionsForChat }() - return PromptPackagingService.buildAIMessage( + let message = PromptPackagingService.buildAIMessage( systemPrompt: systemPrompt, metaInstructions: metaForThisChat, fileTree: fileTreeString, @@ -4870,7 +4746,12 @@ class PromptViewModel: ObservableObject { temperature: temperature, promptSectionsOrder: promptSectionsOrder, disabledPromptSections: disabledPromptSections, - duplicateUserInstructionsAtTop: duplicateUserInstructionsAtTop + duplicateUserInstructionsAtTop: duplicateUserInstructionsAtTop, + tailAssemblyStrategy: .coreStandardChat + ) + return PackagedPromptResult( + message: message, + exactPayload: PromptPackagingService.exactChatPayload(for: message, source: tokenSource) ) } @@ -5149,6 +5030,7 @@ class PromptViewModel: ObservableObject { private func handleCodeMapsGloballyDisabledChanged(_ disabled: Bool) { guard codeMapsGloballyDisabled != disabled else { return } codeMapsGloballyDisabled = disabled + invalidateChatPromptEntriesCache() tokenCountingViewModel.markDirty(.codeMap.union(.fileTree)) isDirty = true Task { @@ -5186,6 +5068,7 @@ class PromptViewModel: ObservableObject { deinit { activeTabApplyTask?.cancel() + chatPromptEntriesProjectionTask?.cancel() cancellables.removeAll() /* // Stop any background timer/work owned by the token counter. @@ -5400,193 +5283,221 @@ extension PromptViewModel { ) } - /// Builds clipboard content using a resolved configuration without mutating any AppStorage/UI state. - func buildClipboard( - for inputConfig: PromptContextResolved, - promptTextOverride: String? = nil, - selectionOverride: StoredSelection? = nil, - includeLocalDefinitionsInFileTree: Bool = false - ) async -> String { - let cfg = applyingGlobalCodeMapOverride(inputConfig) - let promptText = promptTextOverride ?? promptText - let effectiveSelection = selectionOverride ?? activeComposeTabStoredSelectionForPromptPackaging() - let preAssembly = await preAssemblePromptContext( - cfg: cfg, - selection: effectiveSelection, - lookupContext: allLoadedWorkspaceLookupContext(), - includeLocalDefinitionsInFileTree: includeLocalDefinitionsInFileTree + private func activeComposeTabTitleForPromptPackaging() -> String? { + guard let tabID = activeComposeTabID, + let snapshot = workspaceManager?.composeTabSnapshot(for: tabID) + else { return nil } + return snapshot.name + } + + private func currentClipboardPackagingRequest() -> ClipboardPackagingRequest { + ClipboardPackagingRequest( + config: applyingGlobalCodeMapOverride(resolvePromptContext()), + selection: activeComposeTabStoredSelectionForPromptPackaging(), + promptText: promptText, + metaInstructions: metaInstructions, + includeSavedPrompts: includeSavedPromptsInClipboard, + includeFiles: includeFilesInClipboard, + includeUserPrompt: includeUserPromptInClipboard, + includeLocalDefinitionsInFileTree: false, + filePathDisplay: filePathDisplayOption, + onlyIncludeRootsWithSelectedFiles: onlyIncludeRootsWithSelectedFiles, + showCodeMapMarkers: !codeMapsGloballyDisabled, + includeDatetimeInUserInstructions: includeDatetimeInUserInstructions, + promptSectionsOrder: promptSectionsOrder, + disabledPromptSections: disabledPromptSections, + duplicateUserInstructionsAtTop: duplicateUserInstructionsAtTop, + tabTitle: activeComposeTabTitleForPromptPackaging(), + renderingDate: Date() ) + } - // 2.5) Meta prompts assembly. - let combinedMeta = metaInstructions(for: cfg) - let includeMetaBlock = !combinedMeta.isEmpty - - // 3) Generate clipboard string via existing packaging service - return await PromptPackagingService.generateClipboardContent( + private func clipboardPackagingRequest( + for inputConfig: PromptContextResolved, + promptTextOverride: String?, + selectionOverride: StoredSelection?, + includeLocalDefinitionsInFileTree: Bool, + tabTitle: String?, + renderingDate: Date + ) -> ClipboardPackagingRequest { + let config = applyingGlobalCodeMapOverride(inputConfig) + let combinedMeta = metaInstructions(for: config) + return ClipboardPackagingRequest( + config: config, + selection: selectionOverride ?? activeComposeTabStoredSelectionForPromptPackaging(), + promptText: promptTextOverride ?? promptText, metaInstructions: combinedMeta, - userInstructions: cfg.includeUserPrompt ? promptText : "", - files: preAssembly.entries, - fileTreeContent: preAssembly.fileTreeContent, - gitDiff: preAssembly.gitDiff, - includeSavedPrompts: includeMetaBlock, - includeFiles: cfg.includeFiles, - includeUserPrompt: cfg.includeUserPrompt, + includeSavedPrompts: !combinedMeta.isEmpty, + includeFiles: config.includeFiles, + includeUserPrompt: config.includeUserPrompt, + includeLocalDefinitionsInFileTree: includeLocalDefinitionsInFileTree, filePathDisplay: filePathDisplayOption, - codemapSnapshots: preAssembly.codemapSnapshots, + onlyIncludeRootsWithSelectedFiles: onlyIncludeRootsWithSelectedFiles, + showCodeMapMarkers: !codeMapsGloballyDisabled, includeDatetimeInUserInstructions: includeDatetimeInUserInstructions, promptSectionsOrder: promptSectionsOrder, disabledPromptSections: disabledPromptSections, - duplicateUserInstructionsAtTop: duplicateUserInstructionsAtTop + duplicateUserInstructionsAtTop: duplicateUserInstructionsAtTop, + tabTitle: tabTitle, + renderingDate: renderingDate ) } - /// Estimates the token count for the current Copy context (what would be copied now) - /// Uses the resolved copy preset (including manual overrides) and builds the exact clipboard payload. - func calculateTokensForCopyContext() async -> Int { - await calculateTokensForCopyContext(using: currentCopyPreset()) - } + private func buildClipboardPayload( + for request: ClipboardPackagingRequest, + source: TokenProjection.Source + ) async -> PromptPackagingService.ExactRenderedPayload { + let store = workspaceFileContextStore + let accountingService = PromptContextAccountingService() + let resolution = await accountingService.resolveEntries( + selection: request.selection, + store: store, + rootScope: .allLoaded, + profile: .uiAssisted, + codeMapUsage: request.config.codeMapUsage + ) + let fileEntries = resolution.entries + let codemapSnapshots = await store.codemapSnapshotDictionary() + let (diffEntries, codeEntries) = PromptPackagingService.partitionPromptEntriesForGitDiff(fileEntries) + + let combinedTreeAndMap: String? + if request.config.rendersFileTree { + let fileTreeSnapshot = await store.makeFileTreeSelectionSnapshot( + selection: request.selection, + request: WorkspaceFileTreeSnapshotRequest( + mode: WorkspaceFileTreeSnapshotMode(fileTreeOption: request.config.effectiveFileTreeMode), + filePathDisplay: request.filePathDisplay, + onlyIncludeRootsWithSelectedFiles: request.onlyIncludeRootsWithSelectedFiles, + includeLegend: true, + showCodeMapMarkers: request.showCodeMapMarkers, + rootScope: .allLoaded + ), + profile: .uiAssisted + ) + let tree = CodeMapExtractor.generateFileTree(using: fileTreeSnapshot) + let defBlock: String + if request.includeLocalDefinitionsInFileTree { + let hasCodemapEntries = codeEntries.contains { + $0.isCodemap && codemapSnapshots[$0.file.id]?.fileAPI != nil + } + if hasCodemapEntries { + defBlock = "" + } else { + let rootInfos = fileTreeSnapshot.roots.map { + CodeMapExtractor.RootInfo( + standardizedRootFullPath: $0.standardizedRootPath, + displayName: $0.name + ) + } + let allFileAPIs = await store.allCodemapFileAPIs() + defBlock = CodeMapExtractor.buildLocalDefinitionBlockIfNeeded( + codeMapUsage: request.config.codeMapUsage, + selectedFiles: codeEntries.filter { !$0.isCodemap }.map(\.file), + allFileAPIs: allFileAPIs, + filePathDisplay: request.filePathDisplay, + roots: rootInfos + ).text + } + } else { + defBlock = "" + } + let combined = [tree, defBlock].filter { !$0.isEmpty }.joined(separator: "\n\n") + combinedTreeAndMap = combined.isEmpty ? nil : combined + } else { + combinedTreeAndMap = nil + } - /// Estimates the token count for a specific Copy preset without mutating UI state. - func calculateTokensForCopyContext(using preset: CopyPreset, promptTextOverride: String? = nil) async -> Int { - let cfg = resolvePromptContext(preset, custom: workingCopyCustomizations) - let text = await buildClipboard(for: cfg, promptTextOverride: promptTextOverride) - return estimateTokens(for: text) - } - - private func chatPresetTokenBaselineKey(_ preset: ChatPreset) -> ChatPresetTokenBaselineKey { - ChatPresetTokenBaselineKey( - id: preset.id, - mode: preset.mode, - modelPresetName: preset.modelPresetName, - fileTreeMode: preset.fileTreeMode, - codeMapUsage: preset.codeMapUsage, - gitInclusion: preset.gitInclusion, - storedPromptIds: preset.storedPromptIds ?? [], - useStoredPromptsAsSystem: preset.useStoredPromptsAsSystem ?? false + let gitDiff: String? = await { + guard diffEntries.isEmpty else { return nil } + switch request.config.gitInclusion { + case .none: + return nil + case .selected: + let selectedPaths = await WorkspaceGitDiffSelectionResolver.selectedGitDiffPaths( + for: request.selection, + store: store, + rootScope: .allLoaded, + folderPolicy: .expandFolders, + profile: .uiAssisted, + allowFilesystemFallback: WorkspaceLookupRootScope.allLoaded.allowsSelectedGitDiffFilesystemFallback + ) + return await gitViewModel.getDiffForAbsolutePaths(selectedPaths, forceRefreshStatus: true) + case .complete: + return await gitViewModel.getDiffUsing(inclusionMode: .all, forceRefreshStatus: true) + } + }() + + let text = await PromptPackagingService.generateClipboardContent( + metaInstructions: request.metaInstructions, + userInstructions: request.includeUserPrompt ? request.promptText : "", + files: fileEntries, + fileTreeContent: combinedTreeAndMap, + gitDiff: gitDiff, + includeSavedPrompts: request.includeSavedPrompts, + includeFiles: request.includeFiles && !fileEntries.isEmpty, + includeUserPrompt: request.includeUserPrompt, + filePathDisplay: request.filePathDisplay, + codemapSnapshots: codemapSnapshots, + includeDatetimeInUserInstructions: request.includeDatetimeInUserInstructions, + renderingDate: request.renderingDate, + promptSectionsOrder: request.promptSectionsOrder, + disabledPromptSections: request.disabledPromptSections, + duplicateUserInstructionsAtTop: request.duplicateUserInstructionsAtTop, + tabTitle: request.tabTitle ) + return PromptPackagingService.exactRenderedPayload(text, source: source) } - private func promptContextTokenBaselineKey(_ cfg: PromptContextResolved) -> PromptContextTokenBaselineKey { - PromptContextTokenBaselineKey( - includeFiles: cfg.includeFiles, - includeUserPrompt: cfg.includeUserPrompt, - includeMetaPrompts: cfg.includeMetaPrompts, - includeFileTree: cfg.includeFileTree, - fileTreeMode: cfg.fileTreeMode, - codeMapUsage: cfg.codeMapUsage, - gitInclusion: cfg.gitInclusion, - storedPromptIds: cfg.storedPromptIds ?? [] + /// Builds clipboard content using a resolved configuration without mutating any AppStorage/UI state. + func buildClipboard( + for inputConfig: PromptContextResolved, + promptTextOverride: String? = nil, + selectionOverride: StoredSelection? = nil, + includeLocalDefinitionsInFileTree: Bool = false + ) async -> String { + let request = clipboardPackagingRequest( + for: inputConfig, + promptTextOverride: promptTextOverride, + selectionOverride: selectionOverride, + includeLocalDefinitionsInFileTree: includeLocalDefinitionsInFileTree, + tabTitle: nil, + renderingDate: Date() ) + return await buildClipboardPayload(for: request, source: .virtualRecomputed).text } - private func chatContextTokenBaselineCacheKey( - chatPreset: ChatPreset, - config cfg: PromptContextResolved - ) -> ChatContextTokenBaselineCacheKey { - let disabledSections = disabledPromptSections.sorted { $0.rawValue < $1.rawValue } - let selectedPromptIDs = selectedPromptIDsForChat.sorted { $0.uuidString < $1.uuidString } - let storedPromptKeys = storedPrompts.map { - StoredPromptTokenBaselineKey( - id: $0.id, - title: $0.title, - content: $0.content, - isUserEdited: $0.isUserEdited - ) - } - let rootOrder = fileManager.visibleRootFolders.map { - RootTokenBaselineKey( - id: $0.id, - fullPath: $0.fullPath, - name: $0.name, - isSystemRoot: $0.isSystemRoot - ) - } - - return ChatContextTokenBaselineCacheKey( - workspaceID: currentWorkspaceID, - selectedChatPresetID: selectedChatPresetID, - chatPreset: chatPresetTokenBaselineKey(chatPreset), - resolvedContext: promptContextTokenBaselineKey(cfg), - fileTreeOptionForChat: fileTreeOptionForChat, - codeMapUsageForChat: codeMapUsageForChat, - gitDiffInclusionModeForChat: gitDiffInclusionModeForChat, - codeMapsGloballyDisabled: codeMapsGloballyDisabled, - filePathDisplayOption: filePathDisplayOption, - selectedFilesSortMethod: selectedFilesSortMethod, - fileTreeSortMethod: fileManager.currentSortMethod, - onlyIncludeRootsWithSelectedFiles: onlyIncludeRootsWithSelectedFiles, - includeDatetimeInUserInstructions: includeDatetimeInUserInstructions, - promptSectionsOrder: promptSectionsOrder, - disabledPromptSections: disabledSections, - duplicateUserInstructionsAtTop: duplicateUserInstructionsAtTop, - selectedPromptIDsForChat: selectedPromptIDs, - hasManualChatPromptSelection: hasManualChatPromptSelection, - storedPrompts: storedPromptKeys, - hierarchyGenerationSignature: fileManager.currentHierarchyGenerationSignature(), - rootOrder: rootOrder, - selectionVersion: chatSelectionVersion, - slicesVersion: chatSlicesVersion, - autoCodemapVersion: chatAutoCodemapVersion, - fileAPIsVersion: chatFileAPIsVersion, - fileSystemDeltaVersion: chatFileSystemDeltaVersion - ) + /// Estimates the exact rendered token count for the current Copy context. + func calculateTokensForCopyContext() async -> Int { + let request = currentClipboardPackagingRequest() + return await buildClipboardPayload(for: request, source: .activeLive).projection.total } - private func promptTextDuplicateFactor(for cfg: PromptContextResolved) -> Int { - guard cfg.includeUserPrompt else { return 0 } - var factor = disabledPromptSections.contains(.userInstructions) ? 0 : 1 - if duplicateUserInstructionsAtTop { - factor += 1 - } - return factor + /// Estimates the exact rendered token count for a specific Copy preset without mutating UI state. + func calculateTokensForCopyContext(using preset: CopyPreset, promptTextOverride: String? = nil) async -> Int { + let config = resolvePromptContext(preset, custom: workingCopyCustomizations) + let request = clipboardPackagingRequest( + for: config, + promptTextOverride: promptTextOverride, + selectionOverride: nil, + includeLocalDefinitionsInFileTree: false, + tabTitle: nil, + renderingDate: Date() + ) + return await buildClipboardPayload(for: request, source: .virtualRecomputed).projection.total } - /// Estimates the token count for the current Chat context. - /// If the chat preset references a specific copy preset, that configuration is used; otherwise falls back to current state. + /// Estimates the exact package-level chat payload for the current Chat context. func calculateTokensForChatContext() async -> Int { let chatPreset = currentChatPreset() - // Prefer the chat preset's resolved configuration (includes git/meta/system flavor overrides), - // falling back to the current copy configuration only if unavailable. - let cfg: PromptContextResolved = resolvedPromptContext(from: chatPreset) ?? resolvePromptContext() - guard cfg.gitInclusion == .none else { - let text = await buildClipboard(for: cfg, includeLocalDefinitionsInFileTree: true) - return estimateTokens(for: text) - } - let cacheKey = chatContextTokenBaselineCacheKey(chatPreset: chatPreset, config: cfg) + let config = resolvedPromptContext(from: chatPreset) ?? resolvePromptContext() let promptTextSnapshot = promptText - let promptTextTokens = estimateTokens(for: promptTextSnapshot) - let duplicateFactor = promptTextDuplicateFactor(for: cfg) - let hasPromptText = !promptTextSnapshot.isEmpty - - if let cache = chatContextTokenBaselineCache, cache.key == cacheKey { - if hasPromptText, cache.supportsPromptTextDeltas { - return cache.baseTokensWithoutPromptText + (promptTextTokens * duplicateFactor) - } - if !hasPromptText, let emptyPromptTokenCount = cache.emptyPromptTokenCount { - return emptyPromptTokenCount - } - } - - let text = await buildClipboard( - for: cfg, - promptTextOverride: promptTextSnapshot, - includeLocalDefinitionsInFileTree: true + let result = await packagePromptResult( + conversation: [ConversationEntry(role: .user, content: promptTextSnapshot)], + overridePromptConfig: config, + overrideChatPreset: chatPreset, + tokenSource: .activeLive ) - let tokenCount = estimateTokens(for: text) - - let currentChatPreset = currentChatPreset() - let currentCfg: PromptContextResolved = resolvedPromptContext(from: currentChatPreset) ?? resolvePromptContext() - let currentCacheKey = chatContextTokenBaselineCacheKey(chatPreset: currentChatPreset, config: currentCfg) - if currentCacheKey == cacheKey { - chatContextTokenBaselineCache = ChatContextTokenBaselineCache( - key: cacheKey, - baseTokensWithoutPromptText: max(0, tokenCount - (promptTextTokens * duplicateFactor)), - supportsPromptTextDeltas: hasPromptText && duplicateFactor > 0, - emptyPromptTokenCount: hasPromptText ? nil : tokenCount - ) - } - - return tokenCount + return result.exactPayload.projection.total } /// Resolve, build and place the clipboard content for a specific copy preset without mutating UI state. @@ -5795,8 +5706,3 @@ enum PromptError: Error { enum AIResponseError: Error { case invalidData } - -enum FilePathDisplay: String, CaseIterable { - case full = "Full" - case relative = "Relative" -} diff --git a/Sources/RepoPrompt/Features/Prompt/ViewModels/TokenCountingViewModel.swift b/Sources/RepoPrompt/Features/Prompt/ViewModels/TokenCountingViewModel.swift index 4061e6fee..e1993e1da 100644 --- a/Sources/RepoPrompt/Features/Prompt/ViewModels/TokenCountingViewModel.swift +++ b/Sources/RepoPrompt/Features/Prompt/ViewModels/TokenCountingViewModel.swift @@ -1,5 +1,6 @@ import Combine import Foundation +import RepoPromptCore import SwiftUI @MainActor @@ -61,31 +62,48 @@ class TokenCountingViewModel: ObservableObject { private let heavyDirtyKinds: DirtyKind = [.selection, .fileTree, .codeMap, .settings] private var pendingDirty: DirtyKind = [] - /// Cached components to support light, incremental recomputation. + /// Accepted projection state used by light, incremental recomputation. private var didComputeBaseline: Bool = false - private var lastBaseWithoutUserText: Int = 0 // Everything except user prompt/instructions - private var lastPromptTokens: Int = 0 // Tokens for prompt text only - private var lastDuplicatePromptTokens: Int = 0 // Duplicate prompt tokens (if setting is on) - private var lastInstructionsTokens: Int = 0 // Tokens for meta/stored instructions - private var lastGitDiffTokens: Int = 0 + private var acceptedSelectionProjection: WorkspaceSelectionProjection? + private var acceptedWorkspaceTokenViews: TokenProjectionService.WorkspaceViews? + private var publishedWorkspaceTokenProjection: TokenProjection? + private var acceptedHasSelectedArtifacts: Bool = false private var lastFileTreeTokens: Int = 0 + private var lastGitDiffText: String? // MARK: - Private Properties private static let tokenUpdateDebounceNanoseconds: UInt64 = 500_000_000 - private let tokenCalculationService = TokenCalculationService() + typealias ProjectionAdapterFactory = @MainActor (WorkspaceFileContextStore) -> WorkspacePromptProjectionAdapter + typealias AccountingOperation = ( + PromptContextAccountingRequest, + WorkspaceFileContextStore, + WorkspaceFileContextCapture + ) async throws -> PromptContextAccountingResult + typealias LightProjectionOperation = ( + WorkspaceSelectionProjection, + TokenProjection.Source, + TokenProjectionService.WorkspaceNonFileComponents + ) async throws -> TokenProjectionService.WorkspaceViews + private let promptContextAccountingService = PromptContextAccountingService() + private let projectionAdapterFactory: ProjectionAdapterFactory + private let accountingOperation: AccountingOperation? + private let lightProjectionOperation: LightProjectionOperation private var tokenUpdateDebounceTask: Task? private var updateTokenCountTask: Task? private var cancellables = Set() private var isTokenCountSchedulerActive = false private var isImmediateRecountInProgress = false private var tokenCountSchedulerGeneration: UInt64 = 0 + private var inputRevision: UInt64 = 0 + private var nextRecountRunID: UInt64 = 0 + private var activeRecountRunID: UInt64? private var selectionObservationRevision: UInt64 = 0 private var lastObservedSelectionObservationRevision: UInt64 = 0 - private var lastPredominantLanguage: String = "Swift" private var automaticRecountSuspendDepth: Int = 0 + private var heavyRecoveryAttempted = false // MARK: - Dependencies @@ -135,8 +153,22 @@ class TokenCountingViewModel: ObservableObject { // MARK: - Initialization - init() { - // Initialize with empty state + init( + projectionAdapterFactory: @escaping ProjectionAdapterFactory = { store in + WorkspacePromptProjectionAdapter(store: store) + }, + accountingOperation: AccountingOperation? = nil, + lightProjectionOperation: @escaping LightProjectionOperation = { selection, source, nonFile in + TokenProjectionService.workspaceComponentEstimates( + from: selection, + source: source, + nonFile: nonFile + ) + } + ) { + self.projectionAdapterFactory = projectionAdapterFactory + self.accountingOperation = accountingOperation + self.lightProjectionOperation = lightProjectionOperation } func configure( @@ -250,7 +282,6 @@ class TokenCountingViewModel: ObservableObject { tokenUpdateDebounceTask = nil updateTokenCountTask?.cancel() updateTokenCountTask = nil - await tokenCalculationService.shutdown() } private func scheduleTokenCountUpdateIfNeeded() { @@ -319,8 +350,15 @@ class TokenCountingViewModel: ObservableObject { } func markDirty(_ kind: DirtyKind) { + guard !kind.isEmpty else { return } + inputRevision &+= 1 pendingDirty.formUnion(kind) + if !kind.intersection(heavyDirtyKinds).isEmpty { + heavyRecoveryAttempted = false + invalidateAcceptedSelectionProjection() + } + let currentSelectionRevision = selectionObservationRevision if kind.contains(.selection), resolveCopyContextSnapshot().includeFiles, @@ -371,6 +409,9 @@ class TokenCountingViewModel: ObservableObject { "updatePending": "\(updateTokenCountTask != nil)", "immediateInProgress": "\(isImmediateRecountInProgress)", "didComputeBaseline": "\(didComputeBaseline)", + "inputRevision": "\(inputRevision)", + "activeRunID": activeRecountRunID.map(String.init) ?? "none", + "acceptedSelection": "\(acceptedSelectionProjection != nil)", "totalTokens": "\(totalTokenCount)", "fileTokens": "\(totalTokenCountFilesOnly)", "codeMapTokens": "\(codeMapTokenCount)", @@ -382,6 +423,24 @@ class TokenCountingViewModel: ObservableObject { private func debugSelectionFields(_ selection: StoredSelection) -> [String: String] { PromptTokenRecountDiagnostics.selectionFields(selection) } + + func processPendingRecountForTesting() async { + let kinds = pendingDirty + pendingDirty = [] + if !kinds.intersection(heavyDirtyKinds).isEmpty { + await performTokenCountOffMainThread() + } else { + await recalculateLight(kinds: kinds) + } + } + + var hasAcceptedSelectionProjectionForTesting: Bool { + acceptedSelectionProjection != nil + } + + var publishedTokenProjectionForTesting: TokenProjection? { + publishedWorkspaceTokenProjection + } #endif @MainActor @@ -440,50 +499,117 @@ class TokenCountingViewModel: ObservableObject { return fileManager?.snapshotSelection() ?? StoredSelection() } - private func allStoreFileRecords(from store: WorkspaceFileContextStore) async -> [WorkspaceFileRecord] { - let roots = await store.roots() - var records: [WorkspaceFileRecord] = [] - for root in roots { - await records.append(contentsOf: store.files(inRoot: root.id)) + private struct RecountRun { + let id: UInt64 + let inputRevision: UInt64 + } + + private func beginRecountRun() -> RecountRun { + nextRecountRunID &+= 1 + let run = RecountRun(id: nextRecountRunID, inputRevision: inputRevision) + activeRecountRunID = run.id + return run + } + + private func isCurrent(_ run: RecountRun) -> Bool { + !Task.isCancelled + && activeRecountRunID == run.id + && inputRevision == run.inputRevision + } + + private func finishRecountRun(_ run: RecountRun) { + if activeRecountRunID == run.id { + activeRecountRunID = nil } - return records } - private func predominantLanguage( - from entries: [ResolvedPromptFileEntry], - includeFiles: Bool, - codeMapUsage: CodeMapUsage - ) -> String { - guard includeFiles else { return "Swift" } - let languageFiles: [WorkspaceFileRecord] = if codeMapUsage == .selected { - // `.selected` renders explicitly selected files as codemap entries, so they - // should still participate in language inference like live selectedFiles did. - deduplicatedFilesPreservingOrder(entries.map(\.file)) - } else { - // For `.auto` and `.complete`, codemap entries can include unselected files; - // prefer full/sliced selected files to avoid workspace-wide language bias. - deduplicatedFilesPreservingOrder(entries.filter { !$0.isCodemap }.map(\.file)) + private func invalidateAcceptedSelectionProjection() { + acceptedSelectionProjection = nil + acceptedWorkspaceTokenViews = nil + acceptedHasSelectedArtifacts = false + didComputeBaseline = false + } + + private func selectedTokenProjection( + from views: TokenProjectionService.WorkspaceViews + ) -> TokenProjection { + views.userConfigured ?? views.normalized + } + + private func selectedIncludedFiles( + from selection: WorkspaceSelectionProjection + ) -> [WorkspaceSelectionProjection.IncludedFile] { + selection.alternate?.includedFiles ?? selection.normalizedFiles + } + + private func retryHeavyImmediatelyAfterRecoverableError( + _ error: Error, + run: RecountRun + ) async -> Bool { + guard isCurrent(run), !(error is CancellationError), !Task.isCancelled else { return false } + guard isRecoverableHeavyError(error), !heavyRecoveryAttempted else { return false } + heavyRecoveryAttempted = true + await performTokenCountOffMainThread(isRetry: true) + return true + } + + private func isRecoverableHeavyError(_ error: Error) -> Bool { + if let adapterError = error as? WorkspacePromptProjectionAdapter.Error { + switch adapterError { + case .missingTokenFacts, .unusedTokenFacts, .projectionProvenanceMismatch: + return true + case .missingSelectionProjection, .missingTokenProjection: + return false + } + } + if let projectionError = error as? WorkspaceContextProjectionError { + switch projectionError { + case .captureProvenanceMismatch, + .recordAssociationMismatch, + .codemapAssociationMismatch, + .materializationProvenanceMismatch, + .missingOccurrenceIDs, + .missingTokenFacts: + return true + case .duplicateRootID, + .rootAssociationMismatch, + .duplicateCodemapFileID, + .duplicateOccurrenceID, + .unexpectedOccurrenceIDs, + .invalidTokenFacts: + return false + } } - guard !languageFiles.isEmpty else { return "Swift" } - return SystemPromptService.predominantLanguage(from: languageFiles) + if error is PromptContextAccountingError { + return true + } + return true } - private func deduplicatedFilesPreservingOrder(_ files: [WorkspaceFileRecord]) -> [WorkspaceFileRecord] { - var seen = Set() - var result: [WorkspaceFileRecord] = [] - for file in files where seen.insert(file.id).inserted { - result.append(file) + private func allStoreFileRecords(from store: WorkspaceFileContextStore) async -> [WorkspaceFileRecord] { + let roots = await store.roots() + var records: [WorkspaceFileRecord] = [] + for root in roots { + await records.append(contentsOf: store.files(inRoot: root.id)) } - return result + return records } // MARK: - Token Calculation /// Heavy path (rebuild baseline and everything else). - private func performTokenCountOffMainThread() async { + private func performTokenCountOffMainThread(isRetry: Bool = false) async { + if !isRetry { + heavyRecoveryAttempted = false + } + let run = beginRecountRun() + defer { finishRecountRun(run) } #if DEBUG let calculateStartMS = PromptTokenRecountDiagnostics.start() - PromptTokenRecountDiagnostics.event("tokenRecount.calculate.begin", fields: debugTokenRecountStateFields()) + var beginFields = debugTokenRecountStateFields() + beginFields["runID"] = "\(run.id)" + beginFields["runInputRevision"] = "\(run.inputRevision)" + PromptTokenRecountDiagnostics.event("tokenRecount.calculate.begin", fields: beginFields) #endif guard let fileManager, let promptSource = getPromptText?(), @@ -504,149 +630,139 @@ class TokenCountingViewModel: ObservableObject { let copySnapshot = resolveCopyContextSnapshot() let includeFiles = copySnapshot.includeFiles - #if DEBUG - let selectionSnapshotStartMS = PromptTokenRecountDiagnostics.start() - #endif - let selectionAtStart = includeFiles ? currentStoredSelection(includeFiles: true) : StoredSelection() + let selectionAtStart = currentStoredSelection(includeFiles: true) #if DEBUG var selectionFields = debugSelectionFields(selectionAtStart) selectionFields["includeFiles"] = "\(includeFiles)" - selectionFields["duration"] = selectionSnapshotStartMS.map { PromptTokenRecountDiagnostics.formatElapsedMS(since: $0) } ?? "notMeasured" PromptTokenRecountDiagnostics.event("tokenRecount.calculate.selectionSnapshot", fields: selectionFields) #endif - let includeUserPrompt = copySnapshot.includeUserPrompt - let includeMetaPrompts = copySnapshot.includeMetaPrompts - let includeFileTree = copySnapshot.includeFileTree - - let promptText = includeUserPrompt ? promptSource : "" - // For MCP system prompts, always include them even if includeMetaPrompts is false - // (e.g., MCP Discover has includeMetaPrompts=false but still needs system prompt counted) - let selectedInstructionsText = includeMetaPrompts ? instructionsSource : "" - let duplicatePromptAtTop = includeUserPrompt ? copySnapshot.duplicateUserInstructionsAtTop : false + let promptText = copySnapshot.includeUserPrompt ? promptSource : "" + let selectedInstructionsText = copySnapshot.includeMetaPrompts ? instructionsSource : "" + let duplicatePromptAtTop = copySnapshot.includeUserPrompt + ? copySnapshot.duplicateUserInstructionsAtTop + : false let store = fileManager.workspaceFileContextStore - #if DEBUG - let allFilesStartMS = PromptTokenRecountDiagnostics.start() - PromptTokenRecountDiagnostics.event("tokenRecount.calculate.allFiles.begin") - #endif + let allFileRecords = await allStoreFileRecords(from: store) - #if DEBUG - PromptTokenRecountDiagnostics.event( - "tokenRecount.calculate.allFiles.end", - fields: [ - "files": "\(allFileRecords.count)", - "duration": allFilesStartMS.map { PromptTokenRecountDiagnostics.formatElapsedMS(since: $0) } ?? "notMeasured" - ] - ) - #endif - guard !Task.isCancelled else { - #if DEBUG - PromptTokenRecountDiagnostics.event("tokenRecount.calculate.cancelled", fields: ["phase": "allFiles", "duration": calculateStartMS.map { PromptTokenRecountDiagnostics.formatElapsedMS(since: $0) } ?? "notMeasured"]) - #endif - return - } - #if DEBUG - let codemapAPIsStartMS = PromptTokenRecountDiagnostics.start() - PromptTokenRecountDiagnostics.event("tokenRecount.calculate.codemapAPIs.begin") - #endif + guard isCurrent(run) else { return } let storeFileAPIs = await store.allCodemapFileAPIs() - #if DEBUG - PromptTokenRecountDiagnostics.event( - "tokenRecount.calculate.codemapAPIs.end", - fields: [ - "fileAPIs": "\(storeFileAPIs.count)", - "duration": codemapAPIsStartMS.map { PromptTokenRecountDiagnostics.formatElapsedMS(since: $0) } ?? "notMeasured" - ] - ) - #endif - guard !Task.isCancelled else { - #if DEBUG - PromptTokenRecountDiagnostics.event("tokenRecount.calculate.cancelled", fields: ["phase": "codemapAPIs", "duration": calculateStartMS.map { PromptTokenRecountDiagnostics.formatElapsedMS(since: $0) } ?? "notMeasured"]) - #endif - return - } - - // Cache the store-owned file APIs for reuse by legacy UI surfaces. - cachedFileAPIs = storeFileAPIs + guard isCurrent(run) else { return } - // Derive and publish the set of detected languages from store-owned file records. let detectedExts = allFileRecords.map { (($0.name as NSString).pathExtension).lowercased() } - let detectedLangs = detectedExts.compactMap { SyntaxManager.shared.extensionToLanguage[$0] } - scannedLanguages = Set(detectedLangs) - - let effectiveCodeMapUsage = copySnapshot.codeMapUsage - let accountingCodeMapUsage: CodeMapUsage = includeFiles ? effectiveCodeMapUsage : .none - let accountingSelection = includeFiles ? selectionAtStart : StoredSelection() - - let effectiveFileTreeOption: FileTreeOption = includeFileTree ? copySnapshot.fileTreeMode : .none + let detectedLanguages = Set(detectedExts.compactMap { SyntaxManager.shared.extensionToLanguage[$0] }) + let normalizedCodeMapUsage: CodeMapUsage = settings.codeMapsGloballyDisabled ? .none : .auto + let configuredCodeMapUsage: CodeMapUsage = settings.codeMapsGloballyDisabled + ? .none + : copySnapshot.codeMapUsage + let effectiveFileTreeOption: FileTreeOption = copySnapshot.includeFileTree + ? copySnapshot.fileTreeMode + : .none #if DEBUG PromptTokenRecountDiagnostics.event( "tokenRecount.calculate.context", fields: [ "includeFiles": "\(includeFiles)", - "includeFileTree": "\(includeFileTree)", + "includeFileTree": "\(copySnapshot.includeFileTree)", "fileTreeMode": "\(effectiveFileTreeOption)", - "codeMapUsage": "\(accountingCodeMapUsage)", + "normalizedCodeMapUsage": "\(normalizedCodeMapUsage)", + "configuredCodeMapUsage": "\(configuredCodeMapUsage)", "gitInclusion": "\(copySnapshot.gitInclusion)" ] ) #endif - let fileTreeInput: TokenCalculationFileTreeInput - if includeFileTree, effectiveFileTreeOption != .none { - #if DEBUG - let fileTreeStartMS = PromptTokenRecountDiagnostics.start() - PromptTokenRecountDiagnostics.event("tokenRecount.calculate.fileTree.begin") - #endif - let fileTreeSnapshot = await store.makeFileTreeSelectionSnapshot( - selection: accountingSelection, - request: WorkspaceFileTreeSnapshotRequest( - mode: WorkspaceFileTreeSnapshotMode(fileTreeOption: effectiveFileTreeOption), - filePathDisplay: settings.filePathDisplayOption, - onlyIncludeRootsWithSelectedFiles: settings.onlyIncludeRootsWithSelectedFiles, - includeLegend: true, - showCodeMapMarkers: !settings.codeMapsGloballyDisabled, - rootScope: .allLoaded - ), - profile: .uiAssisted + + let alternatePolicy = WorkspaceSelectionProjectionRequest.AlternatePolicy( + includeFiles: includeFiles, + codeMapUsage: configuredCodeMapUsage + ) + let fileTreeRequest = WorkspaceFileTreeSnapshotRequest( + mode: WorkspaceFileTreeSnapshotMode(fileTreeOption: effectiveFileTreeOption), + filePathDisplay: settings.filePathDisplayOption, + onlyIncludeRootsWithSelectedFiles: settings.onlyIncludeRootsWithSelectedFiles, + includeLegend: copySnapshot.includeFileTree, + showCodeMapMarkers: copySnapshot.includeFileTree && !settings.codeMapsGloballyDisabled, + rootScope: .allLoaded + ) + let adapter = projectionAdapterFactory(store) + let workspaceCapture: WorkspaceFileContextCapture + do { + workspaceCapture = try await adapter.captureWorkspaceContext( + selection: selectionAtStart, + codeMapUsage: normalizedCodeMapUsage, + filePathDisplay: settings.filePathDisplayOption, + alternatePolicy: alternatePolicy, + fileTreeRequest: fileTreeRequest ) + } catch { #if DEBUG PromptTokenRecountDiagnostics.event( - "tokenRecount.calculate.fileTree.end", + "tokenRecount.calculate.error", fields: [ - "roots": "\(fileTreeSnapshot.roots.count)", - "duration": fileTreeStartMS.map { PromptTokenRecountDiagnostics.formatElapsedMS(since: $0) } ?? "notMeasured" + "reason": "capture", + "error": "\(error)", + "duration": calculateStartMS.map { PromptTokenRecountDiagnostics.formatElapsedMS(since: $0) } ?? "notMeasured" ] ) #endif - fileTreeInput = .snapshot(fileTreeSnapshot) - } else { - fileTreeInput = .none - } - guard !Task.isCancelled else { - #if DEBUG - PromptTokenRecountDiagnostics.event("tokenRecount.calculate.cancelled", fields: ["phase": "fileTree", "duration": calculateStartMS.map { PromptTokenRecountDiagnostics.formatElapsedMS(since: $0) } ?? "notMeasured"]) - #endif + _ = await retryHeavyImmediatelyAfterRecoverableError(error, run: run) return } + guard isCurrent(run) else { return } + + let fileTreeInput: TokenCalculationFileTreeInput = if copySnapshot.includeFileTree, + effectiveFileTreeOption != .none + { + .snapshot(workspaceCapture.fileTree) + } else { + .none + } + let accountingRequest = PromptContextAccountingRequest( - selection: accountingSelection, + selection: selectionAtStart, promptText: promptText, selectedInstructionsText: selectedInstructionsText, duplicateUserInstructionsAtTop: duplicatePromptAtTop, fileTree: fileTreeInput, - codeMapUsage: accountingCodeMapUsage, + codeMapUsage: normalizedCodeMapUsage, filePathDisplay: settings.filePathDisplayOption, rootScope: .allLoaded, pathLocateProfile: .uiAssisted ) - #if DEBUG - let accountingStartMS = PromptTokenRecountDiagnostics.start() - PromptTokenRecountDiagnostics.event("tokenRecount.calculate.accounting.begin") - #endif - let accountingResult = await promptContextAccountingService.calculatePromptStats( - request: accountingRequest, - store: store - ) + let accountingResult: PromptContextAccountingResult + do { + if let accountingOperation { + accountingResult = try await accountingOperation( + accountingRequest, + store, + workspaceCapture + ) + } else { + accountingResult = try await promptContextAccountingService.calculatePromptStats( + request: accountingRequest, + store: store, + capture: workspaceCapture + ) + } + guard accountingResult.captureProvenance == workspaceCapture.provenance else { + throw WorkspacePromptProjectionAdapter.Error.projectionProvenanceMismatch + } + } catch { + #if DEBUG + PromptTokenRecountDiagnostics.event( + "tokenRecount.calculate.error", + fields: [ + "reason": "accounting", + "error": "\(error)", + "duration": calculateStartMS.map { PromptTokenRecountDiagnostics.formatElapsedMS(since: $0) } ?? "notMeasured" + ] + ) + #endif + _ = await retryHeavyImmediatelyAfterRecoverableError(error, run: run) + return + } + guard isCurrent(run) else { return } + #if DEBUG PromptTokenRecountDiagnostics.event( "tokenRecount.calculate.accounting.end", @@ -655,188 +771,220 @@ class TokenCountingViewModel: ObservableObject { "promptEntries": "\(accountingResult.promptFileEntrySnapshots.count)", "missingPaths": "\(accountingResult.missingPaths.count)", "invalidPaths": "\(accountingResult.invalidPaths.count)", - "codemapsUsed": "\(accountingResult.codemapSnapshotsUsed.count)", - "duration": accountingStartMS.map { PromptTokenRecountDiagnostics.formatElapsedMS(since: $0) } ?? "notMeasured" + "codemapsUsed": "\(accountingResult.codemapSnapshotsUsed.count)" ] ) #endif - guard !Task.isCancelled else { - #if DEBUG - PromptTokenRecountDiagnostics.event("tokenRecount.calculate.cancelled", fields: ["phase": "accounting", "duration": calculateStartMS.map { PromptTokenRecountDiagnostics.formatElapsedMS(since: $0) } ?? "notMeasured"]) - #endif - return - } - - let predominantLanguage = predominantLanguage( - from: accountingResult.resolvedEntries, - includeFiles: includeFiles, - codeMapUsage: accountingCodeMapUsage - ) - let result = accountingResult.tokenResult - // Git diff tokens: only count generated diffs from GitViewModel when no artifact files are selected. - // Artifact files (_git_data/*.diff/*.patch) are already counted as normal files in calculatePromptStats, - // so we don't double-count them here. gitDiffTokenCount represents ONLY generated diffs. + let detailResult = accountingResult.tokenResult let resolvedFileEntries = includeFiles ? accountingResult.resolvedEntries : [] let (diffEntries, _) = PromptPackagingService.partitionPromptEntriesForGitDiff(resolvedFileEntries) let hasSelectedArtifacts = !diffEntries.isEmpty - var gitDiffTokens = 0 - #if DEBUG - let gitDiffStartMS = PromptTokenRecountDiagnostics.start() - PromptTokenRecountDiagnostics.event( - "tokenRecount.calculate.gitDiff.begin", - fields: [ - "hasSelectedArtifacts": "\(hasSelectedArtifacts)", - "gitInclusion": "\(copySnapshot.gitInclusion)" - ] - ) - #endif - if !hasSelectedArtifacts, let gitViewModel { - // No artifact files selected - use GitViewModel to generate diff if git inclusion is enabled + let gitDiffText: String? = if hasSelectedArtifacts || copySnapshot.gitInclusion == .none || gitViewModel == nil { + nil + } else { switch copySnapshot.gitInclusion { case .none: - break + nil case .selected: - if let diff = await gitViewModel.getDiffUsing(inclusionMode: .selectedFiles) { - gitDiffTokens = TokenCalculationService.estimateTokens(for: diff) - } + await gitViewModel?.getDiffUsing(inclusionMode: .selectedFiles) case .complete: - if let diff = await gitViewModel.getDiffUsing(inclusionMode: .all) { - gitDiffTokens = TokenCalculationService.estimateTokens(for: diff) - } + await gitViewModel?.getDiffUsing(inclusionMode: .all) } } - #if DEBUG - PromptTokenRecountDiagnostics.event( - "tokenRecount.calculate.gitDiff.end", - fields: [ - "gitDiffTokens": "\(gitDiffTokens)", - "duration": gitDiffStartMS.map { PromptTokenRecountDiagnostics.formatElapsedMS(since: $0) } ?? "notMeasured" - ] + guard isCurrent(run) else { return } + + let componentBreakdown = TokenCalculationService.calculateComponentBreakdown( + promptText: promptText, + selectedInstructionsText: selectedInstructionsText, + fileTreeText: detailResult.fileTreeContent, + gitDiffText: gitDiffText, + metadataText: nil, + duplicateUserInstructionsAtTop: duplicatePromptAtTop + ) + let adapterProjection: WorkspacePromptProjectionAdapter.TokenAwareProjection + do { + adapterProjection = try await adapter.projectTokens( + capture: workspaceCapture, + codeMapUsage: normalizedCodeMapUsage, + filePathDisplay: settings.filePathDisplayOption, + alternatePolicy: alternatePolicy, + resolvedEntries: accountingResult.resolvedEntries, + promptFileEntrySnapshots: accountingResult.promptFileEntrySnapshots, + tokenProjectionInput: .activeLive(.init( + reportedTotal: detailResult.totalTokenCount + componentBreakdown.gitDiff, + prompt: componentBreakdown.promptDisplay, + fileTree: componentBreakdown.fileTree, + meta: componentBreakdown.instructions, + git: componentBreakdown.gitDiff, + requestedFileTreeEstimate: detailResult.fileTreeTokenCountRaw + )) ) - #endif - // When artifact files ARE selected, gitDiffTokens stays 0 - those files are counted as normal files in calculatePromptStats - guard !Task.isCancelled else { - #if DEBUG - PromptTokenRecountDiagnostics.event("tokenRecount.calculate.cancelled", fields: ["phase": "gitDiff", "duration": calculateStartMS.map { PromptTokenRecountDiagnostics.formatElapsedMS(since: $0) } ?? "notMeasured"]) - #endif - return - } - #if DEBUG - let consistencyStartMS = PromptTokenRecountDiagnostics.start() - #endif - if includeFiles, currentStoredSelection(includeFiles: true) != selectionAtStart { + } catch { #if DEBUG PromptTokenRecountDiagnostics.event( - "tokenRecount.calculate.selectionChanged", - fields: ["duration": consistencyStartMS.map { PromptTokenRecountDiagnostics.formatElapsedMS(since: $0) } ?? "notMeasured"] + "tokenRecount.calculate.error", + fields: [ + "reason": "projection", + "error": "\(error)", + "duration": calculateStartMS.map { PromptTokenRecountDiagnostics.formatElapsedMS(since: $0) } ?? "notMeasured" + ] ) #endif + _ = await retryHeavyImmediatelyAfterRecoverableError(error, run: run) + return + } + guard isCurrent(run) else { return } + + if currentStoredSelection(includeFiles: true) != selectionAtStart { markDirty(.selection) return } - #if DEBUG - PromptTokenRecountDiagnostics.event( - "tokenRecount.calculate.selectionConsistent", - fields: ["duration": consistencyStartMS.map { PromptTokenRecountDiagnostics.formatElapsedMS(since: $0) } ?? "notMeasured"] - ) - #endif + guard isCurrent(run) else { return } - let copyTotal = result.totalTokenCount + gitDiffTokens - let copyTokenString = String(format: "%.2fk", Double(copyTotal) / 1000.0) + let workspaceViews = TokenProjectionService.WorkspaceViews( + normalized: adapterProjection.tokens.normalized, + userConfigured: adapterProjection.tokens.userConfigured + ) + let selectedProjection = selectedTokenProjection(from: workspaceViews) + let components = selectedProjection.components + let filesContentTokens = components.filesContent ?? 0 + let codemapTokens = components.codemaps ?? 0 + let gitTokens = components.git ?? 0 + let fileTreeTokens = components.fileTree ?? 0 + let totalTokens = selectedProjection.total + let totalTokenString = String(format: "%.2fk", Double(totalTokens) / 1000.0) + let filesContentString = String(format: "%.2fk", Double(filesContentTokens) / 1000.0) + let gitTokenString = String(format: "%.2fk", Double(gitTokens) / 1000.0) + let projectionDetails = projectionDetailData( + selection: adapterProjection.selection, + totalFileTokens: components.files ?? 0 + ) + let selectedCharCount = includeFiles + ? detailResult.charCount + : promptText.count + + (duplicatePromptAtTop ? promptText.count : 0) + + selectedInstructionsText.count #if DEBUG - let publishStartMS = PromptTokenRecountDiagnostics.start() PromptTokenRecountDiagnostics.event( "tokenRecount.publish.begin", fields: [ + "runID": "\(run.id)", "resolvedEntries": "\(accountingResult.resolvedEntries.count)", - "fileTokenInfos": "\(result.fileTokenInfo.count)", - "folderTokenInfos": "\(result.folderTokenInfo.count)", - "totalTokens": "\(copyTotal)", - "fileTokens": "\(result.totalTokenCountFilesOnly)", - "codeMapTokens": "\(result.codeMapTokenCount)", - "fileTreeTokens": "\(result.fileTreeTokenCountRaw)" + "fileTokenInfos": "\(projectionDetails.fileTokenInfo.count)", + "folderTokenInfos": "\(projectionDetails.folderTokenInfo.count)", + "totalTokens": "\(totalTokens)", + "fileTokens": "\(filesContentTokens)", + "codeMapTokens": "\(codemapTokens)", + "fileTreeTokens": "\(fileTreeTokens)" ] ) #endif - fileTokenInfo = remapStoreFileTokenInfo( - result.fileTokenInfo, - resolvedEntries: accountingResult.resolvedEntries, - fileManager: fileManager - ) - folderTokenInfo = result.folderTokenInfo - fileTreeContent = result.fileTreeContent - codeMapContent = result.codeMapContent - lastFileTreeTokens = result.fileTreeTokenCountRaw - charCount = result.charCount - totalTokenCount = copyTotal - tokenCount = copyTokenString - tokenCountFilesOnly = result.tokenCountFilesOnlyString - totalTokenCountFilesOnly = result.totalTokenCountFilesOnly - codeMapFileCount = result.codeMapFileCount - codeMapTokenCount = result.codeMapTokenCount - lastPredominantLanguage = predominantLanguage - - gitDiffTokenCount = gitDiffTokens - gitDiffTokenCountString = String(format: "%.2fk", Double(gitDiffTokens) / 1000.0) - - let promptTokensLocal = TokenCalculationService.estimateTokens(for: promptText) - let instructionsTokensLocal = TokenCalculationService.estimateTokens(for: selectedInstructionsText) - let duplicatePromptTokensLocal = duplicatePromptAtTop ? promptTokensLocal : 0 - - lastBaseWithoutUserText = max( - 0, - result.totalTokenCount - promptTokensLocal - duplicatePromptTokensLocal - instructionsTokensLocal - ) - lastPromptTokens = promptTokensLocal - lastDuplicatePromptTokens = duplicatePromptTokensLocal - lastInstructionsTokens = instructionsTokensLocal - lastGitDiffTokens = gitDiffTokens - copyContextTotalTokens = copyTotal - copyContextTokenCountString = copyTokenString + + cachedFileAPIs = storeFileAPIs + scannedLanguages = detectedLanguages + fileTokenInfo = projectionDetails.fileTokenInfo + folderTokenInfo = projectionDetails.folderTokenInfo + fileTreeContent = detailResult.fileTreeContent + codeMapContent = projectionDetails.codeMapContent + lastFileTreeTokens = fileTreeTokens + charCount = selectedCharCount + totalTokenCount = totalTokens + tokenCount = totalTokenString + tokenCountFilesOnly = filesContentString + totalTokenCountFilesOnly = filesContentTokens + codeMapFileCount = projectionDetails.codeMapFileCount + codeMapTokenCount = codemapTokens + gitDiffTokenCount = gitTokens + gitDiffTokenCountString = gitTokenString + lastGitDiffText = gitDiffText + copyContextTotalTokens = totalTokens + copyContextTokenCountString = totalTokenString + acceptedSelectionProjection = adapterProjection.selection + acceptedWorkspaceTokenViews = workspaceViews + publishedWorkspaceTokenProjection = selectedProjection + acceptedHasSelectedArtifacts = hasSelectedArtifacts didComputeBaseline = true - #if DEBUG - PromptTokenRecountDiagnostics.event( - "tokenRecount.publish.apply.end", - fields: [ - "mappedFileTokenInfos": "\(fileTokenInfo.count)", - "duration": publishStartMS.map { PromptTokenRecountDiagnostics.formatElapsedMS(since: $0) } ?? "notMeasured" - ] - ) - #endif + heavyRecoveryAttempted = false tokenCalculationCompletedPublisher.send() #if DEBUG - PromptTokenRecountDiagnostics.event( - "tokenRecount.calculate.end", - fields: [ - "outcome": Task.isCancelled ? "cancelled" : "completed", - "duration": calculateStartMS.map { PromptTokenRecountDiagnostics.formatElapsedMS(since: $0) } ?? "notMeasured" - ] - ) + var endFields = debugTokenRecountStateFields() + endFields["runID"] = "\(run.id)" + endFields["duration"] = calculateStartMS.map { PromptTokenRecountDiagnostics.formatElapsedMS(since: $0) } ?? "notMeasured" + PromptTokenRecountDiagnostics.event("tokenRecount.calculate.end", fields: endFields) #endif } - private func remapStoreFileTokenInfo( - _ storeFileTokenInfo: [UUID: TokenInfo], - resolvedEntries: [ResolvedPromptFileEntry], - fileManager: WorkspaceFilesViewModel - ) -> [UUID: TokenInfo] { - var mapped: [UUID: TokenInfo] = [:] - for entry in resolvedEntries { - guard let tokenInfo = storeFileTokenInfo[entry.file.id], - let liveFile = fileManager.findFileByFullPath(entry.file.standardizedFullPath) - else { continue } - mapped[liveFile.id] = tokenInfo + private struct ProjectionDetailData { + let fileTokenInfo: [UUID: TokenInfo] + let folderTokenInfo: [String: TokenInfo] + let codeMapFileCount: Int + let codeMapContent: String + } + + private func projectionDetailData( + selection: WorkspaceSelectionProjection, + totalFileTokens: Int + ) -> ProjectionDetailData { + let includedFiles = selectedIncludedFiles(from: selection) + var fileCounts: [UUID: Int] = [:] + var fullCounts: [UUID: Int] = [:] + var codemapCounts: [UUID: Int] = [:] + var folderTokens: [String: Int] = [:] + var codemapContents: [String] = [] + var codeMapFileCount = 0 + + for file in includedFiles { + fileCounts[file.file.id, default: 0] += file.tokens + if let fullTokens = file.fullTokens { + if let existing = fullCounts[file.file.id] { + assert(existing == fullTokens, "Duplicate occurrences disagree on full token facts") + } + fullCounts[file.file.id] = max(fullCounts[file.file.id] ?? 0, fullTokens) + } + codemapCounts[file.file.id] = max(codemapCounts[file.file.id] ?? 0, file.codemapTokens) + + if file.mode == .codemap { + codeMapFileCount += 1 + if let content = file.codemapContent, !content.isEmpty { + codemapContents.append(content) + } + } + + let folderPath = (file.metadata.pathWithinRoot as NSString).deletingLastPathComponent + folderTokens[folderPath == "." ? "" : folderPath, default: 0] += file.tokens } - return mapped + + assert(fileCounts.values.reduce(0, +) == totalFileTokens, "Projection details must match aggregate file tokens") + var mappedFiles: [UUID: TokenInfo] = [:] + for (fileID, count) in fileCounts { + mappedFiles[fileID] = TokenInfo( + count: count, + fullCount: fullCounts[fileID] ?? 0, + codemapCount: codemapCounts[fileID] ?? 0, + totalTokens: totalFileTokens + ) + } + + let mappedFolders = folderTokens.reduce(into: [String: TokenInfo]()) { result, item in + result[item.key] = TokenInfo(count: item.value, totalTokens: totalFileTokens) + } + return ProjectionDetailData( + fileTokenInfo: mappedFiles, + folderTokenInfo: mappedFolders, + codeMapFileCount: codeMapFileCount, + codeMapContent: TokenCalculationService.composeCodemapContent(codemapContents) + ) } /// Light path (prompt text and/or meta instructions and/or git diff only). private func recalculateLight(kinds: DirtyKind) async { guard didComputeBaseline, + let acceptedSelectionProjection, + let acceptedViews = acceptedWorkspaceTokenViews, let promptSource = getPromptText?(), let instructionsSource = getSelectedInstructionsText?() else { @@ -844,82 +992,72 @@ class TokenCountingViewModel: ObservableObject { return } + let run = beginRecountRun() + defer { finishRecountRun(run) } let copySnapshot = resolveCopyContextSnapshot() - let includeUserPrompt = copySnapshot.includeUserPrompt - let includeMetaPrompts = copySnapshot.includeMetaPrompts - let promptText = includeUserPrompt ? promptSource : "" - let selectedInstructionsText = includeMetaPrompts ? instructionsSource : "" - let duplicatePrompt = includeUserPrompt ? copySnapshot.duplicateUserInstructionsAtTop : false - - let promptTokens = TokenCalculationService.estimateTokens(for: promptText) - let duplicatePromptTokens = duplicatePrompt ? promptTokens : 0 - let instructionsTokens = TokenCalculationService.estimateTokens(for: selectedInstructionsText) + let promptText = copySnapshot.includeUserPrompt ? promptSource : "" + let selectedInstructionsText = copySnapshot.includeMetaPrompts ? instructionsSource : "" + let duplicatePrompt = copySnapshot.includeUserPrompt + ? copySnapshot.duplicateUserInstructionsAtTop + : false - var gitDiffTokens = gitDiffTokenCount + var gitDiffText = lastGitDiffText if kinds.contains(.gitDiff) { - if let fileManager { - // Check if artifact files are selected - if so, they're already counted as normal files. - let hasSelectedArtifacts: Bool - if copySnapshot.includeFiles { - let store = fileManager.workspaceFileContextStore - let selection = currentStoredSelection(includeFiles: true) - let resolution = await promptContextAccountingService.resolveEntries( - selection: selection, - store: store, - rootScope: .allLoaded, - profile: .uiAssisted, - codeMapUsage: copySnapshot.includeFiles ? copySnapshot.codeMapUsage : .none - ) - let (diffEntries, _) = PromptPackagingService.partitionPromptEntriesForGitDiff(resolution.entries) - hasSelectedArtifacts = !diffEntries.isEmpty - } else { - hasSelectedArtifacts = false - } - - if hasSelectedArtifacts { - // Artifact files are selected - they're counted as normal files, not as gitDiffTokens - gitDiffTokens = 0 - } else if let gitViewModel { - // No artifact files - use GitViewModel to generate diff if git inclusion is enabled - switch copySnapshot.gitInclusion { - case .none: - gitDiffTokens = 0 - case .selected: - if let diff = await gitViewModel.getDiffUsing(inclusionMode: .selectedFiles) { - gitDiffTokens = TokenCalculationService.estimateTokens(for: diff) - } else { - gitDiffTokens = 0 - } - case .complete: - if let diff = await gitViewModel.getDiffUsing(inclusionMode: .all) { - gitDiffTokens = TokenCalculationService.estimateTokens(for: diff) - } else { - gitDiffTokens = 0 - } - } - } else { - gitDiffTokens = 0 - } + if acceptedHasSelectedArtifacts || copySnapshot.gitInclusion == .none || gitViewModel == nil { + gitDiffText = nil } else { - gitDiffTokens = 0 + switch copySnapshot.gitInclusion { + case .none: + gitDiffText = nil + case .selected: + gitDiffText = await gitViewModel?.getDiffUsing(inclusionMode: .selectedFiles) + case .complete: + gitDiffText = await gitViewModel?.getDiffUsing(inclusionMode: .all) + } } - gitDiffTokenCount = gitDiffTokens - gitDiffTokenCountString = String(format: "%.2fk", Double(gitDiffTokens) / 1000.0) - lastGitDiffTokens = gitDiffTokens } + guard isCurrent(run) else { return } - let mainTotal = lastBaseWithoutUserText + promptTokens + duplicatePromptTokens + instructionsTokens - let totalWithGit = mainTotal + gitDiffTokens - - let copyTokenString = String(format: "%.2fk", Double(totalWithGit) / 1000.0) - totalTokenCount = totalWithGit - tokenCount = copyTokenString - copyContextTotalTokens = totalWithGit - copyContextTokenCountString = copyTokenString - - lastPromptTokens = promptTokens - lastDuplicatePromptTokens = duplicatePromptTokens - lastInstructionsTokens = instructionsTokens + let componentBreakdown = TokenCalculationService.calculateComponentBreakdown( + promptText: promptText, + selectedInstructionsText: selectedInstructionsText, + fileTreeText: "", + gitDiffText: gitDiffText, + metadataText: nil, + duplicateUserInstructionsAtTop: duplicatePrompt + ) + let nonFile = TokenProjectionService.WorkspaceNonFileComponents( + prompt: componentBreakdown.promptDisplay, + fileTree: acceptedViews.normalized.components.fileTree ?? 0, + meta: componentBreakdown.instructions, + git: componentBreakdown.gitDiff, + other: acceptedViews.normalized.components.other ?? 0 + ) + let workspaceViews: TokenProjectionService.WorkspaceViews + do { + workspaceViews = try await lightProjectionOperation( + acceptedSelectionProjection, + .virtualRecomputed, + nonFile + ) + } catch { + return + } + let selectedProjection = selectedTokenProjection(from: workspaceViews) + let gitTokens = selectedProjection.components.git ?? 0 + let totalTokens = selectedProjection.total + let tokenString = String(format: "%.2fk", Double(totalTokens) / 1000.0) + guard isCurrent(run) else { return } + + totalTokenCount = totalTokens + tokenCount = tokenString + copyContextTotalTokens = totalTokens + copyContextTokenCountString = tokenString + gitDiffTokenCount = gitTokens + gitDiffTokenCountString = String(format: "%.2fk", Double(gitTokens) / 1000.0) + lastGitDiffText = gitDiffText + acceptedWorkspaceTokenViews = workspaceViews + publishedWorkspaceTokenProjection = selectedProjection tokenCalculationCompletedPublisher.send() } @@ -945,31 +1083,33 @@ class TokenCountingViewModel: ObservableObject { } func latestTokenBreakdown() -> TokenBreakdown { + if let projection = publishedWorkspaceTokenProjection { + return TokenBreakdown( + total: projection.total, + files: projection.components.files ?? 0, + prompt: projection.components.prompt ?? 0, + meta: projection.components.meta ?? 0, + fileTree: projection.components.fileTree ?? 0, + git: projection.components.git ?? 0, + other: projection.components.other ?? 0 + ) + } + let promptSource = getPromptText?() ?? "" let instructionsSource = getSelectedInstructionsText?() ?? "" - let promptTokens = didComputeBaseline - ? (lastPromptTokens + lastDuplicatePromptTokens) - : (promptSource.isEmpty ? 0 : TokenCalculationService.estimateTokens(for: promptSource)) - let metaTokens = didComputeBaseline - ? lastInstructionsTokens - : (instructionsSource.isEmpty ? 0 : TokenCalculationService.estimateTokens(for: instructionsSource)) - let gitTokens = didComputeBaseline ? lastGitDiffTokens : 0 - let fileTreeTokens = didComputeBaseline - ? lastFileTreeTokens - : (fileTreeContent.isEmpty ? 0 : TokenCalculationService.estimateTokens(for: fileTreeContent)) - let filesTokens = totalTokenCountFilesOnly - let total = didComputeBaseline - ? totalTokenCount - : (promptTokens + filesTokens + metaTokens + gitTokens + fileTreeTokens) - let otherTokens = max(total - (filesTokens + promptTokens + metaTokens + gitTokens + fileTreeTokens), 0) + let promptTokens = promptSource.isEmpty ? 0 : TokenCalculationService.estimateTokens(for: promptSource) + let metaTokens = instructionsSource.isEmpty ? 0 : TokenCalculationService.estimateTokens(for: instructionsSource) + let fileTreeTokens = fileTreeContent.isEmpty ? 0 : TokenCalculationService.estimateTokens(for: fileTreeContent) + let filesTokens = totalFileTokensDisplay + let total = promptTokens + filesTokens + metaTokens + fileTreeTokens return TokenBreakdown( total: total, files: filesTokens, prompt: promptTokens, meta: metaTokens, fileTree: fileTreeTokens, - git: gitTokens, - other: otherTokens + git: 0, + other: 0 ) } diff --git a/Sources/RepoPrompt/Features/Settings/ViewModels/APISettingsViewModel.swift b/Sources/RepoPrompt/Features/Settings/ViewModels/APISettingsViewModel.swift index 7da67209d..61149ad8f 100644 --- a/Sources/RepoPrompt/Features/Settings/ViewModels/APISettingsViewModel.swift +++ b/Sources/RepoPrompt/Features/Settings/ViewModels/APISettingsViewModel.swift @@ -1,4 +1,5 @@ import Combine +import RepoPromptCore import SwiftUI #if DEBUG @@ -446,7 +447,7 @@ public class APISettingsViewModel: ObservableObject { /// the published dictionaries. Safe to call from any context; publishes on the main actor. @MainActor func loadCompatibleBackendState( - accessMode: KeychainAccessMode = .nonInteractive(reason: .backgroundAvailabilityCheck) + accessMode: SecureStorageAccessMode = .nonInteractive(reason: .backgroundAvailabilityCheck) ) async { let previousAvailability = isClaudeFamilyModelProviderAvailable let previousActiveBackends = Set(ClaudeCodeCompatibleBackendID.allCases.filter { compatibleBackendIsActive($0) }) @@ -914,7 +915,7 @@ public class APISettingsViewModel: ObservableObject { @MainActor func loadStoredData( - accessMode: KeychainAccessMode = .nonInteractive(reason: .bulkSettingsLoad) + accessMode: SecureStorageAccessMode = .nonInteractive(reason: .bulkSettingsLoad) ) async { await loadAllKeys(accessMode: accessMode) // returns immediately; fetch tasks run in background hasLoadedStoredData = true @@ -924,7 +925,7 @@ public class APISettingsViewModel: ObservableObject { /// Loads stored data and calls the completion handler after models are fully updated @MainActor func loadStoredData( - accessMode: KeychainAccessMode = .nonInteractive(reason: .bulkSettingsLoad), + accessMode: SecureStorageAccessMode = .nonInteractive(reason: .bulkSettingsLoad), _ completion: @escaping () -> Void ) async { await loadAllKeys(accessMode: accessMode) @@ -935,7 +936,7 @@ public class APISettingsViewModel: ObservableObject { @MainActor func loadStoredDataIfNeeded( - accessMode: KeychainAccessMode = .nonInteractive(reason: .bulkSettingsLoad) + accessMode: SecureStorageAccessMode = .nonInteractive(reason: .bulkSettingsLoad) ) async { guard !hasLoadedStoredData, !isLoadingStoredData else { return } isLoadingStoredData = true @@ -943,7 +944,7 @@ public class APISettingsViewModel: ObservableObject { } private func diagnosticReason(for error: Error) -> APIKeychainAccessDiagnostic.Reason { - guard let keychainError = error as? KeychainService.KeychainError else { + guard let keychainError = error as? SecureStorageError else { return .unexpectedError } switch keychainError { @@ -974,7 +975,7 @@ public class APISettingsViewModel: ObservableObject { @MainActor private func loadStoredAPIKey( for provider: AIProviderType, - accessMode: KeychainAccessMode, + accessMode: SecureStorageAccessMode, currentValue: String = "", preserveExistingValueOnFailure: Bool = true ) async -> String { @@ -991,7 +992,7 @@ public class APISettingsViewModel: ObservableObject { @MainActor func loadAllKeys( - accessMode: KeychainAccessMode = .nonInteractive(reason: .bulkSettingsLoad) + accessMode: SecureStorageAccessMode = .nonInteractive(reason: .bulkSettingsLoad) ) async { // ── 0. Cancel previous background fetches ─────────────────────────────── openAIModelsTask?.cancel() diff --git a/Sources/RepoPrompt/Features/Settings/Views/PromptOrderingSettingsMenu.swift b/Sources/RepoPrompt/Features/Settings/Views/PromptOrderingSettingsMenu.swift index c3d4a3ec2..0e9d9654c 100644 --- a/Sources/RepoPrompt/Features/Settings/Views/PromptOrderingSettingsMenu.swift +++ b/Sources/RepoPrompt/Features/Settings/Views/PromptOrderingSettingsMenu.swift @@ -5,6 +5,7 @@ // Reworked 2025-04-16 - show greyed-out 2nd User Instructions row. // +import RepoPromptCore import SwiftUI struct PromptOrderSettingsView: View { diff --git a/Sources/RepoPrompt/Features/WorkspaceFiles/Models/FileSystemItems.swift b/Sources/RepoPrompt/Features/WorkspaceFiles/Models/FileSystemItems.swift index cceabdba0..fb71e7f35 100644 --- a/Sources/RepoPrompt/Features/WorkspaceFiles/Models/FileSystemItems.swift +++ b/Sources/RepoPrompt/Features/WorkspaceFiles/Models/FileSystemItems.swift @@ -1,54 +1,5 @@ import Foundation -protocol FileSystemItem: Identifiable, Equatable, Sendable { - var id: UUID { get } - var name: String { get } - var path: String { get } - var modificationDate: Date { get } -} - -struct Folder: FileSystemItem { - let id: UUID - let name: String - let path: String - let modificationDate: Date - - init(id: UUID = UUID(), name: String, path: String, modificationDate: Date) { - self.id = id - self.name = name - self.path = path - self.modificationDate = modificationDate - } - - static func == (lhs: Folder, rhs: Folder) -> Bool { - lhs.path == rhs.path - } -} - -extension FileSystemItem { - func relativePath(rootPath: String) -> String { - RelativePath.from(absolutePath: path, rootPath: rootPath) - } -} - -struct File: FileSystemItem { - let id: UUID - let name: String - let path: String - let modificationDate: Date - - init(id: UUID = UUID(), name: String, path: String, modificationDate: Date) { - self.id = id - self.name = name - self.path = path - self.modificationDate = modificationDate - } - - static func == (lhs: File, rhs: File) -> Bool { - lhs.path == rhs.path - } -} - enum FileTreeItem: Identifiable { case folder(String, [FileViewModel]) case file(FileViewModel) diff --git a/Sources/RepoPrompt/Features/WorkspaceFiles/ViewModels/FileViewModel.swift b/Sources/RepoPrompt/Features/WorkspaceFiles/ViewModels/FileViewModel.swift index b212e97bb..788e2a5a6 100644 --- a/Sources/RepoPrompt/Features/WorkspaceFiles/ViewModels/FileViewModel.swift +++ b/Sources/RepoPrompt/Features/WorkspaceFiles/ViewModels/FileViewModel.swift @@ -16,21 +16,6 @@ enum FilePreviewMode { case syntaxHighlighted } -enum FileContentFreshnessPolicy { - /// Trust the existing FileViewModel metadata/cache fast path. - case cachedMetadata - /// Validate disk metadata before trusting cached content; never return stale fallback on validation/load failure. - case validateDiskMetadata -} - -/// Snapshot of file content plus a stable in-memory revision for search cache identity. -struct FileSearchContentSnapshot { - let content: String? - let contentRevision: UInt64? - let modificationDate: Date - let isFresh: Bool -} - /// Snapshot of preview state computed by FileViewModel for safe consumption by views. /// This moves all preview policy decisions to the view model layer, away from SwiftUI views. struct FilePreviewSnapshot { diff --git a/Sources/RepoPrompt/Features/WorkspaceFiles/ViewModels/WorkspaceFilesViewModel.swift b/Sources/RepoPrompt/Features/WorkspaceFiles/ViewModels/WorkspaceFilesViewModel.swift index a2d374349..6221fd790 100644 --- a/Sources/RepoPrompt/Features/WorkspaceFiles/ViewModels/WorkspaceFilesViewModel.swift +++ b/Sources/RepoPrompt/Features/WorkspaceFiles/ViewModels/WorkspaceFilesViewModel.swift @@ -603,7 +603,7 @@ class WorkspaceFilesViewModel: ObservableObject { private var fileSystemSettingsCancellable: AnyCancellable? private var forceReloadOnNextFileSystemSettingsRefresh = false - private let selectionSliceCoordinator = SelectionSliceCoordinator() + private let selectionSliceCoordinator: SelectionSliceCoordinator private var currentSlicesByRoot: [String: [String: PartitionStore.StoredSlices]] = [:] @Published private(set) var selectionSlicesByFileID: [UUID: [LineRange]] = [:] private var sliceSnapshotRebuildDeferralDepth = 0 @@ -935,14 +935,13 @@ class WorkspaceFilesViewModel: ObservableObject { #endif private var workspaceStoreDeltaBridgeTask: Task? private var workspaceStoreCodemapBridgeTask: Task? - private let alwaysReadableHomeDirectoryURL: URL init( - alwaysReadableHomeDirectoryURL: URL? = nil, - workspaceFileContextStore: WorkspaceFileContextStore + workspaceFileContextStore: WorkspaceFileContextStore, + selectionSliceCoordinator: SelectionSliceCoordinator ) { - self.alwaysReadableHomeDirectoryURL = (alwaysReadableHomeDirectoryURL ?? FileManager.default.homeDirectoryForCurrentUser).standardizedFileURL self.workspaceFileContextStore = workspaceFileContextStore + self.selectionSliceCoordinator = selectionSliceCoordinator // If you store sortMethod in user defaults, do that here if let loaded = SortMethod(rawValue: storedSortMethod) { currentSortMethod = loaded @@ -962,10 +961,11 @@ class WorkspaceFilesViewModel: ObservableObject { } #if DEBUG - convenience init(alwaysReadableHomeDirectoryURL: URL? = nil) { + convenience init() { + let runtime = RepoPromptEmbeddedWorkspaceRuntimeFactory().makeRuntime() self.init( - alwaysReadableHomeDirectoryURL: alwaysReadableHomeDirectoryURL, - workspaceFileContextStore: WorkspaceFileContextStore() + workspaceFileContextStore: runtime.workspaceFileContextStore, + selectionSliceCoordinator: runtime.selectionSliceCoordinator ) } #endif @@ -6831,22 +6831,12 @@ class WorkspaceFilesViewModel: ObservableObject { let issue: PathResolutionIssue? } - struct ExternalReadableFile: Equatable { - let absolutePath: String - let displayPath: String - } - private struct ExplicitSystemPathResolution { let root: FolderViewModel let standardizedAbsolutePath: String let standardizedRelativePath: String } - enum ReadableFileHandle { - case workspace(FileViewModel) - case external(ExternalReadableFile) - } - enum RootAliasPrefixCheck { case notPrefixed case uniqueRoot(root: VisibleRootSnapshot, alias: String) @@ -9548,25 +9538,27 @@ class WorkspaceFilesViewModel: ObservableObject { throw FileManagerError.fileSystemServiceNotFoundWithContext(msg) } - return try await StoreBackedWorkspaceSearch.search( - pattern: pattern, - mode: mode, - isRegex: isRegex, - caseInsensitive: caseInsensitive, - maxPaths: maxPaths, - maxMatches: maxMatches, - paths: paths, - includeExtensions: includeExtensions, - excludePatterns: excludePatterns, - contextLines: contextLines, - wholeWord: wholeWord, - countOnly: countOnly, - fuzzySpaceMatching: fuzzySpaceMatching, - allowLiteralUnescapeFallback: allowLiteralUnescapeFallback, - rootScope: rootScope, - store: workspaceFileContextStore, - workspaceManager: workspaceManager - ) + return try await withEmbeddedWorkspaceRuntimeDiagnostics { + try await StoreBackedWorkspaceSearch.search( + pattern: pattern, + mode: mode, + isRegex: isRegex, + caseInsensitive: caseInsensitive, + maxPaths: maxPaths, + maxMatches: maxMatches, + paths: paths, + includeExtensions: includeExtensions, + excludePatterns: excludePatterns, + contextLines: contextLines, + wholeWord: wholeWord, + countOnly: countOnly, + fuzzySpaceMatching: fuzzySpaceMatching, + allowLiteralUnescapeFallback: allowLiteralUnescapeFallback, + rootScope: rootScope, + store: workspaceFileContextStore, + readinessSource: workspaceManager.map(WorkspaceManagerSearchReadinessSource.init) + ) + } } } @@ -10695,117 +10687,6 @@ extension WorkspaceFilesViewModel { ) } - @MainActor - func resolveReadableFileForUserInput( - _ userPath: String, - profile: PathLocateProfile = .mcpRead, - rootScopeOverride: LookupRootScope? = nil - ) async -> ReadableFileHandle? { - let trimmed = normalizeUserInputPath(userPath) - .trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { return nil } - - if profile == .mcpRead, let explicitSystemFile = resolveExplicitSystemFile(trimmed) { - return .workspace(explicitSystemFile) - } - - if let workspaceFile = await resolveFileForUserInput( - trimmed, - profile: profile, - rootScopeOverride: rootScopeOverride - ) { - return .workspace(workspaceFile) - } - - guard trimmed.hasPrefix("/") else { return nil } - return resolveAlwaysReadableExternalFile(atAbsolutePath: trimmed).map { .external($0) } - } - - @MainActor - func resolveAlwaysReadableExternalFolderDisplayPath(_ userPath: String) -> String? { - let normalized = normalizeUserInputPath(userPath).trimmingCharacters(in: .whitespacesAndNewlines) - guard normalized.hasPrefix("/") else { return nil } - guard isAlwaysReadableExternalPath(normalized) else { return nil } - - let absolutePath = normalizedAlwaysReadableAbsolutePath(for: normalized) - guard isAlwaysReadableExternalPath(absolutePath) else { return nil } - var isDirectory: ObjCBool = false - guard FileManager.default.fileExists(atPath: absolutePath, isDirectory: &isDirectory), isDirectory.boolValue else { - return nil - } - return AgentSupportDirectoryCatalog.displayPath( - for: absolutePath, - homeDirectoryURL: alwaysReadableHomeDirectoryURL - ) - } - - @MainActor - func displayPath(forExternalPath userPath: String) -> String { - AgentSupportDirectoryCatalog.displayPath( - for: normalizeUserInputPath(userPath), - homeDirectoryURL: alwaysReadableHomeDirectoryURL - ) - } - - @MainActor - func isAlwaysReadableExternalPath(_ userPath: String) -> Bool { - let normalized = normalizeUserInputPath(userPath).trimmingCharacters(in: .whitespacesAndNewlines) - guard normalized.hasPrefix("/") else { return false } - let directories = AgentSupportDirectoryCatalog.effectiveAlwaysReadableDirectories( - homeDirectoryURL: alwaysReadableHomeDirectoryURL - ) - return directories.contains { - AgentSupportDirectoryCatalog.contains( - absolutePath: normalized, - in: $0 - ) - } - } - - func readAlwaysReadableExternalFile(_ file: ExternalReadableFile) async throws -> String { - let path = file.absolutePath - return try await Task.detached(priority: .userInitiated) { - let url = URL(fileURLWithPath: path) - let data = try Data(contentsOf: url) - if let decoded = String(data: data, encoding: .utf8) { - return decoded - } - if let decoded = String(data: data, encoding: .unicode) { - return decoded - } - return String(decoding: data, as: UTF8.self) - }.value - } - - @MainActor - private func resolveAlwaysReadableExternalFile(atAbsolutePath path: String) -> ExternalReadableFile? { - guard isAlwaysReadableExternalPath(path) else { return nil } - let absolutePath = normalizedAlwaysReadableAbsolutePath(for: path) - guard isAlwaysReadableExternalPath(absolutePath) else { return nil } - var isDirectory: ObjCBool = false - guard FileManager.default.fileExists(atPath: absolutePath, isDirectory: &isDirectory), !isDirectory.boolValue else { - return nil - } - return ExternalReadableFile( - absolutePath: absolutePath, - displayPath: AgentSupportDirectoryCatalog.displayPath( - for: absolutePath, - homeDirectoryURL: alwaysReadableHomeDirectoryURL - ) - ) - } - - @MainActor - private func normalizedAlwaysReadableAbsolutePath(for path: String) -> String { - let normalized = AgentSupportDirectoryCatalog.normalizedPath(for: path) - if FileManager.default.fileExists(atPath: normalized) { - return AgentSupportDirectoryCatalog.normalizedPath( - for: URL(fileURLWithPath: normalized).resolvingSymlinksInPath().standardizedFileURL.path - ) - } - return normalized - } - @MainActor func openFileForMarkdownLink(_ target: MarkdownFileLinkTarget) async -> Bool { if let file = await resolveFileForMarkdownLink(target) { @@ -11154,30 +11035,6 @@ extension WorkspaceFilesViewModel { } } -enum FileManagerError: Error, LocalizedError { - case failedToLoadFolder(Error) - case failedToLoadFile(Error) - case fileSystemServiceNotFound - case failedToLoadContent - // New: richer, contextual variant used by MCP tools and FS ops - case fileSystemServiceNotFoundWithContext(String) - - var errorDescription: String? { - switch self { - case let .failedToLoadFolder(err): - "Failed to load folder: \(err.localizedDescription)" - case let .failedToLoadFile(err): - "Failed to load file: \(err.localizedDescription)" - case .fileSystemServiceNotFound: - "No matching workspace folder for the requested path." - case .failedToLoadContent: - "Failed to load content." - case let .fileSystemServiceNotFoundWithContext(context): - context - } - } -} - struct PathLocation { let rootPath: String let correctedPath: String @@ -11602,32 +11459,32 @@ extension WorkspaceFilesViewModel { private func subscribeToPartitionStoreSaves() { partitionStoreSaveCancellable = NotificationCenter.default - .publisher(for: PartitionStore.didSaveNotification) + .publisher(for: EmbeddedPartitionStoreEventAdapter.didSaveNotification) .sink { [weak self] note in guard let self else { return } Task { @MainActor in // Workspace must match - guard let wsAny = note.userInfo?[PartitionStore.notifWorkspaceIDKey], + guard let wsAny = note.userInfo?[EmbeddedPartitionStoreEventAdapter.workspaceIDKey], let ws = wsAny as? UUID else { return } guard ws == self.currentWorkspaceID else { return } self.partitionSliceSaveRevision &+= 1 // Ignore our own writes to avoid redundant reload churn in this VM. - if let sourceAny = note.userInfo?[PartitionStore.notifSourceIDKey], + if let sourceAny = note.userInfo?[EmbeddedPartitionStoreEventAdapter.sourceIDKey], let sourceID = sourceAny as? UUID, sourceID == self.selectionSliceCoordinator.notificationSourceID { return } - guard let rootAny = note.userInfo?[PartitionStore.notifRootPathKey], + guard let rootAny = note.userInfo?[EmbeddedPartitionStoreEventAdapter.rootPathKey], let nsRoot = rootAny as? String else { return } let stdRoot = (nsRoot as NSString).standardizingPath // Only refresh if this root folder is actually loaded in this window guard self.isRootFolderLoaded(stdRoot) else { return } // Tab must match (nil == nil is fine) - let tabAny = note.userInfo?[PartitionStore.notifTabIDKey] + let tabAny = note.userInfo?[EmbeddedPartitionStoreEventAdapter.tabIDKey] let eventTab = tabAny as? UUID guard eventTab == self.currentTabID else { return } diff --git a/Sources/RepoPrompt/Features/Workspaces/ViewModels/WorkspaceManagerViewModel.swift b/Sources/RepoPrompt/Features/Workspaces/ViewModels/WorkspaceManagerViewModel.swift index 17c46d584..99f2194c0 100644 --- a/Sources/RepoPrompt/Features/Workspaces/ViewModels/WorkspaceManagerViewModel.swift +++ b/Sources/RepoPrompt/Features/Workspaces/ViewModels/WorkspaceManagerViewModel.swift @@ -1,6 +1,7 @@ import Combine import Foundation import os +import RepoPromptCore import SwiftUI /// Free helper function not tied to any actor @@ -54,12 +55,6 @@ struct WorkspaceFileLoadResult { let workspace: WorkspaceModel let cacheHit: Bool let composeTabsNormalized: Bool - - var normalizationRequiresSave: Bool { - composeTabsNormalized - } - - let normalizationSaveTask: Task? } private struct WorkspaceFileDecodeCacheKey: Hashable { @@ -72,67 +67,47 @@ private struct WorkspaceFileCachedLoadResult { let workspace: WorkspaceModel let cacheHit: Bool let composeTabsNormalized: Bool - - var normalizationRequiresSave: Bool { - composeTabsNormalized - } - - let cacheKey: WorkspaceFileDecodeCacheKey } final class WorkspaceFileDecodeCache: @unchecked Sendable { static let shared = WorkspaceFileDecodeCache() private let lock = NSLock() - private var cachedWorkspacesByKey: [WorkspaceFileDecodeCacheKey: WorkspaceModel] = [:] - private var scheduledNormalizationSaveKeys: Set = [] + private var cachedResultsByKey: [WorkspaceFileDecodeCacheKey: WorkspaceDocumentDecodeResult] = [:] private init() {} fileprivate func loadWorkspace(at fileURL: URL) throws -> WorkspaceFileCachedLoadResult { let keyBeforeRead = try metadataKey(for: fileURL) lock.lock() - if let cached = cachedWorkspacesByKey[keyBeforeRead] { + if let cached = cachedResultsByKey[keyBeforeRead] { lock.unlock() return WorkspaceFileCachedLoadResult( - workspace: cached, + workspace: cached.document, cacheHit: true, - composeTabsNormalized: cached.normalizationRequiresSave, - cacheKey: keyBeforeRead + composeTabsNormalized: cached.requiresRewrite ) } lock.unlock() let standardizedURL = URL(fileURLWithPath: keyBeforeRead.standardizedPath) - let data = try Data(contentsOf: standardizedURL) - var workspace = try JSONDecoder().decode(WorkspaceModel.self, from: data) - let decodedRequiresSave = workspace.normalizationRequiresSave - let normalized = workspace.normalizeComposeTabInvariants() - let normalizationRequiresSave = decodedRequiresSave || normalized || workspace.normalizationRequiresSave - workspace.normalizationRequiresSave = normalizationRequiresSave - - if let keyAfterRead = try? metadataKey(for: fileURL), - keyAfterRead == keyBeforeRead - { + let result = try EmbeddedWorkspaceCodecV1().decode(Data(contentsOf: standardizedURL)) + if let keyAfterRead = try? metadataKey(for: fileURL), keyAfterRead == keyBeforeRead { lock.lock() - cachedWorkspacesByKey[keyBeforeRead] = workspace + cachedResultsByKey[keyBeforeRead] = result lock.unlock() } - return WorkspaceFileCachedLoadResult( - workspace: workspace, + workspace: result.document, cacheHit: false, - composeTabsNormalized: normalizationRequiresSave, - cacheKey: keyBeforeRead + composeTabsNormalized: result.requiresRewrite ) } fileprivate func metadataKey(for fileURL: URL) throws -> WorkspaceFileDecodeCacheKey { let standardizedURL = fileURL.standardizedFileURL let values = try standardizedURL.resourceValues(forKeys: [.fileSizeKey, .contentModificationDateKey]) - guard let fileSize = values.fileSize, - let modificationDate = values.contentModificationDate - else { + guard let fileSize = values.fileSize, let modificationDate = values.contentModificationDate else { throw CocoaError(.fileReadUnknown) } return WorkspaceFileDecodeCacheKey( @@ -142,55 +117,22 @@ final class WorkspaceFileDecodeCache: @unchecked Sendable { ) } - fileprivate func claimNormalizationSave(for key: WorkspaceFileDecodeCacheKey) -> Bool { - lock.lock() - defer { lock.unlock() } - guard !scheduledNormalizationSaveKeys.contains(key) else { return false } - scheduledNormalizationSaveKeys.insert(key) - return true - } - - fileprivate func isNormalizationSaveClaimed(for key: WorkspaceFileDecodeCacheKey) -> Bool { - lock.lock() - defer { lock.unlock() } - return scheduledNormalizationSaveKeys.contains(key) - } - - fileprivate func finishNormalizationSave(for key: WorkspaceFileDecodeCacheKey) { - lock.lock() - defer { lock.unlock() } - scheduledNormalizationSaveKeys.remove(key) - cachedWorkspacesByKey = cachedWorkspacesByKey.filter { $0.key.standardizedPath != key.standardizedPath } - } - func invalidate(url: URL) { let standardizedPath = url.standardizedFileURL.path lock.lock() defer { lock.unlock() } - cachedWorkspacesByKey = cachedWorkspacesByKey.filter { $0.key.standardizedPath != standardizedPath } - scheduledNormalizationSaveKeys = scheduledNormalizationSaveKeys.filter { $0.standardizedPath != standardizedPath } + cachedResultsByKey = cachedResultsByKey.filter { $0.key.standardizedPath != standardizedPath } } #if DEBUG func removeAllForTesting() { lock.lock() defer { lock.unlock() } - cachedWorkspacesByKey.removeAll() - scheduledNormalizationSaveKeys.removeAll() + cachedResultsByKey.removeAll() } #endif } -/// Minimal info we keep in the index for each workspace -/// so we can load their details individually from disk. -struct WorkspaceIndexEntry: Codable { - let id: UUID - var name: String - var customStoragePath: URL? - var isSystemWorkspace: Bool - var isHiddenInMenus: Bool -} - enum WorkspaceOpenBehavior { case addToActiveOrCreateNew case createNewWorkspace @@ -216,25 +158,31 @@ enum WorkspaceOpenError: LocalizedError { /// The main WorkspaceManager, refactored to store each WorkspaceModel /// in its own folder + workspace.json, and maintain an index file for all known workspaces. +private struct WorkspaceSaveSubmission { + let workspaceID: UUID + let capturedGeneration: UInt64 + let persistedWorkspace: WorkspaceModel + let receipt: WorkspaceWriteReceipt +} + @MainActor class WorkspaceManagerViewModel: ObservableObject { private static let logger = Logger(subsystem: "com.repoprompt.workspace", category: "WorkspaceSwitch") private static var coalescedInitialCodeMapPurgeTask: Task? private static var coalescedInitialCodeMapPurgeRoots: Set = [] - @Published var workspaces: [WorkspaceModel] = [] { - didSet { - // Workspace IDs should be unique, but a corrupted index or - // migration bug must not SIGTRAP every workspace assignment. - // Last-wins matches the array-order lookup semantics below. - workspaceIndexMap = Dictionary( - workspaces.enumerated().map { ($1.id, $0) }, - uniquingKeysWith: { _, last in last } - ) - } + let sessionController: WorkspaceSessionController + let workspaceObservation: WorkspaceSessionObservationBridge + + /// Read-only projection of the canonical Core workspace state. + var workspaces: [WorkspaceModel] { + sessionController.workspaces + } + + var activeWorkspaceID: UUID? { + sessionController.activeWorkspaceID } - @Published private(set) var activeWorkspaceID: UUID? = nil // New property to track the active workspace ID #if DEBUG private var restoreTokenRecountWatchdogIDs: Set = [] #endif @@ -246,36 +194,22 @@ class WorkspaceManagerViewModel: ObservableObject { @Published private var applyingTabContextID: UUID? = nil private var applyingTabContextDepthByTabID: [UUID: Int] = [:] - // MARK: - Versioned dirty-tracking (no equality needed) - - private var stateVersionByWorkspaceID: [UUID: Int] = [:] - private var lastSavedVersionByWorkspaceID: [UUID: Int] = [:] - - @MainActor - private static var nextWorkspaceSelectionRevision: UInt64 = 1 - private var selectionRevisionByWorkspaceTab: [WorkspaceTabSelectionKey: UInt64] = [:] - private var revisedSelectionByWorkspaceTab: [WorkspaceTabSelectionKey: StoredSelection] = [:] + // MARK: - Versioned dirty-tracking private func bumpStateVersion(for id: UUID?) { guard let id else { return } - stateVersionByWorkspaceID[id, default: 0] &+= 1 // wraparound-safe - } - - private static func allocateWorkspaceSelectionRevision() -> UInt64 { - let revision = nextWorkspaceSelectionRevision - nextWorkspaceSelectionRevision &+= 1 - return revision + sessionController.markDirty(workspaceID: id) } func debugActiveSelectionRevisionForCurrentTab() -> UInt64 { let tabID = activeWorkspace?.activeComposeTabID ?? activeWorkspace?.composeTabs.first?.id - guard let workspaceID = activeWorkspace?.id else { return 0 } - return selectionRevision(workspaceID: workspaceID, tabID: tabID) + guard let workspaceID = activeWorkspace?.id, let tabID else { return 0 } + return sessionController.selectionRevision(workspaceID: workspaceID, tabID: tabID) } private func selectionRevision(workspaceID: UUID, tabID: UUID?) -> UInt64 { guard let tabID else { return 0 } - return selectionRevisionByWorkspaceTab[WorkspaceTabSelectionKey(workspaceID: workspaceID, tabID: tabID), default: 0] + return sessionController.selectionRevision(workspaceID: workspaceID, tabID: tabID) } private func recordSelectionRevisionIfChanged( @@ -285,27 +219,22 @@ class WorkspaceManagerViewModel: ObservableObject { newSelection: StoredSelection, reason: String ) { - guard oldSelection != newSelection, - workspaces.indices.contains(workspaceIndex), - workspaces[workspaceIndex].composeTabs.indices.contains(tabIndex) - else { return } - let workspace = workspaces[workspaceIndex] - let tabID = workspaces[workspaceIndex].composeTabs[tabIndex].id - let key = WorkspaceTabSelectionKey(workspaceID: workspace.id, tabID: tabID) - let revision = Self.allocateWorkspaceSelectionRevision() - selectionRevisionByWorkspaceTab[key] = revision - revisedSelectionByWorkspaceTab[key] = newSelection #if DEBUG + guard oldSelection != newSelection, + workspaces.indices.contains(workspaceIndex), + workspaces[workspaceIndex].composeTabs.indices.contains(tabIndex) + else { return } + let workspace = workspaces[workspaceIndex] + let tabID = workspace.composeTabs[tabIndex].id var fields: [String: String] = [ "workspaceID": WorkspaceRestorePerfLog.shortID(workspace.id), "workspaceName": workspace.name, "tabID": WorkspaceRestorePerfLog.shortID(tabID), - "revision": "\(revision)", "reason": reason ] fields.merge(WorkspaceSaveSelectionSummary(tabID: tabID, selection: oldSelection).fields(prefix: "old")) { current, _ in current } fields.merge(WorkspaceSaveSelectionSummary(tabID: tabID, selection: newSelection).fields(prefix: "new")) { current, _ in current } - WorkspaceRestorePerfLog.event("workspaceSave.selectionRevision.recorded", fields: fields) + WorkspaceRestorePerfLog.event("workspaceSave.selectionRevision.changed", fields: fields) #endif } @@ -313,11 +242,6 @@ class WorkspaceManagerViewModel: ObservableObject { bumpStateVersion(for: activeWorkspaceID) } - /// Quick lookup cache replaced with index-based lookup - private var workspaceIndexMap: [UUID: Int] = [:] - /// Last root-path order this manager loaded from or saved to disk, by workspace. - /// Used to distinguish local root edits from stale in-memory snapshots during full saves. - private var lastSyncedRepoPathsByWorkspaceID: [UUID: [String]] = [:] private var pendingRepoPathSyncWorkspaceIDs: Set = [] /// Track if the active preset has diverged from its stored file selection @@ -332,30 +256,99 @@ class WorkspaceManagerViewModel: ObservableObject { private var initializationCallbacks: [() -> Void] = [] private var switchingCompletionCallbacks: [() -> Void] = [] - /// Computed property to get/set the active workspace using the cache var activeWorkspace: WorkspaceModel? { - get { workspace(withID: activeWorkspaceID) } - set { activeWorkspaceID = newValue?.id } + sessionController.activeWorkspace } - /// Returns the workspace with the given identifier, if loaded. - /// Uses index-based lookup for O(1) performance without duplication. func workspace(withID id: UUID?) -> WorkspaceModel? { - guard let id, let idx = workspaceIndexMap[id], workspaces.indices.contains(idx) else { return nil } - return workspaces[idx] + guard let id else { return nil } + return sessionController.workspace(id: id) } - /// Returns the current index for a workspace ID, validating the cached map. - /// Safe to call after `await` points where `workspaces` may have been mutated. + /// Resolve by stable identity immediately before use. Callers must not retain indexes across `await`. private func workspaceIndex(for id: UUID) -> Int? { - if let idx = workspaceIndexMap[id], - workspaces.indices.contains(idx), - workspaces[idx].id == id - { - return idx - } - // Fallback in case the map is temporarily stale - return workspaces.firstIndex(where: { $0.id == id }) + workspaces.lastIndex(where: { $0.id == id }) + } + + @discardableResult + func mutateWorkspace( + id: UUID, + touchDateModified: Bool = true, + markDirty: Bool = true, + _ mutation: (inout WorkspaceModel) -> Void + ) -> WorkspaceModel? { + sessionController.mutateWorkspace( + id: id, + options: WorkspaceSessionMutationOptions( + touchDateModified: touchDateModified, + markDirty: markDirty, + recordsSelectionRevisions: markDirty + ), + mutation + ) + } + + @discardableResult + func mutateActiveWorkspace( + touchDateModified: Bool = true, + markDirty: Bool = true, + _ mutation: (inout WorkspaceModel) -> Void + ) -> WorkspaceModel? { + sessionController.mutateActiveWorkspace( + options: WorkspaceSessionMutationOptions( + touchDateModified: touchDateModified, + markDirty: markDirty, + recordsSelectionRevisions: markDirty + ), + mutation + ) + } + + @discardableResult + func mutateComposeTab( + workspaceID: UUID, + tabID: UUID, + touchDateModified: Bool = true, + markDirty: Bool = true, + _ mutation: (inout ComposeTabState) -> Void + ) -> ComposeTabState? { + sessionController.mutateComposeTab( + workspaceID: workspaceID, + tabID: tabID, + options: WorkspaceSessionMutationOptions( + touchDateModified: touchDateModified, + markDirty: markDirty, + recordsSelectionRevisions: markDirty + ), + mutation + ) + } + + func workspaceTransaction( + touchDateModified: Bool = true, + markDirty: Bool = true, + _ mutation: (inout WorkspaceSessionTransaction) -> Void + ) { + sessionController.transaction( + options: WorkspaceSessionMutationOptions( + touchDateModified: touchDateModified, + markDirty: markDirty, + recordsSelectionRevisions: markDirty + ), + mutation + ) + } + + func replaceWorkspaceInventory(_ workspaces: [WorkspaceModel], activeWorkspaceID: UUID?) { + sessionController.replaceAll(workspaces, activeWorkspaceID: activeWorkspaceID) + } + + func setActiveWorkspaceID(_ id: UUID?) { + sessionController.setActiveWorkspaceID(id) + } + + func setWorkspaceEphemeral(_ isEphemeral: Bool, workspaceID: UUID) { + mutateWorkspace(id: workspaceID) { $0.isEphemeral = isEphemeral } } nonisolated static func normalizedRepoPathsForComparison(_ paths: [String]) -> [String] { @@ -367,18 +360,17 @@ class WorkspaceManagerViewModel: ObservableObject { } private func recordRepoPathBaseline(for workspace: WorkspaceModel) { - lastSyncedRepoPathsByWorkspaceID[workspace.id] = workspace.repoPaths + sessionController.recordRepositoryBaseline(workspace) } private func recordRepoPathBaselines(for workspaces: [WorkspaceModel]) { for workspace in workspaces { - recordRepoPathBaseline(for: workspace) + sessionController.recordRepositoryBaseline(workspace) } } private func hasLocalRepoPathEdit(for workspace: WorkspaceModel) -> Bool { - guard let baseline = lastSyncedRepoPathsByWorkspaceID[workspace.id] else { return true } - return !Self.repoPathsEquivalent(workspace.repoPaths, baseline) + sessionController.hasLocalRepoPathEdit(workspaceID: workspace.id) } private func drainPendingRepoPathSyncIfNeeded() { @@ -439,6 +431,7 @@ class WorkspaceManagerViewModel: ObservableObject { store: fileManager.workspaceFileContextStore, searchService: workspaceSearchService ) + let workspaceRepository: WorkspaceRepository private weak var selectionCoordinator: WorkspaceSelectionCoordinator? @Published var isChatBusy: Bool = false @@ -524,16 +517,9 @@ class WorkspaceManagerViewModel: ObservableObject { /// No disk I/O is triggered here; the regular polling / save cycle will /// flush the change later. func setLastSearchQuery(_ query: String) { - guard let activeId = activeWorkspaceID, - let idx = workspaces.firstIndex(where: { $0.id == activeId }) - else { return } - - // Avoid needless mutations - if workspaces[idx].lastSearchQuery != query { - workspaces[idx].lastSearchQuery = query - workspaces[idx].dateModified = Date() - bumpStateVersion(for: activeId) - } + guard let activeID = activeWorkspaceID, + activeWorkspace?.lastSearchQuery != query else { return } + mutateWorkspace(id: activeID) { $0.lastSearchQuery = query } } @MainActor @@ -1176,7 +1162,10 @@ class WorkspaceManagerViewModel: ObservableObject { init( fileManager: WorkspaceFilesViewModel, promptViewModel: PromptViewModel, - workspaceSearchService: WorkspaceSearchService = WorkspaceSearchService() + workspaceSearchService: WorkspaceSearchService, + workspaceRepository: WorkspaceRepository, + sessionController: WorkspaceSessionController, + workspaceObservation: WorkspaceSessionObservationBridge ) { #if DEBUG let initStartMS = WorkspaceRestorePerfLog.timestampMSIfEnabled() @@ -1184,6 +1173,9 @@ class WorkspaceManagerViewModel: ObservableObject { self.fileManager = fileManager self.promptViewModel = promptViewModel self.workspaceSearchService = workspaceSearchService + self.workspaceRepository = workspaceRepository + self.sessionController = sessionController + self.workspaceObservation = workspaceObservation self.promptViewModel.attachWorkspaceManager(self) self.fileManager.setWorkspaceManager(self) @@ -1191,6 +1183,10 @@ class WorkspaceManagerViewModel: ObservableObject { globalCustomStorageURL = URL(fileURLWithPath: path) } + workspaceObservation.objectWillChange + .sink { [weak self] in self?.objectWillChange.send() } + .store(in: &cancellables) + // Track when the file selection changes to detect if active preset is dirty fileManager.$selectedFiles // Debounce to avoid rapid consecutive events @@ -1251,7 +1247,6 @@ class WorkspaceManagerViewModel: ObservableObject { var missingWorkspaceFileCount = 0 var failedWorkspaceDecodeCount = 0 var composeTabNormalizationCount = 0 - var normalizationSaveBackCount = 0 #endif var loaded = [WorkspaceModel]() let base = currentBaseRoot @@ -1273,7 +1268,6 @@ class WorkspaceManagerViewModel: ObservableObject { decodedWorkspaceCount += 1 if loadResult.cacheHit { workspaceDecodeCacheHitCount += 1 } if loadResult.composeTabsNormalized { composeTabNormalizationCount += 1 } - if loadResult.normalizationRequiresSave { normalizationSaveBackCount += 1 } #endif loaded.append(ws) } else { @@ -1294,11 +1288,11 @@ class WorkspaceManagerViewModel: ObservableObject { let indexDuration = indexLoadDurationMS.map(WorkspaceRestorePerfLog.formatMS) ?? "notMeasured" let decodeDuration = decodeStartMS.map { WorkspaceRestorePerfLog.formatElapsedMS(since: $0) } ?? "notMeasured" WorkspaceRestorePerfLog.log( - "workspaceManager.init managerID=\(instanceID.uuidString.prefix(8)) indexEntries=\(indexEntries.count) decoded=\(decodedWorkspaceCount) decodeCacheHits=\(workspaceDecodeCacheHitCount) missingFiles=\(missingWorkspaceFileCount) decodeFailures=\(failedWorkspaceDecodeCount) composeTabNormalizations=\(composeTabNormalizationCount) normalizationSaveBacks=\(normalizationSaveBackCount) indexLoad=\(indexDuration) decodeAndMigration=\(decodeDuration) totalBeforeDefaultSwitch=\(WorkspaceRestorePerfLog.formatElapsedMS(since: initStartMS))" + "workspaceManager.init managerID=\(instanceID.uuidString.prefix(8)) indexEntries=\(indexEntries.count) decoded=\(decodedWorkspaceCount) decodeCacheHits=\(workspaceDecodeCacheHitCount) missingFiles=\(missingWorkspaceFileCount) decodeFailures=\(failedWorkspaceDecodeCount) composeTabNormalizations=\(composeTabNormalizationCount) normalizationSaveBacks=0 indexLoad=\(indexDuration) decodeAndMigration=\(decodeDuration) totalBeforeDefaultSwitch=\(WorkspaceRestorePerfLog.formatElapsedMS(since: initStartMS))" ) } #endif - workspaces = loaded + sessionController.replaceAll(loaded, activeWorkspaceID: nil) recordRepoPathBaselines(for: loaded) purgeStaleCodeMapCachesForKnownRoots(coalesceAcrossInitialManagers: true) @@ -1486,16 +1480,12 @@ class WorkspaceManagerViewModel: ObservableObject { } } - private func saveWorkspaceIndex(_ entries: [WorkspaceIndexEntry]) throws { - try ensureBaseRootExists(at: currentBaseRoot) - let data = try JSONEncoder().encode(entries) - try data.write(to: workspaceIndexFileURL, options: .atomic) - } - private func saveWorkspaceIndexAsync(_ entries: [WorkspaceIndexEntry]) async throws { - try ensureBaseRootExists(at: currentBaseRoot) - let data = try JSONEncoder().encode(entries) - await WorkspaceDiskWriter.shared.enqueue(data: data, url: workspaceIndexFileURL) + let receipt = try await workspaceRepository.saveIndex(entries, baseRoot: currentBaseRoot) + let completion = await workspaceRepository.flush(receipt) + if let errorDescription = completion.errorDescription { + throw CocoaError(.fileWriteUnknown, userInfo: [NSLocalizedDescriptionKey: errorDescription]) + } } /// Reloads the workspace list from disk, preserving the active workspace @@ -1544,13 +1534,14 @@ class WorkspaceManagerViewModel: ObservableObject { // Only apply if this is the latest issued task guard reloadWorkspacesToken == token else { return } - workspaces = loaded - recordRepoPathBaselines(for: loaded) - if let currentActiveID, - loaded.contains(where: { $0.id == currentActiveID }) - { - activeWorkspaceID = currentActiveID - } + sessionController.replaceAll( + loaded, + activeWorkspaceID: currentActiveID, + repositoryBaselines: Dictionary( + loaded.map { ($0.id, $0.repoPaths) }, + uniquingKeysWith: { _, last in last } + ) + ) purgeStaleCodeMapCachesForKnownRoots() // Clear running task reference @@ -1580,9 +1571,16 @@ class WorkspaceManagerViewModel: ObservableObject { guard let latestIndex = workspaceIndex(for: workspaceID) else { return } guard !hasLocalRepoPathEdit(for: workspaces[latestIndex]) else { return } let previousRepoPaths = workspaces[latestIndex].repoPaths - workspaces[latestIndex].repoPaths = diskWorkspace.repoPaths - workspaces[latestIndex].dateModified = diskWorkspace.dateModified - recordRepoPathBaseline(for: workspaces[latestIndex]) + let updated = mutateWorkspace( + id: workspaceID, + touchDateModified: false, + markDirty: false + ) { workspace in + workspace.repoPaths = diskWorkspace.repoPaths + workspace.dateModified = diskWorkspace.dateModified + } + guard let updated else { return } + recordRepoPathBaseline(for: updated) if activeWorkspaceID == workspaceID, !Self.repoPathsEquivalent(previousRepoPaths, diskWorkspace.repoPaths) @@ -1590,7 +1588,7 @@ class WorkspaceManagerViewModel: ObservableObject { if isRefreshing || isSwitchingWorkspace { pendingRepoPathSyncWorkspaceIDs.insert(workspaceID) } else { - await syncLoadedRootsWithWorkspace(workspaces[latestIndex]) + await syncLoadedRootsWithWorkspace(updated) } } } catch { @@ -1603,37 +1601,7 @@ class WorkspaceManagerViewModel: ObservableObject { /// decode cache when the on-disk file metadata is unchanged. /// Use this when you need guaranteed accurate workspace data (e.g., for MCP tool responses). func loadWorkspaceSnapshotFromDisk() async -> [WorkspaceModel] { - let base = currentBaseRoot - let indexURL = workspaceIndexFileURL - - return await Task.detached(priority: .utility) { - let indexEntries = Self.loadWorkspaceIndex(from: indexURL) - var loaded: [WorkspaceModel] = [] - - for entry in indexEntries { - let wURL: URL - if let customURL = entry.customStoragePath { - wURL = customURL.appendingPathComponent("workspace.json") - } else { - let folder = base.appendingPathComponent("Workspace-\(entry.name)-\(entry.id.uuidString)") - wURL = folder.appendingPathComponent("workspace.json") - } - - if FileManager.default.fileExists(atPath: wURL.path) { - do { - let ws = try Self.loadWorkspaceFromFile(at: wURL) - print("[WorkspaceSnapshot] Loaded \(ws.name): \(ws.repoPaths.count) repoPaths") - loaded.append(ws) - } catch { - print("[WorkspaceSnapshot] Failed to load from \(wURL.path): \(error)") - } - } else { - print("[WorkspaceSnapshot] File not found: \(wURL.path)") - } - } - - return loaded - }.value + await workspaceRepository.loadWorkspaceSnapshotFromDisk(baseRoot: currentBaseRoot) } /// Reloads only the presets for all workspaces from disk @@ -1682,21 +1650,12 @@ class WorkspaceManagerViewModel: ObservableObject { // Ensure this result is from the latest task guard reloadPresetsToken == token else { return } - // Recompute the current index map (state may have changed since task started) - // Defensive: workspace IDs should be unique, but use a - // duplicate-tolerant (last-wins) init so a stray duplicate - // ID never SIGTRAPs during a background reload. Last-wins - // matches the array order semantics below. - let indexMap = Dictionary( - workspaces.enumerated().map { ($1.id, $0) }, - uniquingKeysWith: { _, last in last } - ) - - for update in updates { - guard let idx = indexMap[update.id], - workspaces.indices.contains(idx) else { continue } - workspaces[idx].presets = update.presets - workspaces[idx].activePresetID = update.activePresetID + workspaceTransaction(touchDateModified: false, markDirty: false) { transaction in + for update in updates { + guard let index = transaction.workspaceIndex(id: update.id) else { continue } + transaction.workspaces[index].presets = update.presets + transaction.workspaces[index].activePresetID = update.activePresetID + } } if activeWorkspace != nil { @@ -1709,27 +1668,6 @@ class WorkspaceManagerViewModel: ObservableObject { } } - /// Legacy synchronous version - only used during initialization - private func rebuildAndSaveIndex() { - // Exclude ephemeral workspaces from the index - let entries: [WorkspaceIndexEntry] = workspaces - .filter { !$0.isEphemeral } - .map { - WorkspaceIndexEntry( - id: $0.id, - name: $0.name, - customStoragePath: $0.customStoragePath, - isSystemWorkspace: $0.isSystemWorkspace, - isHiddenInMenus: $0.isHiddenInMenus - ) - } - do { - try saveWorkspaceIndex(entries) - } catch { - print("Error saving index: \(error)") - } - } - private func rebuildAndSaveIndexAsync() async { // Exclude ephemeral workspaces from the index let entries: [WorkspaceIndexEntry] = workspaces @@ -1794,7 +1732,7 @@ class WorkspaceManagerViewModel: ObservableObject { // Mark as ephemeral if needed newWorkspace.isEphemeral = ephemeral - workspaces.append(newWorkspace) + workspaceTransaction(touchDateModified: false) { $0.workspaces.append(newWorkspace) } recordRepoPathBaseline(for: newWorkspace) // Notify for auto-apply recommendations (non-ephemeral only) @@ -1813,11 +1751,11 @@ class WorkspaceManagerViewModel: ObservableObject { _ = try ensureWorkspaceDirectoryExists(for: newWorkspace) // Persist this new workspace file and flush before proceeding let finalURL = try await saveWorkspaceToFileAsync(newWorkspace, preserveDiskRepoPathsIfUnchangedSinceBaseline: false, source: .createWorkspace) - await WorkspaceDiskWriter.shared.flush(url: finalURL) + await sessionController.persistenceWriter.flush(url: finalURL) await MainActor.run { self.recordRepoPathBaseline(for: newWorkspace) } await rebuildAndSaveIndexAsync() - await WorkspaceDiskWriter.shared.flush(url: workspaceIndexFileURL) + await sessionController.persistenceWriter.flush(url: workspaceIndexFileURL) // Notify other windows after disk commits await MainActor.run { @@ -2024,10 +1962,10 @@ class WorkspaceManagerViewModel: ObservableObject { #endif let saveSignpost = WorkspaceExitPerf.begin("switchWorkspace.saveAndUnload") defer { WorkspaceExitPerf.end("switchWorkspace.saveAndUnload", saveSignpost) } - if let index = workspaces.firstIndex(where: { $0.id == oldActive.id }) { - // Use snapshot to avoid Set→Array conversion on hot path - let savedPromptIDs = promptViewModel.getSelectedPromptIDsSnapshot() - workspaces[index].selectedMetaPromptIDs = savedPromptIDs + // Use snapshot to avoid Set→Array conversion on hot path. + let savedPromptIDs = promptViewModel.getSelectedPromptIDsSnapshot() + mutateWorkspace(id: oldActive.id, touchDateModified: false) { + $0.selectedMetaPromptIDs = savedPromptIDs } await pollAndSaveStateAsync(source: .workspaceSwitchSaveState) #if DEBUG @@ -2059,13 +1997,17 @@ class WorkspaceManagerViewModel: ObservableObject { if FileManager.default.fileExists(atPath: diskURL.path) { do { let upgraded = try await Self.loadWorkspaceFromFileAsync(at: diskURL) - workspaces[wsIndex] = upgraded + workspaceTransaction(touchDateModified: false, markDirty: false) { transaction in + guard let index = transaction.workspaceIndex(id: newWorkspace.id) else { return } + transaction.workspaces[index] = upgraded + transaction.activeWorkspaceID = upgraded.id + } recordRepoPathBaseline(for: upgraded) } catch { print("Error reloading workspace from disk: \(error)") } } - activeWorkspaceID = workspaces[wsIndex].id // Set the active ID + setActiveWorkspaceID(newWorkspace.id) } else { let diskURL = workspaceFileURL(for: newWorkspace) guard FileManager.default.fileExists(atPath: diskURL.path) else { @@ -2075,9 +2017,11 @@ class WorkspaceManagerViewModel: ObservableObject { do { let upgraded = try await Self.loadWorkspaceFromFileAsync(at: diskURL) - workspaces.append(upgraded) + workspaceTransaction(touchDateModified: false, markDirty: false) { transaction in + transaction.workspaces.append(upgraded) + transaction.activeWorkspaceID = upgraded.id + } recordRepoPathBaseline(for: upgraded) - activeWorkspaceID = upgraded.id } catch { print("‼️ switchWorkspace: failed to load workspace from disk: \(error)") return // abort – keep current active workspace @@ -2362,21 +2306,10 @@ class WorkspaceManagerViewModel: ObservableObject { source: WorkspaceSaveSource, owner: WorkspaceSaveOwner? = nil ) -> WorkspaceSavePayloadMetadata { - let activeTabID = workspace.activeComposeTabID ?? workspace.composeTabs.first?.id - let activeSelection = activeTabID.flatMap { id in workspace.composeTabs.first(where: { $0.id == id })?.selection } - let key = activeTabID.map { WorkspaceTabSelectionKey(workspaceID: workspace.id, tabID: $0) } - let candidateRevision = selectionRevision(workspaceID: workspace.id, tabID: activeTabID) - let recordedSelection = key.flatMap { revisedSelectionByWorkspaceTab[$0] } - let revision = (candidateRevision > 0 && recordedSelection == activeSelection) ? candidateRevision : 0 - return WorkspaceSavePayloadMetadata( + sessionController.saveMetadata( + for: workspace, source: source, - owner: owner ?? WorkspaceSaveOwner(windowID: promptViewModel.windowID, managerID: instanceID), - workspaceID: workspace.id, - workspaceName: workspace.name, - workspaceDateModified: workspace.dateModified, - activeTabID: activeTabID, - activeSelectionRevision: revision, - activeSelection: activeSelection + owner: owner ?? WorkspaceSaveOwner(windowID: promptViewModel.windowID, managerID: instanceID) ) } @@ -2546,9 +2479,8 @@ class WorkspaceManagerViewModel: ObservableObject { /// Returns nil if the tab is not found in the active workspace func composeTabSnapshot(for tabID: UUID) -> ComposeTabState? { guard let workspaceID = activeWorkspaceID, - let index = workspaceIndexMap[workspaceID], - workspaces.indices.contains(index) else { return nil } - return workspaces[index].composeTabs.first(where: { $0.id == tabID }) + let workspace = workspace(withID: workspaceID) else { return nil } + return workspace.composeTabs.first(where: { $0.id == tabID }) } // MARK: - In-memory snapshot publishing (no disk I/O) @@ -2556,21 +2488,27 @@ class WorkspaceManagerViewModel: ObservableObject { /// In-memory commit of the active tab (no disk save) @MainActor private func updateComposeTabFastNoDirty(_ tab: ComposeTabState, touchModified: Bool = false) { - for wi in workspaces.indices { - if let ti = workspaces[wi].composeTabs.firstIndex(where: { $0.id == tab.id }) { - let oldSelection = workspaces[wi].composeTabs[ti].selection - var t = tab - if touchModified { t.lastModified = Date() } - workspaces[wi].composeTabs[ti] = t - recordSelectionRevisionIfChanged( - workspaceIndex: wi, - tabIndex: ti, - oldSelection: oldSelection, - newSelection: t.selection, - reason: "updateComposeTabFastNoDirty.uiSnapshotCommit" - ) - return - } + guard let workspace = workspaces.first(where: { workspace in + workspace.composeTabs.contains(where: { $0.id == tab.id }) + }), let oldTab = workspace.composeTabs.first(where: { $0.id == tab.id }) else { return } + var updated = tab + if touchModified { updated.lastModified = Date() } + _ = mutateComposeTab( + workspaceID: workspace.id, + tabID: tab.id, + touchDateModified: false, + markDirty: false + ) { $0 = updated } + if let workspaceIndex = workspaceIndex(for: workspace.id), + let tabIndex = self.workspace(withID: workspace.id)?.composeTabs.firstIndex(where: { $0.id == tab.id }) + { + recordSelectionRevisionIfChanged( + workspaceIndex: workspaceIndex, + tabIndex: tabIndex, + oldSelection: oldTab.selection, + newSelection: updated.selection, + reason: "updateComposeTabFastNoDirty.uiSnapshotCommit" + ) } } @@ -2730,15 +2668,9 @@ class WorkspaceManagerViewModel: ObservableObject { @MainActor private func markWorkspaceDirtyIfTabStillActive(tabID: UUID) -> Bool { - guard - let active = activeWorkspace, - active.activeComposeTabID == tabID, - let index = workspaces.firstIndex(where: { $0.id == active.id }) - else { return false } - - workspaces[index].dateModified = Date() - markWorkspaceDirty() - return true + guard let active = activeWorkspace, + active.activeComposeTabID == tabID else { return false } + return mutateWorkspace(id: active.id) { _ in } != nil } private static func resolveComposeTabRoutingSnapshot( @@ -2785,14 +2717,6 @@ class WorkspaceManagerViewModel: ObservableObject { ) } - struct ComposeTabBindingCandidate: Equatable { - let tabID: UUID - let workspaceID: UUID - let workspaceName: String - let isActiveInWorkspace: Bool - let repoPaths: [String] - } - func composeTab(with id: UUID) -> ComposeTabState? { for workspace in workspaces { if let tab = workspace.composeTabs.first(where: { $0.id == id }) { @@ -2806,54 +2730,6 @@ class WorkspaceManagerViewModel: ObservableObject { composeTab(with: id)?.name } - func bindingCandidate(forContextID id: UUID) -> ComposeTabBindingCandidate? { - for workspace in workspaces { - guard let tab = workspace.composeTabs.first(where: { $0.id == id }) else { continue } - return ComposeTabBindingCandidate( - tabID: tab.id, - workspaceID: workspace.id, - workspaceName: workspace.name, - isActiveInWorkspace: workspace.activeComposeTabID == tab.id, - repoPaths: workspace.repoPaths - ) - } - return nil - } - - func bindingCandidates(matchingWorkingDirs dirs: [String], includeHidden: Bool = false) -> [ComposeTabBindingCandidate] { - Self.bindingCandidates( - matchingWorkingDirs: dirs, - workspaces: workspaces, - activeWorkspaceID: activeWorkspaceID, - includeHidden: includeHidden - ) - } - - func hasAnyWorkspaceMatch(matchingWorkingDirs dirs: [String]) -> Bool { - Self.hasAnyWorkspaceMatch(matchingWorkingDirs: dirs, workspaces: workspaces) - } - - nonisolated static func test_bindingCandidates( - matchingWorkingDirs dirs: [String], - workspaces: [WorkspaceModel], - activeWorkspaceID: UUID?, - includeHidden: Bool = false - ) -> [ComposeTabBindingCandidate] { - bindingCandidates( - matchingWorkingDirs: dirs, - workspaces: workspaces, - activeWorkspaceID: activeWorkspaceID, - includeHidden: includeHidden - ) - } - - nonisolated static func test_hasAnyWorkspaceMatch( - matchingWorkingDirs dirs: [String], - workspaces: [WorkspaceModel] - ) -> Bool { - hasAnyWorkspaceMatch(matchingWorkingDirs: dirs, workspaces: workspaces) - } - nonisolated static func normalizedExactWorkspaceDirectorySet(_ paths: [String]) -> [String] { WorkspaceRootSetKey(paths: paths).normalizedPaths } @@ -2928,78 +2804,8 @@ class WorkspaceManagerViewModel: ObservableObject { ) } - private nonisolated static func bindingCandidates( - matchingWorkingDirs dirs: [String], - workspaces: [WorkspaceModel], - activeWorkspaceID: UUID?, - includeHidden: Bool = false - ) -> [ComposeTabBindingCandidate] { - let normalizedDirs = normalizedBindingDirs(dirs) - guard !normalizedDirs.isEmpty, - let activeWorkspaceID, - let workspace = workspaces.first(where: { $0.id == activeWorkspaceID }), - includeHidden || !workspace.isHiddenInMenus, - workspaceMatchesWorkingDirs(workspace, normalizedDirs: normalizedDirs) - else { - return [] - } - - let tab = workspace.composeTabs.first(where: { $0.id == workspace.activeComposeTabID }) ?? workspace.composeTabs.first - guard let tab else { return [] } - return [ - ComposeTabBindingCandidate( - tabID: tab.id, - workspaceID: workspace.id, - workspaceName: workspace.name, - isActiveInWorkspace: workspace.activeComposeTabID == tab.id, - repoPaths: workspace.repoPaths - ) - ] - } - - private nonisolated static func hasAnyWorkspaceMatch( - matchingWorkingDirs dirs: [String], - workspaces: [WorkspaceModel] - ) -> Bool { - let normalizedDirs = normalizedBindingDirs(dirs) - guard !normalizedDirs.isEmpty else { return false } - return workspaces.contains { workspace in - workspaceMatchesWorkingDirs(workspace, normalizedDirs: normalizedDirs) - } - } - - private nonisolated static func normalizedBindingDirs(_ dirs: [String]) -> [String] { - dirs.map(normalizeBindingPath).filter { !$0.isEmpty } - } - - private nonisolated static func workspaceMatchesWorkingDirs( - _ workspace: WorkspaceModel, - normalizedDirs: [String] - ) -> Bool { - let normalizedRoots = workspace.repoPaths.map(Self.normalizeBindingPath).filter { !$0.isEmpty } - guard !normalizedRoots.isEmpty else { return false } - return normalizedDirs.allSatisfy { dir in - normalizedRoots.contains { root in - Self.bindingPath(dir, isWithinOrEqualTo: root) - } - } - } - - private nonisolated static func normalizeBindingPath(_ rawPath: String) -> String { - let trimmed = rawPath.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { return "" } - let expanded = (trimmed as NSString).expandingTildeInPath - return URL(fileURLWithPath: expanded).standardizedFileURL.path - } - - private nonisolated static func bindingPath(_ child: String, isWithinOrEqualTo parent: String) -> Bool { - if child == parent { return true } - let normalizedParent = parent.hasSuffix("/") ? parent : parent + "/" - return child.hasPrefix(normalizedParent) - } - func composeTabNameLookup(forWorkspaceID workspaceID: UUID) -> [UUID: String] { - guard let index = workspaceIndexMap[workspaceID], workspaces.indices.contains(index) else { + guard let workspace = workspace(withID: workspaceID) else { return [:] } // Compose tab IDs are expected to be unique, but use a @@ -3007,7 +2813,7 @@ class WorkspaceManagerViewModel: ObservableObject { // ever produces a duplicate — a stale name lookup is strictly // better than a crash. return Dictionary( - workspaces[index].composeTabs.map { ($0.id, $0.name) }, + workspace.composeTabs.map { ($0.id, $0.name) }, uniquingKeysWith: { _, last in last } ) } @@ -3057,38 +2863,35 @@ class WorkspaceManagerViewModel: ObservableObject { var changedWorkspaceIDs = Set() let now = Date() - for workspaceIndex in workspaces.indices where workspaceID == nil || workspaces[workspaceIndex].id == workspaceID { - var didChangeWorkspace = false - - for tabIndex in workspaces[workspaceIndex].composeTabs.indices - where workspaces[workspaceIndex].composeTabs[tabIndex].activeAgentSessionID == sessionID + workspaceTransaction(touchDateModified: false) { transaction in + for workspaceIndex in transaction.workspaces.indices + where workspaceID == nil || transaction.workspaces[workspaceIndex].id == workspaceID { - let tabID = workspaces[workspaceIndex].composeTabs[tabIndex].id - workspaces[workspaceIndex].composeTabs[tabIndex].activeAgentSessionID = nil - workspaces[workspaceIndex].composeTabs[tabIndex].lastModified = now - composeTabIDs.insert(tabID) - didChangeWorkspace = true - } - - for stashedIndex in workspaces[workspaceIndex].stashedTabs.indices - where workspaces[workspaceIndex].stashedTabs[stashedIndex].tab.activeAgentSessionID == sessionID - { - let tabID = workspaces[workspaceIndex].stashedTabs[stashedIndex].tab.id - workspaces[workspaceIndex].stashedTabs[stashedIndex].tab.activeAgentSessionID = nil - workspaces[workspaceIndex].stashedTabs[stashedIndex].tab.lastModified = now - stashedTabIDs.insert(tabID) - didChangeWorkspace = true - } - - if didChangeWorkspace { - workspaces[workspaceIndex].dateModified = now - changedWorkspaceIDs.insert(workspaces[workspaceIndex].id) + var didChangeWorkspace = false + for tabIndex in transaction.workspaces[workspaceIndex].composeTabs.indices + where transaction.workspaces[workspaceIndex].composeTabs[tabIndex].activeAgentSessionID == sessionID + { + let tabID = transaction.workspaces[workspaceIndex].composeTabs[tabIndex].id + transaction.workspaces[workspaceIndex].composeTabs[tabIndex].activeAgentSessionID = nil + transaction.workspaces[workspaceIndex].composeTabs[tabIndex].lastModified = now + composeTabIDs.insert(tabID) + didChangeWorkspace = true + } + for stashedIndex in transaction.workspaces[workspaceIndex].stashedTabs.indices + where transaction.workspaces[workspaceIndex].stashedTabs[stashedIndex].tab.activeAgentSessionID == sessionID + { + let tabID = transaction.workspaces[workspaceIndex].stashedTabs[stashedIndex].tab.id + transaction.workspaces[workspaceIndex].stashedTabs[stashedIndex].tab.activeAgentSessionID = nil + transaction.workspaces[workspaceIndex].stashedTabs[stashedIndex].tab.lastModified = now + stashedTabIDs.insert(tabID) + didChangeWorkspace = true + } + if didChangeWorkspace { + transaction.workspaces[workspaceIndex].dateModified = now + changedWorkspaceIDs.insert(transaction.workspaces[workspaceIndex].id) + } } } - - for id in changedWorkspaceIDs { - bumpStateVersion(for: id) - } if !changedWorkspaceIDs.isEmpty { let workspacesToSave = workspaces.filter { changedWorkspaceIDs.contains($0.id) } Task { @MainActor [weak self] in @@ -3113,51 +2916,82 @@ class WorkspaceManagerViewModel: ObservableObject { forTabID tabID: UUID, inWorkspaceID workspaceID: UUID? = nil ) -> Bool { - for workspaceIndex in workspaces.indices where workspaceID == nil || workspaces[workspaceIndex].id == workspaceID { - if let tabIndex = workspaces[workspaceIndex].composeTabs.firstIndex(where: { $0.id == tabID }) { - guard workspaces[workspaceIndex].composeTabs[tabIndex].activeAgentSessionID == expectedSessionID else { return false } - workspaces[workspaceIndex].composeTabs[tabIndex].activeAgentSessionID = sessionID - workspaces[workspaceIndex].composeTabs[tabIndex].lastModified = Date() - workspaces[workspaceIndex].dateModified = Date() - markWorkspaceDirty() - return true + guard let target = workspaces.first(where: { workspace in + (workspaceID == nil || workspace.id == workspaceID) && + ( + workspace.composeTabs.contains(where: { $0.id == tabID }) || + workspace.stashedTabs.contains(where: { $0.tab.id == tabID }) + ) + }) else { return false } + let now = Date() + var didSet = false + _ = mutateWorkspace(id: target.id, touchDateModified: false) { workspace in + if let tabIndex = workspace.composeTabs.firstIndex(where: { $0.id == tabID }) { + guard workspace.composeTabs[tabIndex].activeAgentSessionID == expectedSessionID else { return } + workspace.composeTabs[tabIndex].activeAgentSessionID = sessionID + workspace.composeTabs[tabIndex].lastModified = now + didSet = true + } else if let stashedIndex = workspace.stashedTabs.firstIndex(where: { $0.tab.id == tabID }) { + guard workspace.stashedTabs[stashedIndex].tab.activeAgentSessionID == expectedSessionID else { return } + workspace.stashedTabs[stashedIndex].tab.activeAgentSessionID = sessionID + workspace.stashedTabs[stashedIndex].tab.lastModified = now + didSet = true } - - if let stashedIndex = workspaces[workspaceIndex].stashedTabs.firstIndex(where: { $0.tab.id == tabID }) { - guard workspaces[workspaceIndex].stashedTabs[stashedIndex].tab.activeAgentSessionID == expectedSessionID else { return false } - workspaces[workspaceIndex].stashedTabs[stashedIndex].tab.activeAgentSessionID = sessionID - workspaces[workspaceIndex].stashedTabs[stashedIndex].tab.lastModified = Date() - workspaces[workspaceIndex].dateModified = Date() - markWorkspaceDirty() - return true + if didSet { + workspace.dateModified = now } } - return false + return didSet } - func updateComposeTab(_ tab: ComposeTabState, markDirty: Bool = true) { - for workspaceIndex in workspaces.indices { - if let tabIndex = workspaces[workspaceIndex].composeTabs.firstIndex(where: { $0.id == tab.id }) { - let oldSelection = workspaces[workspaceIndex].composeTabs[tabIndex].selection - workspaces[workspaceIndex].composeTabs[tabIndex] = tab - recordSelectionRevisionIfChanged( - workspaceIndex: workspaceIndex, - tabIndex: tabIndex, - oldSelection: oldSelection, - newSelection: tab.selection, - reason: "updateComposeTab" + @MainActor + @discardableResult + func setActiveAgentSessionID(_ sessionID: UUID?, forTabID tabID: UUID, inWorkspaceID workspaceID: UUID? = nil) -> Bool { + guard let target = workspaces.first(where: { workspace in + (workspaceID == nil || workspace.id == workspaceID) && + ( + workspace.composeTabs.contains(where: { $0.id == tabID }) || + workspace.stashedTabs.contains(where: { $0.tab.id == tabID }) ) - workspaces[workspaceIndex].dateModified = Date() - if workspaces[workspaceIndex].id == activeWorkspaceID { - // Sync promptText to live UI if updating the active tab - let isActiveTab = workspaces[workspaceIndex].activeComposeTabID == tab.id - promptViewModel.loadComposeTabsFromWorkspace(workspaces[workspaceIndex], syncPromptText: isActiveTab) - } - if markDirty { - markWorkspaceDirty() - } - return + }) else { return false } + let now = Date() + _ = mutateWorkspace(id: target.id, touchDateModified: false) { workspace in + if let tabIndex = workspace.composeTabs.firstIndex(where: { $0.id == tabID }) { + workspace.composeTabs[tabIndex].activeAgentSessionID = sessionID + workspace.composeTabs[tabIndex].lastModified = now + } else if let stashedIndex = workspace.stashedTabs.firstIndex(where: { $0.tab.id == tabID }) { + workspace.stashedTabs[stashedIndex].tab.activeAgentSessionID = sessionID + workspace.stashedTabs[stashedIndex].tab.lastModified = now } + workspace.dateModified = now + } + return true + } + + func updateComposeTab(_ tab: ComposeTabState, markDirty: Bool = true) { + guard let workspace = workspaces.first(where: { $0.composeTabs.contains(where: { $0.id == tab.id }) }), + let oldTab = workspace.composeTabs.first(where: { $0.id == tab.id }) else { return } + _ = mutateComposeTab( + workspaceID: workspace.id, + tabID: tab.id, + touchDateModified: true, + markDirty: markDirty + ) { $0 = tab } + guard let updatedWorkspace = self.workspace(withID: workspace.id), + let workspaceIndex = workspaceIndex(for: workspace.id), + let tabIndex = updatedWorkspace.composeTabs.firstIndex(where: { $0.id == tab.id }) else { return } + recordSelectionRevisionIfChanged( + workspaceIndex: workspaceIndex, + tabIndex: tabIndex, + oldSelection: oldTab.selection, + newSelection: tab.selection, + reason: "updateComposeTab" + ) + if workspace.id == activeWorkspaceID { + promptViewModel.loadComposeTabsFromWorkspace( + updatedWorkspace, + syncPromptText: updatedWorkspace.activeComposeTabID == tab.id + ) } } @@ -3187,30 +3021,21 @@ class WorkspaceManagerViewModel: ObservableObject { /// Used by MCP virtual context commits to avoid triggering empty live-UI snapshots. @MainActor func updateComposeTabStoredOnly(_ tab: ComposeTabState) { - // Update the tab content in-place, without touching the live UI - for wi in workspaces.indices { - if let ti = workspaces[wi].composeTabs.firstIndex(where: { $0.id == tab.id }) { - let oldSelection = workspaces[wi].composeTabs[ti].selection - var t = tab - // Ensure a fresh modified timestamp - t.lastModified = Date() - workspaces[wi].composeTabs[ti] = t - recordSelectionRevisionIfChanged( - workspaceIndex: wi, - tabIndex: ti, - oldSelection: oldSelection, - newSelection: t.selection, - reason: "updateComposeTabStoredOnly" - ) - workspaces[wi].dateModified = Date() - - // Important: do NOT call promptViewModel.loadComposeTabsFromWorkspace(...) - // and do NOT publish snapshots here. We only persist the stored tab data. - - // Always mark dirty so autosave/polling will persist this later - markWorkspaceDirty() - return - } + guard let workspace = workspaces.first(where: { $0.composeTabs.contains(where: { $0.id == tab.id }) }), + let oldTab = workspace.composeTabs.first(where: { $0.id == tab.id }) else { return } + var updated = tab + updated.lastModified = Date() + _ = mutateComposeTab(workspaceID: workspace.id, tabID: tab.id) { $0 = updated } + if let workspaceIndex = workspaceIndex(for: workspace.id), + let tabIndex = self.workspace(withID: workspace.id)?.composeTabs.firstIndex(where: { $0.id == tab.id }) + { + recordSelectionRevisionIfChanged( + workspaceIndex: workspaceIndex, + tabIndex: tabIndex, + oldSelection: oldTab.selection, + newSelection: updated.selection, + reason: "updateComposeTabStoredOnly" + ) } } @@ -3344,40 +3169,24 @@ class WorkspaceManagerViewModel: ObservableObject { // ───────────────────────────────────────────────────────────── // MARK: - State helpers - /// ───────────────────────────────────────────────────────────── - /// Updates the cached "working" state for a workspace *iff* something actually - /// changed, and returns `true` when a mutation occurs. - private func updateWorkspaceState( - at index: Int, - with state: ( - expandedFolders: [String], - selection: StoredSelection, - promptText: String, - promptIDs: [UUID] - ) - ) -> Bool { - workspaces[index].currentPromptText = state.promptText - workspaces[index].selectedMetaPromptIDs = state.promptIDs - // NEW: Persist preset selections and customizations - workspaces[index].copyPresetId = promptViewModel.selectedCopyPresetID - workspaces[index].copyCustomizations = promptViewModel.workingCopyCustomizations - workspaces[index].chatPresetId = promptViewModel.selectedChatPresetID - workspaces[index].dateModified = Date() - return true - } - @discardableResult - private func captureActiveTabSnapshotForWorkspaceIndex(_ index: Int, source: WorkspaceSaveSource = .pollAndSaveState) -> ComposeTabState? { + private func captureActiveTabSnapshotForWorkspaceIndex( + _ index: Int, + source: WorkspaceSaveSource = .pollAndSaveState + ) -> ComposeTabState? { guard workspaces.indices.contains(index) else { return nil } + let workspaceID = workspaces[index].id let (name, _) = activeComposeTabContext() - if let activeTabID = workspaces[index].activeComposeTabID, - let tabIndex = workspaces[index].composeTabs.firstIndex(where: { $0.id == activeTabID }) + guard let workspace = workspace(withID: workspaceID) else { return nil } + + if let activeTabID = workspace.activeComposeTabID, + let storedTab = workspace.composeTabs.first(where: { $0.id == activeTabID }) { #if DEBUG - debugSelectionOwnerTraceEvent("save.capture.before", workspace: workspaces[index]) + debugSelectionOwnerTraceEvent("save.capture.before", workspace: workspace) #endif - let storedSelection = workspaces[index].composeTabs[tabIndex].selection - var snapshot = collectComposeTabSnapshot(name: name, base: workspaces[index].composeTabs[tabIndex]) + let storedSelection = storedTab.selection + var snapshot = collectComposeTabSnapshot(name: name, base: storedTab) let liveUISelection = snapshot.selection let canonical = selectionCoordinator?.activeSelectionSnapshot(flushPendingUI: false) let saveSelection = Self.selectionForSaveSnapshot( @@ -3388,30 +3197,41 @@ class WorkspaceManagerViewModel: ObservableObject { activeTabID: activeTabID ) snapshot.selection = saveSelection.selection - workspaces[index].composeTabs[tabIndex] = snapshot - workspaces[index].dateModified = Date() - _ = updateWorkspaceState( - at: index, - with: (snapshot.expandedFolders, snapshot.selection, snapshot.promptText, snapshot.selectedMetaPromptIDs) - ) - let metadata = workspaceSaveMetadata(for: workspaces[index], source: source) - WorkspaceSaveTracer.capture( - metadata: metadata, - url: workspaceFileURL(for: workspaces[index]), - liveUI: liveUISelection, - stored: storedSelection, - canonical: canonical?.selection, - chosenOwner: saveSelection.owner - ) - #if DEBUG - debugSelectionOwnerTraceEvent("save.capture.after", workspace: workspaces[index]) - #endif + let updatedWorkspace = mutateWorkspace(id: workspaceID) { workspace in + guard let tabIndex = workspace.composeTabs.firstIndex(where: { $0.id == activeTabID }) else { return } + workspace.composeTabs[tabIndex] = snapshot + workspace.currentPromptText = snapshot.promptText + workspace.selectedMetaPromptIDs = snapshot.selectedMetaPromptIDs + workspace.copyPresetId = promptViewModel.selectedCopyPresetID + workspace.copyCustomizations = promptViewModel.workingCopyCustomizations + workspace.chatPresetId = promptViewModel.selectedChatPresetID + } + if let updatedWorkspace { + let metadata = workspaceSaveMetadata(for: updatedWorkspace, source: source) + WorkspaceSaveTracer.capture( + metadata: metadata, + url: workspaceFileURL(for: updatedWorkspace), + liveUI: liveUISelection, + stored: storedSelection, + canonical: canonical?.selection, + chosenOwner: saveSelection.owner + ) + #if DEBUG + debugSelectionOwnerTraceEvent("save.capture.after", workspace: updatedWorkspace) + #endif + } return snapshot - } else { - let legacy = collectWorkspaceState() - _ = updateWorkspaceState(at: index, with: legacy) - return nil } + + let legacy = collectWorkspaceState() + _ = mutateWorkspace(id: workspaceID) { workspace in + workspace.currentPromptText = legacy.promptText + workspace.selectedMetaPromptIDs = legacy.promptIDs + workspace.copyPresetId = promptViewModel.selectedCopyPresetID + workspace.copyCustomizations = promptViewModel.workingCopyCustomizations + workspace.chatPresetId = promptViewModel.selectedChatPresetID + } + return nil } func pollAndSaveState(source: WorkspaceSaveSource = .pollAndSaveState) { @@ -3448,10 +3268,7 @@ class WorkspaceManagerViewModel: ObservableObject { let index = workspaces.firstIndex(where: { $0.id == active.id }) else { return } let wsID = active.id - let cur = stateVersionByWorkspaceID[wsID, default: 0] - let last = lastSavedVersionByWorkspaceID[wsID, default: -1] - - guard cur != last else { return } // not dirty → nothing to do + guard sessionController.isDirty(workspaceID: wsID) else { return } // Post notification to allow SwiftUI views to flush pending state NotificationCenter.default.post( @@ -3475,12 +3292,7 @@ class WorkspaceManagerViewModel: ObservableObject { if let snapshot { composeTabSnapshotSubject.send(snapshot) } - await saveWorkspaceAsync(source: source) // see change below to avoid big reassign - if let savedWorkspace = workspace(withID: wsID) { - await WorkspaceDiskWriter.shared.flush(url: workspaceFileURL(for: savedWorkspace)) - } - - lastSavedVersionByWorkspaceID[wsID] = cur + _ = await saveWorkspaceAsync(source: source) } func restoreWorkspaceState(_ workspace: WorkspaceModel) async { @@ -3522,8 +3334,8 @@ class WorkspaceManagerViewModel: ObservableObject { let normalizationStartMS = WorkspaceRestorePerfLog.timestampMSIfEnabled() let composeTabsBeforeNormalization = upgraded.composeTabs.count #endif - upgraded.normalizeComposeTabInvariants() - workspaces[index] = upgraded + let composeTabsNormalized = upgraded.normalizeComposeTabInvariants() + _ = mutateWorkspace(id: wsID, touchDateModified: false, markDirty: false) { $0 = upgraded } #if DEBUG WorkspaceRestorePerfLog.event( "workspaceSwitch.restoreState.normalization", @@ -3531,7 +3343,7 @@ class WorkspaceManagerViewModel: ObservableObject { "workspaceID": WorkspaceRestorePerfLog.shortID(wsID), "composeTabsBefore": "\(composeTabsBeforeNormalization)", "composeTabsAfter": "\(upgraded.composeTabs.count)", - "composeTabsNormalized": "\(upgraded.normalizationRequiresSave)", + "composeTabsNormalized": "\(composeTabsNormalized)", "activeComposeTabID": WorkspaceRestorePerfLog.shortID(upgraded.activeComposeTabID), "duration": normalizationStartMS.map { WorkspaceRestorePerfLog.formatElapsedMS(since: $0) } ?? "notMeasured" ] @@ -3640,8 +3452,10 @@ class WorkspaceManagerViewModel: ObservableObject { #if DEBUG let legacyMirrorStartMS = WorkspaceRestorePerfLog.timestampMSIfEnabled() #endif - workspaces[idx2].currentPromptText = latestAppliedTab.promptText - workspaces[idx2].selectedMetaPromptIDs = latestAppliedTab.selectedMetaPromptIDs + _ = mutateWorkspace(id: wsID, touchDateModified: false, markDirty: false) { workspace in + workspace.currentPromptText = latestAppliedTab.promptText + workspace.selectedMetaPromptIDs = latestAppliedTab.selectedMetaPromptIDs + } #if DEBUG WorkspaceRestorePerfLog.event( "workspaceSwitch.restoreState.legacyWorkspaceMirror", @@ -3700,7 +3514,7 @@ class WorkspaceManagerViewModel: ObservableObject { } let activeCopyPreset = promptViewModel.currentCopyPreset() - let storedWorkspaceCustomizations = workspaces[idx2].copyCustomizations + let storedWorkspaceCustomizations = self.workspace(withID: wsID)?.copyCustomizations if activeCopyPreset.builtInKind == .manual { let sanitizedWorkspaceCustomizations = storedWorkspaceCustomizations? .removingCodeMapUsageOverride() @@ -3708,9 +3522,8 @@ class WorkspaceManagerViewModel: ObservableObject { ? sanitizedWorkspaceCustomizations : nil promptViewModel.workingCopyCustomizations = persistedWorkspaceCustomizations ?? .init() - if workspaces[idx2].copyCustomizations != persistedWorkspaceCustomizations { - workspaces[idx2].copyCustomizations = persistedWorkspaceCustomizations - markWorkspaceDirty() + if self.workspace(withID: wsID)?.copyCustomizations != persistedWorkspaceCustomizations { + mutateWorkspace(id: wsID) { $0.copyCustomizations = persistedWorkspaceCustomizations } } } else { promptViewModel.workingCopyCustomizations = storedWorkspaceCustomizations ?? .init() @@ -3723,7 +3536,7 @@ class WorkspaceManagerViewModel: ObservableObject { "savedCopyPreset": workspace.copyPresetId?.uuidString ?? "nil", "activeCopyPreset": activeCopyPreset.id.uuidString, "manualPreset": "\(activeCopyPreset.builtInKind == .manual)", - "customizationsDirty": "\(workspaces[idx2].copyCustomizations != storedWorkspaceCustomizations)", + "customizationsDirty": "\(self.workspace(withID: wsID)?.copyCustomizations != storedWorkspaceCustomizations)", "duration": copyPresetStartMS.map { WorkspaceRestorePerfLog.formatElapsedMS(since: $0) } ?? "notMeasured" ] ) @@ -4026,7 +3839,7 @@ class WorkspaceManagerViewModel: ObservableObject { let diskSnapshot = await loadWorkspaceSnapshotFromDisk() if !diskSnapshot.isEmpty { - workspaces = diskSnapshot + sessionController.replaceAll(diskSnapshot, activeWorkspaceID: activeWorkspaceID) } let activeWorkspaceIDs = Set(windowStates.allWindows.compactMap { $0.workspaceManager.activeWorkspace?.id }) @@ -4095,12 +3908,12 @@ class WorkspaceManagerViewModel: ObservableObject { ) do { - await WorkspaceDiskWriter.shared.flush(url: workspaceFileURL(for: workspaces[canonicalIndex])) + await sessionController.persistenceWriter.flush(url: workspaceFileURL(for: workspaces[canonicalIndex])) for duplicate in commitDuplicates { - await WorkspaceDiskWriter.shared.flush(url: workspaceFileURL(for: duplicate)) + await sessionController.persistenceWriter.flush(url: workspaceFileURL(for: duplicate)) } let mergedURL = try await saveWorkspaceToFileAsync(merged, preserveDiskRepoPathsIfUnchangedSinceBaseline: false, source: .duplicateCleanupCanonicalMerge) - await WorkspaceDiskWriter.shared.flush(url: mergedURL) + await sessionController.persistenceWriter.flush(url: mergedURL) } catch { for duplicate in commitDuplicates { skipped.append( @@ -4128,9 +3941,12 @@ class WorkspaceManagerViewModel: ObservableObject { } continue } - workspaces[commitCanonicalIndex] = merged let committedDuplicateIDs = Set(commitDuplicates.map(\.id)) - workspaces.removeAll { committedDuplicateIDs.contains($0.id) } + workspaceTransaction(touchDateModified: false) { transaction in + guard let canonicalIndex = transaction.workspaceIndex(id: plan.canonical.id) else { return } + transaction.workspaces[canonicalIndex] = merged + transaction.workspaces.removeAll { committedDuplicateIDs.contains($0.id) } + } purgeStaleCodeMapCachesForKnownRoots() for duplicate in commitDuplicates { @@ -4153,7 +3969,7 @@ class WorkspaceManagerViewModel: ObservableObject { if groupsConsolidated > 0 { await rebuildAndSaveIndexAsync() - await WorkspaceDiskWriter.shared.flush(url: workspaceIndexFileURL) + await sessionController.persistenceWriter.flush(url: workspaceIndexFileURL) for window in windowStates.allWindows { window.workspaceManager.reloadWorkspacesFromDisk() } @@ -4413,7 +4229,7 @@ class WorkspaceManagerViewModel: ObservableObject { // Phase 2 removes duplicate records from the index, but intentionally keeps // the backing workspace directory/file in place. Sidecar data such as Chats/ // is not merged yet, so deleting the folder here would be destructive. - await WorkspaceDiskWriter.shared.flush(url: workspaceFileURL(for: workspace)) + await sessionController.persistenceWriter.flush(url: workspaceFileURL(for: workspace)) } nonisolated static func test_duplicateWorkspaceGroups( @@ -4461,9 +4277,11 @@ class WorkspaceManagerViewModel: ObservableObject { // MARK: - CRUD func deleteWorkspace(_ workspace: WorkspaceModel) { - workspaces.removeAll { $0.id == workspace.id } - if activeWorkspaceID == workspace.id { - activeWorkspaceID = nil + workspaceTransaction(touchDateModified: false) { transaction in + transaction.workspaces.removeAll { $0.id == workspace.id } + if transaction.activeWorkspaceID == workspace.id { + transaction.activeWorkspaceID = nil + } } purgeStaleCodeMapCachesForKnownRoots() @@ -4488,7 +4306,7 @@ class WorkspaceManagerViewModel: ObservableObject { // Schedule async index save and notify after completion and flush Task { await rebuildAndSaveIndexAsync() - await WorkspaceDiskWriter.shared.flush(url: workspaceIndexFileURL) + await sessionController.persistenceWriter.flush(url: workspaceIndexFileURL) await MainActor.run { NotificationCenter.default.post( name: .workspaceListDidChange, @@ -4504,10 +4322,9 @@ class WorkspaceManagerViewModel: ObservableObject { let finalName = newName.trimmingCharacters(in: .whitespaces) guard !finalName.isEmpty else { return } - workspaces[index].name = finalName - workspaces[index].dateModified = Date() + guard let updated = mutateWorkspace(id: workspace.id, { $0.name = finalName }) else { return } - if workspaces[index].customStoragePath == nil { + if updated.customStoragePath == nil { // Preserve the original base location (global or default) let baseLocation = currentBaseRoot // 'workspace' param still holds the old name – use it to locate old folder @@ -4527,11 +4344,12 @@ class WorkspaceManagerViewModel: ObservableObject { // Schedule async save of the specific workspace and index update, with flushes before notify Task { do { - let finalURL = try await saveWorkspaceToFileAsync(workspaces[index], source: .renameWorkspace) - await WorkspaceDiskWriter.shared.flush(url: finalURL) + guard let current = self.workspace(withID: workspace.id) else { return } + let finalURL = try await saveWorkspaceToFileAsync(current, source: .renameWorkspace) + await sessionController.persistenceWriter.flush(url: finalURL) await rebuildAndSaveIndexAsync() - await WorkspaceDiskWriter.shared.flush(url: workspaceIndexFileURL) + await sessionController.persistenceWriter.flush(url: workspaceIndexFileURL) await MainActor.run { NotificationCenter.default.post( @@ -4547,17 +4365,16 @@ class WorkspaceManagerViewModel: ObservableObject { } func setWorkspaceHidden(_ workspace: WorkspaceModel, hidden: Bool) { - guard let index = workspaces.firstIndex(where: { $0.id == workspace.id }) else { return } - workspaces[index].isHiddenInMenus = hidden - workspaces[index].dateModified = Date() + guard mutateWorkspace(id: workspace.id, { $0.isHiddenInMenus = hidden }) != nil else { return } Task { do { - let finalURL = try await saveWorkspaceToFileAsync(workspaces[index], source: .setWorkspaceHidden) - await WorkspaceDiskWriter.shared.flush(url: finalURL) + guard let current = self.workspace(withID: workspace.id) else { return } + let finalURL = try await saveWorkspaceToFileAsync(current, source: .setWorkspaceHidden) + await sessionController.persistenceWriter.flush(url: finalURL) await rebuildAndSaveIndexAsync() - await WorkspaceDiskWriter.shared.flush(url: workspaceIndexFileURL) + await sessionController.persistenceWriter.flush(url: workspaceIndexFileURL) await MainActor.run { NotificationCenter.default.post( @@ -4577,18 +4394,19 @@ class WorkspaceManagerViewModel: ObservableObject { updated.isHiddenInMenus = hidden updated.dateModified = Date() - if let index = workspaces.firstIndex(where: { $0.id == updated.id }) { - workspaces[index].isHiddenInMenus = hidden - workspaces[index].dateModified = updated.dateModified - } else { - workspaces.append(updated) + workspaceTransaction(touchDateModified: false) { transaction in + if let index = transaction.workspaceIndex(id: updated.id) { + transaction.workspaces[index] = updated + } else { + transaction.workspaces.append(updated) + } } let finalURL = try await saveWorkspaceToFileAsync(updated, source: .setWorkspaceHiddenFromSnapshot) - await WorkspaceDiskWriter.shared.flush(url: finalURL) + await sessionController.persistenceWriter.flush(url: finalURL) await rebuildAndSaveIndexAsync() - await WorkspaceDiskWriter.shared.flush(url: workspaceIndexFileURL) + await sessionController.persistenceWriter.flush(url: workspaceIndexFileURL) NotificationCenter.default.post( name: .workspaceListDidChange, @@ -4600,9 +4418,10 @@ class WorkspaceManagerViewModel: ObservableObject { } func applyWorkspaceHiddenStateInMemory(workspaceID: UUID, hidden: Bool, dateModified: Date) { - guard let index = workspaces.firstIndex(where: { $0.id == workspaceID }) else { return } - workspaces[index].isHiddenInMenus = hidden - workspaces[index].dateModified = dateModified + mutateWorkspace(id: workspaceID, touchDateModified: false) { + $0.isHiddenInMenus = hidden + $0.dateModified = dateModified + } } // MARK: - FOLDER LOAD @@ -5290,417 +5109,23 @@ class WorkspaceManagerViewModel: ObservableObject { return (updated, true) } - // MARK: - Global disk-writer - - /// Shared actor for serialized workspace disk writes across all windows - actor WorkspaceDiskWriter { - // MARK: internal model - - private struct Pending { - var newestData: Data - var newestMetadata: WorkspaceSavePayloadMetadata? - var newestLifecycleCorrelation: EditFlowPerf.LifecycleCorrelation? - var task: Task? - } - - private struct LatestSelectionRecord { - let revision: UInt64 - let selection: StoredSelection - let metadata: WorkspaceSavePayloadMetadata - } - - private struct EffectiveWritePayload { - let data: Data - let metadata: WorkspaceSavePayloadMetadata? - let selectionKey: WorkspaceTabSelectionKey? - let effectiveSelectionRevision: UInt64 - let shouldWrite: Bool - } - - private var pendingByURL: [URL: Pending] = [:] - private var waitersByURL: [URL: [CheckedContinuation]] = [:] - private var latestSelectionByWorkspaceTab: [WorkspaceTabSelectionKey: LatestSelectionRecord] = [:] - private var lastWrittenSelectionRevisionByWorkspaceTab: [WorkspaceTabSelectionKey: UInt64] = [:] - #if DEBUG - private var atomicWriteGateForTesting: (@Sendable () async -> Void)? - #endif - - // MARK: public API - - static let shared = WorkspaceDiskWriter() - - func enqueue(data: Data, url: URL) { - enqueue(data: data, url: url, metadata: nil) - } - - func enqueueWorkspace(data: Data, url: URL, metadata: WorkspaceSavePayloadMetadata) { - enqueue(data: data, url: url, metadata: metadata) - } - - private func enqueue(data: Data, url: URL, metadata: WorkspaceSavePayloadMetadata?) { - let lifecycleCorrelation = EditFlowPerf.currentLifecycleCorrelation - WorkspaceSaveTracer.event("workspaceSave.enqueue", metadata: metadata, url: url) - recordLatestSelectionIfNeeded(metadata) - - if var pending = pendingByURL[url] { - let decision: String - if let metadata, - let existingMetadata = pending.newestMetadata, - metadata.activeSelectionRevision > existingMetadata.activeSelectionRevision - { - pending.newestData = data - pending.newestMetadata = metadata - pending.newestLifecycleCorrelation = lifecycleCorrelation ?? pending.newestLifecycleCorrelation - decision = "replacedExistingNewerSelectionRevision" - } else if Self.shouldKeepExistingWorkspacePayload(existing: pending.newestData, incoming: data, url: url) { - decision = "keptExistingNewerDate" - WorkspaceSaveTracer.event("workspaceSave.coalesce", metadata: metadata, url: url, extra: ["decision": decision]) - return - } else { - pending.newestData = data - pending.newestMetadata = metadata - pending.newestLifecycleCorrelation = lifecycleCorrelation ?? pending.newestLifecycleCorrelation - decision = "storedAsNewest" - } - pendingByURL[url] = pending - WorkspaceSaveTracer.event("workspaceSave.coalesce", metadata: metadata, url: url, extra: ["decision": decision]) - return - } - - pendingByURL[url] = Pending( - newestData: data, - newestMetadata: metadata, - newestLifecycleCorrelation: lifecycleCorrelation, - task: nil - ) - runNext(for: url) - } - - func enqueueAndWait(data: Data, url: URL) async { - enqueue(data: data, url: url) - await flush(url: url) - } - - func writeNormalizationIfUnchanged( - data: Data, - url: URL, - expectedFileSize: Int64, - expectedModificationDate: Date, - metadata: WorkspaceSavePayloadMetadata? = nil - ) -> Bool { - guard pendingByURL[url] == nil else { return false } - do { - let values = try url.resourceValues(forKeys: [.fileSizeKey, .contentModificationDateKey]) - guard Int64(values.fileSize ?? -1) == expectedFileSize, - values.contentModificationDate == expectedModificationDate - else { - return false - } - WorkspaceSaveTracer.event("workspaceSave.syncWrite.begin", metadata: metadata, url: url, extra: ["path": "normalization"]) - let writeState = EditFlowPerf.begin(EditFlowPerf.Stage.WorkspaceDurability.atomicWrite) - EditFlowPerf.lifecycleEvent(EditFlowPerf.Lifecycle.WorkspaceDurability.writeBegan) - defer { - EditFlowPerf.lifecycleEvent(EditFlowPerf.Lifecycle.WorkspaceDurability.writeEnded) - EditFlowPerf.end(EditFlowPerf.Stage.WorkspaceDurability.atomicWrite, writeState) - } - try data.write(to: url, options: .atomic) - recordLatestSelectionIfNeeded(metadata) - WorkspaceSaveTracer.event("workspaceSave.syncWrite.success", metadata: metadata, url: url, extra: ["path": "normalization"]) - return true - } catch { - WorkspaceSaveTracer.event("workspaceSave.syncWrite.failure", metadata: metadata, url: url, extra: ["error": error.localizedDescription, "path": "normalization"]) - print("💾 Normalization write skipped \(url.lastPathComponent): \(error)") - return false - } - } - - func flush(url: URL) async { - if pendingByURL[url] == nil { return } - let lifecycleCorrelation = EditFlowPerf.currentLifecycleCorrelation - let flushState = EditFlowPerf.begin(EditFlowPerf.Stage.WorkspaceDurability.flushWait) - EditFlowPerf.lifecycleEvent( - EditFlowPerf.Lifecycle.WorkspaceDurability.flushBegan, - correlation: lifecycleCorrelation - ) - await withCheckedContinuation { (cont: CheckedContinuation) in - waitersByURL[url, default: []].append(cont) - } - EditFlowPerf.lifecycleEvent( - EditFlowPerf.Lifecycle.WorkspaceDurability.flushEnded, - correlation: lifecycleCorrelation - ) - EditFlowPerf.end(EditFlowPerf.Stage.WorkspaceDurability.flushWait, flushState) - } - - #if DEBUG - func setAtomicWriteGateForTesting(_ gate: (@Sendable () async -> Void)?) { - atomicWriteGateForTesting = gate - } - - func removeAllForTesting() { - for (_, pending) in pendingByURL { - pending.task?.cancel() - } - pendingByURL.removeAll() - latestSelectionByWorkspaceTab.removeAll() - lastWrittenSelectionRevisionByWorkspaceTab.removeAll() - atomicWriteGateForTesting = nil - let allWaiters = waitersByURL.values.flatMap(\.self) - waitersByURL.removeAll() - for waiter in allWaiters { - waiter.resume() - } - } - #endif - - // MARK: private helpers - - private static func decodedWorkspacePayload(_ data: Data) -> WorkspaceModel? { - guard !data.isEmpty else { return nil } - return try? JSONDecoder().decode(WorkspaceModel.self, from: data) - } - - private func recordLatestSelectionIfNeeded(_ metadata: WorkspaceSavePayloadMetadata?) { - guard let metadata, - let key = metadata.selectionKey, - let selection = metadata.activeSelection, - metadata.activeSelectionRevision > 0 - else { return } - if let existing = latestSelectionByWorkspaceTab[key], existing.revision >= metadata.activeSelectionRevision { - return - } - latestSelectionByWorkspaceTab[key] = LatestSelectionRecord( - revision: metadata.activeSelectionRevision, - selection: selection, - metadata: metadata - ) - } - - private static func shouldKeepExistingWorkspacePayload(existing: Data, incoming: Data, url: URL) -> Bool { - guard let existingWorkspace = decodedWorkspacePayload(existing), - let incomingWorkspace = decodedWorkspacePayload(incoming), - existingWorkspace.id == incomingWorkspace.id, - existingWorkspace.dateModified > incomingWorkspace.dateModified - else { - return false - } - #if DEBUG - WorkspaceRestorePerfLog.event( - "workspaceDiskWriter.skipStaleCoalescedPayload", - fields: [ - "workspaceID": WorkspaceRestorePerfLog.shortID(incomingWorkspace.id), - "workspaceName": incomingWorkspace.name, - "url": url.lastPathComponent - ] - ) - #endif - return true - } - - private static func effectivePayloadForWrite( - payload: Data, - url: URL, - metadata: WorkspaceSavePayloadMetadata?, - latestRecord: LatestSelectionRecord?, - lastWrittenRevision: UInt64 - ) -> EffectiveWritePayload { - guard let metadata, - let incomingWorkspace = decodedWorkspacePayload(payload), - incomingWorkspace.id == metadata.workspaceID - else { - let shouldWrite = !shouldSkipStaleWorkspaceDiskWrite(payload: payload, url: url, metadata: metadata) - return EffectiveWritePayload(data: payload, metadata: metadata, selectionKey: metadata?.selectionKey, effectiveSelectionRevision: metadata?.activeSelectionRevision ?? 0, shouldWrite: shouldWrite) - } - - let key = metadata.selectionKey - let incomingRevision = metadata.activeSelectionRevision - let latestRevision = latestRecord?.revision ?? incomingRevision - let latestSelection = latestRecord?.selection ?? metadata.activeSelection - let latestMetadata = latestRecord?.metadata ?? metadata - let diskWorkspace: WorkspaceModel? = if FileManager.default.fileExists(atPath: url.path), - let diskData = try? Data(contentsOf: url), - let decoded = decodedWorkspacePayload(diskData), - decoded.id == incomingWorkspace.id - { - decoded - } else { - nil - } - - if let diskWorkspace, diskWorkspace.dateModified > incomingWorkspace.dateModified { - if latestRevision > lastWrittenRevision, - let latestSelection, - let activeTabID = metadata.activeTabID - { - let applied = WorkspaceManagerViewModel.workspaceByApplyingSelection(latestSelection, toActiveTab: activeTabID, in: diskWorkspace) - if applied.applied { - var merged = applied.workspace - merged.dateModified = Date() - if let encoded = try? JSONEncoder().encode(merged) { - WorkspaceSaveTracer.event( - "workspaceSave.write.newerSelectionMergedIntoNewerDisk", - metadata: metadata, - url: url, - extra: [ - "latestSelectionRevision": "\(latestRevision)", - "lastWrittenSelectionRevision": "\(lastWrittenRevision)", - "latestPayloadID": latestMetadata.payloadID.uuidString - ] - ) - return EffectiveWritePayload(data: encoded, metadata: latestMetadata, selectionKey: key, effectiveSelectionRevision: latestRevision, shouldWrite: true) - } - } - } - WorkspaceSaveTracer.event("workspaceSave.write.skipStaleDiskPayload", metadata: metadata, url: url) - return EffectiveWritePayload(data: payload, metadata: metadata, selectionKey: key, effectiveSelectionRevision: incomingRevision, shouldWrite: false) - } - - if latestRevision > incomingRevision, - let latestSelection, - let activeTabID = metadata.activeTabID - { - let applied = WorkspaceManagerViewModel.workspaceByApplyingSelection(latestSelection, toActiveTab: activeTabID, in: incomingWorkspace) - if applied.applied, - let encoded = try? JSONEncoder().encode(applied.workspace) - { - WorkspaceSaveTracer.event( - "workspaceSave.write.selectionPreservedFromLatest", - metadata: metadata, - url: url, - extra: [ - "incomingSelectionRevision": "\(incomingRevision)", - "latestSelectionRevision": "\(latestRevision)", - "latestPayloadID": latestMetadata.payloadID.uuidString - ] - ) - return EffectiveWritePayload(data: encoded, metadata: latestMetadata, selectionKey: key, effectiveSelectionRevision: latestRevision, shouldWrite: true) - } - } - - return EffectiveWritePayload(data: payload, metadata: metadata, selectionKey: key, effectiveSelectionRevision: incomingRevision, shouldWrite: true) - } - - private static func shouldSkipStaleWorkspaceDiskWrite(payload: Data, url: URL, metadata: WorkspaceSavePayloadMetadata?) -> Bool { - guard FileManager.default.fileExists(atPath: url.path), - let incomingWorkspace = decodedWorkspacePayload(payload), - let diskData = try? Data(contentsOf: url), - let diskWorkspace = decodedWorkspacePayload(diskData), - diskWorkspace.id == incomingWorkspace.id, - diskWorkspace.dateModified > incomingWorkspace.dateModified - else { - return false - } - WorkspaceSaveTracer.event("workspaceSave.write.skipStaleDiskPayload", metadata: metadata, url: url) - return true - } - - private func runNext(for url: URL) { - guard var slot = pendingByURL[url] else { return } - let payload = slot.newestData - let metadata = slot.newestMetadata - let lifecycleCorrelation = slot.newestLifecycleCorrelation - slot.newestData = Data() - slot.newestMetadata = nil - slot.newestLifecycleCorrelation = nil - pendingByURL[url] = slot - let latestRecord = metadata?.selectionKey.flatMap { latestSelectionByWorkspaceTab[$0] } - let lastWrittenRevision = metadata?.selectionKey.map { lastWrittenSelectionRevisionByWorkspaceTab[$0, default: 0] } ?? 0 - #if DEBUG - let atomicWriteGateForTesting = atomicWriteGateForTesting - #endif - - let task = Task.detached(priority: .utility) { [weak self] in - let effective = Self.effectivePayloadForWrite( - payload: payload, - url: url, - metadata: metadata, - latestRecord: latestRecord, - lastWrittenRevision: lastWrittenRevision - ) - WorkspaceSaveTracer.event("workspaceSave.write.begin", metadata: effective.metadata, url: url, extra: ["shouldWrite": "\(effective.shouldWrite)"]) - var writeSucceeded = false - do { - if effective.shouldWrite { - #if DEBUG - await atomicWriteGateForTesting?() - #endif - let writeState = EditFlowPerf.begin(EditFlowPerf.Stage.WorkspaceDurability.atomicWrite) - EditFlowPerf.lifecycleEvent( - EditFlowPerf.Lifecycle.WorkspaceDurability.writeBegan, - correlation: lifecycleCorrelation - ) - defer { - EditFlowPerf.lifecycleEvent( - EditFlowPerf.Lifecycle.WorkspaceDurability.writeEnded, - correlation: lifecycleCorrelation, - EditFlowPerf.Dimensions(outcome: writeSucceeded ? "success" : "failed") - ) - EditFlowPerf.end( - EditFlowPerf.Stage.WorkspaceDurability.atomicWrite, - writeState, - EditFlowPerf.Dimensions(outcome: writeSucceeded ? "success" : "failed") - ) - } - try effective.data.write(to: url, options: .atomic) - writeSucceeded = true - WorkspaceSaveTracer.event("workspaceSave.write.success", metadata: effective.metadata, url: url) - } - } catch { - WorkspaceSaveTracer.event("workspaceSave.write.failure", metadata: effective.metadata, url: url, extra: ["error": error.localizedDescription]) - print("💾 Write failed \(url.lastPathComponent): \(error)") - } - WorkspaceSaveTracer.event("workspaceSave.write.finish", metadata: effective.metadata, url: url, extra: ["writeSucceeded": "\(writeSucceeded)"]) - await self?.writerFinished(for: url, effective: effective, writeSucceeded: writeSucceeded) - } - if var current = pendingByURL[url] { - current.task = task - pendingByURL[url] = current - } - } - - private func writerFinished(for url: URL, effective: EffectiveWritePayload, writeSucceeded: Bool) { - if writeSucceeded, - let key = effective.selectionKey, - effective.effectiveSelectionRevision > 0 - { - lastWrittenSelectionRevisionByWorkspaceTab[key] = max( - lastWrittenSelectionRevisionByWorkspaceTab[key, default: 0], - effective.effectiveSelectionRevision - ) - } - guard var slot = pendingByURL[url] else { return } - if slot.newestData.isEmpty { - pendingByURL.removeValue(forKey: url) - if let waiters = waitersByURL.removeValue(forKey: url) { - for w in waiters { - w.resume() - } - } - } else { - slot.task = nil - pendingByURL[url] = slot - runNext(for: url) - } - } - } - // MARK: - Save/Load Single Workspace - private func saveWorkspaceAsync(source: WorkspaceSaveSource = .saveWorkspaceAsync) async { + private func saveWorkspaceAsync(source: WorkspaceSaveSource = .saveWorkspaceAsync) async -> WorkspaceSaveSubmission? { guard let active = activeWorkspace, let idx = workspaces.firstIndex(where: { $0.id == active.id }) else { print("No active workspace to save.") - return + return nil } let current = workspaces[idx] - let capturedStateVersion = stateVersionByWorkspaceID[current.id, default: 0] + let capturedStateVersion = sessionController.stateGeneration(workspaceID: current.id) let baseRoot = currentBaseRoot let customStoragePath = current.customStoragePath let workspaceDirName = directoryName(for: current) - let lastSyncedRepoPaths = lastSyncedRepoPathsByWorkspaceID[current.id] - await WorkspaceDiskWriter.shared.flush(url: workspaceFileURL(for: current)) + let lastSyncedRepoPaths = sessionController.repositoryBaseline(workspaceID: current.id) + await sessionController.persistenceWriter.flush(url: workspaceFileURL(for: current)) do { let (merged, data, indexFieldsChanged, preservedDiskRepoPaths, url) = try await Task.detached(priority: .utility) { @@ -5751,7 +5176,7 @@ class WorkspaceManagerViewModel: ObservableObject { return (merged, data, indexFieldsChanged, mergeResult.preservedDiskRepoPaths, url) }.value - let latestStateVersion = stateVersionByWorkspaceID[current.id, default: 0] + let latestStateVersion = sessionController.stateGeneration(workspaceID: current.id) if latestStateVersion != capturedStateVersion { #if DEBUG WorkspaceRestorePerfLog.event( @@ -5763,35 +5188,57 @@ class WorkspaceManagerViewModel: ObservableObject { ] ) #endif - await saveWorkspaceAsync(source: source) - return - } - - // IMPORTANT: Do NOT assign `workspaces[idx] = merged` here. - // Our in-memory `workspaces[idx]` already contains our working state. - // If index-visible fields changed, update them *individually* to avoid a huge copy. - if indexFieldsChanged { - // Mutate only the few small fields that affect the index - workspaces[idx].name = merged.name - workspaces[idx].customStoragePath = merged.customStoragePath - workspaces[idx].isSystemWorkspace = merged.isSystemWorkspace - workspaces[idx].isHiddenInMenus = merged.isHiddenInMenus - // (These in-place field writes don't rebuild the entire array) - } - if preservedDiskRepoPaths { - workspaces[idx].repoPaths = merged.repoPaths + return await saveWorkspaceAsync(source: source) + } + + if indexFieldsChanged || preservedDiskRepoPaths { + _ = mutateWorkspace( + id: current.id, + touchDateModified: false, + markDirty: false + ) { workspace in + if indexFieldsChanged { + workspace.name = merged.name + workspace.customStoragePath = merged.customStoragePath + workspace.isSystemWorkspace = merged.isSystemWorkspace + workspace.isHiddenInMenus = merged.isHiddenInMenus + } + if preservedDiskRepoPaths { + workspace.repoPaths = merged.repoPaths + } + } } - recordRepoPathBaseline(for: merged) - let metadata = workspaceSaveMetadata(for: merged, source: source) WorkspaceFileDecodeCache.shared.invalidate(url: url) - await WorkspaceDiskWriter.shared.enqueueWorkspace(data: data, url: url, metadata: metadata) + let receipt = await sessionController.persistenceWriter.enqueueWorkspace( + data: data, + url: url, + metadata: metadata + ) if indexFieldsChanged { await rebuildAndSaveIndexAsync() } + + let completion = await sessionController.persistenceWriter.flush(receipt) + guard completion.succeeded else { + print("💾 Failed to persist workspace: \(completion.errorDescription ?? "unknown error")") + return nil + } + sessionController.recordSaveCompletion( + workspaceID: current.id, + capturedGeneration: capturedStateVersion, + persistedWorkspace: merged + ) + return WorkspaceSaveSubmission( + workspaceID: current.id, + capturedGeneration: capturedStateVersion, + persistedWorkspace: merged, + receipt: receipt + ) } catch { print("💾 Failed to serialize workspace: \(error)") + return nil } } @@ -5815,8 +5262,8 @@ class WorkspaceManagerViewModel: ObservableObject { source: WorkspaceSaveSource = .directUnknown ) async throws -> URL { let targetURL = workspaceFileURL(for: workspace) - let capturedStateVersion = stateVersionByWorkspaceID[workspace.id, default: 0] - await WorkspaceDiskWriter.shared.flush(url: targetURL) + let capturedStateVersion = sessionController.stateGeneration(workspaceID: workspace.id) + await sessionController.persistenceWriter.flush(url: targetURL) var workspaceToSave = workspace if preserveDiskRepoPathsIfUnchangedSinceBaseline, @@ -5826,19 +5273,22 @@ class WorkspaceManagerViewModel: ObservableObject { let mergeResult = Self.workspaceForSavePreservingDiskRepoPaths( current: workspace, diskWorkspace: diskWorkspace, - lastSyncedRepoPaths: lastSyncedRepoPathsByWorkspaceID[workspace.id], + lastSyncedRepoPaths: sessionController.repositoryBaseline(workspaceID: workspace.id), modificationDate: workspace.dateModified ) workspaceToSave = mergeResult.workspace if mergeResult.preservedDiskRepoPaths, - let index = workspaceIndex(for: workspace.id), - !hasLocalRepoPathEdit(for: workspaces[index]) + !hasLocalRepoPathEdit(for: workspace) { - workspaces[index].repoPaths = workspaceToSave.repoPaths + _ = mutateWorkspace( + id: workspace.id, + touchDateModified: false, + markDirty: false + ) { $0.repoPaths = workspaceToSave.repoPaths } } } - let latestStateVersion = stateVersionByWorkspaceID[workspace.id, default: 0] + let latestStateVersion = sessionController.stateGeneration(workspaceID: workspace.id) if latestStateVersion != capturedStateVersion, let index = workspaceIndex(for: workspace.id) { @@ -5857,12 +5307,10 @@ class WorkspaceManagerViewModel: ObservableObject { let metadata = workspaceSaveMetadata(for: workspaceToSave, source: source) WorkspaceSaveTracer.event("workspaceSave.direct.enqueue", metadata: metadata, url: targetURL) - let finalURL = try await saveWorkspaceToFileAsync(workspaceToSave, baseRoot: currentBaseRoot, metadata: metadata) - recordRepoPathBaseline(for: workspaceToSave) - return finalURL + return try await saveWorkspaceToFileAsync(workspaceToSave, baseRoot: currentBaseRoot, metadata: metadata) } - nonisolated func saveWorkspaceToFileAsync(_ workspace: WorkspaceModel, baseRoot: URL, metadata: WorkspaceSavePayloadMetadata? = nil) async throws -> URL { + func saveWorkspaceToFileAsync(_ workspace: WorkspaceModel, baseRoot: URL, metadata: WorkspaceSavePayloadMetadata? = nil) async throws -> URL { // Encode JSON let encoded = try JSONEncoder().encode(workspace) @@ -5872,78 +5320,24 @@ class WorkspaceManagerViewModel: ObservableObject { // Enqueue write to shared disk writer for serialization WorkspaceFileDecodeCache.shared.invalidate(url: finalURL) - if let metadata { - await WorkspaceDiskWriter.shared.enqueueWorkspace(data: encoded, url: finalURL, metadata: metadata) + let receipt: WorkspaceWriteReceipt = if let metadata { + await sessionController.persistenceWriter.enqueueWorkspace(data: encoded, url: finalURL, metadata: metadata) } else { - await WorkspaceDiskWriter.shared.enqueue(data: encoded, url: finalURL) + await sessionController.persistenceWriter.enqueue(data: encoded, url: finalURL) } - - return finalURL - } - - /// Synchronous workspace write used by focused tests and direct save paths. - func saveWorkspaceToFile(_ workspace: WorkspaceModel, source: WorkspaceSaveSource = .directUnknown) throws -> URL { - // Encode JSON and prepare file path - let encoded = try JSONEncoder().encode(workspace) - let folder = try ensureWorkspaceDirectoryExists(for: workspace) - let finalURL = folder.appendingPathComponent("workspace.json") - let metadata = workspaceSaveMetadata(for: workspace, source: source) - - // Write synchronously for direct save paths. - WorkspaceFileDecodeCache.shared.invalidate(url: finalURL) - WorkspaceSaveTracer.event("workspaceSave.syncWrite.begin", metadata: metadata, url: finalURL) - do { - try encoded.write(to: finalURL, options: .atomic) - WorkspaceSaveTracer.event("workspaceSave.syncWrite.success", metadata: metadata, url: finalURL) - } catch { - WorkspaceSaveTracer.event("workspaceSave.syncWrite.failure", metadata: metadata, url: finalURL, extra: ["error": error.localizedDescription]) - throw error + let completion = await sessionController.persistenceWriter.flush(receipt) + if let errorDescription = completion.errorDescription { + throw CocoaError(.fileWriteUnknown, userInfo: [NSLocalizedDescriptionKey: errorDescription]) } - return finalURL } nonisolated static func loadWorkspaceFromFileResult(at fileURL: URL) throws -> WorkspaceFileLoadResult { let cachedResult = try WorkspaceFileDecodeCache.shared.loadWorkspace(at: fileURL) - let normalizationSaveTask: Task? - if cachedResult.normalizationRequiresSave, - WorkspaceFileDecodeCache.shared.claimNormalizationSave(for: cachedResult.cacheKey) - { - let workspaceToSave = cachedResult.workspace - let saveURL = URL(fileURLWithPath: cachedResult.cacheKey.standardizedPath) - let cacheKey = cachedResult.cacheKey - normalizationSaveTask = Task.detached(priority: .utility) { - do { - guard WorkspaceFileDecodeCache.shared.isNormalizationSaveClaimed(for: cacheKey), - let currentKey = try? WorkspaceFileDecodeCache.shared.metadataKey(for: saveURL), - currentKey == cacheKey - else { - WorkspaceFileDecodeCache.shared.finishNormalizationSave(for: cacheKey) - return - } - let encoded = try JSONEncoder().encode(workspaceToSave) - let metadata = WorkspaceManagerViewModel.metadata(for: workspaceToSave, source: .normalizationWriteback) - _ = await WorkspaceDiskWriter.shared.writeNormalizationIfUnchanged( - data: encoded, - url: saveURL, - expectedFileSize: cacheKey.fileSize, - expectedModificationDate: cacheKey.modificationDate, - metadata: metadata - ) - } catch { - print("💾 Failed to persist normalized workspace \(saveURL.lastPathComponent): \(error)") - } - WorkspaceFileDecodeCache.shared.finishNormalizationSave(for: cacheKey) - } - } else { - normalizationSaveTask = nil - } - return WorkspaceFileLoadResult( workspace: cachedResult.workspace, cacheHit: cachedResult.cacheHit, - composeTabsNormalized: cachedResult.composeTabsNormalized, - normalizationSaveTask: normalizationSaveTask + composeTabsNormalized: cachedResult.composeTabsNormalized ) } @@ -5996,7 +5390,8 @@ class WorkspaceManagerViewModel: ObservableObject { return } - for ws in workspaces { + let currentWorkspaces = workspaces + for ws in currentWorkspaces { let oldFolder = currentGlobal.appendingPathComponent(directoryName(for: ws)) let newFolder = defaultWorkspaceRoot().appendingPathComponent(directoryName(for: ws)) @@ -6014,9 +5409,10 @@ class WorkspaceManagerViewModel: ObservableObject { } try? FileManager.default.removeItem(at: oldFolder) } - - if let idx = workspaces.firstIndex(where: { $0.id == ws.id }) { - workspaces[idx].customStoragePath = nil + } + workspaceTransaction(touchDateModified: false) { transaction in + for index in transaction.workspaces.indices { + transaction.workspaces[index].customStoragePath = nil } } globalCustomStorageURL = nil @@ -6218,30 +5614,26 @@ class WorkspaceManagerViewModel: ObservableObject { direction: WorkspaceRootMoveDirection, visibleRootOrder: [String] ) async { - guard !isRefreshing, - let activeWS = activeWorkspace, - let index = workspaces.firstIndex(where: { $0.id == activeWS.id }) else { return } + guard !isRefreshing, let activeWS = activeWorkspace else { return } let reorderedRepoPaths = WorkspaceRootActions.movedRepoPaths( - repoPaths: workspaces[index].repoPaths, + repoPaths: activeWS.repoPaths, movingRootPath: path, direction: direction, visibleRootPaths: visibleRootOrder ) - guard !Self.repoPathsEquivalent(workspaces[index].repoPaths, reorderedRepoPaths) else { return } - - workspaces[index].repoPaths = reorderedRepoPaths - workspaces[index].dateModified = Date() + guard !Self.repoPathsEquivalent(activeWS.repoPaths, reorderedRepoPaths), + let workspaceToSave = mutateWorkspace(id: activeWS.id, { $0.repoPaths = reorderedRepoPaths }) + else { return } fileManager.reorderRootFolders(to: reorderedRepoPaths) do { - let workspaceToSave = workspaces[index] let finalURL = try await saveWorkspaceToFileAsync( workspaceToSave, preserveDiskRepoPathsIfUnchangedSinceBaseline: false, source: .rootReorder ) - await WorkspaceDiskWriter.shared.flush(url: finalURL) + await sessionController.persistenceWriter.flush(url: finalURL) recordRepoPathBaseline(for: workspaceToSave) postWorkspaceRepoPathsDidChange(for: workspaceToSave.id) } catch { @@ -6264,7 +5656,7 @@ class WorkspaceManagerViewModel: ObservableObject { return candidate != normalisedTarget } - guard let index = workspaces.firstIndex(where: { $0.id == workspace.id }) else { return } + guard self.workspace(withID: workspace.id) != nil else { return } if newPaths.isEmpty { if let fallback = findOrCreateDefaultWorkspace() { await switchWorkspace(to: fallback) @@ -6273,13 +5665,12 @@ class WorkspaceManagerViewModel: ObservableObject { } pollAndSaveState(source: .rootRemove) - workspaces[index].repoPaths = newPaths + guard let workspaceToSave = mutateWorkspace(id: workspace.id, { $0.repoPaths = newPaths }) else { return } // Save and wait for completion before unloading folder—but persist this workspace, not just the active one do { - let workspaceToSave = workspaces[index] let finalURL = try await saveWorkspaceToFileAsync(workspaceToSave, preserveDiskRepoPathsIfUnchangedSinceBaseline: false, source: .rootRemove) - await WorkspaceDiskWriter.shared.flush(url: finalURL) + await sessionController.persistenceWriter.flush(url: finalURL) recordRepoPathBaseline(for: workspaceToSave) postWorkspaceRepoPathsDidChange(for: workspaceToSave.id) await fileManager.unloadRootFolderPath(folderPath) @@ -6291,26 +5682,24 @@ class WorkspaceManagerViewModel: ObservableObject { @MainActor func addFolder(_ folderURL: URL, to workspace: WorkspaceModel) async throws { - guard let index = workspaces.firstIndex(where: { $0.id == workspace.id }) else { return } + guard let current = self.workspace(withID: workspace.id) else { return } let path = (folderURL.path as NSString).standardizingPath - try validateNewRootPath(path, against: workspaces[index].repoPaths) - let alreadyHas = workspaces[index].repoPaths.contains { + try validateNewRootPath(path, against: current.repoPaths) + let alreadyHas = current.repoPaths.contains { let normalized = ($0 as NSString).standardizingPath return normalized.caseInsensitiveCompare(path) == .orderedSame } if !alreadyHas { - workspaces[index].repoPaths.append(path) - workspaces[index].dateModified = Date() + guard let workspaceToSave = mutateWorkspace(id: workspace.id, { $0.repoPaths.append(path) }) else { return } // Save asynchronously and flush for cross-window consistency do { - let workspaceToSave = workspaces[index] let finalURL = try await saveWorkspaceToFileAsync(workspaceToSave, preserveDiskRepoPathsIfUnchangedSinceBaseline: false, source: .rootAdd) - await WorkspaceDiskWriter.shared.flush(url: finalURL) + await sessionController.persistenceWriter.flush(url: finalURL) recordRepoPathBaseline(for: workspaceToSave) await rebuildAndSaveIndexAsync() - await WorkspaceDiskWriter.shared.flush(url: workspaceIndexFileURL) + await sessionController.persistenceWriter.flush(url: workspaceIndexFileURL) postWorkspaceRepoPathsDidChange(for: workspaceToSave.id) } catch { print("Error saving workspace after adding folder: \(error)") @@ -6477,7 +5866,7 @@ class WorkspaceManagerViewModel: ObservableObject { } var ws = WorkspaceModel(name: "Default", repoPaths: []) ws.isSystemWorkspace = true - workspaces.append(ws) + workspaceTransaction(touchDateModified: false) { $0.workspaces.append(ws) } return ws } @@ -6487,22 +5876,24 @@ class WorkspaceManagerViewModel: ObservableObject { } var ws = WorkspaceModel(name: "Default", repoPaths: []) ws.isSystemWorkspace = true - workspaces.append(ws) + workspaceTransaction(touchDateModified: false) { $0.workspaces.append(ws) } - do { - _ = try ensureWorkspaceDirectoryExists(for: ws) - _ = try saveWorkspaceToFile(ws, source: .createDefaultWorkspace) - } catch { - print("Error while creating default workspace: \(error)") + Task { [weak self, ws] in + guard let self else { return } + do { + try await workspaceRepository.save(ws) + recordRepoPathBaseline(for: ws) + } catch { + print("Error while creating default workspace: \(error)") + } } - rebuildAndSaveIndex() return ws } // MARK: - Preset Operations func createPreset(for workspace: WorkspaceModel, name: String) async { - guard let index = workspaces.firstIndex(where: { $0.id == workspace.id }) else { return } + guard self.workspace(withID: workspace.id) != nil else { return } let selectedPaths = fileManager.selectedFiles.map(\.fullPath) let newPreset = WorkspacePreset( @@ -6516,14 +5907,15 @@ class WorkspaceManagerViewModel: ObservableObject { lastUpdated: Date() ) - workspaces[index].presets.append(newPreset) - workspaces[index].activePresetID = newPreset.id - workspaces[index].dateModified = Date() + guard let updated = mutateWorkspace(id: workspace.id, { workspace in + workspace.presets.append(newPreset) + workspace.activePresetID = newPreset.id + }) else { return } // Save asynchronously and notify after disk commit do { - let finalURL = try await saveWorkspaceToFileAsync(workspaces[index], source: .createPreset) - await WorkspaceDiskWriter.shared.flush(url: finalURL) + let finalURL = try await saveWorkspaceToFileAsync(updated, source: .createPreset) + await sessionController.persistenceWriter.flush(url: finalURL) // The selection now matches the preset; not dirty. activePresetIsDirty = false @@ -6539,7 +5931,7 @@ class WorkspaceManagerViewModel: ObservableObject { } func createPreset(for workspace: WorkspaceModel, name: String, selectedPaths: [String]) async { - guard let index = workspaces.firstIndex(where: { $0.id == workspace.id }) else { return } + guard self.workspace(withID: workspace.id) != nil else { return } let newPreset = WorkspacePreset( name: name, @@ -6552,14 +5944,15 @@ class WorkspaceManagerViewModel: ObservableObject { lastUpdated: Date() ) - workspaces[index].presets.append(newPreset) - workspaces[index].activePresetID = newPreset.id - workspaces[index].dateModified = Date() + guard let updated = mutateWorkspace(id: workspace.id, { workspace in + workspace.presets.append(newPreset) + workspace.activePresetID = newPreset.id + }) else { return } // Save this workspace (not only the active one), flush, then notify do { - let finalURL = try await saveWorkspaceToFileAsync(workspaces[index], source: .createPresetWithPaths) - await WorkspaceDiskWriter.shared.flush(url: finalURL) + let finalURL = try await saveWorkspaceToFileAsync(updated, source: .createPresetWithPaths) + await sessionController.persistenceWriter.flush(url: finalURL) // The selection now matches the preset; not dirty. activePresetIsDirty = false @@ -6605,7 +5998,7 @@ class WorkspaceManagerViewModel: ObservableObject { return } - workspaces[wsIndex].activePresetID = presetID + mutateWorkspace(id: active.id) { $0.activePresetID = presetID } if preset.capturesFileSelection { await fileManager.selectFiles(withPaths: preset.selectedFilePaths, allowEmpty: true) @@ -6629,13 +6022,16 @@ class WorkspaceManagerViewModel: ObservableObject { // Always save full, absolute paths let selectedPaths = fileManager.selectedFiles.map(\.fullPath) - workspaces[wsIndex].presets[presetIdx].selectedFilePaths = selectedPaths - workspaces[wsIndex].presets[presetIdx].lastUpdated = Date() + guard let updated = mutateWorkspace(id: active.id, { workspace in + guard let index = workspace.presets.firstIndex(where: { $0.id == pid }) else { return } + workspace.presets[index].selectedFilePaths = selectedPaths + workspace.presets[index].lastUpdated = Date() + }) else { return } // Save, flush, and notify other windows so they reload presets immediately do { - let finalURL = try await saveWorkspaceToFileAsync(workspaces[wsIndex], source: .saveCurrentPreset) - await WorkspaceDiskWriter.shared.flush(url: finalURL) + let finalURL = try await saveWorkspaceToFileAsync(updated, source: .saveCurrentPreset) + await sessionController.persistenceWriter.flush(url: finalURL) // The selection now matches the preset; not dirty activePresetIsDirty = false @@ -6650,32 +6046,20 @@ class WorkspaceManagerViewModel: ObservableObject { } func updatePromptText(_ newText: String) { - guard let active = activeWorkspace, - let index = workspaces.firstIndex(where: { $0.id == active.id }) - else { - return - } - workspaces[index].currentPromptText = newText + guard let active = activeWorkspace else { return } + mutateWorkspace(id: active.id) { $0.currentPromptText = newText } scheduleSave(source: .updatePromptText) - bumpStateVersion(for: active.id) } func updateSelectedMetaPromptIDs(_ newIDs: [UUID]) { - guard let active = activeWorkspace, - let index = workspaces.firstIndex(where: { $0.id == active.id }) - else { - return - } - workspaces[index].selectedMetaPromptIDs = newIDs + guard let active = activeWorkspace else { return } + mutateWorkspace(id: active.id) { $0.selectedMetaPromptIDs = newIDs } scheduleSave(source: .updateSelectedMetaPromptIDs) - bumpStateVersion(for: active.id) } /// Sets a workspace's ephemeral property by ID func setWorkspaceEphemeral(_ workspaceID: UUID, _ value: Bool) { - if let idx = workspaces.firstIndex(where: { $0.id == workspaceID }) { - workspaces[idx].isEphemeral = value - } + setWorkspaceEphemeral(value, workspaceID: workspaceID) } // MARK: - Workspace and Preset Convenience Methods @@ -6723,17 +6107,17 @@ class WorkspaceManagerViewModel: ObservableObject { // Store absolute full paths to avoid cross-root ambiguity let selection = fileManager.selectedFiles.map(\.fullPath) - guard let i = ws.presets.firstIndex(where: { $0.id == oldPreset.id }), - let wsIndex = workspaces.firstIndex(where: { $0.id == ws.id }) else { return } - - workspaces[wsIndex].presets[i].selectedFilePaths = selection - workspaces[wsIndex].presets[i].lastUpdated = Date() + guard let updated = mutateWorkspace(id: ws.id, { workspace in + guard let index = workspace.presets.firstIndex(where: { $0.id == oldPreset.id }) else { return } + workspace.presets[index].selectedFilePaths = selection + workspace.presets[index].lastUpdated = Date() + }) else { return } // Save, flush, and notify other windows so they reload presets Task { do { - let finalURL = try await saveWorkspaceToFileAsync(workspaces[wsIndex], source: .savePresetShortcut) - await WorkspaceDiskWriter.shared.flush(url: finalURL) + let finalURL = try await saveWorkspaceToFileAsync(updated, source: .savePresetShortcut) + await sessionController.persistenceWriter.flush(url: finalURL) await MainActor.run { NotificationCenter.default.post( name: .workspacePresetsDidChange, @@ -6755,12 +6139,7 @@ class WorkspaceManagerViewModel: ObservableObject { /// Creates an ephemeral workspace (non-persisted) func createEphemeralWorkspace(name: String, repoPaths: [String]) -> WorkspaceModel { - var ws = createWorkspace(name: name, repoPaths: repoPaths) - if let idx = workspaces.firstIndex(where: { $0.id == ws.id }) { - workspaces[idx].isEphemeral = true - ws = workspaces[idx] // re-fetch the mutated copy - } - return ws + createWorkspace(name: name, repoPaths: repoPaths, ephemeral: true) } @MainActor @@ -6813,14 +6192,15 @@ class WorkspaceManagerViewModel: ObservableObject { } func deletePreset(_ preset: WorkspacePreset, from workspace: WorkspaceModel) { - guard let widx = workspaces.firstIndex(where: { $0.id == workspace.id }) else { return } - workspaces[widx].presets.removeAll { $0.id == preset.id } + guard let updated = mutateWorkspace(id: workspace.id, { + $0.presets.removeAll { $0.id == preset.id } + }) else { return } // Schedule async save of the mutated workspace and notify after flush Task { do { - let finalURL = try await saveWorkspaceToFileAsync(workspaces[widx], source: .deletePreset) - await WorkspaceDiskWriter.shared.flush(url: finalURL) + let finalURL = try await saveWorkspaceToFileAsync(updated, source: .deletePreset) + await sessionController.persistenceWriter.flush(url: finalURL) await MainActor.run { NotificationCenter.default.post( name: .workspacePresetsDidChange, @@ -6835,19 +6215,17 @@ class WorkspaceManagerViewModel: ObservableObject { } func renamePreset(_ preset: WorkspacePreset, newName: String, in workspace: WorkspaceModel) { - guard let widx = workspaces.firstIndex(where: { $0.id == workspace.id }), - let pidx = workspaces[widx].presets.firstIndex(where: { $0.id == preset.id }) - else { - return - } - workspaces[widx].presets[pidx].name = newName - workspaces[widx].presets[pidx].lastUpdated = Date() + guard let updated = mutateWorkspace(id: workspace.id, { workspace in + guard let index = workspace.presets.firstIndex(where: { $0.id == preset.id }) else { return } + workspace.presets[index].name = newName + workspace.presets[index].lastUpdated = Date() + }) else { return } // Schedule async save of the mutated workspace and notify after flush Task { do { - let finalURL = try await saveWorkspaceToFileAsync(workspaces[widx], source: .renamePreset) - await WorkspaceDiskWriter.shared.flush(url: finalURL) + let finalURL = try await saveWorkspaceToFileAsync(updated, source: .renamePreset) + await sessionController.persistenceWriter.flush(url: finalURL) await MainActor.run { NotificationCenter.default.post( name: .workspacePresetsDidChange, @@ -6863,20 +6241,13 @@ class WorkspaceManagerViewModel: ObservableObject { /// Reorders presets in a workspace func reorderPresets(for workspace: WorkspaceModel, newPresets: [WorkspacePreset]) { - // Find the workspace in our array - guard let index = workspaces.firstIndex(where: { $0.id == workspace.id }) else { - return - } - - // Update presets and modify timestamp in place - workspaces[index].presets = newPresets - workspaces[index].dateModified = Date() + guard let updated = mutateWorkspace(id: workspace.id, { $0.presets = newPresets }) else { return } // Persist this workspace and notify other windows to reload presets Task { do { - let finalURL = try await saveWorkspaceToFileAsync(workspaces[index], source: .reorderPresets) - await WorkspaceDiskWriter.shared.flush(url: finalURL) + let finalURL = try await saveWorkspaceToFileAsync(updated, source: .reorderPresets) + await sessionController.persistenceWriter.flush(url: finalURL) await MainActor.run { NotificationCenter.default.post( name: .workspacePresetsDidChange, @@ -7096,7 +6467,7 @@ class WorkspaceManagerViewModel: ObservableObject { // 5) Replace your in-memory array with newly loaded (or appended) ones // or you can union them if you want to keep older existing ones. - workspaces = newlyLoadedWorkspaces + sessionController.replaceAll(newlyLoadedWorkspaces, activeWorkspaceID: oldActiveID) // Rebuild and save the index to reflect the newly loaded sets await rebuildAndSaveIndexAsync() diff --git a/Sources/RepoPrompt/Features/Workspaces/ViewModels/WorkspaceSaveDiagnostics.swift b/Sources/RepoPrompt/Features/Workspaces/ViewModels/WorkspaceSaveDiagnostics.swift index 97cbecacd..039bc0378 100644 --- a/Sources/RepoPrompt/Features/Workspaces/ViewModels/WorkspaceSaveDiagnostics.swift +++ b/Sources/RepoPrompt/Features/Workspaces/ViewModels/WorkspaceSaveDiagnostics.swift @@ -1,20 +1,15 @@ import Foundation +import RepoPromptCore -struct WorkspaceSaveSource: Equatable, Hashable, ExpressibleByStringLiteral, CustomStringConvertible { - let rawValue: String - - init(_ rawValue: String) { - self.rawValue = rawValue - } - - init(stringLiteral value: StringLiteralType) { - rawValue = value - } - - var description: String { - rawValue - } +typealias WorkspaceSaveSource = RepoPromptCore.WorkspaceSaveSource +typealias WorkspaceSaveOwner = RepoPromptCore.WorkspaceSaveOwner +typealias WorkspaceTabSelectionKey = RepoPromptCore.WorkspaceTabSelectionKey +typealias WorkspaceSaveSelectionSummary = RepoPromptCore.WorkspaceSaveSelectionSummary +typealias WorkspaceSavePayloadMetadata = RepoPromptCore.WorkspaceSavePayloadMetadata +typealias WorkspaceSelectionSaveOwner = RepoPromptCore.WorkspaceSelectionSaveOwner +typealias WorkspaceSelectionForSaveDecision = RepoPromptCore.WorkspaceSelectionForSaveDecision +extension WorkspaceSaveSource { static let pollTimer = WorkspaceSaveSource("pollTimer") static let pollAndSaveState = WorkspaceSaveSource("pollAndSaveState") static let pollAndSaveStateAsync = WorkspaceSaveSource("pollAndSaveStateAsync") @@ -42,52 +37,16 @@ struct WorkspaceSaveSource: Equatable, Hashable, ExpressibleByStringLiteral, Cus static let duplicateCleanupPreSwitch = WorkspaceSaveSource("duplicateCleanupPreSwitch") static let duplicateCleanupCanonicalMerge = WorkspaceSaveSource("duplicateCleanupCanonicalMerge") static let createDefaultWorkspace = WorkspaceSaveSource("createDefaultWorkspace") - static let normalizationWriteback = WorkspaceSaveSource("normalizationWriteback") static let refreshWorkspace = WorkspaceSaveSource("refreshWorkspace") static let mcpTabContextEndOfRun = WorkspaceSaveSource("mcpTabContextEndOfRun") #if DEBUG - /// DEBUG diagnostics/fixture save attribution for workspace selection fixture apply flows. static let debugWorkspaceSelectionFixtureApply = WorkspaceSaveSource("debugWorkspaceSelectionFixtureApply") #endif static let directUnknown = WorkspaceSaveSource("directUnknown") } -struct WorkspaceSaveOwner: Equatable, Hashable { - let windowID: Int? - let managerID: UUID? - - static let none = WorkspaceSaveOwner(windowID: nil, managerID: nil) -} - -struct WorkspaceTabSelectionKey: Hashable { - let workspaceID: UUID - let tabID: UUID -} - -struct WorkspaceSaveSelectionSummary: Equatable { - let tabID: UUID? - let signature: String? - let selectedPaths: Int - let autoCodemapPaths: Int - let sliceFiles: Int - let sliceRanges: Int - let codemapAutoEnabled: Bool - - init(tabID: UUID?, selection: StoredSelection?) { - self.tabID = tabID - selectedPaths = selection?.selectedPaths.count ?? 0 - autoCodemapPaths = selection?.autoCodemapPaths.count ?? 0 - sliceFiles = selection?.slices.count ?? 0 - sliceRanges = selection?.slices.values.reduce(0) { $0 + $1.count } ?? 0 - codemapAutoEnabled = selection?.codemapAutoEnabled ?? true - #if DEBUG - signature = selection.map { WorkspaceSelectionDebugSignature.signature(for: $0) } - #else - signature = nil - #endif - } - - func fields(prefix: String = "selection") -> [String: String] { +extension WorkspaceSaveSelectionSummary { + func fields(prefix: String = "selection", selection: StoredSelection? = nil) -> [String: String] { var result: [String: String] = [ "\(prefix)TabID": tabID.map { String($0.uuidString.prefix(8)) } ?? "", "\(prefix)SelectedPaths": "\(selectedPaths)", @@ -96,57 +55,15 @@ struct WorkspaceSaveSelectionSummary: Equatable { "\(prefix)SliceRanges": "\(sliceRanges)", "\(prefix)CodemapAutoEnabled": "\(codemapAutoEnabled)" ] - if let signature { - result["\(prefix)Signature"] = signature - } + #if DEBUG + if let selection { + result["\(prefix)Signature"] = WorkspaceSelectionDebugSignature.signature(for: selection) + } + #endif return result } } -struct WorkspaceSavePayloadMetadata: Equatable { - let payloadID: UUID - let source: WorkspaceSaveSource - let owner: WorkspaceSaveOwner - let workspaceID: UUID - let workspaceName: String - let workspaceDateModified: Date - let activeTabID: UUID? - let activeSelectionRevision: UInt64 - let activeSelection: StoredSelection? - let selectionSummary: WorkspaceSaveSelectionSummary - let createdAt: Date - - init( - payloadID: UUID = UUID(), - source: WorkspaceSaveSource, - owner: WorkspaceSaveOwner, - workspaceID: UUID, - workspaceName: String, - workspaceDateModified: Date, - activeTabID: UUID?, - activeSelectionRevision: UInt64, - activeSelection: StoredSelection?, - createdAt: Date = Date() - ) { - self.payloadID = payloadID - self.source = source - self.owner = owner - self.workspaceID = workspaceID - self.workspaceName = workspaceName - self.workspaceDateModified = workspaceDateModified - self.activeTabID = activeTabID - self.activeSelectionRevision = activeSelectionRevision - self.activeSelection = activeSelection - selectionSummary = WorkspaceSaveSelectionSummary(tabID: activeTabID, selection: activeSelection) - self.createdAt = createdAt - } - - var selectionKey: WorkspaceTabSelectionKey? { - guard let activeTabID else { return nil } - return WorkspaceTabSelectionKey(workspaceID: workspaceID, tabID: activeTabID) - } -} - enum WorkspaceSaveTracer { static func event( _ name: String, @@ -160,9 +77,7 @@ enum WorkspaceSaveTracer { if let metadata { payload.merge(baseFields(for: metadata)) { current, _ in current } } - if let url { - payload["url"] = url.lastPathComponent - } + if let url { payload["url"] = url.lastPathComponent } WorkspaceRestorePerfLog.event(name, fields: payload) #endif } @@ -177,9 +92,9 @@ enum WorkspaceSaveTracer { ) { #if DEBUG var fields: [String: String] = ["chosenOwner": chosenOwner.rawValue] - fields.merge(WorkspaceSaveSelectionSummary(tabID: metadata.activeTabID, selection: liveUI).fields(prefix: "liveUI")) { current, _ in current } - fields.merge(WorkspaceSaveSelectionSummary(tabID: metadata.activeTabID, selection: stored).fields(prefix: "stored")) { current, _ in current } - fields.merge(WorkspaceSaveSelectionSummary(tabID: metadata.activeTabID, selection: canonical).fields(prefix: "canonical")) { current, _ in current } + fields.merge(WorkspaceSaveSelectionSummary(tabID: metadata.activeTabID, selection: liveUI).fields(prefix: "liveUI", selection: liveUI)) { current, _ in current } + fields.merge(WorkspaceSaveSelectionSummary(tabID: metadata.activeTabID, selection: stored).fields(prefix: "stored", selection: stored)) { current, _ in current } + fields.merge(WorkspaceSaveSelectionSummary(tabID: metadata.activeTabID, selection: canonical).fields(prefix: "canonical", selection: canonical)) { current, _ in current } event("workspaceSave.capture", metadata: metadata, url: url, extra: fields) #endif } @@ -198,19 +113,8 @@ enum WorkspaceSaveTracer { "activeSelectionRevision": "\(metadata.activeSelectionRevision)", "createdAt": String(format: "%.6f", metadata.createdAt.timeIntervalSince1970) ] - fields.merge(metadata.selectionSummary.fields()) { current, _ in current } + fields.merge(metadata.selectionSummary.fields(selection: metadata.activeSelection)) { current, _ in current } return fields } #endif } - -enum WorkspaceSelectionSaveOwner: String, Equatable { - case canonicalCoordinator - case storedComposeTab - case legacyLiveUI -} - -struct WorkspaceSelectionForSaveDecision: Equatable { - let selection: StoredSelection - let owner: WorkspaceSelectionSaveOwner -} diff --git a/Sources/RepoPrompt/Features/Workspaces/WorkspaceDuplicateCleanupModels.swift b/Sources/RepoPrompt/Features/Workspaces/WorkspaceDuplicateCleanupModels.swift new file mode 100644 index 000000000..22626d00b --- /dev/null +++ b/Sources/RepoPrompt/Features/Workspaces/WorkspaceDuplicateCleanupModels.swift @@ -0,0 +1,77 @@ +import Foundation + +struct WorkspaceRootSetKey: Hashable { + let normalizedPaths: [String] + + var isEmpty: Bool { + normalizedPaths.isEmpty + } + + init(paths: [String]) { + var canonicalByLowercasedPath: [String: String] = [:] + for rawPath in paths { + let trimmed = rawPath.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { continue } + let expanded = (trimmed as NSString).expandingTildeInPath + let normalizedPath = URL(fileURLWithPath: expanded).standardizedFileURL.path + guard !normalizedPath.isEmpty else { continue } + let lowercasedPath = normalizedPath.lowercased() + if let existing = canonicalByLowercasedPath[lowercasedPath] { + canonicalByLowercasedPath[lowercasedPath] = min(existing, normalizedPath) + } else { + canonicalByLowercasedPath[lowercasedPath] = normalizedPath + } + } + normalizedPaths = canonicalByLowercasedPath.values.sorted { + let lhsKey = $0.lowercased() + let rhsKey = $1.lowercased() + return lhsKey == rhsKey ? $0 < $1 : lhsKey < rhsKey + } + } + + static func == (lhs: WorkspaceRootSetKey, rhs: WorkspaceRootSetKey) -> Bool { + lhs.normalizedPaths.map { $0.lowercased() } == rhs.normalizedPaths.map { $0.lowercased() } + } + + func hash(into hasher: inout Hasher) { + for path in normalizedPaths { + hasher.combine(path.lowercased()) + } + } +} + +struct WorkspaceDuplicateGroupSummary: Identifiable, Equatable { + let id: String + let normalizedRepoPaths: [String] + let canonicalWorkspaceID: UUID + let canonicalWorkspaceName: String + let duplicateWorkspaceIDs: [UUID] + let duplicateWorkspaceNames: [String] + let windowIDsByWorkspaceID: [UUID: [Int]] +} + +struct WorkspaceDuplicateCleanupSkippedItem: Equatable { + let workspaceID: UUID + let workspaceName: String + let windowID: Int? + let reason: String +} + +struct WorkspaceDuplicateCleanupResult: Equatable { + let groupsDetected: Int + let groupsConsolidated: Int + let reassignedWindowIDs: [Int] + let deletedWorkspaceIDs: [UUID] + let skipped: [WorkspaceDuplicateCleanupSkippedItem] + let backupURL: URL? +} + +struct WorkspaceDuplicateCleanupBackup: Codable { + struct BackupGroup: Codable { + let canonicalBeforeMerge: WorkspaceModel + let duplicatesBeforeDelete: [WorkspaceModel] + } + + let createdAt: Date + let groups: [BackupGroup] +} diff --git a/Sources/RepoPrompt/Features/Workspaces/WorkspaceModel.swift b/Sources/RepoPrompt/Features/Workspaces/WorkspaceModel.swift index 0f3dc6aed..1bb6825a4 100644 --- a/Sources/RepoPrompt/Features/Workspaces/WorkspaceModel.swift +++ b/Sources/RepoPrompt/Features/Workspaces/WorkspaceModel.swift @@ -1,579 +1,22 @@ -import Foundation -import OSLog - -struct WorkspaceRootSetKey: Hashable { - let normalizedPaths: [String] - - var isEmpty: Bool { - normalizedPaths.isEmpty - } - - init(paths: [String]) { - var canonicalByLowercasedPath: [String: String] = [:] - for rawPath in paths { - let trimmed = rawPath.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { continue } - let expanded = (trimmed as NSString).expandingTildeInPath - let normalizedPath = URL(fileURLWithPath: expanded).standardizedFileURL.path - guard !normalizedPath.isEmpty else { continue } - let lowercasedPath = normalizedPath.lowercased() - if let existing = canonicalByLowercasedPath[lowercasedPath] { - canonicalByLowercasedPath[lowercasedPath] = min(existing, normalizedPath) - } else { - canonicalByLowercasedPath[lowercasedPath] = normalizedPath - } - } - normalizedPaths = canonicalByLowercasedPath.values.sorted { - let lhsKey = $0.lowercased() - let rhsKey = $1.lowercased() - if lhsKey != rhsKey { - return lhsKey < rhsKey - } - return $0 < $1 - } - } - - static func == (lhs: WorkspaceRootSetKey, rhs: WorkspaceRootSetKey) -> Bool { - lhs.normalizedPaths.map { $0.lowercased() } == rhs.normalizedPaths.map { $0.lowercased() } - } - - func hash(into hasher: inout Hasher) { - for path in normalizedPaths { - hasher.combine(path.lowercased()) - } - } -} - -struct WorkspaceDuplicateGroupSummary: Identifiable, Equatable { - let id: String - let normalizedRepoPaths: [String] - let canonicalWorkspaceID: UUID - let canonicalWorkspaceName: String - let duplicateWorkspaceIDs: [UUID] - let duplicateWorkspaceNames: [String] - let windowIDsByWorkspaceID: [UUID: [Int]] -} - -struct WorkspaceDuplicateCleanupSkippedItem: Equatable { - let workspaceID: UUID - let workspaceName: String - let windowID: Int? - let reason: String -} - -struct WorkspaceDuplicateCleanupResult: Equatable { - let groupsDetected: Int - let groupsConsolidated: Int - let reassignedWindowIDs: [Int] - let deletedWorkspaceIDs: [UUID] - let skipped: [WorkspaceDuplicateCleanupSkippedItem] - let backupURL: URL? -} - -struct WorkspaceDuplicateCleanupBackup: Codable { - struct BackupGroup: Codable { - let canonicalBeforeMerge: WorkspaceModel - let duplicatesBeforeDelete: [WorkspaceModel] - } - - let createdAt: Date - let groups: [BackupGroup] -} - -/// A single preset capturing which files/folders/prompts are included. -struct WorkspacePreset: Codable, Identifiable, Equatable { - let id: UUID - var name: String - - var capturesFileSelection: Bool - var capturesFileTreeExpansion: Bool - var capturesSelectedPrompts: Bool - - var selectedFilePaths: [String] - var expandedFolders: [String] - var selectedPromptIDs: [UUID] - - var lastUpdated: Date - - /// Default init used by code - init( - id: UUID = UUID(), - name: String, - capturesFileSelection: Bool = true, - capturesFileTreeExpansion: Bool = true, - capturesSelectedPrompts: Bool = true, - selectedFilePaths: [String] = [], - expandedFolders: [String] = [], - selectedPromptIDs: [UUID] = [], - lastUpdated: Date = Date() - ) { - self.id = id - self.name = name - self.capturesFileSelection = capturesFileSelection - self.capturesFileTreeExpansion = capturesFileTreeExpansion - self.capturesSelectedPrompts = capturesSelectedPrompts - self.selectedFilePaths = selectedFilePaths - self.expandedFolders = expandedFolders - self.selectedPromptIDs = selectedPromptIDs - self.lastUpdated = lastUpdated - } - - /// Partial decoding approach to skip errors for mismatch or missing fields. - init(from decoder: Decoder) throws { - let c = try decoder.container(keyedBy: CodingKeys.self) - id = (try? c.decode(UUID.self, forKey: .id)) ?? UUID() - name = (try? c.decode(String.self, forKey: .name)) ?? "Unnamed Preset" - capturesFileSelection = (try? c.decode(Bool.self, forKey: .capturesFileSelection)) ?? true - capturesFileTreeExpansion = (try? c.decode(Bool.self, forKey: .capturesFileTreeExpansion)) ?? true - capturesSelectedPrompts = (try? c.decode(Bool.self, forKey: .capturesSelectedPrompts)) ?? true - selectedFilePaths = (try? c.decode([String].self, forKey: .selectedFilePaths)) ?? [] - expandedFolders = (try? c.decode([String].self, forKey: .expandedFolders)) ?? [] - selectedPromptIDs = (try? c.decode([UUID].self, forKey: .selectedPromptIDs)) ?? [] - lastUpdated = (try? c.decode(Date.self, forKey: .lastUpdated)) ?? Date() - } - - enum CodingKeys: String, CodingKey { - case id - case name - case capturesFileSelection - case capturesFileTreeExpansion - case capturesSelectedPrompts - case selectedFilePaths - case expandedFolders - case selectedPromptIDs - case lastUpdated - } -} - -struct StoredSelection: Codable, Equatable { - let selectedPaths: [String] - let autoCodemapPaths: [String] - let slices: [String: [LineRange]] - let codemapAutoEnabled: Bool - - init( - selectedPaths: [String] = [], - autoCodemapPaths: [String] = [], - slices: [String: [LineRange]] = [:], - codemapAutoEnabled: Bool = true - ) { - self.selectedPaths = selectedPaths - self.autoCodemapPaths = autoCodemapPaths - self.slices = slices - self.codemapAutoEnabled = codemapAutoEnabled - } -} - -/// Per-tab overrides for Context Builder (prompt overrides only for now) -struct ContextBuilderOverrides: Codable, Equatable { - var useOverridePrompt: Bool - var overridePromptText: String - - init(useOverridePrompt: Bool = false, overridePromptText: String = "") { - self.useOverridePrompt = useOverridePrompt - self.overridePromptText = overridePromptText - } -} - -struct ContextBuilderTabConfig: Codable, Equatable { - var instructions: String = "" - - /// Auto-generate a plan after Context Builder completes (nil = use workspace default) - var autoGeneratePlan: Bool? = nil - /// Selected follow-up type for auto-generate (plan/review/question) - defaults to "plan" - var followUpTypeRaw: String? = nil - /// Selected context builder prompt IDs for this tab - var selectedContextBuilderPromptIDs: [UUID] = [] - - private enum CodingKeys: String, CodingKey { - case instructions - case autoGeneratePlan - case followUpTypeRaw - case selectedContextBuilderPromptIDs - } - - init( - instructions: String = "", - autoGeneratePlan: Bool? = nil, - followUpTypeRaw: String? = nil, - selectedContextBuilderPromptIDs: [UUID] = [] - ) { - self.instructions = instructions - self.autoGeneratePlan = autoGeneratePlan - self.followUpTypeRaw = followUpTypeRaw - self.selectedContextBuilderPromptIDs = selectedContextBuilderPromptIDs - } -} - -/// A stashed compose tab (stored for later retrieval) -struct StashedTab: Codable, Identifiable, Equatable { - var id: UUID - var tab: ComposeTabState - var stashedAt: Date - - init(id: UUID = UUID(), tab: ComposeTabState, stashedAt: Date = Date()) { - self.id = id - self.tab = tab - self.stashedAt = stashedAt - } -} - -/// A single Compose tab (auto-saved working state) -struct ComposeTabState: Codable, Identifiable, Equatable { - var id: UUID - var name: String - var lastModified: Date - var isPinned: Bool - var activeChatSessionID: UUID? - var activeAgentSessionID: UUID? - - var selection: StoredSelection - var expandedFolders: [String] - - var promptText: String - var selectedMetaPromptIDs: [UUID] - var activeSubView: FilesTab? - var contextOverrides: ContextBuilderOverrides - /// Active Context Builder tab config. Encodes/decodes under the legacy JSON key `discover`. - var contextBuilder: ContextBuilderTabConfig - - init( - id: UUID = UUID(), - name: String = "T1", - lastModified: Date = Date(), - isPinned: Bool = false, - activeChatSessionID: UUID? = nil, - activeAgentSessionID: UUID? = nil, - selection: StoredSelection = .init(), - expandedFolders: [String] = [], - promptText: String = "", - selectedMetaPromptIDs: [UUID] = [], - activeSubView: FilesTab? = nil, - contextOverrides: ContextBuilderOverrides = .init(), - contextBuilder: ContextBuilderTabConfig = .init() - ) { - self.id = id - self.name = name - self.lastModified = lastModified - self.isPinned = isPinned - self.activeChatSessionID = activeChatSessionID - self.activeAgentSessionID = activeAgentSessionID - self.selection = selection - self.expandedFolders = expandedFolders - self.promptText = promptText - self.selectedMetaPromptIDs = selectedMetaPromptIDs - self.activeSubView = activeSubView - self.contextOverrides = contextOverrides - self.contextBuilder = contextBuilder - } - - init(from decoder: Decoder) throws { - let c = try decoder.container(keyedBy: CodingKeys.self) - id = try c.decodeIfPresent(UUID.self, forKey: .id) ?? UUID() - name = try c.decodeIfPresent(String.self, forKey: .name) ?? "T1" - lastModified = try c.decodeIfPresent(Date.self, forKey: .lastModified) ?? Date() - isPinned = try c.decodeIfPresent(Bool.self, forKey: .isPinned) ?? false - activeChatSessionID = try c.decodeIfPresent(UUID.self, forKey: .activeChatSessionID) - activeAgentSessionID = try c.decodeIfPresent(UUID.self, forKey: .activeAgentSessionID) - selection = try c.decodeIfPresent(StoredSelection.self, forKey: .selection) ?? .init() - expandedFolders = try c.decodeIfPresent([String].self, forKey: .expandedFolders) ?? [] - promptText = try c.decodeIfPresent(String.self, forKey: .promptText) ?? "" - selectedMetaPromptIDs = try c.decodeIfPresent([UUID].self, forKey: .selectedMetaPromptIDs) ?? [] - activeSubView = try c.decodeIfPresent(FilesTab.self, forKey: .activeSubView) - contextOverrides = try c.decodeIfPresent(ContextBuilderOverrides.self, forKey: .contextOverrides) ?? .init() - contextBuilder = try c.decodeIfPresent(ContextBuilderTabConfig.self, forKey: .discover) ?? .init() - } - - func encode(to encoder: Encoder) throws { - var c = encoder.container(keyedBy: CodingKeys.self) - try c.encode(id, forKey: .id) - try c.encode(name, forKey: .name) - try c.encode(lastModified, forKey: .lastModified) - try c.encode(isPinned, forKey: .isPinned) - try c.encodeIfPresent(activeChatSessionID, forKey: .activeChatSessionID) - try c.encodeIfPresent(activeAgentSessionID, forKey: .activeAgentSessionID) - try c.encode(selection, forKey: .selection) - try c.encode(expandedFolders, forKey: .expandedFolders) - try c.encode(promptText, forKey: .promptText) - try c.encode(selectedMetaPromptIDs, forKey: .selectedMetaPromptIDs) - try c.encodeIfPresent(activeSubView, forKey: .activeSubView) - try c.encode(contextOverrides, forKey: .contextOverrides) - try c.encode(contextBuilder, forKey: .discover) - } - - enum CodingKeys: String, CodingKey { - case id - case name - case lastModified - case isPinned - case activeChatSessionID - case activeAgentSessionID - case selection - case expandedFolders - case promptText - case selectedMetaPromptIDs - case activeSubView - case contextOverrides - case discover - } -} - -/// A single workspace's data, describing a workspace: name, repo paths, presets, etc. -struct WorkspaceModel: Codable, Identifiable, Equatable { - let id: UUID - - var schemaVersion: Int - var dateModified: Date - var customStoragePath: URL? - - var isSystemWorkspace: Bool - var isHiddenInMenus: Bool - - /// When true, the workspace is temporary and should not be persisted to disk - var ephemeralFlag: Bool? - - var name: String - var repoPaths: [String] - - var presets: [WorkspacePreset] - var activePresetID: UUID? - var lastUsed: Date - - // Optional custom fields - var customPath: String? - var currentPromptText: String? - /// The last search query typed in the file-search panel (persisted per workspace) - var lastSearchQuery: String? - var selectedMetaPromptIDs: [UUID] - - // Copy and Chat Preset Fields - var copyPresetId: UUID? - var copyCustomizations: CopyCustomizations? - var chatPresetId: UUID? - - // Compose tabs (auto-saved working contexts) - var composeTabs: [ComposeTabState] - var activeComposeTabID: UUID? - - /// Stashed tabs (stored for later retrieval) - var stashedTabs: [StashedTab] - - /// Transient decode-time signal used by workspace loaders to persist current - /// compose-tab invariant normalization once. Excluded from CodingKeys/Equatable. - var normalizationRequiresSave: Bool - - private static let decodeLogger = Logger(subsystem: "com.repoprompt.workspace", category: "decode") - private static var composeTabsDecodeWarningEmitted = false - - private static func logComposeTabsDecodeFailure(error: Error, workspaceID: UUID) { - guard !composeTabsDecodeWarningEmitted else { return } - composeTabsDecodeWarningEmitted = true - let message = "Failed to decode composeTabs for workspace \(workspaceID.uuidString); falling back to empty array. Error: \(error.localizedDescription)" - decodeLogger.error("\(message, privacy: .public)") - } - - /// Default init used by code - init( - id: UUID = UUID(), - schemaVersion: Int = 1, - dateModified: Date = Date(), - name: String, - repoPaths: [String], - presets: [WorkspacePreset] = [], - activePresetID: UUID? = nil, - lastUsed: Date = Date(), - customPath: String? = nil, - currentPromptText: String? = nil, - lastSearchQuery: String? = nil, - selectedMetaPromptIDs: [UUID] = [], - isSystemWorkspace: Bool = false, - customStoragePath: URL? = nil, - ephemeralFlag: Bool? = nil, - isHiddenInMenus: Bool = false, - copyPresetId: UUID? = nil, - copyCustomizations: CopyCustomizations? = nil, - chatPresetId: UUID? = nil, - composeTabs: [ComposeTabState] = [], - activeComposeTabID: UUID? = nil, - stashedTabs: [StashedTab] = [] - ) { - self.id = id - self.schemaVersion = schemaVersion - self.dateModified = dateModified - self.name = name - self.repoPaths = repoPaths - self.presets = presets - self.activePresetID = activePresetID - self.lastUsed = lastUsed - self.customPath = customPath - self.currentPromptText = currentPromptText - self.selectedMetaPromptIDs = selectedMetaPromptIDs - self.lastSearchQuery = lastSearchQuery - self.isSystemWorkspace = isSystemWorkspace - self.customStoragePath = customStoragePath - self.ephemeralFlag = ephemeralFlag - self.isHiddenInMenus = isHiddenInMenus - self.copyPresetId = copyPresetId - self.copyCustomizations = copyCustomizations - self.chatPresetId = chatPresetId - self.composeTabs = composeTabs - self.activeComposeTabID = activeComposeTabID - self.stashedTabs = stashedTabs - normalizationRequiresSave = false - normalizeComposeTabInvariants() - normalizationRequiresSave = false - } - - /// **Partial decoding** to handle missing fields and type mismatches gracefully. - init(from decoder: Decoder) throws { - let c = try decoder.container(keyedBy: CodingKeys.self) - - id = (try? c.decode(UUID.self, forKey: .id)) ?? UUID() - schemaVersion = (try? c.decode(Int.self, forKey: .schemaVersion)) ?? 1 - dateModified = (try? c.decode(Date.self, forKey: .dateModified)) ?? Date() - customStoragePath = (try? c.decode(URL.self, forKey: .customStoragePath)) - isSystemWorkspace = (try? c.decode(Bool.self, forKey: .isSystemWorkspace)) ?? false - isHiddenInMenus = (try? c.decode(Bool.self, forKey: .isHiddenInMenus)) ?? false - ephemeralFlag = (try? c.decode(Bool?.self, forKey: .ephemeralFlag)) ?? nil - name = (try? c.decode(String.self, forKey: .name)) ?? "Untitled Workspace" - repoPaths = (try? c.decode([String].self, forKey: .repoPaths)) ?? [] - presets = (try? c.decode([WorkspacePreset].self, forKey: .presets)) ?? [] - activePresetID = (try? c.decode(UUID.self, forKey: .activePresetID)) - lastUsed = (try? c.decode(Date.self, forKey: .lastUsed)) ?? Date() - customPath = (try? c.decode(String.self, forKey: .customPath)) - currentPromptText = (try? c.decode(String.self, forKey: .currentPromptText)) - lastSearchQuery = (try? c.decode(String.self, forKey: .lastSearchQuery)) - selectedMetaPromptIDs = (try? c.decode([UUID].self, forKey: .selectedMetaPromptIDs)) ?? [] - copyPresetId = (try? c.decode(UUID.self, forKey: .copyPresetId)) - copyCustomizations = (try? c.decode(CopyCustomizations.self, forKey: .copyCustomizations)) - chatPresetId = (try? c.decode(UUID.self, forKey: .chatPresetId)) - do { - composeTabs = try c.decodeIfPresent([ComposeTabState].self, forKey: .composeTabs) ?? [] - } catch { - Self.logComposeTabsDecodeFailure(error: error, workspaceID: id) - composeTabs = [] - } - activeComposeTabID = (try? c.decode(UUID.self, forKey: .activeComposeTabID)) - stashedTabs = (try? c.decode([StashedTab].self, forKey: .stashedTabs)) ?? [] - normalizationRequiresSave = false - normalizeComposeTabInvariants() - } - - func encode(to encoder: Encoder) throws { - var c = encoder.container(keyedBy: CodingKeys.self) - try c.encode(id, forKey: .id) - try c.encode(schemaVersion, forKey: .schemaVersion) - try c.encode(dateModified, forKey: .dateModified) - try c.encodeIfPresent(customStoragePath, forKey: .customStoragePath) - try c.encode(isSystemWorkspace, forKey: .isSystemWorkspace) - try c.encode(isHiddenInMenus, forKey: .isHiddenInMenus) - try c.encode(name, forKey: .name) - try c.encode(repoPaths, forKey: .repoPaths) - try c.encode(presets, forKey: .presets) - try c.encodeIfPresent(activePresetID, forKey: .activePresetID) - try c.encode(lastUsed, forKey: .lastUsed) - try c.encodeIfPresent(customPath, forKey: .customPath) - try c.encodeIfPresent(currentPromptText, forKey: .currentPromptText) - try c.encodeIfPresent(lastSearchQuery, forKey: .lastSearchQuery) - try c.encode(selectedMetaPromptIDs, forKey: .selectedMetaPromptIDs) - try c.encodeIfPresent(ephemeralFlag, forKey: .ephemeralFlag) - try c.encodeIfPresent(copyPresetId, forKey: .copyPresetId) - try c.encodeIfPresent(copyCustomizations, forKey: .copyCustomizations) - try c.encodeIfPresent(chatPresetId, forKey: .chatPresetId) - try c.encode(composeTabs, forKey: .composeTabs) - try c.encodeIfPresent(activeComposeTabID, forKey: .activeComposeTabID) - try c.encode(stashedTabs, forKey: .stashedTabs) - } - - static func == (lhs: WorkspaceModel, rhs: WorkspaceModel) -> Bool { - lhs.id == rhs.id && - lhs.schemaVersion == rhs.schemaVersion && - lhs.dateModified == rhs.dateModified && - lhs.name == rhs.name && - lhs.repoPaths == rhs.repoPaths && - lhs.presets == rhs.presets && - lhs.activePresetID == rhs.activePresetID && - lhs.lastUsed == rhs.lastUsed && - lhs.customPath == rhs.customPath && - lhs.currentPromptText == rhs.currentPromptText && - lhs.lastSearchQuery == rhs.lastSearchQuery && - lhs.selectedMetaPromptIDs == rhs.selectedMetaPromptIDs && - lhs.isHiddenInMenus == rhs.isHiddenInMenus && - lhs.isSystemWorkspace == rhs.isSystemWorkspace && - lhs.customStoragePath == rhs.customStoragePath && - lhs.ephemeralFlag == rhs.ephemeralFlag && - lhs.copyPresetId == rhs.copyPresetId && - lhs.copyCustomizations == rhs.copyCustomizations && - lhs.chatPresetId == rhs.chatPresetId && - lhs.composeTabs == rhs.composeTabs && - lhs.activeComposeTabID == rhs.activeComposeTabID && - lhs.stashedTabs == rhs.stashedTabs - } - - enum CodingKeys: String, CodingKey { - case id - case schemaVersion - case dateModified - case customStoragePath - case isSystemWorkspace - case isHiddenInMenus - case name - case repoPaths - case presets - case activePresetID - case lastUsed - case customPath - case currentPromptText - case lastSearchQuery - case selectedMetaPromptIDs - case ephemeralFlag - case copyPresetId - case copyCustomizations - case chatPresetId - case composeTabs - case activeComposeTabID - case stashedTabs - } -} - -extension WorkspaceModel { - /// Indicates whether this workspace should not be persisted to disk - var isEphemeral: Bool { - get { ephemeralFlag ?? false } - set { ephemeralFlag = newValue } - } - - @discardableResult - mutating func normalizeComposeTabInvariants() -> Bool { - var mutated = false - - if composeTabs.isEmpty { - let tab = ComposeTabState( - name: "T1", - promptText: currentPromptText ?? "", - selectedMetaPromptIDs: selectedMetaPromptIDs, - activeSubView: nil // nil = use CE default files tab - ) - composeTabs = [tab] - activeComposeTabID = tab.id - mutated = true - } - - let activeTabIDs = Set(composeTabs.map(\.id)) - if activeComposeTabID.map({ !activeTabIDs.contains($0) }) ?? true { - activeComposeTabID = composeTabs.first?.id - mutated = true - } - - let originalCount = stashedTabs.count - stashedTabs.removeAll { activeTabIDs.contains($0.tab.id) } - if stashedTabs.count != originalCount { - mutated = true - } - - if mutated { - normalizationRequiresSave = true - } - return mutated - } -} +import RepoPromptCore + +// App compatibility names for the canonical Slice 1 workspace domain. +typealias WorkspacePreset = RepoPromptCore.WorkspacePreset +typealias StoredSelection = RepoPromptCore.StoredSelection +typealias ContextBuilderOverrides = RepoPromptCore.ContextBuilderOverrides +typealias ContextBuilderTabConfig = RepoPromptCore.ContextBuilderTabConfig +typealias StashedTab = RepoPromptCore.StashedTab +typealias ComposeTabState = RepoPromptCore.ComposeTabState +typealias WorkspaceModel = RepoPromptCore.WorkspaceModel +typealias CopyCustomizations = RepoPromptCore.CopyCustomizations +typealias FileTreeOption = RepoPromptCore.FileTreeOption +typealias CodeMapUsage = RepoPromptCore.CodeMapUsage +typealias GitInclusion = RepoPromptCore.GitInclusion +typealias FilesTab = RepoPromptCore.FilesTab +typealias LineRange = RepoPromptCore.LineRange +typealias WorkspaceIndexEntry = RepoPromptCore.WorkspaceIndexEntry +typealias WorkspaceSessionController = RepoPromptCore.WorkspaceSessionController +typealias WorkspaceSessionSnapshot = RepoPromptCore.WorkspaceSessionSnapshot +typealias WorkspaceSessionMutationOptions = RepoPromptCore.WorkspaceSessionMutationOptions +typealias WorkspaceSessionTransaction = RepoPromptCore.WorkspaceSessionTransaction +typealias WorkspaceSessionBindingCandidate = RepoPromptCore.WorkspaceSessionBindingCandidate diff --git a/Sources/RepoPrompt/Infrastructure/AI/ACP/ACPAgentSessionController.swift b/Sources/RepoPrompt/Infrastructure/AI/ACP/ACPAgentSessionController.swift index c6d850871..b152f3a77 100644 --- a/Sources/RepoPrompt/Infrastructure/AI/ACP/ACPAgentSessionController.swift +++ b/Sources/RepoPrompt/Infrastructure/AI/ACP/ACPAgentSessionController.swift @@ -1,4 +1,6 @@ import Foundation +import RepoPromptCore +import RepoPromptCoreMacOS actor ACPAgentSessionController { struct RequestTimeouts { diff --git a/Sources/RepoPrompt/Infrastructure/AI/AIMessage.swift b/Sources/RepoPrompt/Infrastructure/AI/AIMessage.swift index 12b8fd833..e1c0264f3 100644 --- a/Sources/RepoPrompt/Infrastructure/AI/AIMessage.swift +++ b/Sources/RepoPrompt/Infrastructure/AI/AIMessage.swift @@ -1,4 +1,5 @@ import Foundation +import RepoPromptCore import SwiftOpenAI /// A single conversation entry @@ -14,6 +15,25 @@ struct ConversationEntry { /// Keep each piece separate. We also provide "XML getters" for certain fields. struct AIMessage { + enum TailAssemblyStrategy { + case legacy + case coreStandardChat + } + + struct PreparedMessage: Equatable { + let role: AIProviderInputRole + let content: String + } + + struct PreparedOpenAIChatInput: Equatable { + let messages: [PreparedMessage] + } + + struct PreparedOpenAIResponsesInput: Equatable { + let instructions: String? + let messages: [PreparedMessage] + } + /// The main system prompt let systemPrompt: String @@ -44,6 +64,12 @@ struct AIMessage { /// Duplicate the user‑instruction block at the very top of the prompt let duplicateUserInstructionsAtTop: Bool + /// Selects the prompt-tail renderer without changing provider role placement. + let tailAssemblyStrategy: TailAssemblyStrategy + + /// Immutable Core rendering reused by legacy getters, assembly, and provider adapters. + private let renderedFactualSnippets: PromptRenderedFactualSnippets + // MARK: - XML Getter Properties /// System prompt in XML @@ -69,33 +95,17 @@ struct AIMessage { /// File tree in XML var fileTreeXML: String { - guard !fileTree.isEmpty else { return "" } - return """ - - \(fileTree) - - """ + renderedFactualSnippets.fileMap ?? "" } /// File blocks in XML var fileBlocksXML: String { - guard !fileBlocks.isEmpty else { return "" } - var result = "\n" - for block in fileBlocks { - result += block + "\n\n" - } - result += "" - return result + renderedFactualSnippets.fileContents ?? "" } /// Git diff in XML var gitDiffXML: String { - guard let diff = gitDiff, !diff.isEmpty else { return "" } - return """ - - \(diff) - - """ + renderedFactualSnippets.gitDiff ?? "" } /// Combine the main sections, skipping anything empty @@ -123,7 +133,8 @@ struct AIMessage { disableTemperatureOverrides: Bool = false, promptSectionsOrder: [PromptSection], disabledPromptSections: Set, - duplicateUserInstructionsAtTop: Bool = false + duplicateUserInstructionsAtTop: Bool = false, + tailAssemblyStrategy: TailAssemblyStrategy = .legacy ) { self.systemPrompt = systemPrompt self.metaPrompts = metaPrompts @@ -136,6 +147,12 @@ struct AIMessage { self.promptSectionsOrder = promptSectionsOrder self.disabledPromptSections = disabledPromptSections self.duplicateUserInstructionsAtTop = duplicateUserInstructionsAtTop + self.tailAssemblyStrategy = tailAssemblyStrategy + renderedFactualSnippets = Self.renderFactualSnippets( + fileTree: fileTree, + fileBlocks: fileBlocks, + gitDiff: gitDiff + ) } /// Simpler initializer for "system prompt + user message" usage @@ -156,6 +173,12 @@ struct AIMessage { promptSectionsOrder = PromptAssemblyBuilder.defaultSectionOrder disabledPromptSections = [] duplicateUserInstructionsAtTop = false + tailAssemblyStrategy = .legacy + renderedFactualSnippets = Self.renderFactualSnippets( + fileTree: "", + fileBlocks: [], + gitDiff: nil + ) } /// Builds the text block that must be *prepended* to the **final** user @@ -166,9 +189,35 @@ struct AIMessage { /// tail instead of being sent as an independent `.system` role. /// - Returns: A single string, without leading / trailing blank lines. func buildTail(embedSystemPrompt: Bool) -> String { + let tail = switch tailAssemblyStrategy { + case .legacy: + buildLegacyTail() + case .coreStandardChat: + buildCoreStandardChatTail() + } + + guard embedSystemPrompt, !systemPrompt.isEmpty else { return tail } + guard !tail.isEmpty else { return systemPrompt } + return [tail, "", systemPrompt].joined(separator: "\n\n") + } + + private static func renderFactualSnippets( + fileTree: String, + fileBlocks: [String], + gitDiff: String? + ) -> PromptRenderedFactualSnippets { + PromptRenderingService.renderFactualSnippets( + fileTreeContent: fileTree, + codemapBlocks: [], + contentBlocks: fileBlocks, + gitDiff: gitDiff, + envelopePolicy: .chatStyleTree + ) + } + + private func buildLegacyTail() -> String { var parts: [String] = [] - // ───── 1) Optional *top* copy of the user instructions ───── if duplicateUserInstructionsAtTop, let userBlock = conversationMessages.last(where: { $0.role == .user })?.content, !userBlock.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty @@ -176,7 +225,6 @@ struct AIMessage { parts.append(userBlock) } - // ───── 2) Auto‑generated sections in caller‑defined order ───── for section in promptSectionsOrder where !disabledPromptSections.contains(section) { switch section { case .fileMap: @@ -192,102 +240,124 @@ struct AIMessage { parts.append(gitDiffXML) } case .userInstructions: - // User-authored block, never auto-prepended. continue } } - // ───── 3) Inline system prompt (optional) ───── - if embedSystemPrompt, !systemPrompt.isEmpty { - if !parts.isEmpty { parts.append("") } // blank line separator - parts.append(systemPrompt) + return parts.joined(separator: "\n\n") + } + + private func buildCoreStandardChatTail() -> String { + let factual = renderedFactualSnippets + var snippets: [PromptSection: String] = [:] + snippets[.fileMap] = factual.fileMap + snippets[.fileContents] = factual.fileContents + snippets[.gitDiff] = factual.gitDiff + + if !metaPrompts.isEmpty { + snippets[.metaPrompts] = metaPrompts.joined(separator: "\n") } - return parts.joined(separator: "\n\n") + // Chat treats user instructions as a top-only duplicate. Keep that app policy + // outside Core's ordered traversal so repeated/custom orders cannot emit it again, + // and preserve prewrapped trailing LF/CRLF bytes without layout normalization. + let assembled = PromptAssemblyBuilder.build( + order: promptSectionsOrder, + disabled: disabledPromptSections.union([.userInstructions]), + duplicateUserInstructionsAtTop: false, + snippets: snippets, + layout: .blankLineSeparatedFragments + ) + guard duplicateUserInstructionsAtTop, + let userBlock = conversationMessages.last(where: { $0.role == .user })?.content, + !userBlock.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + else { + return assembled + } + return assembled.isEmpty ? userBlock : userBlock + "\n\n" + assembled } - /// Generates the full array of `ChatCompletionParameters.Message` objects - /// that an OpenAI‑style chat endpoint expects. - /// - /// Replaces the old `createMessages` helper (which has been removed from - /// providers). - func openAIChatMessages(embedSystemPrompt: Bool) -> [ChatCompletionParameters.Message] { + func preparedOpenAIChatInput(embedSystemPrompt: Bool) -> PreparedOpenAIChatInput { let tail = buildTail(embedSystemPrompt: embedSystemPrompt) - - var msgs: [ChatCompletionParameters.Message] = [] + var messages: [PreparedMessage] = [] if !embedSystemPrompt, !systemPrompt.isEmpty { - msgs.append(.init(role: .system, content: .text(systemPrompt))) + messages.append(.init(role: .system, content: systemPrompt)) } let lastUserIndex = conversationMessages.lastIndex { $0.role == .user } - - for (idx, entry) in conversationMessages.enumerated() { - let baseText = entry.content - let text = (entry.role == .user && idx == lastUserIndex && !tail.isEmpty) - ? tail + "\n" + baseText - : baseText - - let role: ChatCompletionParameters.Message.Role = (entry.role == .user) - ? .user - : .assistant - msgs.append(.init(role: role, content: .text(text))) + for (index, entry) in conversationMessages.enumerated() { + let text = entry.role == .user && index == lastUserIndex && !tail.isEmpty + ? tail + "\n" + entry.content + : entry.content + messages.append(.init( + role: entry.role == .user ? .user : .assistant, + content: text + )) } - return msgs + return PreparedOpenAIChatInput(messages: messages) } - /// Generates the full array of `InputItem`s for the Responses-API, - /// applying the **same** "tail-on-last-user" logic that - /// `openAIChatMessages(_:)` uses. + /// Generates the full array of `ChatCompletionParameters.Message` objects + /// that an OpenAI‑style chat endpoint expects. /// - /// All assistant turns are encoded as normal `message` objects - /// (role = "assistant"). This avoids the need for the `msg_…` ids that - /// `output_message` objects require. - func openAIResponsesInput() -> SwiftOpenAI.InputType { - // 1. Build the XML / meta tail that must be prepended to the *first* - // user message. + /// Replaces the old `createMessages` helper (which has been removed from + /// providers). + func openAIChatMessages(embedSystemPrompt: Bool) -> [ChatCompletionParameters.Message] { + preparedOpenAIChatInput(embedSystemPrompt: embedSystemPrompt).messages.map { message in + let role: ChatCompletionParameters.Message.Role = switch message.role { + case .system: .system + case .user: .user + case .assistant: .assistant + } + return .init(role: role, content: .text(message.content)) + } + } + + func preparedOpenAIResponsesInput() -> PreparedOpenAIResponsesInput { let tail = buildTail(embedSystemPrompt: false) let additions = tail.isEmpty ? "" : tail + "\n\n" - var items: [SwiftOpenAI.InputItem] = [] + var messages: [PreparedMessage] = [] var firstUser = true - - // 2. Walk through the stored conversation. for entry in conversationMessages { switch entry.role { case .user: - var text = entry.content - if firstUser { - text = additions + text // prepend only once - firstUser = false - } - - let msg = SwiftOpenAI.InputMessage( - role: "user", - content: .text(text) - ) - items.append(.message(msg)) - + let text = firstUser ? additions + entry.content : entry.content + firstUser = false + messages.append(.init(role: .user, content: text)) case .assistant: - // Previous assistant reply – send as a plain message. - let msg = SwiftOpenAI.InputMessage( - role: "assistant", - content: .text(entry.content) - ) - items.append(.message(msg)) + messages.append(.init(role: .assistant, content: entry.content)) } } - // 3. Edge-case: no user message yet but there *is* a tail. - if items.isEmpty, !additions.isEmpty { - let msg = SwiftOpenAI.InputMessage( - role: "user", - content: .text(additions) - ) - items.append(.message(msg)) + if messages.isEmpty, !additions.isEmpty { + messages.append(.init(role: .user, content: additions)) } + return PreparedOpenAIResponsesInput( + instructions: systemPrompt.isEmpty ? nil : systemPrompt, + messages: messages + ) + } + + /// Generates the full array of `InputItem`s for the Responses API. + /// All assistant turns remain ordinary `message` objects so no provider + /// response IDs are required. + func openAIResponsesInput() -> SwiftOpenAI.InputType { + let prepared = preparedOpenAIResponsesInput() + let items = prepared.messages.map { message in + let role = switch message.role { + case .system: "system" + case .user: "user" + case .assistant: "assistant" + } + return SwiftOpenAI.InputItem.message(.init( + role: role, + content: .text(message.content) + )) + } return .array(items) } diff --git a/Sources/RepoPrompt/Infrastructure/AI/Models/AIProviderInputProjection.swift b/Sources/RepoPrompt/Infrastructure/AI/Models/AIProviderInputProjection.swift new file mode 100644 index 000000000..8f6ea7c6f --- /dev/null +++ b/Sources/RepoPrompt/Infrastructure/AI/Models/AIProviderInputProjection.swift @@ -0,0 +1,210 @@ +import RepoPromptCore + +enum AIProviderInputRole: Equatable { + case system + case user + case assistant +} + +struct AIProviderInputProjection: Equatable { + enum RouteResolution: Equatable { + case unresolved + case preflightResolved + case providerResolved + } + + enum Transport: Equatable { + case unresolved + case openAIChat + case openAIResponses + case anthropicMessages + case roleLabeledCLI + case claudeCode + case customOpenAILegacy + } + + enum FallbackReason: Equatable { + case providerRuntimeConfigurationRequired + case providerProjectionUnavailable + } + + struct Fragment: Equatable { + enum Channel: Equatable { + case system + case instructions + case message(role: AIProviderInputRole) + case standardInput + case nativeSystemOverride + } + + let channel: Channel + let text: String + } + + let transport: Transport + let routeResolution: RouteResolution + let fragments: [Fragment] + let fallbackReason: FallbackReason? + + var renderedText: String { + fragments.map(\.text).joined() + } + + private init( + transport: Transport, + routeResolution: RouteResolution, + fragments: [Fragment], + fallbackReason: FallbackReason? + ) { + self.transport = transport + self.routeResolution = routeResolution + self.fragments = fragments + self.fallbackReason = fallbackReason + } + + static func unresolved( + neutralChatInput input: AIMessage.PreparedOpenAIChatInput, + fallbackReason: FallbackReason + ) -> AIProviderInputProjection { + AIProviderInputProjection( + transport: .unresolved, + routeResolution: .unresolved, + fragments: fragments(for: input), + fallbackReason: fallbackReason + ) + } + + static func preflightResolved( + chatInput input: AIMessage.PreparedOpenAIChatInput + ) -> AIProviderInputProjection { + AIProviderInputProjection( + transport: .openAIChat, + routeResolution: .preflightResolved, + fragments: fragments(for: input), + fallbackReason: nil + ) + } + + static func preflightResolved( + responsesInput input: AIMessage.PreparedOpenAIResponsesInput + ) -> AIProviderInputProjection { + AIProviderInputProjection( + transport: .openAIResponses, + routeResolution: .preflightResolved, + fragments: fragments(for: input), + fallbackReason: nil + ) + } + + static func providerResolved( + chatInput input: AIMessage.PreparedOpenAIChatInput + ) -> AIProviderInputProjection { + AIProviderInputProjection( + transport: .openAIChat, + routeResolution: .providerResolved, + fragments: fragments(for: input), + fallbackReason: nil + ) + } + + static func providerResolved( + responsesInput input: AIMessage.PreparedOpenAIResponsesInput + ) -> AIProviderInputProjection { + AIProviderInputProjection( + transport: .openAIResponses, + routeResolution: .providerResolved, + fragments: fragments(for: input), + fallbackReason: nil + ) + } + + private static func fragments( + for input: AIMessage.PreparedOpenAIChatInput + ) -> [Fragment] { + input.messages.map { + Fragment(channel: .message(role: $0.role), text: $0.content) + } + } + + private static func fragments( + for input: AIMessage.PreparedOpenAIResponsesInput + ) -> [Fragment] { + var fragments: [Fragment] = [] + if let instructions = input.instructions { + fragments.append(.init(channel: .instructions, text: instructions)) + } + fragments.append(contentsOf: input.messages.map { + .init(channel: .message(role: $0.role), text: $0.content) + }) + return fragments + } +} + +struct ChatInputTokenEstimate: Equatable { + let inputProjection: AIProviderInputProjection + let tokenProjection: TokenProjection + + init( + inputProjection: AIProviderInputProjection, + source: TokenProjection.Source + ) { + self.inputProjection = inputProjection + tokenProjection = TokenProjectionService.renderedPayloadEstimate( + inputProjection.renderedText, + view: .userConfigured, + source: source + ) + } +} + +enum AIProviderInputProjectionResolver { + static func preflight( + message: AIMessage, + model: AIModel + ) -> AIProviderInputProjection { + switch model.providerType { + case .openAI: + openAIProjection(message: message, model: model) + case .openRouter: + .preflightResolved( + chatInput: message.preparedOpenAIChatInput(embedSystemPrompt: false) + ) + case .azure, .customProvider: + unresolvedProjection( + for: message, + fallbackReason: .providerRuntimeConfigurationRequired + ) + case .anthropic, .ollama, .gemini, .deepseek, .fireworks, .grok, .groq, .zAI, + .claudeCode, .codex, .openCode, .cursor: + unresolvedProjection( + for: message, + fallbackReason: .providerProjectionUnavailable + ) + } + } + + private static func openAIProjection( + message: AIMessage, + model: AIModel + ) -> AIProviderInputProjection { + if model.usesResponsesAPI { + return .preflightResolved( + responsesInput: message.preparedOpenAIResponsesInput() + ) + } + let embedSystemPrompt = model == .o1Mini || model == .o1Preview + return .preflightResolved( + chatInput: message.preparedOpenAIChatInput(embedSystemPrompt: embedSystemPrompt) + ) + } + + private static func unresolvedProjection( + for message: AIMessage, + fallbackReason: AIProviderInputProjection.FallbackReason + ) -> AIProviderInputProjection { + .unresolved( + neutralChatInput: message.preparedOpenAIChatInput(embedSystemPrompt: false), + fallbackReason: fallbackReason + ) + } +} diff --git a/Sources/RepoPrompt/Infrastructure/AI/Providers/AIProviderFactory.swift b/Sources/RepoPrompt/Infrastructure/AI/Providers/AIProviderFactory.swift index 81b54bd85..1d3ad9ec6 100644 --- a/Sources/RepoPrompt/Infrastructure/AI/Providers/AIProviderFactory.swift +++ b/Sources/RepoPrompt/Infrastructure/AI/Providers/AIProviderFactory.swift @@ -338,6 +338,11 @@ struct AICompletionResult { protocol AIProvider { func streamMessage(_ aiMessage: AIMessage, model: AIModel, maxTokens: Int?) async throws -> AsyncThrowingStream + func streamMessageWithInputProjection( + _ aiMessage: AIMessage, + model: AIModel, + maxTokens: Int? + ) async throws -> AIProviderStreamStart func completeMessage(_ aiMessage: AIMessage, model: AIModel, maxTokens: Int?) async throws -> AICompletionResult func dispose() async } diff --git a/Sources/RepoPrompt/Infrastructure/AI/Providers/AIProviderInputProjectionCapability.swift b/Sources/RepoPrompt/Infrastructure/AI/Providers/AIProviderInputProjectionCapability.swift new file mode 100644 index 000000000..26b9799f3 --- /dev/null +++ b/Sources/RepoPrompt/Infrastructure/AI/Providers/AIProviderInputProjectionCapability.swift @@ -0,0 +1,17 @@ +struct AIProviderStreamStart { + let stream: AsyncThrowingStream + let inputProjection: AIProviderInputProjection? +} + +extension AIProvider { + func streamMessageWithInputProjection( + _ aiMessage: AIMessage, + model: AIModel, + maxTokens: Int? + ) async throws -> AIProviderStreamStart { + try await AIProviderStreamStart( + stream: streamMessage(aiMessage, model: model, maxTokens: maxTokens), + inputProjection: nil + ) + } +} diff --git a/Sources/RepoPrompt/Infrastructure/AI/Providers/ClaudeCode/ClaudeCodeCompatibleBackendStore.swift b/Sources/RepoPrompt/Infrastructure/AI/Providers/ClaudeCode/ClaudeCodeCompatibleBackendStore.swift index c27e5d961..2ae53aac5 100644 --- a/Sources/RepoPrompt/Infrastructure/AI/Providers/ClaudeCode/ClaudeCodeCompatibleBackendStore.swift +++ b/Sources/RepoPrompt/Infrastructure/AI/Providers/ClaudeCode/ClaudeCodeCompatibleBackendStore.swift @@ -1,4 +1,5 @@ import Foundation +import RepoPromptCore final class ClaudeCodeCompatibleBackendStore: @unchecked Sendable { static let shared = ClaudeCodeCompatibleBackendStore() @@ -16,7 +17,9 @@ final class ClaudeCodeCompatibleBackendStore: @unchecked Sendable { init( defaults: UserDefaults = .standard, - secureService: SecureKeysService = SecureKeysService() + secureService: SecureKeysService = SecureKeysService( + secureStorage: SecureKeyValueStorageFactory.defaultBackend() + ) ) { self.defaults = defaults self.secureService = secureService @@ -66,7 +69,7 @@ final class ClaudeCodeCompatibleBackendStore: @unchecked Sendable { func hasSecret( for id: ClaudeCodeCompatibleBackendID, - accessMode: KeychainAccessMode = .nonInteractive(reason: .backgroundAvailabilityCheck) + accessMode: SecureStorageAccessMode = .nonInteractive(reason: .backgroundAvailabilityCheck) ) async -> Bool { do { guard let rawSecret = try await secret(for: id, accessMode: accessMode) else { return false } @@ -79,7 +82,7 @@ final class ClaudeCodeCompatibleBackendStore: @unchecked Sendable { func secret( for id: ClaudeCodeCompatibleBackendID, - accessMode: KeychainAccessMode = .interactive + accessMode: SecureStorageAccessMode = .interactive ) async throws -> String? { try await secureService.getAPIKey(for: id.secureStorageAccount, accessMode: accessMode) } diff --git a/Sources/RepoPrompt/Infrastructure/AI/Providers/ClaudeCode/SDK/ClaudeNativeProcessSessionController.swift b/Sources/RepoPrompt/Infrastructure/AI/Providers/ClaudeCode/SDK/ClaudeNativeProcessSessionController.swift index c5d4f4def..231ad0731 100644 --- a/Sources/RepoPrompt/Infrastructure/AI/Providers/ClaudeCode/SDK/ClaudeNativeProcessSessionController.swift +++ b/Sources/RepoPrompt/Infrastructure/AI/Providers/ClaudeCode/SDK/ClaudeNativeProcessSessionController.swift @@ -1,4 +1,6 @@ import Foundation +import RepoPromptCore +import RepoPromptCoreMacOS final actor ClaudeNativeProcessSessionController { private static let rawEventLogFilePathKey = "claudeRawEventLogFilePath" diff --git a/Sources/RepoPrompt/Infrastructure/AI/Providers/Codex/AppServer/CodexAppServerClient.swift b/Sources/RepoPrompt/Infrastructure/AI/Providers/Codex/AppServer/CodexAppServerClient.swift index f474eb200..d4fc01e79 100644 --- a/Sources/RepoPrompt/Infrastructure/AI/Providers/Codex/AppServer/CodexAppServerClient.swift +++ b/Sources/RepoPrompt/Infrastructure/AI/Providers/Codex/AppServer/CodexAppServerClient.swift @@ -1,6 +1,8 @@ import Darwin import Darwin.POSIX.fcntl import Foundation +import RepoPromptCore +import RepoPromptCoreMacOS enum CodexJSONValue { case string(String) diff --git a/Sources/RepoPrompt/Infrastructure/Core/RepoPromptCoreHost.swift b/Sources/RepoPrompt/Infrastructure/Core/RepoPromptCoreHost.swift new file mode 100644 index 000000000..60e115dde --- /dev/null +++ b/Sources/RepoPrompt/Infrastructure/Core/RepoPromptCoreHost.swift @@ -0,0 +1,188 @@ +import Foundation +import RepoPromptCore + +// Phase 1 compatibility aliases. Remove when the host/session implementation moves to Core. +typealias RepoPromptSessionID = RepoPromptCore.RepoPromptSessionID +typealias MCPRoutingSessionID = RepoPromptCore.MCPRoutingSessionID + +enum RepoPromptCoreSessionLifecycle: String { + case created + case active + case draining + case removed +} + +struct RepoPromptSessionSnapshot { + let sessionID: RepoPromptSessionID + let routingSessionID: MCPRoutingSessionID + let lifecycle: RepoPromptCoreSessionLifecycle +} + +@MainActor +final class RepoPromptCoreSessionHandle { + let session: RepoPromptCoreSession + + var sessionID: RepoPromptSessionID { + session.sessionID + } + + var routingSessionID: MCPRoutingSessionID { + session.routingSessionID + } + + var snapshot: RepoPromptSessionSnapshot { + session.snapshot + } + + fileprivate init(session: RepoPromptCoreSession) { + self.session = session + } +} + +@MainActor +final class RepoPromptCoreSession { + let sessionID: RepoPromptSessionID + let routingSessionID: MCPRoutingSessionID + let workspaceRepository: WorkspaceRepository + let workspacePersistenceWriter: WorkspacePersistenceWriter + let workspaceAccessPolicy: any WorkspaceAccessPolicy + let workspaceFileContextStore: WorkspaceFileContextStore + let workspaceSearchService: WorkspaceSearchService + let workspaceSessionController: WorkspaceSessionController + let workspaceSelectionController: WorkspaceSelectionController + let selectionSliceCoordinator: SelectionSliceCoordinator + fileprivate(set) var lifecycle: RepoPromptCoreSessionLifecycle = .created + + var snapshot: RepoPromptSessionSnapshot { + RepoPromptSessionSnapshot( + sessionID: sessionID, + routingSessionID: routingSessionID, + lifecycle: lifecycle + ) + } + + init( + sessionID: RepoPromptSessionID = RepoPromptSessionID(), + routingSessionID: MCPRoutingSessionID, + workspaceRepository: WorkspaceRepository, + workspacePersistenceWriter: WorkspacePersistenceWriter, + workspaceAccessPolicy: any WorkspaceAccessPolicy, + runtime: RepoPromptEmbeddedWorkspaceRuntime + ) { + self.sessionID = sessionID + self.routingSessionID = routingSessionID + self.workspaceRepository = workspaceRepository + self.workspacePersistenceWriter = workspacePersistenceWriter + self.workspaceAccessPolicy = workspaceAccessPolicy + workspaceFileContextStore = runtime.workspaceFileContextStore + workspaceSearchService = runtime.workspaceSearchService + selectionSliceCoordinator = runtime.selectionSliceCoordinator + let sessionController = WorkspaceSessionController( + repository: workspaceRepository, + persistenceWriter: workspacePersistenceWriter, + accessPolicy: workspaceAccessPolicy + ) + workspaceSessionController = sessionController + workspaceSelectionController = WorkspaceSelectionController( + sessionController: sessionController, + mutationService: runtime.selectionMutationService + ) + } +} + +@MainActor +final class RepoPromptCoreHost { + private final class WeakSession { + weak var value: RepoPromptCoreSession? + + init(_ value: RepoPromptCoreSession) { + self.value = value + } + } + + let workspaceRepository: WorkspaceRepository + let workspacePersistenceWriter: WorkspacePersistenceWriter + let workspaceAccessPolicy: any WorkspaceAccessPolicy + let runtimeSessionRegistry: MCPRuntimeSessionRegistry + let runtimeFactory: RepoPromptEmbeddedWorkspaceRuntimeFactory + private var createdSessions: [RepoPromptSessionID: WeakSession] = [:] + private var activeSessions: [RepoPromptSessionID: RepoPromptCoreSession] = [:] + + init( + workspaceRepository: WorkspaceRepository, + workspacePersistenceWriter: WorkspacePersistenceWriter, + workspaceAccessPolicy: any WorkspaceAccessPolicy, + runtimeSessionRegistry: MCPRuntimeSessionRegistry, + runtimeFactory: RepoPromptEmbeddedWorkspaceRuntimeFactory + ) { + self.workspaceRepository = workspaceRepository + self.workspacePersistenceWriter = workspacePersistenceWriter + self.workspaceAccessPolicy = workspaceAccessPolicy + self.runtimeSessionRegistry = runtimeSessionRegistry + self.runtimeFactory = runtimeFactory + } + + func makeEmbeddedSession(routingSessionID: MCPRoutingSessionID) -> RepoPromptCoreSessionHandle { + let runtime = runtimeFactory.makeRuntime() + let session = RepoPromptCoreSession( + routingSessionID: routingSessionID, + workspaceRepository: workspaceRepository, + workspacePersistenceWriter: workspacePersistenceWriter, + workspaceAccessPolicy: workspaceAccessPolicy, + runtime: runtime + ) + createdSessions[session.sessionID] = WeakSession(session) + return RepoPromptCoreSessionHandle(session: session) + } + + @discardableResult + func activate(_ handle: RepoPromptCoreSessionHandle) -> Bool { + let session = handle.session + guard session.lifecycle == .created else { return session.lifecycle == .active } + let registration = runtimeSessionRegistry.register(session: session) + guard registration == .accepted || registration == .alreadyRegistered else { + return false + } + session.lifecycle = .active + activeSessions[session.sessionID] = session + return true + } + + func beginDraining(_ handle: RepoPromptCoreSessionHandle) { + let session = handle.session + guard session.lifecycle == .active else { return } + guard runtimeSessionRegistry.beginDraining( + windowID: session.routingSessionID.rawValue, + expectedSessionID: session.sessionID + ) else { + return + } + session.lifecycle = .draining + } + + func remove(_ handle: RepoPromptCoreSessionHandle) { + let session = handle.session + guard session.lifecycle != .removed else { return } + _ = runtimeSessionRegistry.remove( + windowID: session.routingSessionID.rawValue, + expectedSessionID: session.sessionID + ) + activeSessions.removeValue(forKey: session.sessionID) + createdSessions.removeValue(forKey: session.sessionID) + session.lifecycle = .removed + } + + func activeSessionHandles() -> [RepoPromptCoreSessionHandle] { + runtimeSessionRegistry.sessions(includeDraining: true).map(RepoPromptCoreSessionHandle.init(session:)) + } + + func shutdownForAppTermination() { + let handles = activeSessionHandles() + for handle in handles { + beginDraining(handle) + } + for handle in handles { + remove(handle) + } + } +} diff --git a/Sources/RepoPrompt/Infrastructure/Diffing/EditFlowPerf.swift b/Sources/RepoPrompt/Infrastructure/Diffing/EditFlowPerf.swift index caca7f944..36524dfc0 100644 --- a/Sources/RepoPrompt/Infrastructure/Diffing/EditFlowPerf.swift +++ b/Sources/RepoPrompt/Infrastructure/Diffing/EditFlowPerf.swift @@ -38,6 +38,15 @@ enum EditFlowPerf { struct IntervalState {} #endif + struct ExternalIntervalState { + #if DEBUG + let captureEpoch: UInt64 + let startNanoseconds: UInt64 + let stageName: String + let sanitizedDimensions: String + #endif + } + struct Dimensions { var toolName: String? var runPurpose: String? @@ -1393,4 +1402,53 @@ enum EditFlowPerf { try await operation() } #endif + + static func beginExternalInterval( + stageName: String, + sanitizedDimensions: String + ) -> ExternalIntervalState? { + #if DEBUG + guard let start = debugCaptureRecorder.startTimestampIfActive() else { return nil } + return ExternalIntervalState( + captureEpoch: start.epoch, + startNanoseconds: start.startNanoseconds, + stageName: stageName, + sanitizedDimensions: sanitizedDimensions + ) + #else + return nil + #endif + } + + static func endExternalInterval( + _ state: ExternalIntervalState?, + sanitizedDimensions: String + ) { + #if DEBUG + guard let state else { return } + debugCaptureRecorder.record( + stageName: state.stageName, + sanitizedDimensions: sanitizedDimensions.isEmpty ? state.sanitizedDimensions : sanitizedDimensions, + captureEpoch: state.captureEpoch, + startNanoseconds: state.startNanoseconds + ) + #endif + } + + static func recordExternalLifecycleEvent( + eventName: String, + correlationID: UUID?, + sanitizedDimensions: String + ) { + #if DEBUG + guard let correlationID, + let captureEpoch = debugCaptureRecorder.activeEpochIfActive() + else { return } + debugCaptureRecorder.recordLifecycleEvent( + eventName: eventName, + correlation: LifecycleCorrelation(id: correlationID, captureEpoch: captureEpoch), + sanitizedDimensions: sanitizedDimensions + ) + #endif + } } diff --git a/Sources/RepoPrompt/Infrastructure/FileSystem/FileContentSnapshot.swift b/Sources/RepoPrompt/Infrastructure/FileSystem/FileContentSnapshot.swift deleted file mode 100644 index d9326d2b9..000000000 --- a/Sources/RepoPrompt/Infrastructure/FileSystem/FileContentSnapshot.swift +++ /dev/null @@ -1,105 +0,0 @@ -import Foundation -#if os(macOS) || os(iOS) || os(tvOS) || os(watchOS) - import Darwin -#else - import Glibc -#endif - -struct FileContentFingerprint: Hashable { - let deviceID: UInt64 - let fileNumber: UInt64 - let byteSize: Int64 - let modificationSeconds: Int64 - let modificationNanoseconds: Int64 - let statusChangeSeconds: Int64 - let statusChangeNanoseconds: Int64 - - var modificationDate: Date { - Date( - timeIntervalSince1970: TimeInterval(modificationSeconds) - + TimeInterval(modificationNanoseconds) / 1_000_000_000 - ) - } -} - -struct ValidatedFileContentSnapshot { - let content: String? - let detectedEncodingRawValue: UInt? - let modificationDate: Date - let fingerprint: FileContentFingerprint - - var estimatedDecodedCost: Int { - guard let content else { return 0 } - return content.utf8.count + content.utf16.count * MemoryLayout.stride - } -} - -enum FileContentValidationError: Error { - case fingerprintChanged -} - -enum FileContentFingerprintReader { - static func fingerprint(atPath path: String) throws -> FileContentFingerprint { - var info = stat() - let result = path.withCString { pointer in - lstat(pointer, &info) - } - guard result == 0 else { - throw fileSystemError(for: errno) - } - return try fingerprint(from: info) - } - - static func fingerprint(fileDescriptor: Int32) throws -> FileContentFingerprint { - var info = stat() - guard fstat(fileDescriptor, &info) == 0 else { - throw fileSystemError(for: errno) - } - return try fingerprint(from: info) - } - - static func openReadOnlyFileHandle(atPath path: String) throws -> FileHandle { - let descriptor = path.withCString { pointer in - open(pointer, O_RDONLY | O_CLOEXEC | O_NOFOLLOW) - } - guard descriptor >= 0 else { - throw fileSystemError(for: errno) - } - return FileHandle(fileDescriptor: descriptor, closeOnDealloc: true) - } - - private static func fingerprint(from info: stat) throws -> FileContentFingerprint { - guard (info.st_mode & mode_t(S_IFMT)) == mode_t(S_IFREG) else { - throw FileSystemError.invalidRelativePath - } - - #if os(macOS) || os(iOS) || os(tvOS) || os(watchOS) - let modificationTime = info.st_mtimespec - let statusChangeTime = info.st_ctimespec - #else - let modificationTime = info.st_mtim - let statusChangeTime = info.st_ctim - #endif - - return FileContentFingerprint( - deviceID: UInt64(info.st_dev), - fileNumber: UInt64(info.st_ino), - byteSize: Int64(info.st_size), - modificationSeconds: Int64(modificationTime.tv_sec), - modificationNanoseconds: Int64(modificationTime.tv_nsec), - statusChangeSeconds: Int64(statusChangeTime.tv_sec), - statusChangeNanoseconds: Int64(statusChangeTime.tv_nsec) - ) - } - - private static func fileSystemError(for errorNumber: Int32) -> FileSystemError { - switch errorNumber { - case ENOENT, ENOTDIR: - .fileNotFound - case ELOOP: - .invalidRelativePath - default: - .failedToReadFile - } - } -} diff --git a/Sources/RepoPrompt/Infrastructure/FileSystem/FileSystemService+FileOperations.swift b/Sources/RepoPrompt/Infrastructure/FileSystem/FileSystemService+FileOperations.swift deleted file mode 100644 index df99ef1ca..000000000 --- a/Sources/RepoPrompt/Infrastructure/FileSystem/FileSystemService+FileOperations.swift +++ /dev/null @@ -1,450 +0,0 @@ -import Foundation -#if os(macOS) || os(iOS) || os(tvOS) || os(watchOS) - import Darwin -#else - import Glibc -#endif - -extension FileSystemService { - // MARK: - File and folder manipulation utilities - - private func mutationTarget( - forRelativePath rawRelativePath: String, - rejectExistingLeafSymlink: Bool = true - ) throws -> (relativePath: String, url: URL) { - guard !rawRelativePath.hasPrefix("/"), !StandardizedPath.containsNUL(rawRelativePath) else { - throw FileSystemError.invalidRelativePath - } - let relativePath = StandardizedPath.relative(rawRelativePath) - guard !relativePath.isEmpty, - relativePath != "..", - !relativePath.hasPrefix("../") - else { - throw FileSystemError.invalidRelativePath - } - - let url = rootURL.appendingPathComponent(relativePath).standardizedFileURL - guard url.path != standardizedRootPath, - StandardizedPath.isDescendant(url.path, of: standardizedRootPath) - else { - throw FileSystemError.invalidRelativePath - } - - var current = rootURL - for component in relativePath.split(separator: "/").dropLast() { - current.appendPathComponent(String(component)) - guard !pathIsSymbolicLink(current.path) else { throw FileSystemError.invalidRelativePath } - var isDirectory = ObjCBool(false) - guard fm.fileExists(atPath: current.path, isDirectory: &isDirectory) else { break } - guard isDirectory.boolValue else { throw FileSystemError.invalidRelativePath } - } - - let canonicalParentPath = url.deletingLastPathComponent().resolvingSymlinksInPath().standardizedFileURL.path - guard canonicalParentPath == canonicalRootPath || StandardizedPath.isDescendant(canonicalParentPath, of: canonicalRootPath) else { - throw FileSystemError.invalidRelativePath - } - if rejectExistingLeafSymlink, pathIsSymbolicLink(url.path) { - throw FileSystemError.invalidRelativePath - } - return (relativePath, url) - } - - private func pathIsSymbolicLink(_ path: String) -> Bool { - var info = stat() - guard lstat(path, &info) == 0 else { return false } - return info.st_mode & S_IFMT == S_IFLNK - } - - private func requireRegularMutationSource(relativePath: String) async throws { - switch await catalogRegularFileEligibility(relativePath: relativePath) { - case .eligible, .ineligible(.ignored): - return - case .ineligible(.missingOrDirectory): - throw FileSystemError.fileNotFound - case .ineligible: - throw FileSystemError.invalidRelativePath - } - } - - /// Atomically move/rename a **file** inside the same root. - func moveFile( - atRelativePath oldRelPath: String, - toRelativePath newRelPath: String - ) async throws { - let fm = fm // Cache for multiple calls in this method - - // --- prepare ----------------------------------------------------- - // ── 0. Validate that both paths stay inside the loaded root ───────────── - let oldTarget = try mutationTarget(forRelativePath: oldRelPath) - let newTarget = try mutationTarget(forRelativePath: newRelPath) - let oldFull = oldTarget.url.path - let newFull = newTarget.url.path - try await requireRegularMutationSource(relativePath: oldTarget.relativePath) - - // 1) Source must exist - guard fm.fileExists(atPath: oldFull, isDirectory: nil) else { - throw FileSystemError.fileNotFound - } - - // 2) Destination must not exist - guard !fm.fileExists(atPath: newFull, isDirectory: nil) else { - throw FileSystemError.fileAlreadyExists - } - - // 3) Ensure parent folder exists (this is fast, keep it in-actor) - let destDir = (newFull as NSString).deletingLastPathComponent - try fm.createDirectory( - atPath: destDir, - withIntermediateDirectories: true, - attributes: nil - ) - _ = try mutationTarget(forRelativePath: newTarget.relativePath) - - // --- 1. do I/O off-actor ---------------------------------------- - // 4) Perform the move on disk - do { - try await Task.detached(priority: .utility) { - try FileManager.default.moveItem(atPath: oldFull, toPath: newFull) - }.value // bubbles error - } catch { - throw FileSystemError.failedToCreateFile(error) - } - - let destinationEligibility = await catalogRegularFileEligibility(relativePath: newTarget.relativePath) - switch destinationEligibility { - case .eligible, .ineligible(.ignored): - break - case .ineligible: - try? FileManager.default.moveItem(atPath: newFull, toPath: oldFull) - throw FileSystemError.invalidRelativePath - } - - // --- 2. in-memory bookkeeping (still inside actor) -------------- - // 5) Immediate in‑memory bookkeeping (fixes race window) ─────────────── - let stdOld = oldTarget.relativePath - let stdNew = newTarget.relativePath - - if let wasDir = visitedItems.removeValue(forKey: stdOld) { - visitedItems[stdNew] = wasDir // will be 'false' for files - } - visitedPaths.remove(stdOld) - visitedPaths.insert(stdNew) - - // Transfer encoding if we have it - if let encoding = encodingMap[stdOld] { - encodingMap.removeValue(forKey: stdOld) - encodingMap[stdNew] = encoding - } - - // 6) Emit synthetic deltas so the UI updates before FSEvents arrive - publishFileSystemDeltas([.fileRemoved(stdOld), .fileAdded(stdNew)], source: .syntheticMutation) - } - - func createFile(atRelativePath relativePath: String, content: String) async throws { - let fm = fm // Cache for multiple calls in this method - // --- prepare ----------------------------------------------------- - let target = try mutationTarget(forRelativePath: relativePath) - let fullPath = target.url.path - let fullURL = target.url - - // Ensure directory exists (this is fast, keep it in-actor) - let directoryURL = fullURL.deletingLastPathComponent() - try fm.createDirectory(at: directoryURL, withIntermediateDirectories: true, attributes: nil) - _ = try mutationTarget(forRelativePath: target.relativePath) - - // Check if file already exists - if fm.fileExists(atPath: fullPath, isDirectory: nil) { - throw FileSystemError.fileAlreadyExists - } - - // Prepare data with UTF-8 encoding - guard let data = content.data(using: .utf8) else { - throw FileSystemError.failedToCreateFile( - NSError( - domain: "encoding", - code: -1, - userInfo: [NSLocalizedDescriptionKey: "Unable to encode text as UTF-8"] - ) - ) - } - - // --- 1. do I/O off-actor ---------------------------------------- - do { - try await Task.detached(priority: .utility) { - try FileSystemService.writeFileRobust(to: fullURL, data: data) - }.value // bubbles error - fileSystemDebugLog("File created at \(fullURL.path)") - } catch { - throw FileSystemError.failedToCreateFile(error) - } - - switch await catalogRegularFileEligibility(relativePath: target.relativePath) { - case .eligible, .ineligible(.ignored): - break - case .ineligible: - try? fm.removeItem(at: fullURL) - forgetTrackedPath(target.relativePath) - throw FileSystemError.invalidRelativePath - } - - // --- 2. in-memory bookkeeping (still inside actor) -------------- - // update encoding cache (new files default to UTF-8) - encodingMap[target.relativePath] = .utf8 - - // update visited* sets - if !visitedPaths.contains(target.relativePath) { - visitedPaths.insert(target.relativePath) - visitedItems[target.relativePath] = false - } - - // emit a *synthetic* delta so the UI updates immediately - publishFileSystemDeltas([.fileAdded(target.relativePath)], source: .syntheticMutation) - } - - func deleteFile(atRelativePath relativePath: String) async throws { - let target = try mutationTarget(forRelativePath: relativePath) - try await requireRegularMutationSource(relativePath: target.relativePath) - let url = target.url - do { - try fm.removeItem(at: url) - fileSystemDebugLog("File deleted at \(url.path)") - } catch { - throw FileSystemError.failedToDeleteFile(error) - } - forgetTrackedPath(target.relativePath) - publishFileSystemDeltas([.fileRemoved(target.relativePath)], source: .syntheticMutation) - } - - func moveItemToTrash(atRelativePath relativePath: String) async throws { - let target = try mutationTarget(forRelativePath: relativePath) - let normalizedRelativePath = target.relativePath - let url = target.url - let fullPath = url.path - - var isDirectory = ObjCBool(false) - guard fm.fileExists(atPath: fullPath, isDirectory: &isDirectory) else { - throw FileSystemError.fileNotFound - } - - do { - _ = try moveURLToTrash(url) - fileSystemDebugLog("File moved to Trash at \(url.path)") - } catch { - throw FileSystemError.failedToDeleteFile(error) - } - - let keysToForget = encodingMap.keys.filter { - $0 == normalizedRelativePath || $0.hasPrefix(normalizedRelativePath + "/") - } - for key in keysToForget { - encodingMap.removeValue(forKey: key) - } - - var deltas = removeSubtree(for: normalizedRelativePath) - if deltas.isEmpty { - deltas = [isDirectory.boolValue ? .folderRemoved(normalizedRelativePath) : .fileRemoved(normalizedRelativePath)] - } - if !deltas.isEmpty { - publishFileSystemDeltas(deltas, source: .syntheticMutation) - } - } - - private func forgetTrackedPath(_ relativePath: String) { - encodingMap.removeValue(forKey: relativePath) - visitedPaths.remove(relativePath) - visitedItems.removeValue(forKey: relativePath) - } - - private func moveURLToTrash(_ url: URL) throws -> URL? { - #if DEBUG - return try fm.moveItemToTrash(at: url) - #else - var resultingItemURL: NSURL? - try fm.trashItem(at: url, resultingItemURL: &resultingItemURL) - return resultingItemURL as URL? - #endif - } - - /// Re-written non-blocking version - func editFile(atRelativePath relativePath: String, newContent: String) async throws { - // --- prepare ----------------------------------------------------- - let target = try mutationTarget(forRelativePath: relativePath) - let fullPath = target.url.path - let fullURL = target.url - guard fm.fileExists(atPath: fullPath, isDirectory: nil) else { - throw FileSystemError.fileNotFound - } - switch await catalogRegularFileEligibility(relativePath: target.relativePath) { - case .eligible, .ineligible(.ignored): - break - case .ineligible(.missingOrDirectory): - throw FileSystemError.fileNotFound - case .ineligible: - throw FileSystemError.invalidRelativePath - } - let enc = encodingMap[target.relativePath] ?? .utf8 - guard let data = newContent.data(using: enc) else { - throw FileSystemError.failedToEditFile( - NSError( - domain: "encoding", - code: -1, - userInfo: [NSLocalizedDescriptionKey: "Unable to encode text as \(enc)"] - ) - ) - } - - // --- 1. do I/O off-actor ---------------------------------------- - do { - try await Task.detached(priority: .utility) { - try FileSystemService.writeFileRobust(to: fullURL, data: data) - }.value // bubbles error - } catch { - throw FileSystemError.failedToEditFile(error) - } - - switch await catalogRegularFileEligibility(relativePath: target.relativePath) { - case .eligible, .ineligible(.ignored): - break - case .ineligible: - throw FileSystemError.invalidRelativePath - } - - // --- 2. in-memory bookkeeping (still inside actor) -------------- - // refresh encoding cache - encodingMap[target.relativePath] = enc - - // update visited* sets so later FSEvents don't look "new" - if !visitedPaths.contains(target.relativePath) { - visitedPaths.insert(target.relativePath) - visitedItems[target.relativePath] = false - } - - // emit a *synthetic* delta so the UI updates immediately, with mtime if available - let mdate = try? await getFileModificationDate(atRelativePath: target.relativePath) - publishFileSystemDeltas([.fileModified(target.relativePath, mdate)], source: .syntheticMutation) - } - - func checkFilePermissions(atRelativePath relativePath: String) -> Bool { - let fullPath = fullPath(forRelativePath: relativePath) - return fm.isWritableFile(atPath: fullPath) - } - - func getFileModificationDate(atRelativePath relativePath: String) async throws -> Date { - let lookupState = EditFlowPerf.begin( - EditFlowPerf.Stage.FileSystem.contentModificationDateLookup, - EditFlowPerf.Dimensions(rootToken: diagnosticRootToken.uuidString) - ) - defer { EditFlowPerf.end(EditFlowPerf.Stage.FileSystem.contentModificationDateLookup, lookupState) } - let fullPath = fullPath(forRelativePath: relativePath) - let attributes = try fm.attributesOfItem(atPath: fullPath) - return attributes[.modificationDate] as? Date ?? Date() - } - - func getItemModificationDateIfAvailable(atRelativePath relativePath: String) async -> Date? { - let fullPath = fullPath(forRelativePath: relativePath) - guard let attributes = try? fm.attributesOfItem(atPath: fullPath) else { return nil } - return attributes[.modificationDate] as? Date - } - - private static func writeFile( - to url: URL, - data: Data - ) throws { - try data.write(to: url, options: .atomic) // blocking write - } - - /// Robust write that works across external/network volumes: - /// 1) try atomic write - /// 2) write to temp in the same directory then move into place (delete destination if needed) - /// 3) POSIX open(O_CREAT|O_TRUNC)+write+fsync fallback - private static func writeFileRobust( - to url: URL, - data: Data - ) throws { - // Fast path: try Foundation's atomic write first. - do { - try data.write(to: url, options: [.atomic]) - return - } catch { - // fall through to robust fallbacks - } - - let fm = FileManager.default - let dirURL = url.deletingLastPathComponent() - let tmpURL = dirURL.appendingPathComponent(".repoprompt.tmp.\(UUID().uuidString)") - - // Fallback #1: write to temp in the same directory then move/replace. - do { - try data.write(to: tmpURL, options: []) - if fm.fileExists(atPath: url.path) { - // Removing the destination first avoids exchange/rename restrictions on some filesystems - // (exFAT/SMB may reject replace semantics). - try? fm.removeItem(at: url) - } - try fm.moveItem(at: tmpURL, to: url) - return - } catch { - // Clean up temp if it remains - try? fm.removeItem(at: tmpURL) - } - - // Fallback #2: POSIX open/write/fsync. - try writeFilePOSIX(to: url, data: data) - } - - /// Low-level write that avoids Foundation's atomic/replace semantics entirely. - private static func writeFilePOSIX( - to url: URL, - data: Data - ) throws { - let path = url.path - let fd = open(path, O_WRONLY | O_CREAT | O_TRUNC, 0o644) - if fd == -1 { - let code = errno - throw NSError( - domain: NSPOSIXErrorDomain, - code: Int(code), - userInfo: [NSLocalizedDescriptionKey: "open() failed for \(path) (\(code))"] - ) - } - - var writeError: Int32 = 0 - data.withUnsafeBytes { (ptr: UnsafeRawBufferPointer) in - guard var base = ptr.baseAddress?.assumingMemoryBound(to: UInt8.self) else { return } - var remaining = data.count - while remaining > 0 { - let n = Darwin.write(fd, base, remaining) - if n < 0 { - writeError = errno - break - } - remaining -= n - base = base.advanced(by: n) - } - } - - if writeError == 0 { - if fsync(fd) != 0 { - writeError = errno - } - } - - // Always attempt to close; prefer first error if any. - let closeResult = close(fd) - if writeError != 0 { - throw NSError( - domain: NSPOSIXErrorDomain, - code: Int(writeError), - userInfo: [NSLocalizedDescriptionKey: "write/fsync failed for \(path) (\(writeError))"] - ) - } - if closeResult != 0 { - let code = errno - throw NSError( - domain: NSPOSIXErrorDomain, - code: Int(code), - userInfo: [NSLocalizedDescriptionKey: "close() failed for \(path) (\(code))"] - ) - } - } -} diff --git a/Sources/RepoPrompt/Infrastructure/FileSystem/MacOS/MacOSFileSystemPublishPerf.swift b/Sources/RepoPrompt/Infrastructure/FileSystem/MacOS/MacOSFileSystemPublishPerf.swift new file mode 100644 index 000000000..d6481f1bd --- /dev/null +++ b/Sources/RepoPrompt/Infrastructure/FileSystem/MacOS/MacOSFileSystemPublishPerf.swift @@ -0,0 +1,36 @@ +import Foundation +#if DEBUG || EDIT_FLOW_PERF + import os +#endif + +/// Embedded-app signpost adapter for reusable filesystem publication work. +enum FileSystemPublishPerf { + #if DEBUG || EDIT_FLOW_PERF + typealias State = OSSignpostIntervalState + static let signposter = OSSignposter(subsystem: "com.repoprompt.workspace", category: "fs-publish") + static var isEnabled: Bool { + UserDefaults.standard.bool(forKey: "enableRepoFileReplaySignposts") + } + + static func begin(_ name: StaticString) -> State? { + guard isEnabled else { return nil } + return signposter.beginInterval(name) + } + + static func end(_ name: StaticString, _ state: State?) { + guard isEnabled, let state else { return } + signposter.endInterval(name, state) + } + #else + struct State {} + static var isEnabled: Bool { + false + } + + static func begin(_ name: StaticString) -> State? { + nil + } + + static func end(_ name: StaticString, _ state: State?) {} + #endif +} diff --git a/Sources/RepoPrompt/Infrastructure/MCP/AppProxy/MacOSBootstrapAcceptedTransportLease.swift b/Sources/RepoPrompt/Infrastructure/MCP/AppProxy/MacOSBootstrapAcceptedTransportLease.swift new file mode 100644 index 000000000..063bbb92c --- /dev/null +++ b/Sources/RepoPrompt/Infrastructure/MCP/AppProxy/MacOSBootstrapAcceptedTransportLease.swift @@ -0,0 +1,196 @@ +import Darwin +import Foundation +import RepoPromptCore +import RepoPromptPOSIXSupport + +/// App-owned accepted-socket lease behind Core's opaque app-proxy transport contracts. +package final class MacOSBootstrapAcceptedTransportLease: MCPAppProxyAcceptedTransportLease, MCPAppProxyAcceptedTransport, @unchecked Sendable { + private enum Ownership: Equatable { + case listenerOwned + case admissionReserved + case listenerOwnedClosing + case publishing + case transferred + case claimed + case closed + } + + package let fileDescriptor: Int32 + + private let lock = NSLock() + private var ownership: Ownership = .listenerOwned + private var activeIOLeases = 0 + private var shutdownInProgress = false + private var closeRequestedDuringPublication = false + #if DEBUG + private let debugBeforeInitiatingShutdown: (() -> Void)? + #endif + + package init(fileDescriptor: Int32) { + self.fileDescriptor = fileDescriptor + #if DEBUG + debugBeforeInitiatingShutdown = nil + #endif + } + + #if DEBUG + package init(fileDescriptor: Int32, debugBeforeInitiatingShutdown: @escaping () -> Void) { + self.fileDescriptor = fileDescriptor + self.debugBeforeInitiatingShutdown = debugBeforeInitiatingShutdown + } + #endif + + package var state: MCPAppProxyAcceptedTransportLeaseState { + lock.lock() + defer { lock.unlock() } + switch ownership { + case .listenerOwned: + return .listenerOwned + case .admissionReserved, .publishing: + return .admissionReserved + case .transferred, .claimed: + return .transferred + case .listenerOwnedClosing, .closed: + return .closed + } + } + + package func isListenerOwnedOpen() -> Bool { + lock.lock() + defer { lock.unlock() } + switch ownership { + case .listenerOwned, .admissionReserved: + return true + case .listenerOwnedClosing, .publishing, .transferred, .claimed, .closed: + return false + } + } + + /// Runs one blocking syscall while preventing descriptor close/reuse underneath it. + package func withListenerOwnedIOLease(_ body: (Int32) -> T) -> T? { + lock.lock() + switch ownership { + case .listenerOwned, .admissionReserved: + activeIOLeases += 1 + lock.unlock() + case .listenerOwnedClosing, .publishing, .transferred, .claimed, .closed: + lock.unlock() + return nil + } + + let result = body(fileDescriptor) + releaseIOLease() + return result + } + + package func reserveForAdmission() -> Bool { + lock.lock() + defer { lock.unlock() } + guard ownership == .listenerOwned else { return false } + ownership = .admissionReserved + return true + } + + package func transfer( + publish: @Sendable (any MCPAppProxyAcceptedTransport) -> Bool + ) -> Bool { + lock.lock() + guard ownership == .admissionReserved, activeIOLeases == 0 else { + lock.unlock() + return false + } + ownership = .publishing + closeRequestedDuringPublication = false + lock.unlock() + + let wasPublished = publish(self) + + lock.lock() + guard ownership == .publishing else { + lock.unlock() + return false + } + let shouldClose = !wasPublished || closeRequestedDuringPublication + if shouldClose { + ownership = .closed + } else { + ownership = .transferred + } + lock.unlock() + + if shouldClose { + closeNativeTransport() + } + return wasPublished && !shouldClose + } + + package func rollback() { + close() + } + + package func close() { + lock.lock() + switch ownership { + case .listenerOwned, .admissionReserved: + ownership = .listenerOwnedClosing + shutdownInProgress = true + lock.unlock() + + #if DEBUG + debugBeforeInitiatingShutdown?() + #endif + POSIXDescriptorSupport.shutdownSocketReadWrite(fileDescriptor) + + lock.lock() + shutdownInProgress = false + let shouldClose = activeIOLeases == 0 && ownership == .listenerOwnedClosing + if shouldClose { + ownership = .closed + } + lock.unlock() + + if shouldClose { + Darwin.close(fileDescriptor) + } + case .transferred: + ownership = .closed + lock.unlock() + closeNativeTransport() + case .publishing: + closeRequestedDuringPublication = true + lock.unlock() + case .listenerOwnedClosing, .claimed, .closed: + lock.unlock() + } + } + + /// Claims the native descriptor once, immediately before the existing app transport adopts it. + package func claimConnectedFileDescriptor() -> Int32? { + lock.lock() + defer { lock.unlock() } + guard ownership == .transferred else { return nil } + ownership = .claimed + return fileDescriptor + } + + private func releaseIOLease() { + lock.lock() + activeIOLeases -= 1 + let shouldClose = activeIOLeases == 0 + && ownership == .listenerOwnedClosing + && !shutdownInProgress + if shouldClose { + ownership = .closed + } + lock.unlock() + + if shouldClose { + Darwin.close(fileDescriptor) + } + } + + private func closeNativeTransport() { + POSIXDescriptorSupport.shutdownSocketReadWrite(fileDescriptor) + Darwin.close(fileDescriptor) + } +} diff --git a/Sources/RepoPrompt/Infrastructure/MCP/BootstrapSocketConnectionManager.swift b/Sources/RepoPrompt/Infrastructure/MCP/AppProxy/MacOSBootstrapSocketConnectionManager.swift similarity index 80% rename from Sources/RepoPrompt/Infrastructure/MCP/BootstrapSocketConnectionManager.swift rename to Sources/RepoPrompt/Infrastructure/MCP/AppProxy/MacOSBootstrapSocketConnectionManager.swift index 118bf2aa1..dc027b863 100644 --- a/Sources/RepoPrompt/Infrastructure/MCP/BootstrapSocketConnectionManager.swift +++ b/Sources/RepoPrompt/Infrastructure/MCP/AppProxy/MacOSBootstrapSocketConnectionManager.swift @@ -1,5 +1,7 @@ +import RepoPromptCore + // -// BootstrapSocketConnectionManager.swift +// MacOSBootstrapSocketConnectionManager.swift // RepoPrompt // // Manages a single MCP connection received via the bootstrap socket. @@ -37,12 +39,17 @@ private let bootstrapLog: Logger = { // MARK: - Connection Manager +enum BootstrapSocketConnectionManagerError: Error { + case trustedPeerPIDUnavailable +} + /// Connection manager for bootstrap socket connections. /// Unlike FileSystemMCPConnectionManager, this doesn't use filesystem folders or meta.json. actor BootstrapSocketConnectionManager: MCPServerConnection { private let connectionID: UUID private let sessionToken: String - private let clientPid: Int + private let peerIdentity: MCPPeerIdentity + private let trustedPeerPID: Int private let _clientName: String? private let purpose: MCPRunPurpose private let server: MCP.Server @@ -66,11 +73,12 @@ actor BootstrapSocketConnectionManager: MCPServerConnection { /// Verified peer PID for this connection (from the bootstrap socket). func peerPID() -> Int { - clientPid + trustedPeerPID } private var healthMonitoringTask: Task? private var closeWatchTask: Task? + private var shutdownTask: Task? private var state: ConnectionStateSnapshot = .connecting private var isClosing = false private var handshakeComplete = false @@ -78,7 +86,7 @@ actor BootstrapSocketConnectionManager: MCPServerConnection { init( connectionID: UUID, sessionToken: String, - clientPid: Int, + peerIdentity: MCPPeerIdentity, clientName: String?, purpose: MCPRunPurpose, codeMapsDisabled: Bool, @@ -86,9 +94,13 @@ actor BootstrapSocketConnectionManager: MCPServerConnection { parentManager: ServerNetworkManager, receiveBufferCapacity: Int = 1024 ) throws { + guard let trustedPeerPID = peerIdentity.trustedPID else { + throw BootstrapSocketConnectionManagerError.trustedPeerPIDUnavailable + } self.connectionID = connectionID self.sessionToken = sessionToken - self.clientPid = clientPid + self.peerIdentity = peerIdentity + self.trustedPeerPID = trustedPeerPID _clientName = clientName self.purpose = purpose self.parentManager = parentManager @@ -116,10 +128,13 @@ actor BootstrapSocketConnectionManager: MCPServerConnection { } func start(approvalHandler: @escaping (MCP.Client.Info) async -> Bool) async throws { + guard !isClosing else { throw MCPError.connectionClosed } + // Start close-watch task to clean up when socket closes closeWatchTask = Task { [weak self] in guard let self else { return } for await _ in await transport.closed() { + if Task.isCancelled { break } let id = connectionID let ingressSnapshot = await transport.ingressSnapshot() mcpConnectionLog("BootstrapSocketConnectionManager: transport closed for \(id)") @@ -129,7 +144,9 @@ actor BootstrapSocketConnectionManager: MCPServerConnection { sessionToken: sessionToken, snapshot: ingressSnapshot ) - await parentManager.removeConnection(id) + Task { [parentManager] in + await parentManager.removeConnection(id) + } break } } @@ -140,6 +157,7 @@ actor BootstrapSocketConnectionManager: MCPServerConnection { // a fast client could call tools/list before handlers are ready. mcpConnectionLog("BootstrapSocketConnectionManager: registering handlers...") await registerHandlers() + guard !isClosing else { throw MCPError.connectionClosed } mcpConnectionLog("BootstrapSocketConnectionManager: starting MCP server...") try await server.start(transport: transport) { [weak self] clientInfo, _ in @@ -154,12 +172,18 @@ actor BootstrapSocketConnectionManager: MCPServerConnection { } mcpConnectionLog("BootstrapSocketConnectionManager: MCP server started successfully") + guard !isClosing else { throw MCPError.connectionClosed } await startHealthMonitoring() + guard !isClosing else { throw MCPError.connectionClosed } updateState(.ready) } catch { bootstrapLog.error("BootstrapSocketConnectionManager: start failed: \(error)") - updateState(.failed(error)) - await transport.disconnect() + if isClosing { + await shutdown() + } else { + updateState(.failed(error)) + await transport.disconnect() + } throw error } } @@ -169,6 +193,7 @@ actor BootstrapSocketConnectionManager: MCPServerConnection { } private func startHealthMonitoring() async { + guard !isClosing else { return } healthMonitoringTask?.cancel() healthMonitoringTask = Task { [self] in let hardIdleSec = UserDefaults.standard.integer(forKey: "mcp.idleConnectionSeconds") @@ -184,7 +209,13 @@ actor BootstrapSocketConnectionManager: MCPServerConnection { let hasInFlight = await parentManager.hasInFlightCalls(for: connectionID) if !hasInFlight { mcpConnectionLog("Bootstrap connection \(connectionID) idle \(Int(idle))s (> \(hardIdleSec)s). Terminating.") - await parentManager.terminateConnection(connectionID, reason: .idleTimeout, message: "Connection idle for \(Int(idle))s") + Task { [parentManager] in + await parentManager.terminateConnection( + connectionID, + reason: .idleTimeout, + message: "Connection idle for \(Int(idle))s" + ) + } break } } @@ -227,28 +258,37 @@ actor BootstrapSocketConnectionManager: MCPServerConnection { } func stop() async { - guard !isClosing else { return } - isClosing = true - healthMonitoringTask?.cancel() - healthMonitoringTask = nil - closeWatchTask?.cancel() - closeWatchTask = nil - await server.stop() - await transport.disconnect() - updateState(.cancelled) + await shutdown() } func terminate(reason: TerminationReason, message: String?) async { - guard !isClosing else { return } mcpConnectionLog("Terminating bootstrap connection \(connectionID) with reason: \(reason.rawValue)") + await shutdown() + } + + private func shutdown() async { + if let shutdownTask { + await shutdownTask.value + return + } + isClosing = true - healthMonitoringTask?.cancel() - healthMonitoringTask = nil - closeWatchTask?.cancel() - closeWatchTask = nil - await server.stop() - await transport.disconnect() - updateState(.cancelled) + let healthTask = healthMonitoringTask + let watcherTask = closeWatchTask + healthTask?.cancel() + watcherTask?.cancel() + + let task = Task { [self, transport, server, healthTask, watcherTask] in + await transport.disconnect() + await server.stop() + await healthTask?.value + await watcherTask?.value + healthMonitoringTask = nil + closeWatchTask = nil + updateState(.cancelled) + } + shutdownTask = task + await task.value } func abortForExecutionWatchdog() async { @@ -284,6 +324,12 @@ actor BootstrapSocketConnectionManager: MCPServerConnection { await transport.ingressSnapshot() } + #if DEBUG + func debugTransportCleanupSnapshot() async -> UnixSocketMCPTransportCleanupSnapshot { + await transport.debugCleanupSnapshot() + } + #endif + private func updateState(_ newState: ConnectionStateSnapshot) { state = newState } diff --git a/Sources/RepoPrompt/Infrastructure/MCP/BootstrapSocketServer.swift b/Sources/RepoPrompt/Infrastructure/MCP/AppProxy/MacOSBootstrapSocketServer.swift similarity index 83% rename from Sources/RepoPrompt/Infrastructure/MCP/BootstrapSocketServer.swift rename to Sources/RepoPrompt/Infrastructure/MCP/AppProxy/MacOSBootstrapSocketServer.swift index dc9174ae1..c87ba08ec 100644 --- a/Sources/RepoPrompt/Infrastructure/MCP/BootstrapSocketServer.swift +++ b/Sources/RepoPrompt/Infrastructure/MCP/AppProxy/MacOSBootstrapSocketServer.swift @@ -1,5 +1,7 @@ +import RepoPromptCore + // -// BootstrapSocketServer.swift +// MacOSBootstrapSocketServer.swift // RepoPrompt // // Single app-owned UNIX socket server for MCP connections. @@ -10,6 +12,7 @@ import Darwin import Dispatch import Foundation import Logging +import RepoPromptPOSIXSupport import RepoPromptShared #if DEBUG @@ -23,138 +26,10 @@ import RepoPromptShared #endif // Note: MCPBootstrapRequest and MCPBootstrapResponse are defined in -// RepoPrompt/Shared/MCPBootstrapMessages.swift for sharing with the CLI. +// RepoPromptShared/MCP/MCPBootstrapMessages.swift for sharing with the CLI. // MARK: - Bootstrap Socket Server -/// Lock-protected ownership wrapper for accepted sockets that are still in the -/// bootstrap handshake. Blocking handshake reads run outside actor isolation, -/// while stop() must be able to invalidate and close these sockets immediately. -private final class BootstrapHandshakeSocket: @unchecked Sendable { - private enum Ownership: Equatable { - case serverOwnedOpen - case serverOwnedClosing - case transferred - case closed - } - - let fd: Int32 - - private let lock = NSLock() - private var ownership: Ownership = .serverOwnedOpen - private var activeIOLeases = 0 - private var shutdownInProgress = false - #if DEBUG - private let debugBeforeInitiatingShutdown: (() -> Void)? - #endif - - init(fd: Int32) { - self.fd = fd - #if DEBUG - debugBeforeInitiatingShutdown = nil - #endif - } - - #if DEBUG - init(fd: Int32, debugBeforeInitiatingShutdown: @escaping () -> Void) { - self.fd = fd - self.debugBeforeInitiatingShutdown = debugBeforeInitiatingShutdown - } - #endif - - func isServerOwnedOpen() -> Bool { - lock.lock() - defer { lock.unlock() } - guard case .serverOwnedOpen = ownership else { return false } - return true - } - - /// Runs one blocking syscall while preventing the numeric FD from being closed - /// and reused underneath it. stop() still calls shutdown immediately to wake I/O; - /// final close occurs when the last lease exits. - func withServerOwnedIOLease(_ body: (Int32) -> T) -> T? { - lock.lock() - guard case .serverOwnedOpen = ownership else { - lock.unlock() - return nil - } - activeIOLeases += 1 - lock.unlock() - - let result = body(fd) - releaseIOLease() - return result - } - - func shutdownAndCloseIfServerOwned() { - lock.lock() - guard case .serverOwnedOpen = ownership else { - lock.unlock() - return - } - ownership = .serverOwnedClosing - // A lease release must not close and recycle the numeric FD before this - // initiating shutdown call has used it. - shutdownInProgress = true - lock.unlock() - - #if DEBUG - debugBeforeInitiatingShutdown?() - #endif - POSIXDescriptorSupport.shutdownSocketReadWrite(fd) - - lock.lock() - shutdownInProgress = false - let shouldClose = activeIOLeases == 0 && ownership == .serverOwnedClosing - if shouldClose { - ownership = .closed - } - lock.unlock() - - if shouldClose { - Darwin.close(fd) - } - } - - /// Atomically leaves handshake ownership and publishes the transferred descriptor - /// into the manager's synchronous full-shutdown ledger. If the receiving lifecycle - /// is already invalid, this method closes the descriptor itself. - func transferOwnershipIfOpen( - publishTransferredFD: (Int32) -> Bool - ) -> Bool { - lock.lock() - guard case .serverOwnedOpen = ownership, activeIOLeases == 0 else { - lock.unlock() - return false - } - ownership = .transferred - let wasPublished = publishTransferredFD(fd) - lock.unlock() - - if !wasPublished { - POSIXDescriptorSupport.shutdownSocketReadWrite(fd) - Darwin.close(fd) - } - return wasPublished - } - - private func releaseIOLease() { - lock.lock() - activeIOLeases -= 1 - let shouldClose = activeIOLeases == 0 - && ownership == .serverOwnedClosing - && !shutdownInProgress - if shouldClose { - ownership = .closed - } - lock.unlock() - - if shouldClose { - Darwin.close(fd) - } - } -} - /// Actor that manages the single bootstrap UNIX socket. /// Accepts CLI connections and hands them off to ServerNetworkManager. actor BootstrapSocketServer { @@ -177,7 +52,7 @@ actor BootstrapSocketServer { /// Prevents FD exhaustion during connection storms. private let maxInFlightHandshakes: Int = 32 private var listenerGeneration: UInt64 = 0 - private var inFlightHandshakeSockets: [UUID: BootstrapHandshakeSocket] = [:] + private var inFlightHandshakeSockets: [UUID: MacOSBootstrapAcceptedTransportLease] = [:] private var acceptSuspendedForBackpressure: Bool = false private var drainInProgress: Bool = false private var drainRequestedWhileBusy: Bool = false @@ -186,8 +61,8 @@ actor BootstrapSocketServer { struct Admission { let accepted: Bool /// Called synchronously while handshake ownership is transferred. The receiver - /// must publish the descriptor into storage visible to full shutdown. - let publishTransferredFD: (@Sendable (Int32) -> Bool)? + /// must publish the opaque transport into storage visible to full shutdown. + let publishTransferredTransport: (@Sendable (any MCPAppProxyAcceptedTransport) -> Bool)? /// Called after the bootstrap server successfully sends the accepted response /// and synchronously publishes transferred descriptor ownership. /// This is where MCP server startup should be scheduled. @@ -199,13 +74,13 @@ actor BootstrapSocketServer { let rejection: MCPBootstrapResponse? static func accept( - publishTransferredFD: @escaping @Sendable (Int32) -> Bool, + publishTransferredTransport: @escaping @Sendable (any MCPAppProxyAcceptedTransport) -> Bool, postAccept: @escaping @Sendable () async -> Void, onAcceptAborted: (@Sendable () async -> Void)? = nil ) -> Self { .init( accepted: true, - publishTransferredFD: publishTransferredFD, + publishTransferredTransport: publishTransferredTransport, postAccept: postAccept, onAcceptAborted: onAcceptAborted, rejection: nil @@ -213,14 +88,13 @@ actor BootstrapSocketServer { } static func reject(_ response: MCPBootstrapResponse? = nil) -> Self { - .init(accepted: false, publishTransferredFD: nil, postAccept: nil, onAcceptAborted: nil, rejection: response) + .init(accepted: false, publishTransferredTransport: nil, postAccept: nil, onAcceptAborted: nil, rejection: response) } } - /// Callback when a new CLI connects and completes handshake - /// Parameters: (clientFD, sessionToken, clientPid, clientName) - /// Returns: Admission decision with optional postAccept hook for MCP startup - private var onNewConnection: ((Int32, String, Int, String?) async -> Admission)? + /// Callback when a new CLI connects and completes handshake. + /// The macOS listener normalizes peer identity before reusable admission policy sees it. + private var onNewConnection: ((MCPAppProxyInboundConnection, String, String?) async -> Admission)? init(socketURL: URL = MCPFilesystemConstants.bootstrapSocketURL(), logger: Logger? = nil) { self.socketURL = socketURL @@ -240,7 +114,7 @@ actor BootstrapSocketServer { /// Starts listening on the bootstrap socket. /// - Parameter onNewConnection: Callback invoked for each new CLI connection. /// Return an Admission with postAccept closure for MCP startup. - func start(onNewConnection: @escaping (Int32, String, Int, String?) async -> Admission) throws { + func start(onNewConnection: @escaping (MCPAppProxyInboundConnection, String, String?) async -> Admission) throws { #if DEBUG print("[MCPStartup] BootstrapSocketServer.start entered socket=\(socketURL.path)") #endif @@ -410,8 +284,8 @@ actor BootstrapSocketServer { } } - let handshakeSocket = BootstrapHandshakeSocket( - fd: descriptors[0], + let handshakeSocket = MacOSBootstrapAcceptedTransportLease( + fileDescriptor: descriptors[0], debugBeforeInitiatingShutdown: { initiatingShutdown.signal() continueShutdown.wait() @@ -420,7 +294,7 @@ actor BootstrapSocketServer { workerGroup.enter() DispatchQueue.global(qos: .userInitiated).async { defer { workerGroup.leave() } - _ = handshakeSocket.withServerOwnedIOLease { _ in + _ = handshakeSocket.withListenerOwnedIOLease { _ in leaseStarted.signal() releaseLease.wait() } @@ -431,7 +305,7 @@ actor BootstrapSocketServer { workerGroup.enter() DispatchQueue.global(qos: .userInitiated).async { defer { workerGroup.leave() } - handshakeSocket.shutdownAndCloseIfServerOwned() + handshakeSocket.rollback() shutdownFinished.signal() } try debugWait(initiatingShutdown, phase: "shutdown initiation") @@ -494,7 +368,7 @@ actor BootstrapSocketServer { } for socket in inFlightHandshakeSockets.values { - socket.shutdownAndCloseIfServerOwned() + socket.rollback() } inFlightHandshakeSockets.removeAll() @@ -598,7 +472,7 @@ actor BootstrapSocketServer { } let handshakeID = UUID() - let handshakeSocket = BootstrapHandshakeSocket(fd: clientFD) + let handshakeSocket = MacOSBootstrapAcceptedTransportLease(fileDescriptor: clientFD) let generation = listenerGeneration inFlightHandshakeSockets[handshakeID] = handshakeSocket let lifecycleCorrelation = EditFlowPerf.makeLifecycleCorrelationIfActive() @@ -691,12 +565,12 @@ actor BootstrapSocketServer { private func handleNewConnectionWithBackpressure( handshakeID: UUID, - handshakeSocket: BootstrapHandshakeSocket, + handshakeSocket: MacOSBootstrapAcceptedTransportLease, generation: UInt64, lifecycleCorrelation: EditFlowPerf.LifecycleCorrelation? ) async { defer { - handshakeSocket.shutdownAndCloseIfServerOwned() + handshakeSocket.rollback() inFlightHandshakeSockets.removeValue(forKey: handshakeID) // If we were paused and now have room, resume accepting. if inFlightHandshakeSockets.count < maxInFlightHandshakes { @@ -722,8 +596,8 @@ actor BootstrapSocketServer { } } - private func isActiveHandshake(_ handshakeSocket: BootstrapHandshakeSocket, generation: UInt64) -> Bool { - isRunning && listenerGeneration == generation && handshakeSocket.isServerOwnedOpen() + private func isActiveHandshake(_ handshakeSocket: MacOSBootstrapAcceptedTransportLease, generation: UInt64) -> Bool { + isRunning && listenerGeneration == generation && handshakeSocket.isListenerOwnedOpen() } private func abortAcceptedAdmissionIfNeeded(_ admission: Admission) async { @@ -734,11 +608,11 @@ actor BootstrapSocketServer { /// Handles a new client connection: read handshake, validate, callback. private func handleNewConnection( handshakeID: UUID, - handshakeSocket: BootstrapHandshakeSocket, + handshakeSocket: MacOSBootstrapAcceptedTransportLease, generation: UInt64, lifecycleCorrelation: EditFlowPerf.LifecycleCorrelation? ) async { - let clientFD = handshakeSocket.fd + let clientFD = handshakeSocket.fileDescriptor bootstrapSocketServerLog("BootstrapSocketServer: new connection on fd \(clientFD)") guard isActiveHandshake(handshakeSocket, generation: generation) else { return } @@ -763,25 +637,40 @@ actor BootstrapSocketServer { guard isActiveHandshake(handshakeSocket, generation: generation) else { return } - let peerPid = Self.peerPID(for: clientFD) - let effectivePid = peerPid ?? request.clientPid - if let peerPid, peerPid != request.clientPid { - bootstrapSocketServerLog("BootstrapSocketServer: clientPid mismatch (request=\(request.clientPid), peer=\(peerPid)); using peer pid") + guard MCPPeerIdentity.isValidHandshakeClaimedPID(request.clientPid) else { + logger.warning("BootstrapSocketServer: invalid claimed clientPid \(request.clientPid)") + _ = await sendResponseAsync( + .rejected(reason: "Invalid client PID", errorCode: "invalid_client_pid"), + to: handshakeSocket + ) + return + } + + let peerIdentity = MCPPeerIdentity( + socketObservedPID: Self.peerPID(for: clientFD), + handshakeClaimedPID: request.clientPid + ) + if let peerPID = peerIdentity.socketObservedPID, peerPID != request.clientPid { + bootstrapSocketServerLog("BootstrapSocketServer: clientPid mismatch (request=\(request.clientPid), peer=\(peerPID)); using peer pid") } + let inboundConnection = MCPAppProxyInboundConnection( + transportLease: handshakeSocket, + peerIdentity: peerIdentity + ) bootstrapSocketServerLog("BootstrapSocketServer: handshake from '\(request.clientName ?? "unknown")' session=\(request.sessionToken.prefix(8))...") // Validate protocol version guard request.protocolVersion == MCPBootstrapProtocol.currentVersion else { logger.warning("BootstrapSocketServer: protocol version mismatch (got \(request.protocolVersion), expected \(MCPBootstrapProtocol.currentVersion))") - _ = await sendResponseAsync(.rejected(reason: "Protocol version mismatch", errorCode: "protocol_version_mismatch"), to: handshakeSocket) + _ = await sendResponseAsync(.rejected(reason: "Protocol version mismatch", errorCode: MCPBootstrapErrorCode.protocolVersionMismatch.rawValue), to: handshakeSocket) return } // Invoke callback to let ServerNetworkManager decide guard let handler = onNewConnection else { logger.error("BootstrapSocketServer: no connection handler registered") - _ = await sendResponseAsync(.rejected(reason: "Server not ready", errorCode: "server_not_ready"), to: handshakeSocket) + _ = await sendResponseAsync(.rejected(reason: "Server not ready", errorCode: MCPBootstrapErrorCode.serverNotReady.rawValue), to: handshakeSocket) return } @@ -795,7 +684,7 @@ actor BootstrapSocketServer { EditFlowPerf.Stage.Bootstrap.admission, EditFlowPerf.Dimensions(activeCount: inFlightHandshakeSockets.count) ) - let admission = await handler(clientFD, request.sessionToken, effectivePid, request.clientName) + let admission = await handler(inboundConnection, request.sessionToken, request.clientName) EditFlowPerf.end( EditFlowPerf.Stage.Bootstrap.admission, admissionState, @@ -820,7 +709,7 @@ actor BootstrapSocketServer { } if admission.accepted { - guard let publishTransferredFD = admission.publishTransferredFD, + guard let publishTransferredTransport = admission.publishTransferredTransport, let postAccept = admission.postAccept else { logger.error("BootstrapSocketServer: accepted admission missing ownership-transfer hooks for fd \(clientFD)") @@ -846,8 +735,8 @@ actor BootstrapSocketServer { // NOW it's safe to transfer ownership and start the MCP server. The CLI // has received "accepted". Handshake ownership and full-shutdown-visible // manager publication move together under the handshake socket lock. - guard handshakeSocket.transferOwnershipIfOpen( - publishTransferredFD: publishTransferredFD + guard handshakeSocket.transfer( + publish: publishTransferredTransport ) else { await abortAcceptedAdmissionIfNeeded(admission) return @@ -863,7 +752,7 @@ actor BootstrapSocketServer { } await postAccept() } else { - let response = admission.rejection ?? .rejected(reason: "Connection rejected", errorCode: "approval_denied") + let response = admission.rejection ?? .rejected(reason: "Connection rejected", errorCode: MCPBootstrapErrorCode.approvalDenied.rawValue) _ = await sendResponseAsync(response, to: handshakeSocket) bootstrapSocketServerLog("BootstrapSocketServer: rejected connection from '\(request.clientName ?? "unknown")'") } @@ -874,7 +763,7 @@ actor BootstrapSocketServer { /// Reads the handshake request from the client socket. /// Format: newline-delimited JSON (same as MCP protocol) private func readHandshakeRequestAsync( - from handshakeSocket: BootstrapHandshakeSocket, + from handshakeSocket: MacOSBootstrapAcceptedTransportLease, lifecycleCorrelation: EditFlowPerf.LifecycleCorrelation? ) async -> MCPBootstrapRequest? { EditFlowPerf.lifecycleEvent( @@ -915,7 +804,7 @@ actor BootstrapSocketServer { return request } - private nonisolated static func readHandshakeRequestBlocking(from handshakeSocket: BootstrapHandshakeSocket) -> MCPBootstrapRequest? { + private nonisolated static func readHandshakeRequestBlocking(from handshakeSocket: MacOSBootstrapAcceptedTransportLease) -> MCPBootstrapRequest? { var buffer = Data() var byte: UInt8 = 0 @@ -925,7 +814,7 @@ actor BootstrapSocketServer { while Date() < deadline { let remaining = Int32(deadline.timeIntervalSinceNow * 1000) - guard let pollResult = handshakeSocket.withServerOwnedIOLease({ leasedFD in + guard let pollResult = handshakeSocket.withListenerOwnedIOLease({ leasedFD in var pfd = pollfd(fd: leasedFD, events: Int16(POLLIN), revents: 0) return poll(&pfd, 1, max(0, remaining)) }) else { @@ -939,7 +828,7 @@ actor BootstrapSocketServer { continue } - guard let bytesRead = handshakeSocket.withServerOwnedIOLease({ leasedFD in + guard let bytesRead = handshakeSocket.withListenerOwnedIOLease({ leasedFD in Darwin.read(leasedFD, &byte, 1) }) else { return nil @@ -976,9 +865,9 @@ actor BootstrapSocketServer { /// Returns true if the full response was written successfully. /// Uses SO_SNDTIMEO for bounded writes - if the client isn't reading, we fail fast. @discardableResult - private func sendResponseAsync(_ response: MCPBootstrapResponse, to handshakeSocket: BootstrapHandshakeSocket) async -> Bool { - let fd = handshakeSocket.fd - guard handshakeSocket.isServerOwnedOpen() else { return false } + private func sendResponseAsync(_ response: MCPBootstrapResponse, to handshakeSocket: MacOSBootstrapAcceptedTransportLease) async -> Bool { + let fd = handshakeSocket.fileDescriptor + guard handshakeSocket.isListenerOwnedOpen() else { return false } guard let jsonData = try? JSONEncoder().encode(response) else { logger.error("BootstrapSocketServer: failed to encode response") return false @@ -996,7 +885,7 @@ actor BootstrapSocketServer { var totalWritten = 0 while totalWritten < bytes.count { - if Task.isCancelled || !handshakeSocket.isServerOwnedOpen() { + if Task.isCancelled || !handshakeSocket.isListenerOwnedOpen() { POSIXDescriptorSupport.shutdownSocketReadWrite(fd) return false } diff --git a/Sources/RepoPrompt/Infrastructure/MCP/UnixSocketMCPTransport.swift b/Sources/RepoPrompt/Infrastructure/MCP/AppProxy/MacOSUnixSocketMCPTransport.swift similarity index 99% rename from Sources/RepoPrompt/Infrastructure/MCP/UnixSocketMCPTransport.swift rename to Sources/RepoPrompt/Infrastructure/MCP/AppProxy/MacOSUnixSocketMCPTransport.swift index 1148aeee6..be1a63a67 100644 --- a/Sources/RepoPrompt/Infrastructure/MCP/UnixSocketMCPTransport.swift +++ b/Sources/RepoPrompt/Infrastructure/MCP/AppProxy/MacOSUnixSocketMCPTransport.swift @@ -1,5 +1,5 @@ // -// UnixSocketMCPTransport.swift +// MacOSUnixSocketMCPTransport.swift // RepoPrompt // // UNIX domain socket transport for local MCP connections. @@ -9,6 +9,7 @@ import Foundation import Logging import MCP +import RepoPromptPOSIXSupport import RepoPromptShared #if DEBUG diff --git a/Sources/RepoPrompt/Infrastructure/MCP/AppSettingsMCPService.swift b/Sources/RepoPrompt/Infrastructure/MCP/AppSettingsMCPService.swift index 0671a99a5..a0bb6ca12 100644 --- a/Sources/RepoPrompt/Infrastructure/MCP/AppSettingsMCPService.swift +++ b/Sources/RepoPrompt/Infrastructure/MCP/AppSettingsMCPService.swift @@ -1,6 +1,7 @@ import Foundation import JSONSchema import MCP +import RepoPromptCore /// Global, non-window-scoped MCP service for allowlisted RepoPrompt app settings. /// diff --git a/Sources/RepoPrompt/Infrastructure/MCP/AppShared/MCPConstants.swift b/Sources/RepoPrompt/Infrastructure/MCP/AppShared/MCPConstants.swift index 9ea12e307..f44628db5 100644 --- a/Sources/RepoPrompt/Infrastructure/MCP/AppShared/MCPConstants.swift +++ b/Sources/RepoPrompt/Infrastructure/MCP/AppShared/MCPConstants.swift @@ -1,4 +1,5 @@ import Foundation +import RepoPromptShared /// Centralised constants used by the MCP layer. /// TCP/Bonjour transport has been removed – only the UNIX bootstrap socket is used. @@ -24,7 +25,7 @@ enum MCPConstants { /// Bootstrap socket protocol version number. /// CLI sends this in handshake; app can reject incompatible versions. - static let bootstrapProtocolVersion = 2 + static let bootstrapProtocolVersion = MCPBootstrapProtocol.currentVersion /// Content context identifier for heartbeat frames. static let hbContextID = "com.repoprompt.mcp.heartbeat" diff --git a/Sources/RepoPrompt/Infrastructure/MCP/AppShared/MCPFilesystemConstants.swift b/Sources/RepoPrompt/Infrastructure/MCP/AppShared/MCPFilesystemConstants.swift index 2eac79b54..6416de43e 100644 --- a/Sources/RepoPrompt/Infrastructure/MCP/AppShared/MCPFilesystemConstants.swift +++ b/Sources/RepoPrompt/Infrastructure/MCP/AppShared/MCPFilesystemConstants.swift @@ -1,3 +1,4 @@ +import Darwin import Foundation import RepoPromptShared @@ -66,6 +67,8 @@ func mcpRoutingDebugLog(_ message: @autoclosure () -> String) { } enum MCPFilesystemConstants { + private static let userID = UInt32(getuid()) + #if DEBUG static let identity = MCPFilesystemIdentity.repoPromptCE(.debug) #else @@ -85,7 +88,7 @@ enum MCPFilesystemConstants { } static func socketDirectoryURL() -> URL { - identity.socketDirectoryURL() + identity.socketDirectoryURL(userID: userID) } @discardableResult @@ -111,7 +114,7 @@ enum MCPFilesystemConstants { } static func bootstrapSocketURL() -> URL { - identity.bootstrapSocketURL() + identity.bootstrapSocketURL(userID: userID) } static func eventsDirectoryURL() -> URL { diff --git a/Sources/RepoPrompt/Infrastructure/MCP/MCPConnectionManager.swift b/Sources/RepoPrompt/Infrastructure/MCP/MCPConnectionManager.swift index 182b433fc..4d05bc997 100644 --- a/Sources/RepoPrompt/Infrastructure/MCP/MCPConnectionManager.swift +++ b/Sources/RepoPrompt/Infrastructure/MCP/MCPConnectionManager.swift @@ -1,3 +1,6 @@ +import RepoPromptCore +import RepoPromptCoreMacOS + // MARK: - Connection Management Components import Darwin @@ -7,6 +10,7 @@ import Logging import MCP import Ontology import OSLog +import RepoPromptPOSIXSupport import RepoPromptShared import SwiftUI @@ -207,13 +211,13 @@ struct IdentityContextSnapshot { // MCPServerConnection implementation is BootstrapSocketConnectionManager (in a separate file). // No replacement code is needed; we now go directly to ServerNetworkManager. -/// Lock-protected ownership ledger for accepted bootstrap descriptors after handshake +/// Lock-protected ownership ledger for accepted bootstrap transports after handshake /// ownership transfer but before actor-processed deferred registration. Full shutdown -/// invalidates one lifecycle and drains its descriptors synchronously. +/// invalidates one lifecycle and drains its transports synchronously. private final class BootstrapTransferredSocketLedger: @unchecked Sendable { private struct Entry { let lifecycleGeneration: UInt64 - let fd: Int32 + let transport: any MCPAppProxyAcceptedTransport } private let lock = NSLock() @@ -226,13 +230,20 @@ private final class BootstrapTransferredSocketLedger: @unchecked Sendable { lock.unlock() } - func publish(connectionID: UUID, lifecycleGeneration: UInt64, fd: Int32) -> Bool { + func publish( + connectionID: UUID, + lifecycleGeneration: UInt64, + transport: any MCPAppProxyAcceptedTransport + ) -> Bool { lock.lock() defer { lock.unlock() } guard activeLifecycleGeneration == lifecycleGeneration, entriesByConnectionID[connectionID] == nil else { return false } - entriesByConnectionID[connectionID] = Entry(lifecycleGeneration: lifecycleGeneration, fd: fd) + entriesByConnectionID[connectionID] = Entry( + lifecycleGeneration: lifecycleGeneration, + transport: transport + ) return true } @@ -245,7 +256,10 @@ private final class BootstrapTransferredSocketLedger: @unchecked Sendable { return entry.lifecycleGeneration == lifecycleGeneration } - func claim(connectionID: UUID, lifecycleGeneration: UInt64) -> Int32? { + func claim( + connectionID: UUID, + lifecycleGeneration: UInt64 + ) -> (any MCPAppProxyAcceptedTransport)? { lock.lock() defer { lock.unlock() } guard activeLifecycleGeneration == lifecycleGeneration, @@ -253,20 +267,25 @@ private final class BootstrapTransferredSocketLedger: @unchecked Sendable { entry.lifecycleGeneration == lifecycleGeneration else { return nil } entriesByConnectionID.removeValue(forKey: connectionID) - return entry.fd + return entry.transport } - func remove(connectionID: UUID, lifecycleGeneration: UInt64) -> Int32? { + func remove( + connectionID: UUID, + lifecycleGeneration: UInt64 + ) -> (any MCPAppProxyAcceptedTransport)? { lock.lock() defer { lock.unlock() } guard let entry = entriesByConnectionID[connectionID], entry.lifecycleGeneration == lifecycleGeneration else { return nil } entriesByConnectionID.removeValue(forKey: connectionID) - return entry.fd + return entry.transport } - func invalidateAndDrain(lifecycleGeneration: UInt64) -> [Int32] { + func invalidateAndDrain( + lifecycleGeneration: UInt64 + ) -> [any MCPAppProxyAcceptedTransport] { lock.lock() defer { lock.unlock() } guard activeLifecycleGeneration == lifecycleGeneration else { return [] } @@ -275,7 +294,7 @@ private final class BootstrapTransferredSocketLedger: @unchecked Sendable { for connectionID in matchingEntries.keys { entriesByConnectionID.removeValue(forKey: connectionID) } - return matchingEntries.values.map(\.fd) + return matchingEntries.values.map(\.transport) } var isEmptyAndInactive: Bool { @@ -300,14 +319,37 @@ actor ServerNetworkManager { /// active client connections. Use this when you need to reference the /// MCP listener from anywhere in the app. static let shared = ServerNetworkManager() + nonisolated let runtimeSessionRegistry: MCPRuntimeSessionRegistry + nonisolated let serviceRegistry: MCPServiceRegistry + nonisolated let appSessionAdapters: RepoPromptAppSessionAdapterRegistry + nonisolated let processAncestryInspector: any ProcessAncestryInspecting + nonisolated let toolCatalogReadiness: MCPToolCatalogReadiness private static let repoCLIPrefix = "RepoPrompt CLI" - private static let toolNameAliases: [String: String] = [ - "discover_manage_selection": "manage_selection", - "discover_prompt": "prompt", - "discover_workspace_context": "workspace_context" - ] + + init( + runtimeSessionRegistry: MCPRuntimeSessionRegistry = MCPRuntimeSessionRegistry(), + serviceRegistry: MCPServiceRegistry = MCPServiceRegistry(), + appSessionAdapters: RepoPromptAppSessionAdapterRegistry = .shared, + processAncestryInspector: any ProcessAncestryInspecting = MacOSProcessAncestryInspector() + ) { + self.runtimeSessionRegistry = runtimeSessionRegistry + self.serviceRegistry = serviceRegistry + self.appSessionAdapters = appSessionAdapters + self.processAncestryInspector = processAncestryInspector + toolCatalogReadiness = MCPToolCatalogReadiness( + runtimeSessionRegistry: runtimeSessionRegistry, + serviceRegistry: serviceRegistry, + appSessionAdapters: appSessionAdapters + ) + Task { @MainActor [weak self] in + serviceRegistry.setSnapshotDidChangeSink { [weak self] in + await self?.broadcastToolListChanged() + } + } + } + nonisolated static func canonicalToolName(for name: String) -> String { - toolNameAliases[name] ?? name + MCPToolNameCanonicalizer.canonicalName(for: name) } private static func validatedLiveRunID( @@ -352,7 +394,7 @@ actor ServerNetworkManager { return policyWindowID } let resolvedWindowID = await MainActor.run { - WindowStatesManager.shared.allWindows.first { $0.mcpServer.hasLiveRunID(runID) }?.windowID + appSessionAdapters.windowStates().first { $0.mcpServer.hasLiveRunID(runID) }?.windowID } if let resolvedWindowID { windowIDByRunID[runID] = resolvedWindowID @@ -1085,11 +1127,11 @@ actor ServerNetworkManager { } private func closeTransferredBootstrapSocket(connectionID: UUID, lifecycleGeneration: UInt64) { - guard let fd = transferredBootstrapSockets.remove( + guard let transport = transferredBootstrapSockets.remove( connectionID: connectionID, lifecycleGeneration: lifecycleGeneration ) else { return } - closeUnregisteredBootstrapFD(fd) + transport.close() } private func closeUnregisteredBootstrapFD(_ fd: Int32) { @@ -1182,7 +1224,7 @@ actor ServerNetworkManager { // The VM API is identity-bound, so it is safe to scan all windows. This avoids // missing tools routed via a per-call _windowID override that differs from the // connection's sticky/default assigned window. - WindowStatesManager.shared.allWindows.reduce(into: 0) { partialResult, window in + appSessionAdapters.windowStates(includeDraining: true).reduce(into: 0) { partialResult, window in partialResult += window.mcpServer.cancelActiveToolsForConnection( connectionID: connectionID, reason: reason @@ -1433,16 +1475,14 @@ actor ServerNetworkManager { return existing } - // Get window state on MainActor - let (mcpEnabledWindows, multiWindowEffective) = await MainActor.run { - let windows = WindowStatesManager.shared.allWindows.filter(\.mcpServer.windowToolsEnabled) - let effective = WindowStatesManager.shared.isMultiWindowModeEffectivelyActive - return (windows, effective) + let routingSnapshot = await MainActor.run { runtimeSessionRegistry.routingSnapshot() } + let mcpEnabledWindowIDs = routingSnapshot.orderedActiveWindowIDs.filter { + routingSnapshot.mcpEnabledWindowIDs.contains($0) } + let multiWindowEffective = routingSnapshot.isMultiWindowModeEffectivelyActive // Single MCP-enabled window → unambiguous binding - if mcpEnabledWindows.count == 1, let window = mcpEnabledWindows.first { - let windowID = window.windowID + if mcpEnabledWindowIDs.count == 1, let windowID = mcpEnabledWindowIDs.first { setConnectionWindowMapping(connectionID, windowID: windowID) connectionLog("\(reason): auto-bound connection \(connectionID) to single MCP-enabled window \(windowID)") return windowID @@ -1455,14 +1495,13 @@ actor ServerNetworkManager { if let recordedWindowCount = windowCountAtConnectionTime[connectionID] { connectedDuringSingleWindow = recordedWindowCount == 1 } else { - connectedDuringSingleWindow = !multiWindowEffective && mcpEnabledWindows.count <= 1 + connectedDuringSingleWindow = !multiWindowEffective && mcpEnabledWindowIDs.count <= 1 if multiWindowEffective { connectionLog("\(reason): missing connection-time window count for \(connectionID); treating as multi-window") } } if connectedDuringSingleWindow || !multiWindowEffective { - if let firstWindow = await WindowStatesManager.shared.firstMCPEnabledWindow() { - let windowID = firstWindow.windowID + if let windowID = routingSnapshot.firstMCPEnabledWindowID { setConnectionWindowMapping(connectionID, windowID: windowID) let bindReason = connectedDuringSingleWindow ? "single-window-at-connect" : "single-window-mode" connectionLog("\(reason): auto-bound connection \(connectionID) to window \(windowID) (\(bindReason))") @@ -1570,7 +1609,7 @@ actor ServerNetworkManager { } let resolved = await MainActor.run { () -> (runID: UUID, windowID: Int)? in - for window in WindowStatesManager.shared.allWindows { + for window in appSessionAdapters.windowStates() { guard let candidateRunID = window.mcpServer.connectionIDToRunID[connectionID], let runID = Self.validatedLiveRunID( candidateRunID: candidateRunID, @@ -1662,7 +1701,7 @@ actor ServerNetworkManager { // Fast path: if this connection is already mapped to this run in this window, // avoid re-registering and re-binding on every tool call. let alreadyMapped = await MainActor.run { () -> Bool in - guard let window = WindowStatesManager.shared.window(withID: windowID) else { + guard let window = appSessionAdapters.window(withID: windowID) else { return false } let mappedRun = window.mcpServer.connectionIDToRunID[connectionID] @@ -1681,7 +1720,7 @@ actor ServerNetworkManager { } let registrationSucceeded = await MainActor.run { () -> Bool in - guard let window = WindowStatesManager.shared.window(withID: windowID) else { + guard let window = appSessionAdapters.window(withID: windowID) else { log.warning("mapConnectionToRunID: window \(windowID) not found for connection \(connectionID)") return false } @@ -2115,7 +2154,7 @@ actor ServerNetworkManager { } await MainActor.run { - let windows = WindowStatesManager.shared.allWindows.filter { window in + let windows = appSessionAdapters.windowStates(includeDraining: true).filter { window in guard let windowID else { return true } return window.windowID == windowID } @@ -2779,16 +2818,15 @@ actor ServerNetworkManager { #if DEBUG print("[MCPStartup] calling BootstrapSocketServer.start socket=\(socketURL.path) generation=\(expectedLifecycleGeneration)") #endif - try await server.start { [weak self, weak server] clientFD, sessionToken, clientPid, clientName async -> BootstrapSocketServer.Admission in + try await server.start { [weak self, weak server] inboundConnection, sessionToken, clientName async -> BootstrapSocketServer.Admission in guard let self, let server else { return .reject(.rejected(reason: "Server unavailable", errorCode: "server_unavailable")) } return await handleBootstrapConnection( sourceListener: server, lifecycleGeneration: expectedLifecycleGeneration, - clientFD: clientFD, + inboundConnection: inboundConnection, sessionToken: sessionToken, - clientPid: clientPid, clientName: clientName ) } @@ -2833,14 +2871,20 @@ actor ServerNetworkManager { private func handleBootstrapConnection( sourceListener: BootstrapSocketServer, lifecycleGeneration admissionLifecycleGeneration: UInt64, - clientFD: Int32, + inboundConnection: MCPAppProxyInboundConnection, sessionToken: String, - clientPid: Int, clientName: String? ) async -> BootstrapSocketServer.Admission { + let transportLease = inboundConnection.transportLease + let peerIdentity = inboundConnection.peerIdentity let connectionID = UUID() - connectionLog("Bootstrap socket connection: \(connectionID) from '\(clientName ?? "unknown")' (pid=\(clientPid), session=\(sessionToken.prefix(8))...)") - mcpACPLog("[MCP-ACP] bootstrap connection connection=\(connectionID) bootstrapClientName=\(clientName ?? "unknown") pid=\(clientPid)") + connectionLog("Bootstrap socket connection: \(connectionID) from '\(clientName ?? "unknown")' (trustedPid=\(peerIdentity.trustedPID.map(String.init) ?? "unavailable"), claimedPid=\(peerIdentity.handshakeClaimedPID), provenance=\(String(describing: peerIdentity.provenance)), session=\(sessionToken.prefix(8))...)") + mcpACPLog("[MCP-ACP] bootstrap connection connection=\(connectionID) bootstrapClientName=\(clientName ?? "unknown") trustedPid=\(peerIdentity.trustedPID.map(String.init) ?? "unavailable") claimedPid=\(peerIdentity.handshakeClaimedPID) provenance=\(String(describing: peerIdentity.provenance))") + + guard let clientPid = peerIdentity.trustedPID else { + log.warning("Rejecting bootstrap connection \(connectionID) - trusted socket peer pid unavailable") + return .reject(.rejected(reason: "Unable to verify peer process", errorCode: "peer_pid_unavailable")) + } guard isCurrentBootstrapListener(sourceListener, lifecycleGeneration: admissionLifecycleGeneration) else { return rejectBootstrapAdmissionBecauseStopped(connectionID: connectionID) @@ -2902,8 +2946,9 @@ actor ServerNetworkManager { connectionID: connectionID, lifecycleGeneration: admissionLifecycleGeneration, sessionToken: sessionToken, - clientPid: clientPid, - clientName: clientName + peerIdentity: peerIdentity, + clientName: clientName, + transportLease: transportLease ) } @@ -2920,9 +2965,14 @@ actor ServerNetworkManager { connectionID: UUID, lifecycleGeneration admissionLifecycleGeneration: UInt64, sessionToken: String, - clientPid: Int, - clientName: String? + peerIdentity: MCPPeerIdentity, + clientName: String?, + transportLease: any MCPAppProxyAcceptedTransportLease ) -> BootstrapSocketServer.Admission { + guard transportLease.reserveForAdmission() else { + connectionLog("Rejecting bootstrap connection \(connectionID) - transport lease unavailable") + return .reject(.rejected(reason: "Server unavailable", errorCode: "server_unavailable")) + } reserveBootstrapSlot( connectionID: connectionID, lifecycleGeneration: admissionLifecycleGeneration @@ -2932,11 +2982,11 @@ actor ServerNetworkManager { // NOTE: Using strong capture [self] to ensure reservation cleanup always reaches // the owning manager. These closures are short-lived and not stored long-term. return .accept( - publishTransferredFD: { [transferredBootstrapSockets] clientFD in + publishTransferredTransport: { [transferredBootstrapSockets] transport in transferredBootstrapSockets.publish( connectionID: connectionID, lifecycleGeneration: admissionLifecycleGeneration, - fd: clientFD + transport: transport ) }, postAccept: { [self] in @@ -2944,11 +2994,12 @@ actor ServerNetworkManager { connectionID: connectionID, lifecycleGeneration: admissionLifecycleGeneration, sessionToken: sessionToken, - clientPid: clientPid, + peerIdentity: peerIdentity, clientName: clientName ) }, onAcceptAborted: { [self] in + transportLease.rollback() await rollbackBootstrapReservation( connectionID: connectionID, lifecycleGeneration: admissionLifecycleGeneration, @@ -2962,7 +3013,7 @@ actor ServerNetworkManager { connectionID: UUID, lifecycleGeneration admissionLifecycleGeneration: UInt64, sessionToken: String, - clientPid: Int, + peerIdentity: MCPPeerIdentity, clientName: String? ) async { guard let reservation = bootstrapReservations[connectionID], @@ -3007,7 +3058,7 @@ actor ServerNetworkManager { return } - guard let committedFD = transferredBootstrapSockets.claim( + guard let committedTransport = transferredBootstrapSockets.claim( connectionID: connectionID, lifecycleGeneration: admissionLifecycleGeneration ) else { @@ -3018,13 +3069,22 @@ actor ServerNetworkManager { ) return } + + guard let acceptedTransport = committedTransport as? MacOSBootstrapAcceptedTransportLease, + let committedFD = acceptedTransport.claimConnectedFileDescriptor() + else { + committedTransport.close() + bootstrapReservations.removeValue(forKey: connectionID) + connectionLog("Abandoned pending bootstrap commit \(connectionID) (opaque transport could not be adopted)") + return + } bootstrapReservations.removeValue(forKey: connectionID) registerAndStartBootstrapConnection( connectionID: connectionID, lifecycleGeneration: admissionLifecycleGeneration, sessionToken: sessionToken, - clientPid: clientPid, + peerIdentity: peerIdentity, clientName: clientName, clientFD: committedFD ) @@ -3059,14 +3119,18 @@ actor ServerNetworkManager { clientFD: Int32 ) -> BootstrapSocketServer.Admission? { guard isRunningState else { return nil } + let transportLease = MacOSBootstrapAcceptedTransportLease(fileDescriptor: clientFD) let admission = makeAcceptedBootstrapAdmission( connectionID: connectionID, lifecycleGeneration: lifecycleGeneration, sessionToken: sessionToken, - clientPid: clientPid, - clientName: clientName + peerIdentity: MCPPeerIdentity(socketObservedPID: clientPid, handshakeClaimedPID: clientPid), + clientName: clientName, + transportLease: transportLease ) - guard admission.publishTransferredFD?(clientFD) == true else { + guard let publish = admission.publishTransferredTransport, + transportLease.transfer(publish: publish) + else { rollbackBootstrapReservation( connectionID: connectionID, lifecycleGeneration: lifecycleGeneration, @@ -3096,7 +3160,7 @@ actor ServerNetworkManager { connectionID: UUID, lifecycleGeneration: UInt64, sessionToken: String, - clientPid: Int, + peerIdentity: MCPPeerIdentity, clientName: String?, clientFD: Int32 ) { @@ -3104,6 +3168,11 @@ actor ServerNetworkManager { closeUnregisteredBootstrapFD(clientFD) return } + guard let clientPid = peerIdentity.trustedPID else { + log.error("Refusing to register bootstrap connection \(connectionID) without a trusted socket peer pid") + closeUnregisteredBootstrapFD(clientFD) + return + } let purpose = purposeForNewBootstrapConnection( clientName: clientName, @@ -3122,7 +3191,7 @@ actor ServerNetworkManager { manager = try BootstrapSocketConnectionManager( connectionID: connectionID, sessionToken: sessionToken, - clientPid: clientPid, + peerIdentity: peerIdentity, clientName: clientName, purpose: purpose, codeMapsDisabled: codeMapsDisabled, @@ -3187,7 +3256,7 @@ actor ServerNetworkManager { // Routing logic treats nil as unknown and falls back to current window topology. Task { @MainActor [weak self] in guard let self else { return } - let count = WindowStatesManager.shared.allWindows.count + let count = runtimeSessionRegistry.routingSnapshot().activeWindowCount await setWindowCountAtConnectionTime( connectionID: connectionID, lifecycleGeneration: lifecycleGeneration, @@ -3362,7 +3431,7 @@ actor ServerNetworkManager { guard self.isCurrentConnection(connectionID, lifecycleGeneration: expectedLifecycleGeneration) else { return } let windowID = await self.ensureWindowBindingIfUnambiguous(connectionID: connectionID, reason: "handshake") guard self.isCurrentConnection(connectionID, lifecycleGeneration: expectedLifecycleGeneration) else { return } - let ready = await MCPToolCatalogReadiness.shared.awaitReady( + let ready = await self.toolCatalogReadiness.awaitReady( windowID: windowID, timeout: MCPToolCatalogReadiness.defaultTimeout ) @@ -3372,7 +3441,7 @@ actor ServerNetworkManager { } if let windowID { - await MCPToolCatalogReadiness.shared.warmToolCache(windowID: windowID) + await self.toolCatalogReadiness.warmToolCache(windowID: windowID) } } } else { @@ -3466,11 +3535,11 @@ actor ServerNetworkManager { // Invalidate synchronous transfer publication and close transferred-but-unregistered // sockets before awaiting listener teardown. This prevents old-lifecycle commits // from surviving a full stop followed by restart. - let transferredBootstrapFDs = transferredBootstrapSockets.invalidateAndDrain( + let transferredBootstrapTransports = transferredBootstrapSockets.invalidateAndDrain( lifecycleGeneration: stoppedLifecycleGeneration ) - for fd in transferredBootstrapFDs { - closeUnregisteredBootstrapFD(fd) + for transport in transferredBootstrapTransports { + transport.close() } bootstrapReservations.removeAll() let waiterIDsToResume = connectionWaiters.compactMap { waiterID, waiter in @@ -3908,6 +3977,35 @@ actor ServerNetworkManager { } } + private func hasPerConnectionState(_ id: UUID) -> Bool { + let hasState = connections[id] != nil + || connectionLifecycleGenerationByID[id] != nil + || connectionTasks[id] != nil + || pendingConnections[id] != nil + || restrictedToolsByConnection[id] != nil + || additionalToolsByConnection[id] != nil + || runPurposeByConnection[id] != nil + || runIDByConnectionID[id] != nil + || windowAssignmentByConnection[id] != nil + || preassignedConnections.contains(id) + || windowCountAtConnectionTime[id] != nil + || connectionWindowMap[id] != nil + || clientIDByConnection[id] != nil + || activeConnectionsByClient.values.contains { $0.contains(id) } + || callLimiters[id] != nil + || capabilityTokenByConnection[id] != nil + || connectionIDBySessionToken.values.contains(id) + || identityContextByConnection[id] != nil + || connectionStats[id] != nil + || activeToolOwnerByWindow.values.contains(id) + || executionWatchdogTerminalConnections.contains(id) + #if DEBUG + return hasState || debugExecutionWatchdogAbortTargets[id] != nil + #else + return hasState + #endif + } + func removeConnection(_ id: UUID) async { guard !connectionsBeingRemoved.contains(id) else { connectionLog("removeConnection: \(id) cleanup already in progress; ignoring duplicate call") @@ -3924,10 +4022,7 @@ actor ServerNetworkManager { } // Idempotent guard – if already gone, do nothing (and do not log) - guard connections[id] != nil - || connectionTasks[id] != nil - || pendingConnections[id] != nil - else { + guard hasPerConnectionState(id) else { connectionLog("removeConnection: \(id) already removed; ignoring duplicate call") return } @@ -3960,6 +4055,10 @@ actor ServerNetworkManager { preassignedConnections.remove(id) windowCountAtConnectionTime.removeValue(forKey: id) identityContextByConnection.removeValue(forKey: id) + executionWatchdogTerminalConnections.remove(id) + #if DEBUG + debugExecutionWatchdogAbortTargets.removeValue(forKey: id) + #endif let cancelledToolCount = await cancelActiveToolsOwnedByConnection( id, @@ -3970,7 +4069,7 @@ actor ServerNetworkManager { } await MainActor.run { - let windows = WindowStatesManager.shared.allWindows + let windows = appSessionAdapters.windowStates(includeDraining: true) let nameForCleanup = cleanupClientName if let windowID = assignedWindowID, let windowState = windows.first(where: { $0.windowID == windowID }) @@ -4015,17 +4114,22 @@ actor ServerNetworkManager { // Note: connectionIDToRunID mapping is managed by MCPServerViewModel, not here // Release admission slot & limiter - if let clientID = clientIDByConnection[id] { - var set = activeConnectionsByClient[clientID] ?? [] - set.remove(id) - if set.isEmpty { activeConnectionsByClient.removeValue(forKey: clientID) } - else { activeConnectionsByClient[clientID] = set } - clientIDByConnection.removeValue(forKey: id) + for clientID in Array(activeConnectionsByClient.keys) { + activeConnectionsByClient[clientID]?.remove(id) + if activeConnectionsByClient[clientID]?.isEmpty == true { + activeConnectionsByClient.removeValue(forKey: clientID) + } } + clientIDByConnection.removeValue(forKey: id) callLimiters[id] = nil // Clean up routing metadata unbindSessionToken(sessionToken, forConnectionID: id) + for token in connectionIDBySessionToken.compactMap({ token, connectionID in + connectionID == id ? token : nil + }) { + connectionIDBySessionToken.removeValue(forKey: token) + } // Notify dashboard of connection removal emitDashboardUpdate() @@ -4081,12 +4185,12 @@ actor ServerNetworkManager { MCPBindingResolver( collectMatchesForContextID: { contextID in await MainActor.run { - WindowStatesManager.shared.allWindows.compactMap { windowState in - guard let candidate = windowState.workspaceManager.bindingCandidate(forContextID: contextID) else { + self.runtimeSessionRegistry.sessions().compactMap { session in + guard let candidate = session.workspaceSessionController.bindingCandidate(forContextID: contextID) else { return nil } return MCPContextBindingMatch( - windowID: windowState.windowID, + windowID: session.routingSessionID.rawValue, tabID: candidate.tabID, workspaceID: candidate.workspaceID, workspaceName: candidate.workspaceName, @@ -4097,10 +4201,10 @@ actor ServerNetworkManager { }, collectMatchesForWorkingDirs: { workingDirs in await MainActor.run { - WindowStatesManager.shared.allWindows.flatMap { windowState in - windowState.workspaceManager.bindingCandidates(matchingWorkingDirs: workingDirs, includeHidden: false).map { candidate in + self.runtimeSessionRegistry.sessions().flatMap { session in + session.workspaceSessionController.bindingCandidates(matchingWorkingDirs: workingDirs, includeHidden: false).map { candidate in MCPContextBindingMatch( - windowID: windowState.windowID, + windowID: session.routingSessionID.rawValue, tabID: candidate.tabID, workspaceID: candidate.workspaceID, workspaceName: candidate.workspaceName, @@ -4541,7 +4645,7 @@ actor ServerNetworkManager { if expectedPIDs.contains(current) { return true } - guard let parent = parentPID(of: current), parent > 1, parent != current else { + guard let parent = processAncestryInspector.parentPID(of: current), parent > 1, parent != current else { return false } current = parent @@ -4553,7 +4657,7 @@ actor ServerNetworkManager { var chain = [String(startPid)] var current = startPid for _ in 0 ..< 16 { - guard let parent = parentPID(of: current), parent > 1, parent != current else { + guard let parent = processAncestryInspector.parentPID(of: current), parent > 1, parent != current else { break } chain.append(String(parent)) @@ -4562,16 +4666,6 @@ actor ServerNetworkManager { return chain.joined(separator: "<-") } - private nonisolated func parentPID(of pid: pid_t) -> pid_t? { - var info = kinfo_proc() - var size = MemoryLayout.stride(ofValue: info) - var mib: [Int32] = [CTL_KERN, KERN_PROC, KERN_PROC_PID, pid] - guard sysctl(&mib, u_int(mib.count), &info, &size, nil, 0) == 0, size > 0 else { - return nil - } - return info.kp_eproc.e_ppid - } - func installClientConnectionPolicy( for clientName: String, windowID: Int, @@ -4807,6 +4901,35 @@ actor ServerNetworkManager { ) } + func debugConnectionCleanupState( + for connectionID: UUID + ) -> ( + hasLimiter: Bool, + hasRoutingMetadata: Bool, + hasExecutionPolicyMetadata: Bool, + hasSessionMetadata: Bool, + hasWatchdogMetadata: Bool + ) { + ( + hasLimiter: callLimiters[connectionID] != nil, + hasRoutingMetadata: connectionWindowMap[connectionID] != nil + || runIDByConnectionID[connectionID] != nil + || windowAssignmentByConnection[connectionID] != nil + || preassignedConnections.contains(connectionID) + || windowCountAtConnectionTime[connectionID] != nil, + hasExecutionPolicyMetadata: restrictedToolsByConnection[connectionID] != nil + || additionalToolsByConnection[connectionID] != nil + || runPurposeByConnection[connectionID] != nil, + hasSessionMetadata: pendingConnections[connectionID] != nil + || clientIDByConnection[connectionID] != nil + || activeConnectionsByClient.values.contains { $0.contains(connectionID) } + || capabilityTokenByConnection[connectionID] != nil + || connectionIDBySessionToken.values.contains(connectionID), + hasWatchdogMetadata: executionWatchdogTerminalConnections.contains(connectionID) + || debugExecutionWatchdogAbortTargets[connectionID] != nil + ) + } + func debugSeedConnectionRunRouting( connectionID: UUID, runID: UUID, @@ -5025,7 +5148,7 @@ actor ServerNetworkManager { private func debugBindingSnapshot(for connectionID: UUID, selectedWindowID: Int?) async -> MCPServerViewModel.ConnectionBindingSnapshot { await MainActor.run { - let windows = WindowStatesManager.shared.allWindows + let windows = appSessionAdapters.windowStates() let snapshots = windows.map { $0.mcpServer.connectionBindingSnapshot(forConnection: connectionID) } if let explicit = snapshots.first(where: { $0.bindingKind == .tabContext && $0.explicitlyBound && $0.runID == nil }) { return explicit @@ -5081,7 +5204,7 @@ actor ServerNetworkManager { private func debugWindowObjects() async -> [[String: Any]] { await MainActor.run { - WindowStatesManager.shared.allWindows.map { window in + appSessionAdapters.windowStates().map { window in let workspace = window.workspaceManager.activeWorkspace let activeTabID = window.promptManager.activeComposeTabID let workspaceID: Any = workspace?.id.uuidString ?? NSNull() @@ -5916,7 +6039,7 @@ actor ServerNetworkManager { } func debugSeedRoutingAffinityPayload(connectionID: UUID, windowID: Int) async -> [String: Any] { - let exists = await MainActor.run { WindowStatesManager.shared.hasWindow(id: windowID) } + let exists = await MainActor.run { runtimeSessionRegistry.hasActiveWindow(id: windowID) } guard exists else { return ["ok": false, "op": "seed_routing_affinity", "code": "invalid_params", "error": "No window found for window_id \(windowID)."] } @@ -6029,10 +6152,10 @@ actor ServerNetworkManager { } func debugRemoveConnection(_ id: UUID) async { - #if DEBUG - debugExecutionWatchdogAbortTargets.removeValue(forKey: id) - #endif await removeConnection(id) + while connectionsBeingRemoved.contains(id) || hasPerConnectionState(id) { + await Task.yield() + } } #if DEBUG @@ -6042,9 +6165,11 @@ actor ServerNetworkManager { clientName: String, sessionToken: String ) { - _ = clientName - _ = sessionToken + connections[connectionID] = connection + connectionLifecycleGenerationByID[connectionID] = lifecycleGeneration debugExecutionWatchdogAbortTargets[connectionID] = connection + pendingConnections[connectionID] = clientName + bindSessionToken(sessionToken, to: connectionID) } func debugSetToolExecutionWatchdogEnvironment(_ environment: MCPToolExecutionWatchdogEnvironment) { @@ -6098,21 +6223,27 @@ actor ServerNetworkManager { } } + func debugWaitForLimiterWaiter(for connectionID: UUID) async { + let limiter = limiter(for: connectionID) + await limiter.debugWaitForWaiter() + } + func debugListToolNames( for connectionID: UUID, hydratePersistedPolicy: Bool = true ) async throws -> [String] { let windowID = await ensureWindowBindingIfUnambiguous(connectionID: connectionID, reason: "debug/tools/list") - let isReady = await MCPToolCatalogReadiness.shared.awaitReady( + let isReady = await toolCatalogReadiness.awaitReady( windowID: windowID, timeout: 5.0 ) - if !isReady, windowID != nil { + if !isReady { throw MCPError.internalError("Tool catalog not ready. Please retry.") } if let windowID { - await MCPToolCatalogReadiness.shared.warmToolCache(windowID: windowID) + await toolCatalogReadiness.warmToolCache(windowID: windowID) } + let catalogBoundary = await serviceRegistry.capturePublicationBoundary() if hydratePersistedPolicy { _ = await hydratePersistedAgentModePolicyForConnectionIfNeeded( @@ -6121,12 +6252,8 @@ actor ServerNetworkManager { ) } - let (disabled, registeredServices) = await MainActor.run { - ( - ToolAvailabilityStore.shared.effectiveDisabledTools, - ServiceRegistry.services - ) - } + let indexedToolSnapshot = await serviceRegistry.snapshot(for: catalogBoundary) + let disabled = await MainActor.run { ToolAvailabilityStore.shared.effectiveDisabledTools } let policy = effectivePolicyState(for: connectionID) let restricted = policy.restricted let additionalTools = policy.additional @@ -6134,26 +6261,25 @@ actor ServerNetworkManager { var names: [String] = [] if isEnabledState { - for service in registeredServices { - for tool in await service.tools { - guard !disabled.contains(tool.name) else { continue } - guard !restricted.contains(tool.name) else { continue } - - if MCPPolicyGatedTools.names.contains(tool.name), - !additionalTools.contains(tool.name) - { - continue - } - - if !AgentModeMCPToolAdvertisementPolicy.shouldAdvertise( - toolName: tool.name, - taskLabelKind: policy.taskLabelKind, - allowsAgentExternalControlTools: policy.allowsAgentExternalControlTools - ) { continue } + for route in indexedToolSnapshot.orderedRoutes { + let tool = route.tool + guard !disabled.contains(tool.name) else { continue } + guard !restricted.contains(tool.name) else { continue } - guard seenNames.insert(tool.name).inserted else { continue } - names.append(tool.name) + if MCPPolicyGatedTools.names.contains(tool.name), + !additionalTools.contains(tool.name) + { + continue } + + if !AgentModeMCPToolAdvertisementPolicy.shouldAdvertise( + toolName: tool.name, + taskLabelKind: policy.taskLabelKind, + allowsAgentExternalControlTools: policy.allowsAgentExternalControlTools + ) { continue } + + guard seenNames.insert(tool.name).inserted else { continue } + names.append(tool.name) } } @@ -6553,7 +6679,7 @@ actor ServerNetworkManager { runID: UUID ) async { let resolved = await MainActor.run { () -> (workspaceID: UUID, snapshot: ComposeTabState)? in - guard let windowState = WindowStatesManager.shared.window(withID: windowID) else { + guard let windowState = appSessionAdapters.window(withID: windowID) else { return nil } guard let resolved = windowState.workspaceManager.resolveComposeTabRoutingSnapshot(for: tabID) else { @@ -6608,7 +6734,7 @@ actor ServerNetworkManager { let resolvedClientName = clientName ?? clientIdentifier(forConnection: connectionID) do { let bindingResult = try await MainActor.run { () throws -> (didBind: Bool, workspaceID: UUID?) in - guard let windowState = WindowStatesManager.shared.window(withID: windowID) else { + guard let windowState = appSessionAdapters.window(withID: windowID) else { throw MCPError.invalidParams("Window \(windowID) not found") } // Fast path: already bound to this exact tab+run in this window. @@ -6742,26 +6868,20 @@ actor ServerNetworkManager { // Use ensureWindowBindingIfUnambiguous to treat nil as single-window fallback, // preventing failures for clients that call tools/list before explicit window selection. let windowID = await ensureWindowBindingIfUnambiguous(connectionID: connectionID, reason: "tools/list") - let isReady = await MCPToolCatalogReadiness.shared.awaitReady( + let isReady = await toolCatalogReadiness.awaitReady( windowID: windowID, timeout: 2.0 // Shorter timeout here since handshake should have waited ) if !isReady { - // Only fail closed if we had a specific window to wait for. - // If windowID is nil (true multi-window ambiguity), log warning but proceed - // with whatever tools are available - the client will need to select a window. - if windowID != nil { - log.warning("Tool catalog not ready for tools/list - failing closed for connection \(connectionID) window \(windowID!)") - throw MCPError.internalError("Tool catalog not ready. Please retry.") - } else { - connectionLog("Tool catalog readiness skipped for multi-window ambiguous connection \(connectionID)") - } + log.warning("Tool catalog not ready for tools/list - failing closed for connection \(connectionID) window \(windowID.map(String.init) ?? "unbound")") + throw MCPError.internalError("Tool catalog not ready. Please retry.") } // Warm tool cache if we have a bound window if let windowID { - await MCPToolCatalogReadiness.shared.warmToolCache(windowID: windowID) + await toolCatalogReadiness.warmToolCache(windowID: windowID) } + let catalogBoundary = await serviceRegistry.capturePublicationBoundary() // Opportunistic persisted hydration for resumed agent-mode sessions. // Persisted routing metadata may restore window/run mapping, and cached @@ -6772,12 +6892,8 @@ actor ServerNetworkManager { ) // Get all MainActor-isolated data in one hop - let (disabled, registeredServices) = await MainActor.run { - ( - ToolAvailabilityStore.shared.effectiveDisabledTools, - ServiceRegistry.services - ) - } + let indexedToolSnapshot = await serviceRegistry.snapshot(for: catalogBoundary) + let disabled = await MainActor.run { ToolAvailabilityStore.shared.effectiveDisabledTools } let policy = await effectivePolicyState(for: connectionID) let restricted = policy.restricted let additionalTools = policy.additional @@ -6796,72 +6912,67 @@ actor ServerNetworkManager { // Only proceed when the global MCP switch is ON if await isEnabledState { - // Enumerate every registered Service - for service in registeredServices { - // Walk through the service's declared tools - for tool in await service.tools { - if disabled.contains(tool.name) { - #if DEBUG - recordHiddenTool(tool.name, reason: "disabled") - #endif - continue - } - if restricted.contains(tool.name) { - #if DEBUG - recordHiddenTool(tool.name, reason: "restricted") - #endif - continue - } + // Enumerate one immutable indexed tool snapshot. + for route in indexedToolSnapshot.orderedRoutes { + let tool = route.tool + if disabled.contains(tool.name) { + #if DEBUG + recordHiddenTool(tool.name, reason: "disabled") + #endif + continue + } + if restricted.contains(tool.name) { + #if DEBUG + recordHiddenTool(tool.name, reason: "restricted") + #endif + continue + } - // • hide policy-gated tools unless explicitly granted via additionalTools - if MCPPolicyGatedTools.names.contains(tool.name), - !additionalTools.contains(tool.name) - { - #if DEBUG - recordHiddenTool(tool.name, reason: "missing_additional_tool_grant") - #endif - continue - } + // • hide policy-gated tools unless explicitly granted via additionalTools + if MCPPolicyGatedTools.names.contains(tool.name), + !additionalTools.contains(tool.name) + { + #if DEBUG + recordHiddenTool(tool.name, reason: "missing_additional_tool_grant") + #endif + continue + } - // • role-based advertisement filtering (advertisement-only, not execution-time) - if !AgentModeMCPToolAdvertisementPolicy.shouldAdvertise( - toolName: tool.name, - taskLabelKind: policy.taskLabelKind, - allowsAgentExternalControlTools: policy.allowsAgentExternalControlTools - ) { - #if DEBUG - recordHiddenTool(tool.name, reason: "role_advertisement_policy") - #endif - continue - } + // • role-based advertisement filtering (advertisement-only, not execution-time) + if !AgentModeMCPToolAdvertisementPolicy.shouldAdvertise( + toolName: tool.name, + taskLabelKind: policy.taskLabelKind, + allowsAgentExternalControlTools: policy.allowsAgentExternalControlTools + ) { + #if DEBUG + recordHiddenTool(tool.name, reason: "role_advertisement_policy") + #endif + continue + } - // • skip duplicates coming from other windows - guard seenNames.insert(tool.name).inserted else { continue } + // • skip duplicates coming from other windows + guard seenNames.insert(tool.name).inserted else { continue } - // OK – advertise the tool - let schemaValue = try await cachedSchema( - for: tool.name, - schema: tool.inputSchema, - purpose: policy.purpose - ) - let description = advertisedToolDescription( - for: tool.name, - baseDescription: tool.description, - purpose: policy.purpose - ) + // OK – advertise the tool + let schemaValue = try await cachedSchema( + for: tool.name, + schema: tool.inputSchema, + purpose: policy.purpose + ) + let description = advertisedToolDescription( + for: tool.name, + baseDescription: tool.description, + purpose: policy.purpose + ) - tools.append( - .init( - name: tool.name, - description: description, - inputSchema: schemaValue, - annotations: CodexMCPToolAnnotationProjection.project( - tool.annotations, - clientIdentifier: clientIdentifier - ) - ) + tools.append( + .init( + name: tool.name, + description: description, + inputSchema: schemaValue, + annotations: tool.annotations ) - } + ) } } @@ -7116,21 +7227,15 @@ actor ServerNetworkManager { // mutable cross-connection service state. let bypassWindowRoutingForSnapshot = Self.shouldBypassWindowRouting(for: toolName) connectionLog("tools/call \(toolName): reading MainActor routing state") - let routingSnapshot: (Int, [any Service], Bool) = await EditFlowPerf.measure( + let routingSnapshot: (MCPRuntimeSessionRegistry.RoutingSnapshot, MCPServiceRegistry.Snapshot) = await EditFlowPerf.measure( EditFlowPerf.Stage.MCPToolCall.routingSnapshot, EditFlowPerf.Dimensions(toolName: toolName) ) { - await MainActor.run { - let services = ServiceRegistry.services - guard !bypassWindowRoutingForSnapshot else { - return (0, services, false) - } - let windows = WindowStatesManager.shared.allWindows - let effectiveMode = WindowStatesManager.shared.isMultiWindowModeEffectivelyActive - return (windows.count, services, effectiveMode) - } + let sessions = await MainActor.run { self.runtimeSessionRegistry.routingSnapshot() } + let tools = await self.serviceRegistry.routeSnapshot() + return (sessions, tools) } - connectionLog("tools/call \(toolName): routing state windowCount=\(routingSnapshot.0) services=\(routingSnapshot.1.count) multi=\(routingSnapshot.2)") + connectionLog("tools/call \(toolName): routing state windowCount=\(routingSnapshot.0.activeWindowCount) services=\(routingSnapshot.1.orderedRoutes.count) multi=\(routingSnapshot.0.isMultiWindowModeEffectivelyActive) bypass=\(bypassWindowRoutingForSnapshot)") EditFlowPerf.lifecycleEvent( EditFlowPerf.Lifecycle.MCPToolCall.routingSnapshotCompleted, correlation: lifecycleCorrelation, @@ -7214,7 +7319,9 @@ actor ServerNetworkManager { // Hidden params like `_windowID` can explicitly redirect a call even // when the connection already has a preferred window binding. - let (windowCount, allServices, multiWindowModeEffective) = routingSnapshot + let (runtimeSessions, indexedTools) = routingSnapshot + let windowCount = bypassWindowRoutingForSnapshot ? 0 : runtimeSessions.activeWindowCount + let multiWindowModeEffective = bypassWindowRoutingForSnapshot ? false : runtimeSessions.isMultiWindowModeEffectivelyActive var chosenID: Int? let windowStr: String let observerRunIDForCallbacksFinal: UUID? @@ -7239,7 +7346,7 @@ actor ServerNetworkManager { // When provided, _windowID always takes precedence, even over // existing connection mappings. This enables explicit window targeting. if !bypassWindowRouting, let requestedWindowID = capturedWindowID { - let windowValid = await WindowStatesManager.shared.hasWindowWithMCPEnabled(requestedWindowID) + let windowValid = runtimeSessions.hasMCPEnabledWindow(requestedWindowID) guard windowValid else { return Self.toolErrorResult( rawJSON: capturedRawJSON, @@ -7266,15 +7373,20 @@ actor ServerNetworkManager { // PRIORITY 1: Use existing connection mapping (no override requested) // ═══════════════════════════════════════════════════════════════ if !bypassWindowRouting, chosenID == nil, let mapped = existingMapping { - chosenID = mapped - mcpRoutingLog("Tool=\(toolName) using existing mapping conn=\(connectionID) window=\(mapped)") + if runtimeSessions.hasMCPEnabledWindow(mapped) { + chosenID = mapped + mcpRoutingLog("Tool=\(toolName) using existing mapping conn=\(connectionID) window=\(mapped)") + } else { + mcpRoutingLog("Tool=\(toolName) ignoring stale existing mapping conn=\(connectionID) window=\(mapped)") + } } // PRIORITY 2: Use clientName to find existing window assignment (for same client, new connection) if !bypassWindowRouting, chosenID == nil, let clientName = await self.clientIdentifier(forConnection: connectionID), - let windowID = await self.reusableWindowForClient(newConnectionID: connectionID, clientName: clientName) + let windowID = await self.reusableWindowForClient(newConnectionID: connectionID, clientName: clientName), + runtimeSessions.hasMCPEnabledWindow(windowID) { chosenID = windowID await self.setConnectionWindowMapping(connectionID, windowID: windowID) @@ -7290,7 +7402,9 @@ actor ServerNetworkManager { let cachedToken = await self.capabilityTokenByConnection[connectionID] let sessionKey = managerToken ?? cachedToken mcpRoutingInternalDebugLog("[PRIORITY 2b] client='\(clientName)' managerToken=\(managerToken?.prefix(8) ?? "nil") cachedToken=\(cachedToken?.prefix(8) ?? "nil") sessionKey=\(sessionKey?.prefix(8) ?? "nil")") - if let liveAffinity = await self.preferredLiveRunAffinity(for: clientName, sessionKey: sessionKey) { + if let liveAffinity = await self.preferredLiveRunAffinity(for: clientName, sessionKey: sessionKey), + runtimeSessions.hasMCPEnabledWindow(liveAffinity.windowID) + { chosenID = liveAffinity.windowID await self.setConnectionWindowMapping(connectionID, windowID: liveAffinity.windowID) _ = await self.mapConnectionToRunID( @@ -7299,7 +7413,9 @@ actor ServerNetworkManager { windowID: liveAffinity.windowID ) connectionLog("Tool call: restored live run affinity for connection \(connectionID) → runID \(liveAffinity.runID)") - } else if let preferredWindowID = await self.preferredWindowID(for: clientName, sessionKey: sessionKey) { + } else if let preferredWindowID = await self.preferredWindowID(for: clientName, sessionKey: sessionKey), + runtimeSessions.hasMCPEnabledWindow(preferredWindowID) + { chosenID = preferredWindowID await self.setConnectionWindowMapping(connectionID, windowID: preferredWindowID) connectionLog("Tool call: auto-routed connection \(connectionID) to window \(preferredWindowID) via persisted routing affinity for client '\(clientName)'") @@ -7320,7 +7436,7 @@ actor ServerNetworkManager { } if !bypassWindowRouting && chosenID == nil && (!multiWindowModeEffective || connectedDuringSingleWindow) { // Find the window with active MCP tools - let activeWindowID = await WindowStatesManager.shared.firstMCPEnabledWindow()?.windowID + let activeWindowID = runtimeSessions.firstMCPEnabledWindowID if let activeID = activeWindowID { let reason = connectedDuringSingleWindow && multiWindowModeEffective ? "single-window-at-connect" @@ -7469,7 +7585,7 @@ actor ServerNetworkManager { do { try await MainActor.run { - guard let windowState = WindowStatesManager.shared.window(withID: windowID) else { + guard let windowState = self.appSessionAdapters.window(withID: windowID) else { throw MCPError.invalidParams("Window \(windowID) not found") } let resolvedWorkspaceID = capturedTabContextHint?.workspaceID @@ -7713,43 +7829,50 @@ actor ServerNetworkManager { EditFlowPerf.Stage.MCPToolCall.serviceToolLookup, EditFlowPerf.Dimensions(toolName: toolName) ) - for service in allServices { - // App-wide coordination tools have a single owning service. Avoid probing - // unrelated window-scoped services for their tool lists during startup, - // because some of those lists hop through UI/window state. - if toolName == "bind_context", !(service is WindowRoutingService) { continue } - if toolName == AppSettingsMCPService.toolName, !(service is AppSettingsMCPService) { continue } + for indexedRoute in indexedTools.routes(forCanonicalName: toolName) { + if toolName == "bind_context", indexedRoute.role != .contextRouting { continue } + if toolName == AppSettingsMCPService.toolName, indexedRoute.role != .appSettings { continue } + let service = indexedRoute.service + let toolDef = indexedRoute.tool let wsSvc = service as? WindowScopedService + let routeWindowID: Int? = if case let .window(windowID) = indexedRoute.scope { + windowID + } else { + nil + } - // Skip window-scoped services that don't match this connection - if let wsSvc, windowCount > 1 { - guard let wID = chosenID, wID == wsSvc.windowID else { continue } + // A selected window must match exactly. Implicit fallback is allowed only + // while the captured topology remains unambiguous. + if let routeWindowID { + if let chosenID { + guard chosenID == routeWindowID else { continue } + } else { + guard windowCount <= 1 else { continue } + } + let invocationAllowed = await MainActor.run { + self.runtimeSessionRegistry.isInvocationAllowed(windowID: routeWindowID) + } + guard invocationAllowed else { continue } } - // Get the tool definition (need schema for window_id injection) - connectionLog("tools/call \(toolName): inspecting service \(String(describing: type(of: service)))") - #if DEBUG || EDIT_FLOW_PERF - let serviceToolsAwaitState = EditFlowPerf.begin(EditFlowPerf.Stage.MCPToolCall.serviceToolLookupServiceToolsAwait) - #endif - let serviceTools = await service.tools - #if DEBUG || EDIT_FLOW_PERF - EditFlowPerf.end(EditFlowPerf.Stage.MCPToolCall.serviceToolLookupServiceToolsAwait, serviceToolsAwaitState) - #endif - connectionLog("tools/call \(toolName): service \(String(describing: type(of: service))) exposes \(serviceTools.count) tools") - #if DEBUG || EDIT_FLOW_PERF - let toolDefinitionScanState = EditFlowPerf.begin(EditFlowPerf.Stage.MCPToolCall.serviceToolLookupToolDefinitionScan) - #endif - guard let toolDef = serviceTools.first(where: { $0.name == toolName }) else { - #if DEBUG || EDIT_FLOW_PERF - EditFlowPerf.end(EditFlowPerf.Stage.MCPToolCall.serviceToolLookupToolDefinitionScan, toolDefinitionScanState) - #endif - continue + @Sendable func ensureIndexedRouteStillInvocable() async throws { + let routeIsCurrent = await MainActor.run { + self.serviceRegistry.isCurrent(indexedRoute) + } + guard routeIsCurrent else { + throw MCPError.invalidParams("The MCP tool catalog changed while this call was queued. Retry the call after refreshing tools/list.") + } + guard let routeWindowID else { return } + let windowIsAllowed = await MainActor.run { + self.runtimeSessionRegistry.isInvocationAllowed(windowID: routeWindowID) + } + guard windowIsAllowed else { + throw MCPError.invalidParams("Selected MCP window is no longer available. Use bind_context op='list' to refresh window_id routing.") + } } - #if DEBUG || EDIT_FLOW_PERF - EditFlowPerf.end(EditFlowPerf.Stage.MCPToolCall.serviceToolLookupToolDefinitionScan, toolDefinitionScanState) - #endif - connectionLog("tools/call \(toolName): dispatching via service \(String(describing: type(of: service))) windowScoped=\(wsSvc != nil)") + + connectionLog("tools/call \(toolName): dispatching indexed service \(String(describing: type(of: service))) windowScoped=\(wsSvc != nil)") // Inject window_id from routing if tool schema declares it and caller didn't provide it. // bind_context manages its own window_id semantics and must not be auto-injected. @@ -7792,6 +7915,7 @@ actor ServerNetworkManager { endPermitPreDispatchEnvelopeIfNeeded() let resolvedOperation: @Sendable () async throws -> Value = { + try await ensureIndexedRouteStillInvocable() #if DEBUG if let operation = await self.debugResolvedToolOperationOverrides[toolName] { return try await operation() @@ -8387,7 +8511,7 @@ actor ServerNetworkManager { // Get live windows to also prune records pointing to closed windows let liveWindows: Set = await MainActor.run { - Set(WindowStatesManager.shared.allWindows.map(\.windowID)) + Set(runtimeSessionRegistry.routingSnapshot().orderedActiveWindowIDs) } for clientID in keys { @@ -8454,7 +8578,7 @@ actor ServerNetworkManager { let (workspaceID, instanceNumber): (UUID?, Int?) = await MainActor.run { guard let windowID, - let win = WindowStatesManager.shared.window(withID: windowID) + let win = appSessionAdapters.window(withID: windowID) else { return (nil, nil) } return (win.workspaceManager.activeWorkspace?.id, win.workspaceInstanceNumber) } @@ -8513,7 +8637,7 @@ actor ServerNetworkManager { for key in matchingClientKeys(for: clientName, in: Array(lastWindowByClientSession.keys)) { if let sessionKey, let win = lastWindowByClientSession[key]?[sessionKey] { - let exists = await WindowStatesManager.shared.hasWindow(id: win) + let exists = await MainActor.run { runtimeSessionRegistry.hasActiveWindow(id: win) } if exists { mcpRoutingInternalDebugLog("[preferredWindowID] fast path hit: client '\(clientName)' matchedKey '\(key)' window \(win)") return win @@ -8546,7 +8670,7 @@ actor ServerNetworkManager { let sortedRecords = freshRecords.sorted { $0.lastSeenAt > $1.lastSeenAt } let windowSnapshot: [(workspaceID: UUID?, instanceNumber: Int?, windowID: Int)] = await MainActor.run { - WindowStatesManager.shared.allWindows.map { + appSessionAdapters.windowStates().map { ($0.workspaceManager.activeWorkspace?.id, $0.workspaceInstanceNumber, $0.windowID) } } @@ -8858,6 +8982,9 @@ actor AsyncLimiter { private let limit: Int private var permits: Int private var waiters: [CheckedContinuation] = [] + #if DEBUG + private var debugWaiterObservers: [CheckedContinuation] = [] + #endif /// Tracks the number of tasks currently inside withPermit (including queued ones) private var inFlight: Int = 0 @@ -8887,6 +9014,11 @@ actor AsyncLimiter { await withCheckedContinuation { waiters.append($0) notifyDebugStateChanged() + #if DEBUG + let observers = debugWaiterObservers + debugWaiterObservers.removeAll() + observers.forEach { $0.resume() } + #endif } // When resumed, the caller now has a permit (recycled from a release) } @@ -8921,6 +9053,11 @@ actor AsyncLimiter { observer?(makeDebugSnapshot()) } + func debugWaitForWaiter() async { + guard waiters.isEmpty else { return } + await withCheckedContinuation { debugWaiterObservers.append($0) } + } + private func makeDebugSnapshot() -> DebugSnapshot { DebugSnapshot( permits: permits, diff --git a/Sources/RepoPrompt/Infrastructure/MCP/MCPExternalEventsMonitor.swift b/Sources/RepoPrompt/Infrastructure/MCP/MCPExternalEventsMonitor.swift index dbacb5d2d..7a0342e6d 100644 --- a/Sources/RepoPrompt/Infrastructure/MCP/MCPExternalEventsMonitor.swift +++ b/Sources/RepoPrompt/Infrastructure/MCP/MCPExternalEventsMonitor.swift @@ -1,6 +1,7 @@ import Combine import Darwin import Foundation +import RepoPromptPOSIXSupport import RepoPromptShared /// Monitors the MCP events directory for error events written by external CLI clients. diff --git a/Sources/RepoPrompt/Infrastructure/MCP/MCPRuntimeSessionRegistry.swift b/Sources/RepoPrompt/Infrastructure/MCP/MCPRuntimeSessionRegistry.swift new file mode 100644 index 000000000..059a4e378 --- /dev/null +++ b/Sources/RepoPrompt/Infrastructure/MCP/MCPRuntimeSessionRegistry.swift @@ -0,0 +1,248 @@ +import Foundation + +/// MCP routing projection for reusable runtime sessions. +/// +/// Compatibility snapshots retain `windowID` terminology while existing MCP +/// schemas continue to expose `window_id`. Internally these values are routing +/// session IDs and no longer retain app windows. +@MainActor +final class MCPRuntimeSessionRegistry { + enum RegistrationResult: Equatable { + case accepted + case alreadyRegistered + case routingIDInUse + case retiredRoutingID + } + + enum Lifecycle { + case active + case draining + } + + struct RoutingSnapshot { + let generation: UInt64 + let orderedActiveWindowIDs: [Int] + let mcpEnabledWindowIDs: Set + + var activeWindowCount: Int { + orderedActiveWindowIDs.count + } + + var isMultiWindowModeEffectivelyActive: Bool { + activeWindowCount > 1 + } + + var firstMCPEnabledWindowID: Int? { + orderedActiveWindowIDs.first { mcpEnabledWindowIDs.contains($0) } + } + + func hasActiveWindow(_ windowID: Int) -> Bool { + orderedActiveWindowIDs.contains(windowID) + } + + func hasMCPEnabledWindow(_ windowID: Int) -> Bool { + hasActiveWindow(windowID) && mcpEnabledWindowIDs.contains(windowID) + } + } + + private final class Entry { + let windowID: Int + let sessionID: RepoPromptSessionID + weak var session: RepoPromptCoreSession? + var lifecycle: Lifecycle + var isMCPEnabled: Bool + + init(session: RepoPromptCoreSession, isMCPEnabled: Bool) { + windowID = session.routingSessionID.rawValue + sessionID = session.sessionID + self.session = session + lifecycle = .active + self.isMCPEnabled = isMCPEnabled + } + } + + private struct PendingEnable { + let sessionID: RepoPromptSessionID? + let enabled: Bool + } + + private var entriesByID: [Int: Entry] = [:] + private var orderedIDs: [Int] = [] + private var pendingEnabledByUnknownID: [Int: PendingEnable] = [:] + private var retiredIDs: Set = [] + private var generation: UInt64 = 0 + + nonisolated init() {} + + @discardableResult + func register(session: RepoPromptCoreSession) -> RegistrationResult { + let windowID = session.routingSessionID.rawValue + guard !retiredIDs.contains(windowID) else { return .retiredRoutingID } + if let existing = entriesByID[windowID] { + guard existing.sessionID == session.sessionID else { return .routingIDInUse } + guard existing.lifecycle == .active else { return .routingIDInUse } + existing.session = session + return .alreadyRegistered + } + + let pendingEnable = pendingEnabledByUnknownID.removeValue(forKey: windowID) + let isEnabled = if let pendingEnable, + pendingEnable.sessionID == nil || pendingEnable.sessionID == session.sessionID + { + pendingEnable.enabled + } else { + false + } + entriesByID[windowID] = Entry(session: session, isMCPEnabled: isEnabled) + orderedIDs.append(windowID) + generation &+= 1 + return .accepted + } + + func setMCPEnabled(windowID: Int, enabled: Bool) { + _ = setMCPEnabled(windowID: windowID, expectedSessionID: nil, enabled: enabled) + } + + @discardableResult + func setMCPEnabled( + windowID: Int, + expectedSessionID: RepoPromptSessionID, + enabled: Bool + ) -> Bool { + setMCPEnabled(windowID: windowID, expectedSessionID: Optional(expectedSessionID), enabled: enabled) + } + + private func setMCPEnabled( + windowID: Int, + expectedSessionID: RepoPromptSessionID?, + enabled: Bool + ) -> Bool { + if retiredIDs.contains(windowID) { + return false + } + guard let entry = entriesByID[windowID] else { + if let pending = pendingEnabledByUnknownID[windowID] { + if let expectedSessionID, + let pendingSessionID = pending.sessionID, + pendingSessionID != expectedSessionID + { + return false + } + if expectedSessionID == nil, pending.sessionID != nil { + return false + } + guard pending.enabled != enabled || pending.sessionID != expectedSessionID else { return true } + } + pendingEnabledByUnknownID[windowID] = PendingEnable( + sessionID: expectedSessionID, + enabled: enabled + ) + generation &+= 1 + return true + } + if let expectedSessionID, entry.sessionID != expectedSessionID { return false } + if entry.lifecycle == .draining, enabled { + return false + } + guard entry.isMCPEnabled != enabled else { return true } + entry.isMCPEnabled = enabled + generation &+= 1 + return true + } + + @discardableResult + func beginDraining(windowID: Int, expectedSessionID: RepoPromptSessionID) -> Bool { + guard let entry = entriesByID[windowID], + entry.sessionID == expectedSessionID + else { + return false + } + guard entry.lifecycle == .active else { return true } + entry.lifecycle = .draining + entry.isMCPEnabled = false + generation &+= 1 + return true + } + + @discardableResult + func remove(windowID: Int, expectedSessionID: RepoPromptSessionID) -> Bool { + guard let entry = entriesByID[windowID], + entry.sessionID == expectedSessionID + else { + return false + } + entriesByID.removeValue(forKey: windowID) + orderedIDs.removeAll { $0 == windowID } + generation &+= 1 + pendingEnabledByUnknownID.removeValue(forKey: windowID) + retiredIDs.insert(windowID) + return true + } + + func routingSnapshot() -> RoutingSnapshot { + let activeIDs = orderedIDs.filter { windowID in + guard let entry = entriesByID[windowID], + entry.lifecycle == .active, + entry.session != nil + else { + return false + } + return true + } + let enabledIDs = Set(activeIDs.filter { entriesByID[$0]?.isMCPEnabled == true }) + return RoutingSnapshot( + generation: generation, + orderedActiveWindowIDs: activeIDs, + mcpEnabledWindowIDs: enabledIDs + ) + } + + func session(withRoutingID windowID: Int, includeDraining: Bool = false) -> RepoPromptCoreSession? { + guard let entry = entriesByID[windowID], + includeDraining || entry.lifecycle == .active + else { + return nil + } + return entry.session + } + + func sessions(includeDraining: Bool = false) -> [RepoPromptCoreSession] { + orderedIDs.compactMap { session(withRoutingID: $0, includeDraining: includeDraining) } + } + + func hasActiveWindow(id windowID: Int) -> Bool { + session(withRoutingID: windowID) != nil + } + + func hasActiveSession(windowID: Int, expectedSessionID: RepoPromptSessionID) -> Bool { + guard let entry = entriesByID[windowID], + entry.sessionID == expectedSessionID, + entry.lifecycle == .active, + entry.session != nil + else { + return false + } + return true + } + + func hasMCPEnabledWindow(id windowID: Int) -> Bool { + guard let entry = entriesByID[windowID], + entry.lifecycle == .active, + entry.isMCPEnabled, + entry.session != nil + else { + return false + } + return true + } + + func isInvocationAllowed(windowID: Int) -> Bool { + hasMCPEnabledWindow(id: windowID) + } + + #if DEBUG + func debugIsRetired(windowID: Int) -> Bool { + retiredIDs.contains(windowID) + } + #endif +} diff --git a/Sources/RepoPrompt/Infrastructure/MCP/MCPService.swift b/Sources/RepoPrompt/Infrastructure/MCP/MCPService.swift index 651a5c1bd..b3decc7ba 100644 --- a/Sources/RepoPrompt/Infrastructure/MCP/MCPService.swift +++ b/Sources/RepoPrompt/Infrastructure/MCP/MCPService.swift @@ -22,6 +22,18 @@ import SwiftUI /// Background actor that manages the single MCP server instance and handles all networking/file I/O operations. /// This actor ensures that no long-running network or file-system work ever executes on @MainActor. actor MCPService: Sendable { + struct ListenerOperations: @unchecked Sendable { + let start: @Sendable () async throws -> Void + let stop: @Sendable () async -> Void + let fullShutdown: @Sendable () async -> Void + + static let live = ListenerOperations( + start: { await ServerController.shared.startServer() }, + stop: { await ServerController.shared.stopServer() }, + fullShutdown: { await ServerController.shared.fullShutdown() } + ) + } + // ────────────────────────────────────────────── // MARK: - Public state that the UI may query @@ -77,22 +89,51 @@ actor MCPService: Sendable { /// ────────────────────────────────────────────── private let controller = ServerController.shared + private let listenerOperations: ListenerOperations + private let participationEligibility: @Sendable (Int) async -> Bool + nonisolated let networkManager: ServerNetworkManager + + nonisolated var runtimeSessionRegistry: MCPRuntimeSessionRegistry { + networkManager.runtimeSessionRegistry + } + + nonisolated var serviceRegistry: MCPServiceRegistry { + networkManager.serviceRegistry + } - /// Tracks which windows are participating in MCP - private var participatingWindows = Set() + /// Desired participation is authoritative; actual listener state is reconciled serially. + private var desiredParticipatingWindows = Set() + private var participationRequestGeneration: UInt64 = 0 + private var latestParticipationRequestByWindow: [Int: UInt64] = [:] + private var listenerReconciliation: (id: UUID, task: Task)? + private var terminalShutdown = false // ────────────────────────────────────────────── // MARK: - Initialization /// ────────────────────────────────────────────── - init() { + init( + networkManager: ServerNetworkManager = .shared, + listenerOperations: ListenerOperations = .live, + participationEligibility: (@Sendable (Int) async -> Bool)? = nil, + configureControllerCallbacks: Bool = true + ) { + self.networkManager = networkManager + self.listenerOperations = listenerOperations + let runtimeSessionRegistry = networkManager.runtimeSessionRegistry + self.participationEligibility = participationEligibility ?? { windowID in + await MainActor.run { + runtimeSessionRegistry.hasMCPEnabledWindow(id: windowID) + } + } + guard configureControllerCallbacks else { return } // Set up the approval request callback Task { await controller.setMCPService(self) await controller.setApprovalCallback { [weak self] clientID in await self?.setPendingApproval(clientID) } - await ServerNetworkManager.shared.setDashboardDidChangeHook { [weak self] in + await networkManager.setDashboardDidChangeHook { [weak self] in Task { await self?.notifyDashboardUpdate() } } } @@ -103,11 +144,11 @@ actor MCPService: Sendable { /// ────────────────────────────────────────────── func start() async throws { - guard !state.isRunning else { return } + guard !terminalShutdown, !state.isRunning else { return } // One-time Codex migration: no-op when the RepoPrompt entry or config file is missing. _ = MCPIntegrationHelper.ensureCodexToolTimeout() mcpServiceLog("Starting MCP listener") - await controller.startServer() + try await listenerOperations.start() state.isRunning = true updates.continuation.yield(state) } @@ -115,33 +156,104 @@ actor MCPService: Sendable { func stop() async { guard state.isRunning else { return } mcpServiceLog("Stopping MCP listener") - await controller.stopServer() + await listenerOperations.stop() state.isRunning = false updates.continuation.yield(state) } func join(windowID: Int) async throws { - let inserted = participatingWindows.insert(windowID).inserted - mcpServiceLog("Window \(windowID) joining MCP (new: \(inserted), total: \(participatingWindows.count))") + try await reconcileParticipation(windowID: windowID, propagatesStartFailure: true) + } + + func leave(windowID: Int) async { + do { + try await reconcileParticipation(windowID: windowID, propagatesStartFailure: false) + } catch { + mcpServiceLog("Failed to reconcile MCP leave for window \(windowID): \(error)") + } + } - if inserted, participatingWindows.count == 1 { - try await start() // start() already yields + private func reconcileParticipation(windowID: Int, propagatesStartFailure: Bool) async throws { + participationRequestGeneration &+= 1 + let requestGeneration = participationRequestGeneration + latestParticipationRequestByWindow[windowID] = requestGeneration + + let isEligible = await participationEligibility(windowID) + guard !terminalShutdown, + latestParticipationRequestByWindow[windowID] == requestGeneration + else { + mcpServiceLog("Ignoring stale MCP participation request for window \(windowID)") + updates.continuation.yield(state) + return } - // Always re-broadcast so newly-joined windows get an up-to-date snapshot + if isEligible { + desiredParticipatingWindows.insert(windowID) + } else { + desiredParticipatingWindows.remove(windowID) + } + mcpServiceLog( + "Window \(windowID) participation reconciled (eligible: \(isEligible), desired total: \(desiredParticipatingWindows.count))" + ) + + do { + try await settleListenerState() + } catch { + updates.continuation.yield(state) + if propagatesStartFailure { throw error } + } + + // Always re-broadcast so callers receive an up-to-date snapshot even for idempotent work. updates.continuation.yield(state) } - func leave(windowID: Int) async { - let removed = participatingWindows.remove(windowID) != nil - mcpServiceLog("Window \(windowID) leaving MCP (removed: \(removed), remaining: \(participatingWindows.count))") + private func settleListenerState() async throws { + while true { + let reconciliation: (id: UUID, task: Task) + if let existing = listenerReconciliation { + reconciliation = existing + } else { + guard state.isRunning != (!desiredParticipatingWindows.isEmpty && !terminalShutdown) else { + return + } + let id = UUID() + let task = Task { [weak self] in + guard let self else { return } + try await performNextListenerTransition() + } + reconciliation = (id, task) + listenerReconciliation = reconciliation + } - if participatingWindows.isEmpty { - await stop() // stop() already yields + do { + try await reconciliation.task.value + } catch { + if listenerReconciliation?.id == reconciliation.id { + listenerReconciliation = nil + } + if desiredParticipatingWindows.isEmpty || terminalShutdown { + continue + } + throw error + } + if listenerReconciliation?.id == reconciliation.id { + listenerReconciliation = nil + } } + } - // Broadcast even if nothing else changed so UI stays in sync - updates.continuation.yield(state) + private func performNextListenerTransition() async throws { + let shouldRun = !desiredParticipatingWindows.isEmpty && !terminalShutdown + if shouldRun, !state.isRunning { + do { + try await start() + } catch { + guard !desiredParticipatingWindows.isEmpty, !terminalShutdown else { return } + throw error + } + } else if !shouldRun, state.isRunning { + await stop() + } } /// Force a state refresh (useful when UI needs immediate update) @@ -174,18 +286,14 @@ actor MCPService: Sendable { func fullShutdown() async { mcpServiceLog("Performing full MCP server shutdown") - participatingWindows.removeAll() + terminalShutdown = true + desiredParticipatingWindows.removeAll() + participationRequestGeneration &+= 1 + latestParticipationRequestByWindow.removeAll() + try? await settleListenerState() + await listenerOperations.fullShutdown() state.isRunning = false - updates.continuation.yield(state) - - await controller.fullShutdown() - - // Preserve participation registered while the controller shutdown was suspended. - // Such a join represents a newer lifecycle and must be made ready again. - if !participatingWindows.isEmpty { - await controller.startServer() - state.isRunning = true - } + terminalShutdown = false updates.continuation.yield(state) } diff --git a/Sources/RepoPrompt/Infrastructure/MCP/MCPToolCatalogReadiness.swift b/Sources/RepoPrompt/Infrastructure/MCP/MCPToolCatalogReadiness.swift index f67b18f44..cacdc0a5b 100644 --- a/Sources/RepoPrompt/Infrastructure/MCP/MCPToolCatalogReadiness.swift +++ b/Sources/RepoPrompt/Infrastructure/MCP/MCPToolCatalogReadiness.swift @@ -25,9 +25,19 @@ private let log = Logger(label: "com.repoprompt.mcp.readiness") /// Ensures that before a connection can list tools, all required services /// are registered and their tools are built. actor MCPToolCatalogReadiness { - static let shared = MCPToolCatalogReadiness() - - private init() {} + private let runtimeSessionRegistry: MCPRuntimeSessionRegistry + private let serviceRegistry: MCPServiceRegistry + private let appSessionAdapters: RepoPromptAppSessionAdapterRegistry + + init( + runtimeSessionRegistry: MCPRuntimeSessionRegistry, + serviceRegistry: MCPServiceRegistry, + appSessionAdapters: RepoPromptAppSessionAdapterRegistry + ) { + self.runtimeSessionRegistry = runtimeSessionRegistry + self.serviceRegistry = serviceRegistry + self.appSessionAdapters = appSessionAdapters + } /// Default timeout for readiness wait static let defaultTimeout: TimeInterval = 5.0 @@ -71,7 +81,7 @@ actor MCPToolCatalogReadiness { func warmToolCache(windowID: Int) async { // Get the MCPServerViewModel on MainActor let mcpServer: MCPServerViewModel? = await MainActor.run { - WindowStatesManager.shared.window(withID: windowID)?.mcpServer + appSessionAdapters.window(withID: windowID)?.mcpServer } guard let mcpServer else { @@ -96,7 +106,9 @@ actor MCPToolCatalogReadiness { private func checkServicesReady(windowID: Int?) -> Bool { if let windowID { // Check if the window exists - guard let window = WindowStatesManager.shared.window(withID: windowID) else { + guard runtimeSessionRegistry.hasActiveWindow(id: windowID), + let window = appSessionAdapters.window(withID: windowID) + else { mcpToolCatalogReadinessLog("Window \(windowID) not found during readiness check") return false } @@ -108,9 +120,11 @@ actor MCPToolCatalogReadiness { } } - // Always require WindowRoutingService to be registered (provides routing tools) - let hasRoutingService = ServiceRegistry.services.contains { service in - service is WindowRoutingService + // Always require WindowRoutingService to be committed in the indexed catalog (provides routing tools). + let indexedToolSnapshot = serviceRegistry.routeSnapshot() + let hasRoutingService = indexedToolSnapshot.orderedRoutes.contains { route in + if case .contextRouting = route.role { return true } + return false } if !hasRoutingService { @@ -123,14 +137,15 @@ actor MCPToolCatalogReadiness { return true } - // Check if the window's catalog service is registered. - let catalogService = WindowStatesManager.shared.window(withID: windowID)?.mcpServer.windowMCPToolCatalogService - let isWindowServiceRegistered = ServiceRegistry.services.contains { service in - guard let catalogService else { return false } - return (service as AnyObject) === (catalogService as AnyObject) + // Check if the window's catalog service is committed in the indexed catalog. + let catalogService = appSessionAdapters.window(withID: windowID)?.mcpServer.windowMCPToolCatalogService + let isWindowServiceCommitted: Bool = if let catalogService { + serviceRegistry.committedSnapshotContains(catalogService) + } else { + false } - if !isWindowServiceRegistered { + if !isWindowServiceCommitted { mcpToolCatalogReadinessLog("MCPWindowToolCatalogService for window \(windowID) not yet registered") return false } diff --git a/Sources/RepoPrompt/Infrastructure/MCP/MCPToolNameCanonicalizer.swift b/Sources/RepoPrompt/Infrastructure/MCP/MCPToolNameCanonicalizer.swift new file mode 100644 index 000000000..6fde5c206 --- /dev/null +++ b/Sources/RepoPrompt/Infrastructure/MCP/MCPToolNameCanonicalizer.swift @@ -0,0 +1,11 @@ +enum MCPToolNameCanonicalizer { + private static let aliases: [String: String] = [ + "discover_manage_selection": "manage_selection", + "discover_prompt": "prompt", + "discover_workspace_context": "workspace_context" + ] + + static func canonicalName(for name: String) -> String { + aliases[name] ?? name + } +} diff --git a/Sources/RepoPrompt/Infrastructure/MCP/ServerController.swift b/Sources/RepoPrompt/Infrastructure/MCP/ServerController.swift index 2dd50f74e..0f14ad04b 100644 --- a/Sources/RepoPrompt/Infrastructure/MCP/ServerController.swift +++ b/Sources/RepoPrompt/Infrastructure/MCP/ServerController.swift @@ -1,3 +1,6 @@ +import RepoPromptCore +import RepoPromptCoreMacOS + // // ServerController.swift // RepoPrompt @@ -6,7 +9,6 @@ // import AppKit -import Darwin import Foundation import Logging import OSLog @@ -58,6 +60,7 @@ final actor ServerController: ObservableObject { // ––––– Private implementation helpers ––––– private let networkManager = ServerNetworkManager.shared + private let bundledHelperPeerVerifier: any BundledHelperPeerVerifying private var activeApprovalDialogs: Set = [] private var pendingApprovals: [(String, () -> Void, () -> Void)] = [] @@ -79,7 +82,8 @@ final actor ServerController: ObservableObject { } /// ––––– Init: wire approval-flow & kick off the listener ––––– - init() { + init(bundledHelperPeerVerifier: any BundledHelperPeerVerifying = MacOSBundledHelperPeerVerifier()) { + self.bundledHelperPeerVerifier = bundledHelperPeerVerifier Task { [weak self] in await self?.bootstrapCallbacks() } @@ -255,6 +259,10 @@ final actor ServerController: ObservableObject { static func test_sanitizedAlwaysAllowedClients(_ clients: Set) -> Set { sanitizedAlwaysAllowedClients(clients) } + + nonisolated static func test_bundledHelperPathMatches(expectedURL: URL, actualPath: String) -> Bool { + MacOSBundledHelperPeerVerifier.pathsMatch(expectedURL: expectedURL, actualPath: actualPath) + } #endif /// Returns true iff the connecting process matches the app-bundled `repoprompt-mcp` executable. @@ -265,19 +273,10 @@ final actor ServerController: ObservableObject { guard let peerPID = await networkManager.peerPID(for: connectionID) else { return false } - guard let actualPath = Self.executablePath(forPID: peerPID) else { - return false - } - let expected = expectedURL.resolvingSymlinksInPath().standardizedFileURL.path - let actual = URL(fileURLWithPath: actualPath).resolvingSymlinksInPath().standardizedFileURL.path - return actual == expected - } - - private nonisolated static func executablePath(forPID pid: Int) -> String? { - var buffer = [CChar](repeating: 0, count: 4096) - let result = proc_pidpath(pid_t(pid), &buffer, UInt32(buffer.count)) - guard result > 0 else { return nil } - return String(cString: buffer) + return bundledHelperPeerVerifier.matches(BundledHelperPeerVerificationInput( + expectedExecutableURL: expectedURL, + peerPID: peerPID + )) } private func addAlwaysAllowed(clientID: String) { diff --git a/Sources/RepoPrompt/Infrastructure/MCP/ServiceRegistry.swift b/Sources/RepoPrompt/Infrastructure/MCP/ServiceRegistry.swift index 4ee456ee0..b97ec1b47 100644 --- a/Sources/RepoPrompt/Infrastructure/MCP/ServiceRegistry.swift +++ b/Sources/RepoPrompt/Infrastructure/MCP/ServiceRegistry.swift @@ -5,47 +5,314 @@ // Created by Eric Provencher on 2025-06-20. // -/// Central registry for all `Service` instances that want to expose tools -/// to external MCP clients. Services register themselves at runtime. +/// Instance-owned registry for MCP tool providers. Mutable state stays on the main actor; +/// consumers receive immutable snapshots so request hot paths never scan service catalogs. @MainActor -enum ServiceRegistry { - private static var _services: [any Service] = [] +final class MCPServiceRegistry { + enum Scope: Equatable { + case host + case window(Int) + } - /// Read-only view of all registered services. - static var services: [any Service] { - _services + enum Role { + case ordinary + case contextRouting + case appSettings } - /// Register a new service so its tools become discoverable. - static func register(_ service: any Service) { - // Avoid duplicate registrations - if _services.contains(where: { $0 as AnyObject === service as AnyObject }) { - return + /// Immutable after publication. The service revision is advanced synchronously on every + /// catalog invalidation so queued calls can reject routes captured from an older catalog. + struct IndexedToolRoute: @unchecked Sendable { + let serviceIdentity: ObjectIdentifier + let catalogRevision: UInt64 + let toolIndex: Int + let service: any Service + let scope: Scope + let role: Role + let tool: Tool + } + + /// Immutable generation-fenced route catalog safe to hand from MainActor to connection actors. + struct Snapshot: @unchecked Sendable { + let generation: UInt64 + let orderedRoutes: [IndexedToolRoute] + let routesByCanonicalName: [String: [IndexedToolRoute]] + + func routes(forCanonicalName name: String) -> [IndexedToolRoute] { + routesByCanonicalName[name] ?? [] } - _services.append(service) - // Inform the availability store so the Settings UI can list them - Task { + } + + /// Fixed listing boundary. Services registered after capture cannot delay or appear in the + /// result, while every service already registered at capture is awaited and represented. + struct PublicationBoundary: @unchecked Sendable { + fileprivate let generation: UInt64 + fileprivate let publications: [ServicePublication] + } + + private struct RegisteredService { + let identity: ObjectIdentifier + let service: any Service + var catalogRevision: UInt64 + } + + fileprivate struct ServicePublication: @unchecked Sendable { + let identity: ObjectIdentifier + let service: any Service + let catalogRevision: UInt64 + let scope: Scope + let role: Role + } + + private var registeredServices: [RegisteredService] = [] + private var requestedGeneration: UInt64 = 0 + private var nextCatalogRevision: UInt64 = 0 + private var snapshotNeedsRebuild = false + private var committedSnapshot = Snapshot(generation: 0, orderedRoutes: [], routesByCanonicalName: [:]) + private var snapshotDidChangeSink: (@Sendable () async -> Void)? + private var hasPendingSnapshotChangeNotification = false + + nonisolated init() {} + + var services: [any Service] { + registeredServices.map(\.service) + } + + func setSnapshotDidChangeSink(_ sink: @escaping @Sendable () async -> Void) { + snapshotDidChangeSink = sink + guard hasPendingSnapshotChangeNotification else { return } + hasPendingSnapshotChangeNotification = false + Task { await sink() } + } + + func contains(_ service: any Service) -> Bool { + let identity = ObjectIdentifier(service as AnyObject) + return registeredServices.contains { $0.identity == identity } + } + + /// Register a new service so its tools become discoverable. + func register(_ service: any Service) { + guard !contains(service) else { return } + invalidateSnapshot() + nextCatalogRevision &+= 1 + let registrationRevision = nextCatalogRevision + let serviceIdentity = ObjectIdentifier(service as AnyObject) + registeredServices.append(RegisteredService( + identity: serviceIdentity, + service: service, + catalogRevision: registrationRevision + )) + let registrationGeneration = requestedGeneration + + Task { @MainActor [weak self] in #if DEBUG || EDIT_FLOW_PERF let serviceTools = await EditFlowPerf.measure(EditFlowPerf.Stage.MCPWindowToolCatalog.serviceRegistryToolsPublication) { await service.tools } - await ToolAvailabilityStore.shared.registerTools(serviceTools) #else - await ToolAvailabilityStore.shared.registerTools(service.tools) + let serviceTools = await service.tools #endif - // Tools list has effectively changed; notify connected clients - await ServerNetworkManager.shared.broadcastToolListChanged() + guard let self, + isCurrent(serviceIdentity: serviceIdentity, catalogRevision: registrationRevision) + else { + return + } + ToolAvailabilityStore.shared.registerTools(serviceTools) + await publishSnapshotChangeIfCurrent(expectedGeneration: registrationGeneration) } } - /// Unregister a service to remove its tools. - static func unregister(_ service: any Service) { - if let idx = _services.firstIndex(where: { $0 as AnyObject === service as AnyObject }) { - _services.remove(at: idx) - // Broadcast tool list change to connected clients - Task { - await ServerNetworkManager.shared.broadcastToolListChanged() + /// Invalidate a registered service after its cached catalog changes. The per-service revision + /// advances before async publication so already-queued routes become stale immediately. + func invalidateCatalog(for service: any Service) { + let identity = ObjectIdentifier(service as AnyObject) + guard let index = registeredServices.firstIndex(where: { $0.identity == identity }) else { return } + invalidateSnapshot() + nextCatalogRevision &+= 1 + registeredServices[index].catalogRevision = nextCatalogRevision + scheduleSnapshotChangePublication(expectedGeneration: requestedGeneration) + } + + /// Unregister a service and synchronously remove its committed routes. + func unregister(_ service: any Service) { + let serviceIdentity = ObjectIdentifier(service as AnyObject) + guard let index = registeredServices.firstIndex(where: { $0.identity == serviceIdentity }) else { + return + } + registeredServices.remove(at: index) + invalidateSnapshot() + committedSnapshot = Self.snapshot( + generation: requestedGeneration, + routes: committedSnapshot.orderedRoutes.filter { $0.serviceIdentity != serviceIdentity } + ) + scheduleSnapshotChangePublication(expectedGeneration: requestedGeneration) + } + + /// Returns the last committed immutable index without rebuilding on a request hot path. + func routeSnapshot() -> Snapshot { + committedSnapshot + } + + /// Captures the exact set of registered catalogs a listing operation must represent. + func capturePublicationBoundary() -> PublicationBoundary { + PublicationBoundary( + generation: requestedGeneration, + publications: registeredServices.map(Self.publication(for:)) + ) + } + + /// Resolves a fixed publication boundary without chasing services registered afterward. + func snapshot(for boundary: PublicationBoundary) async -> Snapshot { + let routes = await Self.routes(for: boundary.publications) + ToolAvailabilityStore.shared.registerTools(routes.map(\.tool)) + return Self.snapshot(generation: boundary.generation, routes: routes) + } + + /// Rebuilds eagerly after registration or invalidation and commits only the newest generation. + func awaitCurrentSnapshot() async -> Snapshot { + while snapshotNeedsRebuild { + let boundary = capturePublicationBoundary() + let snapshot = await snapshot(for: boundary) + + guard boundary.generation == requestedGeneration, + boundaryStillCurrent(boundary) + else { + continue } + committedSnapshot = snapshot + snapshotNeedsRebuild = false } + return committedSnapshot + } + + func committedSnapshotContains(_ service: any Service) -> Bool { + let identity = ObjectIdentifier(service as AnyObject) + return committedSnapshot.orderedRoutes.contains { $0.serviceIdentity == identity } + } + + func isRegistered(serviceIdentity: ObjectIdentifier) -> Bool { + registeredServices.contains { $0.identity == serviceIdentity } + } + + /// Validates the exact service catalog revision captured by an indexed route. Global registry + /// changes for unrelated services do not invalidate the route. + func isCurrent(_ route: IndexedToolRoute) -> Bool { + isCurrent(serviceIdentity: route.serviceIdentity, catalogRevision: route.catalogRevision) + } + + #if DEBUG + var debugRequestedGeneration: UInt64 { + requestedGeneration + } + #endif + + private func invalidateSnapshot() { + requestedGeneration &+= 1 + snapshotNeedsRebuild = true + } + + private func boundaryStillCurrent(_ boundary: PublicationBoundary) -> Bool { + guard boundary.publications.count == registeredServices.count else { return false } + return zip(boundary.publications, registeredServices).allSatisfy { publication, registered in + publication.identity == registered.identity + && publication.catalogRevision == registered.catalogRevision + } + } + + private func isCurrent(serviceIdentity: ObjectIdentifier, catalogRevision: UInt64) -> Bool { + registeredServices.contains { + $0.identity == serviceIdentity && $0.catalogRevision == catalogRevision + } + } + + private func scheduleSnapshotChangePublication(expectedGeneration: UInt64) { + Task { @MainActor [weak self] in + await self?.publishSnapshotChangeIfCurrent(expectedGeneration: expectedGeneration) + } + } + + private func publishSnapshotChangeIfCurrent(expectedGeneration: UInt64) async { + let snapshot = await awaitCurrentSnapshot() + guard expectedGeneration == requestedGeneration, + snapshot.generation == expectedGeneration + else { + return + } + if let snapshotDidChangeSink { + await snapshotDidChangeSink() + } else { + hasPendingSnapshotChangeNotification = true + } + } + + private static func publication(for registered: RegisteredService) -> ServicePublication { + let service = registered.service + let scope: Scope = if let windowScoped = service as? WindowScopedService { + .window(windowScoped.windowID) + } else { + .host + } + let role: Role = if service is WindowRoutingService { + .contextRouting + } else if service is AppSettingsMCPService { + .appSettings + } else { + .ordinary + } + return ServicePublication( + identity: registered.identity, + service: service, + catalogRevision: registered.catalogRevision, + scope: scope, + role: role + ) + } + + private static func routes(for publications: [ServicePublication]) async -> [IndexedToolRoute] { + var routes: [IndexedToolRoute] = [] + for publication in publications { + for (toolIndex, tool) in await publication.service.tools.enumerated() { + routes.append(IndexedToolRoute( + serviceIdentity: publication.identity, + catalogRevision: publication.catalogRevision, + toolIndex: toolIndex, + service: publication.service, + scope: publication.scope, + role: publication.role, + tool: tool + )) + } + } + return routes + } + + private static func snapshot(generation: UInt64, routes: [IndexedToolRoute]) -> Snapshot { + var routesByCanonicalName: [String: [IndexedToolRoute]] = [:] + for route in routes { + let canonicalName = MCPToolNameCanonicalizer.canonicalName(for: route.tool.name) + routesByCanonicalName[canonicalName, default: []].append(route) + } + return Snapshot( + generation: generation, + orderedRoutes: routes, + routesByCanonicalName: routesByCanonicalName + ) + } +} + +/// Transitional forwarding facade for legacy tests and audited call sites. +/// Production MCP paths should use the manager-owned `MCPServiceRegistry` instance. +@MainActor +enum ServiceRegistry { + static var services: [any Service] { + ServerNetworkManager.shared.serviceRegistry.services + } + + static func register(_ service: any Service) { + ServerNetworkManager.shared.serviceRegistry.register(service) + } + + static func unregister(_ service: any Service) { + ServerNetworkManager.shared.serviceRegistry.unregister(service) } } diff --git a/Sources/RepoPrompt/Infrastructure/MCP/ViewModels/MCPServerViewModel+SelectionReply.swift b/Sources/RepoPrompt/Infrastructure/MCP/ViewModels/MCPServerViewModel+SelectionReply.swift index 771bc0a9a..36bb9aa39 100644 --- a/Sources/RepoPrompt/Infrastructure/MCP/ViewModels/MCPServerViewModel+SelectionReply.swift +++ b/Sources/RepoPrompt/Infrastructure/MCP/ViewModels/MCPServerViewModel+SelectionReply.swift @@ -79,7 +79,9 @@ extension MCPServerViewModel { rootScope: .allLoaded, pathLocateProfile: .uiAssisted ) - let accounting = await accountingService.calculatePromptStats(request: request, store: store) + guard let accounting = try? await accountingService.calculatePromptStats(request: request, store: store) else { + return .empty + } let service = TokenCalculationService() return await service.evaluatePromptEntries(accounting.promptFileEntrySnapshots) } diff --git a/Sources/RepoPrompt/Infrastructure/MCP/ViewModels/MCPServerViewModel.swift b/Sources/RepoPrompt/Infrastructure/MCP/ViewModels/MCPServerViewModel.swift index 0fceed7cb..8ead0fb06 100644 --- a/Sources/RepoPrompt/Infrastructure/MCP/ViewModels/MCPServerViewModel.swift +++ b/Sources/RepoPrompt/Infrastructure/MCP/ViewModels/MCPServerViewModel.swift @@ -165,7 +165,11 @@ final class MCPServerViewModel: ObservableObject { // --------------------------------------------------------------------- let windowID: Int + let coreSessionHandle: RepoPromptCoreSessionHandle private(set) var service: MCPService + private let runtimeSessionRegistry: MCPRuntimeSessionRegistry + private let serviceRegistry: MCPServiceRegistry + private let appSessionAdapters: RepoPromptAppSessionAdapterRegistry private let logger = Logger(label: "com.repoprompt.mcp") private var oracleToolService: MCPOracleToolService { @@ -486,6 +490,11 @@ final class MCPServerViewModel: ObservableObject { /// Whether this window's tools are enabled @Published var windowToolsEnabled: Bool = false { didSet { + runtimeSessionRegistry.setMCPEnabled( + windowID: windowID, + expectedSessionID: coreSessionHandle.sessionID, + enabled: windowToolsEnabled + ) updateDashboardSubscriptionIfNeeded() recomputeCloseSafetyState() #if DEBUG || EDIT_FLOW_PERF @@ -910,7 +919,7 @@ final class MCPServerViewModel: ObservableObject { var activeTabCompatibilityFallbackDiagnostics: [ActiveTabCompatibilityFallbackDiagnostic] = [] var isMultiWindowModeEffectivelyActive: Bool { - WindowStatesManager.shared.isMultiWindowModeEffectivelyActive + runtimeSessionRegistry.routingSnapshot().isMultiWindowModeEffectivelyActive } @MainActor @@ -1608,12 +1617,18 @@ final class MCPServerViewModel: ObservableObject { oracleVM: OracleViewModel, workspaceManager: WorkspaceManagerViewModel, selectionCoordinator: WorkspaceSelectionCoordinator? = nil, + coreSessionHandle: RepoPromptCoreSessionHandle, + appSessionAdapters: RepoPromptAppSessionAdapterRegistry = .shared, windowID: Int, workspaceSearch: @escaping WorkspaceSearchHandler, ensureGitDataRootLoaded: @escaping (WorkspaceModel?, WorkspaceManagerViewModel?) async -> Void, applyEditsApprovalStore: ApplyEditsApprovalStore = .shared ) { self.service = service + runtimeSessionRegistry = service.runtimeSessionRegistry + serviceRegistry = service.serviceRegistry + self.coreSessionHandle = coreSessionHandle + self.appSessionAdapters = appSessionAdapters self.windowID = windowID self.promptVM = promptVM self.oracleVM = oracleVM @@ -1651,6 +1666,11 @@ final class MCPServerViewModel: ObservableObject { // Enable tools based on auto-start setting. CE builds do not license-gate MCP. windowToolsEnabled = GlobalSettingsStore.shared.mcpAutoStart() + runtimeSessionRegistry.setMCPEnabled( + windowID: windowID, + expectedSessionID: coreSessionHandle.sessionID, + enabled: windowToolsEnabled + ) } // MARK: – Private helpers @@ -1728,7 +1748,7 @@ final class MCPServerViewModel: ObservableObject { /// Brings this window to front to show the approval overlay @MainActor private func bringWindowToFront() { - guard let windowState = WindowStatesManager.shared.allWindows.first(where: { $0.windowID == windowID }), + guard let windowState = appSessionAdapters.window(withID: windowID), let nsWindow = windowState.nsWindow else { return @@ -1781,9 +1801,7 @@ final class MCPServerViewModel: ObservableObject { /// Ensures tools are enabled and the window is joined before agent bootstrap continues. func ensureServerReadyForAgentBootstrap() async { let invalidateCatalogBeforeUpdate = !windowToolsEnabled - || !ServiceRegistry.services.contains { service in - (service as AnyObject) === (windowToolCatalogService as AnyObject) - } + || !serviceRegistry.contains(windowToolCatalogService) if !windowToolsEnabled { windowToolsEnabled = true } @@ -1816,6 +1834,43 @@ final class MCPServerViewModel: ObservableObject { await service.refreshState() } + /// Reconcile pending auto-start after the window-backed runtime session becomes active. + @MainActor + func windowDidRegister() { + guard runtimeSessionRegistry.hasActiveSession( + windowID: windowID, + expectedSessionID: coreSessionHandle.sessionID + ) else { + serviceRegistry.unregister(windowToolCatalogService) + Task { await service.leave(windowID: windowID) } + return + } + runtimeSessionRegistry.setMCPEnabled( + windowID: windowID, + expectedSessionID: coreSessionHandle.sessionID, + enabled: windowToolsEnabled + ) + if windowToolsEnabled { + Task { await updateToolRegistration(invalidateCatalogBeforeUpdate: false) } + } else { + // Keep the disabled path synchronous so an older registration task cannot + // unregister a newer explicit catalog installation. + serviceRegistry.unregister(windowToolCatalogService) + Task { await service.leave(windowID: windowID) } + } + } + + /// Remove routing eligibility before asynchronous listener and catalog cleanup completes. + @MainActor + func windowWillUnregister() { + runtimeSessionRegistry.beginDraining( + windowID: windowID, + expectedSessionID: coreSessionHandle.sessionID + ) + serviceRegistry.unregister(windowToolCatalogService) + Task { await service.leave(windowID: windowID) } + } + /// Updates tool registration based on windowToolsEnabled state @MainActor private func updateToolRegistration(invalidateCatalogBeforeUpdate: Bool = true) async { @@ -1829,8 +1884,13 @@ final class MCPServerViewModel: ObservableObject { #endif } - if windowToolsEnabled { - ServiceRegistry.register(windowToolCatalogService) // idempotent + if windowToolsEnabled, + runtimeSessionRegistry.hasActiveSession( + windowID: windowID, + expectedSessionID: coreSessionHandle.sessionID + ) + { + serviceRegistry.register(windowToolCatalogService) // idempotent do { try await service.join(windowID: windowID) await service.refreshState() @@ -1838,7 +1898,7 @@ final class MCPServerViewModel: ObservableObject { logger.error("Failed to join MCP: \(error)") } } else { - ServiceRegistry.unregister(windowToolCatalogService) + serviceRegistry.unregister(windowToolCatalogService) await service.leave(windowID: windowID) await service.refreshState() } @@ -2012,6 +2072,7 @@ final class MCPServerViewModel: ObservableObject { @MainActor private func invalidateToolsCache() { windowToolCatalogService.invalidateToolsCache() + serviceRegistry.invalidateCatalog(for: windowToolCatalogService) } // ===================================================================== @@ -2496,7 +2557,7 @@ final class MCPServerViewModel: ObservableObject { } private func requireTargetWindow() throws -> WindowState { - guard let targetWindow = WindowStatesManager.shared.window(withID: windowID) else { + guard let targetWindow = appSessionAdapters.window(withID: windowID) else { throw MCPError.invalidParams("No valid target window found") } return targetWindow @@ -2565,7 +2626,7 @@ final class MCPServerViewModel: ObservableObject { } private func existingTabContextBindingAcrossWindows(for connectionID: UUID) -> ConnectionBindingSnapshot? { - let snapshots = WindowStatesManager.shared.allWindows.map { + let snapshots = appSessionAdapters.windowStates().map { $0.mcpServer.connectionBindingSnapshot(forConnection: connectionID) } if let explicit = snapshots.first(where: { $0.bindingKind == .tabContext && $0.explicitlyBound && $0.runID == nil }) { diff --git a/Sources/RepoPrompt/Infrastructure/MCP/WindowRoutingService.swift b/Sources/RepoPrompt/Infrastructure/MCP/WindowRoutingService.swift index 50bcdbc66..648a7853a 100644 --- a/Sources/RepoPrompt/Infrastructure/MCP/WindowRoutingService.swift +++ b/Sources/RepoPrompt/Infrastructure/MCP/WindowRoutingService.swift @@ -2,6 +2,7 @@ import Foundation import JSONSchema import MCP import Ontology +import RepoPromptCore import SwiftUI #if DEBUG @@ -321,6 +322,8 @@ final class WindowRoutingService: Service { // --------------------------------------------------------------------- private let windowStates: WindowStatesManager private let networkMgr: ServerNetworkManager + private let serviceRegistry: MCPServiceRegistry + private let workspaceRepository: WorkspaceRepository private var previousDisabledTools: Set /// Thread-safe tools storage @@ -336,10 +339,14 @@ final class WindowRoutingService: Service { /// --------------------------------------------------------------------- init( windowStates: WindowStatesManager, - networkMgr: ServerNetworkManager + networkMgr: ServerNetworkManager, + serviceRegistry: MCPServiceRegistry? = nil, + workspaceRepository: WorkspaceRepository ) { self.windowStates = windowStates self.networkMgr = networkMgr + self.serviceRegistry = serviceRegistry ?? networkMgr.serviceRegistry + self.workspaceRepository = workspaceRepository previousDisabledTools = Set(UserDefaults.standard.stringArray(forKey: "mcp.disabledTools") ?? []) // Initialize cached tools and register service @@ -347,7 +354,7 @@ final class WindowRoutingService: Service { await updateCachedTools() // Register only after tools are cached - ServiceRegistry.register(self) + self.serviceRegistry.register(self) } // Listen for changes to relevant MCP settings @@ -381,8 +388,6 @@ final class WindowRoutingService: Service { if !removedToolNames.isEmpty { ToolAvailabilityStore.shared.unregisterTools(Array(removedToolNames)) } - - await networkMgr.broadcastToolListChanged() } } @@ -416,9 +421,6 @@ final class WindowRoutingService: Service { if !removedToolNames.isEmpty { ToolAvailabilityStore.shared.unregisterTools(Array(removedToolNames)) } - - // Notify connected clients that the tool list has changed - await networkMgr.broadcastToolListChanged() } } } @@ -449,12 +451,10 @@ final class WindowRoutingService: Service { } private func loadWorkspaceDiskSnapshot() async throws -> [WorkspaceModel] { - guard let referenceManager = await MainActor.run(body: { - self.windowStates.allWindows.first?.workspaceManager - }) else { + guard networkMgr.runtimeSessionRegistry.routingSnapshot().activeWindowCount > 0 else { throw MCPError.invalidParams("No windows available to load workspace list. Open at least one window first.") } - return await referenceManager.loadWorkspaceSnapshotFromDisk() + return await workspaceRepository.loadWorkspaceSnapshotFromDisk() } private nonisolated static func availableWorkspaceSuggestion(_ workspaces: [WorkspaceModel], includeHidden: Bool) -> String { @@ -1169,11 +1169,8 @@ final class WindowRoutingService: Service { kind: WorkingDirsWorkspaceMatchKind ) async throws -> [WorkspaceMatch] { let windows = windowStates.allWindows - guard let inventoryWindow = windows.first else { - throw MCPError.invalidParams("No windows available to load workspace list. Open at least one window first.") - } let activeWindowSnapshots = Self.activeWorkspaceSnapshots(from: windows) - let diskWorkspaces = await inventoryWindow.workspaceManager.loadWorkspaceSnapshotFromDisk() + let diskWorkspaces = try await loadWorkspaceDiskSnapshot() return Self.collapsedWorkspaceMatches( normalizedWorkingDirs: normalizedWorkingDirs, kind: kind, @@ -1189,11 +1186,8 @@ final class WindowRoutingService: Service { kind: WorkingDirsWorkspaceMatchKind ) async throws -> [WorkspaceMatch] { let windows = windowStates.allWindows - guard let inventoryWindow = windows.first else { - throw MCPError.invalidParams("No windows available to load workspace list. Open at least one window first.") - } let activeWindowSnapshots = Self.activeWorkspaceSnapshots(from: windows) - let diskWorkspaces = await inventoryWindow.workspaceManager.loadWorkspaceSnapshotFromDisk() + let diskWorkspaces = try await loadWorkspaceDiskSnapshot() return Self.collapsedWorkspaceMatches( normalizedWorkingDirs: normalizedWorkingDirs, kind: kind, @@ -1731,7 +1725,9 @@ final class WindowRoutingService: Service { let matches = windows.compactMap { window -> ResolvedBindTarget? in guard windowID == nil || window.windowID == windowID else { return nil } - guard let candidate = window.workspaceManager.bindingCandidate(forContextID: contextID) else { return nil } + guard let candidate = window.coreSessionHandle.session.workspaceSessionController + .bindingCandidate(forContextID: contextID) + else { return nil } let tabName = window.workspaceManager.composeTabName(with: candidate.tabID) ?? contextID.uuidString return ResolvedBindTarget( windowID: window.windowID, @@ -1803,7 +1799,7 @@ final class WindowRoutingService: Service { } let approvalWindow = try await resolveWorkspaceApprovalWindow(requestedWindowID: windowID, openInNewWindow: true) - let existingWorkspaces = await approvalWindow.workspaceManager.loadWorkspaceSnapshotFromDisk() + let existingWorkspaces = await workspaceRepository.loadWorkspaceSnapshotFromDisk() let workspaceName = derivedWorkspaceName( normalizedWorkingDirs: normalizedWorkingDirs, creationNameHint: tabName, @@ -2210,15 +2206,14 @@ final class WindowRoutingService: Service { // Load fresh workspace data from disk to ensure accurate repoPaths // Then overlay window visibility information from in-memory state - // Get a workspace manager to load disk snapshot - guard let referenceManager = await MainActor.run(body: { - self.windowStates.allWindows.first?.workspaceManager + guard await MainActor.run(body: { + self.networkMgr.runtimeSessionRegistry.routingSnapshot().activeWindowCount > 0 }) else { return ManageWorkspacesResponse(action: "list", workspaces: [], status: "ok") } // Load authoritative workspace data from disk - let diskWorkspaces = await referenceManager.loadWorkspaceSnapshotFromDisk() + let diskWorkspaces = await workspaceRepository.loadWorkspaceSnapshotFromDisk() // Build map of which windows are showing each workspace let windowsByWorkspaceID: [UUID: Set] = await MainActor.run { @@ -3096,6 +3091,7 @@ final class WindowRoutingService: Service { // Update the cache with the new tools await toolsCache.update(newTools) + serviceRegistry.invalidateCatalog(for: self) } // --------------------------------------------------------------------- diff --git a/Sources/RepoPrompt/Infrastructure/MacOS/MacOSRepoPromptCorePlatformDependencies.swift b/Sources/RepoPrompt/Infrastructure/MacOS/MacOSRepoPromptCorePlatformDependencies.swift new file mode 100644 index 000000000..c81f650cf --- /dev/null +++ b/Sources/RepoPrompt/Infrastructure/MacOS/MacOSRepoPromptCorePlatformDependencies.swift @@ -0,0 +1,13 @@ +import RepoPromptCore +import RepoPromptCoreMacOS + +/// Embedded-app composition of macOS platform adapters. +enum MacOSRepoPromptCorePlatformDependencies { + static func embeddedApp() -> RepoPromptCorePlatformDependencies { + RepoPromptCorePlatformDependencies( + fileSystemWatcherFactory: MacOSFSEventsWatcherFactory(), + processLauncher: POSIXProcessLauncher(), + secureStorageBackend: { SecureKeyValueStorageFactory.defaultBackend() } + ) + } +} diff --git a/Sources/RepoPrompt/Infrastructure/Process/CLIProcessRunner.swift b/Sources/RepoPrompt/Infrastructure/Process/CLIProcessRunner.swift index 874d38146..c02bb3544 100644 --- a/Sources/RepoPrompt/Infrastructure/Process/CLIProcessRunner.swift +++ b/Sources/RepoPrompt/Infrastructure/Process/CLIProcessRunner.swift @@ -1,5 +1,7 @@ import Darwin import Foundation +import RepoPromptCore +import RepoPromptCoreMacOS /// Separate diagnostic logger for deadlock debugging (independent of config.enableDebugLogging) enum ProcessDiagnostics { @@ -710,8 +712,8 @@ final class CLIProcessRunner { switch error { case let .pipeCreationFailed(pipe): return .spawnFailed("Failed to create \(pipe) pipe for process startup") - case let .descriptorConfigurationFailed(label, fd, underlying): - let message = String(cString: strerror(underlying.errnoValue)) + case let .descriptorConfigurationFailed(_, label, fd, errnoValue): + let message = String(cString: strerror(errnoValue)) return .spawnFailed("Failed to configure \(label) pipe descriptor \(fd) for process startup: \(message)") case let .spawnFileActionsFailed(operation, errnoValue): let message = String(cString: strerror(errnoValue)) diff --git a/Sources/RepoPrompt/Infrastructure/Process/ProcessRegistry.swift b/Sources/RepoPrompt/Infrastructure/Process/ProcessRegistry.swift index b002591f0..1073b0207 100644 --- a/Sources/RepoPrompt/Infrastructure/Process/ProcessRegistry.swift +++ b/Sources/RepoPrompt/Infrastructure/Process/ProcessRegistry.swift @@ -1,5 +1,6 @@ import Darwin import Foundation +import RepoPromptCore actor ProcessRegistry { private var children: [pid_t: SpawnedProcess] = [:] diff --git a/Sources/RepoPrompt/Infrastructure/Security/KeyManager.swift b/Sources/RepoPrompt/Infrastructure/Security/KeyManager.swift index fb61975ce..85c32fd3a 100644 --- a/Sources/RepoPrompt/Infrastructure/Security/KeyManager.swift +++ b/Sources/RepoPrompt/Infrastructure/Security/KeyManager.swift @@ -1,4 +1,5 @@ import Foundation +import RepoPromptCore actor KeyManager { private let secureService: SecureKeysService @@ -6,14 +7,18 @@ actor KeyManager { /// Simple in-memory store of keys private var cache = [AIProviderType: String]() - init(secureService: SecureKeysService = SecureKeysService()) { + init( + secureService: SecureKeysService = SecureKeysService( + secureStorage: SecureKeyValueStorageFactory.defaultBackend() + ) + ) { self.secureService = secureService } /// Lazily loads the key from disk only if not already in the `cache`. func getAPIKey( for provider: AIProviderType, - accessMode: KeychainAccessMode = .interactive + accessMode: SecureStorageAccessMode = .interactive ) async throws -> String? { if let cached = cache[provider] { return cached @@ -33,7 +38,7 @@ actor KeyManager { func saveAPIKey( _ key: String, for provider: AIProviderType, - accessMode: KeychainAccessMode = .interactive + accessMode: SecureStorageAccessMode = .interactive ) throws { cache[provider] = key let account = provider.secureStorageAccount @@ -43,7 +48,7 @@ actor KeyManager { /// Deletes from both in-memory cache and disk. func deleteAPIKey( for provider: AIProviderType, - accessMode: KeychainAccessMode = .interactive + accessMode: SecureStorageAccessMode = .interactive ) throws { cache.removeValue(forKey: provider) let account = provider.secureStorageAccount diff --git a/Sources/RepoPrompt/Infrastructure/Security/MacOS/AppSecureKeyValueStorageFactory.swift b/Sources/RepoPrompt/Infrastructure/Security/MacOS/AppSecureKeyValueStorageFactory.swift new file mode 100644 index 000000000..1a56987d3 --- /dev/null +++ b/Sources/RepoPrompt/Infrastructure/Security/MacOS/AppSecureKeyValueStorageFactory.swift @@ -0,0 +1,130 @@ +import Foundation +import RepoPromptCore +import RepoPromptCoreMacOS + +typealias KeychainAccessMode = SecureStorageAccessMode + +struct SecureKeyValueStorageSelection { + let decision: RuntimeSecureStorageDecision + let backend: SecureKeyValueStorageBackend +} + +extension SecureKeysService { + func saveAPIKey( + _ key: String, + for account: SecureStorageAccount, + accessMode: SecureStorageAccessMode = .interactive + ) throws { + try saveAPIKey(key, for: account.identifier, accessMode: accessMode) + } + + func getAPIKey( + for account: SecureStorageAccount, + accessMode: SecureStorageAccessMode = .interactive + ) async throws -> String? { + try await getAPIKey(for: account.identifier, accessMode: accessMode) + } + + func deleteAPIKey( + for account: SecureStorageAccount, + accessMode: SecureStorageAccessMode = .interactive + ) throws { + try deleteAPIKey(for: account.identifier, accessMode: accessMode) + } + + func savePlainValue( + _ value: String, + for account: SecureStorageAccount, + accessMode: SecureStorageAccessMode = .interactive + ) throws { + try savePlainValue(value, for: account.identifier, accessMode: accessMode) + } + + func getPlainValue( + for account: SecureStorageAccount, + accessMode: SecureStorageAccessMode = .interactive + ) throws -> String? { + try getPlainValue(for: account.identifier, accessMode: accessMode) + } + + func deletePlainValue( + for account: SecureStorageAccount, + accessMode: SecureStorageAccessMode = .interactive + ) throws { + try deletePlainValue(for: account.identifier, accessMode: accessMode) + } +} + +extension SecurePlainStringStoring { + func getPlainValue( + for account: SecureStorageAccount, + accessMode: SecureStorageAccessMode = .interactive + ) throws -> String? { + try getPlainValue(for: account.identifier, accessMode: accessMode) + } + + func savePlainValue( + _ value: String, + for account: SecureStorageAccount, + accessMode: SecureStorageAccessMode = .interactive + ) throws { + try savePlainValue(value, for: account.identifier, accessMode: accessMode) + } + + func deletePlainValue( + for account: SecureStorageAccount, + accessMode: SecureStorageAccessMode = .interactive + ) throws { + try deletePlainValue(for: account.identifier, accessMode: accessMode) + } +} + +/// Embedded macOS app policy for selecting the secure-storage adapter. +enum SecureKeyValueStorageFactory { + private static let cachedSelection: SecureKeyValueStorageSelection = { + let localSigningContext = RuntimeCodeSigningPolicy.currentLocalSigningContext() + let signingInfo = RuntimeCodeSigningDetector.currentProcessSigningInfo( + requirements: RuntimeCodeSigningRequirements( + developerIDRequirement: RuntimeCodeSigningPolicy.developerIDRequirement, + appleDevelopmentDebugRequirement: RuntimeCodeSigningPolicy.appleDevelopmentDebugRequirement, + localCodeIdentifier: RuntimeCodeSigningPolicy.developerIDBundleIdentifier + ), + localSigningExpectation: localSigningContext.expectation + ) + return selection( + for: RuntimeCodeSigningPolicy.currentDecision( + signingInfo: signingInfo, + localSigningContext: localSigningContext + ) + ) + }() + + static func defaultBackend() -> SecureKeyValueStorageBackend { + cachedSelection.backend + } + + static func currentDecision() -> RuntimeSecureStorageDecision { + cachedSelection.decision + } + + static func selection(for decision: RuntimeSecureStorageDecision) -> SecureKeyValueStorageSelection { + let backend: SecureKeyValueStorageBackend = switch decision.domain { + case .officialDeveloperID: + KeychainService.officialV2Shared + case .localSelfSigned: + if let fingerprint = decision.localCertificateFingerprint, + let generation = decision.localServiceGeneration, + generation > 0 + { + KeychainService.localSelfSigned(fingerprint: fingerprint, generation: generation) + } else { + EphemeralSecureKeyValueStore.shared + } + case .appleDevelopmentDebug: + KeychainService.debugShared + case .ephemeral: + EphemeralSecureKeyValueStore.shared + } + return SecureKeyValueStorageSelection(decision: decision, backend: backend) + } +} diff --git a/Sources/RepoPrompt/Infrastructure/Security/RuntimeCodeSigningPolicy.swift b/Sources/RepoPrompt/Infrastructure/Security/RuntimeCodeSigningPolicy.swift index a4ff9ecc8..e4aa50f7e 100644 --- a/Sources/RepoPrompt/Infrastructure/Security/RuntimeCodeSigningPolicy.swift +++ b/Sources/RepoPrompt/Infrastructure/Security/RuntimeCodeSigningPolicy.swift @@ -1,55 +1,5 @@ import Foundation - -enum RuntimeCodeSigningDomain: Hashable { - case developerID - case appleDevelopmentDebug - case localSelfSigned -} - -enum RuntimeCodeSigningFailureCategory: Equatable { - case codeObjectUnavailable - case signatureInvalid - case signingInformationUnavailable - case requirementUnavailable -} - -enum RuntimeCodeSigningValidationResult: Equatable { - case valid(domains: Set) - case invalid(RuntimeCodeSigningFailureCategory) - - func validates(_ domain: RuntimeCodeSigningDomain) -> Bool { - guard case let .valid(domains) = self else { return false } - return domains.contains(domain) - } -} - -struct RuntimeCodeSigningInfo: Equatable { - let codeIdentifier: String? - let teamIdentifier: String? - let signingFlags: UInt32? - let isAdHoc: Bool - let leafCertificateSHA256: String? - let validationResult: RuntimeCodeSigningValidationResult - - static func synthetic( - codeIdentifier: String? = nil, - teamIdentifier: String? = nil, - isAdHoc: Bool = false, - leafCertificateSHA256: String? = nil, - validatedDomains: Set = [], - failure: RuntimeCodeSigningFailureCategory? = nil - ) -> RuntimeCodeSigningInfo { - RuntimeCodeSigningInfo( - codeIdentifier: codeIdentifier, - teamIdentifier: teamIdentifier, - signingFlags: isAdHoc ? 0x2 : 0, - isAdHoc: isAdHoc, - leafCertificateSHA256: leafCertificateSHA256, - validationResult: failure.map(RuntimeCodeSigningValidationResult.invalid) - ?? .valid(domains: validatedDomains) - ) - } -} +import RepoPromptCoreMacOS enum RuntimeSecureStorageDomain: Equatable { case officialDeveloperID diff --git a/Sources/RepoPrompt/Infrastructure/Security/SecureKeyService.swift b/Sources/RepoPrompt/Infrastructure/Security/SecureKeyService.swift deleted file mode 100644 index 0168cc47e..000000000 --- a/Sources/RepoPrompt/Infrastructure/Security/SecureKeyService.swift +++ /dev/null @@ -1,100 +0,0 @@ -import Foundation - -protocol SecurePlainStringStoring { - var persistsValuesAcrossLaunches: Bool { get } - - func getPlainValue(for account: SecureStorageAccount, accessMode: KeychainAccessMode) throws -> String? - func savePlainValue(_ value: String, for account: SecureStorageAccount, accessMode: KeychainAccessMode) throws - func deletePlainValue(for account: SecureStorageAccount, accessMode: KeychainAccessMode) throws -} - -extension SecurePlainStringStoring { - var persistsValuesAcrossLaunches: Bool { - true - } - - func getPlainValue(for account: SecureStorageAccount) throws -> String? { - try getPlainValue(for: account, accessMode: .interactive) - } - - func savePlainValue(_ value: String, for account: SecureStorageAccount) throws { - try savePlainValue(value, for: account, accessMode: .interactive) - } - - func deletePlainValue(for account: SecureStorageAccount) throws { - try deletePlainValue(for: account, accessMode: .interactive) - } -} - -/// Secure key storage service backed by canonical Keychain/plain UTF-8 values. -final class SecureKeysService { - private let secureStorage: SecureKeyValueStorageBackend - - init( - secureStorage: SecureKeyValueStorageBackend = SecureKeyValueStorageFactory.defaultBackend() - ) { - self.secureStorage = secureStorage - } - - // MARK: - API Key Storage - - func saveAPIKey( - _ key: String, - for account: SecureStorageAccount, - accessMode: KeychainAccessMode = .interactive - ) throws { - try secureStorage.save(key, for: account.identifier, accessMode: accessMode) - } - - func getAPIKey( - for account: SecureStorageAccount, - accessMode: KeychainAccessMode = .interactive - ) async throws -> String? { - do { - return try secureStorage.get(for: account.identifier, accessMode: accessMode) - } catch KeychainService.KeychainError.itemNotFound { - return nil - } - } - - func deleteAPIKey( - for account: SecureStorageAccount, - accessMode: KeychainAccessMode = .interactive - ) throws { - try secureStorage.delete(for: account.identifier, accessMode: accessMode) - } - - // MARK: - Plain String Storage - - func savePlainValue( - _ value: String, - for account: SecureStorageAccount, - accessMode: KeychainAccessMode = .interactive - ) throws { - try secureStorage.save(value, for: account.identifier, accessMode: accessMode) - } - - func getPlainValue( - for account: SecureStorageAccount, - accessMode: KeychainAccessMode = .interactive - ) throws -> String? { - do { - return try secureStorage.get(for: account.identifier, accessMode: accessMode) - } catch KeychainService.KeychainError.itemNotFound { - return nil - } - } - - func deletePlainValue( - for account: SecureStorageAccount, - accessMode: KeychainAccessMode = .interactive - ) throws { - try secureStorage.delete(for: account.identifier, accessMode: accessMode) - } -} - -extension SecureKeysService: SecurePlainStringStoring { - var persistsValuesAcrossLaunches: Bool { - secureStorage.persistsValuesAcrossLaunches - } -} diff --git a/Sources/RepoPrompt/Infrastructure/Security/SecureKeyValueStorageBackend.swift b/Sources/RepoPrompt/Infrastructure/Security/SecureKeyValueStorageBackend.swift deleted file mode 100644 index 8fa29616b..000000000 --- a/Sources/RepoPrompt/Infrastructure/Security/SecureKeyValueStorageBackend.swift +++ /dev/null @@ -1,70 +0,0 @@ -import Foundation - -protocol SecureKeyValueStorageBackend: AnyObject, Sendable { - var persistsValuesAcrossLaunches: Bool { get } - - func save( - _ value: String, - for key: String, - accessMode: KeychainAccessMode - ) throws - - func get( - for key: String, - accessMode: KeychainAccessMode - ) throws -> String - - func delete( - for key: String, - accessMode: KeychainAccessMode - ) throws -} - -struct SecureKeyValueStorageSelection { - let decision: RuntimeSecureStorageDecision - let backend: SecureKeyValueStorageBackend -} - -enum SecureKeyValueStorageFactory { - private static let cachedSelection: SecureKeyValueStorageSelection = { - let localSigningContext = RuntimeCodeSigningPolicy.currentLocalSigningContext() - let signingInfo = RuntimeCodeSigningDetector.currentProcessSigningInfo( - localSigningExpectation: localSigningContext.expectation - ) - return selection( - for: RuntimeCodeSigningPolicy.currentDecision( - signingInfo: signingInfo, - localSigningContext: localSigningContext - ) - ) - }() - - static func defaultBackend() -> SecureKeyValueStorageBackend { - cachedSelection.backend - } - - static func currentDecision() -> RuntimeSecureStorageDecision { - cachedSelection.decision - } - - static func selection(for decision: RuntimeSecureStorageDecision) -> SecureKeyValueStorageSelection { - let backend: SecureKeyValueStorageBackend = switch decision.domain { - case .officialDeveloperID: - KeychainService.officialV2Shared - case .localSelfSigned: - if let fingerprint = decision.localCertificateFingerprint, - let generation = decision.localServiceGeneration, - generation > 0 - { - KeychainService.localSelfSigned(fingerprint: fingerprint, generation: generation) - } else { - EphemeralSecureKeyValueStore.shared - } - case .appleDevelopmentDebug: - KeychainService.debugShared - case .ephemeral: - EphemeralSecureKeyValueStore.shared - } - return SecureKeyValueStorageSelection(decision: decision, backend: backend) - } -} diff --git a/Sources/RepoPrompt/Infrastructure/Security/SecureStorageRepairService.swift b/Sources/RepoPrompt/Infrastructure/Security/SecureStorageRepairService.swift index 30a61652f..6f1d93b27 100644 --- a/Sources/RepoPrompt/Infrastructure/Security/SecureStorageRepairService.swift +++ b/Sources/RepoPrompt/Infrastructure/Security/SecureStorageRepairService.swift @@ -1,4 +1,6 @@ import Foundation +import RepoPromptCore +import RepoPromptCoreMacOS enum SecureStorageRepairFailure: Equatable { case authenticationFailed @@ -85,7 +87,7 @@ actor SecureStorageRepairService { guard resolution == .replaceTarget else { return SecureStorageRepairRecord(account: account, state: .conflict, targetVerified: false) } - } catch KeychainService.KeychainError.itemNotFound { + } catch SecureStorageError.itemNotFound { // The target will be created below. } catch { return record(account, for: error) @@ -129,7 +131,7 @@ actor SecureStorageRepairService { do { _ = try legacyStore.get(for: account.identifier, accessMode: .interactive) return failedRecord(account, .verificationFailed) - } catch KeychainService.KeychainError.itemNotFound { + } catch SecureStorageError.itemNotFound { return SecureStorageRepairRecord(account: account, state: .absent, targetVerified: true) } catch { return record(account, for: error) @@ -140,7 +142,7 @@ actor SecureStorageRepairService { } private func scanAccount(_ account: SecureStorageAccount) -> SecureStorageRepairRecord { - let accessMode = KeychainAccessMode.nonInteractive(reason: .backgroundAvailabilityCheck) + let accessMode = SecureStorageAccessMode.nonInteractive(reason: .backgroundAvailabilityCheck) let legacyValue: String do { legacyValue = try legacyStore.get(for: account.identifier, accessMode: accessMode) @@ -154,7 +156,7 @@ actor SecureStorageRepairService { return importedRecord(account) } return SecureStorageRepairRecord(account: account, state: .conflict, targetVerified: false) - } catch KeychainService.KeychainError.itemNotFound { + } catch SecureStorageError.itemNotFound { return SecureStorageRepairRecord(account: account, state: .importable, targetVerified: false) } catch { return record(account, for: error) @@ -163,15 +165,15 @@ actor SecureStorageRepairService { private func record(_ account: SecureStorageAccount, for error: Error) -> SecureStorageRepairRecord { let state: SecureStorageRepairState = switch error { - case KeychainService.KeychainError.itemNotFound: + case SecureStorageError.itemNotFound: .absent - case KeychainService.KeychainError.interactionNotAllowed: + case SecureStorageError.interactionNotAllowed: .interactionRequired - case KeychainService.KeychainError.userInteractionCancelled: + case SecureStorageError.userInteractionCancelled: .cancelled - case KeychainService.KeychainError.authenticationFailed: + case SecureStorageError.authenticationFailed: .failed(.authenticationFailed) - case KeychainService.KeychainError.invalidData: + case SecureStorageError.invalidData: .failed(.invalidData) default: .failed(.keychainFailure) diff --git a/Sources/RepoPrompt/Infrastructure/Utilities/StringExtensions.swift b/Sources/RepoPrompt/Infrastructure/Utilities/StringExtensions.swift index 5901d9585..24be09ca9 100644 --- a/Sources/RepoPrompt/Infrastructure/Utilities/StringExtensions.swift +++ b/Sources/RepoPrompt/Infrastructure/Utilities/StringExtensions.swift @@ -5,8 +5,8 @@ // Created by Eric Provencher on 2024-07-25. // -import Darwin import Foundation +import RepoPromptC public extension String { internal static func truncateModelName(_ text: String, maxLength: Int = 40) -> String { @@ -68,7 +68,7 @@ public extension String { guard let cRes = repo_longest_common_subsequence(aPtr, bPtr) else { return "" } - defer { free(cRes) } + defer { repo_free(cRes) } return String(cString: cRes) } } @@ -272,7 +272,7 @@ public extension String { guard let raw = repo_encode_indentation(cLine, CChar(115)) else { return line } - defer { free(raw) } + defer { repo_free(raw) } return String(cString: raw) } } @@ -284,7 +284,7 @@ public extension String { guard let raw = repo_decode_indentation(cLine) else { return encodedLine } - defer { free(raw) } + defer { repo_free(raw) } return String(cString: raw) } } @@ -303,7 +303,7 @@ public extension String { guard let raw = repo_trim_common_leading_whitespace_preserving_endings(ptr) else { return content } - defer { free(raw) } + defer { repo_free(raw) } return String(cString: raw) } } @@ -463,7 +463,7 @@ public extension String { internal func escapedString() -> String { withCString { ptr in guard let raw = repo_escape_string(ptr) else { return self } - defer { free(raw) } + defer { repo_free(raw) } return String(cString: raw) } } @@ -471,7 +471,7 @@ public extension String { internal func unescaped() -> String { withCString { ptr in guard let raw = repo_unescape_string(ptr) else { return self } - defer { free(raw) } + defer { repo_free(raw) } return String(cString: raw) } } @@ -825,7 +825,7 @@ public extension String { internal func decodingHTMLEntities() -> String { withCString { ptr in guard let raw = repo_decode_html_entities(ptr) else { return self } - defer { free(raw) } + defer { repo_free(raw) } return String(cString: raw) } } @@ -910,7 +910,7 @@ public extension String { internal func condensingWhitespace() -> String { withCString { ptr in guard let raw = repo_condense_whitespace(ptr) else { return self } - defer { free(raw) } + defer { repo_free(raw) } return String(cString: raw) } } @@ -938,7 +938,7 @@ public extension String { internal static func canonicalKey(_ raw: String) -> String? { raw.withCString { ptr in guard let result = repo_canonical_key(ptr) else { return nil } - defer { free(result) } + defer { repo_free(result) } return String(cString: result) } } diff --git a/Sources/RepoPrompt/Infrastructure/VCS/GitService.swift b/Sources/RepoPrompt/Infrastructure/VCS/GitService.swift index 35e4784d3..1f2ce9e2a 100644 --- a/Sources/RepoPrompt/Infrastructure/VCS/GitService.swift +++ b/Sources/RepoPrompt/Infrastructure/VCS/GitService.swift @@ -1,6 +1,7 @@ import CryptoKit import Darwin import Foundation +import RepoPromptCoreMacOS /// Async Git helper for fetching repository information /// Based on the macOS 14+ Swift Git integration guide diff --git a/Sources/RepoPrompt/Infrastructure/VCS/JJCommandRunner.swift b/Sources/RepoPrompt/Infrastructure/VCS/JJCommandRunner.swift index f03354b7d..b9b636e7f 100644 --- a/Sources/RepoPrompt/Infrastructure/VCS/JJCommandRunner.swift +++ b/Sources/RepoPrompt/Infrastructure/VCS/JJCommandRunner.swift @@ -1,5 +1,6 @@ import CryptoKit import Foundation +import RepoPromptCoreMacOS // MARK: - JJ Command Runner diff --git a/Sources/RepoPrompt/Infrastructure/WorkspaceContext/Models/WorkspaceFileContextModels.swift b/Sources/RepoPrompt/Infrastructure/WorkspaceContext/Models/WorkspaceFileContextModels.swift deleted file mode 100644 index 8b2863d68..000000000 --- a/Sources/RepoPrompt/Infrastructure/WorkspaceContext/Models/WorkspaceFileContextModels.swift +++ /dev/null @@ -1,490 +0,0 @@ -import Foundation - -/// Root scopes shared by UI and headless workspace file lookup paths. -enum WorkspaceLookupRootScope: Hashable { - case visibleWorkspace - case visibleWorkspacePlusGitData - case allLoaded - case sessionBoundWorkspace(logicalRootPaths: Set, physicalRootPaths: Set) -} - -enum WorkspaceLookupRootScopeAvailability: Equatable { - case available - case sessionWorktreeUnavailable(missingPhysicalRootPaths: [String]) -} - -enum WorkspaceSearchCatalogAccess: Equatable { - case available(WorkspaceSearchCatalogSnapshot) - case unavailable(WorkspaceLookupRootScopeAvailability) -} - -typealias LookupRootScope = WorkspaceLookupRootScope - -enum WorkspaceRootKind: Hashable { - case primaryWorkspace - case workspaceGitData - case supplementalSystem - case sessionWorktree -} - -enum WorkspaceExactPathLookupKind: Hashable { - case file - case folder - case either -} - -struct WorkspaceFolderExpansionResult: Equatable { - let files: [WorkspaceFileRecord] - let handled: Bool - let displayPath: String? - let issue: PathResolutionIssue? -} - -struct WorkspaceRootLoadFailure: Equatable, Identifiable { - let id: UUID - let rootPath: String - let standardizedRootPath: String - let kind: WorkspaceRootKind - let errorDescription: String - - init(id: UUID = UUID(), rootPath: String, kind: WorkspaceRootKind, errorDescription: String) { - self.id = id - self.rootPath = rootPath - standardizedRootPath = StandardizedPath.absolute(rootPath) - self.kind = kind - self.errorDescription = errorDescription - } - - static func == (lhs: WorkspaceRootLoadFailure, rhs: WorkspaceRootLoadFailure) -> Bool { - lhs.standardizedRootPath == rhs.standardizedRootPath && - lhs.kind == rhs.kind && - lhs.errorDescription == rhs.errorDescription - } -} - -enum WorkspaceSearchReadinessState: Equatable { - case idle - case activating(workspaceID: UUID?, generation: UInt64) - case loadingCatalog(workspaceID: UUID?, generation: UInt64, loadedRootCount: Int, expectedRootCount: Int, failures: [WorkspaceRootLoadFailure]) - case buildingIndexes(workspaceID: UUID?, generation: UInt64, catalogGeneration: UInt64, failures: [WorkspaceRootLoadFailure]) - case ready(workspaceID: UUID?, generation: UInt64, catalogGeneration: UInt64, indexedGeneration: UInt64, diagnostics: WorkspaceCatalogDiagnostics) - case degraded(workspaceID: UUID?, generation: UInt64, catalogGeneration: UInt64?, indexedGeneration: UInt64?, failures: [WorkspaceRootLoadFailure], diagnostics: WorkspaceCatalogDiagnostics?) -} - -struct WorkspaceCatalogDiagnostics: Equatable { - let generation: UInt64 - let rootScope: WorkspaceLookupRootScope - let rootCount: Int - let folderCount: Int - let fileCount: Int - let totalItemCount: Int - - init( - generation: UInt64, - rootScope: WorkspaceLookupRootScope, - rootCount: Int, - folderCount: Int, - fileCount: Int - ) { - self.generation = generation - self.rootScope = rootScope - self.rootCount = rootCount - self.folderCount = folderCount - self.fileCount = fileCount - totalItemCount = folderCount + fileCount - } -} - -struct WorkspaceSearchCatalogEntry: Identifiable, Equatable, Hashable { - let id: UUID - let rootID: UUID - let rootPath: String - let rootName: String - let name: String - let relativePath: String - let standardizedRelativePath: String - let fullPath: String - let standardizedFullPath: String - let displayPath: String - - init(file: WorkspaceFileRecord, root: WorkspaceRootRecord, displayPath: String? = nil) { - id = file.id - rootID = file.rootID - rootPath = root.standardizedFullPath - rootName = root.name - name = file.name - relativePath = file.relativePath - standardizedRelativePath = file.standardizedRelativePath - fullPath = file.fullPath - standardizedFullPath = file.standardizedFullPath - self.displayPath = displayPath ?? WorkspaceSearchCatalogEntry.defaultDisplayPath(file: file, root: root) - } - - private static func defaultDisplayPath(file: WorkspaceFileRecord, root: WorkspaceRootRecord) -> String { - guard !file.standardizedRelativePath.isEmpty else { return root.name } - return root.name + "/" + file.standardizedRelativePath - } -} - -struct WorkspaceSearchCatalogSnapshot: Equatable { - let generation: UInt64 - let rootScope: WorkspaceLookupRootScope - let roots: [WorkspaceRootRecord] - let files: [WorkspaceFileRecord] - let entries: [WorkspaceSearchCatalogEntry] - let diagnostics: WorkspaceCatalogDiagnostics -} - -struct WorkspaceDirectFolderChildrenSnapshot: Equatable { - let generation: UInt64 - let root: WorkspaceRootRecord - let folder: WorkspaceFolderRecord - let childFolders: [WorkspaceFolderRecord] - let childFiles: [WorkspaceFileRecord] - - var isEmpty: Bool { - childFolders.isEmpty && childFiles.isEmpty - } -} - -struct WorkspaceSearchQueryResult: Equatable { - let query: String - let indexedGeneration: UInt64? - let snapshotGeneration: UInt64? - let pendingGeneration: UInt64? - let observedGeneration: UInt64? - let results: [WorkspaceSearchCatalogEntry] - let isIndexReady: Bool - let isStale: Bool - - init( - query: String, - indexedGeneration: UInt64?, - snapshotGeneration: UInt64?, - pendingGeneration: UInt64? = nil, - observedGeneration: UInt64? = nil, - results: [WorkspaceSearchCatalogEntry], - isIndexReady: Bool, - isStale: Bool = false - ) { - self.query = query - self.indexedGeneration = indexedGeneration - self.snapshotGeneration = snapshotGeneration - self.pendingGeneration = pendingGeneration - self.observedGeneration = observedGeneration - self.results = results - self.isIndexReady = isIndexReady - self.isStale = isStale - } -} - -struct WorkspaceResolvedCandidates: Equatable { - let candidates: [WorkspaceFileRecord] - let resolvedMap: [String: String] - let invalidPaths: [String] -} - -struct WorkspaceCodemapOnlyCandidates: Equatable { - let candidates: [WorkspaceFileRecord] - let resolvedMap: [String: String] - let invalidPaths: [String] - let codemapUnavailable: [String] -} - -struct WorkspaceRootRecord: Identifiable, Equatable, Hashable { - let id: UUID - let name: String - let fullPath: String - let standardizedFullPath: String - let isSystemRoot: Bool - let kind: WorkspaceRootKind - - init(id: UUID = UUID(), name: String, fullPath: String, isSystemRoot: Bool = false) { - self.init( - id: id, - name: name, - fullPath: fullPath, - kind: isSystemRoot ? .supplementalSystem : .primaryWorkspace, - isSystemRoot: isSystemRoot - ) - } - - init(id: UUID = UUID(), name: String, fullPath: String, kind: WorkspaceRootKind) { - self.init( - id: id, - name: name, - fullPath: fullPath, - kind: kind, - isSystemRoot: kind != .primaryWorkspace - ) - } - - private init(id: UUID, name: String, fullPath: String, kind: WorkspaceRootKind, isSystemRoot: Bool) { - self.id = id - self.name = name - self.fullPath = fullPath - standardizedFullPath = (fullPath as NSString).standardizingPath - self.isSystemRoot = isSystemRoot - self.kind = kind - } -} - -struct WorkspaceFolderRecord: Identifiable, Equatable, Hashable { - let id: UUID - let rootID: UUID - let name: String - let relativePath: String - let standardizedRelativePath: String - let fullPath: String - let standardizedFullPath: String - let parentFolderID: UUID? - let modificationDate: Date? - - init( - id: UUID = UUID(), - rootID: UUID, - name: String, - relativePath: String, - fullPath: String, - parentFolderID: UUID?, - modificationDate: Date? = nil - ) { - self.id = id - self.rootID = rootID - self.name = name - self.relativePath = relativePath - standardizedRelativePath = StandardizedPath.relative(relativePath) - self.fullPath = fullPath - standardizedFullPath = (fullPath as NSString).standardizingPath - self.parentFolderID = parentFolderID - self.modificationDate = modificationDate - } -} - -struct WorkspaceFileRecord: Identifiable, Equatable, Hashable { - let id: UUID - let rootID: UUID - let name: String - let relativePath: String - let standardizedRelativePath: String - let fullPath: String - let standardizedFullPath: String - let parentFolderID: UUID? - let modificationDate: Date? - - init( - id: UUID = UUID(), - rootID: UUID, - name: String, - relativePath: String, - fullPath: String, - parentFolderID: UUID?, - modificationDate: Date? = nil - ) { - self.id = id - self.rootID = rootID - self.name = name - self.relativePath = relativePath - standardizedRelativePath = StandardizedPath.relative(relativePath) - self.fullPath = fullPath - standardizedFullPath = (fullPath as NSString).standardizingPath - self.parentFolderID = parentFolderID - self.modificationDate = modificationDate - } -} - -struct ResolvedWorkspaceSelection: Equatable { - let files: [WorkspaceFileRecord] - let folders: [WorkspaceFolderRecord] - let missingPaths: [String] -} - -struct ResolvedPromptFileEntry: Identifiable, Equatable { - let id: ResolvedPromptFileEntryID - let file: WorkspaceFileRecord - let isCodemap: Bool - let lineRanges: [LineRange]? - let mode: PromptFileEntryMode - let loadedContent: String? - let rootFolderPath: String? - - init( - file: WorkspaceFileRecord, - isCodemap: Bool = false, - lineRanges: [LineRange]? = nil, - mode: PromptFileEntryMode = .fullFile, - loadedContent: String? = nil, - rootFolderPath: String? = nil - ) { - id = ResolvedPromptFileEntryID(fileID: file.id, mode: mode, lineRanges: lineRanges) - self.file = file - self.isCodemap = isCodemap - self.lineRanges = lineRanges - self.mode = mode - self.loadedContent = loadedContent - self.rootFolderPath = rootFolderPath - } -} - -struct ResolvedPromptFileBlockRecord: Equatable { - let entry: ResolvedPromptFileEntry - let file: WorkspaceFileRecord - let text: String - let isCodemap: Bool -} - -struct ResolvedPromptFileEntryID: Hashable { - let fileID: UUID - let mode: PromptFileEntryMode - let lineRanges: [LineRange]? -} - -enum PromptFileEntryMode: Hashable { - case fullFile - case sliced - case codemap -} - -struct WorkspaceExternalReadableFile: Equatable, Hashable { - let absolutePath: String - let displayPath: String -} - -enum WorkspaceReadableFileHandle: Equatable { - case workspace(WorkspaceFileRecord) - case external(WorkspaceExternalReadableFile) -} - -struct WorkspaceFileSystemDeltaEvent: Equatable { - let rootID: UUID - let rootPath: String - let delta: FileSystemDelta -} - -struct WorkspaceIngressBarrierSample: Equatable { - let rootID: UUID - let rootPath: String - let pendingRawEventCountBeforeFlush: Int - let acceptedWatcherWatermark: UInt64 - let publishedServicePublicationSequence: UInt64 - let appliedServicePublicationSequence: UInt64 - let appliedWatcherWatermark: UInt64 -} - -struct WorkspaceAppliedIndexBatchEvent: Equatable { - let rootID: UUID - let rootPath: String - let generation: UInt64 - let upsertedFiles: [WorkspaceFileRecord] - let upsertedFolders: [WorkspaceFolderRecord] - let removedFileIDs: [UUID] - let removedFolderIDs: [UUID] - let removedFilePaths: [String] - let removedFolderPaths: [String] - let modifiedFileIDs: [UUID] - let modifiedFolderIDs: [UUID] - let requiresFullResync: Bool - let isRootUnload: Bool - - init( - rootID: UUID, - rootPath: String, - generation: UInt64, - upsertedFiles: [WorkspaceFileRecord] = [], - upsertedFolders: [WorkspaceFolderRecord] = [], - removedFileIDs: [UUID] = [], - removedFolderIDs: [UUID] = [], - removedFilePaths: [String] = [], - removedFolderPaths: [String] = [], - modifiedFileIDs: [UUID] = [], - modifiedFolderIDs: [UUID] = [], - requiresFullResync: Bool = false, - isRootUnload: Bool = false - ) { - self.rootID = rootID - self.rootPath = rootPath - self.generation = generation - self.upsertedFiles = upsertedFiles - self.upsertedFolders = upsertedFolders - self.removedFileIDs = removedFileIDs - self.removedFolderIDs = removedFolderIDs - self.removedFilePaths = removedFilePaths - self.removedFolderPaths = removedFolderPaths - self.modifiedFileIDs = modifiedFileIDs - self.modifiedFolderIDs = modifiedFolderIDs - self.requiresFullResync = requiresFullResync - self.isRootUnload = isRootUnload - } -} - -struct WorkspaceCodemapSnapshot { - let fileID: UUID - let rootID: UUID - let rootPath: String - let relativePath: String - let fullPath: String - let modificationDate: Date - let fileAPI: FileAPI? -} - -struct WorkspaceCodemapUpdateEvent { - let rootID: UUID - let rootPath: String - let snapshots: [WorkspaceCodemapSnapshot] - let removedFileIDs: [UUID] - let isRootUnload: Bool - - init( - rootID: UUID, - rootPath: String, - snapshots: [WorkspaceCodemapSnapshot], - removedFileIDs: [UUID] = [], - isRootUnload: Bool = false - ) { - self.rootID = rootID - self.rootPath = rootPath - self.snapshots = snapshots - self.removedFileIDs = removedFileIDs - self.isRootUnload = isRootUnload - } -} - -struct WorkspacePathLookupRequest: Equatable { - let userPath: String - let profile: PathLocateProfile - let rootScope: WorkspaceLookupRootScope - let selectedFileFullPaths: Set - - init( - userPath: String, - profile: PathLocateProfile = .uiAssisted, - rootScope: WorkspaceLookupRootScope = .allLoaded, - selectedFileFullPaths: Set = [] - ) { - self.userPath = userPath - self.profile = profile - self.rootScope = rootScope - self.selectedFileFullPaths = selectedFileFullPaths - } -} - -struct WorkspacePathLocation: Equatable, Hashable { - let rootID: UUID - let rootPath: String - let correctedPath: String - - var absolutePath: String { - let standardizedRoot = (rootPath as NSString).standardizingPath - if correctedPath.hasPrefix("/") { - return (correctedPath as NSString).standardizingPath - } - return ((standardizedRoot as NSString).appendingPathComponent(correctedPath) as NSString).standardizingPath - } -} - -struct WorkspacePathLookupResult: Equatable { - let input: String - let location: WorkspacePathLocation - let file: WorkspaceFileRecord? - let folder: WorkspaceFolderRecord? -} diff --git a/Sources/RepoPrompt/Infrastructure/WorkspaceContext/Selection/WorkspaceSelectionCoordinator.swift b/Sources/RepoPrompt/Infrastructure/WorkspaceContext/Selection/WorkspaceSelectionCoordinator.swift index 4c3843202..620312c50 100644 --- a/Sources/RepoPrompt/Infrastructure/WorkspaceContext/Selection/WorkspaceSelectionCoordinator.swift +++ b/Sources/RepoPrompt/Infrastructure/WorkspaceContext/Selection/WorkspaceSelectionCoordinator.swift @@ -1,18 +1,8 @@ import Combine import Foundation +import RepoPromptCore -@MainActor -protocol WorkspaceSelectionHost: AnyObject { - var activeWorkspace: WorkspaceModel? { get } - func composeTab(with id: UUID) -> ComposeTabState? - func publishActiveComposeTabSnapshot(commitToMemory: Bool, touchModified: Bool) - func updateComposeTabStoredOnly(_ tab: ComposeTabState) -} - -extension WorkspaceManagerViewModel: WorkspaceSelectionHost {} - -/// Window-scoped coordinator that makes compose-tab `StoredSelection` the runtime -/// selection source while the WorkspaceFiles UI adapter still owns checkbox state. +/// App-only UI bridge over the canonical Core selection controller. @MainActor final class WorkspaceSelectionCoordinator { struct Snapshot: Equatable { @@ -35,11 +25,13 @@ final class WorkspaceSelectionCoordinator { case mirror } - private weak var workspaceManager: (any WorkspaceSelectionHost)? + private weak var workspaceManager: WorkspaceManagerViewModel? let store: WorkspaceFileContextStore let mutationService: WorkspaceSelectionMutationService + let controller: WorkspaceSelectionController private let changeSubject = PassthroughSubject() private var applyingSelectionMirrorDepth = 0 + private var observationToken: WorkspaceSelectionObservationToken? var changes: AnyPublisher { changeSubject.eraseToAnyPublisher() @@ -50,41 +42,40 @@ final class WorkspaceSelectionCoordinator { } init( - workspaceManager: (any WorkspaceSelectionHost)? = nil, - store: WorkspaceFileContextStore, - mutationService: WorkspaceSelectionMutationService? = nil + controller: WorkspaceSelectionController, + store: WorkspaceFileContextStore ) { - self.workspaceManager = workspaceManager + self.controller = controller self.store = store - self.mutationService = mutationService ?? WorkspaceSelectionMutationService(store: store) + mutationService = controller.mutationService + observationToken = controller.observe { [weak self] change in + self?.changeSubject.send(Change( + tabID: change.tabID, + selection: change.selection, + source: Source(rawValue: change.source.rawValue) ?? .runtimeMutation + )) + } } - func attachWorkspaceManager(_ workspaceManager: any WorkspaceSelectionHost) { + func attachWorkspaceManager(_ workspaceManager: WorkspaceManagerViewModel) { self.workspaceManager = workspaceManager } func activeTabID() -> UUID? { - guard let workspaceManager else { return nil } - return workspaceManager.activeWorkspace?.activeComposeTabID - ?? workspaceManager.activeWorkspace?.composeTabs.first?.id + controller.activeTabID() } func activeSelectionSnapshot(flushPendingUI: Bool = true) -> Snapshot { if flushPendingUI { flushPendingUISelectionToActiveTab() } - guard let workspaceManager, let tabID = activeTabID() else { - return Snapshot(tabID: nil, selection: StoredSelection(), isVirtual: false) - } - return Snapshot( - tabID: tabID, - selection: workspaceManager.composeTab(with: tabID)?.selection ?? StoredSelection(), - isVirtual: false - ) + let snapshot = controller.activeSelectionSnapshot() + return Snapshot(tabID: snapshot.tabID, selection: snapshot.selection, isVirtual: snapshot.isVirtual) } func virtualSelectionSnapshot(tabID: UUID, selection: StoredSelection) -> Snapshot { - Snapshot(tabID: tabID, selection: selection, isVirtual: true) + let snapshot = controller.virtualSelectionSnapshot(tabID: tabID, selection: selection) + return Snapshot(tabID: snapshot.tabID, selection: snapshot.selection, isVirtual: snapshot.isVirtual) } func selectionSnapshot(for tabID: UUID, flushPendingUIIfActive: Bool = true) -> Snapshot? { @@ -96,13 +87,12 @@ final class WorkspaceSelectionCoordinator { } func flushPendingUISelectionToActiveTab() { - guard !isApplyingSelectionMirror, let workspaceManager else { return } - let previousTabID = activeTabID() - let previousSelection = previousTabID.flatMap { workspaceManager.composeTab(with: $0)?.selection } ?? StoredSelection() + guard !isApplyingSelectionMirror, + let workspaceManager, + let pending = controller.beginExternallyCommittedSelection(source: .uiFlush) + else { return } workspaceManager.publishActiveComposeTabSnapshot(commitToMemory: true, touchModified: false) - let snapshot = activeSelectionSnapshot(flushPendingUI: false) - guard snapshot.tabID != previousTabID || snapshot.selection != previousSelection else { return } - changeSubject.send(Change(tabID: snapshot.tabID, selection: snapshot.selection, source: .uiFlush)) + controller.finishExternallyCommittedSelection(target: pending.target, previous: pending.previous) } @discardableResult @@ -111,17 +101,20 @@ final class WorkspaceSelectionCoordinator { source: Source = .runtimeMutation, mirrorToUI: Bool = true ) async -> StoredSelection { - guard workspaceManager != nil, let tabID = activeTabID() else { return selection } - guard persist(selection, for: tabID, markDirty: true) else { return selection } - let change = Change(tabID: tabID, selection: selection, source: source) + let target = controller.activeTarget() + let coreSource = WorkspaceSelectionController.Source(rawValue: source.rawValue) ?? .runtimeMutation + let result = controller.persistActiveSelection(selection, source: coreSource, publishChange: false) + guard let target, + controller.selectionSnapshot(for: target) == result + else { return result } + + let change = Change(tabID: target.tabID, selection: result, source: source) if mirrorToUI { - await applySelectionMirror { - changeSubject.send(change) - } + await deliverMirrored(change) } else { changeSubject.send(change) } - return selection + return result } @discardableResult @@ -143,9 +136,14 @@ final class WorkspaceSelectionCoordinator { for tabID: UUID, source: Source = .virtual ) -> StoredSelection { - guard persist(selection, for: tabID, markDirty: true) else { return selection } - changeSubject.send(Change(tabID: tabID, selection: selection, source: source)) - return selection + let target = controller.target(forTabID: tabID) + let result = controller.persistVirtualSelection(selection, for: tabID, publishChange: false) + if let target, + controller.selectionSnapshot(for: target) == result + { + changeSubject.send(Change(tabID: target.tabID, selection: result, source: source)) + } + return result } @discardableResult @@ -159,16 +157,23 @@ final class WorkspaceSelectionCoordinator { mode: String = "full", rootScope: WorkspaceLookupRootScope = .visibleWorkspace ) async -> WorkspaceAddSelectionResult { - let current = activeSelectionSnapshot(flushPendingUI: true).selection - let result = await mutationService.addPaths( - existing: current, + flushPendingUISelectionToActiveTab() + let target = controller.activeTarget() + let result = await controller.addPathsToActiveSelection( paths: paths, - rawPaths: paths, mode: mode, - rootScope: rootScope + rootScope: rootScope, + publishChange: false ) - if result.mutated { - _ = await persistActiveSelection(result.selection, source: .runtimeMutation) + if result.mutated, + let target, + controller.selectionSnapshot(for: target) == result.selection + { + await deliverMirrored(Change( + tabID: target.tabID, + selection: result.selection, + source: .runtimeMutation + )) } return result } @@ -179,16 +184,23 @@ final class WorkspaceSelectionCoordinator { mode: String = "full", rootScope: WorkspaceLookupRootScope = .visibleWorkspace ) async -> WorkspaceRemoveSelectionResult { - let current = activeSelectionSnapshot(flushPendingUI: true).selection - let result = await mutationService.removePaths( - existing: current, + flushPendingUISelectionToActiveTab() + let target = controller.activeTarget() + let result = await controller.removePathsFromActiveSelection( paths: paths, - rawPaths: paths, mode: mode, - rootScope: rootScope + rootScope: rootScope, + publishChange: false ) - if result.mutated { - _ = await persistActiveSelection(result.selection, source: .runtimeMutation) + if result.mutated, + let target, + controller.selectionSnapshot(for: target) == result.selection + { + await deliverMirrored(Change( + tabID: target.tabID, + selection: result.selection, + source: .runtimeMutation + )) } return result } @@ -199,18 +211,9 @@ final class WorkspaceSelectionCoordinator { return try await operation() } - private func applySelectionMirror(_ operation: () async -> Void) async { + private func deliverMirrored(_ change: Change) async { await withApplyingSelectionMirror { - await operation() + changeSubject.send(change) } } - - private func persist(_ selection: StoredSelection, for tabID: UUID, markDirty: Bool) -> Bool { - guard let workspaceManager, var tab = workspaceManager.composeTab(with: tabID) else { return false } - guard tab.selection != selection else { return false } - tab.selection = selection - tab.lastModified = Date() - workspaceManager.updateComposeTabStoredOnly(tab) - return true - } } diff --git a/Sources/RepoPrompt/Infrastructure/WorkspaceContext/TokenAccounting/TokenCalculationSnapshot.swift b/Sources/RepoPrompt/Infrastructure/WorkspaceContext/TokenAccounting/TokenCalculationSnapshot.swift deleted file mode 100644 index 4ee230e20..000000000 --- a/Sources/RepoPrompt/Infrastructure/WorkspaceContext/TokenAccounting/TokenCalculationSnapshot.swift +++ /dev/null @@ -1,26 +0,0 @@ -import Foundation - -struct PromptFileEntrySnapshot { - let fileID: UUID - let relativePath: String - let isCodemapRequested: Bool - let ranges: [LineRange]? - let cachedFullTokenCount: Int? - let loadedContent: String? - let codeMapContent: String? - let availableCodeMapTokenCount: Int -} - -enum TokenCalculationFileTreeInput { - case none - case rendered(String) - case snapshot(FileTreeSelectionSnapshot) -} - -struct TokenCalculationSnapshot { - let promptText: String - let selectedInstructionsText: String - let duplicateUserInstructionsAtTop: Bool - let promptEntries: [PromptFileEntrySnapshot] - let fileTree: TokenCalculationFileTreeInput -} diff --git a/Sources/RepoPrompt/Support/RepoPrompt-Bridging-Header.h b/Sources/RepoPrompt/Support/RepoPrompt-Bridging-Header.h deleted file mode 100644 index aece2480a..000000000 --- a/Sources/RepoPrompt/Support/RepoPrompt-Bridging-Header.h +++ /dev/null @@ -1,69 +0,0 @@ -// RepoPrompt-Bridging-Header.h -// -// Use this file to import your target's public headers that you would like to expose to Swift. -// - -#ifndef RepoPrompt_Bridging_Header_h -#define RepoPrompt_Bridging_Header_h - -#include -#include -#include -#include -#include - -// Define PT_DENY_ATTACH if not already defined -#ifndef PT_DENY_ATTACH -#define PT_DENY_ATTACH 31 -#endif - - -// Forward declare TSLanguage so the compiler knows it's a struct. -typedef struct TSLanguage TSLanguage; - -const TSLanguage * tree_sitter_javascript(void); -const TSLanguage * tree_sitter_python(void); -const TSLanguage * tree_sitter_c_sharp(void); -const TSLanguage * tree_sitter_swift(void); -const TSLanguage * tree_sitter_c(void); -const TSLanguage * tree_sitter_cpp(void); -const TSLanguage * tree_sitter_rust(void); -const TSLanguage * tree_sitter_go(void); -const TSLanguage * tree_sitter_java(void); // Java support added -const TSLanguage * tree_sitter_dart(void); // Dart support added -const TSLanguage * tree_sitter_php(void); // Dart support added -const TSLanguage * tree_sitter_ruby(void); // Ruby support added - -// Bundled wildmatch matcher for gitignore-compatible pattern matching -int repo_wildmatch(const char *pattern, const char *text, unsigned int flags); - -// Gitignore-specific matching functions -int repo_gitignore_match_anchored(const char *pattern, const char *path); -int repo_gitignore_match_anywhere(const char *pattern, const char *path); -void repo_normalize_pattern(char *dest, const char *src, size_t dest_size); - -// Pattern parsing structure -typedef struct { - char pattern[1024]; - bool is_negation; - bool directory_only; - bool absolute; -} repo_gitignore_pattern; - -// Parse a gitignore line -bool repo_parse_gitignore_line(const char *line, repo_gitignore_pattern *result); - -// String extensions from string_extensions_wrapper.h -#include "../../RepoPromptC/include/string_extensions_wrapper.h" - -// Search scoring functions -#include "../../RepoPromptC/include/search_scoring.h" - -// Path search functions -#include "../../RepoPromptC/include/path_search.h" - - -// PCRE2 regex (vendored from SwiftPCRE2) -#include "../../CSwiftPCRE2/include/CSwiftPCRE2.h" - -#endif /* RepoPrompt_Bridging_Header_h */ diff --git a/Sources/RepoPrompt/ThirdParty/SwiftPCRE2/PCRE2Error.swift b/Sources/RepoPrompt/ThirdParty/SwiftPCRE2/PCRE2Error.swift deleted file mode 100644 index bb16c20cd..000000000 --- a/Sources/RepoPrompt/ThirdParty/SwiftPCRE2/PCRE2Error.swift +++ /dev/null @@ -1,56 +0,0 @@ -import Foundation - -public enum PCRE2LimitKind: Sendable, Equatable { - case match - case depth - case heap - case jitStack - - var description: String { - switch self { - case .match: - return "MATCHLIMIT" - case .depth: - return "DEPTHLIMIT" - case .heap: - return "HEAPLIMIT" - case .jitStack: - return "JIT_STACKLIMIT" - } - } -} - -public enum PCRE2Error: Error, LocalizedError, Sendable, Equatable { - case compile(pattern: String, offset: Int, code: Int32, message: String) - case match(code: Int32, message: String) - case matchLimitExceeded(kind: PCRE2LimitKind, code: Int32, message: String) - case jitRequiredButUnavailable(String) - case internalInvariant(String) - - public var errorDescription: String? { - switch self { - case let .compile(pattern, offset, code, message): - return "PCRE2 compile error at byte offset \(offset) for pattern \(String(reflecting: pattern)) (\(code)): \(message)" - case let .match(code, message): - return "PCRE2 match error (\(code)): \(message)" - case let .matchLimitExceeded(kind, code, message): - return "PCRE2 match limit exceeded (\(kind.description), \(code)): \(message)" - case let .jitRequiredButUnavailable(message): - return "PCRE2 JIT required but unavailable: \(message)" - case let .internalInvariant(message): - return "PCRE2 wrapper invariant failed: \(message)" - } - } -} - -internal func pcre2ErrorMessage(_ code: Int32) -> String { - var buffer = [UInt8](repeating: 0, count: 512) - let rc = buffer.withUnsafeMutableBufferPointer { pointer in - rp_pcre2_get_error_message_8(Int32(code), pointer.baseAddress, pointer.count) - } - guard rc >= 0 else { - return "unknown PCRE2 error \(code)" - } - let length = buffer.firstIndex(of: 0) ?? Int(rc) - return String(decoding: buffer[.. String { - if literal.isEmpty { - return "" - } - return "\\Q" + literal.replacingOccurrences(of: "\\E", with: "\\E\\\\E\\Q") + "\\E" - } -} diff --git a/Sources/RepoPrompt/ThirdParty/SwiftPCRE2/PCRE2Match.swift b/Sources/RepoPrompt/ThirdParty/SwiftPCRE2/PCRE2Match.swift deleted file mode 100644 index 56f34b090..000000000 --- a/Sources/RepoPrompt/ThirdParty/SwiftPCRE2/PCRE2Match.swift +++ /dev/null @@ -1,9 +0,0 @@ -public struct PCRE2Match: Sendable, Equatable { - public let byteRange: Range - public let captureByteRanges: [Range?] - - public init(byteRange: Range, captureByteRanges: [Range?]) { - self.byteRange = byteRange - self.captureByteRanges = captureByteRanges - } -} diff --git a/Sources/RepoPrompt/ThirdParty/SwiftPCRE2/PCRE2Options.swift b/Sources/RepoPrompt/ThirdParty/SwiftPCRE2/PCRE2Options.swift deleted file mode 100644 index 2e9a29748..000000000 --- a/Sources/RepoPrompt/ThirdParty/SwiftPCRE2/PCRE2Options.swift +++ /dev/null @@ -1,47 +0,0 @@ -public struct PCRE2CompileOptions: OptionSet, Sendable { - public let rawValue: UInt32 - - public init(rawValue: UInt32) { - self.rawValue = rawValue - } - - public static let utf = PCRE2CompileOptions(rawValue: rp_pcre2_option_utf_8()) - public static let unicodeProperties = PCRE2CompileOptions(rawValue: rp_pcre2_option_ucp_8()) - public static let caseless = PCRE2CompileOptions(rawValue: rp_pcre2_option_caseless_8()) - public static let multiline = PCRE2CompileOptions(rawValue: rp_pcre2_option_multiline_8()) - public static let dotMatchesNewline = PCRE2CompileOptions(rawValue: rp_pcre2_option_dotall_8()) - - public static let defaultRegex: PCRE2CompileOptions = [.utf, .unicodeProperties] -} - -public struct PCRE2MatchOptions: OptionSet, Sendable { - public let rawValue: UInt32 - - public init(rawValue: UInt32) { - self.rawValue = rawValue - } - - public static let noUTFCheck = PCRE2MatchOptions(rawValue: rp_pcre2_option_no_utf_check_8()) - public static let notBOL = PCRE2MatchOptions(rawValue: rp_pcre2_option_notbol_8()) - public static let notEOL = PCRE2MatchOptions(rawValue: rp_pcre2_option_noteol_8()) - - public static let trustedSwiftString: PCRE2MatchOptions = [.noUTFCheck] -} - -public struct PCRE2MatchLimits: Sendable, Equatable { - public let matchLimit: UInt32? - public let depthLimit: UInt32? - public let heapLimitKiB: UInt32? - - public init(matchLimit: UInt32? = nil, depthLimit: UInt32? = nil, heapLimitKiB: UInt32? = nil) { - self.matchLimit = matchLimit - self.depthLimit = depthLimit - self.heapLimitKiB = heapLimitKiB - } -} - -public enum PCRE2JITMode: Sendable, Equatable { - case disabled - case auto - case required -} diff --git a/Sources/RepoPrompt/ThirdParty/SwiftPCRE2/PCRE2Regex.swift b/Sources/RepoPrompt/ThirdParty/SwiftPCRE2/PCRE2Regex.swift deleted file mode 100644 index fc9c37c19..000000000 --- a/Sources/RepoPrompt/ThirdParty/SwiftPCRE2/PCRE2Regex.swift +++ /dev/null @@ -1,1200 +0,0 @@ -public final class PCRE2Regex: @unchecked Sendable { - /// A reusable, single-consumer matching session. - /// - /// A session may be reused across multiple subjects to avoid per-match allocation - /// churn, but it owns mutable PCRE2 match state and is not thread-safe. Do not use - /// the same session concurrently from multiple tasks or threads. - public final class MatchSession { - fileprivate let regex: PCRE2Regex - fileprivate let matchData: OpaquePointer - fileprivate let matchContext: OpaquePointer? - - fileprivate init(regex: PCRE2Regex, matchLimits: PCRE2MatchLimits?) throws { - let createdMatchData = try regex.makeMatchData() - do { - let createdMatchContext = try regex.makeMatchContext(limits: matchLimits) - self.regex = regex - self.matchData = createdMatchData - self.matchContext = createdMatchContext - } catch { - rp_pcre2_match_data_free_8(createdMatchData) - throw error - } - } - - deinit { - rp_pcre2_match_data_free_8(matchData) - if let matchContext { - rp_pcre2_match_context_free_8(matchContext) - } - } - - public func firstMatch( - in subject: String, - startOffset: Int = 0, - options: PCRE2MatchOptions = .trustedSwiftString - ) throws -> PCRE2Match? { - try regex.withSubjectBuffer(for: subject) { buffer in - try regex.match(in: buffer, startOffset: startOffset, options: options, matchData: matchData, matchContext: matchContext) - } - } - - public func firstMatch( - in subject: Substring, - startOffset: Int = 0, - options: PCRE2MatchOptions = .trustedSwiftString - ) throws -> PCRE2Match? { - try regex.withSubjectBuffer(for: subject) { buffer in - try regex.match(in: buffer, startOffset: startOffset, options: options, matchData: matchData, matchContext: matchContext) - } - } - - public func containsMatch( - in subject: String, - startOffset: Int = 0, - options: PCRE2MatchOptions = .trustedSwiftString - ) throws -> Bool { - try regex.withSubjectBuffer(for: subject) { buffer in - try regex.containsMatch(in: buffer, startOffset: startOffset, options: options, matchData: matchData, matchContext: matchContext) - } - } - - public func containsMatch( - in subject: Substring, - startOffset: Int = 0, - options: PCRE2MatchOptions = .trustedSwiftString - ) throws -> Bool { - try regex.withSubjectBuffer(for: subject) { buffer in - try regex.containsMatch(in: buffer, startOffset: startOffset, options: options, matchData: matchData, matchContext: matchContext) - } - } - } - - public let pattern: String - public let compileOptions: PCRE2CompileOptions - public let jitStatus: PCRE2JITStatus - - private let code: OpaquePointer - - public init( - _ pattern: String, - options: PCRE2CompileOptions = .defaultRegex, - jit: PCRE2JITMode = .auto - ) throws { - self.pattern = pattern - self.compileOptions = options - - var errorCode: Int32 = 0 - var errorOffset = 0 - let patternBytes = Array(pattern.utf8) - let compiled: OpaquePointer? = patternBytes.withUnsafeBufferPointer { pointer in - withPCRE2BytePointer(for: pointer) { base in - rp_pcre2_compile_8(base, pointer.count, options.rawValue, &errorCode, &errorOffset) - } - } - - guard let compiled else { - throw PCRE2Error.compile( - pattern: pattern, - offset: errorOffset, - code: errorCode, - message: pcre2ErrorMessage(errorCode) - ) - } - - let resolvedJITStatus: PCRE2JITStatus - switch jit { - case .disabled: - resolvedJITStatus = .disabled - case .auto, .required: - let status = Self.compileJITIfPossible(compiled) - switch (jit, status) { - case (.required, .compiled): - resolvedJITStatus = status - case (.required, .disabled), (.required, .unavailable), (.required, .fallback): - rp_pcre2_code_free_8(compiled) - throw PCRE2Error.jitRequiredButUnavailable(status.descriptionForRequiredMode) - default: - resolvedJITStatus = status - } - } - - self.code = compiled - self.jitStatus = resolvedJITStatus - } - - deinit { - rp_pcre2_code_free_8(code) - } - - public func withMatchSession( - matchLimits: PCRE2MatchLimits? = nil, - _ body: (MatchSession) throws -> R - ) throws -> R { - let session = try MatchSession(regex: self, matchLimits: matchLimits) - return try body(session) - } - - public func firstMatch( - in subject: String, - options: PCRE2MatchOptions = .trustedSwiftString, - matchLimits: PCRE2MatchLimits? = nil - ) throws -> PCRE2Match? { - try withMatchSession(matchLimits: matchLimits) { session in - try session.firstMatch(in: subject, options: options) - } - } - - public func firstMatch( - in subject: Substring, - options: PCRE2MatchOptions = .trustedSwiftString, - matchLimits: PCRE2MatchLimits? = nil - ) throws -> PCRE2Match? { - try withMatchSession(matchLimits: matchLimits) { session in - try session.firstMatch(in: subject, options: options) - } - } - - public func enumerateMatches( - in subject: String, - options: PCRE2MatchOptions = .trustedSwiftString, - limit: Int? = nil, - matchLimits: PCRE2MatchLimits? = nil, - _ body: (PCRE2Match) throws -> Bool - ) throws { - let byteCount = subject.utf8.count - var startOffset = 0 - var emitted = 0 - - let matchData = try makeMatchData() - defer { rp_pcre2_match_data_free_8(matchData) } - - let matchContext = try makeMatchContext(limits: matchLimits) - defer { - if let matchContext { - rp_pcre2_match_context_free_8(matchContext) - } - } - - try withSubjectBuffer(for: subject) { buffer in - while startOffset <= byteCount { - if let limit, emitted >= limit { return } - guard let match = try match(in: buffer, startOffset: startOffset, options: options, matchData: matchData, matchContext: matchContext) else { - return - } - - emitted += 1 - let shouldContinue = try body(match) - if !shouldContinue { return } - - if match.byteRange.isEmpty { - let next = Self.nextUTF8ScalarBoundary(in: subject, after: startOffset) - if next <= startOffset { return } - startOffset = next - } else { - startOffset = match.byteRange.upperBound - } - } - } - } - - private func withSubjectBuffer( - for subject: String, - _ body: (UnsafeBufferPointer) throws -> R - ) throws -> R { - if let result = try subject.utf8.withContiguousStorageIfAvailable({ buffer in - try body(buffer) - }) { - return result - } - - let subjectBytes = Array(subject.utf8) - return try subjectBytes.withUnsafeBufferPointer { buffer in - try body(buffer) - } - } - - private func withSubjectBuffer( - for subject: Substring, - _ body: (UnsafeBufferPointer) throws -> R - ) throws -> R { - if let result = try subject.utf8.withContiguousStorageIfAvailable({ buffer in - try body(buffer) - }) { - return result - } - - let subjectBytes = Array(subject.utf8) - return try subjectBytes.withUnsafeBufferPointer { buffer in - try body(buffer) - } - } - - private func makeMatchData() throws -> OpaquePointer { - guard let matchData = rp_pcre2_match_data_create_from_pattern_8(code) else { - throw PCRE2Error.internalInvariant("pcre2_match_data_create_from_pattern returned nil") - } - return matchData - } - - private func makeMatchContext(limits: PCRE2MatchLimits?) throws -> OpaquePointer? { - guard let limits else { return nil } - guard let context = rp_pcre2_match_context_create_8() else { - throw PCRE2Error.internalInvariant("pcre2_match_context_create returned nil") - } - - do { - if let limit = limits.matchLimit { - try applyMatchContextLimit(rp_pcre2_set_match_limit_8(context, limit), name: "match") - } - if let limit = limits.depthLimit { - try applyMatchContextLimit(rp_pcre2_set_depth_limit_8(context, limit), name: "depth") - } - if let limit = limits.heapLimitKiB { - try applyMatchContextLimit(rp_pcre2_set_heap_limit_8(context, limit), name: "heap") - } - return context - } catch { - rp_pcre2_match_context_free_8(context) - throw error - } - } - - private func applyMatchContextLimit(_ rc: Int32, name: String) throws { - guard rc == 0 else { - throw PCRE2Error.internalInvariant("pcre2_set_\(name)_limit failed (\(rc)): \(pcre2ErrorMessage(rc))") - } - } - - private func match( - in buffer: UnsafeBufferPointer, - startOffset: Int, - options: PCRE2MatchOptions, - matchData: OpaquePointer, - matchContext: OpaquePointer? - ) throws -> PCRE2Match? { - let rc = rawMatchReturnCode(in: buffer, startOffset: startOffset, options: options, matchData: matchData, matchContext: matchContext) - - if rc == rp_pcre2_error_nomatch_8() { - return nil - } - guard rc >= 0 else { - throw Self.matchError(for: rc) - } - - let count = Int(rp_pcre2_get_ovector_count_8(matchData)) - guard let ovector = rp_pcre2_get_ovector_pointer_8(matchData) else { - throw PCRE2Error.internalInvariant("pcre2_get_ovector_pointer returned nil") - } - let unset = Int(rp_pcre2_unset_8()) - var ranges: [Range?] = [] - ranges.reserveCapacity(count) - - for index in 0.., - startOffset: Int, - options: PCRE2MatchOptions, - matchData: OpaquePointer, - matchContext: OpaquePointer? - ) throws -> Bool { - let rc = rawMatchReturnCode(in: buffer, startOffset: startOffset, options: options, matchData: matchData, matchContext: matchContext) - - if rc == rp_pcre2_error_nomatch_8() { - return false - } - guard rc >= 0 else { - throw Self.matchError(for: rc) - } - return true - } - - private func rawMatchReturnCode( - in buffer: UnsafeBufferPointer, - startOffset: Int, - options: PCRE2MatchOptions, - matchData: OpaquePointer, - matchContext: OpaquePointer? - ) -> Int32 { - withPCRE2BytePointer(for: buffer) { base in - if jitStatus.isCompiled { - let jitRC = rp_pcre2_jit_match_with_context_8(code, base, buffer.count, startOffset, options.rawValue, matchData, matchContext) - if jitRC != rp_pcre2_error_jit_badoption_8() { - return jitRC - } - } - return rp_pcre2_match_with_context_8(code, base, buffer.count, startOffset, options.rawValue, matchData, matchContext) - } - } - - private static func matchError(for code: Int32) -> PCRE2Error { - let message = pcre2ErrorMessage(code) - if let kind = limitKind(for: code) { - return .matchLimitExceeded(kind: kind, code: code, message: message) - } - return .match(code: code, message: message) - } - - private static func limitKind(for code: Int32) -> PCRE2LimitKind? { - if code == rp_pcre2_error_matchlimit_8() { - return .match - } - if code == rp_pcre2_error_depthlimit_8() { - return .depth - } - if code == rp_pcre2_error_heaplimit_8() { - return .heap - } - if code == rp_pcre2_error_jit_stacklimit_8() { - return .jitStack - } - return nil - } - - private static func compileJITIfPossible(_ code: OpaquePointer) -> PCRE2JITStatus { - let configured = rp_pcre2_config_jit_8() - if configured <= 0 { - return .unavailable(reason: configured == 0 ? "PCRE2 was built without JIT support" : pcre2ErrorMessage(configured)) - } - - let rc = rp_pcre2_jit_compile_8(code, rp_pcre2_jit_complete_8()) - guard rc == 0 else { - return .fallback(errorCode: rc, message: pcre2ErrorMessage(rc)) - } - - var size = 0 - let infoRC = rp_pcre2_jit_size_8(code, &size) - guard infoRC == 0 else { - return .fallback(errorCode: infoRC, message: pcre2ErrorMessage(infoRC)) - } - if size > 0 { - return .compiled(sizeBytes: size) - } - return .unavailable(reason: "PCRE2 accepted JIT compilation but reported no JIT code size") - } - - private static func nextUTF8ScalarBoundary(in subject: String, after byteOffset: Int) -> Int { - let byteCount = subject.utf8.count - if byteOffset >= byteCount { return byteCount } - - var lower = 0 - for scalar in subject.unicodeScalars { - let upper = lower + scalar.utf8.count - if byteOffset < upper { - return upper - } - lower = upper - } - return byteCount - } -} - -public struct PCRE2LinePrefilter: Sendable, Equatable { - public let asciiRequiredAlternatives: [String] - public let caseInsensitive: Bool - - public init(asciiRequiredAlternatives: [String], caseInsensitive: Bool) { - self.asciiRequiredAlternatives = asciiRequiredAlternatives - self.caseInsensitive = caseInsensitive - } -} - -public struct PCRE2LineScanOptions: Sendable, Equatable { - public let maxLineUTF8Length: Int? - public let collectMatches: Bool - public let maxCollectedMatches: Int? - public let cancellationCheckStride: Int - public let prefilter: PCRE2LinePrefilter? - - public init( - maxLineUTF8Length: Int? = nil, - collectMatches: Bool = true, - maxCollectedMatches: Int? = nil, - cancellationCheckStride: Int = 256, - prefilter: PCRE2LinePrefilter? = nil - ) { - self.maxLineUTF8Length = maxLineUTF8Length - self.collectMatches = collectMatches - self.maxCollectedMatches = maxCollectedMatches - self.cancellationCheckStride = max(1, cancellationCheckStride) - self.prefilter = prefilter - } -} - -public struct PCRE2LineScanResult: Sendable, Equatable { - public let matchingLineNumbers: [Int] - public let lineMatchCount: Int - - public init(matchingLineNumbers: [Int], lineMatchCount: Int) { - self.matchingLineNumbers = matchingLineNumbers - self.lineMatchCount = lineMatchCount - } -} - -public struct PCRE2LineRangeHit: Sendable, Equatable { - public let lineNumber: Int - public let byteRange: Range - - public init(lineNumber: Int, byteRange: Range) { - self.lineNumber = lineNumber - self.byteRange = byteRange - } -} - -public struct PCRE2LineRangeScanResult: Sendable, Equatable { - public let hits: [PCRE2LineRangeHit] - public let lineMatchCount: Int - - public init(hits: [PCRE2LineRangeHit], lineMatchCount: Int) { - self.hits = hits - self.lineMatchCount = lineMatchCount - } -} - -public enum PCRE2LineMode: Sendable, Equatable { - case crlf -} - -public struct PCRE2ASCIIMarkerLinePattern: Sendable, Equatable { - private static let speculativeCollectCapacity = 64 - private static let maximumRequestedCollectCapacity = 16_384 - - public let marker: String - public let digitCount: UInt32 - public let requiredPrefix: String - public let caseInsensitive: Bool - private let markerBytes: [UInt8] - private let requiredPrefixBytes: [UInt8] - - public init?(marker: String, digitCount: UInt32, requiredPrefix: String, caseInsensitive: Bool) { - guard digitCount > 0, - !marker.isEmpty, - !requiredPrefix.isEmpty, - marker.utf8.allSatisfy({ PCRE2ASCIIWholeWordLiteral.isASCIIWordByte($0) }), - requiredPrefix.utf8.allSatisfy({ PCRE2ASCIIWholeWordLiteral.isASCIIWordByte($0) }) else { - return nil - } - self.marker = marker - self.digitCount = digitCount - self.requiredPrefix = requiredPrefix - self.caseInsensitive = caseInsensitive - self.markerBytes = marker.utf8.map { caseInsensitive ? PCRE2ASCIIWholeWordLiteral.asciiLowercase($0) : $0 } - self.requiredPrefixBytes = requiredPrefix.utf8.map { caseInsensitive ? PCRE2ASCIIWholeWordLiteral.asciiLowercase($0) : $0 } - } - - public func countMatchingLines(in subject: String) -> Int? { - scanMatchingLines(in: subject, collectMatches: false)?.lineMatchCount - } - - public func scanMatchingLineRanges( - in subject: String, - maxCollectedMatches: Int, - shouldCancel: () -> Bool = { false } - ) -> PCRE2LineRangeScanResult? { - guard maxCollectedMatches > 0 else { return PCRE2LineRangeScanResult(hits: [], lineMatchCount: 0) } - if shouldCancel() { return PCRE2LineRangeScanResult(hits: [], lineMatchCount: 0) } - guard !markerBytes.isEmpty, !requiredPrefixBytes.isEmpty else { return nil } - - func scan(_ buffer: UnsafeBufferPointer) -> PCRE2LineRangeScanResult? { - func collect(capacity requestedCapacity: Int) -> PCRE2LineRangeScanResult? { - let capacity = max(0, min(maxCollectedMatches, requestedCapacity)) - var lineNumbers = Array(repeating: 0, count: capacity) - var lineStarts = Array(repeating: 0, count: capacity) - var lineEnds = Array(repeating: 0, count: capacity) - var collectedCount = 0 - var lineCount = 0 - var nonASCII: Int32 = 0 - let rc = lineNumbers.withUnsafeMutableBufferPointer { lineNumberBuffer in - lineStarts.withUnsafeMutableBufferPointer { lineStartBuffer in - lineEnds.withUnsafeMutableBufferPointer { lineEndBuffer in - markerBytes.withUnsafeBufferPointer { markerBuffer in - requiredPrefixBytes.withUnsafeBufferPointer { prefixBuffer in - withPCRE2BytePointer(for: buffer) { subjectBase in - withPCRE2BytePointer(for: markerBuffer) { markerBase in - withPCRE2BytePointer(for: prefixBuffer) { prefixBase in - rp_pcre2_ascii_marker_line_range_scan_8( - subjectBase, - buffer.count, - markerBase, - markerBuffer.count, - digitCount, - prefixBase, - prefixBuffer.count, - caseInsensitive ? 1 : 0, - lineNumberBuffer.baseAddress, - lineStartBuffer.baseAddress, - lineEndBuffer.baseAddress, - lineNumberBuffer.count, - &collectedCount, - &lineCount, - &nonASCII - ) - } - } - } - } - } - } - } - } - guard rc == 0, nonASCII == 0 else { return nil } - let hits = (0.. Bool = { false } - ) -> PCRE2LineScanResult? { - if shouldCancel() { return PCRE2LineScanResult(matchingLineNumbers: [], lineMatchCount: 0) } - guard !markerBytes.isEmpty, !requiredPrefixBytes.isEmpty else { return nil } - - func scan(_ buffer: UnsafeBufferPointer) -> PCRE2LineScanResult? { - var lineCount = 0 - var nonASCII: Int32 = 0 - - func runScan(lineNumbers: UnsafeMutablePointer?, capacity: Int, collectedCount: inout Int) -> Int32 { - markerBytes.withUnsafeBufferPointer { markerBuffer in - requiredPrefixBytes.withUnsafeBufferPointer { prefixBuffer in - withPCRE2BytePointer(for: buffer) { subjectBase in - withPCRE2BytePointer(for: markerBuffer) { markerBase in - withPCRE2BytePointer(for: prefixBuffer) { prefixBase in - rp_pcre2_ascii_marker_line_scan_8( - subjectBase, - buffer.count, - markerBase, - markerBuffer.count, - digitCount, - prefixBase, - prefixBuffer.count, - caseInsensitive ? 1 : 0, - lineNumbers, - capacity, - &collectedCount, - &lineCount, - &nonASCII - ) - } - } - } - } - } - } - - func countOnlyResult() -> PCRE2LineScanResult? { - var ignoredCollectedCount = 0 - lineCount = 0 - nonASCII = 0 - let rc = runScan(lineNumbers: nil, capacity: 0, collectedCount: &ignoredCollectedCount) - guard rc == 0, nonASCII == 0 else { return nil } - return PCRE2LineScanResult(matchingLineNumbers: [], lineMatchCount: lineCount) - } - - func collectResult(capacity: Int) -> PCRE2LineScanResult? { - guard capacity > 0 else { return countOnlyResult() } - var collectedLines = Array(repeating: 0, count: capacity) - var collectedCount = 0 - lineCount = 0 - nonASCII = 0 - let rc = collectedLines.withUnsafeMutableBufferPointer { lineBuffer in - runScan(lineNumbers: lineBuffer.baseAddress, capacity: lineBuffer.count, collectedCount: &collectedCount) - } - guard rc == 0, nonASCII == 0 else { return nil } - return PCRE2LineScanResult(matchingLineNumbers: Array(collectedLines.prefix(collectedCount)), lineMatchCount: lineCount) - } - - guard collectMatches else { return countOnlyResult() } - if let maxCollectedMatches { - guard maxCollectedMatches > 0 else { return countOnlyResult() } - if maxCollectedMatches > Self.maximumRequestedCollectCapacity { - guard let counted = countOnlyResult() else { return nil } - let exactCapacity = min(maxCollectedMatches, counted.lineMatchCount) - guard exactCapacity > 0 else { return counted } - return collectResult(capacity: exactCapacity) - } - return collectResult(capacity: maxCollectedMatches) - } - - guard let speculative = collectResult(capacity: Self.speculativeCollectCapacity) else { return nil } - if speculative.lineMatchCount <= Self.speculativeCollectCapacity { - return speculative - } - return collectResult(capacity: speculative.lineMatchCount) - } - - if let contiguous = subject.utf8.withContiguousStorageIfAvailable({ buffer in - scan(buffer) - }) { - return contiguous - } - let bytes = Array(subject.utf8) - return bytes.withUnsafeBufferPointer { scan($0) } - } -} - -public struct PCRE2ASCIIWholeWordLiteral: Sendable, Equatable { - public let needle: String - public let caseInsensitive: Bool - - public init?(needle: String, caseInsensitive: Bool) { - guard !needle.isEmpty, - needle.utf8.allSatisfy({ PCRE2ASCIIWholeWordLiteral.isASCIIWordByte($0) }) else { - return nil - } - self.needle = needle - self.caseInsensitive = caseInsensitive - } - - public func countMatchingLines(in subject: String) -> Int? { - scanMatchingLines(in: subject, collectMatches: false)?.lineMatchCount - } - - public func scanMatchingLines( - in subject: String, - lineMode: PCRE2LineMode = .crlf, - collectMatches: Bool, - maxCollectedMatches: Int? = nil, - cancellationCheckStride: Int = 256, - shouldCancel: () -> Bool = { false } - ) -> PCRE2LineScanResult? { - guard lineMode == .crlf else { return nil } - if collectMatches, subject.utf8.count > Self.cScanCollectByteLimit { - return nil - } - if shouldCancel() { return PCRE2LineScanResult(matchingLineNumbers: [], lineMatchCount: 0) } - let needleBytes = needle.utf8.map { caseInsensitive ? Self.asciiLowercase($0) : $0 } - guard !needleBytes.isEmpty else { return nil } - - func scan(_ buffer: UnsafeBufferPointer) -> PCRE2LineScanResult? { - var lineCount = 0 - var nonASCII: Int32 = 0 - - func runScan(lineNumbers: UnsafeMutablePointer?, capacity: Int, collectedCount: inout Int) -> Int32 { - needleBytes.withUnsafeBufferPointer { needleBuffer in - withPCRE2BytePointer(for: buffer) { subjectBase in - withPCRE2BytePointer(for: needleBuffer) { needleBase in - rp_pcre2_ascii_whole_word_line_scan_8( - subjectBase, - buffer.count, - needleBase, - needleBuffer.count, - caseInsensitive ? 1 : 0, - lineNumbers, - capacity, - &collectedCount, - &lineCount, - &nonASCII - ) - } - } - } - } - - var ignoredCollectedCount = 0 - let countRC = runScan(lineNumbers: nil, capacity: 0, collectedCount: &ignoredCollectedCount) - guard countRC == 0, nonASCII == 0 else { return nil } - guard collectMatches, lineCount > 0 else { - return PCRE2LineScanResult(matchingLineNumbers: [], lineMatchCount: lineCount) - } - - let capacity = min(maxCollectedMatches ?? lineCount, lineCount) - guard capacity > 0 else { - return PCRE2LineScanResult(matchingLineNumbers: [], lineMatchCount: lineCount) - } - - var collectedLines = Array(repeating: 0, count: capacity) - var collectedCount = 0 - lineCount = 0 - nonASCII = 0 - let collectRC = collectedLines.withUnsafeMutableBufferPointer { lineBuffer in - runScan(lineNumbers: lineBuffer.baseAddress, capacity: lineBuffer.count, collectedCount: &collectedCount) - } - guard collectRC == 0, nonASCII == 0 else { return nil } - return PCRE2LineScanResult( - matchingLineNumbers: Array(collectedLines.prefix(collectedCount)), - lineMatchCount: lineCount - ) - } - - if let contiguous = subject.utf8.withContiguousStorageIfAvailable({ buffer in - scan(buffer) - }) { - return contiguous - } - let bytes = Array(subject.utf8) - return bytes.withUnsafeBufferPointer { scan($0) } - } - - private static let cScanCollectByteLimit = 4 * 1024 * 1024 - - private static func matchesWholeWordNeedle( - at index: Int, - in buffer: UnsafeBufferPointer, - lineStart: Int, - needle: [UInt8], - caseInsensitive: Bool - ) -> Bool { - let first = caseInsensitive ? asciiLowercase(buffer[index]) : buffer[index] - guard first == needle[0] else { return false } - if needle.count > 1 { - for offset in 1.. lineStart && isASCIIWordByte(buffer[index - 1]) - let nextIndex = index + needle.count - let nextIsWord = nextIndex < buffer.count && buffer[nextIndex] != 10 && buffer[nextIndex] != 13 && isASCIIWordByte(buffer[nextIndex]) - return !previousIsWord && !nextIsWord - } - - private static func lineContainsWholeWordNeedle( - _ buffer: UnsafeBufferPointer, - range: Range, - needle: [UInt8], - caseInsensitive: Bool - ) -> Bool { - let count = needle.count - guard count > 0, range.count >= count else { return false } - var index = range.lowerBound - let lastStart = range.upperBound - count - while index <= lastStart { - let current = caseInsensitive ? asciiLowercase(buffer[index]) : buffer[index] - if current == needle[0] { - var matched = true - if count > 1 { - for offset in 1.. range.lowerBound && isASCIIWordByte(buffer[index - 1]) - let nextIndex = index + count - let nextIsWord = nextIndex < range.upperBound && isASCIIWordByte(buffer[nextIndex]) - if !previousIsWord && !nextIsWord { - return true - } - } - } - index += 1 - } - return false - } - - fileprivate static func isASCIIWordByte(_ byte: UInt8) -> Bool { - (byte >= 65 && byte <= 90) || (byte >= 97 && byte <= 122) || (byte >= 48 && byte <= 57) || byte == 95 - } - - fileprivate static func asciiLowercase(_ byte: UInt8) -> UInt8 { - (byte >= 65 && byte <= 90) ? byte + 32 : byte - } -} - -public struct PCRE2AnchoredDeclarationLinePattern: Sendable, Equatable { - public let caseInsensitive: Bool - - public init(caseInsensitive: Bool) { - self.caseInsensitive = caseInsensitive - } - - public func scanMatchingLines( - in subject: String, - collectMatches: Bool, - maxCollectedMatches: Int? = nil, - cancellationCheckStride: Int = 256, - shouldCancel: () -> Bool = { false } - ) -> PCRE2LineScanResult? { - if subject.utf8.count <= Self.cScanByteLimit { - return scanMatchingLinesWithC( - in: subject, - collectMatches: collectMatches, - maxCollectedMatches: maxCollectedMatches, - shouldCancel: shouldCancel - ) - } - return scanMatchingLinesWithSwift( - in: subject, - collectMatches: collectMatches, - maxCollectedMatches: maxCollectedMatches, - cancellationCheckStride: cancellationCheckStride, - shouldCancel: shouldCancel - ) - } - - private func scanMatchingLinesWithC( - in subject: String, - collectMatches: Bool, - maxCollectedMatches: Int?, - shouldCancel: () -> Bool - ) -> PCRE2LineScanResult? { - if shouldCancel() { return PCRE2LineScanResult(matchingLineNumbers: [], lineMatchCount: 0) } - - func scan(_ buffer: UnsafeBufferPointer) -> PCRE2LineScanResult? { - var lineCount = 0 - var fallbackRequired: Int32 = 0 - - func runScan(lineNumbers: UnsafeMutablePointer?, capacity: Int, collectedCount: inout Int) -> Int32 { - withPCRE2BytePointer(for: buffer) { subjectBase in - rp_pcre2_ascii_declaration_line_scan_8( - subjectBase, - buffer.count, - caseInsensitive ? 1 : 0, - lineNumbers, - capacity, - &collectedCount, - &lineCount, - &fallbackRequired - ) - } - } - - var ignoredCollectedCount = 0 - let countRC = runScan(lineNumbers: nil, capacity: 0, collectedCount: &ignoredCollectedCount) - guard countRC == 0, fallbackRequired == 0 else { return nil } - guard collectMatches, lineCount > 0 else { - return PCRE2LineScanResult(matchingLineNumbers: [], lineMatchCount: lineCount) - } - - let capacity = min(maxCollectedMatches ?? lineCount, lineCount) - guard capacity > 0 else { - return PCRE2LineScanResult(matchingLineNumbers: [], lineMatchCount: lineCount) - } - - var collectedLines = Array(repeating: 0, count: capacity) - var collectedCount = 0 - lineCount = 0 - fallbackRequired = 0 - let collectRC = collectedLines.withUnsafeMutableBufferPointer { lineBuffer in - runScan(lineNumbers: lineBuffer.baseAddress, capacity: lineBuffer.count, collectedCount: &collectedCount) - } - guard collectRC == 0, fallbackRequired == 0 else { return nil } - return PCRE2LineScanResult( - matchingLineNumbers: Array(collectedLines.prefix(collectedCount)), - lineMatchCount: lineCount - ) - } - - if let contiguous = subject.utf8.withContiguousStorageIfAvailable({ buffer in - scan(buffer) - }) { - return contiguous - } - let bytes = Array(subject.utf8) - return bytes.withUnsafeBufferPointer { scan($0) } - } - - private func scanMatchingLinesWithSwift( - in subject: String, - collectMatches: Bool, - maxCollectedMatches: Int?, - cancellationCheckStride: Int, - shouldCancel: () -> Bool - ) -> PCRE2LineScanResult? { - guard subject.utf8.allSatisfy({ $0 < 0x80 }) else { return nil } - var matchingLines: [Int] = [] - if collectMatches { matchingLines.reserveCapacity(8) } - var matchCount = 0 - - func scan(_ buffer: UnsafeBufferPointer) { - forEachPCRE2CRLFLine(in: buffer) { lineNumber, range in - if lineNumber % max(1, cancellationCheckStride) == 0, shouldCancel() { - return false - } - if Self.matchesDeclarationLine(buffer, range: range, caseInsensitive: caseInsensitive) { - matchCount += 1 - if collectMatches, maxCollectedMatches.map({ matchingLines.count < $0 }) ?? true { - matchingLines.append(lineNumber) - } - } - return true - } - } - - var usedContiguousStorage = false - subject.utf8.withContiguousStorageIfAvailable { buffer in - usedContiguousStorage = true - scan(buffer) - } - if !usedContiguousStorage { - let bytes = Array(subject.utf8) - bytes.withUnsafeBufferPointer { scan($0) } - } - - return PCRE2LineScanResult(matchingLineNumbers: matchingLines, lineMatchCount: matchCount) - } - - private static let cScanByteLimit = 4 * 1024 * 1024 - - private static func matchesDeclarationLine(_ buffer: UnsafeBufferPointer, range: Range, caseInsensitive: Bool) -> Bool { - var index = range.lowerBound - while index < range.upperBound, isHorizontalWhitespace(buffer[index]) { - index += 1 - } - - let saved = index - if consumeWord("final", in: buffer, range: range, index: &index, caseInsensitive: caseInsensitive) { - guard index < range.upperBound, isHorizontalWhitespace(buffer[index]) else { return false } - repeat { index += 1 } while index < range.upperBound && isHorizontalWhitespace(buffer[index]) - } else { - index = saved - } - - let matchedKeyword = consumeWord("class", in: buffer, range: range, index: &index, caseInsensitive: caseInsensitive) - || consumeWord("struct", in: buffer, range: range, index: &index, caseInsensitive: caseInsensitive) - || consumeWord("func", in: buffer, range: range, index: &index, caseInsensitive: caseInsensitive) - guard matchedKeyword, - index < range.upperBound, - isHorizontalWhitespace(buffer[index]) else { return false } - repeat { index += 1 } while index < range.upperBound && isHorizontalWhitespace(buffer[index]) - guard index < range.upperBound else { return false } - let first = buffer[index] - guard (first >= 65 && first <= 90) || (first >= 97 && first <= 122) || first == 95 else { return false } - return true - } - - private static func consumeWord(_ word: String, in buffer: UnsafeBufferPointer, range: Range, index: inout Int, caseInsensitive: Bool) -> Bool { - let start = index - for expected in word.utf8 { - guard index < range.upperBound else { index = start; return false } - let hay = caseInsensitive ? PCRE2ASCIIWholeWordLiteral.asciiLowercase(buffer[index]) : buffer[index] - guard hay == expected else { index = start; return false } - index += 1 - } - return true - } - - private static func isHorizontalWhitespace(_ byte: UInt8) -> Bool { - switch byte { - case 9, 11, 12, 32: - return true - default: - return false - } - } -} - -public struct PCRE2PathSuffixPattern: Sendable, Equatable { - public let suffixes: [String] - public let basenamePrefix: String? - public let singleDigitRange: ClosedRange? - - public init(suffixes: [String], basenamePrefix: String? = nil, singleDigitRange: ClosedRange? = nil) { - self.suffixes = suffixes - self.basenamePrefix = basenamePrefix - self.singleDigitRange = singleDigitRange - } - - public func matches(_ candidate: String, caseInsensitive: Bool) -> Bool { - let haystack = caseInsensitive ? candidate.lowercased() : candidate - let basenameStart = haystack.lastIndex(of: "/").map { haystack.index(after: $0) } ?? haystack.startIndex - let basename = haystack[basenameStart...] - for suffix in suffixes { - let effectiveSuffix = caseInsensitive ? suffix.lowercased() : suffix - if let basenamePrefix { - let effectivePrefix = caseInsensitive ? basenamePrefix.lowercased() : basenamePrefix - if let singleDigitRange { - for digit in singleDigitRange { - let scalar = UnicodeScalar(Int(digit)) ?? UnicodeScalar(48)! - if basename.hasSuffix(effectivePrefix + String(scalar) + effectiveSuffix) { - return true - } - } - continue - } - if basename.hasSuffix(effectivePrefix + effectiveSuffix) { - return true - } - continue - } - if haystack.hasSuffix(effectiveSuffix) { - return true - } - } - return false - } -} - -public extension PCRE2Regex.MatchSession { - func scanMatchingLines( - in subject: String, - options: PCRE2LineScanOptions, - shouldCancel: () -> Bool = { false } - ) throws -> PCRE2LineScanResult { - let prefilterNeedles = options.prefilter?.preparedNeedles() ?? [] - var matchingLines: [Int] = [] - if options.collectMatches { - matchingLines.reserveCapacity(8) - } - var matchCount = 0 - var cancelled = false - - try regex.withSubjectBuffer(for: subject) { buffer in - try forEachPCRE2CRLFLine(in: buffer) { lineNumber, range in - if lineNumber % options.cancellationCheckStride == 0, shouldCancel() { - cancelled = true - return false - } - if let maxLineUTF8Length = options.maxLineUTF8Length, range.count > maxLineUTF8Length { - return true - } - if !prefilterNeedles.isEmpty, !lineContainsAnyPrefilterNeedle(buffer, range: range, needles: prefilterNeedles, caseInsensitive: options.prefilter?.caseInsensitive ?? false) { - return true - } - let base = buffer.baseAddress?.advanced(by: range.lowerBound) - let lineBuffer = UnsafeBufferPointer(start: base, count: range.count) - if try regex.containsMatch(in: lineBuffer, startOffset: 0, options: .trustedSwiftString, matchData: matchData, matchContext: matchContext) { - matchCount += 1 - if options.collectMatches, options.maxCollectedMatches.map({ matchingLines.count < $0 }) ?? true { - matchingLines.append(lineNumber) - } - } - return true - } - } - - if cancelled { - return PCRE2LineScanResult(matchingLineNumbers: matchingLines, lineMatchCount: matchCount) - } - return PCRE2LineScanResult(matchingLineNumbers: matchingLines, lineMatchCount: matchCount) - } -} - -private extension PCRE2LinePrefilter { - func preparedNeedles() -> [[UInt8]] { - asciiRequiredAlternatives.compactMap { alternative in - let bytes = alternative.utf8.map { caseInsensitive ? PCRE2ASCIIWholeWordLiteral.asciiLowercase($0) : $0 } - guard !bytes.isEmpty, bytes.allSatisfy({ $0 < 0x80 }) else { return nil } - return bytes - } - } -} - -private func lineContainsAnyPrefilterNeedle( - _ buffer: UnsafeBufferPointer, - range: Range, - needles: [[UInt8]], - caseInsensitive: Bool -) -> Bool { - for needle in needles { - guard range.count >= needle.count else { continue } - var index = range.lowerBound - let lastStart = range.upperBound - needle.count - while index <= lastStart { - var matched = true - for offset in 0.., - _ body: (Int, Range) throws -> Bool -) rethrows -> Bool { - guard buffer.count > 0 else { return true } - - var lineNumber = 0 - var lineStart = 0 - var index = 0 - while index < buffer.count { - let byte = buffer[index] - if byte == 10 || byte == 13 { - if try !body(lineNumber, lineStart..( - for buffer: UnsafeBufferPointer, - _ body: (UnsafePointer) -> R -) -> R { - if let base = buffer.baseAddress { - return body(base) - } - var emptyByte: UInt8 = 0 - return withUnsafePointer(to: &emptyByte) { pointer in - body(pointer) - } -} - -private extension PCRE2JITStatus { - var descriptionForRequiredMode: String { - switch self { - case .disabled: - return "JIT mode is disabled" - case let .unavailable(reason): - return reason - case let .compiled(sizeBytes): - return "compiled (\(sizeBytes) bytes)" - case let .fallback(errorCode, message): - return "JIT compile failed (\(errorCode)): \(message)" - } - } -} diff --git a/Sources/RepoPromptC/include/descriptor_path.h b/Sources/RepoPromptC/include/descriptor_path.h new file mode 100644 index 000000000..047b3ce4b --- /dev/null +++ b/Sources/RepoPromptC/include/descriptor_path.h @@ -0,0 +1,3 @@ +#pragma once + +int repo_prompt_descriptor_get_path(int descriptor, char *buffer); diff --git a/Sources/RepoPromptC/include/string_extensions_wrapper.h b/Sources/RepoPromptC/include/string_extensions_wrapper.h index 5c70cad5c..2c979ceb1 100644 --- a/Sources/RepoPromptC/include/string_extensions_wrapper.h +++ b/Sources/RepoPromptC/include/string_extensions_wrapper.h @@ -15,6 +15,10 @@ extern "C" { #endif +/* Narrow allocation wrappers for Swift consumers that should not import Darwin. */ +char* repo_strdup(const char *text); +void repo_free(void *pointer); + /* Levenshtein distance calculation with optional cap * Returns actual distance if <= maxDist, or maxDist + 1 if greater * Pass maxDist = -1 for uncapped calculation diff --git a/Sources/RepoPromptC/include/wildmatch.h b/Sources/RepoPromptC/include/wildmatch.h index 29d23f722..88dd89fec 100644 --- a/Sources/RepoPromptC/include/wildmatch.h +++ b/Sources/RepoPromptC/include/wildmatch.h @@ -39,6 +39,9 @@ #ifndef _WILDMATCH_H_ #define _WILDMATCH_H_ +#include +#include + #ifdef __cplusplus extern "C" { #endif @@ -73,6 +76,21 @@ extern "C" { int wildmatch(const char *pattern, const char *string, int flags); +/* Swift-friendly gitignore wrappers. */ +int repo_wildmatch(const char *pattern, const char *text, unsigned int flags); +int repo_gitignore_match_anchored(const char *pattern, const char *path); +int repo_gitignore_match_anywhere(const char *pattern, const char *path); +void repo_normalize_pattern(char *dest, const char *src, size_t dest_size); + +typedef struct { + char pattern[1024]; + bool is_negation; + bool directory_only; + bool absolute; +} repo_gitignore_pattern; + +bool repo_parse_gitignore_line(const char *line, repo_gitignore_pattern *result); + #ifdef __cplusplus } diff --git a/Sources/RepoPromptC/src/Utils/descriptor_path.c b/Sources/RepoPromptC/src/Utils/descriptor_path.c new file mode 100644 index 000000000..44b1b46c1 --- /dev/null +++ b/Sources/RepoPromptC/src/Utils/descriptor_path.c @@ -0,0 +1,7 @@ +#include "descriptor_path.h" + +#include + +int repo_prompt_descriptor_get_path(int descriptor, char *buffer) { + return fcntl(descriptor, F_GETPATH, buffer); +} diff --git a/Sources/RepoPromptC/src/Utils/string_extensions_wrapper.c b/Sources/RepoPromptC/src/Utils/string_extensions_wrapper.c index 8eea87f95..085d0b4d7 100644 --- a/Sources/RepoPromptC/src/Utils/string_extensions_wrapper.c +++ b/Sources/RepoPromptC/src/Utils/string_extensions_wrapper.c @@ -16,6 +16,14 @@ #include #include +char* repo_strdup(const char *text) { + return text ? strdup(text) : NULL; +} + +void repo_free(void *pointer) { + free(pointer); +} + /* MARK: - Levenshtein Distance */ /** diff --git a/Sources/RepoPromptC/src/wildmatch/repo_wildmatch_wrapper.c b/Sources/RepoPromptC/src/wildmatch/repo_wildmatch_wrapper.c index 2f8176e6d..da99fae05 100644 --- a/Sources/RepoPromptC/src/wildmatch/repo_wildmatch_wrapper.c +++ b/Sources/RepoPromptC/src/wildmatch/repo_wildmatch_wrapper.c @@ -191,14 +191,6 @@ void repo_normalize_pattern(char *dest, const char *src, size_t dest_size) dest[j] = '\0'; } -/* Parsed pattern structure for Swift interop */ -typedef struct { - char pattern[1024]; - bool is_negation; - bool directory_only; - bool absolute; -} repo_gitignore_pattern; - static void trim_trailing_whitespace_preserving_escapes(char *text) { size_t len = strlen(text); diff --git a/Sources/RepoPrompt/Features/CodeMap/CodeMapCacheManager.swift b/Sources/RepoPromptCore/CodeMap/CodeMapCacheManager.swift similarity index 88% rename from Sources/RepoPrompt/Features/CodeMap/CodeMapCacheManager.swift rename to Sources/RepoPromptCore/CodeMap/CodeMapCacheManager.swift index cd389d0b1..2a8ba0e06 100644 --- a/Sources/RepoPrompt/Features/CodeMap/CodeMapCacheManager.swift +++ b/Sources/RepoPromptCore/CodeMap/CodeMapCacheManager.swift @@ -3,16 +3,16 @@ import Foundation // ============ The Cache Data Structures ============ -struct CodeMapContentFingerprint: Codable, Equatable { - let contentHash: String - let byteCount: Int +package struct CodeMapContentFingerprint: Codable, Equatable { + package let contentHash: String + package let byteCount: Int - init(contentHash: String, byteCount: Int) { + package init(contentHash: String, byteCount: Int) { self.contentHash = contentHash self.byteCount = byteCount } - init(content: String) { + package init(content: String) { let data = Data(content.utf8) let hash = SHA256.hash(data: data) contentHash = hash.compactMap { String(format: "%02x", $0) }.joined() @@ -20,12 +20,12 @@ struct CodeMapContentFingerprint: Codable, Equatable { } } -struct CodeMapCacheFileEntry: Codable { - let modificationDate: Date - let contentFingerprint: CodeMapContentFingerprint? - let fileAPI: FileAPI +package struct CodeMapCacheFileEntry: Codable { + package let modificationDate: Date + package let contentFingerprint: CodeMapContentFingerprint? + package let fileAPI: FileAPI - init( + package init( modificationDate: Date, contentFingerprint: CodeMapContentFingerprint, fileAPI: FileAPI @@ -36,21 +36,27 @@ struct CodeMapCacheFileEntry: Codable { } } -struct CodeMapCacheRootFolder: Codable { - var files: [String: CodeMapCacheFileEntry] +package struct CodeMapCacheRootFolder: Codable { + package var files: [String: CodeMapCacheFileEntry] } /// Container to wrap the cache data with a version number. -struct CodeMapCacheContainer: Codable { +package struct CodeMapCacheContainer: Codable { /// Version of the cached data format. - let version: Int + package let version: Int /// The actual cache data. - let rootFolder: CodeMapCacheRootFolder + package let rootFolder: CodeMapCacheRootFolder } // ============ The Manager ============ -class CodeMapCacheManager { +package final class CodeMapCacheManager { + private let baseDirectory: URL + + package init(baseDirectory: URL) { + self.baseDirectory = baseDirectory.standardizedFileURL + } + /// The current cache version. private let currentCacheVersion = 6 @@ -64,7 +70,7 @@ class CodeMapCacheManager { // NEW: async loader that reads & decodes the entire root cache from disk. // No reads/writes to any in-memory state inside this class. - func loadRootFolderCacheAsync(rootFolderPath: String) async -> CodeMapCacheRootFolder? { + package func loadRootFolderCacheAsync(rootFolderPath: String) async -> CodeMapCacheRootFolder? { let codeMapFile = cacheFileURL(forRootFolder: rootFolderPath) let currentVersion = currentCacheVersion @@ -91,7 +97,7 @@ class CodeMapCacheManager { /// New: Async/background cache lookup to avoid blocking callers (e.g. actors) on heavy JSON decode. /// This method is intentionally side-effect free (no reads/writes to self.cache). /// It only checks the on-disk JSON and returns a FileAPI if it is fresh. - func loadCachedCodeMapAsync( + package func loadCachedCodeMapAsync( rootFolderPath: String, relativeFilePath: String, currentFullPath: String, @@ -142,7 +148,7 @@ class CodeMapCacheManager { /// Looks up a file's cached FileAPI for a given root folder & relative file path. /// Returns nil if not present OR if the stored modification date is older than `currentModDate`. - func loadCachedCodeMap( + package func loadCachedCodeMap( rootFolderPath: String, relativeFilePath: String, currentFullPath: String, @@ -182,7 +188,7 @@ class CodeMapCacheManager { } /// Stores or updates a single file’s cache data in memory (no disk write until `commit()`). - func storeCodeMap( + package func storeCodeMap( rootFolderPath: String, relativeFilePath: String, modificationDate: Date, @@ -203,7 +209,7 @@ class CodeMapCacheManager { } /// Removes the entire cache (in memory and on disk) for the given root folder. - func removeRootFolder(_ rootFolderPath: String) { + package func removeRootFolder(_ rootFolderPath: String) { cache.removeValue(forKey: rootFolderPath) dirtyRootFolders.remove(rootFolderPath) @@ -218,7 +224,7 @@ class CodeMapCacheManager { } /// Replaces the entire file dictionary in memory for a given root folder. - func overwriteRootFolderEntry(rootPath: String, newFilesDict: [String: CodeMapCacheFileEntry]) { + package func overwriteRootFolderEntry(rootPath: String, newFilesDict: [String: CodeMapCacheFileEntry]) { var rootEntry = cache[rootPath] ?? CodeMapCacheRootFolder(files: [:]) rootEntry.files = newFilesDict cache[rootPath] = rootEntry @@ -228,7 +234,7 @@ class CodeMapCacheManager { } /// Retrieves the root folder’s entire cache (either from memory or disk). - func fetchRootFolderEntry(_ rootFolderPath: String) -> CodeMapCacheRootFolder? { + package func fetchRootFolderEntry(_ rootFolderPath: String) -> CodeMapCacheRootFolder? { if let entry = cache[rootFolderPath] { return entry } @@ -240,7 +246,7 @@ class CodeMapCacheManager { } /// Writes to disk only the caches for root folders that have changed. - func commit() { + package func commit() { for (rootFolderPath, rootEntry) in cache { if dirtyRootFolders.contains(rootFolderPath), saveRootFolderCache(rootFolderPath, rootEntry: rootEntry) @@ -252,13 +258,13 @@ class CodeMapCacheManager { /// Removes the in‑memory cache for a given root folder without deleting the on‑disk file. /// Useful if a folder is unloaded from the app but we still want to keep the disk cache around. - func unloadCache(forRootFolder rootFolderPath: String) { + package func unloadCache(forRootFolder rootFolderPath: String) { cache.removeValue(forKey: rootFolderPath) dirtyRootFolders.remove(rootFolderPath) } /// Removes any on-disk caches that do not match the provided root paths. - func purgeStaleRootCaches(keepingRootPaths: [String]) { + package func purgeStaleRootCaches(keepingRootPaths: [String]) { let normalizedRoots = keepingRootPaths.map { ($0 as NSString).standardizingPath } let keepSet = Set(normalizedRoots) let keepFiles = Set(normalizedRoots.map { "\(hashedFilename(forRootFolderPath: $0)).json" }) @@ -313,15 +319,9 @@ class CodeMapCacheManager { // MARK: - Private Helpers - /// Returns the base directory: ~/Library/Application Support/RepoPrompt/CodeMapCaches private func baseCacheDirectory() -> URL { - guard let appSupport = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first else { - // Fallback to home directory if something unexpected - return FileManager.default.homeDirectoryForCurrentUser - } - let codeMapDir = appSupport.appendingPathComponent("RepoPrompt/CodeMapCaches", isDirectory: true) - try? FileManager.default.createDirectory(at: codeMapDir, withIntermediateDirectories: true) - return codeMapDir + try? FileManager.default.createDirectory(at: baseDirectory, withIntermediateDirectories: true) + return baseDirectory } /// Returns an SHA-256 hash for the given string, used as a unique filename. @@ -364,7 +364,7 @@ class CodeMapCacheManager { /// Saves the cache for a given root folder to disk. /// CHANGED: made public so the actor can flush its in-memory cache to disk. @discardableResult - func saveRootFolderCache(_ rootFolderPath: String, rootEntry: CodeMapCacheRootFolder) -> Bool { + package func saveRootFolderCache(_ rootFolderPath: String, rootEntry: CodeMapCacheRootFolder) -> Bool { Self.fileSaveQueue.sync { let codeMapFile = cacheFileURL(forRootFolder: rootFolderPath) let directoryURL = codeMapFile.deletingLastPathComponent() diff --git a/Sources/RepoPrompt/Features/CodeMap/CodeMapCaptureIndex.swift b/Sources/RepoPromptCore/CodeMap/CodeMapCaptureIndex.swift similarity index 88% rename from Sources/RepoPrompt/Features/CodeMap/CodeMapCaptureIndex.swift rename to Sources/RepoPromptCore/CodeMap/CodeMapCaptureIndex.swift index 9fb6112d7..031afaa5c 100644 --- a/Sources/RepoPrompt/Features/CodeMap/CodeMapCaptureIndex.swift +++ b/Sources/RepoPromptCore/CodeMap/CodeMapCaptureIndex.swift @@ -10,15 +10,15 @@ import SwiftTreeSitter /// Indexed access to Tree-sitter captures for efficient lookup. /// Eliminates O(n²) scans when searching for captures by name or containment. -struct CodeMapCaptureIndex { +package struct CodeMapCaptureIndex { /// All captures sorted by location - let all: [NamedRange] + package let all: [NamedRange] /// Captures grouped by name, each list sorted by location - let byName: [String: [NamedRange]] + package let byName: [String: [NamedRange]] /// Creates an index from an array of captures - init(_ captures: [NamedRange]) { + package init(_ captures: [NamedRange]) { all = captures.sorted { $0.range.location < $1.range.location } var grouped: [String: [NamedRange]] = [:] @@ -29,12 +29,12 @@ struct CodeMapCaptureIndex { } /// Returns all captures with the given name, sorted by location - func captures(named name: String) -> [NamedRange] { + package func captures(named name: String) -> [NamedRange] { byName[name] ?? [] } /// Returns the first capture with the given name that is fully contained within the parent range - func firstCapture(named name: String, containedIn parent: NSRange) -> NamedRange? { + package func firstCapture(named name: String, containedIn parent: NSRange) -> NamedRange? { guard let candidates = byName[name] else { return nil } // Binary search to find the first candidate that could be in range @@ -53,7 +53,7 @@ struct CodeMapCaptureIndex { } /// Returns all captures with the given name that are fully contained within the parent range - func captures(named name: String, containedIn parent: NSRange) -> [NamedRange] { + package func captures(named name: String, containedIn parent: NSRange) -> [NamedRange] { guard let candidates = byName[name] else { return [] } var results: [NamedRange] = [] @@ -74,7 +74,7 @@ struct CodeMapCaptureIndex { } /// Returns all captures (of any name) that are fully contained within the parent range - func allCaptures(containedIn parent: NSRange) -> [NamedRange] { + package func allCaptures(containedIn parent: NSRange) -> [NamedRange] { var results: [NamedRange] = [] // Binary search to find the first capture that could be in range @@ -93,13 +93,13 @@ struct CodeMapCaptureIndex { } /// Returns the smallest capture with the given name that fully contains the target range. - func smallestCapture(named name: String, containing target: NSRange) -> NamedRange? { + package func smallestCapture(named name: String, containing target: NSRange) -> NamedRange? { guard let candidates = byName[name] else { return nil } return smallestCapture(in: candidates, containing: target) } /// Returns the smallest capture with any of the given names that fully contains the target range. - func smallestCapture(namedAny names: [String], containing target: NSRange) -> NamedRange? { + package func smallestCapture(namedAny names: [String], containing target: NSRange) -> NamedRange? { var best: NamedRange? = nil for name in names { guard let candidate = smallestCapture(named: name, containing: target) else { continue } diff --git a/Sources/RepoPrompt/Features/CodeMap/CodeMapExtractionMemo.swift b/Sources/RepoPromptCore/CodeMap/CodeMapExtractionMemo.swift similarity index 91% rename from Sources/RepoPrompt/Features/CodeMap/CodeMapExtractionMemo.swift rename to Sources/RepoPromptCore/CodeMap/CodeMapExtractionMemo.swift index 141b9da13..97c01232c 100644 --- a/Sources/RepoPrompt/Features/CodeMap/CodeMapExtractionMemo.swift +++ b/Sources/RepoPromptCore/CodeMap/CodeMapExtractionMemo.swift @@ -7,7 +7,7 @@ import Foundation -struct CodeMapExtractionMemo { +package struct CodeMapExtractionMemo { private struct LanguageLineKey: Hashable { let language: LanguageType let line: String @@ -31,7 +31,7 @@ struct CodeMapExtractionMemo { private var tsTypeAnnotationByLine: [String: CachedOptional] = [:] private var tsTypeAliasRHSByLine: [String: CachedOptional] = [:] - mutating func jstsSignature( + package mutating func jstsSignature( from line: String, context: JSTSSignatureContext, perfStats: CodeMapPerfStats?, @@ -53,7 +53,7 @@ struct CodeMapExtractionMemo { return result } - mutating func matchFunctionLine( + package mutating func matchFunctionLine( _ line: String, language: LanguageType, stats: CodeMapPerfStats? @@ -69,7 +69,7 @@ struct CodeMapExtractionMemo { return result } - mutating func matchFunctionLineParsed( + package mutating func matchFunctionLineParsed( _ line: String, language: LanguageType, stats: CodeMapPerfStats? @@ -85,7 +85,7 @@ struct CodeMapExtractionMemo { return result } - mutating func matchVariableLine( + package mutating func matchVariableLine( _ line: String, language: LanguageType, stats: CodeMapPerfStats? @@ -101,7 +101,7 @@ struct CodeMapExtractionMemo { return result } - mutating func tsReturnType( + package mutating func tsReturnType( from signature: String, stats: CodeMapPerfStats? ) -> String? { @@ -115,7 +115,7 @@ struct CodeMapExtractionMemo { return result } - mutating func tsTypeAnnotation(from line: String, stats: CodeMapPerfStats?) -> String? { + package mutating func tsTypeAnnotation(from line: String, stats: CodeMapPerfStats?) -> String? { if let cached = tsTypeAnnotationByLine[line] { stats?.extractionMemoTSFastPathHits += 1 return Self.unwrap(cached) @@ -126,7 +126,7 @@ struct CodeMapExtractionMemo { return result } - mutating func tsTypeAliasRHS(from line: String, stats: CodeMapPerfStats?) -> String? { + package mutating func tsTypeAliasRHS(from line: String, stats: CodeMapPerfStats?) -> String? { if let cached = tsTypeAliasRHSByLine[line] { stats?.extractionMemoTSFastPathHits += 1 return Self.unwrap(cached) diff --git a/Sources/RepoPromptCore/CodeMap/CodeMapExtractor.swift b/Sources/RepoPromptCore/CodeMap/CodeMapExtractor.swift new file mode 100644 index 000000000..d10016962 --- /dev/null +++ b/Sources/RepoPromptCore/CodeMap/CodeMapExtractor.swift @@ -0,0 +1,146 @@ +import Foundation + +/// Neutral code-map relationship extraction used by selection and prompt adapters. +package enum CodeMapExtractor { + @inline(__always) + private static func standardizedAPIFilePath(_ api: FileAPI) -> String { + StandardizedPath.absolute(api.filePath) + } + + private static func acceptedFileAPIs( + from files: [WorkspaceFileRecord], + allFileAPIs: [FileAPI] + ) -> [FileAPI] { + guard !files.isEmpty, !allFileAPIs.isEmpty else { return [] } + let pathGrouping = WorkspaceRuntimePerf.begin( + WorkspaceRuntimePerf.Stage.ReadFile.AutoSelect.AcceptedFileAPIFilter.pathGrouping + ) + let apisByPath = Dictionary(grouping: allFileAPIs, by: standardizedAPIFilePath) + WorkspaceRuntimePerf.end( + WorkspaceRuntimePerf.Stage.ReadFile.AutoSelect.AcceptedFileAPIFilter.pathGrouping, + pathGrouping + ) + let selectedRecordProjection = WorkspaceRuntimePerf.begin( + WorkspaceRuntimePerf.Stage.ReadFile.AutoSelect.AcceptedFileAPIFilter.selectedRecordProjection + ) + let selectedAPIs = files.compactMap { apisByPath[$0.standardizedFullPath]?.first } + WorkspaceRuntimePerf.end( + WorkspaceRuntimePerf.Stage.ReadFile.AutoSelect.AcceptedFileAPIFilter.selectedRecordProjection, + selectedRecordProjection + ) + return selectedAPIs + } + + private static func acceptedFileAPIs( + from files: [WorkspaceFileRecord], + firstFileAPIByStandardizedNestedPath: [String: FileAPI] + ) -> [FileAPI] { + guard !files.isEmpty, !firstFileAPIByStandardizedNestedPath.isEmpty else { return [] } + let selectedRecordProjection = WorkspaceRuntimePerf.begin( + WorkspaceRuntimePerf.Stage.ReadFile.AutoSelect.AcceptedFileAPIFilter.selectedRecordProjection + ) + let selectedAPIs = files.compactMap { firstFileAPIByStandardizedNestedPath[$0.standardizedFullPath] } + WorkspaceRuntimePerf.end( + WorkspaceRuntimePerf.Stage.ReadFile.AutoSelect.AcceptedFileAPIFilter.selectedRecordProjection, + selectedRecordProjection + ) + return selectedAPIs + } + + package static func getAutoReferencedAPIs( + selectedAPIs: [FileAPI], + unselectedAPIs: [FileAPI] + ) -> [FileAPI] { + guard !selectedAPIs.isEmpty else { return [] } + var typeToFileAPI: [String: FileAPI] = [:] + for api in unselectedAPIs { + for type in api.definedTypeNames { + typeToFileAPI[type] = api + } + } + + let referencedTypes = Set(selectedAPIs.flatMap(\.referencedTypes)) + let localRefs = referencedTypes.compactMap { typeToFileAPI[$0] } + var seen = Set() + var included: [FileAPI] = [] + for api in localRefs where seen.insert(standardizedAPIFilePath(api)).inserted { + included.append(api) + } + return included + } + + package static func resolveReferencedFilePaths( + from selectedFiles: [WorkspaceFileRecord], + among allFileAPIs: [FileAPI] + ) -> [String] { + guard !selectedFiles.isEmpty else { return [] } + let acceptedFileAPIFilter = WorkspaceRuntimePerf.begin( + WorkspaceRuntimePerf.Stage.ReadFile.AutoSelect.acceptedFileAPIFilter + ) + let selectedAPIs = acceptedFileAPIs(from: selectedFiles, allFileAPIs: allFileAPIs) + WorkspaceRuntimePerf.end( + WorkspaceRuntimePerf.Stage.ReadFile.AutoSelect.acceptedFileAPIFilter, + acceptedFileAPIFilter + ) + return resolveReferencedFilePaths( + from: selectedFiles, + selectedAPIs: selectedAPIs, + among: allFileAPIs + ) + } + + package static func resolveReferencedFilePaths( + from selectedFiles: [WorkspaceFileRecord], + among allFileAPIs: [FileAPI], + firstFileAPIByStandardizedNestedPath: [String: FileAPI] + ) -> [String] { + guard !selectedFiles.isEmpty else { return [] } + let acceptedFileAPIFilter = WorkspaceRuntimePerf.begin( + WorkspaceRuntimePerf.Stage.ReadFile.AutoSelect.acceptedFileAPIFilter + ) + let selectedAPIs = acceptedFileAPIs( + from: selectedFiles, + firstFileAPIByStandardizedNestedPath: firstFileAPIByStandardizedNestedPath + ) + WorkspaceRuntimePerf.end( + WorkspaceRuntimePerf.Stage.ReadFile.AutoSelect.acceptedFileAPIFilter, + acceptedFileAPIFilter + ) + return resolveReferencedFilePaths( + from: selectedFiles, + selectedAPIs: selectedAPIs, + among: allFileAPIs + ) + } + + private static func resolveReferencedFilePaths( + from selectedFiles: [WorkspaceFileRecord], + selectedAPIs: [FileAPI], + among allFileAPIs: [FileAPI] + ) -> [String] { + guard !selectedAPIs.isEmpty else { return [] } + let selectedPaths = Set(selectedFiles.map(\.standardizedFullPath)) + let unselectedAPIs = allFileAPIs.filter { !selectedPaths.contains(standardizedAPIFilePath($0)) } + let computation = WorkspaceRuntimePerf.begin( + WorkspaceRuntimePerf.Stage.ReadFile.AutoSelect.autoReferencedAPIComputation + ) + let referencedAPIs = getAutoReferencedAPIs( + selectedAPIs: selectedAPIs, + unselectedAPIs: unselectedAPIs + ) + WorkspaceRuntimePerf.end( + WorkspaceRuntimePerf.Stage.ReadFile.AutoSelect.autoReferencedAPIComputation, + computation + ) + + var seen = Set() + var ordered: [String] = [] + for api in referencedAPIs { + let standardized = standardizedAPIFilePath(api) + if seen.insert(standardized).inserted { + ordered.append(standardized) + } + } + return ordered + } +} diff --git a/Sources/RepoPrompt/Features/CodeMap/CodeMapGenerator.swift b/Sources/RepoPromptCore/CodeMap/CodeMapGenerator.swift similarity index 98% rename from Sources/RepoPrompt/Features/CodeMap/CodeMapGenerator.swift rename to Sources/RepoPromptCore/CodeMap/CodeMapGenerator.swift index dbcc3bb3d..e9503c4b2 100644 --- a/Sources/RepoPrompt/Features/CodeMap/CodeMapGenerator.swift +++ b/Sources/RepoPromptCore/CodeMap/CodeMapGenerator.swift @@ -6,51 +6,16 @@ // import Foundation -#if CODEMAP_PERF_SIGNPOSTS - import os -#endif import SwiftTreeSitter -struct CodeMapGenerator { - static let debug = false +package struct CodeMapGenerator { + package static let debug = false // MARK: - Debug Configuration /// Controls detailed logging for code map generation /// Set to true to enable extensive logging for debugging lightweight language parsing - static let debugLogging = false - - // MARK: - Perf Signposts - - #if CODEMAP_PERF_SIGNPOSTS - private typealias SignpostToken = OSSignpostID - #else - private typealias SignpostToken = UInt8 - #endif - - private enum Signpost { - #if CODEMAP_PERF_SIGNPOSTS - static let log = OSLog(subsystem: "com.repoprompt", category: "codemap") - #endif - - @inline(__always) - static func begin(_ name: StaticString) -> SignpostToken { - #if CODEMAP_PERF_SIGNPOSTS - let id = OSSignpostID(log: log) - os_signpost(.begin, log: log, name: name, signpostID: id) - return id - #else - return 0 - #endif - } - - @inline(__always) - static func end(_ name: StaticString, _ token: SignpostToken) { - #if CODEMAP_PERF_SIGNPOSTS - os_signpost(.end, log: log, name: name, signpostID: token) - #endif - } - } + package static let debugLogging = false private enum CaptureLoopAttributionCategory { case swiftStrategy @@ -279,7 +244,7 @@ struct CodeMapGenerator { /// • Supports new buckets: `exports`, `interfaces`, `aliases`, `literalUnions`. /// • Detects trivial literal-union type-aliases. /// • Keeps previous heavyweight/lightweight extraction logic for other languages. - static func generateCodeMap( + package static func generateCodeMap( from namedRanges: [NamedRange], content: String, fullPath: String, @@ -330,14 +295,12 @@ struct CodeMapGenerator { let perfCollectCounters = activePerfOptions.collectCounters // Build capture index for efficient lookups - let indexToken = Signpost.begin("codemap.index") let indexStart = perfEnabled ? CFAbsoluteTimeGetCurrent() : 0 let captureIndex = CodeMapCaptureIndex(namedRanges) if perfEnabled { activePerfStats?.captureIndexDuration += (CFAbsoluteTimeGetCurrent() - indexStart) } let sortedCaps = captureIndex.all - Signpost.end("codemap.index", indexToken) if debugLogging { print("🗂️ Line boundaries computed: \(boundaries.count) lines") @@ -667,7 +630,6 @@ struct CodeMapGenerator { if debugLogging { print("\n🍎 [Phase 2.5] Swift-specific: Building range-based type boundaries via strategy") } - let swiftToken = Signpost.begin("codemap.swift_context") let swiftContextStart = perfEnabled ? CFAbsoluteTimeGetCurrent() : 0 swiftContext = SwiftCodeMapStrategy.buildContext( index: captureIndex, @@ -677,7 +639,6 @@ struct CodeMapGenerator { if perfEnabled { activePerfStats?.swiftContextDuration += (CFAbsoluteTimeGetCurrent() - swiftContextStart) } - Signpost.end("codemap.swift_context", swiftToken) // Also populate interfaceBoundaries for protocols for boundary in swiftContext!.typeBoundaries where boundary.isProtocol { @@ -700,7 +661,6 @@ struct CodeMapGenerator { if debugLogging { print("\n📘 [Phase 2.6] TS/TSX-specific: Building range-based container boundaries via strategy") } - let tsToken = Signpost.begin("codemap.ts_context") let tsContextStart = perfEnabled ? CFAbsoluteTimeGetCurrent() : 0 tsContext = TypeScriptCodeMapStrategy.buildContext( index: captureIndex, @@ -710,7 +670,6 @@ struct CodeMapGenerator { if perfEnabled { activePerfStats?.tsContextDuration += (CFAbsoluteTimeGetCurrent() - tsContextStart) } - Signpost.end("codemap.ts_context", tsToken) usesTSRangeContainment = TypeScriptCodeMapStrategy.useRangeContainment(tsContext!) @@ -756,7 +715,6 @@ struct CodeMapGenerator { print("\n🔄 [Phase 3] Processing captures in main loop") } - let loopToken = Signpost.begin("codemap.capture_loop") let loopStart = perfEnabled ? CFAbsoluteTimeGetCurrent() : 0 func recordCaptureAttribution(_ category: CaptureLoopAttributionCategory, since start: CFAbsoluteTime) { guard perfEnabled else { return } @@ -1884,7 +1842,6 @@ struct CodeMapGenerator { if perfEnabled { activePerfStats?.captureLoopDuration += (CFAbsoluteTimeGetCurrent() - loopStart) } - Signpost.end("codemap.capture_loop", loopToken) if let e = currentEnum { enums.append(e) if debugLogging { @@ -1994,7 +1951,6 @@ struct CodeMapGenerator { finalGlobals = finalClasses.isEmpty ? globalVariables : [] } - let typeFinalizeToken = Signpost.begin("codemap.referenced_types") let typeFinalizeStart = perfEnabled ? CFAbsoluteTimeGetCurrent() : 0 let finalRefs = referencedTypes.finalizeSorted() if perfEnabled { @@ -2003,7 +1959,6 @@ struct CodeMapGenerator { if debugLogging { print("🔍 Referenced types (filtered): \(finalRefs.count) (from \(referencedTypes.rawInsertions) raw)") } - Signpost.end("codemap.referenced_types", typeFinalizeToken) if debugLogging { print("📊 Final counts:") @@ -2043,7 +1998,6 @@ struct CodeMapGenerator { print("\n✅ [Phase 6] Creating FileAPI and returning result") } - let fileAPIToken = Signpost.begin("codemap.fileapi_init") let fileAPIStart = perfEnabled ? CFAbsoluteTimeGetCurrent() : 0 let api = FileAPI( filePath: fullPath, @@ -2062,7 +2016,6 @@ struct CodeMapGenerator { if perfEnabled { activePerfStats?.fileAPIInitDuration += (CFAbsoluteTimeGetCurrent() - fileAPIStart) } - Signpost.end("codemap.fileapi_init", fileAPIToken) if debug { api.printAPI() } return api @@ -2093,7 +2046,7 @@ struct CodeMapGenerator { /// Precomputes the starting indices of each line in the content. /// Matches read_file semantics by treating \r, \n, and \r\n as line endings. - static func computeLineBoundaries(content: String) -> [Int] { + package static func computeLineBoundaries(content: String) -> [Int] { let utf16 = content.utf16 var boundaries = [0] var idx = utf16.startIndex @@ -2142,7 +2095,7 @@ struct CodeMapGenerator { } /// Returns the 1-indexed line number for a given location using precomputed boundaries. - static func lineNumber(for location: Int, using boundaries: [Int]) -> Int { + package static func lineNumber(for location: Int, using boundaries: [Int]) -> Int { lineIndex(for: location, using: boundaries) + 1 } diff --git a/Sources/RepoPrompt/Features/CodeMap/CodeMapPCRE2Regex.swift b/Sources/RepoPromptCore/CodeMap/CodeMapPCRE2Regex.swift similarity index 84% rename from Sources/RepoPrompt/Features/CodeMap/CodeMapPCRE2Regex.swift rename to Sources/RepoPromptCore/CodeMap/CodeMapPCRE2Regex.swift index 3f8cc0024..e1b489320 100644 --- a/Sources/RepoPrompt/Features/CodeMap/CodeMapPCRE2Regex.swift +++ b/Sources/RepoPromptCore/CodeMap/CodeMapPCRE2Regex.swift @@ -7,10 +7,10 @@ import Foundation -struct CodeMapPCRE2Match { +package struct CodeMapPCRE2Match { private let captures: [String?] - init(subject: String, match: PCRE2Match) { + package init(subject: String, match: PCRE2Match) { let utf8 = subject.utf8 captures = match.captureByteRanges.map { range in guard let range else { return nil } @@ -28,20 +28,20 @@ struct CodeMapPCRE2Match { return String(decoding: utf8[lower ..< upper], as: UTF8.self) } - func capture(_ index: Int) -> String? { + package func capture(_ index: Int) -> String? { guard index >= 0, index < captures.count else { return nil } return captures[index] } - func trimmedCapture(_ index: Int) -> String? { + package func trimmedCapture(_ index: Int) -> String? { capture(index)?.trimmingCharacters(in: .whitespaces) } } -struct CodeMapPCRE2Pattern { +package struct CodeMapPCRE2Pattern { private let regex: PCRE2Regex - init( + package init( _ pattern: String, caseInsensitive: Bool = false, multilineAnchors: Bool = false, @@ -65,29 +65,29 @@ struct CodeMapPCRE2Pattern { } } - func firstMatch(in text: String) -> CodeMapPCRE2Match? { + package func firstMatch(in text: String) -> CodeMapPCRE2Match? { guard let match = try? regex.firstMatch(in: text) else { return nil } return CodeMapPCRE2Match(subject: text, match: match) } - func firstCapture(_ index: Int = 1, in text: String) -> String? { + package func firstCapture(_ index: Int = 1, in text: String) -> String? { firstMatch(in: text)?.capture(index) } - func trimmedCapture(_ index: Int = 1, in text: String) -> String? { + package func trimmedCapture(_ index: Int = 1, in text: String) -> String? { firstMatch(in: text)?.trimmedCapture(index) } - func matches(_ text: String) -> Bool { + package func matches(_ text: String) -> Bool { (try? regex.firstMatch(in: text)) != nil } - func wholeMatch(in text: String) -> Bool { + package func wholeMatch(in text: String) -> Bool { guard let match = try? regex.firstMatch(in: text) else { return false } return match.byteRange == 0 ..< text.utf8.count } - func replacingMatches(in text: String, with replacement: String = "") -> String { + package func replacingMatches(in text: String, with replacement: String = "") -> String { let byteCount = text.utf8.count var sourceBytes: [UInt8]? = nil var replacementBytes: [UInt8]? = nil diff --git a/Sources/RepoPromptCore/CodeMap/CodeMapPerfStats.swift b/Sources/RepoPromptCore/CodeMap/CodeMapPerfStats.swift new file mode 100644 index 000000000..7f07b8b4b --- /dev/null +++ b/Sources/RepoPromptCore/CodeMap/CodeMapPerfStats.swift @@ -0,0 +1,700 @@ +// +// CodeMapPerfStats.swift +// RepoPrompt +// +// Lightweight counters for codemap performance analysis. +// These are expected to be used on a single thread per file scan. +// + +import Foundation + +package struct CodeMapPerfOptions { + package let enabled: Bool + package let collectCounters: Bool + + package static let disabled = CodeMapPerfOptions(enabled: false, collectCounters: false) + package static let countersOnly = CodeMapPerfOptions(enabled: true, collectCounters: true) +} + +package struct CodeMapSyntaxStartupPerfStats { + package var primeDuration: TimeInterval = 0 + package var warmCacheDuration: TimeInterval = 0 + package var warmCodeMapQueriesDuration: TimeInterval = 0 + package var languageConfigCreateDuration: TimeInterval = 0 + package var languagePointerDuration: TimeInterval = 0 + package var highlightQueryDataDuration: TimeInterval = 0 + package var highlightQueryCompileDuration: TimeInterval = 0 + package var codeMapQueryDataDuration: TimeInterval = 0 + package var codeMapQueryCompileDuration: TimeInterval = 0 + + package var warmCacheLanguageCount = 0 + package var languageConfigCreateCount = 0 + package var languageConfigSuccessCount = 0 + package var languageConfigFailureCount = 0 + package var highlightQueryCompileSuccessCount = 0 + package var highlightQueryCompileFailureCount = 0 + package var warmCodeMapQueryLanguageCount = 0 + package var codeMapQueryPrecomputeSuccessCount = 0 + package var codeMapQueryPrecomputeFailureCount = 0 + package var codeMapQueryPrecomputeSkippedCount = 0 +} + +package struct CodeMapSyntaxPerfStats { + package var languageLookupDuration: TimeInterval = 0 + package var oversizeGuardDuration: TimeInterval = 0 + package var parserCreateDuration: TimeInterval = 0 + package var setLanguageDuration: TimeInterval = 0 + package var parseDuration: TimeInterval = 0 + package var codeMapQueryLookupDuration: TimeInterval = 0 + package var queryExecuteDuration: TimeInterval = 0 + package var captureMaterializationDuration: TimeInterval = 0 + + package var calls = 0 + package var unsupported = 0 + package var oversized = 0 + package var parseNilTree = 0 + package var parseNilRoot = 0 + package var parserCreates = 0 + package var queryExecutes = 0 + package var captures = 0 + package var codeMapQueryCacheHits = 0 + package var codeMapQueryCacheMisses = 0 +} + +package struct CodeMapPipelinePerfSnapshot: Equatable { + package var snapshotBuildDuration: TimeInterval = 0 + package var requestBuildDuration: TimeInterval = 0 + package var contentLoadDuration: TimeInterval = 0 + package var actorRequestIngestDuration: TimeInterval = 0 + package var actorCachePrefetchDuration: TimeInterval = 0 + package var actorCacheCheckDuration: TimeInterval = 0 + package var actorQueueWaitDuration: TimeInterval = 0 + package var parseAndQueryDuration: TimeInterval = 0 + package var generatorDuration: TimeInterval = 0 + package var batchApplyDuration: TimeInterval = 0 + package var syntaxManagerPrimeDuration: TimeInterval = 0 + package var syntaxWarmCacheDuration: TimeInterval = 0 + package var syntaxWarmCodeMapQueriesDuration: TimeInterval = 0 + package var syntaxLanguageConfigCreateDuration: TimeInterval = 0 + package var syntaxLanguagePointerDuration: TimeInterval = 0 + package var syntaxHighlightQueryDataDuration: TimeInterval = 0 + package var syntaxHighlightQueryCompileDuration: TimeInterval = 0 + package var syntaxCodeMapQueryDataDuration: TimeInterval = 0 + package var syntaxCodeMapQueryCompileDuration: TimeInterval = 0 + package var syntaxLanguageLookupDuration: TimeInterval = 0 + package var syntaxOversizeGuardDuration: TimeInterval = 0 + package var syntaxParserCreateDuration: TimeInterval = 0 + package var syntaxSetLanguageDuration: TimeInterval = 0 + package var syntaxParseDuration: TimeInterval = 0 + package var syntaxCodeMapQueryLookupDuration: TimeInterval = 0 + package var syntaxQueryExecuteDuration: TimeInterval = 0 + package var syntaxCaptureMaterializationDuration: TimeInterval = 0 + package var generatorCaptureIndexDuration: TimeInterval = 0 + package var generatorSwiftContextDuration: TimeInterval = 0 + package var generatorTSContextDuration: TimeInterval = 0 + package var generatorCaptureLoopDuration: TimeInterval = 0 + package var generatorCaptureLoopLineAdvanceDuration: TimeInterval = 0 + package var generatorCaptureLoopSwiftStrategyDuration: TimeInterval = 0 + package var generatorCaptureLoopTSStrategyDuration: TimeInterval = 0 + package var generatorCaptureLoopInterfaceHeuristicDuration: TimeInterval = 0 + package var generatorCaptureLoopImportExportDuration: TimeInterval = 0 + package var generatorCaptureLoopTypeAliasDuration: TimeInterval = 0 + package var generatorCaptureLoopEnumMacroDuration: TimeInterval = 0 + package var generatorCaptureLoopFunctionDuration: TimeInterval = 0 + package var generatorCaptureLoopVariableDuration: TimeInterval = 0 + package var generatorCaptureLoopSkippedDuration: TimeInterval = 0 + package var generatorCaptureLoopUnclassifiedDuration: TimeInterval = 0 + package var generatorSwiftStrategyFunctionSignatureDuration: TimeInterval = 0 + package var generatorSwiftStrategyFunctionNameLookupDuration: TimeInterval = 0 + package var generatorSwiftStrategyParameterExtractionDuration: TimeInterval = 0 + package var generatorSwiftStrategyReturnTypeExtractionDuration: TimeInterval = 0 + package var generatorSwiftStrategyPropertyDeclarationDuration: TimeInterval = 0 + package var generatorSwiftStrategyPropertyTypeExtractionDuration: TimeInterval = 0 + package var generatorSwiftStrategyEnclosingTypeLookupDuration: TimeInterval = 0 + package var generatorSwiftStrategyModelInsertionDuration: TimeInterval = 0 + package var generatorSwiftStrategyContextOnlyDuration: TimeInterval = 0 + package var generatorFallbackFunctionDeclarationDuration: TimeInterval = 0 + package var generatorFallbackFunctionJSTSSignatureDuration: TimeInterval = 0 + package var generatorFallbackFunctionNameExtractionDuration: TimeInterval = 0 + package var generatorFallbackFunctionLTEParseDuration: TimeInterval = 0 + package var generatorFallbackFunctionTSFastPathDuration: TimeInterval = 0 + package var generatorFallbackFunctionReferencedTypesDuration: TimeInterval = 0 + package var generatorFallbackFunctionRoutingDuration: TimeInterval = 0 + package var generatorFallbackFunctionModelInsertionDuration: TimeInterval = 0 + package var generatorFallbackFunctionSkippedDuration: TimeInterval = 0 + package var generatorDeclarationExtractionDuration: TimeInterval = 0 + package var generatorJSTSSignatureDuration: TimeInterval = 0 + package var generatorLanguageTypeExtractorFunctionDuration: TimeInterval = 0 + package var generatorLanguageTypeExtractorVariableDuration: TimeInterval = 0 + package var generatorTypeCleanerDuration: TimeInterval = 0 + package var generatorTypeCleanerSwiftDuration: TimeInterval = 0 + package var generatorTypeCleanerTSDuration: TimeInterval = 0 + package var generatorTypeCleanerTSXDuration: TimeInterval = 0 + package var generatorTypeCleanerJSDuration: TimeInterval = 0 + package var generatorTypeCleanerOtherLanguageDuration: TimeInterval = 0 + package var generatorTypeCleanerPrecleanDuration: TimeInterval = 0 + package var generatorTypeCleanerTSLogicDuration: TimeInterval = 0 + package var generatorTypeCleanerNonTSLogicDuration: TimeInterval = 0 + package var generatorTypeCleanerTSObjectLiteralDuration: TimeInterval = 0 + package var generatorTypeCleanerFilterDuration: TimeInterval = 0 + package var generatorTypeCleanerDedupDuration: TimeInterval = 0 + package var generatorReferencedTypesFinalizeDuration: TimeInterval = 0 + package var generatorFileAPIInitDuration: TimeInterval = 0 + + package var requestsBuilt = 0 + package var requestsEnqueued = 0 + package var cacheHits = 0 + package var cacheMisses = 0 + package var oversizedSkips = 0 + package var parseFailures = 0 + package var generatedAPIs = 0 + package var nilAPIs = 0 + package var codeMapQueryCacheHits = 0 + package var codeMapQueryCacheMisses = 0 + package var syntaxWarmCacheLanguageCount = 0 + package var syntaxLanguageConfigCreateCount = 0 + package var syntaxLanguageConfigSuccessCount = 0 + package var syntaxLanguageConfigFailureCount = 0 + package var syntaxHighlightQueryCompileSuccessCount = 0 + package var syntaxHighlightQueryCompileFailureCount = 0 + package var syntaxWarmCodeMapQueryLanguageCount = 0 + package var syntaxCodeMapQueryPrecomputeSuccessCount = 0 + package var syntaxCodeMapQueryPrecomputeFailureCount = 0 + package var syntaxCodeMapQueryPrecomputeSkippedCount = 0 + package var syntaxCodeMapCalls = 0 + package var syntaxUnsupportedExtensionCount = 0 + package var syntaxOversizedSkipCount = 0 + package var syntaxParseNilTreeCount = 0 + package var syntaxParseNilRootCount = 0 + package var syntaxParserCreateCount = 0 + package var syntaxQueryExecuteCount = 0 + package var syntaxCaptureCount = 0 + package var capturesProcessed = 0 + package var swiftStrategyHandled = 0 + package var tsStrategyHandled = 0 + package var fallbackHandled = 0 + package var generatorCaptureLoopLineAdvanceCount = 0 + package var generatorCaptureLoopSwiftStrategyCount = 0 + package var generatorCaptureLoopTSStrategyCount = 0 + package var generatorCaptureLoopInterfaceHeuristicCount = 0 + package var generatorCaptureLoopImportExportCount = 0 + package var generatorCaptureLoopTypeAliasCount = 0 + package var generatorCaptureLoopEnumMacroCount = 0 + package var generatorCaptureLoopFunctionCount = 0 + package var generatorCaptureLoopVariableCount = 0 + package var generatorCaptureLoopSkippedCount = 0 + package var generatorCaptureLoopUnclassifiedCount = 0 + package var generatorSwiftStrategyFunctionSignatureCount = 0 + package var generatorSwiftStrategyFunctionNameLookupCount = 0 + package var generatorSwiftStrategyParameterExtractionCount = 0 + package var generatorSwiftStrategyReturnTypeExtractionCount = 0 + package var generatorSwiftStrategyPropertyDeclarationCount = 0 + package var generatorSwiftStrategyPropertyTypeExtractionCount = 0 + package var generatorSwiftStrategyEnclosingTypeLookupCount = 0 + package var generatorSwiftStrategyModelInsertionCount = 0 + package var generatorSwiftStrategyContextOnlyCount = 0 + package var generatorSwiftStrategyHandledFunctionCount = 0 + package var generatorSwiftStrategyHandledPropertyCount = 0 + package var generatorFallbackFunctionDeclarationCount = 0 + package var generatorFallbackFunctionJSTSSignatureCount = 0 + package var generatorFallbackFunctionNameExtractionCount = 0 + package var generatorFallbackFunctionLTEParseCount = 0 + package var generatorFallbackFunctionTSFastPathCount = 0 + package var generatorFallbackFunctionReferencedTypesCount = 0 + package var generatorFallbackFunctionRoutingCount = 0 + package var generatorFallbackFunctionModelInsertionCount = 0 + package var generatorFallbackFunctionSkippedCount = 0 + package var generatorFallbackFunctionLightweightCount = 0 + package var generatorFallbackFunctionHeavyweightCount = 0 + package var generatorFallbackFunctionGlobalInsertCount = 0 + package var generatorFallbackFunctionMethodInsertCount = 0 + package var generatorFallbackFunctionInterfaceInsertCount = 0 + package var captureDeclarationCalls = 0 + package var jstsSignatureCallsFunctionLike = 0 + package var jstsSignatureCallsStatementLike = 0 + package var lteMatchAnyFunctionCalls = 0 + package var lteMatchAnyVariableCalls = 0 + package var typeCleanerExtractCalls = 0 + package var typeCleanerCacheHits = 0 + package var typeCleanerCacheMisses = 0 + package var typeCleanerSwiftCalls = 0 + package var typeCleanerTSCalls = 0 + package var typeCleanerTSXCalls = 0 + package var typeCleanerJSCalls = 0 + package var typeCleanerOtherLanguageCalls = 0 + package var typeCleanerPrecleanCount = 0 + package var typeCleanerTSLogicCount = 0 + package var typeCleanerNonTSLogicCount = 0 + package var typeCleanerTSObjectLiteralCount = 0 + package var typeCleanerFilterCount = 0 + package var typeCleanerDedupCount = 0 + package var referencedTypesRawInsertions = 0 + package var referencedTypesPrefilterSkips = 0 + package var referencedTypesEmptyResults = 0 + package var referencedTypesOutputTypeCount = 0 + package var extractionMemoJSTSHits = 0 + package var extractionMemoJSTSMisses = 0 + package var extractionMemoFunctionHits = 0 + package var extractionMemoFunctionMisses = 0 + package var extractionMemoFunctionParsedHits = 0 + package var extractionMemoFunctionParsedMisses = 0 + package var extractionMemoVariableHits = 0 + package var extractionMemoVariableMisses = 0 + package var extractionMemoTSFastPathHits = 0 + package var extractionMemoTSFastPathMisses = 0 + + package var resultBatchCount = 0 + package var maxResultBatchSize = 0 +} + +package final class CodeMapPipelinePerfStats: @unchecked Sendable { + private let lock = NSLock() + private var storage = CodeMapPipelinePerfSnapshot() + + package var snapshot: CodeMapPipelinePerfSnapshot { + lock.withLock { storage } + } + + package func addDuration(_ keyPath: WritableKeyPath, _ duration: TimeInterval) { + lock.withLock { + storage[keyPath: keyPath] += duration + } + } + + package func increment(_ keyPath: WritableKeyPath, by amount: Int = 1) { + guard amount != 0 else { return } + lock.withLock { + storage[keyPath: keyPath] += amount + } + } + + package func recordResultBatch(size: Int) { + lock.withLock { + storage.resultBatchCount += 1 + storage.maxResultBatchSize = max(storage.maxResultBatchSize, size) + } + } + + package func mergeSyntaxManagerStartupStats(_ stats: CodeMapSyntaxStartupPerfStats) { + lock.withLock { + storage.syntaxManagerPrimeDuration += stats.primeDuration + storage.syntaxWarmCacheDuration += stats.warmCacheDuration + storage.syntaxWarmCodeMapQueriesDuration += stats.warmCodeMapQueriesDuration + storage.syntaxLanguageConfigCreateDuration += stats.languageConfigCreateDuration + storage.syntaxLanguagePointerDuration += stats.languagePointerDuration + storage.syntaxHighlightQueryDataDuration += stats.highlightQueryDataDuration + storage.syntaxHighlightQueryCompileDuration += stats.highlightQueryCompileDuration + storage.syntaxCodeMapQueryDataDuration += stats.codeMapQueryDataDuration + storage.syntaxCodeMapQueryCompileDuration += stats.codeMapQueryCompileDuration + + storage.syntaxWarmCacheLanguageCount += stats.warmCacheLanguageCount + storage.syntaxLanguageConfigCreateCount += stats.languageConfigCreateCount + storage.syntaxLanguageConfigSuccessCount += stats.languageConfigSuccessCount + storage.syntaxLanguageConfigFailureCount += stats.languageConfigFailureCount + storage.syntaxHighlightQueryCompileSuccessCount += stats.highlightQueryCompileSuccessCount + storage.syntaxHighlightQueryCompileFailureCount += stats.highlightQueryCompileFailureCount + storage.syntaxWarmCodeMapQueryLanguageCount += stats.warmCodeMapQueryLanguageCount + storage.syntaxCodeMapQueryPrecomputeSuccessCount += stats.codeMapQueryPrecomputeSuccessCount + storage.syntaxCodeMapQueryPrecomputeFailureCount += stats.codeMapQueryPrecomputeFailureCount + storage.syntaxCodeMapQueryPrecomputeSkippedCount += stats.codeMapQueryPrecomputeSkippedCount + } + } + + package func mergeSyntaxCodeMapStats(_ stats: CodeMapSyntaxPerfStats) { + lock.withLock { + storage.syntaxLanguageLookupDuration += stats.languageLookupDuration + storage.syntaxOversizeGuardDuration += stats.oversizeGuardDuration + storage.syntaxParserCreateDuration += stats.parserCreateDuration + storage.syntaxSetLanguageDuration += stats.setLanguageDuration + storage.syntaxParseDuration += stats.parseDuration + storage.syntaxCodeMapQueryLookupDuration += stats.codeMapQueryLookupDuration + storage.syntaxQueryExecuteDuration += stats.queryExecuteDuration + storage.syntaxCaptureMaterializationDuration += stats.captureMaterializationDuration + + storage.syntaxCodeMapCalls += stats.calls + storage.syntaxUnsupportedExtensionCount += stats.unsupported + storage.syntaxOversizedSkipCount += stats.oversized + storage.syntaxParseNilTreeCount += stats.parseNilTree + storage.syntaxParseNilRootCount += stats.parseNilRoot + storage.syntaxParserCreateCount += stats.parserCreates + storage.syntaxQueryExecuteCount += stats.queryExecutes + storage.syntaxCaptureCount += stats.captures + storage.codeMapQueryCacheHits += stats.codeMapQueryCacheHits + storage.codeMapQueryCacheMisses += stats.codeMapQueryCacheMisses + } + } + + package func mergeGeneratorStats(_ stats: CodeMapPerfStats) { + lock.withLock { + storage.generatorCaptureIndexDuration += stats.captureIndexDuration + storage.generatorSwiftContextDuration += stats.swiftContextDuration + storage.generatorTSContextDuration += stats.tsContextDuration + storage.generatorCaptureLoopDuration += stats.captureLoopDuration + storage.generatorCaptureLoopLineAdvanceDuration += stats.captureLoopLineAdvanceDuration + storage.generatorCaptureLoopSwiftStrategyDuration += stats.captureLoopSwiftStrategyDuration + storage.generatorCaptureLoopTSStrategyDuration += stats.captureLoopTSStrategyDuration + storage.generatorCaptureLoopInterfaceHeuristicDuration += stats.captureLoopInterfaceHeuristicDuration + storage.generatorCaptureLoopImportExportDuration += stats.captureLoopImportExportDuration + storage.generatorCaptureLoopTypeAliasDuration += stats.captureLoopTypeAliasDuration + storage.generatorCaptureLoopEnumMacroDuration += stats.captureLoopEnumMacroDuration + storage.generatorCaptureLoopFunctionDuration += stats.captureLoopFunctionDuration + storage.generatorCaptureLoopVariableDuration += stats.captureLoopVariableDuration + storage.generatorCaptureLoopSkippedDuration += stats.captureLoopSkippedDuration + storage.generatorCaptureLoopUnclassifiedDuration += stats.captureLoopUnclassifiedDuration + storage.generatorSwiftStrategyFunctionSignatureDuration += stats.swiftStrategyFunctionSignatureDuration + storage.generatorSwiftStrategyFunctionNameLookupDuration += stats.swiftStrategyFunctionNameLookupDuration + storage.generatorSwiftStrategyParameterExtractionDuration += stats.swiftStrategyParameterExtractionDuration + storage.generatorSwiftStrategyReturnTypeExtractionDuration += stats.swiftStrategyReturnTypeExtractionDuration + storage.generatorSwiftStrategyPropertyDeclarationDuration += stats.swiftStrategyPropertyDeclarationDuration + storage.generatorSwiftStrategyPropertyTypeExtractionDuration += stats.swiftStrategyPropertyTypeExtractionDuration + storage.generatorSwiftStrategyEnclosingTypeLookupDuration += stats.swiftStrategyEnclosingTypeLookupDuration + storage.generatorSwiftStrategyModelInsertionDuration += stats.swiftStrategyModelInsertionDuration + storage.generatorSwiftStrategyContextOnlyDuration += stats.swiftStrategyContextOnlyDuration + storage.generatorFallbackFunctionDeclarationDuration += stats.fallbackFunctionDeclarationDuration + storage.generatorFallbackFunctionJSTSSignatureDuration += stats.fallbackFunctionJSTSSignatureDuration + storage.generatorFallbackFunctionNameExtractionDuration += stats.fallbackFunctionNameExtractionDuration + storage.generatorFallbackFunctionLTEParseDuration += stats.fallbackFunctionLTEParseDuration + storage.generatorFallbackFunctionTSFastPathDuration += stats.fallbackFunctionTSFastPathDuration + storage.generatorFallbackFunctionReferencedTypesDuration += stats.fallbackFunctionReferencedTypesDuration + storage.generatorFallbackFunctionRoutingDuration += stats.fallbackFunctionRoutingDuration + storage.generatorFallbackFunctionModelInsertionDuration += stats.fallbackFunctionModelInsertionDuration + storage.generatorFallbackFunctionSkippedDuration += stats.fallbackFunctionSkippedDuration + storage.generatorDeclarationExtractionDuration += stats.captureDeclarationDuration + storage.generatorJSTSSignatureDuration += stats.jstsSignatureDuration + storage.generatorLanguageTypeExtractorFunctionDuration += stats.languageTypeExtractorFunctionDuration + storage.generatorLanguageTypeExtractorVariableDuration += stats.languageTypeExtractorVariableDuration + storage.generatorTypeCleanerDuration += stats.typeCleanerDuration + storage.generatorTypeCleanerSwiftDuration += stats.typeCleanerSwiftDuration + storage.generatorTypeCleanerTSDuration += stats.typeCleanerTSDuration + storage.generatorTypeCleanerTSXDuration += stats.typeCleanerTSXDuration + storage.generatorTypeCleanerJSDuration += stats.typeCleanerJSDuration + storage.generatorTypeCleanerOtherLanguageDuration += stats.typeCleanerOtherLanguageDuration + storage.generatorTypeCleanerPrecleanDuration += stats.typeCleanerPrecleanDuration + storage.generatorTypeCleanerTSLogicDuration += stats.typeCleanerTSLogicDuration + storage.generatorTypeCleanerNonTSLogicDuration += stats.typeCleanerNonTSLogicDuration + storage.generatorTypeCleanerTSObjectLiteralDuration += stats.typeCleanerTSObjectLiteralDuration + storage.generatorTypeCleanerFilterDuration += stats.typeCleanerFilterDuration + storage.generatorTypeCleanerDedupDuration += stats.typeCleanerDedupDuration + storage.generatorReferencedTypesFinalizeDuration += stats.referencedTypesFinalizeDuration + storage.generatorFileAPIInitDuration += stats.fileAPIInitDuration + + storage.capturesProcessed += stats.capturesProcessed + storage.swiftStrategyHandled += stats.swiftStrategyHandled + storage.tsStrategyHandled += stats.tsStrategyHandled + storage.fallbackHandled += stats.fallbackHandled + storage.generatorCaptureLoopLineAdvanceCount += stats.captureLoopLineAdvanceCount + storage.generatorCaptureLoopSwiftStrategyCount += stats.captureLoopSwiftStrategyCount + storage.generatorCaptureLoopTSStrategyCount += stats.captureLoopTSStrategyCount + storage.generatorCaptureLoopInterfaceHeuristicCount += stats.captureLoopInterfaceHeuristicCount + storage.generatorCaptureLoopImportExportCount += stats.captureLoopImportExportCount + storage.generatorCaptureLoopTypeAliasCount += stats.captureLoopTypeAliasCount + storage.generatorCaptureLoopEnumMacroCount += stats.captureLoopEnumMacroCount + storage.generatorCaptureLoopFunctionCount += stats.captureLoopFunctionCount + storage.generatorCaptureLoopVariableCount += stats.captureLoopVariableCount + storage.generatorCaptureLoopSkippedCount += stats.captureLoopSkippedCount + storage.generatorCaptureLoopUnclassifiedCount += stats.captureLoopUnclassifiedCount + storage.generatorSwiftStrategyFunctionSignatureCount += stats.swiftStrategyFunctionSignatureCount + storage.generatorSwiftStrategyFunctionNameLookupCount += stats.swiftStrategyFunctionNameLookupCount + storage.generatorSwiftStrategyParameterExtractionCount += stats.swiftStrategyParameterExtractionCount + storage.generatorSwiftStrategyReturnTypeExtractionCount += stats.swiftStrategyReturnTypeExtractionCount + storage.generatorSwiftStrategyPropertyDeclarationCount += stats.swiftStrategyPropertyDeclarationCount + storage.generatorSwiftStrategyPropertyTypeExtractionCount += stats.swiftStrategyPropertyTypeExtractionCount + storage.generatorSwiftStrategyEnclosingTypeLookupCount += stats.swiftStrategyEnclosingTypeLookupCount + storage.generatorSwiftStrategyModelInsertionCount += stats.swiftStrategyModelInsertionCount + storage.generatorSwiftStrategyContextOnlyCount += stats.swiftStrategyContextOnlyCount + storage.generatorSwiftStrategyHandledFunctionCount += stats.swiftStrategyHandledFunctionCount + storage.generatorSwiftStrategyHandledPropertyCount += stats.swiftStrategyHandledPropertyCount + storage.generatorFallbackFunctionDeclarationCount += stats.fallbackFunctionDeclarationCount + storage.generatorFallbackFunctionJSTSSignatureCount += stats.fallbackFunctionJSTSSignatureCount + storage.generatorFallbackFunctionNameExtractionCount += stats.fallbackFunctionNameExtractionCount + storage.generatorFallbackFunctionLTEParseCount += stats.fallbackFunctionLTEParseCount + storage.generatorFallbackFunctionTSFastPathCount += stats.fallbackFunctionTSFastPathCount + storage.generatorFallbackFunctionReferencedTypesCount += stats.fallbackFunctionReferencedTypesCount + storage.generatorFallbackFunctionRoutingCount += stats.fallbackFunctionRoutingCount + storage.generatorFallbackFunctionModelInsertionCount += stats.fallbackFunctionModelInsertionCount + storage.generatorFallbackFunctionSkippedCount += stats.fallbackFunctionSkippedCount + storage.generatorFallbackFunctionLightweightCount += stats.fallbackFunctionLightweightCount + storage.generatorFallbackFunctionHeavyweightCount += stats.fallbackFunctionHeavyweightCount + storage.generatorFallbackFunctionGlobalInsertCount += stats.fallbackFunctionGlobalInsertCount + storage.generatorFallbackFunctionMethodInsertCount += stats.fallbackFunctionMethodInsertCount + storage.generatorFallbackFunctionInterfaceInsertCount += stats.fallbackFunctionInterfaceInsertCount + storage.captureDeclarationCalls += stats.captureDeclarationCalls + storage.jstsSignatureCallsFunctionLike += stats.jstsSignatureCallsFunctionLike + storage.jstsSignatureCallsStatementLike += stats.jstsSignatureCallsStatementLike + storage.lteMatchAnyFunctionCalls += stats.lteMatchAnyFunctionCalls + storage.lteMatchAnyVariableCalls += stats.lteMatchAnyVariableCalls + storage.typeCleanerExtractCalls += stats.typeCleanerExtractCalls + storage.typeCleanerCacheHits += stats.typeCleanerCacheHits + storage.typeCleanerCacheMisses += stats.typeCleanerCacheMisses + storage.typeCleanerSwiftCalls += stats.typeCleanerSwiftCalls + storage.typeCleanerTSCalls += stats.typeCleanerTSCalls + storage.typeCleanerTSXCalls += stats.typeCleanerTSXCalls + storage.typeCleanerJSCalls += stats.typeCleanerJSCalls + storage.typeCleanerOtherLanguageCalls += stats.typeCleanerOtherLanguageCalls + storage.typeCleanerPrecleanCount += stats.typeCleanerPrecleanCount + storage.typeCleanerTSLogicCount += stats.typeCleanerTSLogicCount + storage.typeCleanerNonTSLogicCount += stats.typeCleanerNonTSLogicCount + storage.typeCleanerTSObjectLiteralCount += stats.typeCleanerTSObjectLiteralCount + storage.typeCleanerFilterCount += stats.typeCleanerFilterCount + storage.typeCleanerDedupCount += stats.typeCleanerDedupCount + storage.referencedTypesRawInsertions += stats.referencedTypesRawInsertions + storage.referencedTypesPrefilterSkips += stats.referencedTypesPrefilterSkips + storage.referencedTypesEmptyResults += stats.referencedTypesEmptyResults + storage.referencedTypesOutputTypeCount += stats.referencedTypesOutputTypeCount + storage.extractionMemoJSTSHits += stats.extractionMemoJSTSHits + storage.extractionMemoJSTSMisses += stats.extractionMemoJSTSMisses + storage.extractionMemoFunctionHits += stats.extractionMemoFunctionHits + storage.extractionMemoFunctionMisses += stats.extractionMemoFunctionMisses + storage.extractionMemoFunctionParsedHits += stats.extractionMemoFunctionParsedHits + storage.extractionMemoFunctionParsedMisses += stats.extractionMemoFunctionParsedMisses + storage.extractionMemoVariableHits += stats.extractionMemoVariableHits + storage.extractionMemoVariableMisses += stats.extractionMemoVariableMisses + storage.extractionMemoTSFastPathHits += stats.extractionMemoTSFastPathHits + storage.extractionMemoTSFastPathMisses += stats.extractionMemoTSFastPathMisses + } + } +} + +package enum CodeMapPerfRuntime { + package static let instrumentationEnvironmentKey = "REPOPROMPT_CODEMAP_PERF" + package static let benchmarkEnvironmentKey = "REPOPROMPT_RUN_CODEMAP_BENCHMARKS" + package static let benchmarkIterationsEnvironmentKey = "REPOPROMPT_CODEMAP_BENCHMARK_ITERATIONS" + package static let benchmarkMarkerPath = "/tmp/repoprompt-run-codemap-benchmarks" + + #if DEBUG || CODEMAP_PERF + static let isCompiledIn = true + #else + static let isCompiledIn = false + #endif + + private static var benchmarkMarkerEnabled: Bool { + guard isCompiledIn else { return false } + return !isRunningInCI && FileManager.default.fileExists(atPath: benchmarkMarkerPath) + } + + private static var benchmarkRequested: Bool { + guard isCompiledIn else { return false } + return environmentFlagEnabled(benchmarkEnvironmentKey) + || CommandLine.arguments.contains("--run-codemap-benchmarks") + || benchmarkMarkerEnabled + } + + package static let isEnabled: Bool = { + guard isCompiledIn else { return false } + return environmentFlagEnabled(instrumentationEnvironmentKey) || benchmarkRequested + }() + + package static let sharedPipelineStats: CodeMapPipelinePerfStats? = isEnabled ? CodeMapPipelinePerfStats() : nil + + package static func makeGeneratorOptions() -> CodeMapPerfOptions { + isEnabled ? .countersOnly : .disabled + } + + package static func makeGeneratorStats() -> CodeMapPerfStats? { + isEnabled ? CodeMapPerfStats() : nil + } + + @inline(__always) + package static func activeOptions(_ options: CodeMapPerfOptions) -> CodeMapPerfOptions { + #if DEBUG || CODEMAP_PERF + return options + #else + return .disabled + #endif + } + + @inline(__always) + package static func activeStats(_ stats: CodeMapPerfStats?) -> CodeMapPerfStats? { + #if DEBUG || CODEMAP_PERF + return stats + #else + return nil + #endif + } + + package static var shouldRunBenchmarks: Bool { + benchmarkRequested + } + + package static var isRunningInCI: Bool { + ["CI", "GITHUB_ACTIONS", "BUILDKITE", "JENKINS_URL", "TEAMCITY_VERSION"].contains { key in + ProcessInfo.processInfo.environment[key] != nil + } + } + + package static func environmentFlagEnabled(_ name: String) -> Bool { + guard let rawValue = ProcessInfo.processInfo.environment[name] else { + return false + } + switch rawValue.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() { + case "1", "true", "yes", "on", "enabled", "enable", "run": + return true + default: + return false + } + } + + package static func currentTime() -> DispatchTime { + DispatchTime.now() + } + + package static func durationSince(_ start: DispatchTime) -> TimeInterval { + Double(DispatchTime.now().uptimeNanoseconds - start.uptimeNanoseconds) / 1_000_000_000.0 + } +} + +package final class CodeMapPerfStats { + // Capture loop + package var capturesProcessed = 0 + package var swiftStrategyHandled = 0 + package var tsStrategyHandled = 0 + package var fallbackHandled = 0 + package var captureLoopLineAdvanceCount = 0 + package var captureLoopSwiftStrategyCount = 0 + package var captureLoopTSStrategyCount = 0 + package var captureLoopInterfaceHeuristicCount = 0 + package var captureLoopImportExportCount = 0 + package var captureLoopTypeAliasCount = 0 + package var captureLoopEnumMacroCount = 0 + package var captureLoopFunctionCount = 0 + package var captureLoopVariableCount = 0 + package var captureLoopSkippedCount = 0 + package var captureLoopUnclassifiedCount = 0 + package var swiftStrategyFunctionSignatureCount = 0 + package var swiftStrategyFunctionNameLookupCount = 0 + package var swiftStrategyParameterExtractionCount = 0 + package var swiftStrategyReturnTypeExtractionCount = 0 + package var swiftStrategyPropertyDeclarationCount = 0 + package var swiftStrategyPropertyTypeExtractionCount = 0 + package var swiftStrategyEnclosingTypeLookupCount = 0 + package var swiftStrategyModelInsertionCount = 0 + package var swiftStrategyContextOnlyCount = 0 + package var swiftStrategyHandledFunctionCount = 0 + package var swiftStrategyHandledPropertyCount = 0 + package var fallbackFunctionDeclarationCount = 0 + package var fallbackFunctionJSTSSignatureCount = 0 + package var fallbackFunctionNameExtractionCount = 0 + package var fallbackFunctionLTEParseCount = 0 + package var fallbackFunctionTSFastPathCount = 0 + package var fallbackFunctionReferencedTypesCount = 0 + package var fallbackFunctionRoutingCount = 0 + package var fallbackFunctionModelInsertionCount = 0 + package var fallbackFunctionSkippedCount = 0 + package var fallbackFunctionLightweightCount = 0 + package var fallbackFunctionHeavyweightCount = 0 + package var fallbackFunctionGlobalInsertCount = 0 + package var fallbackFunctionMethodInsertCount = 0 + package var fallbackFunctionInterfaceInsertCount = 0 + + // Declaration capture + JS/TS signature extraction + package var captureDeclarationCalls = 0 + package var jstsSignatureCallsFunctionLike = 0 + package var jstsSignatureCallsStatementLike = 0 + + // LanguageTypeExtractor + package var lteMatchAnyFunctionCalls = 0 + package var lteMatchAnyVariableCalls = 0 + package var tsConstructorMatches = 0 + package var tsAccessorMatches = 0 + package var tsClassMethodMatches = 0 + package var tsClassArrowMatches = 0 + package var tsClassArrowNoParensMatches = 0 + package var tsArrowFunctionMatches = 0 + package var tsArrowFunctionParamsReturnMatches = 0 + package var tsxConstructorMatches = 0 + package var tsxAccessorMatches = 0 + package var tsxClassMethodMatches = 0 + package var tsxClassArrowMatches = 0 + package var tsxClassArrowNoParensMatches = 0 + package var tsxArrowFunctionMatches = 0 + package var tsxArrowFunctionParamsReturnMatches = 0 + package var swiftReturnTypeFastPathHits = 0 + package var tsReturnTypeFastPathHits = 0 + package var tsTypeAnnotationFastPathHits = 0 + package var tsTypeAliasRhsFastPathHits = 0 + + // TypeCleaner + package var typeCleanerExtractCalls = 0 + package var typeCleanerCacheHits = 0 + package var typeCleanerCacheMisses = 0 + package var typeCleanerSwiftCalls = 0 + package var typeCleanerTSCalls = 0 + package var typeCleanerTSXCalls = 0 + package var typeCleanerJSCalls = 0 + package var typeCleanerOtherLanguageCalls = 0 + package var typeCleanerPrecleanCount = 0 + package var typeCleanerTSLogicCount = 0 + package var typeCleanerNonTSLogicCount = 0 + package var typeCleanerTSObjectLiteralCount = 0 + package var typeCleanerFilterCount = 0 + package var typeCleanerDedupCount = 0 + package var referencedTypesRawInsertions = 0 + package var referencedTypesPrefilterSkips = 0 + package var referencedTypesEmptyResults = 0 + package var referencedTypesOutputTypeCount = 0 + + // Extraction memo + package var extractionMemoJSTSHits = 0 + package var extractionMemoJSTSMisses = 0 + package var extractionMemoFunctionHits = 0 + package var extractionMemoFunctionMisses = 0 + package var extractionMemoFunctionParsedHits = 0 + package var extractionMemoFunctionParsedMisses = 0 + package var extractionMemoVariableHits = 0 + package var extractionMemoVariableMisses = 0 + package var extractionMemoTSFastPathHits = 0 + package var extractionMemoTSFastPathMisses = 0 + + // Durations + package var captureIndexDuration: TimeInterval = 0 + package var swiftContextDuration: TimeInterval = 0 + package var tsContextDuration: TimeInterval = 0 + package var captureLoopDuration: TimeInterval = 0 + package var captureLoopLineAdvanceDuration: TimeInterval = 0 + package var captureLoopSwiftStrategyDuration: TimeInterval = 0 + package var captureLoopTSStrategyDuration: TimeInterval = 0 + package var captureLoopInterfaceHeuristicDuration: TimeInterval = 0 + package var captureLoopImportExportDuration: TimeInterval = 0 + package var captureLoopTypeAliasDuration: TimeInterval = 0 + package var captureLoopEnumMacroDuration: TimeInterval = 0 + package var captureLoopFunctionDuration: TimeInterval = 0 + package var captureLoopVariableDuration: TimeInterval = 0 + package var captureLoopSkippedDuration: TimeInterval = 0 + package var captureLoopUnclassifiedDuration: TimeInterval = 0 + package var swiftStrategyFunctionSignatureDuration: TimeInterval = 0 + package var swiftStrategyFunctionNameLookupDuration: TimeInterval = 0 + package var swiftStrategyParameterExtractionDuration: TimeInterval = 0 + package var swiftStrategyReturnTypeExtractionDuration: TimeInterval = 0 + package var swiftStrategyPropertyDeclarationDuration: TimeInterval = 0 + package var swiftStrategyPropertyTypeExtractionDuration: TimeInterval = 0 + package var swiftStrategyEnclosingTypeLookupDuration: TimeInterval = 0 + package var swiftStrategyModelInsertionDuration: TimeInterval = 0 + package var swiftStrategyContextOnlyDuration: TimeInterval = 0 + package var fallbackFunctionDeclarationDuration: TimeInterval = 0 + package var fallbackFunctionJSTSSignatureDuration: TimeInterval = 0 + package var fallbackFunctionNameExtractionDuration: TimeInterval = 0 + package var fallbackFunctionLTEParseDuration: TimeInterval = 0 + package var fallbackFunctionTSFastPathDuration: TimeInterval = 0 + package var fallbackFunctionReferencedTypesDuration: TimeInterval = 0 + package var fallbackFunctionRoutingDuration: TimeInterval = 0 + package var fallbackFunctionModelInsertionDuration: TimeInterval = 0 + package var fallbackFunctionSkippedDuration: TimeInterval = 0 + package var captureDeclarationDuration: TimeInterval = 0 + package var jstsSignatureDuration: TimeInterval = 0 + package var languageTypeExtractorFunctionDuration: TimeInterval = 0 + package var languageTypeExtractorVariableDuration: TimeInterval = 0 + package var typeCleanerDuration: TimeInterval = 0 + package var typeCleanerSwiftDuration: TimeInterval = 0 + package var typeCleanerTSDuration: TimeInterval = 0 + package var typeCleanerTSXDuration: TimeInterval = 0 + package var typeCleanerJSDuration: TimeInterval = 0 + package var typeCleanerOtherLanguageDuration: TimeInterval = 0 + package var typeCleanerPrecleanDuration: TimeInterval = 0 + package var typeCleanerTSLogicDuration: TimeInterval = 0 + package var typeCleanerNonTSLogicDuration: TimeInterval = 0 + package var typeCleanerTSObjectLiteralDuration: TimeInterval = 0 + package var typeCleanerFilterDuration: TimeInterval = 0 + package var typeCleanerDedupDuration: TimeInterval = 0 + package var referencedTypesFinalizeDuration: TimeInterval = 0 + package var fileAPIInitDuration: TimeInterval = 0 +} diff --git a/Sources/RepoPromptCore/CodeMap/CodeMapRuntimeDiagnostics.swift b/Sources/RepoPromptCore/CodeMap/CodeMapRuntimeDiagnostics.swift new file mode 100644 index 000000000..4044e7794 --- /dev/null +++ b/Sources/RepoPromptCore/CodeMap/CodeMapRuntimeDiagnostics.swift @@ -0,0 +1,14 @@ +import Foundation + +#if DEBUG + enum CodeMapRuntimeDiagnostics { + static func start() -> Double? { + ProcessInfo.processInfo.systemUptime * 1000 + } + + static func cacheRebuild(rootCount _: Int, requestCount _: Int, startMS _: Double?) {} + static func cacheCheck(requestCount _: Int, queueableRequests _: Int, droppedRequests _: Int, startMS _: Double?) {} + static func prune(rootCount _: Int, startMS _: Double?) {} + static func enqueue(queueableRequests _: Int, startMS _: Double?) {} + } +#endif diff --git a/Sources/RepoPrompt/Features/CodeMap/CodeScanActor.swift b/Sources/RepoPromptCore/CodeMap/CodeScanActor.swift similarity index 92% rename from Sources/RepoPrompt/Features/CodeMap/CodeScanActor.swift rename to Sources/RepoPromptCore/CodeMap/CodeScanActor.swift index 3a3f9c9f9..d3610216d 100644 --- a/Sources/RepoPrompt/Features/CodeMap/CodeScanActor.swift +++ b/Sources/RepoPromptCore/CodeMap/CodeScanActor.swift @@ -1,4 +1,3 @@ -import Cocoa import Foundation #if DEBUG @@ -124,7 +123,7 @@ private actor CodeScanAsyncLimiter { #endif } -actor CodeScanActor { +package actor CodeScanActor { /// Conservative default scan concurrency: scale modestly with CPU, capped to avoid parser/generator memory spikes. private static var defaultMaxConcurrentScans: Int { let cpu = ProcessInfo.processInfo.activeProcessorCount @@ -138,7 +137,11 @@ actor CodeScanActor { /// The global SyntaxManager lock remains the safety backstop for all Tree-sitter entry points. private let treeSitterParseLimiter = CodeScanAsyncLimiter(capacity: 1) - init(maxConcurrentScans: Int = CodeScanActor.defaultMaxConcurrentScans) { + package init( + cacheRoot: URL, + maxConcurrentScans: Int = CodeScanActor.defaultMaxConcurrentScans + ) { + cacheManager = CodeMapCacheManager(baseDirectory: cacheRoot) self.maxConcurrentScans = max(1, maxConcurrentScans) } @@ -190,7 +193,7 @@ actor CodeScanActor { /// ------------------------------------- /// Cache and lightweight API completion state /// ------------------------------------- - private let cacheManager = CodeMapCacheManager() + private let cacheManager: CodeMapCacheManager /// Lightweight completion/accounting state for files that have produced an accepted API. /// Full `FileAPI` values are delivered through `ScanResult` and persisted in root caches; this set avoids retaining a duplicate actor-owned copy. private var acceptedAPIFileIDs = Set() @@ -237,12 +240,12 @@ actor CodeScanActor { // MARK: - Nested Data Structures /// ------------------------------------- - enum ScanBatchPurpose { + package enum ScanBatchPurpose { case initialRootLoad case adhoc } - struct ScanRequest { + package struct ScanRequest { let fileID: UUID let modificationDate: Date let content: String @@ -252,7 +255,7 @@ actor CodeScanActor { let rootFolderPath: String } - struct ScanResult: @unchecked Sendable { + package struct ScanResult: @unchecked Sendable { // Invariant: completed result batches must not retain ScanRequest.content. let fileID: UUID let modificationDate: Date @@ -274,31 +277,68 @@ actor CodeScanActor { } #if DEBUG - struct CodemapMemoryCounters { - let fileAPIEntryCount: Int - let latestFileModDateCount: Int - let trackedRootCount: Int - let trackedFileIDCount: Int - let rootKeyByFileIDCount: Int - - let rootCacheRootCount: Int - let rootCacheFileEntryCount: Int - let dirtyRootCount: Int - let rootCacheLoadTaskCount: Int - - let rebuildLookupRootCount: Int - let rebuildLookupFileEntryCount: Int - - let queuedCount: Int - let activeScanCount: Int - let outstandingScanCount: Int - let totalScheduledCount: Int - let cacheProcessingCount: Int - - let resultBatchBufferCount: Int - let resultBatchBufferFileAPICount: Int - - let actorRetainedFileAPILikeEntryCount: Int + package struct CodemapMemoryCounters { + package let fileAPIEntryCount: Int + package let latestFileModDateCount: Int + package let trackedRootCount: Int + package let trackedFileIDCount: Int + package let rootKeyByFileIDCount: Int + package let rootCacheRootCount: Int + package let rootCacheFileEntryCount: Int + package let dirtyRootCount: Int + package let rootCacheLoadTaskCount: Int + package let rebuildLookupRootCount: Int + package let rebuildLookupFileEntryCount: Int + package let queuedCount: Int + package let activeScanCount: Int + package let outstandingScanCount: Int + package let totalScheduledCount: Int + package let cacheProcessingCount: Int + package let resultBatchBufferCount: Int + package let resultBatchBufferFileAPICount: Int + package let actorRetainedFileAPILikeEntryCount: Int + + package init( + fileAPIEntryCount: Int, + latestFileModDateCount: Int, + trackedRootCount: Int, + trackedFileIDCount: Int, + rootKeyByFileIDCount: Int, + rootCacheRootCount: Int, + rootCacheFileEntryCount: Int, + dirtyRootCount: Int, + rootCacheLoadTaskCount: Int, + rebuildLookupRootCount: Int, + rebuildLookupFileEntryCount: Int, + queuedCount: Int, + activeScanCount: Int, + outstandingScanCount: Int, + totalScheduledCount: Int, + cacheProcessingCount: Int, + resultBatchBufferCount: Int, + resultBatchBufferFileAPICount: Int, + actorRetainedFileAPILikeEntryCount: Int + ) { + self.fileAPIEntryCount = fileAPIEntryCount + self.latestFileModDateCount = latestFileModDateCount + self.trackedRootCount = trackedRootCount + self.trackedFileIDCount = trackedFileIDCount + self.rootKeyByFileIDCount = rootKeyByFileIDCount + self.rootCacheRootCount = rootCacheRootCount + self.rootCacheFileEntryCount = rootCacheFileEntryCount + self.dirtyRootCount = dirtyRootCount + self.rootCacheLoadTaskCount = rootCacheLoadTaskCount + self.rebuildLookupRootCount = rebuildLookupRootCount + self.rebuildLookupFileEntryCount = rebuildLookupFileEntryCount + self.queuedCount = queuedCount + self.activeScanCount = activeScanCount + self.outstandingScanCount = outstandingScanCount + self.totalScheduledCount = totalScheduledCount + self.cacheProcessingCount = cacheProcessingCount + self.resultBatchBufferCount = resultBatchBufferCount + self.resultBatchBufferFileAPICount = resultBatchBufferFileAPICount + self.actorRetainedFileAPILikeEntryCount = actorRetainedFileAPILikeEntryCount + } } #endif @@ -352,7 +392,7 @@ actor CodeScanActor { // MARK: 1) Subscribe to batched scanning results /// ------------------------------------------------------- - nonisolated func subscribeToScanResults() -> AsyncStream<[ScanResult]> { + package nonisolated func subscribeToScanResults() -> AsyncStream<[ScanResult]> { let id = UUID() return AsyncStream { continuation in Task { await self.addResultContinuation(continuation, withID: id) } @@ -377,7 +417,7 @@ actor CodeScanActor { // MARK: 2) Subscribe to progress /// ------------------------------------------------------- - nonisolated func subscribeToProgress() -> AsyncStream<(Int, Int)> { + package nonisolated func subscribeToProgress() -> AsyncStream<(Int, Int)> { let id = UUID() return AsyncStream { continuation in Task { await self.addProgressContinuation(continuation, withID: id) } @@ -578,7 +618,7 @@ actor CodeScanActor { // MARK: 3) Cancel scans & unload for a given root /// ------------------------------------------------------- - func cancelAndUnloadScans(forRootFolder rootFolderPath: String) async { + package func cancelAndUnloadScans(forRootFolder rootFolderPath: String) async { let rootKey = canonicalRoot(rootFolderPath) advanceRootGenerations(forRootKeys: [rootKey]) @@ -616,7 +656,7 @@ actor CodeScanActor { // MARK: 3b) Cancel scans & unload for multiple roots /// ------------------------------------------------------- - func cancelAndUnloadScans(forRootFolders rootFolderPaths: [String]) async { + package func cancelAndUnloadScans(forRootFolders rootFolderPaths: [String]) async { let rootKeys = Set(rootFolderPaths.map { canonicalRoot($0) }) guard !rootKeys.isEmpty else { return } advanceRootGenerations(forRootKeys: rootKeys) @@ -764,7 +804,7 @@ actor CodeScanActor { // MARK: 5) Request scans (single or batch) - ASYNC versions /// ------------------------------------------------------- - func requestScan(_ request: ScanRequest) async { + package func requestScan(_ request: ScanRequest) async { let ingestStart = CodeMapPerfRuntime.sharedPipelineStats.map { _ in CodeMapPerfRuntime.currentTime() } defer { if let ingestStart { @@ -820,7 +860,7 @@ actor CodeScanActor { scheduleNextScan() } - func requestScans( + package func requestScans( _ requests: [ScanRequest], purpose: ScanBatchPurpose = .adhoc, rootFolderPaths: [String] = [], @@ -846,11 +886,11 @@ actor CodeScanActor { let initialRootLookupByRoot: [String: [String: CodeMapCacheFileEntry]] if purpose == .initialRootLoad { #if DEBUG - let cacheRebuildStartMS = CodeMapInitialRootLoadDiagnostics.start() + let cacheRebuildStartMS = CodeMapRuntimeDiagnostics.start() #endif initialRootLookupByRoot = await prepareCacheRebuild(forRoots: rootsForRebuild) #if DEBUG - CodeMapInitialRootLoadDiagnostics.cacheRebuild( + CodeMapRuntimeDiagnostics.cacheRebuild( rootCount: rootsForRebuild.count, requestCount: requests.count, startMS: cacheRebuildStartMS @@ -926,7 +966,7 @@ actor CodeScanActor { var droppedRequests = 0 finalQueue.reserveCapacity(requestsToQueue.count) #if DEBUG - let cacheCheckStartMS = purpose == .initialRootLoad ? CodeMapInitialRootLoadDiagnostics.start() : nil + let cacheCheckStartMS = purpose == .initialRootLoad ? CodeMapRuntimeDiagnostics.start() : nil #endif for request in requestsToQueue { let rootKey = canonicalRoot(request.rootFolderPath) @@ -964,7 +1004,7 @@ actor CodeScanActor { droppedRequests += finalQueue.count - queueableRequests.count #if DEBUG if purpose == .initialRootLoad { - CodeMapInitialRootLoadDiagnostics.cacheCheck( + CodeMapRuntimeDiagnostics.cacheCheck( requestCount: requestsToQueue.count, queueableRequests: queueableRequests.count, droppedRequests: droppedRequests, @@ -979,7 +1019,7 @@ actor CodeScanActor { if purpose == .initialRootLoad { #if DEBUG - let pruneStartMS = CodeMapInitialRootLoadDiagnostics.start() + let pruneStartMS = CodeMapRuntimeDiagnostics.start() #endif pruneInitialRootCaches( forRoots: rootsForRebuild, @@ -987,7 +1027,7 @@ actor CodeScanActor { expectedRootGenerations: expectedRootGenerations ) #if DEBUG - CodeMapInitialRootLoadDiagnostics.prune( + CodeMapRuntimeDiagnostics.prune( rootCount: rootsForRebuild.count, startMS: pruneStartMS ) @@ -996,14 +1036,14 @@ actor CodeScanActor { if !queueableRequests.isEmpty { #if DEBUG - let enqueueStartMS = purpose == .initialRootLoad ? CodeMapInitialRootLoadDiagnostics.start() : nil + let enqueueStartMS = purpose == .initialRootLoad ? CodeMapRuntimeDiagnostics.start() : nil #endif queue.append(contentsOf: queueableRequests) pushProgressUpdate() scheduleNextScan() #if DEBUG if purpose == .initialRootLoad { - CodeMapInitialRootLoadDiagnostics.enqueue( + CodeMapRuntimeDiagnostics.enqueue( queueableRequests: queueableRequests.count, startMS: enqueueStartMS ) @@ -1025,11 +1065,11 @@ actor CodeScanActor { // MARK: 6) Non-isolated wrappers for backward-compatible calls /// ------------------------------------------------------- - nonisolated func requestScan(_ request: ScanRequest) { + package nonisolated func requestScan(_ request: ScanRequest) { Task { await self.requestScan(request) } } - nonisolated func requestScans( + package nonisolated func requestScans( _ requests: [ScanRequest], purpose: ScanBatchPurpose = .adhoc, rootFolderPaths: [String] = [], @@ -1179,7 +1219,7 @@ actor CodeScanActor { // MARK: 8) Cancel everything /// ------------------------------------------------------- - func cancelAllScans() { + package func cancelAllScans() { advanceRootGenerations(forRootKeys: knownRootKeys()) cancelAllRootCacheLoads() @@ -1200,7 +1240,7 @@ actor CodeScanActor { } /// Clears all cached code maps for all root folders - func clearAllCaches(rootFolders: [String]) { + package func clearAllCaches(rootFolders: [String]) { let rootsToInvalidate = knownRootKeys().union(rootFolders.map { canonicalRoot($0) }) advanceRootGenerations(forRootKeys: rootsToInvalidate) @@ -1220,7 +1260,7 @@ actor CodeScanActor { } /// Removes on-disk caches for roots that no longer exist in any workspace. - func purgeStaleRootCaches(keepingRootPaths: [String]) { + package func purgeStaleRootCaches(keepingRootPaths: [String]) { let keepRoots = Set(keepingRootPaths.map { canonicalRoot($0) }) let staleTrackedRoots = Set(fileIDsByRoot.keys).subtracting(keepRoots) let staleKnownRoots = knownRootKeys().subtracting(keepRoots) @@ -1442,7 +1482,7 @@ actor CodeScanActor { } #if DEBUG - func codemapMemoryCounters() -> CodemapMemoryCounters { + package func codemapMemoryCounters() -> CodemapMemoryCounters { let trackedFileIDCount = fileIDsByRoot.values.reduce(0) { $0 + $1.count } let rootCacheFileEntryCount = rootCaches.values.reduce(0) { $0 + $1.files.count } let rebuildLookupFileEntryCount = rebuildLookupByRoot.values.reduce(0) { $0 + $1.count } diff --git a/Sources/RepoPromptCore/CodeMap/FileTreeSelectionSnapshot.swift b/Sources/RepoPromptCore/CodeMap/FileTreeSelectionSnapshot.swift new file mode 100644 index 000000000..da39e03c7 --- /dev/null +++ b/Sources/RepoPromptCore/CodeMap/FileTreeSelectionSnapshot.swift @@ -0,0 +1,90 @@ +import Foundation + +package struct FileTreeSelectionSnapshot { + package let roots: [FileTreeFolderSnapshot] + package let selectedFileIDs: Set + package let mode: String + package let showFullPaths: Bool + package let onlyIncludeRootsWithSelectedFiles: Bool + package let includeLegend: Bool + package let showCodeMapMarkers: Bool + package let maxDepth: Int? + + package init( + roots: [FileTreeFolderSnapshot], + selectedFileIDs: Set, + mode: String, + showFullPaths: Bool, + onlyIncludeRootsWithSelectedFiles: Bool, + includeLegend: Bool, + showCodeMapMarkers: Bool = true, + maxDepth: Int? = nil + ) { + self.roots = roots + self.selectedFileIDs = selectedFileIDs + self.mode = mode + self.showFullPaths = showFullPaths + self.onlyIncludeRootsWithSelectedFiles = onlyIncludeRootsWithSelectedFiles + self.includeLegend = includeLegend + self.showCodeMapMarkers = showCodeMapMarkers + self.maxDepth = maxDepth + } +} + +package struct FileTreeFolderSnapshot: Hashable { + package let id: UUID + package let name: String + package let fullPath: String + package let standardizedFullPath: String + package let standardizedRootPath: String + package let children: [FileTreeNodeSnapshot] + + package init( + id: UUID, + name: String, + fullPath: String, + standardizedFullPath: String, + standardizedRootPath: String, + children: [FileTreeNodeSnapshot] + ) { + self.id = id + self.name = name + self.fullPath = fullPath + self.standardizedFullPath = standardizedFullPath + self.standardizedRootPath = standardizedRootPath + self.children = children + } +} + +package struct FileTreeFileSnapshot: Hashable { + package let id: UUID + package let name: String + package let fileExtension: String? + package let hasCodeMap: Bool + + package init(id: UUID, name: String, fileExtension: String?, hasCodeMap: Bool) { + self.id = id + self.name = name + self.fileExtension = fileExtension + self.hasCodeMap = hasCodeMap + } +} + +package indirect enum FileTreeNodeSnapshot: Hashable { + case folder(FileTreeFolderSnapshot) + case file(FileTreeFileSnapshot) + + package var id: UUID { + switch self { + case let .folder(folder): folder.id + case let .file(file): file.id + } + } + + package var name: String { + switch self { + case let .folder(folder): folder.name + case let .file(file): file.name + } + } +} diff --git a/Sources/RepoPrompt/Features/CodeMap/CodeMapExtractor+Snapshots.swift b/Sources/RepoPromptCore/CodeMap/FileTreeSnapshotRenderer.swift similarity index 87% rename from Sources/RepoPrompt/Features/CodeMap/CodeMapExtractor+Snapshots.swift rename to Sources/RepoPromptCore/CodeMap/FileTreeSnapshotRenderer.swift index 678ba4d9d..c0c1a0c08 100644 --- a/Sources/RepoPrompt/Features/CodeMap/CodeMapExtractor+Snapshots.swift +++ b/Sources/RepoPromptCore/CodeMap/FileTreeSnapshotRenderer.swift @@ -1,41 +1,7 @@ import Foundation -extension CodeMapExtractor { - @MainActor - static func makeFileTreeSnapshot(using context: FileTreeSelectionContext) -> FileTreeSelectionSnapshot { - var roots: [FileTreeFolderSnapshot] = [] - roots.reserveCapacity(context.rootFolders.count) - - for root in context.rootFolders { - var visited = Set() - if let snapshot = snapshot( - folder: root, - rootStandardizedPath: root.standardizedFullPath, - visited: &visited - ) { - roots.append(snapshot) - } - } - - let mode = switch context.option { - case .none: "none" - case .selected: "selected" - case .files: "full" - case .auto: "auto" - } - - return FileTreeSelectionSnapshot( - roots: roots, - selectedFileIDs: context.selectedFileIDs, - mode: mode, - showFullPaths: context.filePathDisplay == .full, - onlyIncludeRootsWithSelectedFiles: context.onlyIncludeRootsWithSelectedFiles, - includeLegend: context.includeLegend, - showCodeMapMarkers: context.showCodeMapMarkers - ) - } - - static func generateFileTree(using snapshot: FileTreeSelectionSnapshot) -> String { +package enum FileTreeSnapshotRenderer { + package static func generateFileTree(using snapshot: FileTreeSelectionSnapshot) -> String { guard snapshot.mode.lowercased() != "none" else { return "" } guard !snapshot.roots.isEmpty else { return "" } if Task.isCancelled { return "" } @@ -98,7 +64,7 @@ extension CodeMapExtractor { usedSelectedMarker = usedSelectedMarker || usedSelection if let currentRemaining = remaining { - let consumedTokens = text.isEmpty ? 0 : max(1, estimateTokens(for: text)) + let consumedTokens = text.isEmpty ? 0 : max(1, snapshotEstimateTokens(for: text)) remaining = max(0, currentRemaining - consumedTokens) } @@ -166,7 +132,7 @@ extension CodeMapExtractor { var text = built.tree let usedCodeMapMarker = snapshot.showCodeMapMarkers && text.contains(snapshotCodeMapMark) - if estimateTokens(for: text) <= snapshotAutoTokenBudget { + if snapshotEstimateTokens(for: text) <= snapshotAutoTokenBudget { text = finalizeSnapshotTree( tree: text, includeLegend: snapshot.includeLegend, @@ -186,51 +152,6 @@ extension CodeMapExtractor { .map { snapshot.showFullPaths ? $0.fullPath : $0.name } .joined(separator: "\n") } - - @MainActor - private static func snapshot( - folder: FolderViewModel, - rootStandardizedPath: String, - visited: inout Set - ) -> FileTreeFolderSnapshot? { - guard visited.insert(folder.id).inserted else { return nil } - - var children: [FileTreeNodeSnapshot] = [] - children.reserveCapacity(folder.children.count) - for child in folder.children { - switch child { - case let .folder(subfolder): - if let subfolderSnapshot = snapshot( - folder: subfolder, - rootStandardizedPath: rootStandardizedPath, - visited: &visited - ) { - children.append(.folder(subfolderSnapshot)) - } - case let .file(file): - children.append(.file(snapshot(file: file))) - } - } - - return FileTreeFolderSnapshot( - id: folder.id, - name: folder.name, - fullPath: folder.fullPath, - standardizedFullPath: folder.standardizedFullPath, - standardizedRootPath: rootStandardizedPath, - children: children - ) - } - - @MainActor - private static func snapshot(file: FileViewModel) -> FileTreeFileSnapshot { - FileTreeFileSnapshot( - id: file.id, - name: file.name, - fileExtension: file.fileExtension, - hasCodeMap: file.hasAcceptedCodeMap - ) - } } private let snapshotBadExt: Set = ["o", "obj", "a", "so", "dll", "exe", "tmp", "swp"] @@ -242,6 +163,11 @@ private let snapshotCodeMapMark = " +" private let snapshotSelectedLegend = "(* denotes selected files)" private let snapshotCodeMapLegend = "(+ denotes code-map available)" +@inline(__always) +private func snapshotEstimateTokens(for text: String) -> Int { + Int((Double(text.utf8.count) / 4.0) * 1.05) +} + private struct SnapshotStringBuilder { private(set) var estimatedTokens: Int = 0 private var storage = "" diff --git a/Sources/RepoPrompt/Features/CodeMap/JSTSSignatureExtractor.swift b/Sources/RepoPromptCore/CodeMap/JSTSSignatureExtractor.swift similarity index 98% rename from Sources/RepoPrompt/Features/CodeMap/JSTSSignatureExtractor.swift rename to Sources/RepoPromptCore/CodeMap/JSTSSignatureExtractor.swift index 624f70991..82bed9ab0 100644 --- a/Sources/RepoPrompt/Features/CodeMap/JSTSSignatureExtractor.swift +++ b/Sources/RepoPromptCore/CodeMap/JSTSSignatureExtractor.swift @@ -8,7 +8,7 @@ import Foundation /// Context for JS/TS signature extraction - determines how braces are interpreted -enum JSTSSignatureContext { +package enum JSTSSignatureContext { /// Function-like declarations (function, method, arrow function) /// Cuts at the body `{` when it can be distinguished from type literals. /// Typed return functions may retain `{` in the signature. @@ -21,14 +21,14 @@ enum JSTSSignatureContext { /// Extracts clean signatures from JS/TS declarations. /// Handles type literals, generics, and arrow functions correctly. -enum JSTSSignatureExtractor { +package enum JSTSSignatureExtractor { /// Extracts a signature from a single line based on the context. /// /// - Parameters: /// - line: The declaration line to extract from /// - context: Whether this is a function-like or statement-like declaration /// - Returns: The extracted signature - static func extract( + package static func extract( from line: String, context: JSTSSignatureContext, perfStats: CodeMapPerfStats? = nil, @@ -403,7 +403,7 @@ enum JSTSSignatureExtractor { /// Extracts a variable signature by stripping the initializer from const/let/var lines. /// Keeps the full line for non-variable statements (e.g. type aliases). - static func extractVariableSignature(from line: String) -> String { + package static func extractVariableSignature(from line: String) -> String { let normalized = normalizeSingleLine(line) let trimmed = normalized.trimmingCharacters(in: .whitespacesAndNewlines) if trimmed.hasPrefix("type ") { return trimmed } diff --git a/Sources/RepoPrompt/Features/CodeMap/LanguageStrategies/SwiftCodeMapStrategy.swift b/Sources/RepoPromptCore/CodeMap/LanguageStrategies/SwiftCodeMapStrategy.swift similarity index 99% rename from Sources/RepoPrompt/Features/CodeMap/LanguageStrategies/SwiftCodeMapStrategy.swift rename to Sources/RepoPromptCore/CodeMap/LanguageStrategies/SwiftCodeMapStrategy.swift index 4d013100b..643096206 100644 --- a/Sources/RepoPrompt/Features/CodeMap/LanguageStrategies/SwiftCodeMapStrategy.swift +++ b/Sources/RepoPromptCore/CodeMap/LanguageStrategies/SwiftCodeMapStrategy.swift @@ -10,11 +10,11 @@ import SwiftTreeSitter /// Swift-specific code map generation strategy. /// Handles Swift type declarations, protocols, functions, and properties using range-based containment. -enum SwiftCodeMapStrategy { +package enum SwiftCodeMapStrategy { // MARK: - Swift Type Boundary /// Represents a Swift type container (class, struct, enum, actor, extension, protocol) with its full range - struct TypeBoundary { + package struct TypeBoundary { enum Kind: String { case `class`, `struct`, `enum`, actor, `extension`, `protocol` } let kind: Kind let name: String @@ -34,7 +34,7 @@ enum SwiftCodeMapStrategy { // MARK: - Context /// Context built during the pre-pass phase - struct Context { + package struct Context { var typeBoundaries: [TypeBoundary] = [] var typeNamesByRange: [NSRange: String] = [:] var protocolNamesByRange: [NSRange: String] = [:] @@ -94,7 +94,7 @@ enum SwiftCodeMapStrategy { // MARK: - Pre-pass: Build Type Boundaries /// Builds Swift type boundaries from captures using the capture index - static func buildContext( + package static func buildContext( index: CodeMapCaptureIndex, content: String, boundaries: [Int] @@ -246,7 +246,7 @@ enum SwiftCodeMapStrategy { // MARK: - Capture Handling /// Handles a Swift-specific capture. Returns true if handled, false to fall through to default handling. - static func handleCapture( + package static func handleCapture( _ cap: NamedRange, context: Context, index: CodeMapCaptureIndex, diff --git a/Sources/RepoPrompt/Features/CodeMap/LanguageStrategies/TypeScriptCodeMapStrategy.swift b/Sources/RepoPromptCore/CodeMap/LanguageStrategies/TypeScriptCodeMapStrategy.swift similarity index 99% rename from Sources/RepoPrompt/Features/CodeMap/LanguageStrategies/TypeScriptCodeMapStrategy.swift rename to Sources/RepoPromptCore/CodeMap/LanguageStrategies/TypeScriptCodeMapStrategy.swift index 4337e9d48..c770e8387 100644 --- a/Sources/RepoPrompt/Features/CodeMap/LanguageStrategies/TypeScriptCodeMapStrategy.swift +++ b/Sources/RepoPromptCore/CodeMap/LanguageStrategies/TypeScriptCodeMapStrategy.swift @@ -11,11 +11,11 @@ import SwiftTreeSitter /// TypeScript/TSX-specific code map generation strategy. /// Handles TS class/interface declarations and members using range-based containment. /// Note: This strategy is for TS/TSX only - JS uses the default/legacy path. -enum TypeScriptCodeMapStrategy { +package enum TypeScriptCodeMapStrategy { // MARK: - TS Container Boundary /// Represents a TS container (class, interface) with its full range - struct ContainerBoundary { + package struct ContainerBoundary { enum Kind { case `class`, interface } let kind: Kind let name: String @@ -26,14 +26,14 @@ enum TypeScriptCodeMapStrategy { // MARK: - Context /// Context built during the pre-pass phase - struct Context { + package struct Context { var containerBoundaries: [ContainerBoundary] = [] } // MARK: - Pre-pass: Build Container Boundaries /// Builds TS container boundaries from captures using the capture index - static func buildContext( + package static func buildContext( index: CodeMapCaptureIndex, content: String, boundaries: [Int] @@ -135,14 +135,14 @@ enum TypeScriptCodeMapStrategy { } /// Checks if TS range containment should be used (has container boundaries) - static func useRangeContainment(_ context: Context) -> Bool { + package static func useRangeContainment(_ context: Context) -> Bool { !context.containerBoundaries.isEmpty } // MARK: - Capture Handling /// Handles a TS-specific capture. Returns true if handled, false to fall through to default handling. - static func handleCapture( + package static func handleCapture( _ cap: NamedRange, context: Context, index: CodeMapCaptureIndex, diff --git a/Sources/RepoPrompt/Features/CodeMap/LanguageTypeExtractor.swift b/Sources/RepoPromptCore/CodeMap/LanguageTypeExtractor.swift similarity index 94% rename from Sources/RepoPrompt/Features/CodeMap/LanguageTypeExtractor.swift rename to Sources/RepoPromptCore/CodeMap/LanguageTypeExtractor.swift index 42d68832e..a8a237116 100644 --- a/Sources/RepoPrompt/Features/CodeMap/LanguageTypeExtractor.swift +++ b/Sources/RepoPromptCore/CodeMap/LanguageTypeExtractor.swift @@ -26,7 +26,7 @@ import Foundation /// /// The main static struct that holds all regex patterns and /// top-level “matchAny…” methods for variables & functions. -enum LanguageTypeExtractor { +package enum LanguageTypeExtractor { // MARK: - Swift Patterns /// Now allows optional `<...>` generics right after function name. @@ -34,7 +34,7 @@ enum LanguageTypeExtractor { /// Now allows optional `<...>` generics right after function name, /// plus newly added keywords such as weak, unowned, dynamic, distributed, isolated, etc. - static let swiftFunctionRegex = CodeMapPCRE2Pattern(#""" + package static let swiftFunctionRegex = CodeMapPCRE2Pattern(#""" (?xm) ^ (?:[-*]\s*)? @@ -47,7 +47,7 @@ enum LanguageTypeExtractor { rethrows|throws|weak|unowned|dynamic|distributed) \s+ )* - func\s+ + package func\s+ ([A-Za-z_]\w*) (?:<[^>]+>)? # <-- optional generics \s* @@ -63,7 +63,7 @@ enum LanguageTypeExtractor { """#) /// Updated with new Swift keywords for var/let. - static let swiftVariableRegex = CodeMapPCRE2Pattern(#""" + package static let swiftVariableRegex = CodeMapPCRE2Pattern(#""" (?xm) ^ (?:[-*]\s*)? @@ -84,7 +84,7 @@ enum LanguageTypeExtractor { // MARK: - C# Patterns - static let cSharpVariableRegex: Regex = try! Regex(#""" + package static let cSharpVariableRegex: Regex = try! Regex(#""" (?xm) ^ (?:public|private|protected|internal)?\s* @@ -95,7 +95,7 @@ enum LanguageTypeExtractor { \s+(\**[A-Za-z_]\w*) """#) - static let cSharpFunctionRegex: Regex = try! Regex(#""" + package static let cSharpFunctionRegex: Regex = try! Regex(#""" (?xm) ^ (?:public|private|protected|internal)?\s* @@ -110,7 +110,7 @@ enum LanguageTypeExtractor { // MARK: - Java - static let javaVariableRegex: Regex = try! Regex(#""" + package static let javaVariableRegex: Regex = try! Regex(#""" (?xm) ^ (?:public|private|protected)?\s* @@ -121,7 +121,7 @@ enum LanguageTypeExtractor { \s+([A-Za-z_]\w*) """#) - static let javaFunctionRegex: Regex = try! Regex(#""" + package static let javaFunctionRegex: Regex = try! Regex(#""" (?xm) ^ (?:public|private|protected)?\s* @@ -136,7 +136,7 @@ enum LanguageTypeExtractor { // MARK: - Dart - static let dartVariableRegex: Regex = try! Regex(#""" + package static let dartVariableRegex: Regex = try! Regex(#""" (?xm) ^ (?: @@ -149,7 +149,7 @@ enum LanguageTypeExtractor { ) """#) - static let dartFunctionRegex: Regex = try! Regex(#""" + package static let dartFunctionRegex: Regex = try! Regex(#""" (?xm) ^\s* (?: @@ -162,7 +162,7 @@ enum LanguageTypeExtractor { .*$ """#) - static let dartFactoryRegex: Regex = try! Regex(#""" + package static let dartFactoryRegex: Regex = try! Regex(#""" (?xm) ^\s* factory\s+ @@ -172,7 +172,7 @@ enum LanguageTypeExtractor { \) """#) - static let dartGetterSetterRegex: Regex = try! Regex(#""" + package static let dartGetterSetterRegex: Regex = try! Regex(#""" (?xm) ^\s* (?:([A-Za-z_][A-Za-z0-9_<>\?]+)\s+)? # group(1) => optional return type @@ -183,7 +183,7 @@ enum LanguageTypeExtractor { // MARK: - C - static let cVariableRegex: Regex = try! Regex(#""" + package static let cVariableRegex: Regex = try! Regex(#""" (?xm) ^ (?:extern|static|register)?\s* @@ -193,7 +193,7 @@ enum LanguageTypeExtractor { \s+([A-Za-z_]\w*) """#) - static let cFunctionRegex: Regex = try! Regex(#""" + package static let cFunctionRegex: Regex = try! Regex(#""" (?xm) ^ (?:extern|static)?\s* @@ -208,7 +208,7 @@ enum LanguageTypeExtractor { // MARK: - C++ (leading-return + trailing-return) - static let cppVariableRegex: Regex = try! Regex(#""" + package static let cppVariableRegex: Regex = try! Regex(#""" (?xm) ^ (?:extern|static|register|thread_local)?\s* @@ -220,7 +220,7 @@ enum LanguageTypeExtractor { \s+([A-Za-z_]\w*) """#) - static let cppFunctionRegex: Regex = try! Regex(#""" + package static let cppFunctionRegex: Regex = try! Regex(#""" (?xm) ^ (?:template\s*<[^>]*>\s*)? @@ -233,7 +233,7 @@ enum LanguageTypeExtractor { \) """#) - static let cppConstructorRegex: Regex = try! Regex(#""" + package static let cppConstructorRegex: Regex = try! Regex(#""" (?xm) ^\s* (?:template\s*<[^>]*>\s*)? @@ -244,7 +244,7 @@ enum LanguageTypeExtractor { \) """#) - static let cppTrailingReturnFunctionRegex: Regex = try! Regex(#""" + package static let cppTrailingReturnFunctionRegex: Regex = try! Regex(#""" (?xm) ^ (?:template\s*<[^>]*>\s*)? @@ -263,26 +263,26 @@ enum LanguageTypeExtractor { /// Python variable: `name: Type` /// Output: (wholeMatch, varName, typeName) - static let pythonVariableRegex: Regex<(Substring, Substring, Substring)> = + package static let pythonVariableRegex: Regex<(Substring, Substring, Substring)> = #/^([A-Za-z_]\w*)\s*:\s*([A-Za-z_][A-Za-z0-9_\.\[\]\|]*)/# /// Python function: `(async )?def name(params)( -> returnType)?:` /// Output: (wholeMatch, funcName, paramList, returnType?) - static let pythonFunctionRegex: Regex<(Substring, Substring, Substring, Substring?)> = + package static let pythonFunctionRegex: Regex<(Substring, Substring, Substring, Substring?)> = #/^(?:async\s+)?def\s+([A-Za-z_]\w*)\s*\(([^)]*)\)(?:\s*->\s*([A-Za-z_][A-Za-z0-9_\.\[\]\|\,\(\)\{\}\s]*))?\s*:.*$/# // MARK: - JavaScript/TypeScript (basic patterns) /// JS/TS variable: `(var|let|const) name?: Type` /// Output: (wholeMatch, varName, typeName?) - static let jsTsVariableRegex = CodeMapPCRE2Pattern(#"^[ \t]*(?:var|let|const)\s+([A-Za-z_]\w*\??)\s*(?::\s*([A-Za-z_][A-Za-z0-9_<>\|\[\]\(\)\{\}\&\?\.\-]*))?"#) + package static let jsTsVariableRegex = CodeMapPCRE2Pattern(#"^[ \t]*(?:var|let|const)\s+([A-Za-z_]\w*\??)\s*(?::\s*([A-Za-z_][A-Za-z0-9_<>\|\[\]\(\)\{\}\&\?\.\-]*))?"#) /// JS/TS function: `(export)? (default)? (async)? function name?(params): ReturnType?` /// Output: (wholeMatch, funcName, paramList, returnType?) - static let jsTsFunctionRegex = CodeMapPCRE2Pattern(#"^[ \t]*(?:export\s+)?(?:default\s+)?(?:async\s+)?function\s+([A-Za-z_]\w*\??)(?:<[^>]+>)?\s*\(([^)]*)\)(?:\s*:\s*([A-Za-z_][A-Za-z0-9_<>\|\[\]\(\)\{\}\&\?\.\-]*))?(?:\s*\{)?"#) + package static let jsTsFunctionRegex = CodeMapPCRE2Pattern(#"^[ \t]*(?:export\s+)?(?:default\s+)?(?:async\s+)?function\s+([A-Za-z_]\w*\??)(?:<[^>]+>)?\s*\(([^)]*)\)(?:\s*:\s*([A-Za-z_][A-Za-z0-9_<>\|\[\]\(\)\{\}\&\?\.\-]*))?(?:\s*\{)?"#) /// TS arrow function pattern - static let tsArrowFunctionRegex = CodeMapPCRE2Pattern(#""" + package static let tsArrowFunctionRegex = CodeMapPCRE2Pattern(#""" (?xm) ^[ \t]* (?:export\s+)?(?:default\s+)?(?:async\s+)? @@ -300,20 +300,20 @@ enum LanguageTypeExtractor { /// TS arrow function with params and return: `const name = (params): ReturnType =>` /// Output: (wholeMatch, funcName, paramList, returnType) - static let tsArrowFunctionParamsReturnRegex = CodeMapPCRE2Pattern(#"^[ \t]*(?:export\s+)?(?:default\s+)?(?:async\s+)?(?:var|let|const)\s+([A-Za-z_]\w*\??)\s*=\s*\(([^)]*)\)\s*:\s*([A-Za-z_][A-Za-z0-9_<>\|\[\]\(\)\{\}\&\?\.\-\s,]*)\s*=>"#) + package static let tsArrowFunctionParamsReturnRegex = CodeMapPCRE2Pattern(#"^[ \t]*(?:export\s+)?(?:default\s+)?(?:async\s+)?(?:var|let|const)\s+([A-Za-z_]\w*\??)\s*=\s*\(([^)]*)\)\s*:\s*([A-Za-z_][A-Za-z0-9_<>\|\[\]\(\)\{\}\&\?\.\-\s,]*)\s*=>"#) /// TS type alias: `(export)? type Name = Type` /// Output: (wholeMatch, aliasName, rhsType) - static let tsTypeAliasRegex = CodeMapPCRE2Pattern(#"^[ \t]*(?:export\s+)?(?:default\s+)?type\s+([A-Za-z_]\w*)\s*=\s*([A-Za-z_][A-Za-z0-9_<>\|\[\]\(\)\{\}\&\?\.\-]*)"#) + package static let tsTypeAliasRegex = CodeMapPCRE2Pattern(#"^[ \t]*(?:export\s+)?(?:default\s+)?type\s+([A-Za-z_]\w*)\s*=\s*([A-Za-z_][A-Za-z0-9_<>\|\[\]\(\)\{\}\&\?\.\-]*)"#) /// TS variable: `(export)? (var|let|const) name?: Type` /// Output: (wholeMatch, varName, typeName?) - static let tsVariableRegex = CodeMapPCRE2Pattern(#"^[ \t]*(?:export\s+)?(?:default\s+)?(?:async\s+)?(?:var|let|const)\s+([A-Za-z_]\w*\??)(?:\s*:\s*([A-Za-z_][A-Za-z0-9_<>\|\[\]\(\)\{\}\&\?\.\-]*))?"#) + package static let tsVariableRegex = CodeMapPCRE2Pattern(#"^[ \t]*(?:export\s+)?(?:default\s+)?(?:async\s+)?(?:var|let|const)\s+([A-Za-z_]\w*\??)(?:\s*:\s*([A-Za-z_][A-Za-z0-9_<>\|\[\]\(\)\{\}\&\?\.\-]*))?"#) // MARK: - TSX /// Very similar to TS but we allow a generic in the name, e.g. "function FooComponent()" - static let tsxVariableRegex = CodeMapPCRE2Pattern(#""" + package static let tsxVariableRegex = CodeMapPCRE2Pattern(#""" (?xm) ^[ \t]* (?:export\s+)?(?:default\s+)?(?:async\s+)? @@ -323,7 +323,7 @@ enum LanguageTypeExtractor { ([A-Za-z_][A-Za-z0-9_<>\|\[\]\(\)\{\}\&\?\.\-]*))? """#) - static let tsxFunctionRegex = CodeMapPCRE2Pattern(#""" + package static let tsxFunctionRegex = CodeMapPCRE2Pattern(#""" (?xm) ^[ \t]* (?:export\s+)?(?:default\s+)?(?:async\s+)? @@ -337,7 +337,7 @@ enum LanguageTypeExtractor { (?:\s*\{)? """#) - static let tsxArrowFunctionRegex = CodeMapPCRE2Pattern(#""" + package static let tsxArrowFunctionRegex = CodeMapPCRE2Pattern(#""" (?xm) ^[ \t]* (?:export\s+)?(?:default\s+)?(?:async\s+)? @@ -355,7 +355,7 @@ enum LanguageTypeExtractor { // MARK: - TS class-level methods / properties - static let tsClassMethodRegex = CodeMapPCRE2Pattern(#""" + package static let tsClassMethodRegex = CodeMapPCRE2Pattern(#""" (?xm) ^[ \t]* (?:export\s+)?(?:default\s+)? @@ -369,7 +369,7 @@ enum LanguageTypeExtractor { (?:\s*\{)? """#) - static let tsClassPropertyRegex = CodeMapPCRE2Pattern(#""" + package static let tsClassPropertyRegex = CodeMapPCRE2Pattern(#""" (?xm) ^[ \t]* (?:export\s+)?(?:default\s+)? @@ -379,7 +379,7 @@ enum LanguageTypeExtractor { ([A-Za-z_][A-Za-z0-9_<>\|\[\]\(\)\{\}\&\?\.\-]*) """#) - static let tsClassArrowMethodRegex = CodeMapPCRE2Pattern(#""" + package static let tsClassArrowMethodRegex = CodeMapPCRE2Pattern(#""" (?xm) ^[ \t]* (?:export\s+)?(?:default\s+)? @@ -394,7 +394,7 @@ enum LanguageTypeExtractor { (?:\s*\{)? """#) - static let tsClassArrowNoParensRegex = CodeMapPCRE2Pattern(#""" + package static let tsClassArrowNoParensRegex = CodeMapPCRE2Pattern(#""" (?xm) ^[ \t]* (?:export\s+)?(?:default\s+)? @@ -408,7 +408,7 @@ enum LanguageTypeExtractor { (?:\s*\{)? """#) - static let tsConstructorRegex = CodeMapPCRE2Pattern(#""" + package static let tsConstructorRegex = CodeMapPCRE2Pattern(#""" (?xm) ^[ \t]* (?:(?:public|private|protected)\s+)? @@ -418,7 +418,7 @@ enum LanguageTypeExtractor { (?:\s*\{)? """#) - static let tsAccessorRegex = CodeMapPCRE2Pattern(#""" + package static let tsAccessorRegex = CodeMapPCRE2Pattern(#""" (?xm) ^[ \t]* (?:export\s+)?(?:default\s+)? @@ -433,7 +433,7 @@ enum LanguageTypeExtractor { // MARK: - Go - static let goVariableRegex: Regex = try! Regex(#""" + package static let goVariableRegex: Regex = try! Regex(#""" (?xm) ^ (?:var|const)\s+ @@ -444,10 +444,10 @@ enum LanguageTypeExtractor { (?:\s*=\s*[^;]+)? """#) - static let goFunctionRegex: Regex = try! Regex(#""" + package static let goFunctionRegex: Regex = try! Regex(#""" (?xm) ^ - func\s+ + package func\s+ (?:\([^)]*\)\s+)? # optional receiver ([A-Za-z_]\w*) \s*\( @@ -463,10 +463,10 @@ enum LanguageTypeExtractor { // MARK: - Rust - static let rustVariableRegex: Regex = try! Regex(#""" + package static let rustVariableRegex: Regex = try! Regex(#""" (?xm) ^ - let\s+(?:mut\s+)? + package let\s+(?:mut\s+)? ([A-Za-z_]\w*) (?: \s*:\s*([A-Za-z_][A-Za-z0-9_<>:\?\*\&]+) @@ -474,7 +474,7 @@ enum LanguageTypeExtractor { (?:\s*=\s*[^;]+)? """#) - static let rustFunctionRegex: Regex = try! Regex(#""" + package static let rustFunctionRegex: Regex = try! Regex(#""" (?xm) ^ (?:pub(?:\([^)]*\))?\s+)?(?:async\s+)?(?:unsafe\s+)? @@ -536,7 +536,7 @@ enum LanguageTypeExtractor { // MARK: - Public API for variables /// Generic entry point for variable lines. Returns ["name":..., "type":...]. - static func matchAnyVariableLine( + package static func matchAnyVariableLine( _ line: String, language: LanguageType, stats: CodeMapPerfStats? = nil @@ -754,7 +754,7 @@ enum LanguageTypeExtractor { // MARK: - Public API for function lines - struct FunctionLineMatch { + package struct FunctionLineMatch { let name: String? let paramList: String? let returnType: String? @@ -1086,7 +1086,7 @@ enum LanguageTypeExtractor { /// Generic entry point for function lines. Returns a dictionary with keys /// ["name":..., "returnType":..., "paramList":..., "parameterTypes":...]. - static func matchAnyFunctionLineParsed( + package static func matchAnyFunctionLineParsed( _ line: String, language: LanguageType, stats: CodeMapPerfStats? = nil @@ -1291,7 +1291,7 @@ enum LanguageTypeExtractor { } } - static func matchAnyFunctionLine( + package static func matchAnyFunctionLine( _ line: String, language: LanguageType, stats: CodeMapPerfStats? = nil @@ -1611,7 +1611,7 @@ enum LanguageTypeExtractor { // MARK: - Shared / Private Helpers (Swift Regex) /// Typealias for Swift Regex match result with dynamic output. - typealias RegexMatch = Regex.Match + package typealias RegexMatch = Regex.Match /// Extracts variable name and type from a regex match. /// - Parameters: @@ -1666,7 +1666,7 @@ enum LanguageTypeExtractor { } /// Extracts up to 3 named groups from a match, returning them in a dict. - static func extractNamedGroups( + package static func extractNamedGroups( match: RegexMatch, groupNames: (String, String, String), indices: (Int, Int, Int) @@ -1687,7 +1687,7 @@ enum LanguageTypeExtractor { return dict } - static func extractNamedGroups( + package static func extractNamedGroups( match: CodeMapPCRE2Match, groupNames: (String, String, String), indices: (Int, Int, Int) @@ -2104,7 +2104,7 @@ enum LanguageTypeExtractor { } } -extension LanguageTypeExtractor { +package extension LanguageTypeExtractor { enum TS { static func extractTypeAnnotation(from line: String) -> String? { LanguageTypeExtractor.extractTSTypeAnnotation(from: line) diff --git a/Sources/RepoPromptCore/CodeMap/Models/FileAPI.swift b/Sources/RepoPromptCore/CodeMap/Models/FileAPI.swift new file mode 100644 index 000000000..f00acf42b --- /dev/null +++ b/Sources/RepoPromptCore/CodeMap/Models/FileAPI.swift @@ -0,0 +1,323 @@ +import Foundation + +// MARK: - Supporting Types + +package struct InterfaceInfo: Codable { + package let name: String + package var properties: [PropertyInfo] + package var methods: [FunctionInfo] + + package init(name: String, properties: [PropertyInfo] = [], methods: [FunctionInfo] = []) { + self.name = name + self.properties = properties + self.methods = methods + } +} + +package struct TypeAliasInfo: Codable { + package let name: String + package let definitionLine: String + + package init(name: String, definitionLine: String) { + self.name = name + self.definitionLine = definitionLine + } +} + +package struct ClassInfo: Codable { + package let name: String + package var methods: [FunctionInfo] + package var properties: [PropertyInfo] + + package init(name: String, methods: [FunctionInfo], properties: [PropertyInfo]) { + self.name = name + self.methods = methods + self.properties = properties + } +} + +package struct FunctionInfo: Codable { + package let name: String + package var parameters: [ParameterInfo] + package var returnType: String? + package let definitionLine: String + package let lineNumber: Int? + + package init( + name: String, + parameters: [ParameterInfo], + returnType: String?, + definitionLine: String, + lineNumber: Int? + ) { + self.name = name + self.parameters = parameters + self.returnType = returnType + self.definitionLine = definitionLine + self.lineNumber = lineNumber + } +} + +package struct ParameterInfo: Codable { + package let externalName: String? + package let localName: String + package var typeName: String? + + package init(externalName: String?, localName: String, typeName: String?) { + self.externalName = externalName + self.localName = localName + self.typeName = typeName + } +} + +package struct PropertyInfo: Codable { + package let name: String + package let typeName: String? + + package init(name: String, typeName: String?) { + self.name = name + self.typeName = typeName + } +} + +package struct VariableInfo: Codable { + package let name: String + package let typeName: String? + package let definitionLine: String + + package init(name: String, typeName: String?, definitionLine: String) { + self.name = name + self.typeName = typeName + self.definitionLine = definitionLine + } +} + +package struct EnumInfo: Codable { + package let name: String + package var cases: [String] + + package init(name: String, cases: [String]) { + self.name = name + self.cases = cases + } +} + +/// Represents a structured "API surface" for a file. +package struct FileAPI: Codable { + package let filePath: String + package var imports: [String] + package var exports: [String] + package var classes: [ClassInfo] + package var interfaces: [InterfaceInfo] + package var aliases: [TypeAliasInfo] + package var literalUnions: [String] + package var functions: [FunctionInfo] + package var enums: [EnumInfo] + package var globalVars: [VariableInfo] + package var macros: [String] + package let referencedTypes: [String] + + package let apiDescription: String + package let definedTypeNames: Set + package let pathAndImportsDescription: String + package let apiTokenCount: Int + + package enum CodingKeys: String, CodingKey { + case filePath, imports, exports, classes, interfaces, aliases, + literalUnions, functions, enums, globalVars, macros, referencedTypes + } + + package init( + filePath: String, + imports: [String], + exports: [String] = [], + classes: [ClassInfo], + interfaces: [InterfaceInfo] = [], + aliases: [TypeAliasInfo] = [], + literalUnions: [String] = [], + functions: [FunctionInfo], + enums: [EnumInfo], + globalVars: [VariableInfo], + macros: [String], + referencedTypes: [String] + ) { + self.filePath = filePath + self.imports = imports + self.exports = exports + self.classes = classes + self.interfaces = interfaces + self.aliases = aliases + self.literalUnions = literalUnions + self.functions = functions + self.enums = enums + self.globalVars = globalVars + self.macros = macros + self.referencedTypes = referencedTypes + + var lines = ["---"] + + func formatFunctionLine(_ function: FunctionInfo) -> String { + if let line = function.lineNumber { + return "L\(line): \(function.definitionLine)" + } + return function.definitionLine + } + + func formatPropertyLine(_ name: String, typeName: String?) -> String { + guard let typeName, !typeName.isEmpty else { return name } + if name.contains(":") { return name } + return "\(name): \(typeName)" + } + + if !classes.isEmpty { + lines.append("Classes:") + for classInfo in classes { + lines.append(" - \(classInfo.name)") + if !classInfo.methods.isEmpty { + lines.append(" Methods:") + for method in classInfo.methods { + lines.append(" - \(formatFunctionLine(method))") + } + } + if !classInfo.properties.isEmpty { + lines.append(" Properties:") + for property in classInfo.properties { + lines.append(" - \(formatPropertyLine(property.name, typeName: property.typeName))") + } + } + } + } + if !interfaces.isEmpty { + lines.append("") + lines.append("Interfaces:") + for interface in interfaces { + lines.append(" - \(interface.name)") + if !interface.methods.isEmpty { + lines.append(" Methods:") + for method in interface.methods { + lines.append(" - \(formatFunctionLine(method))") + } + } + if !interface.properties.isEmpty { + lines.append(" Properties:") + for property in interface.properties { + lines.append(" - \(formatPropertyLine(property.name, typeName: property.typeName))") + } + } + } + } + if !aliases.isEmpty { + lines.append("") + lines.append("Type-aliases:") + for alias in aliases { + lines.append(" - \(alias.name)") + } + } + if !literalUnions.isEmpty { + lines.append("") + lines.append("Literal-union aliases:") + for union in literalUnions { + lines.append(" - \(union)") + } + } + if !functions.isEmpty { + lines.append("") + lines.append("Functions:") + for function in functions { + lines.append(" - \(formatFunctionLine(function))") + } + } + if !enums.isEmpty { + lines.append("") + lines.append("Enums:") + for enumInfo in enums { + lines.append(" - \(enumInfo.name)") + } + } + if !globalVars.isEmpty { + lines.append("") + lines.append("Global vars:") + for variable in globalVars { + lines.append(" - \(formatPropertyLine(variable.name, typeName: variable.typeName))") + } + } + if !exports.isEmpty { + lines.append("") + lines.append("Exports:") + for export in exports { + lines.append(" - \(export)") + } + } + if !macros.isEmpty { + lines.append("") + lines.append("Macros:") + for macro in macros { + lines.append(" - \(macro)") + } + } + lines.append("---") + + apiDescription = "\n" + lines.joined(separator: "\n") + "\n" + definedTypeNames = Set(classes.map(\.name)) + .union(interfaces.map(\.name)) + .union(aliases.map(\.name)) + .union(enums.map(\.name)) + pathAndImportsDescription = Self.pathAndImportsBlock(displayPath: filePath, imports: imports) + apiTokenCount = TokenCalculationService.estimateTokens(for: apiDescription) + } + + package func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(filePath, forKey: .filePath) + try container.encode(imports, forKey: .imports) + try container.encode(exports, forKey: .exports) + try container.encode(classes, forKey: .classes) + try container.encode(interfaces, forKey: .interfaces) + try container.encode(aliases, forKey: .aliases) + try container.encode(literalUnions, forKey: .literalUnions) + try container.encode(functions, forKey: .functions) + try container.encode(enums, forKey: .enums) + try container.encode(globalVars, forKey: .globalVars) + try container.encode(macros, forKey: .macros) + try container.encode(referencedTypes, forKey: .referencedTypes) + } + + package init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + try self.init( + filePath: container.decode(String.self, forKey: .filePath), + imports: container.decode([String].self, forKey: .imports), + exports: container.decodeIfPresent([String].self, forKey: .exports) ?? [], + classes: container.decode([ClassInfo].self, forKey: .classes), + interfaces: container.decodeIfPresent([InterfaceInfo].self, forKey: .interfaces) ?? [], + aliases: container.decodeIfPresent([TypeAliasInfo].self, forKey: .aliases) ?? [], + literalUnions: container.decodeIfPresent([String].self, forKey: .literalUnions) ?? [], + functions: container.decode([FunctionInfo].self, forKey: .functions), + enums: container.decode([EnumInfo].self, forKey: .enums), + globalVars: container.decode([VariableInfo].self, forKey: .globalVars), + macros: container.decode([String].self, forKey: .macros), + referencedTypes: container.decode([String].self, forKey: .referencedTypes) + ) + } + + package func getFullAPIDescription() -> String { + getFullAPIDescription(displayPath: filePath) + } + + package func getFullAPIDescription(displayPath: String) -> String { + let pathAndImports = Self.pathAndImportsBlock(displayPath: displayPath, imports: imports) + return [pathAndImports, apiDescription].joined() + } + + package func estimatedFullAPIDescriptionTokens(displayPath: String) -> Int { + TokenCalculationService.estimateTokens(for: Self.pathAndImportsBlock(displayPath: displayPath, imports: imports)) + apiTokenCount + } + + package func printAPI() { + print(apiDescription) + } + + private static func pathAndImportsBlock(displayPath: String, imports: [String]) -> String { + (["File: \(displayPath)", "Imports:"] + imports.map { " - \($0)" }).joined(separator: "\n") + } +} diff --git a/Sources/RepoPrompt/Features/CodeMap/ReferencedTypesAccumulator.swift b/Sources/RepoPromptCore/CodeMap/ReferencedTypesAccumulator.swift similarity index 91% rename from Sources/RepoPrompt/Features/CodeMap/ReferencedTypesAccumulator.swift rename to Sources/RepoPromptCore/CodeMap/ReferencedTypesAccumulator.swift index e3cc754d0..47adcda89 100644 --- a/Sources/RepoPrompt/Features/CodeMap/ReferencedTypesAccumulator.swift +++ b/Sources/RepoPromptCore/CodeMap/ReferencedTypesAccumulator.swift @@ -7,21 +7,21 @@ import Foundation -struct ReferencedTypesAccumulator { - let language: LanguageType +package struct ReferencedTypesAccumulator { + package let language: LanguageType private(set) var types: Set = [] private var cache: [TypeCleaner.TypeCleanerCacheKey: [String]] = [:] private var rawInsertCount = 0 private let stats: CodeMapPerfStats? private let perfOptions: CodeMapPerfOptions - init(language: LanguageType, stats: CodeMapPerfStats? = nil, perfOptions: CodeMapPerfOptions = .disabled) { + package init(language: LanguageType, stats: CodeMapPerfStats? = nil, perfOptions: CodeMapPerfOptions = .disabled) { self.language = language self.stats = stats self.perfOptions = perfOptions } - mutating func insert(rawType: String?) { + package mutating func insert(rawType: String?) { guard let raw = rawType?.trimmingCharacters(in: .whitespacesAndNewlines), !raw.isEmpty else { return } let activePerfOptions = CodeMapPerfRuntime.activeOptions(perfOptions) let activeStats = CodeMapPerfRuntime.activeStats(stats) @@ -130,17 +130,17 @@ struct ReferencedTypesAccumulator { || (raw.hasPrefix("'") && raw.hasSuffix("'")) } - mutating func insertMany(rawTypes: [String]) { + package mutating func insertMany(rawTypes: [String]) { for rawType in rawTypes { insert(rawType: rawType) } } - func finalizeSorted() -> [String] { + package func finalizeSorted() -> [String] { Array(types).sorted() } - var rawInsertions: Int { + package var rawInsertions: Int { rawInsertCount } } diff --git a/Sources/RepoPrompt/Features/CodeMap/SwiftSignatureParser.swift b/Sources/RepoPromptCore/CodeMap/SwiftSignatureParser.swift similarity index 95% rename from Sources/RepoPrompt/Features/CodeMap/SwiftSignatureParser.swift rename to Sources/RepoPromptCore/CodeMap/SwiftSignatureParser.swift index 854c89364..adf062262 100644 --- a/Sources/RepoPrompt/Features/CodeMap/SwiftSignatureParser.swift +++ b/Sources/RepoPromptCore/CodeMap/SwiftSignatureParser.swift @@ -7,8 +7,8 @@ import Foundation -enum SwiftSignatureParser { - static func extractReturnType(from signature: String) -> String? { +package enum SwiftSignatureParser { + package static func extractReturnType(from signature: String) -> String? { let trimmed = signature.trimmingCharacters(in: .whitespacesAndNewlines) guard let arrowRange = TopLevelScanner.firstTopLevelRange(of: "->", in: trimmed, track: .all) else { return nil diff --git a/Sources/RepoPrompt/Features/CodeMap/TopLevelScanner.swift b/Sources/RepoPromptCore/CodeMap/TopLevelScanner.swift similarity index 88% rename from Sources/RepoPrompt/Features/CodeMap/TopLevelScanner.swift rename to Sources/RepoPromptCore/CodeMap/TopLevelScanner.swift index c3033da22..a2f53ff4d 100644 --- a/Sources/RepoPrompt/Features/CodeMap/TopLevelScanner.swift +++ b/Sources/RepoPromptCore/CodeMap/TopLevelScanner.swift @@ -7,17 +7,21 @@ import Foundation -enum TopLevelScanner { - struct TrackDelimiters: OptionSet { - let rawValue: Int - static let angle = TrackDelimiters(rawValue: 1 << 0) // < > - static let paren = TrackDelimiters(rawValue: 1 << 1) // ( ) - static let brace = TrackDelimiters(rawValue: 1 << 2) // { } - static let square = TrackDelimiters(rawValue: 1 << 3) // [ ] - static let all: TrackDelimiters = [.angle, .paren, .brace, .square] +package enum TopLevelScanner { + package struct TrackDelimiters: OptionSet { + package let rawValue: Int + package static let angle = TrackDelimiters(rawValue: 1 << 0) // < > + package static let paren = TrackDelimiters(rawValue: 1 << 1) // ( ) + package static let brace = TrackDelimiters(rawValue: 1 << 2) // { } + package static let square = TrackDelimiters(rawValue: 1 << 3) // [ ] + package static let all: TrackDelimiters = [.angle, .paren, .brace, .square] + + package init(rawValue: Int) { + self.rawValue = rawValue + } } - static func splitTopLevel( + package static func splitTopLevel( _ input: String, separator: Character, track: TrackDelimiters = .all @@ -56,7 +60,7 @@ enum TopLevelScanner { return results } - static func splitTopLevel( + package static func splitTopLevel( _ input: String, operators: Set, track: TrackDelimiters = .all @@ -94,7 +98,7 @@ enum TopLevelScanner { return results } - static func firstTopLevelIndex( + package static func firstTopLevelIndex( of char: Character, in input: String, track: TrackDelimiters = .all @@ -124,7 +128,7 @@ enum TopLevelScanner { return nil } - static func firstTopLevelRange( + package static func firstTopLevelRange( of needle: String, in input: String, track: TrackDelimiters = .all @@ -171,7 +175,7 @@ enum TopLevelScanner { return nil } - static func containsTopLevel( + package static func containsTopLevel( _ needle: String, in input: String, track: TrackDelimiters = .all diff --git a/Sources/RepoPrompt/Features/CodeMap/TypeCleaner.swift b/Sources/RepoPromptCore/CodeMap/TypeCleaner.swift similarity index 98% rename from Sources/RepoPrompt/Features/CodeMap/TypeCleaner.swift rename to Sources/RepoPromptCore/CodeMap/TypeCleaner.swift index 95b250f10..97cc75785 100644 --- a/Sources/RepoPrompt/Features/CodeMap/TypeCleaner.swift +++ b/Sources/RepoPromptCore/CodeMap/TypeCleaner.swift @@ -10,8 +10,8 @@ import Foundation -enum TypeCleaner { - struct TypeCleanerCacheKey: Hashable { +package enum TypeCleaner { + package struct TypeCleanerCacheKey: Hashable { let language: LanguageType let raw: String } @@ -100,7 +100,7 @@ enum TypeCleaner { /// Returns one or more atomic type names for a raw type string. /// TS/TSX types are routed through TS-specific logic. - static func extractBaseTypes(from rawType: String, language: LanguageType) -> [String] { + package static func extractBaseTypes(from rawType: String, language: LanguageType) -> [String] { switch language { case .ts, .tsx: extractBaseTypesTS(rawType, language: language) @@ -110,7 +110,7 @@ enum TypeCleaner { } /// Cached variant to avoid repeated parsing within a file. - static func extractBaseTypes( + package static func extractBaseTypes( from rawType: String, language: LanguageType, cache: inout [TypeCleanerCacheKey: [String]], @@ -200,7 +200,7 @@ enum TypeCleaner { // MARK: - Non‑TypeScript/TSX Logic - static func extractBaseTypesNonTS(_ rawType: String, language: LanguageType) -> [String] { + package static func extractBaseTypesNonTS(_ rawType: String, language: LanguageType) -> [String] { var type = rawType.trimmingCharacters(in: .whitespacesAndNewlines) type = removeComments(from: type, language: language) if language == .swift { @@ -448,7 +448,7 @@ enum TypeCleaner { // MARK: - TypeScript/TSX - static func extractBaseTypesTS(_ rawType: String, language: LanguageType) -> [String] { + package static func extractBaseTypesTS(_ rawType: String, language: LanguageType) -> [String] { var trimmed = rawType.trimmingCharacters(in: .whitespacesAndNewlines) trimmed = removeComments(from: trimmed, language: language) trimmed = removeMethodCalls(trimmed) @@ -969,7 +969,7 @@ enum TypeCleaner { regex.replacingMatches(in: text, with: replacement) } - static func filterOutPrimitiveAndSpecialTypes(_ extracted: [String], language: LanguageType) -> [String] { + package static func filterOutPrimitiveAndSpecialTypes(_ extracted: [String], language: LanguageType) -> [String] { var results: [String] = [] for raw in extracted { @@ -1135,7 +1135,7 @@ enum TypeCleaner { } } - static func isGenericPlaceholderTypeName(_ typeName: String, language: LanguageType) -> Bool { + package static func isGenericPlaceholderTypeName(_ typeName: String, language: LanguageType) -> Bool { isGenericPlaceholder(typeName, language: language) } @@ -1372,11 +1372,11 @@ enum TypeCleaner { return TypeCleanerSets.swiftSpecialTypes.contains(base) } - static func isSwiftSpecialTypeName(_ typeName: String) -> Bool { + package static func isSwiftSpecialTypeName(_ typeName: String) -> Bool { isSwiftSpecialType(typeName) } - static func isPrimitiveType(_ typeName: String, language: LanguageType) -> Bool { + package static func isPrimitiveType(_ typeName: String, language: LanguageType) -> Bool { let lower = typeName.lowercased() switch language { case .swift: @@ -1419,7 +1419,7 @@ enum TypeCleaner { } } - static func isContainerType(_ typeName: String, language: LanguageType) -> Bool { + package static func isContainerType(_ typeName: String, language: LanguageType) -> Bool { let lower = typeName.lowercased() switch language { diff --git a/Sources/RepoPromptCore/FileSystem/FileContentSnapshot.swift b/Sources/RepoPromptCore/FileSystem/FileContentSnapshot.swift new file mode 100644 index 000000000..9892205f9 --- /dev/null +++ b/Sources/RepoPromptCore/FileSystem/FileContentSnapshot.swift @@ -0,0 +1,58 @@ +import Foundation + +package struct FileContentFingerprint: Hashable { + package let deviceID: UInt64 + package let fileNumber: UInt64 + package let byteSize: Int64 + package let modificationSeconds: Int64 + package let modificationNanoseconds: Int64 + package let statusChangeSeconds: Int64 + package let statusChangeNanoseconds: Int64 + + package init( + deviceID: UInt64, + fileNumber: UInt64, + byteSize: Int64, + modificationSeconds: Int64, + modificationNanoseconds: Int64, + statusChangeSeconds: Int64, + statusChangeNanoseconds: Int64 + ) { + self.deviceID = deviceID + self.fileNumber = fileNumber + self.byteSize = byteSize + self.modificationSeconds = modificationSeconds + self.modificationNanoseconds = modificationNanoseconds + self.statusChangeSeconds = statusChangeSeconds + self.statusChangeNanoseconds = statusChangeNanoseconds + } + + var modificationDate: Date { + Date( + timeIntervalSince1970: TimeInterval(modificationSeconds) + + TimeInterval(modificationNanoseconds) / 1_000_000_000 + ) + } +} + +package protocol FileContentSnapshotReading: Sendable { + func fingerprint(atPath path: String) throws -> FileContentFingerprint + func fingerprint(fileDescriptor: Int32) throws -> FileContentFingerprint + func openReadOnlyFileHandle(atPath path: String) throws -> FileHandle +} + +struct ValidatedFileContentSnapshot { + let content: String? + let detectedEncodingRawValue: UInt? + let modificationDate: Date + let fingerprint: FileContentFingerprint + + var estimatedDecodedCost: Int { + guard let content else { return 0 } + return content.utf8.count + content.utf16.count * MemoryLayout.stride + } +} + +enum FileContentValidationError: Error { + case fingerprintChanged +} diff --git a/Sources/RepoPromptCore/FileSystem/FileSystemDeltaPublicationHub.swift b/Sources/RepoPromptCore/FileSystem/FileSystemDeltaPublicationHub.swift new file mode 100644 index 000000000..9effacfbb --- /dev/null +++ b/Sources/RepoPromptCore/FileSystem/FileSystemDeltaPublicationHub.swift @@ -0,0 +1,71 @@ +import Foundation + +package final class FileSystemDeltaPublicationSubscription: @unchecked Sendable { + private let cancellation: @Sendable () -> Void + private let lock = NSLock() + private var isCancelled = false + + package init(cancellation: @escaping @Sendable () -> Void) { + self.cancellation = cancellation + } + + package func cancel() { + lock.lock() + guard !isCancelled else { + lock.unlock() + return + } + isCancelled = true + lock.unlock() + cancellation() + } + + deinit { + cancel() + } +} + +/// Single-consumer callback publication seam. Delivery is synchronous: `publish` +/// returns only after the subscriber has accepted or rejected the publication. +package final class FileSystemDeltaPublicationHub: @unchecked Sendable { + package typealias Handler = @Sendable (FileSystemDeltaPublication) -> Bool + + private let lock = NSLock() + private var generation: UInt64 = 0 + private var handler: Handler? + + package func subscribe(_ handler: @escaping Handler) -> FileSystemDeltaPublicationSubscription { + lock.lock() + generation &+= 1 + let subscriptionGeneration = generation + self.handler = handler + lock.unlock() + return FileSystemDeltaPublicationSubscription { [weak self] in + self?.cancel(generation: subscriptionGeneration) + } + } + + package func close() { + lock.lock() + generation &+= 1 + handler = nil + lock.unlock() + } + + @discardableResult + package func publish(_ publication: FileSystemDeltaPublication) -> Bool { + lock.lock() + let current = handler + lock.unlock() + return current?(publication) ?? false + } + + private func cancel(generation expectedGeneration: UInt64) { + lock.lock() + if generation == expectedGeneration { + generation &+= 1 + handler = nil + } + lock.unlock() + } +} diff --git a/Sources/RepoPromptCore/FileSystem/FileSystemItems.swift b/Sources/RepoPromptCore/FileSystem/FileSystemItems.swift new file mode 100644 index 000000000..417ae0eb0 --- /dev/null +++ b/Sources/RepoPromptCore/FileSystem/FileSystemItems.swift @@ -0,0 +1,50 @@ +import Foundation + +package protocol FileSystemItem: Identifiable, Equatable, Sendable { + var id: UUID { get } + var name: String { get } + var path: String { get } + var modificationDate: Date { get } +} + +package struct Folder: FileSystemItem { + package let id: UUID + package let name: String + package let path: String + package let modificationDate: Date + + package init(id: UUID = UUID(), name: String, path: String, modificationDate: Date) { + self.id = id + self.name = name + self.path = path + self.modificationDate = modificationDate + } + + package static func == (lhs: Folder, rhs: Folder) -> Bool { + lhs.path == rhs.path + } +} + +package extension FileSystemItem { + func relativePath(rootPath: String) -> String { + RelativePath.from(absolutePath: path, rootPath: rootPath) + } +} + +package struct File: FileSystemItem { + package let id: UUID + package let name: String + package let path: String + package let modificationDate: Date + + package init(id: UUID = UUID(), name: String, path: String, modificationDate: Date) { + self.id = id + self.name = name + self.path = path + self.modificationDate = modificationDate + } + + package static func == (lhs: File, rhs: File) -> Bool { + lhs.path == rhs.path + } +} diff --git a/Sources/RepoPrompt/Infrastructure/FileSystem/FileSystemProviding.swift b/Sources/RepoPromptCore/FileSystem/FileSystemProviding.swift similarity index 100% rename from Sources/RepoPrompt/Infrastructure/FileSystem/FileSystemProviding.swift rename to Sources/RepoPromptCore/FileSystem/FileSystemProviding.swift diff --git a/Sources/RepoPromptCore/FileSystem/FileSystemRuntimeDiagnostics.swift b/Sources/RepoPromptCore/FileSystem/FileSystemRuntimeDiagnostics.swift new file mode 100644 index 000000000..74475f68b --- /dev/null +++ b/Sources/RepoPromptCore/FileSystem/FileSystemRuntimeDiagnostics.swift @@ -0,0 +1,9 @@ +import Foundation + +package struct FileSystemDiagnosticContext: Equatable { + package let correlationID: UUID + + package init(correlationID: UUID = UUID()) { + self.correlationID = correlationID + } +} diff --git a/Sources/RepoPrompt/Infrastructure/FileSystem/FileSystemService+ContentLoading.swift b/Sources/RepoPromptCore/FileSystem/FileSystemService+ContentLoading.swift similarity index 83% rename from Sources/RepoPrompt/Infrastructure/FileSystem/FileSystemService+ContentLoading.swift rename to Sources/RepoPromptCore/FileSystem/FileSystemService+ContentLoading.swift index 328a095ed..e49a3773f 100644 --- a/Sources/RepoPrompt/Infrastructure/FileSystem/FileSystemService+ContentLoading.swift +++ b/Sources/RepoPromptCore/FileSystem/FileSystemService+ContentLoading.swift @@ -47,6 +47,7 @@ private struct ContentReadRequest { let mode: ContentReadMode let workloadClass: ContentReadWorkloadClass let schedulerOwnerID: UUID + let fileContentSnapshotReader: any FileContentSnapshotReading #if DEBUG let chunkReadHandler: (@Sendable (String) async -> Void)? #endif @@ -76,6 +77,7 @@ private struct ValidatedContentFile { let fileSize: Int64 let modificationDate: Date let fingerprint: FileContentFingerprint + let fileContentSnapshotReader: any FileContentSnapshotReading } private enum BoundedDataReadResult { @@ -83,24 +85,37 @@ private enum BoundedDataReadResult { case tooLarge(observedByteCount: Int64) } -actor ContentReadAsyncLimiter { +package actor ContentReadAsyncLimiter { #if DEBUG - struct Snapshot: Equatable { - let capacity: Int - let maxQueuedWaiterCount: Int - let activePermitCount: Int - let queuedWaiterCount: Int - let ownerLaneCount: Int - let cancellationCount: Int - let grantCount: Int - let overloadCount: Int - let interactiveGrantCount: Int - let normalGrantCount: Int - let bulkGrantCount: Int - - var isIdle: Bool { + package struct Snapshot: Equatable { + package let capacity: Int + package let maxQueuedWaiterCount: Int + package let activePermitCount: Int + package let queuedWaiterCount: Int + package let ownerLaneCount: Int + package let cancellationCount: Int + package let grantCount: Int + package let overloadCount: Int + package let interactiveGrantCount: Int + package let normalGrantCount: Int + package let bulkGrantCount: Int + + package var isIdle: Bool { activePermitCount == 0 && queuedWaiterCount == 0 && ownerLaneCount == 0 } + + /// Compatibility aliases for existing idle-state assertions. + var queueDepth: Int { + queuedWaiterCount + } + + var waiterCount: Int { + queuedWaiterCount + } + + var pendingWaiterCount: Int { + queuedWaiterCount + } } #endif @@ -122,7 +137,8 @@ actor ContentReadAsyncLimiter { let workloadClass: ContentReadWorkloadClass let ownerID: UUID let priorityClass: PriorityClass - let lifecycleCorrelation: EditFlowPerf.LifecycleCorrelation? + let lifecycleCorrelation: WorkspaceRuntimePerf.LifecycleCorrelation? + let diagnosticsSink: (any WorkspaceRuntimeDiagnosticsSink)? let enqueueOrdinal: UInt64 let enqueuedAtUptimeNanoseconds: UInt64 } @@ -170,10 +186,11 @@ actor ContentReadAsyncLimiter { ownerID: UUID, _ body: @Sendable () async throws -> T ) async throws -> T { - let lifecycleCorrelation = EditFlowPerf.currentLifecycleCorrelation - let permitWaitState = EditFlowPerf.begin( - EditFlowPerf.Stage.FileSystem.contentReadWorkerPermitWait, - EditFlowPerf.Dimensions( + let lifecycleCorrelation = WorkspaceRuntimePerf.currentLifecycleCorrelation + let diagnosticsSink = WorkspaceRuntimePerf.currentSink + let permitWaitState = WorkspaceRuntimePerf.begin( + WorkspaceRuntimePerf.Stage.FileSystem.contentReadWorkerPermitWait, + WorkspaceRuntimePerf.Dimensions( workloadClass: workloadClass.rawValue, queueDepth: waiterStates.count, waiterCount: waiterStates.count @@ -184,12 +201,13 @@ actor ContentReadAsyncLimiter { acquisition = try await acquire( workloadClass: workloadClass, ownerID: ownerID, - lifecycleCorrelation: lifecycleCorrelation + lifecycleCorrelation: lifecycleCorrelation, + diagnosticsSink: diagnosticsSink ) - EditFlowPerf.end( - EditFlowPerf.Stage.FileSystem.contentReadWorkerPermitWait, + WorkspaceRuntimePerf.end( + WorkspaceRuntimePerf.Stage.FileSystem.contentReadWorkerPermitWait, permitWaitState, - EditFlowPerf.Dimensions( + WorkspaceRuntimePerf.Dimensions( outcome: acquisition.waited ? "acquiredAfterWait" : "immediate", workloadClass: workloadClass.rawValue, queueDepth: acquisition.queueDepth, @@ -197,10 +215,10 @@ actor ContentReadAsyncLimiter { ) ) } catch { - EditFlowPerf.end( - EditFlowPerf.Stage.FileSystem.contentReadWorkerPermitWait, + WorkspaceRuntimePerf.end( + WorkspaceRuntimePerf.Stage.FileSystem.contentReadWorkerPermitWait, permitWaitState, - EditFlowPerf.Dimensions( + WorkspaceRuntimePerf.Dimensions( outcome: error is CancellationError ? "cancelled" : "error", workloadClass: workloadClass.rawValue, queueDepth: waiterStates.count, @@ -217,7 +235,8 @@ actor ContentReadAsyncLimiter { private func acquire( workloadClass: ContentReadWorkloadClass, ownerID: UUID, - lifecycleCorrelation: EditFlowPerf.LifecycleCorrelation? + lifecycleCorrelation: WorkspaceRuntimePerf.LifecycleCorrelation?, + diagnosticsSink: (any WorkspaceRuntimeDiagnosticsSink)? ) async throws -> PermitAcquisition { try Task.checkCancellation() scheduleAvailablePermits() @@ -230,10 +249,10 @@ actor ContentReadAsyncLimiter { } guard waiterStates.count < maxQueuedWaiterCount else { overloadCount &+= 1 - EditFlowPerf.lifecycleEvent( - EditFlowPerf.Lifecycle.FileSystem.contentReadWorkerOverloaded, + WorkspaceRuntimePerf.lifecycleEvent( + WorkspaceRuntimePerf.Lifecycle.FileSystem.contentReadWorkerOverloaded, correlation: lifecycleCorrelation, - EditFlowPerf.Dimensions( + WorkspaceRuntimePerf.Dimensions( workloadClass: workloadClass.rawValue, queueDepth: waiterStates.count, waiterCount: waiterStates.count @@ -250,7 +269,8 @@ actor ContentReadAsyncLimiter { continuation: continuation, workloadClass: workloadClass, ownerID: ownerID, - lifecycleCorrelation: lifecycleCorrelation + lifecycleCorrelation: lifecycleCorrelation, + diagnosticsSink: diagnosticsSink ) } } onCancel: { @@ -263,7 +283,8 @@ actor ContentReadAsyncLimiter { continuation: CheckedContinuation, workloadClass: ContentReadWorkloadClass, ownerID: UUID, - lifecycleCorrelation: EditFlowPerf.LifecycleCorrelation? + lifecycleCorrelation: WorkspaceRuntimePerf.LifecycleCorrelation?, + diagnosticsSink: (any WorkspaceRuntimeDiagnosticsSink)? ) { guard !Task.isCancelled else { continuation.resume(throwing: CancellationError()) @@ -271,10 +292,10 @@ actor ContentReadAsyncLimiter { } guard waiterStates.count < maxQueuedWaiterCount else { overloadCount &+= 1 - EditFlowPerf.lifecycleEvent( - EditFlowPerf.Lifecycle.FileSystem.contentReadWorkerOverloaded, + WorkspaceRuntimePerf.lifecycleEvent( + WorkspaceRuntimePerf.Lifecycle.FileSystem.contentReadWorkerOverloaded, correlation: lifecycleCorrelation, - EditFlowPerf.Dimensions( + WorkspaceRuntimePerf.Dimensions( workloadClass: workloadClass.rawValue, queueDepth: waiterStates.count, waiterCount: waiterStates.count @@ -292,13 +313,14 @@ actor ContentReadAsyncLimiter { ownerID: ownerID, priorityClass: Self.priorityClass(for: workloadClass), lifecycleCorrelation: lifecycleCorrelation, + diagnosticsSink: diagnosticsSink, enqueueOrdinal: nextEnqueueOrdinal, enqueuedAtUptimeNanoseconds: DispatchTime.now().uptimeNanoseconds ) - EditFlowPerf.lifecycleEvent( - EditFlowPerf.Lifecycle.FileSystem.contentReadWorkerPermitWaitBegan, + WorkspaceRuntimePerf.lifecycleEvent( + WorkspaceRuntimePerf.Lifecycle.FileSystem.contentReadWorkerPermitWaitBegan, correlation: lifecycleCorrelation, - EditFlowPerf.Dimensions( + WorkspaceRuntimePerf.Dimensions( workloadClass: workloadClass.rawValue, queueDepth: waiterStates.count, waiterCount: waiterStates.count @@ -311,10 +333,10 @@ actor ContentReadAsyncLimiter { guard let state = waiterStates.removeValue(forKey: id) else { return } cancellationCount &+= 1 cleanupOwnerIfIdle(state.ownerID) - EditFlowPerf.lifecycleEvent( - EditFlowPerf.Lifecycle.FileSystem.contentReadWorkerPermitCancelled, + WorkspaceRuntimePerf.lifecycleEvent( + WorkspaceRuntimePerf.Lifecycle.FileSystem.contentReadWorkerPermitCancelled, correlation: state.lifecycleCorrelation, - EditFlowPerf.Dimensions( + WorkspaceRuntimePerf.Dimensions( workloadClass: state.workloadClass.rawValue, queueDepth: waiterStates.count, waiterCount: waiterStates.count @@ -348,10 +370,11 @@ actor ContentReadAsyncLimiter { priorityClass: state.priorityClass, waited: true ) - EditFlowPerf.lifecycleEvent( - EditFlowPerf.Lifecycle.FileSystem.contentReadWorkerPermitAcquired, + WorkspaceRuntimePerf.lifecycleEvent( + WorkspaceRuntimePerf.Lifecycle.FileSystem.contentReadWorkerPermitAcquired, correlation: state.lifecycleCorrelation, - EditFlowPerf.Dimensions( + sink: state.diagnosticsSink, + WorkspaceRuntimePerf.Dimensions( workloadClass: state.workloadClass.rawValue, queueDepth: waiterStates.count, waiterCount: waiterStates.count @@ -497,11 +520,11 @@ extension FileSystemService { ) #if DEBUG - nonisolated static var contentReadWorkerLimitForTesting: Int { + package nonisolated static var contentReadWorkerLimitForTesting: Int { contentReadWorkerLimit } - nonisolated static func contentReadWorkerLimiterSnapshotForTesting() async -> ContentReadAsyncLimiter.Snapshot { + package nonisolated static func contentReadWorkerLimiterSnapshotForTesting() async -> ContentReadAsyncLimiter.Snapshot { await contentReadWorkerLimiter.snapshotForTesting() } #endif @@ -535,7 +558,7 @@ extension FileSystemService { ) let result = try await performMeasuredContentReadOffActor( request, - lifecycleCorrelation: EditFlowPerf.currentLifecycleCorrelation, + lifecycleCorrelation: WorkspaceRuntimePerf.currentLifecycleCorrelation, expectedFingerprint: expectedFingerprint, requirePostReadValidation: true ) @@ -554,35 +577,35 @@ extension FileSystemService { ) } - func loadContent( + package func loadContent( ofRelativePath relativePath: String, workloadClass: ContentReadWorkloadClass = .unspecified ) async throws -> String? { - let lifecycleCorrelation = EditFlowPerf.currentLifecycleCorrelation - EditFlowPerf.lifecycleEvent( - EditFlowPerf.Lifecycle.FileSystem.contentLoadEntered, + let lifecycleCorrelation = WorkspaceRuntimePerf.currentLifecycleCorrelation + WorkspaceRuntimePerf.lifecycleEvent( + WorkspaceRuntimePerf.Lifecycle.FileSystem.contentLoadEntered, correlation: lifecycleCorrelation, - EditFlowPerf.Dimensions(workloadClass: workloadClass.rawValue, rootToken: diagnosticRootToken.uuidString) + WorkspaceRuntimePerf.Dimensions(workloadClass: workloadClass.rawValue, rootToken: diagnosticRootToken.uuidString) ) - let contentLoadState = EditFlowPerf.begin( - EditFlowPerf.Stage.FileSystem.contentLoadTotal, - EditFlowPerf.Dimensions(workloadClass: workloadClass.rawValue, rootToken: diagnosticRootToken.uuidString) + let contentLoadState = WorkspaceRuntimePerf.begin( + WorkspaceRuntimePerf.Stage.FileSystem.contentLoadTotal, + WorkspaceRuntimePerf.Dimensions(workloadClass: workloadClass.rawValue, rootToken: diagnosticRootToken.uuidString) ) var contentLoadOutcome = "error" defer { - EditFlowPerf.end( - EditFlowPerf.Stage.FileSystem.contentLoadTotal, + WorkspaceRuntimePerf.end( + WorkspaceRuntimePerf.Stage.FileSystem.contentLoadTotal, contentLoadState, - EditFlowPerf.Dimensions( + WorkspaceRuntimePerf.Dimensions( outcome: contentLoadOutcome, workloadClass: workloadClass.rawValue, rootToken: diagnosticRootToken.uuidString ) ) - EditFlowPerf.lifecycleEvent( - EditFlowPerf.Lifecycle.FileSystem.contentLoadReturned, + WorkspaceRuntimePerf.lifecycleEvent( + WorkspaceRuntimePerf.Lifecycle.FileSystem.contentLoadReturned, correlation: lifecycleCorrelation, - EditFlowPerf.Dimensions( + WorkspaceRuntimePerf.Dimensions( outcome: contentLoadOutcome, workloadClass: workloadClass.rawValue, rootToken: diagnosticRootToken.uuidString @@ -594,9 +617,9 @@ extension FileSystemService { return nil } - let preparationState = EditFlowPerf.begin( - EditFlowPerf.Stage.FileSystem.contentReadRequestPreparation, - EditFlowPerf.Dimensions(workloadClass: workloadClass.rawValue, rootToken: diagnosticRootToken.uuidString) + let preparationState = WorkspaceRuntimePerf.begin( + WorkspaceRuntimePerf.Stage.FileSystem.contentReadRequestPreparation, + WorkspaceRuntimePerf.Dimensions(workloadClass: workloadClass.rawValue, rootToken: diagnosticRootToken.uuidString) ) let request: ContentReadRequest do { @@ -607,23 +630,23 @@ extension FileSystemService { mode: .automatic, workloadClass: workloadClass ) - EditFlowPerf.end( - EditFlowPerf.Stage.FileSystem.contentReadRequestPreparation, + WorkspaceRuntimePerf.end( + WorkspaceRuntimePerf.Stage.FileSystem.contentReadRequestPreparation, preparationState, - EditFlowPerf.Dimensions(outcome: "prepared", workloadClass: workloadClass.rawValue) + WorkspaceRuntimePerf.Dimensions(outcome: "prepared", workloadClass: workloadClass.rawValue) ) } catch { - EditFlowPerf.end( - EditFlowPerf.Stage.FileSystem.contentReadRequestPreparation, + WorkspaceRuntimePerf.end( + WorkspaceRuntimePerf.Stage.FileSystem.contentReadRequestPreparation, preparationState, - EditFlowPerf.Dimensions(outcome: "error", workloadClass: workloadClass.rawValue) + WorkspaceRuntimePerf.Dimensions(outcome: "error", workloadClass: workloadClass.rawValue) ) throw error } - EditFlowPerf.lifecycleEvent( - EditFlowPerf.Lifecycle.FileSystem.contentReadRequestPrepared, + WorkspaceRuntimePerf.lifecycleEvent( + WorkspaceRuntimePerf.Lifecycle.FileSystem.contentReadRequestPrepared, correlation: lifecycleCorrelation, - EditFlowPerf.Dimensions(workloadClass: workloadClass.rawValue, rootToken: diagnosticRootToken.uuidString) + WorkspaceRuntimePerf.Dimensions(workloadClass: workloadClass.rawValue, rootToken: diagnosticRootToken.uuidString) ) #if DEBUG if shouldUseSerialContentReadFallback { @@ -656,7 +679,7 @@ extension FileSystemService { } /// For backward compatibility - delegates to the new implementation - func loadContent( + package func loadContent( of url: URL, workloadClass: ContentReadWorkloadClass = .unspecified ) async throws -> String? { @@ -664,7 +687,7 @@ extension FileSystemService { return try await loadContent(ofRelativePath: relativePath, workloadClass: workloadClass) } - func loadContentWithDate( + package func loadContentWithDate( ofRelativePath relativePath: String, workloadClass: ContentReadWorkloadClass = .unspecified ) async throws -> (content: String?, modificationDate: Date) { @@ -679,37 +702,37 @@ extension FileSystemService { /// 1. BOM (cheap, deterministic) /// 2. Cuchardet’s streaming detector /// 3. Default to UTF-8 ← no further fall-backs - func loadEntireFileContentOptimized( + package func loadEntireFileContentOptimized( ofRelativePath relativePath: String, chunkSize: Int = 1_048_576, // 1 MB fileSizeLimit: Int64 = 10_000_000, // 10 MB workloadClass: ContentReadWorkloadClass = .unspecified ) async throws -> String? { - let lifecycleCorrelation = EditFlowPerf.currentLifecycleCorrelation - EditFlowPerf.lifecycleEvent( - EditFlowPerf.Lifecycle.FileSystem.contentLoadEntered, + let lifecycleCorrelation = WorkspaceRuntimePerf.currentLifecycleCorrelation + WorkspaceRuntimePerf.lifecycleEvent( + WorkspaceRuntimePerf.Lifecycle.FileSystem.contentLoadEntered, correlation: lifecycleCorrelation, - EditFlowPerf.Dimensions(workloadClass: workloadClass.rawValue, rootToken: diagnosticRootToken.uuidString) + WorkspaceRuntimePerf.Dimensions(workloadClass: workloadClass.rawValue, rootToken: diagnosticRootToken.uuidString) ) - let contentLoadState = EditFlowPerf.begin( - EditFlowPerf.Stage.FileSystem.contentLoadTotal, - EditFlowPerf.Dimensions(workloadClass: workloadClass.rawValue, rootToken: diagnosticRootToken.uuidString) + let contentLoadState = WorkspaceRuntimePerf.begin( + WorkspaceRuntimePerf.Stage.FileSystem.contentLoadTotal, + WorkspaceRuntimePerf.Dimensions(workloadClass: workloadClass.rawValue, rootToken: diagnosticRootToken.uuidString) ) var contentLoadOutcome = "error" defer { - EditFlowPerf.end( - EditFlowPerf.Stage.FileSystem.contentLoadTotal, + WorkspaceRuntimePerf.end( + WorkspaceRuntimePerf.Stage.FileSystem.contentLoadTotal, contentLoadState, - EditFlowPerf.Dimensions( + WorkspaceRuntimePerf.Dimensions( outcome: contentLoadOutcome, workloadClass: workloadClass.rawValue, rootToken: diagnosticRootToken.uuidString ) ) - EditFlowPerf.lifecycleEvent( - EditFlowPerf.Lifecycle.FileSystem.contentLoadReturned, + WorkspaceRuntimePerf.lifecycleEvent( + WorkspaceRuntimePerf.Lifecycle.FileSystem.contentLoadReturned, correlation: lifecycleCorrelation, - EditFlowPerf.Dimensions( + WorkspaceRuntimePerf.Dimensions( outcome: contentLoadOutcome, workloadClass: workloadClass.rawValue, rootToken: diagnosticRootToken.uuidString @@ -721,9 +744,9 @@ extension FileSystemService { return nil } - let preparationState = EditFlowPerf.begin( - EditFlowPerf.Stage.FileSystem.contentReadRequestPreparation, - EditFlowPerf.Dimensions(workloadClass: workloadClass.rawValue, rootToken: diagnosticRootToken.uuidString) + let preparationState = WorkspaceRuntimePerf.begin( + WorkspaceRuntimePerf.Stage.FileSystem.contentReadRequestPreparation, + WorkspaceRuntimePerf.Dimensions(workloadClass: workloadClass.rawValue, rootToken: diagnosticRootToken.uuidString) ) let request: ContentReadRequest do { @@ -734,23 +757,23 @@ extension FileSystemService { mode: .streamed, workloadClass: workloadClass ) - EditFlowPerf.end( - EditFlowPerf.Stage.FileSystem.contentReadRequestPreparation, + WorkspaceRuntimePerf.end( + WorkspaceRuntimePerf.Stage.FileSystem.contentReadRequestPreparation, preparationState, - EditFlowPerf.Dimensions(outcome: "prepared", workloadClass: workloadClass.rawValue) + WorkspaceRuntimePerf.Dimensions(outcome: "prepared", workloadClass: workloadClass.rawValue) ) } catch { - EditFlowPerf.end( - EditFlowPerf.Stage.FileSystem.contentReadRequestPreparation, + WorkspaceRuntimePerf.end( + WorkspaceRuntimePerf.Stage.FileSystem.contentReadRequestPreparation, preparationState, - EditFlowPerf.Dimensions(outcome: "error", workloadClass: workloadClass.rawValue) + WorkspaceRuntimePerf.Dimensions(outcome: "error", workloadClass: workloadClass.rawValue) ) throw error } - EditFlowPerf.lifecycleEvent( - EditFlowPerf.Lifecycle.FileSystem.contentReadRequestPrepared, + WorkspaceRuntimePerf.lifecycleEvent( + WorkspaceRuntimePerf.Lifecycle.FileSystem.contentReadRequestPrepared, correlation: lifecycleCorrelation, - EditFlowPerf.Dimensions(workloadClass: workloadClass.rawValue, rootToken: diagnosticRootToken.uuidString) + WorkspaceRuntimePerf.Dimensions(workloadClass: workloadClass.rawValue, rootToken: diagnosticRootToken.uuidString) ) #if DEBUG if shouldUseSerialContentReadFallback { @@ -799,8 +822,8 @@ extension FileSystemService { workloadClass: ContentReadWorkloadClass, schedulerOwnerID: UUID? = nil ) throws -> ContentReadRequest { - let contentLoadState = EditFlowPerf.begin(EditFlowPerf.Stage.FileSystem.contentLoadActorBody) - defer { EditFlowPerf.end(EditFlowPerf.Stage.FileSystem.contentLoadActorBody, contentLoadState) } + let contentLoadState = WorkspaceRuntimePerf.begin(WorkspaceRuntimePerf.Stage.FileSystem.contentLoadActorBody) + defer { WorkspaceRuntimePerf.end(WorkspaceRuntimePerf.Stage.FileSystem.contentLoadActorBody, contentLoadState) } guard !cacheKey.hasPrefix("/"), !StandardizedPath.containsNUL(cacheKey) else { throw FileSystemError.invalidRelativePath @@ -831,6 +854,7 @@ extension FileSystemService { mode: mode, workloadClass: workloadClass, schedulerOwnerID: schedulerOwnerID ?? diagnosticRootToken, + fileContentSnapshotReader: fileContentSnapshotReader, chunkReadHandler: contentReadChunkHandler ) #else @@ -845,25 +869,26 @@ extension FileSystemService { fileSizeLimit: fileSizeLimit, mode: mode, workloadClass: workloadClass, - schedulerOwnerID: schedulerOwnerID ?? diagnosticRootToken + schedulerOwnerID: schedulerOwnerID ?? diagnosticRootToken, + fileContentSnapshotReader: fileContentSnapshotReader ) #endif } private func performMeasuredContentReadOffActor( _ request: ContentReadRequest, - lifecycleCorrelation: EditFlowPerf.LifecycleCorrelation?, + lifecycleCorrelation: WorkspaceRuntimePerf.LifecycleCorrelation?, expectedFingerprint: FileContentFingerprint? = nil, requirePostReadValidation: Bool = false ) async throws -> ContentReadResult { - EditFlowPerf.lifecycleEvent( - EditFlowPerf.Lifecycle.FileSystem.contentReadOffActorScheduled, + WorkspaceRuntimePerf.lifecycleEvent( + WorkspaceRuntimePerf.Lifecycle.FileSystem.contentReadOffActorScheduled, correlation: lifecycleCorrelation, - EditFlowPerf.Dimensions(workloadClass: request.workloadClass.rawValue, rootToken: diagnosticRootToken.uuidString) + WorkspaceRuntimePerf.Dimensions(workloadClass: request.workloadClass.rawValue, rootToken: diagnosticRootToken.uuidString) ) - let offActorState = EditFlowPerf.begin( - EditFlowPerf.Stage.FileSystem.contentReadOffActorAwait, - EditFlowPerf.Dimensions(workloadClass: request.workloadClass.rawValue, rootToken: diagnosticRootToken.uuidString) + let offActorState = WorkspaceRuntimePerf.begin( + WorkspaceRuntimePerf.Stage.FileSystem.contentReadOffActorAwait, + WorkspaceRuntimePerf.Dimensions(workloadClass: request.workloadClass.rawValue, rootToken: diagnosticRootToken.uuidString) ) do { let result = try await Self.performContentReadOffActor( @@ -871,15 +896,15 @@ extension FileSystemService { expectedFingerprint: expectedFingerprint, requirePostReadValidation: requirePostReadValidation ) - EditFlowPerf.end( - EditFlowPerf.Stage.FileSystem.contentReadOffActorAwait, + WorkspaceRuntimePerf.end( + WorkspaceRuntimePerf.Stage.FileSystem.contentReadOffActorAwait, offActorState, - EditFlowPerf.Dimensions(outcome: result.telemetryOutcome.rawValue, workloadClass: request.workloadClass.rawValue) + WorkspaceRuntimePerf.Dimensions(outcome: result.telemetryOutcome.rawValue, workloadClass: request.workloadClass.rawValue) ) - EditFlowPerf.lifecycleEvent( - EditFlowPerf.Lifecycle.FileSystem.contentReadWorkerReturned, + WorkspaceRuntimePerf.lifecycleEvent( + WorkspaceRuntimePerf.Lifecycle.FileSystem.contentReadWorkerReturned, correlation: lifecycleCorrelation, - EditFlowPerf.Dimensions( + WorkspaceRuntimePerf.Dimensions( outcome: result.telemetryOutcome.rawValue, workloadClass: request.workloadClass.rawValue, rootToken: diagnosticRootToken.uuidString @@ -888,15 +913,15 @@ extension FileSystemService { return result } catch { let outcome = error is CancellationError ? "cancelled" : "error" - EditFlowPerf.end( - EditFlowPerf.Stage.FileSystem.contentReadOffActorAwait, + WorkspaceRuntimePerf.end( + WorkspaceRuntimePerf.Stage.FileSystem.contentReadOffActorAwait, offActorState, - EditFlowPerf.Dimensions(outcome: outcome, workloadClass: request.workloadClass.rawValue) + WorkspaceRuntimePerf.Dimensions(outcome: outcome, workloadClass: request.workloadClass.rawValue) ) - EditFlowPerf.lifecycleEvent( - EditFlowPerf.Lifecycle.FileSystem.contentReadWorkerReturned, + WorkspaceRuntimePerf.lifecycleEvent( + WorkspaceRuntimePerf.Lifecycle.FileSystem.contentReadWorkerReturned, correlation: lifecycleCorrelation, - EditFlowPerf.Dimensions( + WorkspaceRuntimePerf.Dimensions( outcome: outcome, workloadClass: request.workloadClass.rawValue, rootToken: diagnosticRootToken.uuidString @@ -910,10 +935,10 @@ extension FileSystemService { guard let detectedEncoding = result.detectedEncoding, let fingerprint = result.fingerprint else { return } - let contentLoadState = EditFlowPerf.begin(EditFlowPerf.Stage.FileSystem.contentLoadActorBody) - defer { EditFlowPerf.end(EditFlowPerf.Stage.FileSystem.contentLoadActorBody, contentLoadState) } + let contentLoadState = WorkspaceRuntimePerf.begin(WorkspaceRuntimePerf.Stage.FileSystem.contentLoadActorBody) + defer { WorkspaceRuntimePerf.end(WorkspaceRuntimePerf.Stage.FileSystem.contentLoadActorBody, contentLoadState) } - guard (try? FileContentFingerprintReader.fingerprint(atPath: result.absolutePath)) == fingerprint else { return } + guard (try? fileContentSnapshotReader.fingerprint(atPath: result.absolutePath)) == fingerprint else { return } encodingMap[cacheKey] = detectedEncoding } @@ -949,9 +974,9 @@ extension FileSystemService { expectedFingerprint: FileContentFingerprint? = nil, requirePostReadValidation: Bool = false ) async throws -> ContentReadResult { - let workerBodyState = EditFlowPerf.begin( - EditFlowPerf.Stage.FileSystem.contentReadWorkerBody, - EditFlowPerf.Dimensions( + let workerBodyState = WorkspaceRuntimePerf.begin( + WorkspaceRuntimePerf.Stage.FileSystem.contentReadWorkerBody, + WorkspaceRuntimePerf.Dimensions( workloadClass: request.workloadClass.rawValue, contentSource: "disk" ) @@ -959,10 +984,10 @@ extension FileSystemService { var workerBodyOutcome = "failed" var workerBodyFileBytes: Int? defer { - EditFlowPerf.end( - EditFlowPerf.Stage.FileSystem.contentReadWorkerBody, + WorkspaceRuntimePerf.end( + WorkspaceRuntimePerf.Stage.FileSystem.contentReadWorkerBody, workerBodyState, - EditFlowPerf.Dimensions( + WorkspaceRuntimePerf.Dimensions( outcome: workerBodyOutcome, fileBytes: workerBodyFileBytes, workloadClass: request.workloadClass.rawValue, @@ -1005,7 +1030,7 @@ extension FileSystemService { ) } if requirePostReadValidation { - let postReadFingerprint = try FileContentFingerprintReader.fingerprint(atPath: request.absolutePath) + let postReadFingerprint = try request.fileContentSnapshotReader.fingerprint(atPath: request.absolutePath) guard postReadFingerprint == validated.fingerprint else { throw FileContentValidationError.fingerprintChanged } @@ -1218,12 +1243,13 @@ extension FileSystemService { guard StandardizedPath.isDescendant(canonicalPath, of: request.canonicalRootPath) else { throw FileSystemError.invalidRelativePath } - let fingerprint = try FileContentFingerprintReader.fingerprint(atPath: standardizedAbsolutePath) + let fingerprint = try request.fileContentSnapshotReader.fingerprint(atPath: standardizedAbsolutePath) return ValidatedContentFile( url: url, fileSize: fingerprint.byteSize, modificationDate: fingerprint.modificationDate, - fingerprint: fingerprint + fingerprint: fingerprint, + fileContentSnapshotReader: request.fileContentSnapshotReader ) } @@ -1251,10 +1277,10 @@ extension FileSystemService { validated: ValidatedContentFile, requireStableIdentity: Bool ) throws -> FileHandle { - let handle = try FileContentFingerprintReader.openReadOnlyFileHandle(atPath: request.absolutePath) + let handle = try request.fileContentSnapshotReader.openReadOnlyFileHandle(atPath: request.absolutePath) do { if requireStableIdentity { - guard try FileContentFingerprintReader.fingerprint(fileDescriptor: handle.fileDescriptor) == validated.fingerprint else { + guard try validated.fileContentSnapshotReader.fingerprint(fileDescriptor: handle.fileDescriptor) == validated.fingerprint else { throw FileContentValidationError.fingerprintChanged } } @@ -1272,7 +1298,7 @@ extension FileSystemService { required: Bool ) throws -> ContentReadResult { if required { - guard try FileContentFingerprintReader.fingerprint(fileDescriptor: handle.fileDescriptor) == validated.fingerprint else { + guard try validated.fileContentSnapshotReader.fingerprint(fileDescriptor: handle.fileDescriptor) == validated.fingerprint else { throw FileContentValidationError.fingerprintChanged } } @@ -1360,8 +1386,8 @@ extension FileSystemService { } private func loadContentSerialForTesting(_ request: ContentReadRequest) async throws -> String? { - let contentLoadState = EditFlowPerf.begin(EditFlowPerf.Stage.FileSystem.contentLoadActorBody) - defer { EditFlowPerf.end(EditFlowPerf.Stage.FileSystem.contentLoadActorBody, contentLoadState) } + let contentLoadState = WorkspaceRuntimePerf.begin(WorkspaceRuntimePerf.Stage.FileSystem.contentLoadActorBody) + defer { WorkspaceRuntimePerf.end(WorkspaceRuntimePerf.Stage.FileSystem.contentLoadActorBody, contentLoadState) } let fm = fm guard fm.fileExists(atPath: request.absolutePath, isDirectory: nil) else { @@ -1385,8 +1411,8 @@ extension FileSystemService { } private func loadEntireFileContentOptimizedSerialForTesting(_ request: ContentReadRequest) async throws -> String? { - let contentLoadState = EditFlowPerf.begin(EditFlowPerf.Stage.FileSystem.contentLoadActorBody) - defer { EditFlowPerf.end(EditFlowPerf.Stage.FileSystem.contentLoadActorBody, contentLoadState) } + let contentLoadState = WorkspaceRuntimePerf.begin(WorkspaceRuntimePerf.Stage.FileSystem.contentLoadActorBody) + defer { WorkspaceRuntimePerf.end(WorkspaceRuntimePerf.Stage.FileSystem.contentLoadActorBody, contentLoadState) } let fm = fm guard fm.fileExists(atPath: request.absolutePath, isDirectory: nil) else { @@ -1437,7 +1463,7 @@ extension FileSystemService { #endif /// Attempt to decode with all post‑UTF‑8 fall‑backs, including region‑specific ones. - func tryDecodeWithFallbackEncodings(_ data: Data) -> String? { + package func tryDecodeWithFallbackEncodings(_ data: Data) -> String? { for enc in Self.orderedFallbackEncodings + Self.regionSpecificEncodings { if let s = String(data: data, encoding: enc) { return s } } @@ -1453,7 +1479,7 @@ extension FileSystemService { /// 4. Western single-byte fall-backs /// 5. Heuristic UTF-16 without BOM /// 6. Region-specific legacies - func detectEncodingForInitialChunk(initialData: Data) throws -> String.Encoding { + package func detectEncodingForInitialChunk(initialData: Data) throws -> String.Encoding { guard !initialData.isEmpty else { return .utf8 } // 1) Honor BOM immediately @@ -1495,7 +1521,7 @@ extension FileSystemService { } /// Example approach if you want a standalone data-based detection - func detectFileEncodingFromData(_ data: Data) async throws -> String.Encoding { + package func detectFileEncodingFromData(_ data: Data) async throws -> String.Encoding { // 1) BOM check if let bom = Self.detectBOMEncoding(in: data) { return bom } @@ -1534,7 +1560,7 @@ extension FileSystemService { /// • Any NUL byte → binary /// • Control bytes 0x00–0x1F **except** TAB/LF/CR /// • If ≥ 30 % of the bytes in the sample are control bytes → binary - static func isProbablyBinary(_ data: Data, sampleSize: Int = 8192) -> Bool { + package static func isProbablyBinary(_ data: Data, sampleSize: Int = 8192) -> Bool { guard !data.isEmpty else { return false } let sample = data.prefix(sampleSize) @@ -1564,13 +1590,13 @@ extension FileSystemService { /// Encodings to try **after** UTF‑8 fails, in the exact order mandated /// by the research note: Windows‑1252 → Mac Roman → UTF‑16 (LE/BE) - static let orderedFallbackEncodings: [String.Encoding] = [ + package static let orderedFallbackEncodings: [String.Encoding] = [ .windowsCP1252, .macOSRoman ] /// Optional, low‑priority locale‑specific single‑byte encodings - static let regionSpecificEncodings: [String.Encoding] = [ + package static let regionSpecificEncodings: [String.Encoding] = [ .shiftJIS, .japaneseEUC, .iso2022JP, // Japanese // Mainland‑China GB18030 String.Encoding( @@ -1592,7 +1618,7 @@ extension FileSystemService { // MARK: - Extension / filename whitelists /// Extensions that are always treated as binary; we short-circuit before any filesystem queries. - static let alwaysBinaryExtensions: Set = [ + package static let alwaysBinaryExtensions: Set = [ // ── Video ─────────────────────────────────────────────────── "mp4", "m4v", "mov", "avi", "mkv", "webm", "flv", "wmv", "mpeg", "mpg", "m2ts", "mts", "3gp", "3g2", "ogv", "asf", "rm", "rmvb", "vob", "ogm", "f4v", "mpe", "m1v", "m2v", "divx", "xvid", "dv", @@ -1617,7 +1643,7 @@ extension FileSystemService { ] /// Extensions that are **always** treated as plain-text – we skip the binary probe entirely. - static let alwaysTextExtensions: Set = [ + package static let alwaysTextExtensions: Set = [ // ── General text / docs ───────────────────────────────────── "txt", "text", "md", "markdown", "rst", "mdx", // ── Data / config ─────────────────────────────────────────── @@ -1637,14 +1663,14 @@ extension FileSystemService { ] /// Filenames with **no** extension that are always text. - static let alwaysTextFilenames: Set = [ + package static let alwaysTextFilenames: Set = [ "makefile", "dockerfile", "readme", "license", "gitignore", ".gitignore", ".ignore", ".env", ".gitattributes", ".editorconfig" ] /// Detect a Unicode BOM and return the matching encoding, or `nil`. - static func detectBOMEncoding(in data: Data) -> String.Encoding? { + package static func detectBOMEncoding(in data: Data) -> String.Encoding? { guard data.count >= 2 else { return nil } if data.starts(with: [0xEF, 0xBB, 0xBF]) { return .utf8 } // UTF‑8 BOM if data.starts(with: [0x00, 0x00, 0xFE, 0xFF]) { return .utf32BigEndian } @@ -1658,7 +1684,7 @@ extension FileSystemService { /// The fast-path now uses the length-aware `String(data:encoding:)` /// instead of `String(validatingUTF8:)`, eliminating crashes caused by /// missing NUL-termination in `Data` buffers. - func readDataAndDetectEncoding(_ fullPath: String) throws -> DetectedText { + package func readDataAndDetectEncoding(_ fullPath: String) throws -> DetectedText { let data = try Data(contentsOf: URL(fileURLWithPath: fullPath)) // 0 --> return empty string immediately ✅ @@ -1681,7 +1707,7 @@ extension FileSystemService { } /// Quick heuristic: UTF‑16 text usually contains many NUL bytes. - static func looksLikeUTF16(_ data: Data) -> Bool { + package static func looksLikeUTF16(_ data: Data) -> Bool { let sample = data.prefix(256) let zeroCount = sample.count(where: { $0 == 0 }) return zeroCount > sample.count / 4 // > 25 % zeros ⇒ likely UTF‑16 @@ -1689,7 +1715,7 @@ extension FileSystemService { // A minimal directory entry representation - func detectFileEncoding(atRelativePath relativePath: String) async throws -> String.Encoding { + package func detectFileEncoding(atRelativePath relativePath: String) async throws -> String.Encoding { let request = try makeContentReadRequest( cacheKey: relativePath, chunkSize: 1_048_576, @@ -1713,9 +1739,9 @@ extension FileSystemService { ) { try await withThrowingTaskGroup(of: String.Encoding.self) { group in group.addTask(priority: workerPriority) { - let workerBodyState = EditFlowPerf.begin( - EditFlowPerf.Stage.FileSystem.contentReadWorkerBody, - EditFlowPerf.Dimensions( + let workerBodyState = WorkspaceRuntimePerf.begin( + WorkspaceRuntimePerf.Stage.FileSystem.contentReadWorkerBody, + WorkspaceRuntimePerf.Dimensions( workloadClass: request.workloadClass.rawValue, contentSource: "disk" ) @@ -1723,10 +1749,10 @@ extension FileSystemService { var workerBodyOutcome = "failed" var workerBodyFileBytes: Int? defer { - EditFlowPerf.end( - EditFlowPerf.Stage.FileSystem.contentReadWorkerBody, + WorkspaceRuntimePerf.end( + WorkspaceRuntimePerf.Stage.FileSystem.contentReadWorkerBody, workerBodyState, - EditFlowPerf.Dimensions( + WorkspaceRuntimePerf.Dimensions( outcome: workerBodyOutcome, fileBytes: workerBodyFileBytes, workloadClass: request.workloadClass.rawValue, diff --git a/Sources/RepoPrompt/Infrastructure/FileSystem/FileSystemService+DirectoryEnumeration.swift b/Sources/RepoPromptCore/FileSystem/FileSystemService+DirectoryEnumeration.swift similarity index 96% rename from Sources/RepoPrompt/Infrastructure/FileSystem/FileSystemService+DirectoryEnumeration.swift rename to Sources/RepoPromptCore/FileSystem/FileSystemService+DirectoryEnumeration.swift index 7ca0c9f43..9c4277c07 100644 --- a/Sources/RepoPrompt/Infrastructure/FileSystem/FileSystemService+DirectoryEnumeration.swift +++ b/Sources/RepoPromptCore/FileSystem/FileSystemService+DirectoryEnumeration.swift @@ -1,6 +1,6 @@ import Foundation -extension FileSystemService { +package extension FileSystemService { // MARK: - Parallel scanning support /// Result of scanning a single folder (Sendable for cross-task usage) @@ -17,12 +17,13 @@ extension FileSystemService { relFolder: String, skipSymlinks: Bool, rules: IgnoreRulesSnapshot, + directoryListingBackend: any WorkspaceDirectoryListingBackend, preserveChildren: Set = [] ) throws -> ScanResult { // Use the lightweight POSIX-based directory scanner let scan: DirectoryScanResult do { - scan = try listDirectoryWithIgnoreDetection(absFolder) + scan = try directoryListingBackend.listDirectoryWithIgnoreDetection(at: absFolder) } catch { // Return empty result for non-existent or inaccessible paths return ScanResult( @@ -126,8 +127,9 @@ extension FileSystemService { var folderIterator = cappedFolders.makeIterator() var inFlight = 0 - // Capture ignoreRules before entering the task group to avoid actor isolation issues + // Capture actor-isolated dependencies before entering the task group. let fallbackRules = ignoreRules.snapshot() + let directoryListingBackend = directoryListingBackend try await withThrowingTaskGroup(of: ScanResult.self) { group in /// Helper to schedule tasks up to maxParallel @@ -148,6 +150,7 @@ extension FileSystemService { relFolder: folderRel, skipSymlinks: skipLinks, rules: rulesForFolder, + directoryListingBackend: directoryListingBackend, preserveChildren: preservedChildren ) } @@ -228,11 +231,7 @@ extension FileSystemService { } // 2) Single-level listing using POSIX directory scanning - #if DEBUG - let scanResult = try Self.listDirectoryWithIgnoreDetection(absFolder, fm: self.fm) - #else - let scanResult = try Self.listDirectoryWithIgnoreDetection(absFolder) - #endif + let scanResult = try listDirectoryForCurrentFilesystem(absFolder) let parentRules = folderRelPath.isEmpty ? ignoreRules @@ -404,7 +403,7 @@ extension FileSystemService { return deltas } - public func loadContentsInChunks( + func loadContentsInChunks( of folderURL: URL, chunkSize: Int = 200 ) -> AsyncThrowingStream { @@ -637,7 +636,7 @@ extension FileSystemService { ) if trackCycles { - guard let id = Self.dirID(followingSymlinksAtPath: absolutePath) else { + guard let id = service.directoryListingBackend.directoryIdentity(followingSymlinksAt: absolutePath) else { continue } if let chain = context.chain, chain.contains(id) { @@ -708,7 +707,7 @@ extension FileSystemService { let isVirtualFS = isTestMode && !(fm is FileManager) if isVirtualFS || skipSymlinks { rootChain = nil - } else if let rootID = Self.dirID(followingSymlinksAtPath: rootFullPath) { + } else if let rootID = directoryListingBackend.directoryIdentity(followingSymlinksAt: rootFullPath) { rootChain = DirChain(rootID, parent: nil) } else { rootChain = nil @@ -716,7 +715,7 @@ extension FileSystemService { #else if skipSymlinks { rootChain = nil - } else if let rootID = Self.dirID(followingSymlinksAtPath: rootFullPath) { + } else if let rootID = directoryListingBackend.directoryIdentity(followingSymlinksAt: rootFullPath) { rootChain = DirChain(rootID, parent: nil) } else { rootChain = nil @@ -845,7 +844,9 @@ extension FileSystemService { flush(force: true) return totalFilesSeen } +} +extension FileSystemService { #if DEBUG func gatherPathsUsingVirtualFS( rootURL: URL, @@ -913,9 +914,9 @@ extension FileSystemService { let testMode = await service.isTestMode if testMode { let fm = await service.fm - scanResult = try Self.listDirectoryWithIgnoreDetection(context.absPath, fm: fm) + scanResult = try Self.listDirectory(context.absPath, using: fm) } else { - scanResult = try Self.listDirectoryWithIgnoreDetection(context.absPath) + scanResult = try service.directoryListingBackend.listDirectoryWithIgnoreDetection(at: context.absPath) } } catch { return DirectoryChunkResult(folders: [], files: [], subdirs: [], ignoreCacheDelta: [:]) @@ -946,7 +947,7 @@ extension FileSystemService { ) async throws -> DirectoryChunkResult { let scanResult: DirectoryScanResult do { - scanResult = try Self.listDirectoryWithIgnoreDetection(context.absPath) + scanResult = try service.directoryListingBackend.listDirectoryWithIgnoreDetection(at: context.absPath) } catch { return DirectoryChunkResult(folders: [], files: [], subdirs: [], ignoreCacheDelta: [:]) } @@ -965,7 +966,9 @@ extension FileSystemService { ) } #endif +} +package extension FileSystemService { func folderURLRootPath(_ folderURL: URL) -> String { folderURL.standardizedFileURL.path } @@ -1011,7 +1014,7 @@ extension FileSystemService { rootChain = nil } else { var chain: DirChain? - if let rootID = Self.dirID(followingSymlinksAtPath: canonicalRootPath) { + if let rootID = directoryListingBackend.directoryIdentity(followingSymlinksAt: canonicalRootPath) { chain = DirChain(rootID, parent: nil) } if !baseRelativePath.isEmpty { @@ -1019,7 +1022,7 @@ extension FileSystemService { for component in baseRelativePath.split(separator: "/") { relSoFar = relSoFar.isEmpty ? String(component) : "\(relSoFar)/\(component)" let absPath = Self.joinRootAndRelative(root: path, relative: relSoFar) - if let id = Self.dirID(followingSymlinksAtPath: absPath) { + if let id = directoryListingBackend.directoryIdentity(followingSymlinksAt: absPath) { chain = DirChain(id, parent: chain) } } @@ -1031,7 +1034,7 @@ extension FileSystemService { rootChain = nil } else { var chain: DirChain? - if let rootID = Self.dirID(followingSymlinksAtPath: canonicalRootPath) { + if let rootID = directoryListingBackend.directoryIdentity(followingSymlinksAt: canonicalRootPath) { chain = DirChain(rootID, parent: nil) } if !baseRelativePath.isEmpty { @@ -1039,7 +1042,7 @@ extension FileSystemService { for component in baseRelativePath.split(separator: "/") { relSoFar = relSoFar.isEmpty ? String(component) : "\(relSoFar)/\(component)" let absPath = Self.joinRootAndRelative(root: path, relative: relSoFar) - if let id = Self.dirID(followingSymlinksAtPath: absPath) { + if let id = directoryListingBackend.directoryIdentity(followingSymlinksAt: absPath) { chain = DirChain(id, parent: chain) } } @@ -1130,11 +1133,4 @@ extension FileSystemService { if child.isEmpty { return base } return base + "/" + child } - - func getCoreCount() -> Int { - var count: Int32 = 0 - var size = MemoryLayout.size - sysctlbyname("hw.ncpu", &count, &size, nil, 0) - return Int(count) - } } diff --git a/Sources/RepoPromptCore/FileSystem/FileSystemService+DirectoryListingBackend.swift b/Sources/RepoPromptCore/FileSystem/FileSystemService+DirectoryListingBackend.swift new file mode 100644 index 000000000..cb8fa7cc9 --- /dev/null +++ b/Sources/RepoPromptCore/FileSystem/FileSystemService+DirectoryListingBackend.swift @@ -0,0 +1,62 @@ +import Foundation + +package extension FileSystemService { + func listDirectoryForCurrentFilesystem(_ path: String) throws -> WorkspaceDirectoryScanResult { + #if DEBUG + if let override = fileManagerOverride, !(override is FileManager) { + return try Self.listDirectory(path, using: override) + } + #endif + return try directoryListingBackend.listDirectoryWithIgnoreDetection(at: path) + } +} + +#if DEBUG + extension FileSystemService { + nonisolated static func listDirectory( + _ path: String, + using fileSystem: any FileSystemProviding + ) throws -> WorkspaceDirectoryScanResult { + let children = try fileSystem.contentsOfDirectory( + at: URL(fileURLWithPath: path), + includingPropertiesForKeys: [.isDirectoryKey, .isSymbolicLinkKey], + options: [] + ) + var entries: [WorkspaceDirectoryEntry] = [] + var hasGitignore = false + var hasRepoIgnore = false + var hasCursorignore = false + for url in children { + let name = url.lastPathComponent + guard name != ".", name != "..", !isRepoPromptTempFilename(name) else { continue } + switch name { + case ".gitignore": hasGitignore = true + case ".repo_ignore": hasRepoIgnore = true + case ".cursorignore": hasCursorignore = true + default: break + } + var isDirectory = ObjCBool(false) + _ = fileSystem.fileExists(atPath: url.path, isDirectory: &isDirectory) + let isSymbolicLink = (try? url.resourceValues(forKeys: [.isSymbolicLinkKey]).isSymbolicLink) ?? false + entries.append(WorkspaceDirectoryEntry( + name: name, + isDirectory: isDirectory.boolValue, + isSymbolicLink: isSymbolicLink + )) + } + return WorkspaceDirectoryScanResult( + entries: entries, + hasGitignore: hasGitignore, + hasRepoIgnore: hasRepoIgnore, + hasCursorignore: hasCursorignore + ) + } + } +#endif + +package extension FileSystemService { + @inline(__always) + nonisolated static func isRepoPromptTempFilename(_ name: String) -> Bool { + name.hasPrefix(".repoprompt.tmp.") + } +} diff --git a/Sources/RepoPrompt/Infrastructure/FileSystem/FileSystemService+IgnoreRules.swift b/Sources/RepoPromptCore/FileSystem/FileSystemService+IgnoreRules.swift similarity index 88% rename from Sources/RepoPrompt/Infrastructure/FileSystem/FileSystemService+IgnoreRules.swift rename to Sources/RepoPromptCore/FileSystem/FileSystemService+IgnoreRules.swift index 646f675ea..96777ebbc 100644 --- a/Sources/RepoPrompt/Infrastructure/FileSystem/FileSystemService+IgnoreRules.swift +++ b/Sources/RepoPromptCore/FileSystem/FileSystemService+IgnoreRules.swift @@ -22,7 +22,7 @@ extension FileSystemService { return change } - func rebuildPerFolderIgnoreCache( + package func rebuildPerFolderIgnoreCache( changedDirs: Set? = nil ) async { // Clear all per-path ignore caches to avoid stale decisions @@ -33,7 +33,7 @@ extension FileSystemService { perFolderIgnoreCache.removeAll() clearNoIgnoreFilesCache() do { - ignoreRules = try await IgnoreRulesManager.shared.getIgnoreRules( + ignoreRules = try await ignoreRulesManager.getIgnoreRules( for: path, respectGitignore: respectGitignore, respectRepoIgnore: respectRepoIgnore, @@ -52,7 +52,7 @@ extension FileSystemService { perFolderIgnoreCache.removeAll() clearNoIgnoreFilesCache() do { - ignoreRules = try await IgnoreRulesManager.shared.getIgnoreRules( + ignoreRules = try await ignoreRulesManager.getIgnoreRules( for: path, respectGitignore: respectGitignore, respectRepoIgnore: respectRepoIgnore, @@ -87,12 +87,12 @@ extension FileSystemService { // MARK: - New prefix-based ignore check (cached in this actor) - func cachedIgnoreRules(for directoryPath: String) -> IgnoreRules? { + package func cachedIgnoreRules(for directoryPath: String) -> IgnoreRules? { perFolderIgnoreCache[directoryPath] } /// We walk each parent sub-path, caching the result. - func isIgnoredPrefixCheck(relativePath: String, isDirectory: Bool = false) -> Bool { + package func isIgnoredPrefixCheck(relativePath: String, isDirectory: Bool = false) -> Bool { let comps = pathCompsCache.components(for: relativePath) return ignoreCacheStore.isIgnoredPrefixCheck( components: comps, @@ -102,7 +102,7 @@ extension FileSystemService { } /// Check if a path is ignored using hierarchical rules (for delta events) - func isIgnoredHierarchical(relativePath: String, isDirectory overrideValue: Bool? = nil) async -> Bool { + package func isIgnoredHierarchical(relativePath: String, isDirectory overrideValue: Bool? = nil) async -> Bool { // Get the file type let isDir = overrideValue ?? (visitedItems[relativePath] ?? fileOrFolderIsDir(relativePath)) @@ -124,7 +124,7 @@ extension FileSystemService { } /// Hierarchical check that treats the target as a directory regardless of current disk state. - func isIgnoredHierarchicalDir(_ relativePath: String) async -> Bool { + package func isIgnoredHierarchicalDir(_ relativePath: String) async -> Bool { if relativePath.isEmpty { return false } @@ -136,14 +136,14 @@ extension FileSystemService { } /// Rules provider implementation for the hierarchical evaluator - final class FileSystemRulesProvider: HierarchicalIgnoreEvaluator.RulesProvider { + package final class FileSystemRulesProvider: HierarchicalIgnoreEvaluator.RulesProvider { let service: FileSystemService init(service: FileSystemService) { self.service = service } - func rulesForDirectory(_ directoryPath: String) async throws -> IgnoreRules { + package func rulesForDirectory(_ directoryPath: String) async throws -> IgnoreRules { // Check cache first if let cached = await service.cachedIgnoreRules(for: directoryPath) { #if DEBUG @@ -176,7 +176,7 @@ extension FileSystemService { } @discardableResult - func ensureRulesChain(for relativeDirectory: String, using scanResult: DirectoryScanResult? = nil) async throws -> IgnoreRules { + package func ensureRulesChain(for relativeDirectory: String, using scanResult: WorkspaceDirectoryScanResult? = nil) async throws -> IgnoreRules { if let cached = perFolderIgnoreCache[relativeDirectory] { return cached } @@ -190,15 +190,10 @@ extension FileSystemService { let parentRules = try await ensureRulesChain(for: parent) let absPath = fullPath(forRelativePath: relativeDirectory) - let scan: DirectoryScanResult - if let provided = scanResult { - scan = provided + let scan: WorkspaceDirectoryScanResult = if let provided = scanResult { + provided } else { - #if DEBUG - scan = try Self.listDirectoryWithIgnoreDetection(absPath, fm: fm) - #else - scan = try Self.listDirectoryWithIgnoreDetection(absPath) - #endif + try listDirectoryForCurrentFilesystem(absPath) } let dirURL = URL(fileURLWithPath: absPath) @@ -213,19 +208,19 @@ extension FileSystemService { } /// If you had snapshot/merge logic: - func snapshotIgnoreCache() -> [String: Bool] { + package func snapshotIgnoreCache() -> [String: Bool] { ignoreCacheStore.snapshotIgnoreCache() } - func snapshotIgnoreCacheWithPathKeys() -> [IgnoreCacheStore.PathKey: Bool] { + package func snapshotIgnoreCacheWithPathKeys() -> [IgnoreCacheStore.PathKey: Bool] { ignoreCacheStore.snapshotIgnoreCacheWithPathKeys() } - func mergeIgnoreCache(_ localCache: [String: Bool]) { + package func mergeIgnoreCache(_ localCache: [String: Bool]) { ignoreCacheStore.mergeIgnoreCache(localCache) } - func mergeIgnoreCache(_ localCache: [IgnoreCacheStore.PathKey: Bool]) { + package func mergeIgnoreCache(_ localCache: [IgnoreCacheStore.PathKey: Bool]) { guard !localCache.isEmpty else { return } ignoreCacheStore.mergeIgnoreCache(localCache) } @@ -263,7 +258,7 @@ extension FileSystemService { } public func refreshIgnoreRules() async throws { - ignoreRules = try await IgnoreRulesManager.shared.getIgnoreRules( + ignoreRules = try await ignoreRulesManager.getIgnoreRules( for: path, respectGitignore: respectGitignore, respectRepoIgnore: respectRepoIgnore, @@ -272,7 +267,7 @@ extension FileSystemService { invalidateAllIgnoreCaches() } - func effectiveRules( + package func effectiveRules( for dirURL: URL, parentRelPath: String, parentRules: IgnoreRules @@ -280,11 +275,7 @@ extension FileSystemService { // Performance optimization: batch check both files at once // This method is only called when hierarchical ignores are enabled // We need to check what files exist - #if DEBUG - let scanResult = try Self.listDirectoryWithIgnoreDetection(dirURL.path, fm: fm) - #else - let scanResult = try Self.listDirectoryWithIgnoreDetection(dirURL.path) - #endif + let scanResult = try listDirectoryForCurrentFilesystem(dirURL.path) return try await optimizedEffectiveRules( for: dirURL, parentRelPath: parentRelPath, @@ -296,7 +287,7 @@ extension FileSystemService { } /// Optimized version that minimizes file system operations - func optimizedEffectiveRules( + package func optimizedEffectiveRules( for dirURL: URL, parentRelPath: String, parentRules: IgnoreRules, @@ -406,7 +397,7 @@ extension FileSystemService { return effectiveRules } - func effectiveRulesSnapshot( + package func effectiveRulesSnapshot( for dirURL: URL, parentRelPath: String, hasGitignore: Bool, @@ -431,7 +422,7 @@ extension FileSystemService { } /// Cache ignore rules with LRU eviction - func cacheIgnoreRules(_ rules: IgnoreRules, for path: String) { + package func cacheIgnoreRules(_ rules: IgnoreRules, for path: String) { let evicted = perFolderIgnoreCache.set(rules, forKey: path) if let evictedKey = evicted { removeNoIgnoreFilesCached(evictedKey) @@ -444,30 +435,30 @@ extension FileSystemService { } } - func hasNoIgnoreFilesCached(_ path: String) -> Bool { + package func hasNoIgnoreFilesCached(_ path: String) -> Bool { noIgnoreFileCache[path] == true } - func markNoIgnoreFilesCached(_ path: String) { + package func markNoIgnoreFilesCached(_ path: String) { _ = noIgnoreFileCache.set(true, forKey: path) } - func removeNoIgnoreFilesCached(_ path: String) { + package func removeNoIgnoreFilesCached(_ path: String) { noIgnoreFileCache.removeValue(forKey: path) } - func removeNoIgnoreFilesCached(where shouldRemove: (String) -> Bool) { + package func removeNoIgnoreFilesCached(where shouldRemove: (String) -> Bool) { for key in noIgnoreFileCache.keys where shouldRemove(key) { removeNoIgnoreFilesCached(key) } } - func clearNoIgnoreFilesCache() { + package func clearNoIgnoreFilesCache() { noIgnoreFileCache.removeAll() } /// Clear all ignore-related caches and seed the root rules. - func invalidateAllIgnoreCaches() { + package func invalidateAllIgnoreCaches() { ignoreCacheStore = IgnoreCacheStore() perFolderIgnoreCache.removeAll() clearNoIgnoreFilesCache() @@ -475,13 +466,13 @@ extension FileSystemService { } /// Mark a directory as having no ignore files - func markNoIgnoreFiles(_ path: String, parentRules: IgnoreRules) { + package func markNoIgnoreFiles(_ path: String, parentRules: IgnoreRules) { markNoIgnoreFilesCached(path) cacheIgnoreRules(parentRules, for: path) } /// Mark a directory as having no ignore files using cached parent rules - func markNoIgnoreFilesUsingCache(_ path: String) async throws { + package func markNoIgnoreFilesUsingCache(_ path: String) async throws { let parentRel = parentDirectory(of: path) let parentRules: IgnoreRules = if let cached = perFolderIgnoreCache[parentRel] { cached diff --git a/Sources/RepoPromptCore/FileSystem/FileSystemService+Metadata.swift b/Sources/RepoPromptCore/FileSystem/FileSystemService+Metadata.swift new file mode 100644 index 000000000..007b1c3b1 --- /dev/null +++ b/Sources/RepoPromptCore/FileSystem/FileSystemService+Metadata.swift @@ -0,0 +1,15 @@ +import Foundation + +package extension FileSystemService { + func getFileModificationDate(atRelativePath relativePath: String) async throws -> Date { + let fullPath = fullPath(forRelativePath: relativePath) + let attributes = try fm.attributesOfItem(atPath: fullPath) + return attributes[.modificationDate] as? Date ?? Date() + } + + func getItemModificationDateIfAvailable(atRelativePath relativePath: String) async -> Date? { + let fullPath = fullPath(forRelativePath: relativePath) + guard let attributes = try? fm.attributesOfItem(atPath: fullPath) else { return nil } + return attributes[.modificationDate] as? Date + } +} diff --git a/Sources/RepoPromptCore/FileSystem/FileSystemService+MutationCoherence.swift b/Sources/RepoPromptCore/FileSystem/FileSystemService+MutationCoherence.swift new file mode 100644 index 000000000..07cf60c83 --- /dev/null +++ b/Sources/RepoPromptCore/FileSystem/FileSystemService+MutationCoherence.swift @@ -0,0 +1,194 @@ +import Foundation + +extension FileSystemService { + private func mutationBackendOrThrow() throws -> any WorkspaceFileMutationBackend { + guard let mutationBackend else { throw FileSystemError.mutationBackendUnavailable } + return mutationBackend + } + + private func mutationTarget( + forRelativePath rawRelativePath: String, + rejectExistingLeafSymlink: Bool = true + ) throws -> (relativePath: String, url: URL) { + guard !rawRelativePath.hasPrefix("/"), !StandardizedPath.containsNUL(rawRelativePath) else { + throw FileSystemError.invalidRelativePath + } + let relativePath = StandardizedPath.relative(rawRelativePath) + guard !relativePath.isEmpty, relativePath != "..", !relativePath.hasPrefix("../") else { + throw FileSystemError.invalidRelativePath + } + let url = rootURL.appendingPathComponent(relativePath).standardizedFileURL + guard url.path != standardizedRootPath, + StandardizedPath.isDescendant(url.path, of: standardizedRootPath) + else { throw FileSystemError.invalidRelativePath } + + var current = rootURL + for component in relativePath.split(separator: "/").dropLast() { + current.appendPathComponent(String(component)) + guard !pathIsSymbolicLink(current) else { throw FileSystemError.invalidRelativePath } + var isDirectory = ObjCBool(false) + guard fm.fileExists(atPath: current.path, isDirectory: &isDirectory) else { break } + guard isDirectory.boolValue else { throw FileSystemError.invalidRelativePath } + } + let canonicalParent = url.deletingLastPathComponent().resolvingSymlinksInPath().standardizedFileURL.path + guard canonicalParent == canonicalRootPath || StandardizedPath.isDescendant(canonicalParent, of: canonicalRootPath) else { + throw FileSystemError.invalidRelativePath + } + if rejectExistingLeafSymlink, pathIsSymbolicLink(url) { + throw FileSystemError.invalidRelativePath + } + return (relativePath, url) + } + + private func pathIsSymbolicLink(_ url: URL) -> Bool { + (try? url.resourceValues(forKeys: [.isSymbolicLinkKey]).isSymbolicLink) == true + } + + private func requireRegularMutationSource(relativePath: String) async throws { + switch await catalogRegularFileEligibility(relativePath: relativePath) { + case .eligible, .ineligible(.ignored): return + case .ineligible(.missingOrDirectory): throw FileSystemError.fileNotFound + case .ineligible: throw FileSystemError.invalidRelativePath + } + } + + package func createFile(atRelativePath relativePath: String, content: String) async throws { + let backend = try mutationBackendOrThrow() + let target = try mutationTarget(forRelativePath: relativePath) + guard let data = content.data(using: .utf8) else { + throw FileSystemError.failedToCreateFile(CocoaError(.fileWriteInapplicableStringEncoding)) + } + do { + try backend.createDirectory(at: target.url.deletingLastPathComponent()) + _ = try mutationTarget(forRelativePath: target.relativePath) + var isDirectory = false + guard !backend.fileExists(atPath: target.url.path, isDirectory: &isDirectory) else { + throw FileSystemError.fileAlreadyExists + } + try backend.createFile(at: target.url, contents: data) + } catch let error as FileSystemError { + throw error + } catch { + throw FileSystemError.failedToCreateFile(error) + } + switch await catalogRegularFileEligibility(relativePath: target.relativePath) { + case .eligible, .ineligible(.ignored): break + case .ineligible: + try? backend.removeItem(at: target.url) + forgetTrackedPath(target.relativePath) + throw FileSystemError.invalidRelativePath + } + encodingMap[target.relativePath] = .utf8 + visitedPaths.insert(target.relativePath) + visitedItems[target.relativePath] = false + publishFileSystemDeltas([.fileAdded(target.relativePath)], source: .syntheticMutation) + } + + package func editFile(atRelativePath relativePath: String, newContent: String) async throws { + let backend = try mutationBackendOrThrow() + let target = try mutationTarget(forRelativePath: relativePath) + try await requireRegularMutationSource(relativePath: target.relativePath) + var isDirectory = false + guard backend.fileExists(atPath: target.url.path, isDirectory: &isDirectory), !isDirectory else { + throw FileSystemError.fileNotFound + } + let encoding = encodingMap[target.relativePath] ?? .utf8 + guard let data = newContent.data(using: encoding) else { + throw FileSystemError.failedToEditFile(CocoaError(.fileWriteInapplicableStringEncoding)) + } + do { + try backend.write(data, to: target.url, atomically: true) + } catch { + throw FileSystemError.failedToEditFile(error) + } + switch await catalogRegularFileEligibility(relativePath: target.relativePath) { + case .eligible, .ineligible(.ignored): break + case .ineligible: throw FileSystemError.invalidRelativePath + } + encodingMap[target.relativePath] = encoding + visitedPaths.insert(target.relativePath) + visitedItems[target.relativePath] = false + let modificationDate = try? backend.modificationDate(at: target.url) + publishFileSystemDeltas([.fileModified(target.relativePath, modificationDate)], source: .syntheticMutation) + } + + package func moveFile(atRelativePath oldPath: String, toRelativePath newPath: String) async throws { + let backend = try mutationBackendOrThrow() + let source = try mutationTarget(forRelativePath: oldPath) + let destination = try mutationTarget(forRelativePath: newPath) + try await requireRegularMutationSource(relativePath: source.relativePath) + var isDirectory = false + guard backend.fileExists(atPath: source.url.path, isDirectory: &isDirectory), !isDirectory else { + throw FileSystemError.fileNotFound + } + guard !backend.fileExists(atPath: destination.url.path, isDirectory: &isDirectory) else { + throw FileSystemError.fileAlreadyExists + } + do { + try backend.createDirectory(at: destination.url.deletingLastPathComponent()) + _ = try mutationTarget(forRelativePath: destination.relativePath) + try backend.moveItem(at: source.url, to: destination.url) + } catch { + throw FileSystemError.failedToCreateFile(error) + } + switch await catalogRegularFileEligibility(relativePath: destination.relativePath) { + case .eligible, .ineligible(.ignored): break + case .ineligible: + try? backend.moveItem(at: destination.url, to: source.url) + throw FileSystemError.invalidRelativePath + } + if let wasDirectory = visitedItems.removeValue(forKey: source.relativePath) { + visitedItems[destination.relativePath] = wasDirectory + } + visitedPaths.remove(source.relativePath) + visitedPaths.insert(destination.relativePath) + if let encoding = encodingMap.removeValue(forKey: source.relativePath) { + encodingMap[destination.relativePath] = encoding + } + publishFileSystemDeltas( + [.fileRemoved(source.relativePath), .fileAdded(destination.relativePath)], + source: .syntheticMutation + ) + } + + package func deleteFile(atRelativePath relativePath: String) async throws { + let backend = try mutationBackendOrThrow() + let target = try mutationTarget(forRelativePath: relativePath) + try await requireRegularMutationSource(relativePath: target.relativePath) + do { + try backend.removeItem(at: target.url) + } catch { + throw FileSystemError.failedToDeleteFile(error) + } + forgetTrackedPath(target.relativePath) + publishFileSystemDeltas([.fileRemoved(target.relativePath)], source: .syntheticMutation) + } + + package func moveItemToTrash(atRelativePath relativePath: String) async throws { + let backend = try mutationBackendOrThrow() + let target = try mutationTarget(forRelativePath: relativePath) + var isDirectory = false + guard backend.fileExists(atPath: target.url.path, isDirectory: &isDirectory) else { + throw FileSystemError.fileNotFound + } + do { + try backend.trashItem(at: target.url) + } catch { + throw FileSystemError.failedToDeleteFile(error) + } + encodingMap.keys + .filter { $0 == target.relativePath || $0.hasPrefix(target.relativePath + "/") } + .forEach { encodingMap.removeValue(forKey: $0) } + var deltas = removeSubtree(for: target.relativePath) + if deltas.isEmpty { + deltas = [isDirectory ? .folderRemoved(target.relativePath) : .fileRemoved(target.relativePath)] + } + publishFileSystemDeltas(deltas, source: .syntheticMutation) + } + + private func forgetTrackedPath(_ relativePath: String) { + encodingMap.removeValue(forKey: relativePath) + visitedPaths.remove(relativePath) + visitedItems.removeValue(forKey: relativePath) + } +} diff --git a/Sources/RepoPrompt/Infrastructure/FileSystem/FileSystemService+PathUtilities.swift b/Sources/RepoPromptCore/FileSystem/FileSystemService+PathUtilities.swift similarity index 98% rename from Sources/RepoPrompt/Infrastructure/FileSystem/FileSystemService+PathUtilities.swift rename to Sources/RepoPromptCore/FileSystem/FileSystemService+PathUtilities.swift index 97adeab8b..83738db99 100644 --- a/Sources/RepoPrompt/Infrastructure/FileSystem/FileSystemService+PathUtilities.swift +++ b/Sources/RepoPromptCore/FileSystem/FileSystemService+PathUtilities.swift @@ -1,6 +1,6 @@ import Foundation -extension FileSystemService { +package extension FileSystemService { // MARK: - Helpers func fullPath(forRelativePath relativePath: String) -> String { @@ -187,7 +187,7 @@ extension FileSystemService { // MARK: - FileManager extension -extension FileManager { +package extension FileManager { func isFolder(atPath path: String) -> Bool { var isDir: ObjCBool = false fileExists(atPath: path, isDirectory: &isDir) @@ -197,7 +197,7 @@ extension FileManager { // MARK: - URL extension -extension URL { +package extension URL { func relativePath(from base: URL) -> String { let basePath = (base.path as NSString).standardizingPath let filePath = (path as NSString).standardizingPath diff --git a/Sources/RepoPrompt/Infrastructure/FileSystem/FileSystemService+Testing.swift b/Sources/RepoPromptCore/FileSystem/FileSystemService+Testing.swift similarity index 51% rename from Sources/RepoPrompt/Infrastructure/FileSystem/FileSystemService+Testing.swift rename to Sources/RepoPromptCore/FileSystem/FileSystemService+Testing.swift index f49c7cb57..74ffc154e 100644 --- a/Sources/RepoPrompt/Infrastructure/FileSystem/FileSystemService+Testing.swift +++ b/Sources/RepoPromptCore/FileSystem/FileSystemService+Testing.swift @@ -1,106 +1,11 @@ -import CoreFoundation -import CoreServices import Foundation #if DEBUG extension FileSystemService { // MARK: - Testing Support - nonisolated static func deepCopiedEventPathForTesting(_ source: NSString) -> String? { - deepCopyEventPath(source as CFString) - } - - nonisolated static func buildOwnedFSEventPayloadForTesting( - pathObjects: [Any], - flags: [FSEventStreamEventFlags], - ids: [FSEventStreamEventId], - limit: Int? = nil - ) -> (paths: [String], flags: [FSEventStreamEventFlags], ids: [FSEventStreamEventId])? { - let safeCount = min(limit ?? pathObjects.count, pathObjects.count, flags.count, ids.count) - guard safeCount > 0 else { return nil } - - var copiedPaths: [String] = [] - var copiedFlags: [FSEventStreamEventFlags] = [] - var copiedIDs: [FSEventStreamEventId] = [] - copiedPaths.reserveCapacity(safeCount) - copiedFlags.reserveCapacity(safeCount) - copiedIDs.reserveCapacity(safeCount) - - for index in 0 ..< safeCount { - let copiedPath: String? = switch pathObjects[index] { - case let string as NSString: - deepCopyEventPath(string as CFString) - case let string as String: - deepCopySwiftString(string) - default: - nil - } - - guard let copiedPath else { continue } - copiedPaths.append(copiedPath) - copiedFlags.append(flags[index]) - copiedIDs.append(ids[index]) - } - - guard !copiedPaths.isEmpty else { return nil } - return (copiedPaths, copiedFlags, copiedIDs) - } - - nonisolated static func fseventCallbackEntryCountForTesting( - pathObjects: [AnyObject], - flags: [FSEventStreamEventFlags], - ids: [FSEventStreamEventId], - limit: Int? = nil - ) -> Int { - let safeCount = min(limit ?? pathObjects.count, pathObjects.count, flags.count, ids.count) - guard safeCount > 0 else { return 0 } - let cfArray = pathObjects as CFArray - let eventPaths = UnsafeMutableRawPointer(Unmanaged.passUnretained(cfArray).toOpaque()) - return flags.withUnsafeBufferPointer { flagBuffer in - guard let flagBase = flagBuffer.baseAddress else { return 0 } - return ids.withUnsafeBufferPointer { idBuffer in - guard let idBase = idBuffer.baseAddress else { return 0 } - return buildOwnedFSEventPayload( - numEvents: safeCount, - eventPaths: eventPaths, - eventFlags: flagBase, - eventIds: idBase - )?.entries.count ?? 0 - } - } - } - - nonisolated static func buildOwnedFSEventPayloadFromCFArrayForTesting( - pathObjects: [AnyObject], - flags: [FSEventStreamEventFlags], - ids: [FSEventStreamEventId], - limit: Int? = nil - ) -> (paths: [String], flags: [FSEventStreamEventFlags], ids: [FSEventStreamEventId])? { - let safeCount = min(limit ?? pathObjects.count, pathObjects.count, flags.count, ids.count) - guard safeCount > 0 else { return nil } - let cfArray = pathObjects as CFArray - let eventPaths = UnsafeMutableRawPointer(Unmanaged.passUnretained(cfArray).toOpaque()) - return flags.withUnsafeBufferPointer { flagBuffer in - guard let flagBase = flagBuffer.baseAddress else { return nil } - return ids.withUnsafeBufferPointer { idBuffer in - guard let idBase = idBuffer.baseAddress else { return nil } - guard let payload = buildOwnedFSEventPayload( - numEvents: safeCount, - eventPaths: eventPaths, - eventFlags: flagBase, - eventIds: idBase - ) else { return nil } - return ( - payload.entries.map(\.path), - payload.entries.map(\.flags), - payload.entries.map(\.id) - ) - } - } - } - func simulateFSEvents( - _ events: [(absolutePath: String, flags: FSEventStreamEventFlags, eventId: FSEventStreamEventId)] + _ events: [(absolutePath: String, flags: FileSystemWatchEventFlags, eventId: FileSystemWatchEventID)] ) async -> [FileSystemDelta] { // Clear any previous deltas processedFolders.removeAll() @@ -124,18 +29,18 @@ import Foundation /// Test-only method to get event ID coalescing state func getCoalescingState() -> ( - pendingScanTargets: [String: FSEventStreamEventId], - lastScannedEventIdByFolder: [String: FSEventStreamEventId] + pendingScanTargets: [String: FileSystemWatchEventID], + lastScannedEventIdByFolder: [String: FileSystemWatchEventID] ) { (pendingScanTargets, lastScannedEventIdByFolder) } func enqueuePendingRawEventsForTesting( - _ events: [(absolutePath: String, flags: FSEventStreamEventFlags, eventId: FSEventStreamEventId)] + _ events: [(absolutePath: String, flags: FileSystemWatchEventFlags, eventId: FileSystemWatchEventID)] ) { - let payload = FSEventCallbackPayload( + let payload = FileSystemWatchEventPayload( entries: events.map { event in - FSEventCallbackEntry(path: event.absolutePath, flags: event.flags, id: event.eventId) + FileSystemWatchEvent(path: event.absolutePath, flags: event.flags, id: event.eventId) } ) enqueueFSEventEntries(payload.entries) @@ -144,13 +49,13 @@ import Foundation @discardableResult func acceptWatcherPayloadForTesting( - _ events: [(absolutePath: String, flags: FSEventStreamEventFlags, eventId: FSEventStreamEventId)], + _ events: [(absolutePath: String, flags: FileSystemWatchEventFlags, eventId: FileSystemWatchEventID)], scheduleDrain: Bool = true ) -> FileSystemWatcherIngressMailbox.Watermark? { - watcherIngressMailbox.startAccepting() - let payload = FSEventCallbackPayload( + let acceptanceGeneration = watcherIngressMailbox.startAccepting() + let payload = FileSystemWatchEventPayload( entries: events.map { event in - FSEventCallbackEntry(path: event.absolutePath, flags: event.flags, id: event.eventId) + FileSystemWatchEvent(path: event.absolutePath, flags: event.flags, id: event.eventId) } ) let drain: (@Sendable () async -> Void)? = if scheduleDrain { @@ -158,7 +63,12 @@ import Foundation } else { nil } - return watcherIngressMailbox.accept(payload, lifecycleCorrelation: nil, scheduleDrain: drain) + return watcherIngressMailbox.accept( + payload, + acceptanceGeneration: acceptanceGeneration, + diagnosticContext: nil, + scheduleDrain: drain + ) } func watcherIngressMailboxSnapshotForTesting() -> FileSystemWatcherIngressMailbox.Snapshot { @@ -189,15 +99,15 @@ import Foundation } func isWatchingForChangesForTesting() -> Bool { - fseventStreamRef != nil + watcher?.isWatching == true } func watcherStateForTesting() -> ( pendingRawEventCount: Int, hasPendingOverflowRescan: Bool, overflowChangedIgnoreDirs: Set, - pendingScanTargets: [String: FSEventStreamEventId], - lastScannedEventIdByFolder: [String: FSEventStreamEventId], + pendingScanTargets: [String: FileSystemWatchEventID], + lastScannedEventIdByFolder: [String: FileSystemWatchEventID], lastVerifiedAtByFolder: [String: TimeInterval], fileEventCountSinceLastScan: [String: Int] ) { diff --git a/Sources/RepoPrompt/Infrastructure/FileSystem/FileSystemService+FSEvents.swift b/Sources/RepoPromptCore/FileSystem/FileSystemService+Watching.swift similarity index 73% rename from Sources/RepoPrompt/Infrastructure/FileSystem/FileSystemService+FSEvents.swift rename to Sources/RepoPromptCore/FileSystem/FileSystemService+Watching.swift index eee33d239..a633e8f9a 100644 --- a/Sources/RepoPrompt/Infrastructure/FileSystem/FileSystemService+FSEvents.swift +++ b/Sources/RepoPromptCore/FileSystem/FileSystemService+Watching.swift @@ -1,65 +1,65 @@ -import Combine -import CoreFoundation -import CoreServices import Dispatch import Foundation -struct FSEventCallbackEntry { - let path: String - let flags: FSEventStreamEventFlags - let id: FSEventStreamEventId -} - -struct FSEventCallbackPayload { - let entries: [FSEventCallbackEntry] - - var count: Int { - entries.count +extension FileSystemService { + package struct DetachedWatcherStop { + package let acceptedWatermark: FileSystemWatcherIngressMailbox.Watermark + package let ingressGeneration: UInt64 + package let lifecycleEpoch: UInt64 } -} -extension FileSystemService { // MARK: - Public watchers API - /// Returns ordered publications whenever changes or watcher progress are detected. - func publisherForChanges() -> AnyPublisher { - changePublisher.eraseToAnyPublisher() + /// Installs the sole ordered publication consumer for this root. + package nonisolated func subscribeToChanges( + _ handler: @escaping FileSystemDeltaPublicationHub.Handler + ) -> FileSystemDeltaPublicationSubscription { + publicationHub.subscribe(handler) } - /// Request to stop watching for changes. This tears down the FSEvent stream. - public func stopWatchingForChanges() { - stopFSEventStream() + package nonisolated func closeChangePublication() { + publicationHub.close() } - /// (Re)start the FSEvent stream if needed. - public func startWatchingForChanges() { - startFSEventStream() + /// Gracefully tears down the watcher only after every callback that returned + /// accepted has crossed the service publication boundary. + package func stopWatchingForChanges() async { + let detachedStop = detachWatcherAndCaptureAcceptedWatermark() + _ = await flushPendingEventsNow( + throughAcceptedWatcherWatermark: detachedStop.acceptedWatermark + ) + finishDetachedWatcherStop(detachedStop) } - public func fileExistsOnDisk(relativePath: String) -> Bool { + /// (Re)start the injected watcher if needed. + package func startWatchingForChanges() { + startWatcher() + } + + package func fileExistsOnDisk(relativePath: String) -> Bool { let absolutePath = fullPath(forRelativePath: relativePath) return fm.fileExists(atPath: absolutePath, isDirectory: nil) } - public func regularFileExistsOnDisk(relativePath rawRelativePath: String) -> Bool { - let lifecycleCorrelation = EditFlowPerf.currentLifecycleCorrelation - EditFlowPerf.lifecycleEvent( - EditFlowPerf.Lifecycle.Search.contentFreshnessRootEntered, + package func regularFileExistsOnDisk(relativePath rawRelativePath: String) -> Bool { + let lifecycleCorrelation = WorkspaceRuntimePerf.currentLifecycleCorrelation + WorkspaceRuntimePerf.lifecycleEvent( + WorkspaceRuntimePerf.Lifecycle.Search.contentFreshnessRootEntered, correlation: lifecycleCorrelation, - EditFlowPerf.Dimensions(rootToken: diagnosticRootToken.uuidString) + WorkspaceRuntimePerf.Dimensions(rootToken: diagnosticRootToken.uuidString) ) - let validationState = EditFlowPerf.begin(EditFlowPerf.Stage.Search.contentFreshnessValidationRootActorBody) + let validationState = WorkspaceRuntimePerf.begin(WorkspaceRuntimePerf.Stage.Search.contentFreshnessValidationRootActorBody) var outcome = "missing" defer { - EditFlowPerf.end( - EditFlowPerf.Stage.Search.contentFreshnessValidationRootActorBody, + WorkspaceRuntimePerf.end( + WorkspaceRuntimePerf.Stage.Search.contentFreshnessValidationRootActorBody, validationState, - EditFlowPerf.Dimensions(outcome: outcome, rootToken: diagnosticRootToken.uuidString) + WorkspaceRuntimePerf.Dimensions(outcome: outcome, rootToken: diagnosticRootToken.uuidString) ) - EditFlowPerf.lifecycleEvent( - EditFlowPerf.Lifecycle.Search.contentFreshnessRootReturned, + WorkspaceRuntimePerf.lifecycleEvent( + WorkspaceRuntimePerf.Lifecycle.Search.contentFreshnessRootReturned, correlation: lifecycleCorrelation, - EditFlowPerf.Dimensions(outcome: outcome, rootToken: diagnosticRootToken.uuidString) + WorkspaceRuntimePerf.Dimensions(outcome: outcome, rootToken: diagnosticRootToken.uuidString) ) } let relativePath = (rawRelativePath as NSString).standardizingPath.trimmingCharacters(in: CharacterSet(charactersIn: "/")) @@ -80,11 +80,11 @@ extension FileSystemService { return true } - public func catalogEligibleRegularFileExists(relativePath rawRelativePath: String) async -> Bool { + package func catalogEligibleRegularFileExists(relativePath rawRelativePath: String) async -> Bool { await catalogRegularFileEligibility(relativePath: rawRelativePath).isEligible } - func catalogFolderIsDiscoverable(relativePath rawRelativePath: String) async -> Bool { + package func catalogFolderIsDiscoverable(relativePath rawRelativePath: String) async -> Bool { let relativePath = (rawRelativePath as NSString).standardizingPath.trimmingCharacters(in: CharacterSet(charactersIn: "/")) guard !relativePath.isEmpty, relativePath != "..", !relativePath.hasPrefix("../") else { return false } let absolutePath = fullPath(forRelativePath: relativePath) @@ -105,7 +105,7 @@ extension FileSystemService { return !isIgnoredPrefixCheck(relativePath: relativePath, isDirectory: true) } - public func catalogRegularFileEligibility(relativePath rawRelativePath: String) async -> CatalogRegularFileEligibility { + package func catalogRegularFileEligibility(relativePath rawRelativePath: String) async -> CatalogRegularFileEligibility { let relativePath = (rawRelativePath as NSString).standardizingPath.trimmingCharacters(in: CharacterSet(charactersIn: "/")) guard !relativePath.isEmpty, !relativePath.hasPrefix("../"), relativePath != ".." else { return .ineligible(.invalidRelativePath) @@ -142,7 +142,7 @@ extension FileSystemService { return isIgnored ? .ineligible(.ignored) : .eligible } - func registerExplicitlyManagedRegularFile(relativePath rawRelativePath: String) async -> CatalogRegularFileEligibility { + package func registerExplicitlyManagedRegularFile(relativePath rawRelativePath: String) async -> CatalogRegularFileEligibility { let eligibility = await catalogRegularFileEligibility(relativePath: rawRelativePath) switch eligibility { case .eligible, .ineligible(.ignored): @@ -155,7 +155,7 @@ extension FileSystemService { return eligibility } - func pathContainsSymlinkComponent(relativePath: String) -> Bool { + package func pathContainsSymlinkComponent(relativePath: String) -> Bool { var current = rootURL for component in relativePath.split(separator: "/") { current.appendPathComponent(String(component)) @@ -166,11 +166,11 @@ extension FileSystemService { return false } - nonisolated func captureAcceptedWatcherWatermark() -> FileSystemWatcherIngressMailbox.Watermark { + package nonisolated func captureAcceptedWatcherWatermark() -> FileSystemWatcherIngressMailbox.Watermark { watcherIngressMailbox.captureAcceptedWatermark() } - public func flushPendingEventsNow() async { + package func flushPendingEventsNow() async { _ = await flushPendingEventsNow(throughAcceptedWatcherWatermark: captureAcceptedWatcherWatermark()) } @@ -179,7 +179,7 @@ extension FileSystemService { /// Later callbacks may already have joined an actor-visible batch or an overflow /// sentinel, so this is intentionally a lower-bound barrier rather than a strict /// exclusion boundary. It never returns before the captured cut is published. - func flushPendingEventsNow( + package func flushPendingEventsNow( throughAcceptedWatcherWatermark target: FileSystemWatcherIngressMailbox.Watermark ) async -> UInt64 { drainAcceptedWatcherIngressMailboxPayloads(through: target) @@ -208,7 +208,7 @@ extension FileSystemService { pendingFSEvents.count } - public func lastPublishedDeltaCoalescingDiagnosticsForTesting() -> PublishedDeltaCoalescingDiagnostics? { + func lastPublishedDeltaCoalescingDiagnosticsForTesting() -> PublishedDeltaCoalescingDiagnostics? { lastPublishedDeltaCoalescingDiagnostics } @@ -217,230 +217,82 @@ extension FileSystemService { } #endif - func coalescedPublishableDeltas(from deltas: [FileSystemDelta]) -> [FileSystemDelta] { + package func coalescedPublishableDeltas(from deltas: [FileSystemDelta]) -> [FileSystemDelta] { FileSystemDeltaPreparation.coalesce(deltas, inRoot: canonicalRootPath) } - // MARK: - FSEvent Setup - - func startFSEventStream() { - guard fseventStreamRef == nil else { return } - - watcherIngressMailbox.startAccepting() - selfPointer = Unmanaged.passRetained(self).toOpaque() - - var streamContext = FSEventStreamContext( - version: 0, - info: selfPointer, - retain: nil, - release: nil, - copyDescription: nil - ) - - let flags = FSEventStreamCreateFlags( - kFSEventStreamCreateFlagUseCFTypes - | kFSEventStreamCreateFlagFileEvents - | kFSEventStreamCreateFlagNoDefer - ) - - fseventStreamRef = FSEventStreamCreate( - kCFAllocatorDefault, - Self.fseventCallback, - &streamContext, - [path] as CFArray, - FSEventStreamEventId(kFSEventStreamEventIdSinceNow), - 0, - flags - ) - - guard let stream = fseventStreamRef else { - // Release the retained self if creation failed to avoid leaks - if let ptr = selfPointer { - Unmanaged.fromOpaque(ptr).release() - selfPointer = nil + // MARK: - Watcher Setup + + package func startWatcher() { + guard watcher == nil else { return } + watcherLifecycleEpoch &+= 1 + let acceptanceGeneration = watcherIngressMailbox.startAccepting() + let watcher = watcherFactory.makeWatcher(path: path) + guard watcher.start(eventHandler: { [weak self] payload in + guard let self else { return } + let lifecycleCorrelation = WorkspaceRuntimePerf.makeLifecycleCorrelationIfActive() + let diagnosticContext = lifecycleCorrelation.map { + FileSystemDiagnosticContext(correlationID: $0.id) } - print("Failed to create FSEventStream for \(path)") - return - } - - FSEventStreamSetDispatchQueue(stream, .main) - if !FSEventStreamStart(stream) { - // Clean up to avoid leaks - FSEventStreamInvalidate(stream) - FSEventStreamRelease(stream) - fseventStreamRef = nil - if let ptr = selfPointer { - Unmanaged.fromOpaque(ptr).release() - selfPointer = nil + let acceptedWatermark = watcherIngressMailbox.accept( + payload, + acceptanceGeneration: acceptanceGeneration, + diagnosticContext: diagnosticContext + ) { [weak self] in + await self?.drainAcceptedWatcherIngressMailbox() } - print("Failed to start FSEventStream for \(path)") + guard let acceptedWatermark else { return } + WorkspaceRuntimePerf.lifecycleEvent( + WorkspaceRuntimePerf.Lifecycle.FileSystem.callbackAccepted, + correlation: lifecycleCorrelation, + WorkspaceRuntimePerf.Dimensions( + sourceItemCount: payload.count, + rootToken: diagnosticRootToken.uuidString, + ingressSequence: acceptedWatermark.rawValue + ) + ) + }) else { + resetWatcherIngressState() return } - fileSystemDebugLog("FSEventStream started for path: \(path)") + self.watcher = watcher + fileSystemDebugLog("Filesystem watcher started for path: \(path)") } - func stopFSEventStream() { - if let stream = fseventStreamRef { - FSEventStreamStop(stream) - FSEventStreamFlushSync(stream) - FSEventStreamInvalidate(stream) - FSEventStreamRelease(stream) - fseventStreamRef = nil - - if let ptr = selfPointer { - Unmanaged.fromOpaque(ptr).release() - selfPointer = nil - } - - fileSystemDebugLog("FSEventStream stopped for path: \(path)") - } else { - fileSystemDebugLog("stream could not be stopped") - } - - resetWatcherIngressState() - } - - nonisolated static func deepCopySwiftString(_ source: String) -> String { - String(decoding: Array(source.utf8), as: UTF8.self) - } - - nonisolated static func deepCopyEventPath(_ source: CFString) -> String? { - let length = CFStringGetLength(source) - if length == 0 { return "" } - - let utf8Encoding = CFStringBuiltInEncodings.UTF8.rawValue - if let directUTF8 = CFStringGetCStringPtr(source, utf8Encoding) { - return String(cString: directUTF8) - } - let maxBufferSize = max(CFStringGetMaximumSizeForEncoding(length, utf8Encoding) + 1, 1) - var utf8Buffer = [CChar](repeating: 0, count: maxBufferSize) - let copiedUTF8 = utf8Buffer.withUnsafeMutableBufferPointer { buffer in - CFStringGetCString(source, buffer.baseAddress, buffer.count, utf8Encoding) - } - if copiedUTF8 { - return String(cString: utf8Buffer) - } - - var utf16Buffer = [UniChar](repeating: 0, count: length) - CFStringGetCharacters( - source, - CFRange(location: 0, length: length), - &utf16Buffer + /// Detaches the platform source and closes admission while preserving all work + /// whose callback already returned accepted. + package func detachWatcherAndCaptureAcceptedWatermark() -> DetachedWatcherStop { + let detachedStop = DetachedWatcherStop( + acceptedWatermark: watcherIngressMailbox.stopAccepting(), + ingressGeneration: watcherIngressGeneration, + lifecycleEpoch: watcherLifecycleEpoch ) - return String(utf16CodeUnits: utf16Buffer, count: utf16Buffer.count) - } - - nonisolated static func buildOwnedFSEventPayload( - numEvents: Int, - eventPaths: UnsafeMutableRawPointer, - eventFlags: UnsafePointer, - eventIds: UnsafePointer - ) -> FSEventCallbackPayload? { - let cfArray = Unmanaged.fromOpaque(eventPaths).takeUnretainedValue() - let safeCount = min(numEvents, CFArrayGetCount(cfArray)) - guard safeCount > 0 else { return nil } - - var entries: [FSEventCallbackEntry] = [] - entries.reserveCapacity(safeCount) - - for index in 0 ..< safeCount { - guard let rawValue = CFArrayGetValueAtIndex(cfArray, index) else { continue } - let cfObject = unsafeBitCast(rawValue, to: CFTypeRef.self) - let copiedPath: String? - if CFGetTypeID(cfObject) == CFStringGetTypeID() { - let cfString = unsafeBitCast(rawValue, to: CFString.self) - copiedPath = deepCopyEventPath(cfString) - } else if let string = cfObject as? String { - copiedPath = deepCopySwiftString(string) - } else { - #if DEBUG - if enableDebugLogging { - print("DEBUG: Dropping unexpected FSEvent path payload at index \(index): \(type(of: cfObject))") - } - #endif - copiedPath = nil - } - - guard let copiedPath else { continue } - entries.append( - FSEventCallbackEntry( - path: copiedPath, - flags: eventFlags[index], - id: eventIds[index] - ) - ) + if let watcher { + watcher.stop() + self.watcher = nil + fileSystemDebugLog("Filesystem watcher stopped for path: \(path)") } - - guard !entries.isEmpty else { return nil } - return FSEventCallbackPayload(entries: entries) + return detachedStop } - /// The static callback that FSEvents uses to report changes. We hand off to Task to enter the actor context. - static let fseventCallback: FSEventStreamCallback = { - _, context, numEvents, eventPaths, eventFlags, eventIds in - // Context must be valid - guard let context else { return } - let service = Unmanaged.fromOpaque(context).takeUnretainedValue() - - let count = Int(numEvents) - guard count > 0 else { return } - - // Although these are non-optional in the API, guard against unexpected null pointers defensively - if Int(bitPattern: eventPaths) == 0 { return } - if Int(bitPattern: eventFlags) == 0 { return } - if Int(bitPattern: eventIds) == 0 { return } - - guard let payload = buildOwnedFSEventPayload( - numEvents: count, - eventPaths: eventPaths, - eventFlags: eventFlags, - eventIds: eventIds - ) else { return } - - #if DEBUG - if payload.count != count { - print("DEBUG: FSEvents vector length mismatch. numEvents=\(count), payloadCount=\(payload.count)") - } - - // Log raw FSEvents as they arrive - if enableDebugLogging { - print("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━") - print("🔔 RAW FSEVENTS CALLBACK: \(payload.count) events") - for (index, entry) in payload.entries.enumerated() { - print(" [\(index)] path: \(entry.path)") - print(" flags: \(formatFSEventFlags(entry.flags))") - print(" eventId: \(entry.id)") - } - print("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━") - } - #endif - - let lifecycleCorrelation = EditFlowPerf.makeLifecycleCorrelationIfActive() - let acceptedWatermark = service.watcherIngressMailbox.accept( - payload, - lifecycleCorrelation: lifecycleCorrelation - ) { [weak service] in - await service?.drainAcceptedWatcherIngressMailbox() + /// Destructive cleanup is valid only after `flushPendingEventsNow(through:)`. + package func finishDetachedWatcherStop(_ detachedStop: DetachedWatcherStop) { + guard watcher == nil, + watcherIngressGeneration == detachedStop.ingressGeneration, + watcherLifecycleEpoch == detachedStop.lifecycleEpoch + else { + return } - guard let acceptedWatermark else { return } - EditFlowPerf.lifecycleEvent( - EditFlowPerf.Lifecycle.FileSystem.callbackAccepted, - correlation: lifecycleCorrelation, - EditFlowPerf.Dimensions( - sourceItemCount: payload.count, - rootToken: service.diagnosticRootToken.uuidString, - ingressSequence: acceptedWatermark.rawValue - ) - ) + resetWatcherIngressState() } // MARK: - Core event coalescing & handling - func drainAcceptedWatcherIngressMailbox() async { + package func drainAcceptedWatcherIngressMailbox() async { drainAcceptedWatcherIngressMailboxPayloads() } - func drainAcceptedWatcherIngressMailboxPayloads( + package func drainAcceptedWatcherIngressMailboxPayloads( through target: FileSystemWatcherIngressMailbox.Watermark? = nil ) { while let payload = watcherIngressMailbox.takeNextAcceptedPayload(through: target) { @@ -448,11 +300,13 @@ extension FileSystemService { } } - func enqueueAcceptedWatcherPayload(_ payload: FileSystemWatcherIngressMailbox.AcceptedPayload) { - EditFlowPerf.lifecycleEvent( - EditFlowPerf.Lifecycle.FileSystem.serviceEnqueueEntered, - correlation: payload.lifecycleCorrelation, - EditFlowPerf.Dimensions( + package func enqueueAcceptedWatcherPayload(_ payload: FileSystemWatcherIngressMailbox.AcceptedPayload) { + WorkspaceRuntimePerf.lifecycleEvent( + WorkspaceRuntimePerf.Lifecycle.FileSystem.serviceEnqueueEntered, + correlation: payload.diagnosticContext.map { + WorkspaceRuntimePerf.LifecycleCorrelation(id: $0.correlationID) + }, + WorkspaceRuntimePerf.Dimensions( sourceItemCount: payload.rawEntryCount, rootToken: diagnosticRootToken.uuidString, queueDepth: pendingFSEvents.count, @@ -464,7 +318,7 @@ extension FileSystemService { case let .entries(entries): enqueueFSEventEntries(entries, acceptedHighWatermark: payload.acceptedHighWatermark) case let .overflowRootRescan(highestEventID, changedIgnoreAbsolutePaths): - overflowChangedIgnoreDirs.formUnion(ignoreChangeDirs(in: changedIgnoreAbsolutePaths.map { ($0, 0, 0) })) + overflowChangedIgnoreDirs.formUnion(ignoreChangeDirs(in: changedIgnoreAbsolutePaths.map { ($0, [], 0) })) collapsePendingEventsToRootRescan( upTo: highestEventID, acceptedHighWatermark: payload.acceptedHighWatermark @@ -473,8 +327,8 @@ extension FileSystemService { scheduleCoalescingIfNeeded() } - func enqueueFSEventEntries( - _ entries: [FSEventCallbackEntry], + package func enqueueFSEventEntries( + _ entries: [FileSystemWatchEvent], acceptedHighWatermark: FileSystemWatcherIngressMailbox.Watermark? = nil ) { guard !entries.isEmpty else { return } @@ -511,7 +365,7 @@ extension FileSystemService { } } - func scheduleCoalescingIfNeeded() { + package func scheduleCoalescingIfNeeded() { guard coalescingTask == nil, !pendingFSEvents.isEmpty else { return } coalescingTask = Task { [weak self] in do { @@ -524,20 +378,20 @@ extension FileSystemService { } } - func scheduledCoalescingDelayDidFinish() { + package func scheduledCoalescingDelayDidFinish() { coalescingTask = nil if !startProcessingPendingWatcherBatchIfNeeded(), !pendingFSEvents.isEmpty { scheduleCoalescingIfNeeded() } } - func cancelScheduledCoalescingDelay() { + package func cancelScheduledCoalescingDelay() { coalescingTask?.cancel() coalescingTask = nil } @discardableResult - func startProcessingPendingWatcherBatchIfNeeded() -> Bool { + package func startProcessingPendingWatcherBatchIfNeeded() -> Bool { guard watcherBatchProcessingTask == nil else { return true } let batch = takePendingFSEventsForProcessing() guard !batch.isEmpty || batch.watcherAcceptedHighWatermark != nil else { return false } @@ -551,7 +405,7 @@ extension FileSystemService { return true } - func processWatcherBatch(_ batch: PendingFSEventBatch, token: UInt64) async { + package func processWatcherBatch(_ batch: PendingFSEventBatch, token: UInt64) async { #if DEBUG if let watcherBatchWillProcessHandler { await watcherBatchWillProcessHandler() @@ -565,7 +419,7 @@ extension FileSystemService { watcherBatchProcessingDidFinish(token: token) } - func watcherBatchProcessingDidFinish(token: UInt64) { + package func watcherBatchProcessingDidFinish(token: UInt64) { guard watcherBatchProcessingToken == token else { return } watcherBatchProcessingTask = nil watcherBatchProcessingToken = nil @@ -574,8 +428,8 @@ extension FileSystemService { } } - func collapsePendingEventsToRootRescan( - upTo eventID: FSEventStreamEventId, + package func collapsePendingEventsToRootRescan( + upTo eventID: FileSystemWatchEventID, acceptedHighWatermark: FileSystemWatcherIngressMailbox.Watermark? = nil ) { overflowChangedIgnoreDirs.formUnion(ignoreChangeDirs(in: pendingFSEvents)) @@ -588,7 +442,7 @@ extension FileSystemService { hasPendingOverflowRescan = true } - func takePendingFSEventsForProcessing() -> PendingFSEventBatch { + package func takePendingFSEventsForProcessing() -> PendingFSEventBatch { let batch = PendingFSEventBatch( events: pendingFSEvents, watcherAcceptedHighWatermark: pendingWatcherAcceptedHighWatermark, @@ -602,8 +456,8 @@ extension FileSystemService { return batch } - func ignoreChangeDirs( - in events: [(String, FSEventStreamEventFlags, FSEventStreamEventId)] + package func ignoreChangeDirs( + in events: [(String, FileSystemWatchEventFlags, FileSystemWatchEventID)] ) -> Set { var dirs = Set() for (absolutePath, _, _) in events { @@ -614,8 +468,8 @@ extension FileSystemService { return dirs } - func resetWatcherIngressState() { - watcherIngressMailbox.stopAcceptingAndDiscardPending() + package func resetWatcherIngressState() { + watcherIngressMailbox.discardPendingAndCancelDrain() watcherIngressGeneration &+= 1 cancelScheduledCoalescingDelay() watcherBatchProcessingTask?.cancel() @@ -630,101 +484,77 @@ extension FileSystemService { fileEventCountSinceLastScan.removeAll(keepingCapacity: false) } - func watcherBatchBelongsToCurrentIngressGeneration(_ batch: PendingFSEventBatch) -> Bool { + package func watcherBatchBelongsToCurrentIngressGeneration(_ batch: PendingFSEventBatch) -> Bool { guard let generation = batch.watcherIngressGeneration else { return true } return generation == watcherIngressGeneration } - // MARK: - FSEvents Flag Parsing + // MARK: - Filesystem watcher flag parsing #if DEBUG - /// Format FSEventStreamEventFlags into a human-readable string for debugging - static func formatFSEventFlags(_ flags: FSEventStreamEventFlags) -> String { - let raw = UInt32(flags) - var parts: [String] = [] - - func check(_ flag: Int, _ name: String) { - if (raw & UInt32(flag)) != 0 { parts.append(name) } - } - - check(kFSEventStreamEventFlagItemCreated, "Created") - check(kFSEventStreamEventFlagItemRemoved, "Removed") - check(kFSEventStreamEventFlagItemRenamed, "Renamed") - check(kFSEventStreamEventFlagItemModified, "Modified") - check(kFSEventStreamEventFlagItemInodeMetaMod, "InodeMeta") - check(kFSEventStreamEventFlagItemFinderInfoMod, "FinderInfo") - check(kFSEventStreamEventFlagItemChangeOwner, "OwnerChange") - check(kFSEventStreamEventFlagItemXattrMod, "Xattr") - check(kFSEventStreamEventFlagItemIsFile, "IsFile") - check(kFSEventStreamEventFlagItemIsDir, "IsDir") - check(kFSEventStreamEventFlagItemIsSymlink, "IsSymlink") - check(kFSEventStreamEventFlagMustScanSubDirs, "MustScanSubDirs") - check(kFSEventStreamEventFlagUserDropped, "UserDropped") - check(kFSEventStreamEventFlagKernelDropped, "KernelDropped") - check(kFSEventStreamEventFlagRootChanged, "RootChanged") - - let flagStr = parts.isEmpty ? "None" : parts.joined(separator: "|") - return "\(raw) [\(flagStr)]" + /// Format semantic watcher flags into a human-readable string for debugging. + static func formatFSEventFlags(_ flags: FileSystemWatchEventFlags) -> String { + let names: [(FileSystemWatchEventFlags, String)] = [ + (.itemCreated, "Created"), + (.itemRemoved, "Removed"), + (.itemRenamed, "Renamed"), + (.contentChanged, "ContentChanged"), + (.metadataChanged, "MetadataChanged"), + (.itemIsFile, "IsFile"), + (.itemIsDirectory, "IsDir"), + (.itemIsSymlink, "IsSymlink"), + (.mustScanSubdirectories, "MustScanSubDirs"), + (.droppedEvents, "DroppedEvents"), + (.rootChanged, "RootChanged") + ] + let labels = names.compactMap { flags.contains($0.0) ? $0.1 : nil } + return labels.isEmpty ? "None" : labels.joined(separator: " | ") } #endif - /// Parsed representation of FSEvents flags for cleaner event handling - struct ParsedEvent { + /// Parsed representation of watcher flags for cleaner event handling. + package struct ParsedEvent { let isDir: Bool let isFile: Bool - let isCreated: Bool let isRemoved: Bool let isRenamed: Bool - let isContentChange: Bool // data or xattrs changed - let isMetadataChange: Bool // inode, finder info, owner - - // Reliability signals that require more aggressive handling - let mustScanSubdirs: Bool // kFSEventStreamEventFlagMustScanSubDirs - let userOrKernelDropped: Bool // events were dropped - let rootChanged: Bool // mount/unmount or root moved + let isContentChange: Bool + let isMetadataChange: Bool + let mustScanSubdirs: Bool + let userOrKernelDropped: Bool + let rootChanged: Bool - /// True if this event requires us to scan directories for correctness var requiresAggressiveScan: Bool { mustScanSubdirs || userOrKernelDropped || rootChanged } } - /// Parse FSEventStreamEventFlags into a structured representation - static func parseEventFlags( - _ flags: FSEventStreamEventFlags, + /// Parse semantic watcher flags into a structured representation. + package static func parseEventFlags( + _ flags: FileSystemWatchEventFlags, isDirFallback: Bool ) -> ParsedEvent { - let raw = UInt32(flags) - - /// FSEvents constants are Int on macOS, convert to UInt32 for bitwise comparison - func has(_ flag: Int) -> Bool { - (raw & UInt32(flag)) != 0 - } - - let isDirFlag = has(kFSEventStreamEventFlagItemIsDir) - let isFileFlag = has(kFSEventStreamEventFlagItemIsFile) - + let isDirFlag = flags.contains(.itemIsDirectory) + let isFileFlag = flags.contains(.itemIsFile) return ParsedEvent( isDir: isDirFlag || (!isFileFlag && isDirFallback), isFile: isFileFlag || (!isDirFlag && !isDirFallback), - isCreated: has(kFSEventStreamEventFlagItemCreated), - isRemoved: has(kFSEventStreamEventFlagItemRemoved), - isRenamed: has(kFSEventStreamEventFlagItemRenamed), - isContentChange: has(kFSEventStreamEventFlagItemModified) || has(kFSEventStreamEventFlagItemXattrMod), - isMetadataChange: has(kFSEventStreamEventFlagItemInodeMetaMod) || - has(kFSEventStreamEventFlagItemFinderInfoMod) || - has(kFSEventStreamEventFlagItemChangeOwner), - mustScanSubdirs: has(kFSEventStreamEventFlagMustScanSubDirs), - userOrKernelDropped: has(kFSEventStreamEventFlagUserDropped) || has(kFSEventStreamEventFlagKernelDropped), - rootChanged: has(kFSEventStreamEventFlagRootChanged) + isCreated: flags.contains(.itemCreated), + isRemoved: flags.contains(.itemRemoved), + isRenamed: flags.contains(.itemRenamed), + isContentChange: flags.contains(.contentChanged), + isMetadataChange: flags.contains(.metadataChanged), + mustScanSubdirs: flags.contains(.mustScanSubdirectories), + userOrKernelDropped: flags.contains(.droppedEvents), + rootChanged: flags.contains(.rootChanged) ) } // MARK: - Temp File Detection for Atomic Saves /// Common temp file suffixes used by editors for atomic saves - static let tempNameSuffixes: [String] = [ + package static let tempNameSuffixes: [String] = [ "~", // vim backup ".tmp", ".temp", ".swp", ".swo", ".swx", // vim swap @@ -733,14 +563,14 @@ extension FileSystemService { ] /// Common temp file prefixes used by editors - static let tempNamePrefixes: [String] = [ + package static let tempNamePrefixes: [String] = [ ".#", // Emacs "._", // macOS resource fork "~$" // MS Office ] /// Check if a path looks like a temporary file used for atomic saves - static func isTempSaveName(_ relPath: String) -> Bool { + package static func isTempSaveName(_ relPath: String) -> Bool { let name = (relPath as NSString).lastPathComponent.lowercased() for suffix in tempNameSuffixes where name.hasSuffix(suffix) { @@ -760,19 +590,19 @@ extension FileSystemService { /// Get current time for safety-net interval tracking @inline(__always) - func currentTime() -> TimeInterval { - CFAbsoluteTimeGetCurrent() + package func currentTime() -> TimeInterval { + Date().timeIntervalSinceReferenceDate } /// Record that a folder was just verified via directory scan - func recordFolderVerified(_ folder: String) { + package func recordFolderVerified(_ folder: String) { lastVerifiedAtByFolder[folder] = currentTime() fileEventCountSinceLastScan[folder] = 0 } /// Check if a folder should receive a safety-net scan based on event count and time /// Returns true if we should schedule a scan - func shouldScheduleSafetyNetScan(for parent: String) -> Bool { + package func shouldScheduleSafetyNetScan(for parent: String) -> Bool { guard !parent.isEmpty else { return false } // Increment event count @@ -789,7 +619,7 @@ extension FileSystemService { return stale || highChurn } - func handleBatchedEvents( + package func handleBatchedEvents( _ batch: PendingFSEventBatch, testMode: Bool = false ) async -> [FileSystemDelta]? { @@ -823,13 +653,13 @@ extension FileSystemService { #endif var foldersToScan = Set() - var folderMaxEventId: [String: FSEventStreamEventId] = [:] // Track max event ID per folder + var folderMaxEventId: [String: FileSystemWatchEventID] = [:] // Track max event ID per folder var immediateModifications: [FileSystemDelta] = [] var changedIgnoreDirs = overflowChangedIgnoreDirs overflowChangedIgnoreDirs.removeAll(keepingCapacity: false) /// Helper to track folder with its event ID - func trackFolder(_ folder: String, eventId: FSEventStreamEventId) { + func trackFolder(_ folder: String, eventId: FileSystemWatchEventID) { foldersToScan.insert(folder) folderMaxEventId[folder] = max(folderMaxEventId[folder] ?? 0, eventId) } @@ -903,7 +733,7 @@ extension FileSystemService { #if DEBUG if isTestMode, Self.enableDebugLogging { - let isRename = (flags & FSEventStreamEventFlags(kFSEventStreamEventFlagItemRenamed)) != 0 + let isRename = flags.contains(.itemRenamed) print("DEBUG: Processing event for '\(relPath)' - isKnown=\(isKnown), isRename=\(isRename), shouldIgnore=\(shouldIgnore), isIgnoreFile=\(isIgnoreFile(relPath))") } #endif @@ -942,14 +772,11 @@ extension FileSystemService { // Debug logging for flag analysis if isTestMode, Self.enableDebugLogging, relPath.contains("file.txt") { print("DEBUG: Flags for \(relPath): \(flags)") - print(" ItemModified: \(flags & FSEventStreamEventFlags(kFSEventStreamEventFlagItemModified))") - print(" ItemCreated: \(flags & FSEventStreamEventFlags(kFSEventStreamEventFlagItemCreated))") - print(" ItemRemoved: \(flags & FSEventStreamEventFlags(kFSEventStreamEventFlagItemRemoved))") - print(" ItemRenamed: \(flags & FSEventStreamEventFlags(kFSEventStreamEventFlagItemRenamed))") - print(" ItemInodeMetaMod: \(flags & FSEventStreamEventFlags(kFSEventStreamEventFlagItemInodeMetaMod))") - print(" ItemFinderInfoMod: \(flags & FSEventStreamEventFlags(kFSEventStreamEventFlagItemFinderInfoMod))") - print(" ItemChangeOwner: \(flags & FSEventStreamEventFlags(kFSEventStreamEventFlagItemChangeOwner))") - print(" ItemXattrMod: \(flags & FSEventStreamEventFlags(kFSEventStreamEventFlagItemXattrMod))") + print(" ContentChanged: \(flags.contains(.contentChanged))") + print(" ItemCreated: \(flags.contains(.itemCreated))") + print(" ItemRemoved: \(flags.contains(.itemRemoved))") + print(" ItemRenamed: \(flags.contains(.itemRenamed))") + print(" MetadataChanged: \(flags.contains(.metadataChanged))") print(" Calculated modified: \(modified)") print(" Calculated removed: \(removed)") print(" Is in visitedPaths: \(visitedPaths.contains(relPath))") @@ -1342,7 +1169,6 @@ extension FileSystemService { return testMode ? [] : nil } - let publishSignpost = FileSystemPublishPerf.begin("coalesceAndPublishFileSystemDeltas") let publishableDeltas = coalescedPublishableDeltas(from: allDeltas) #if DEBUG lastPublishedDeltaCoalescingDiagnostics = PublishedDeltaCoalescingDiagnostics( @@ -1386,7 +1212,6 @@ extension FileSystemService { watcherAcceptedWatermark: batch.watcherAcceptedHighWatermark ) } - FileSystemPublishPerf.end("coalesceAndPublishFileSystemDeltas", publishSignpost) // Return the published deltas in test mode. return testMode ? publishableDeltas : nil diff --git a/Sources/RepoPrompt/Infrastructure/FileSystem/FileSystemService.swift b/Sources/RepoPromptCore/FileSystem/FileSystemService.swift similarity index 57% rename from Sources/RepoPrompt/Infrastructure/FileSystem/FileSystemService.swift rename to Sources/RepoPromptCore/FileSystem/FileSystemService.swift index 6705b73f7..15ffaefe5 100644 --- a/Sources/RepoPrompt/Infrastructure/FileSystem/FileSystemService.swift +++ b/Sources/RepoPromptCore/FileSystem/FileSystemService.swift @@ -1,36 +1,21 @@ -import Combine -import CoreServices import Dispatch import Foundation -#if DEBUG || EDIT_FLOW_PERF - import os -#endif -import CoreFoundation -import Cuchardet -import UniversalCharsetDetection -#if os(macOS) || os(iOS) || os(tvOS) || os(watchOS) - import Darwin -#else - import Glibc -#endif - -actor FileSystemService { + +package actor FileSystemService { // Internal for FileSystemService same-target extensions only. // These are not public API; preserve actor isolation when accessing them. - let fileManager = FileManager.default - nonisolated let diagnosticRootToken = UUID() - nonisolated let watcherIngressMailbox: FileSystemWatcherIngressMailbox - static let maxPendingRawEvents = 50000 - static let overflowRescanEventFlags = FSEventStreamEventFlags( - kFSEventStreamEventFlagMustScanSubDirs | kFSEventStreamEventFlagRootChanged - ) + package let fileManager = FileManager.default + package nonisolated let diagnosticRootToken = UUID() + package nonisolated let watcherIngressMailbox: FileSystemWatcherIngressMailbox + package static let maxPendingRawEvents = 50000 + package static let overflowRescanEventFlags = FileSystemWatchEventFlags.overflowRootRescan #if DEBUG /// Static flag to enable verbose debug logging (default: false) static var enableDebugLogging = false #endif - func fileSystemDebugLog(_ message: @autoclosure () -> String) { + package func fileSystemDebugLog(_ message: @autoclosure () -> String) { #if DEBUG guard Self.enableDebugLogging else { return } print(message()) @@ -38,7 +23,7 @@ actor FileSystemService { } @discardableResult - func publishFileSystemDeltas( + package func publishFileSystemDeltas( _ deltas: [FileSystemDelta], source: FileSystemDeltaPublicationSource, watcherAcceptedWatermark: FileSystemWatcherIngressMailbox.Watermark? = nil @@ -52,35 +37,26 @@ actor FileSystemService { if let watcherAcceptedWatermark { lastPublishedWatcherAcceptedWatermark = max(lastPublishedWatcherAcceptedWatermark, watcherAcceptedWatermark) } + let publicationCorrelation = WorkspaceRuntimePerf.makeLifecycleCorrelationIfActive() let publication = FileSystemDeltaPublication( servicePublicationSequence: servicePublicationSequence, source: source, watcherAcceptedWatermark: watcherAcceptedWatermark, + correlationID: publicationCorrelation?.id, deltas: deltas ) - #if DEBUG || EDIT_FLOW_PERF - let publicationCorrelation = EditFlowPerf.makeLifecycleCorrelationIfActive() - EditFlowPerf.lifecycleEvent( - EditFlowPerf.Lifecycle.FileSystem.servicePublish, - correlation: publicationCorrelation, - EditFlowPerf.Dimensions( - status: source.rawValue, - changeCount: deltas.count, - rootToken: diagnosticRootToken.uuidString, - ingressSequence: watcherAcceptedWatermark?.rawValue, - barrierSequence: servicePublicationSequence - ) + WorkspaceRuntimePerf.lifecycleEvent( + WorkspaceRuntimePerf.Lifecycle.FileSystem.servicePublish, + correlation: publicationCorrelation, + WorkspaceRuntimePerf.Dimensions( + status: source.rawValue, + changeCount: deltas.count, + rootToken: diagnosticRootToken.uuidString, + ingressSequence: watcherAcceptedWatermark?.rawValue, + barrierSequence: servicePublicationSequence ) - guard let publicationCorrelation else { - changePublisher.send(publication) - return servicePublicationSequence - } - EditFlowPerf.$currentFileSystemPublicationCorrelation.withValue(publicationCorrelation) { - changePublisher.send(publication) - } - #else - changePublisher.send(publication) - #endif + ) + _ = publicationHub.publish(publication) return servicePublicationSequence } @@ -117,130 +93,135 @@ actor FileSystemService { #endif /// Tracks paths we know about, to detect additions/removals - var visitedPaths = Set() + package var visitedPaths = Set() /// True => directory, False => file - var visitedItems = [String: Bool]() - - /// The FSEvent stream reference - var fseventStreamRef: FSEventStreamRef? - - /// Publishes ordered delta envelopes whenever changes or watcher progress occur. - var changePublisher = PassthroughSubject() - var nextServicePublicationSequence: UInt64 = 0 - var lastServicePublicationSequence: UInt64 = 0 - var lastPublishedWatcherAcceptedWatermark = FileSystemWatcherIngressMailbox.Watermark.zero + package var visitedItems = [String: Bool]() + + /// Injected platform and runtime dependencies. + package let watcherFactory: any FileSystemWatcherCreating + package nonisolated let directoryListingBackend: any WorkspaceDirectoryListingBackend + package nonisolated let fileContentSnapshotReader: any FileContentSnapshotReading + package let mutationBackend: (any WorkspaceFileMutationBackend)? + package let diagnostics: any WorkspaceRuntimeDiagnosticsSink + package let ignoreRulesManager: IgnoreRulesManager + package var watcher: (any FileSystemWatching)? + + /// Synchronous callback publication preserves accepted-ingress ordering. + package nonisolated let publicationHub = FileSystemDeltaPublicationHub() + package var nextServicePublicationSequence: UInt64 = 0 + package var lastServicePublicationSequence: UInt64 = 0 + package var lastPublishedWatcherAcceptedWatermark = FileSystemWatcherIngressMailbox.Watermark.zero #if DEBUG var lastPublishedDeltaCoalescingDiagnostics: PublishedDeltaCoalescingDiagnostics? #endif - /// Retained pointer to self (to avoid deallocation while FSEvent stream is active) - var selfPointer: UnsafeMutableRawPointer? - /// The in-memory IgnoreRules instance for our path - var ignoreRules: IgnoreRules + package var ignoreRules: IgnoreRules - var ignoreCacheStore = IgnoreCacheStore() + package var ignoreCacheStore = IgnoreCacheStore() /// Caches the detected encoding for every file we have successfully opened - var encodingMap = [String: String.Encoding]() + package var encodingMap = [String: String.Encoding]() /// Path we are managing - let path: String - let rootURL: URL - let canonicalRootURL: URL - var canonicalRootPath: String { + package let path: String + package let rootURL: URL + package let canonicalRootURL: URL + package var canonicalRootPath: String { canonicalRootURL.path } - var standardizedRootPath: String { + package var standardizedRootPath: String { rootURL.path } - var respectGitignore: Bool - var respectRepoIgnore: Bool - var respectCursorignore: Bool - var skipSymlinks: Bool - var enableHierarchicalIgnores: Bool + package var respectGitignore: Bool + package var respectRepoIgnore: Bool + package var respectCursorignore: Bool + package var skipSymlinks: Bool + package var enableHierarchicalIgnores: Bool // MARK: - Ignore rules change tracking (revision-based for durability) /// Monotonic revision incremented each time ignore files change - var ignoreRulesRevision: UInt64 = 0 + package var ignoreRulesRevision: UInt64 = 0 /// Directories affected by ignore file changes since last consumption - var pendingIgnoreChangeDirs: Set = [] - - // A buffer for raw FSEvents + coalescing logic - var pendingFSEvents: [PendingFSEvent] = [] - var pendingWatcherAcceptedHighWatermark: FileSystemWatcherIngressMailbox.Watermark? - var pendingWatcherPublicationSource: FileSystemDeltaPublicationSource = .watcher - var hasPendingOverflowRescan = false - var overflowChangedIgnoreDirs: Set = [] - var coalescingTask: Task? - var watcherBatchProcessingTask: Task? - var watcherBatchProcessingToken: UInt64? - var nextWatcherBatchProcessingToken: UInt64 = 0 - var watcherIngressGeneration: UInt64 = 0 - let coalescingDelay: TimeInterval = 0.2 + package var pendingIgnoreChangeDirs: Set = [] + + // A buffer for semantic watcher events + coalescing logic + package var pendingFSEvents: [PendingFSEvent] = [] + package var pendingWatcherAcceptedHighWatermark: FileSystemWatcherIngressMailbox.Watermark? + package var pendingWatcherPublicationSource: FileSystemDeltaPublicationSource = .watcher + package var hasPendingOverflowRescan = false + package var overflowChangedIgnoreDirs: Set = [] + package var coalescingTask: Task? + package var watcherBatchProcessingTask: Task? + package var watcherBatchProcessingToken: UInt64? + package var nextWatcherBatchProcessingToken: UInt64 = 0 + package var watcherIngressGeneration: UInt64 = 0 + package var watcherLifecycleEpoch: UInt64 = 0 + package let coalescingDelay: TimeInterval = 0.2 // MARK: - Event ID-based scan coalescing (prevents dropped events while deduping bursts) - /// Maps folder relative path → highest FSEvent ID that requires scanning - var pendingScanTargets: [String: FSEventStreamEventId] = [:] - /// Maps folder relative path → highest FSEvent ID that has already been scanned - var lastScannedEventIdByFolder: [String: FSEventStreamEventId] = [:] + /// Maps folder relative path → highest watcher event ID that requires scanning + package var pendingScanTargets: [String: FileSystemWatchEventID] = [:] + /// Maps folder relative path → highest watcher event ID that has already been scanned + package var lastScannedEventIdByFolder: [String: FileSystemWatchEventID] = [:] /// Short-lived cache /// results during a directory walk to avoid repeated allocations. - var pathCompsCache = PathComponentsCache() + package var pathCompsCache = PathComponentsCache() /// Maximum number of cached ignore rules (default: 4000) - static let ignoreCacheCapacity = 4000 + package static let ignoreCacheCapacity = 4000 /// Cache for per-folder ignore rules (key = directory's relative path, "" for root) - var perFolderIgnoreCache = LRUCache( + package var perFolderIgnoreCache = LRUCache( capacity: FileSystemService.ignoreCacheCapacity ) /// Bounded marker cache for directories that have no ignore files. /// Eviction is safe: it only causes an extra filesystem recheck. - var noIgnoreFileCache = LRUCache( + package var noIgnoreFileCache = LRUCache( capacity: FileSystemService.ignoreCacheCapacity ) // MARK: - Parallelism Throttling /// Maximum concurrent directory scans per actor (prevents CPU saturation) - let maxParallelScansPerActor: Int + package let maxParallelScansPerActor: Int /// Maximum folders to scan in a single batch (bounds per-tick work) - let maxFoldersPerBatch: Int + package let maxFoldersPerBatch: Int // MARK: - Safety-Net Verification /// Minimum interval between safety-net scans for the same folder (seconds) - let safetyNetMinInterval: TimeInterval = 300 // 5 minutes + package let safetyNetMinInterval: TimeInterval = 300 // 5 minutes /// Number of file events before triggering a safety-net parent scan - let safetyNetEventThreshold: Int = 200 + package let safetyNetEventThreshold: Int = 200 /// Tracks when each folder was last verified via directory scan - var lastVerifiedAtByFolder: [String: TimeInterval] = [:] + package var lastVerifiedAtByFolder: [String: TimeInterval] = [:] /// Tracks file event count per folder since last verification - var fileEventCountSinceLastScan: [String: Int] = [:] + package var fileEventCountSinceLastScan: [String: Int] = [:] // MARK: - Init /// Initializes the FileSystemService for a given path, applying ignore rules, optionally skipping symlinks, - /// and immediately starting an FSEvents watcher to track changes in that path. - init( + /// and preparing an injected watcher to track changes in that path. + package init( path: String, respectGitignore: Bool = true, respectRepoIgnore: Bool = true, respectCursorignore: Bool = true, skipSymlinks: Bool = true, - enableHierarchicalIgnores: Bool = true + enableHierarchicalIgnores: Bool = true, + dependencies: WorkspaceRuntimeDependencies ) async throws { self.path = path rootURL = URL(fileURLWithPath: path).standardizedFileURL @@ -250,16 +231,20 @@ actor FileSystemService { self.respectCursorignore = respectCursorignore self.skipSymlinks = skipSymlinks self.enableHierarchicalIgnores = enableHierarchicalIgnores + watcherFactory = dependencies.watcherFactory + directoryListingBackend = dependencies.directoryListingBackend + fileContentSnapshotReader = dependencies.fileContentSnapshotReader + mutationBackend = dependencies.mutationBackend + diagnostics = dependencies.diagnostics + ignoreRulesManager = IgnoreRulesManager(globalIgnoreDefaults: dependencies.configuration.globalIgnoreDefaults) - watcherIngressMailbox = FileSystemWatcherIngressMailbox(maxQueuedRawEntries: Self.maxPendingRawEvents) + watcherIngressMailbox = FileSystemWatcherIngressMailbox(maxQueuedRawEntries: dependencies.configuration.maxPendingWatcherEntries) - // Configure parallelism caps based on available cores let cores = ProcessInfo.processInfo.activeProcessorCount - maxParallelScansPerActor = max(2, min(4, cores / 2)) - maxFoldersPerBatch = 256 + maxParallelScansPerActor = dependencies.configuration.maxParallelScans ?? max(2, min(4, cores / 2)) + maxFoldersPerBatch = dependencies.configuration.maxFoldersPerBatch - // Load fresh ignore rules from manager, no caching done by manager - ignoreRules = try await IgnoreRulesManager.shared.getIgnoreRules( + ignoreRules = try await ignoreRulesManager.getIgnoreRules( for: path, respectGitignore: respectGitignore, respectRepoIgnore: respectRepoIgnore, @@ -286,7 +271,8 @@ actor FileSystemService { fileManagerOverride: (any FileSystemProviding)? = nil, maxParallelScansOverride: Int? = nil, maxFoldersPerBatchOverride: Int? = nil, - maxPendingWatcherIngressEntriesOverride: Int? = nil + maxPendingWatcherIngressEntriesOverride: Int? = nil, + dependencies: WorkspaceRuntimeDependencies ) async throws { self.path = path rootURL = URL(fileURLWithPath: path).standardizedFileURL @@ -298,6 +284,12 @@ actor FileSystemService { self.enableHierarchicalIgnores = enableHierarchicalIgnores self.isTestMode = isTestMode self.fileManagerOverride = fileManagerOverride + watcherFactory = dependencies.watcherFactory + directoryListingBackend = dependencies.directoryListingBackend + fileContentSnapshotReader = dependencies.fileContentSnapshotReader + mutationBackend = dependencies.mutationBackend + diagnostics = dependencies.diagnostics + ignoreRulesManager = IgnoreRulesManager(globalIgnoreDefaults: dependencies.configuration.globalIgnoreDefaults) watcherIngressMailbox = FileSystemWatcherIngressMailbox( maxQueuedRawEntries: maxPendingWatcherIngressEntriesOverride ?? Self.maxPendingRawEvents @@ -323,10 +315,10 @@ actor FileSystemService { #if DEBUG // Pass the fileManagerOverride to IgnoreRulesManager if we have one if let override = fileManagerOverride { - await IgnoreRulesManager.shared.setFileManagerOverride(override) + await ignoreRulesManager.setFileManagerOverride(override) } #endif - ignoreRules = try await IgnoreRulesManager.shared.getIgnoreRules( + ignoreRules = try await ignoreRulesManager.getIgnoreRules( for: path, respectGitignore: respectGitignore, respectRepoIgnore: respectRepoIgnore, diff --git a/Sources/RepoPrompt/Infrastructure/FileSystem/FileSystemServiceTypes.swift b/Sources/RepoPromptCore/FileSystem/FileSystemServiceTypes.swift similarity index 61% rename from Sources/RepoPrompt/Infrastructure/FileSystem/FileSystemServiceTypes.swift rename to Sources/RepoPromptCore/FileSystem/FileSystemServiceTypes.swift index 50d47a52b..eab20baef 100644 --- a/Sources/RepoPrompt/Infrastructure/FileSystem/FileSystemServiceTypes.swift +++ b/Sources/RepoPromptCore/FileSystem/FileSystemServiceTypes.swift @@ -1,39 +1,4 @@ -import CoreServices import Foundation -#if DEBUG || EDIT_FLOW_PERF - import os -#endif - -enum FileSystemPublishPerf { - #if DEBUG || EDIT_FLOW_PERF - typealias State = OSSignpostIntervalState - static let signposter = OSSignposter(subsystem: "com.repoprompt.workspace", category: "fs-publish") - static var isEnabled: Bool { - UserDefaults.standard.bool(forKey: "enableRepoFileReplaySignposts") - } - - static func begin(_ name: StaticString) -> State? { - guard isEnabled else { return nil } - return signposter.beginInterval(name) - } - - static func end(_ name: StaticString, _ state: State?) { - guard isEnabled, let state else { return } - signposter.endInterval(name, state) - } - #else - struct State {} - static var isEnabled: Bool { - false - } - - static func begin(_ name: StaticString) -> State? { - nil - } - - static func end(_ name: StaticString, _ state: State?) {} - #endif -} public enum FileSystemDelta: Sendable, Equatable { case fileAdded(String) @@ -44,29 +9,44 @@ public enum FileSystemDelta: Sendable, Equatable { case folderModified(String, Date? = nil) // observed disk mtime when available } -enum FileSystemDeltaPublicationSource: String { +package enum FileSystemDeltaPublicationSource: String { case watcher case syntheticMutation case watcherBarrierNoop case overflowRootRescan } -struct FileSystemDeltaPublication { - let servicePublicationSequence: UInt64 - let source: FileSystemDeltaPublicationSource - let watcherAcceptedWatermark: FileSystemWatcherIngressMailbox.Watermark? - let deltas: [FileSystemDelta] +package struct FileSystemDeltaPublication { + package let servicePublicationSequence: UInt64 + package let source: FileSystemDeltaPublicationSource + package let watcherAcceptedWatermark: FileSystemWatcherIngressMailbox.Watermark? + package let correlationID: UUID? + package let deltas: [FileSystemDelta] + + package init( + servicePublicationSequence: UInt64, + source: FileSystemDeltaPublicationSource, + watcherAcceptedWatermark: FileSystemWatcherIngressMailbox.Watermark?, + correlationID: UUID? = nil, + deltas: [FileSystemDelta] + ) { + self.servicePublicationSequence = servicePublicationSequence + self.source = source + self.watcherAcceptedWatermark = watcherAcceptedWatermark + self.correlationID = correlationID + self.deltas = deltas + } } -typealias PendingFSEvent = (path: String, flags: FSEventStreamEventFlags, id: FSEventStreamEventId) +package typealias PendingFSEvent = (path: String, flags: FileSystemWatchEventFlags, id: FileSystemWatchEventID) -struct PendingFSEventBatch { - var events: [PendingFSEvent] = [] - var watcherAcceptedHighWatermark: FileSystemWatcherIngressMailbox.Watermark? - var publicationSource: FileSystemDeltaPublicationSource = .watcher - var watcherIngressGeneration: UInt64? +package struct PendingFSEventBatch { + package var events: [PendingFSEvent] = [] + package var watcherAcceptedHighWatermark: FileSystemWatcherIngressMailbox.Watermark? + package var publicationSource: FileSystemDeltaPublicationSource = .watcher + package var watcherIngressGeneration: UInt64? - var isEmpty: Bool { + package var isEmpty: Bool { events.isEmpty } } @@ -113,31 +93,31 @@ public enum CatalogRegularFileEligibility: Sendable, Equatable { } } -struct FSItemDTO { - let relativePath: String - let isDirectory: Bool - let hierarchy: Int +package struct FSItemDTO { + package let relativePath: String + package let isDirectory: Bool + package let hierarchy: Int } -struct FSPreparedChunk { - let folders: [FSItemDTO] - let files: [FSItemDTO] +package struct FSPreparedChunk { + package let folders: [FSItemDTO] + package let files: [FSItemDTO] } #if DEBUG - struct PublishedDeltaCoalescingDiagnostics: Equatable { + package struct PublishedDeltaCoalescingDiagnostics: Equatable { let rawDeltaCount: Int let publishedDeltaCount: Int } #endif -enum LoadContentsEvent { +package enum LoadContentsEvent { case totalFileCount(Int) // emitted at least once, first emission precedes item payloads case items([(any FileSystemItem, [String])]) // legacy compatibility case preparedItems(FSPreparedChunk) // preferred streaming payload } -enum ContentReadWorkloadClass: String { +package enum ContentReadWorkloadClass: String { case interactiveRead case contentSearch case codemap @@ -166,12 +146,12 @@ enum ContentReadSchedulerError: LocalizedError, Equatable { // MARK: - Encoding support ----------------------------------------------------- /// Bundles the decoded text with the encoding that produced it. -struct DetectedText { - let string: String - let encoding: String.Encoding +package struct DetectedText { + package let string: String + package let encoding: String.Encoding } -enum FileSystemError: Error { +package enum FileSystemError: Error { case fileAlreadyExists case fileNotFound case failedToCreateFile(Error) @@ -183,13 +163,16 @@ enum FileSystemError: Error { case isDirectory case failedToCreateDirectory(Error) case invalidRelativePath + case mutationBackendUnavailable } extension FileSystemError: LocalizedError { - var errorDescription: String? { + package var errorDescription: String? { switch self { case .invalidRelativePath: "Unsafe workspace mutation path: target escapes the loaded root, contains traversal, or uses a symbolic-link component." + case .mutationBackendUnavailable: + "Workspace mutation is unavailable in this runtime." default: nil } diff --git a/Sources/RepoPrompt/Infrastructure/FileSystem/FileSystemWatcherIngressMailbox.swift b/Sources/RepoPromptCore/FileSystem/FileSystemWatcherIngressMailbox.swift similarity index 76% rename from Sources/RepoPrompt/Infrastructure/FileSystem/FileSystemWatcherIngressMailbox.swift rename to Sources/RepoPromptCore/FileSystem/FileSystemWatcherIngressMailbox.swift index c96797d12..66a1cfaf2 100644 --- a/Sources/RepoPrompt/Infrastructure/FileSystem/FileSystemWatcherIngressMailbox.swift +++ b/Sources/RepoPromptCore/FileSystem/FileSystemWatcherIngressMailbox.swift @@ -1,29 +1,32 @@ -import CoreServices import Foundation -/// Owns deep-copied FSEvent callback payloads synchronously before actor entry. +/// Owns deep-copied filesystem watcher callback payloads synchronously before actor entry. /// -/// The FSEvents callback can run outside the `FileSystemService` actor. This mailbox +/// The platform watcher callback can run outside the `FileSystemService` actor. This mailbox /// assigns a per-root monotonic watermark before any task is created, preserves FIFO /// payload order, and retains at most one drain task. Under pressure it collapses /// queued details to the existing root-rescan sentinel contract without discarding /// accepted progress. -final class FileSystemWatcherIngressMailbox: @unchecked Sendable { - struct Watermark: Hashable, Comparable { - let rawValue: UInt64 +package final class FileSystemWatcherIngressMailbox: @unchecked Sendable { + package struct AcceptanceGeneration: Hashable { + package let rawValue: UInt64 + } + + package struct Watermark: Hashable, Comparable { + package let rawValue: UInt64 - static let zero = Watermark(rawValue: 0) + package static let zero = Watermark(rawValue: 0) - static func < (lhs: Watermark, rhs: Watermark) -> Bool { + package static func < (lhs: Watermark, rhs: Watermark) -> Bool { lhs.rawValue < rhs.rawValue } } - struct AcceptedPayload: @unchecked Sendable { + package struct AcceptedPayload: @unchecked Sendable { enum Contents: @unchecked Sendable { - case entries([FSEventCallbackEntry]) + case entries([FileSystemWatchEvent]) case overflowRootRescan( - highestEventID: FSEventStreamEventId, + highestEventID: FileSystemWatchEventID, changedIgnoreAbsolutePaths: Set ) } @@ -31,7 +34,7 @@ final class FileSystemWatcherIngressMailbox: @unchecked Sendable { let lowestAcceptedWatermark: Watermark let acceptedHighWatermark: Watermark let contents: Contents - let lifecycleCorrelation: EditFlowPerf.LifecycleCorrelation? + let diagnosticContext: FileSystemDiagnosticContext? var rawEntryCount: Int { switch contents { @@ -54,7 +57,9 @@ final class FileSystemWatcherIngressMailbox: @unchecked Sendable { private let lock = NSLock() private let maxQueuedRawEntries: Int - private var isAccepting = true + private var isAccepting = false + private var nextAcceptanceGeneration: UInt64 = 0 + private var activeAcceptanceGeneration: AcceptanceGeneration? private var nextAcceptedSequence: UInt64 = 0 private var acceptedHighWatermark = Watermark.zero private var queuedPayloads: [AcceptedPayload] = [] @@ -65,19 +70,36 @@ final class FileSystemWatcherIngressMailbox: @unchecked Sendable { private var activeDrainToken: UInt64? private var drainTask: Task? - init(maxQueuedRawEntries: Int) { + package init(maxQueuedRawEntries: Int) { self.maxQueuedRawEntries = max(1, maxQueuedRawEntries) } - func startAccepting() { + package func startAccepting() -> AcceptanceGeneration { lock.lock() + nextAcceptanceGeneration &+= 1 + let generation = AcceptanceGeneration(rawValue: nextAcceptanceGeneration) + activeAcceptanceGeneration = generation isAccepting = true lock.unlock() + return generation + } + + /// Closes callback admission without discarding any payload that already returned accepted. + @discardableResult + package func stopAccepting() -> Watermark { + lock.lock() + isAccepting = false + activeAcceptanceGeneration = nil + let watermark = acceptedHighWatermark + lock.unlock() + return watermark } - func stopAcceptingAndDiscardPending() { + /// Destructive reset used only after the accepted cut has been flushed. + package func discardPendingAndCancelDrain() { lock.lock() isAccepting = false + activeAcceptanceGeneration = nil queuedPayloads.removeAll(keepingCapacity: false) queuedPayloadHead = 0 queuedRawEntryCount = 0 @@ -89,22 +111,23 @@ final class FileSystemWatcherIngressMailbox: @unchecked Sendable { task?.cancel() } - func captureAcceptedWatermark() -> Watermark { + package func captureAcceptedWatermark() -> Watermark { lock.lock() defer { lock.unlock() } return acceptedHighWatermark } @discardableResult - func accept( - _ payload: FSEventCallbackPayload, - lifecycleCorrelation: EditFlowPerf.LifecycleCorrelation?, + package func accept( + _ payload: FileSystemWatchEventPayload, + acceptanceGeneration: AcceptanceGeneration, + diagnosticContext: FileSystemDiagnosticContext?, scheduleDrain: (@Sendable () async -> Void)? ) -> Watermark? { guard !payload.entries.isEmpty else { return nil } lock.lock() - guard isAccepting else { + guard isAccepting, activeAcceptanceGeneration == acceptanceGeneration else { lock.unlock() return nil } @@ -116,7 +139,7 @@ final class FileSystemWatcherIngressMailbox: @unchecked Sendable { lowestAcceptedWatermark: watermark, acceptedHighWatermark: watermark, contents: .entries(payload.entries), - lifecycleCorrelation: lifecycleCorrelation + diagnosticContext: diagnosticContext ) appendOrCollapse(acceptedPayload) if let scheduleDrain { @@ -126,7 +149,7 @@ final class FileSystemWatcherIngressMailbox: @unchecked Sendable { return watermark } - func takeNextAcceptedPayload(through target: Watermark? = nil) -> AcceptedPayload? { + package func takeNextAcceptedPayload(through target: Watermark? = nil) -> AcceptedPayload? { lock.lock() defer { lock.unlock() } guard queuedPayloadHead < queuedPayloads.count else { return nil } @@ -172,7 +195,7 @@ final class FileSystemWatcherIngressMailbox: @unchecked Sendable { let payloads = Array(queuedPayloads.dropFirst(queuedPayloadHead)) + [payload] var lowestAcceptedWatermark = payload.lowestAcceptedWatermark var acceptedHighWatermark = payload.acceptedHighWatermark - var highestEventID: FSEventStreamEventId = 0 + var highestEventID: FileSystemWatchEventID = 0 var changedIgnoreAbsolutePaths = Set() for queuedPayload in payloads { lowestAcceptedWatermark = min(lowestAcceptedWatermark, queuedPayload.lowestAcceptedWatermark) @@ -197,7 +220,7 @@ final class FileSystemWatcherIngressMailbox: @unchecked Sendable { highestEventID: highestEventID, changedIgnoreAbsolutePaths: changedIgnoreAbsolutePaths ), - lifecycleCorrelation: payload.lifecycleCorrelation + diagnosticContext: payload.diagnosticContext )] queuedPayloadHead = 0 queuedRawEntryCount = 1 diff --git a/Sources/RepoPrompt/Infrastructure/FileSystem/GitignoreCompiler.swift b/Sources/RepoPromptCore/FileSystem/GitignoreCompiler.swift similarity index 95% rename from Sources/RepoPrompt/Infrastructure/FileSystem/GitignoreCompiler.swift rename to Sources/RepoPromptCore/FileSystem/GitignoreCompiler.swift index 630937108..203b87cae 100644 --- a/Sources/RepoPrompt/Infrastructure/FileSystem/GitignoreCompiler.swift +++ b/Sources/RepoPromptCore/FileSystem/GitignoreCompiler.swift @@ -1,4 +1,5 @@ import Foundation +import RepoPromptC // Wildmatch bit-flags (duplicated from wildmatch.h) private let WM_NOESCAPE: UInt32 = 0x01 @@ -24,24 +25,24 @@ private func isMatch(_ result: Int32) -> Bool { /// normalized for matching logic. public struct GitPattern: Sendable { /// The original pattern text, with leading/trailing slash removed if necessary. - let pattern: String + package let pattern: String /// If true, this pattern is `!something`, meaning it *unignores* matching paths. - let isNegation: Bool + package let isNegation: Bool /// If true, pattern must match only directories (like trailing slash in Git). - let directoryOnly: Bool + package let directoryOnly: Bool /// If true, pattern is anchored to the ignore-file directory. /// Leading-slash patterns and slash-containing patterns match from that scoped root. - let absolute: Bool + package let absolute: Bool /// Conservative metadata used to avoid full wildcard matching when a path /// cannot possibly match this pattern. - let prefilter: GitPatternPrefilter + package let prefilter: GitPatternPrefilter } -enum GitPatternPrefilter: Equatable { +package enum GitPatternPrefilter: Equatable { case always case basenameLiteral(String) case directoryBasenameLiteral(String) @@ -49,7 +50,7 @@ enum GitPatternPrefilter: Equatable { case anchoredDirectoryPrefix(String) case basenameSuffix(String) - var requiresCheck: Bool { + package var requiresCheck: Bool { if case .always = self { return false } @@ -57,20 +58,20 @@ enum GitPatternPrefilter: Equatable { } } -struct NegationTraversalDiagnostics: Equatable { - let exactPrefixCount: Int - let patternHintCount: Int - let broadPatternHintCount: Int - let basenameOnlyNegationCount: Int +package struct NegationTraversalDiagnostics: Equatable { + package let exactPrefixCount: Int + package let patternHintCount: Int + package let broadPatternHintCount: Int + package let basenameOnlyNegationCount: Int - static let empty = NegationTraversalDiagnostics( + package static let empty = NegationTraversalDiagnostics( exactPrefixCount: 0, patternHintCount: 0, broadPatternHintCount: 0, basenameOnlyNegationCount: 0 ) - func adding(_ other: NegationTraversalDiagnostics) -> NegationTraversalDiagnostics { + package func adding(_ other: NegationTraversalDiagnostics) -> NegationTraversalDiagnostics { NegationTraversalDiagnostics( exactPrefixCount: exactPrefixCount + other.exactPrefixCount, patternHintCount: patternHintCount + other.patternHintCount, @@ -80,12 +81,12 @@ struct NegationTraversalDiagnostics: Equatable { } } -struct NegationTraversalPattern: Hashable { - let pattern: String - let absolute: Bool - let isBroad: Bool +package struct NegationTraversalPattern: Hashable { + package let pattern: String + package let absolute: Bool + package let isBroad: Bool - func matches(directoryPath: String) -> Bool { + package func matches(directoryPath: String) -> Bool { if pattern == "**" { return true } @@ -118,9 +119,9 @@ public struct CompiledIgnoreRules: Sendable { /// Each entry is one pattern line, in order of appearance in file. /// (Later lines have higher precedence when we do the final check.) private let patterns: [GitPattern] - let negationTraversalPrefixes: Set - let negationTraversalPatterns: Set - let traversalDiagnostics: NegationTraversalDiagnostics + package let negationTraversalPrefixes: Set + package let negationTraversalPatterns: Set + package let traversalDiagnostics: NegationTraversalDiagnostics /// Quick check for "do we have any negative pattern?" public var hasAnyNegativePattern: Bool { @@ -137,7 +138,7 @@ public struct CompiledIgnoreRules: Sendable { case noMatch } - init( + package init( patterns: [GitPattern], negationTraversalPrefixes: Set = [], negationTraversalPatterns: Set = [], @@ -244,7 +245,7 @@ public struct CompiledIgnoreRules: Sendable { /// Returns true if any negation rule requires us to keep traversing the /// directory at `path` even if other patterns would ignore it. - func requiresTraversal(for path: String) -> Bool { + package func requiresTraversal(for path: String) -> Bool { #if DEBUG IgnoreDebugMetricsRecorder.recordTraversalRequiresCheck() #endif diff --git a/Sources/RepoPrompt/Infrastructure/FileSystem/HierarchicalIgnoreEvaluator.swift b/Sources/RepoPromptCore/FileSystem/HierarchicalIgnoreEvaluator.swift similarity index 91% rename from Sources/RepoPrompt/Infrastructure/FileSystem/HierarchicalIgnoreEvaluator.swift rename to Sources/RepoPromptCore/FileSystem/HierarchicalIgnoreEvaluator.swift index ba8592bf7..84d67503b 100644 --- a/Sources/RepoPrompt/Infrastructure/FileSystem/HierarchicalIgnoreEvaluator.swift +++ b/Sources/RepoPromptCore/FileSystem/HierarchicalIgnoreEvaluator.swift @@ -2,9 +2,9 @@ import Foundation /// Evaluates paths against a hierarchy of ignore rules, checking each prefix /// to ensure parent directories that are ignored also ignore their children. -final class HierarchicalIgnoreEvaluator { +package final class HierarchicalIgnoreEvaluator { /// A provider of ignore rules for a given directory path - protocol RulesProvider { + package protocol RulesProvider { /// Get the effective ignore rules for a directory /// - Parameters: /// - directoryPath: The relative path to the directory (empty string for root) @@ -14,7 +14,7 @@ final class HierarchicalIgnoreEvaluator { private let rulesProvider: RulesProvider - init(rulesProvider: RulesProvider) { + package init(rulesProvider: RulesProvider) { self.rulesProvider = rulesProvider } @@ -23,7 +23,7 @@ final class HierarchicalIgnoreEvaluator { /// - relativePath: The relative path to check /// - isDirectory: Whether the final component is a directory /// - Returns: true if the path or any parent directory is ignored - func isIgnored(relativePath: String, isDirectory: Bool) async throws -> Bool { + package func isIgnored(relativePath: String, isDirectory: Bool) async throws -> Bool { let components = relativePath.split(separator: "/").map(String.init) return try await isIgnored(components: components, isDirectory: isDirectory) } @@ -33,7 +33,7 @@ final class HierarchicalIgnoreEvaluator { /// - components: Pre-split path components /// - isDirectory: Whether the final component is a directory /// - Returns: true if the path or any parent directory is ignored - func isIgnored(components: [String], isDirectory finalIsDirectory: Bool) async throws -> Bool { + package func isIgnored(components: [String], isDirectory finalIsDirectory: Bool) async throws -> Bool { guard !components.isEmpty else { return false } @@ -101,7 +101,7 @@ public final class CachedRulesProvider: HierarchicalIgnoreEvaluator.RulesProvide private let rootRules: IgnoreRules private let fallbackProvider: ((String) async throws -> IgnoreRules)? - init( + package init( cache: [String: IgnoreRules], rootRules: IgnoreRules, fallbackProvider: ((String) async throws -> IgnoreRules)? = nil @@ -111,7 +111,7 @@ public final class CachedRulesProvider: HierarchicalIgnoreEvaluator.RulesProvide self.fallbackProvider = fallbackProvider } - func rulesForDirectory(_ directoryPath: String) async throws -> IgnoreRules { + package func rulesForDirectory(_ directoryPath: String) async throws -> IgnoreRules { // Check cache first if let cached = cache[directoryPath] { #if DEBUG diff --git a/Sources/RepoPrompt/Infrastructure/FileSystem/IgnoreCacheStore.swift b/Sources/RepoPromptCore/FileSystem/IgnoreCacheStore.swift similarity index 92% rename from Sources/RepoPrompt/Infrastructure/FileSystem/IgnoreCacheStore.swift rename to Sources/RepoPromptCore/FileSystem/IgnoreCacheStore.swift index 5c7910088..18c7a87d5 100644 --- a/Sources/RepoPrompt/Infrastructure/FileSystem/IgnoreCacheStore.swift +++ b/Sources/RepoPromptCore/FileSystem/IgnoreCacheStore.swift @@ -1,10 +1,10 @@ import Foundation -struct IgnoreCacheStore { - static let finalIgnoreCacheCapacity = 50000 +package struct IgnoreCacheStore { + package static let finalIgnoreCacheCapacity = 50000 /// Compact key used by all internal caches – avoids repeated String concatenation. - struct PathKey: Hashable { + package struct PathKey: Hashable { let path: String let isDirectory: Bool } @@ -19,14 +19,14 @@ struct IgnoreCacheStore { // MARK: - Prefix check ---------------------------------------------------- - mutating func isIgnoredPrefixCheck( + package mutating func isIgnoredPrefixCheck( relativePath: String, ignoreRules: IgnoreRules ) -> Bool { isIgnoredPrefixCheck(relativePath: relativePath, isDirectory: false, ignoreRules: ignoreRules) } - mutating func isIgnoredPrefixCheck( + package mutating func isIgnoredPrefixCheck( relativePath: String, isDirectory: Bool, ignoreRules: IgnoreRules @@ -72,14 +72,14 @@ struct IgnoreCacheStore { // MARK: - Prefix check (pre-split components fast path) ------------------- - mutating func isIgnoredPrefixCheck( + package mutating func isIgnoredPrefixCheck( components comps: [Substring], ignoreRules: IgnoreRules ) -> Bool { isIgnoredPrefixCheck(components: comps, isDirectory: false, ignoreRules: ignoreRules) } - mutating func isIgnoredPrefixCheck( + package mutating func isIgnoredPrefixCheck( components comps: [Substring], isDirectory: Bool, ignoreRules: IgnoreRules @@ -126,7 +126,7 @@ struct IgnoreCacheStore { /// Return a *copy* of the final-decision cache, using the historical /// "`path|isDir`" key format so existing callers remain unchanged. - func snapshotIgnoreCache() -> [String: Bool] { + package func snapshotIgnoreCache() -> [String: Bool] { var out = [String: Bool]() out.reserveCapacity(ignoreCheckCache.count) for (k, v) in ignoreCheckCache.snapshot() { @@ -136,13 +136,13 @@ struct IgnoreCacheStore { } /// Returns a snapshot of the ignore cache with PathKey preservation - func snapshotIgnoreCacheWithPathKeys() -> [PathKey: Bool] { + package func snapshotIgnoreCacheWithPathKeys() -> [PathKey: Bool] { ignoreCheckCache.snapshot() } /// Merge a String-keyed cache (legacy callers) back into our /// PathKey-based storage. - mutating func mergeIgnoreCache(_ localCache: [String: Bool]) { + package mutating func mergeIgnoreCache(_ localCache: [String: Bool]) { for (rawKey, val) in localCache { let split = rawKey.split(separator: "|", maxSplits: 1, omittingEmptySubsequences: false) guard split.count == 2 else { continue } @@ -154,7 +154,7 @@ struct IgnoreCacheStore { } /// Merge a typed cache without any intermediate string allocation. - mutating func mergeIgnoreCache(_ localCache: [PathKey: Bool]) { + package mutating func mergeIgnoreCache(_ localCache: [PathKey: Bool]) { guard !localCache.isEmpty else { return } for (key, value) in localCache { ignoreCheckCache[key] = value @@ -164,7 +164,7 @@ struct IgnoreCacheStore { // MARK: - Static helpers -------------------------------------------------- /// New preferred form – **struct key** cache (used by new call-sites). - static func isIgnored( + package static func isIgnored( _ relPath: String, isDirectory: Bool, ignoreRules: IgnoreRules, @@ -184,7 +184,7 @@ struct IgnoreCacheStore { /// Overload that takes **pre-split** components to avoid the path-split /// cost on every check. Components are joined once to build the cache key. - static func isIgnored( + package static func isIgnored( components: [Substring], isDirectory: Bool, ignoreRules: IgnoreRules, @@ -210,7 +210,7 @@ struct IgnoreCacheStore { } /// Snapshot overload for off-actor use. - static func isIgnored( + package static func isIgnored( components: [Substring], isDirectory: Bool, ignoreRules: IgnoreRulesSnapshot, @@ -236,7 +236,7 @@ struct IgnoreCacheStore { } /// Optimized version with read-only base cache to avoid copy-on-write overhead - static func isIgnored( + package static func isIgnored( components: [Substring], isDirectory: Bool, readOnlyBase: [PathKey: Bool], @@ -273,7 +273,7 @@ struct IgnoreCacheStore { /// Legacy overload – preserves the original String-keyed signature so /// existing code compiles without modification. - static func isIgnored( + package static func isIgnored( _ relPath: String, isDirectory: Bool, ignoreRules: IgnoreRules, @@ -293,7 +293,7 @@ struct IgnoreCacheStore { // MARK: - Global (actor-owned) final cache ------------------------------- - mutating func isIgnoredGlobal( + package mutating func isIgnoredGlobal( _ relPath: String, isDirectory: Bool, ignoreRules: IgnoreRules diff --git a/Sources/RepoPrompt/Infrastructure/FileSystem/IgnoreDebugMetricsRecorder.swift b/Sources/RepoPromptCore/FileSystem/IgnoreDebugMetricsRecorder.swift similarity index 73% rename from Sources/RepoPrompt/Infrastructure/FileSystem/IgnoreDebugMetricsRecorder.swift rename to Sources/RepoPromptCore/FileSystem/IgnoreDebugMetricsRecorder.swift index 0e521233e..466249584 100644 --- a/Sources/RepoPrompt/Infrastructure/FileSystem/IgnoreDebugMetricsRecorder.swift +++ b/Sources/RepoPromptCore/FileSystem/IgnoreDebugMetricsRecorder.swift @@ -1,5 +1,4 @@ #if DEBUG - import Darwin import Foundation struct IgnoreDebugMetrics: Equatable, Codable { @@ -66,8 +65,7 @@ { return true } - return UserDefaults.standard.bool(forKey: enabledDefaultsKey) - || UserDefaults.standard.bool(forKey: dumpEnabledDefaultsKey) + return false }() private static var recordingEnabled = defaultRecordingEnabled @@ -232,88 +230,17 @@ mutate { $0.hierarchicalOutcomeMatchCount += 1 } } - static func resetAndDumpSnapshotIfEnabled(label: String) { - guard metricsDumpEnabled else { return } + static func resetAndDumpSnapshotIfEnabled(label _: String) { reset() - dumpSnapshotIfEnabled(label: label) } - static func dumpSnapshotIfEnabled(label: String) { - guard metricsDumpEnabled else { return } - let payload = IgnoreDebugMetricsDump( - label: label, - timestamp: Date().timeIntervalSince1970, - metrics: snapshot() - ) - guard let data = try? JSONEncoder().encode(payload), - let outputURL = secureDumpOutputURL() - else { - return - } - let fd = open(outputURL.path, O_WRONLY | O_APPEND | O_CREAT | O_NOFOLLOW, S_IRUSR | S_IWUSR) - guard fd >= 0 else { return } - let handle = FileHandle(fileDescriptor: fd, closeOnDealloc: true) - try? handle.seekToEnd() - try? handle.write(contentsOf: data) - try? handle.write(contentsOf: Data([0x0A])) - } - - private static var metricsDumpEnabled: Bool { - if isTruthy(ProcessInfo.processInfo.environment[dumpEnabledEnvironmentKey]) { - return true - } - return UserDefaults.standard.bool(forKey: dumpEnabledDefaultsKey) - } + static func dumpSnapshotIfEnabled(label _: String) {} private static func isTruthy(_ value: String?) -> Bool { guard let value = value?.lowercased() else { return false } return ["1", "true", "yes", "on"].contains(value) } - private static func secureDumpOutputURL() -> URL? { - let fileManager = FileManager.default - let directoryURL = fileManager.temporaryDirectory - .appendingPathComponent("com.repoprompt.ignore-metrics.\(getuid())", isDirectory: true) - let directoryPath = directoryURL.path - if fileManager.fileExists(atPath: directoryPath) { - guard isDirectoryAndNotSymlink(directoryURL) else { return nil } - } else { - do { - try fileManager.createDirectory( - at: directoryURL, - withIntermediateDirectories: false, - attributes: [.posixPermissions: 0o700] - ) - } catch { - return nil - } - } - - let outputURL = directoryURL.appendingPathComponent(dumpOutputFileName, isDirectory: false) - if fileManager.fileExists(atPath: outputURL.path), !isRegularFileAndNotSymlink(outputURL) { - return nil - } - return outputURL - } - - private static func isDirectoryAndNotSymlink(_ url: URL) -> Bool { - var info = stat() - guard lstat(url.path, &info) == 0 else { return false } - return (info.st_mode & S_IFMT) == S_IFDIR - } - - private static func isRegularFileAndNotSymlink(_ url: URL) -> Bool { - var info = stat() - guard lstat(url.path, &info) == 0 else { return false } - return (info.st_mode & S_IFMT) == S_IFREG - } - - private struct IgnoreDebugMetricsDump: Codable { - let label: String - let timestamp: TimeInterval - let metrics: IgnoreDebugMetrics - } - private static func mutate(_ body: (inout IgnoreDebugMetrics) -> Void) { guard isRecordingEnabled else { return } lock.lock() diff --git a/Sources/RepoPrompt/Infrastructure/FileSystem/IgnoreRules.swift b/Sources/RepoPromptCore/FileSystem/IgnoreRules.swift similarity index 88% rename from Sources/RepoPrompt/Infrastructure/FileSystem/IgnoreRules.swift rename to Sources/RepoPromptCore/FileSystem/IgnoreRules.swift index 9d487492c..7c7343926 100644 --- a/Sources/RepoPrompt/Infrastructure/FileSystem/IgnoreRules.swift +++ b/Sources/RepoPromptCore/FileSystem/IgnoreRules.swift @@ -2,7 +2,7 @@ import Foundation // Holds multiple "layers" of compiled patterns (from .gitignore, .repo_ignore, etc.), combined. -final class IgnoreRules { +package final class IgnoreRules { // MARK: - Internal persistent node fileprivate final class RulesNode { @@ -51,7 +51,7 @@ final class IgnoreRules { // MARK: - Initialisers /// Creates a new instance that starts with the shared default ignore layer. - init() { + package init() { tail = IgnoreRules.baseNode } @@ -67,7 +67,7 @@ final class IgnoreRules { /// The `priority` parameter is retained for API compatibility; current /// implementation always appends, which fulfils all existing call-sites /// where `priority` is monotonically increasing. - func addIgnoreFile(content: String, priority: Int, directoryPath: String = "") { + package func addIgnoreFile(content: String, priority: Int, directoryPath: String = "") { let compiled = GitignoreCompiler.compile(content: content, directoryPath: directoryPath) cachedSnapshot = nil tail = RulesNode(compiled: compiled, parent: tail) @@ -76,21 +76,21 @@ final class IgnoreRules { /// Return `true` if, after consulting all layers from highest to lowest, /// the path should be ignored. (String-based entry point – kept for /// backward compatibility, now delegates to the component-based fast path.) - func isIgnored(relativePath: String, isDirectory: Bool) -> Bool { + package func isIgnored(relativePath: String, isDirectory: Bool) -> Bool { let comps = relativePath.split(separator: "/") return matchOutcome(relativePathComponents: comps, isDirectory: isDirectory) == .ignore } /// Fast overload that accepts **pre-split** path components to avoid the /// repeated allocation from `split(separator:)` in tight loops. - func isIgnored(relativePathComponents comps: [Substring], isDirectory: Bool) -> Bool { + package func isIgnored(relativePathComponents comps: [Substring], isDirectory: Bool) -> Bool { matchOutcome(relativePathComponents: comps, isDirectory: isDirectory) == .ignore } /// Returns the highest-priority match outcome for the given path, or nil if /// no pattern matches. This is used by hierarchical evaluators that need to /// understand whether a match was produced by an ignore or negation rule. - func matchOutcome(relativePathComponents comps: [Substring], isDirectory: Bool) -> CompiledIgnoreRules.MatchOutcome? { + package func matchOutcome(relativePathComponents comps: [Substring], isDirectory: Bool) -> CompiledIgnoreRules.MatchOutcome? { var node: RulesNode? = tail while let current = node { switch current.compiled.outcome(for: comps, isDirectory: isDirectory) { @@ -104,13 +104,13 @@ final class IgnoreRules { } /// Fast aggregate check used by directory traversal code. - func hasAnyNegativePatterns() -> Bool { + package func hasAnyNegativePatterns() -> Bool { tail.hasNegative } /// Returns true if any negative rule requires us to keep scanning the /// directory located at `path` (relative to the repository root). - func requiresTraversal(for path: String) -> Bool { + package func requiresTraversal(for path: String) -> Bool { #if DEBUG IgnoreDebugMetricsRecorder.recordTraversalRequiresCheck() #endif @@ -134,22 +134,22 @@ final class IgnoreRules { return false } - var traversalDiagnostics: NegationTraversalDiagnostics { + package var traversalDiagnostics: NegationTraversalDiagnostics { tail.traversalDiagnostics } /// Returns a shallow clone that *shares* all rule layers with the original. - func clone() -> IgnoreRules { + package func clone() -> IgnoreRules { IgnoreRules(tail: tail) } /// The number of rule layers (including defaults). - var depth: Int { + package var depth: Int { tail.depth } /// Immutable snapshot safe to send off-actor. - func snapshot() -> IgnoreRulesSnapshot { + package func snapshot() -> IgnoreRulesSnapshot { if let cached = cachedSnapshot { return cached } @@ -191,12 +191,12 @@ final class IgnoreRules { }() } -struct IgnoreRulesSnapshot { +package struct IgnoreRulesSnapshot { fileprivate let layers: [CompiledIgnoreRules] private let hasNegative: Bool private let traversalPrefixes: Set private let traversalPatterns: Set - let traversalDiagnostics: NegationTraversalDiagnostics + package let traversalDiagnostics: NegationTraversalDiagnostics fileprivate init( layers: [CompiledIgnoreRules], @@ -212,16 +212,16 @@ struct IgnoreRulesSnapshot { self.traversalDiagnostics = traversalDiagnostics } - func isIgnored(relativePath: String, isDirectory: Bool) -> Bool { + package func isIgnored(relativePath: String, isDirectory: Bool) -> Bool { let comps = relativePath.split(separator: "/") return matchOutcome(relativePathComponents: comps, isDirectory: isDirectory) == .ignore } - func isIgnored(relativePathComponents comps: [Substring], isDirectory: Bool) -> Bool { + package func isIgnored(relativePathComponents comps: [Substring], isDirectory: Bool) -> Bool { matchOutcome(relativePathComponents: comps, isDirectory: isDirectory) == .ignore } - func matchOutcome( + package func matchOutcome( relativePathComponents comps: [Substring], isDirectory: Bool ) -> CompiledIgnoreRules.MatchOutcome? { @@ -235,11 +235,11 @@ struct IgnoreRulesSnapshot { return nil } - func hasAnyNegativePatterns() -> Bool { + package func hasAnyNegativePatterns() -> Bool { hasNegative } - func requiresTraversal(for path: String) -> Bool { + package func requiresTraversal(for path: String) -> Bool { #if DEBUG IgnoreDebugMetricsRecorder.recordTraversalRequiresCheck() #endif @@ -264,7 +264,7 @@ struct IgnoreRulesSnapshot { } } -extension IgnoreRules { +package extension IgnoreRules { /// Appends a **pre-compiled** layer as the new highest-priority node. /// This avoids recompiling the same file multiple times when the caller /// already has a `CompiledIgnoreRules` instance. diff --git a/Sources/RepoPrompt/Infrastructure/FileSystem/IgnoreRulesManager.swift b/Sources/RepoPromptCore/FileSystem/IgnoreRulesManager.swift similarity index 66% rename from Sources/RepoPrompt/Infrastructure/FileSystem/IgnoreRulesManager.swift rename to Sources/RepoPromptCore/FileSystem/IgnoreRulesManager.swift index 0db18d9d3..df35ea259 100644 --- a/Sources/RepoPrompt/Infrastructure/FileSystem/IgnoreRulesManager.swift +++ b/Sources/RepoPromptCore/FileSystem/IgnoreRulesManager.swift @@ -1,23 +1,18 @@ import Foundation -#if os(macOS) || os(iOS) || os(tvOS) || os(watchOS) - import Darwin // for stat() -#else - import Glibc -#endif /// Shared defaults and legacy key handling for app-wide ignore preferences. /// /// Kept outside `IgnoreRulesManager` so JSON-backed settings, legacy mirrors, /// and runtime ignore-rule loading agree on the canonical defaults/version. -enum IgnoreSettingsDefaults { - static let globalIgnoreDefaultsKey = "globalIgnoreDefaults" - static let globalIgnoreDefaultsVersionKey = "globalIgnoreDefaultsVersion" +package enum IgnoreSettingsDefaults { + package static let globalIgnoreDefaultsKey = "globalIgnoreDefaults" + package static let globalIgnoreDefaultsVersionKey = "globalIgnoreDefaultsVersion" /// Bump when we add new "required by default" patterns. - static let currentGlobalIgnoreDefaultsVersion = 2 + package static let currentGlobalIgnoreDefaultsVersion = 2 /// Canonical default patterns (do NOT include `.git`; that is always ignored separately). /// These mirror our "big dirs" heuristic plus a few common temp files. - static let canonicalGlobalIgnoreDefaults: String = """ + package static let canonicalGlobalIgnoreDefaults: String = """ # RepoPrompt global ignore defaults (v\(currentGlobalIgnoreDefaultsVersion)) **/node_modules/ **/.npm/ @@ -53,60 +48,12 @@ enum IgnoreSettingsDefaults { **/*.temp **/*.bak """ - - static func resolvedGlobalIgnoreDefaults(defaults: UserDefaults = .standard) -> String { - let storedObject = defaults.object(forKey: globalIgnoreDefaultsKey) - let stored = defaults.string(forKey: globalIgnoreDefaultsKey) - let storedVersion = defaults.object(forKey: globalIgnoreDefaultsVersionKey) as? Int ?? 0 - - guard storedObject != nil, let stored else { - defaults.set(canonicalGlobalIgnoreDefaults, forKey: globalIgnoreDefaultsKey) - defaults.set(currentGlobalIgnoreDefaultsVersion, forKey: globalIgnoreDefaultsVersionKey) - return canonicalGlobalIgnoreDefaults - } - - guard storedVersion < currentGlobalIgnoreDefaultsVersion else { - return stored - } - - guard !stored.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { - defaults.set(canonicalGlobalIgnoreDefaults, forKey: globalIgnoreDefaultsKey) - defaults.set(currentGlobalIgnoreDefaultsVersion, forKey: globalIgnoreDefaultsVersionKey) - return canonicalGlobalIgnoreDefaults - } - - let have = normalizedPatterns(stored) - let required = normalizedPatterns(canonicalGlobalIgnoreDefaults) - let missing = required.subtracting(have) - - guard !missing.isEmpty else { - defaults.set(currentGlobalIgnoreDefaultsVersion, forKey: globalIgnoreDefaultsVersionKey) - return stored - } - - let upgraded = stored.trimmingCharacters(in: .whitespacesAndNewlines) - + "\n\n# (Auto-upgraded to v\(currentGlobalIgnoreDefaultsVersion))\n" - + missing.sorted().joined(separator: "\n") - + "\n" - defaults.set(upgraded, forKey: globalIgnoreDefaultsKey) - defaults.set(currentGlobalIgnoreDefaultsVersion, forKey: globalIgnoreDefaultsVersionKey) - return upgraded - } - - private static func normalizedPatterns(_ text: String) -> Set { - Set( - text - .components(separatedBy: .newlines) - .map { $0.trimmingCharacters(in: .whitespaces) } - .filter { !$0.isEmpty && !$0.hasPrefix("#") } - ) - } } /// A lightweight manager that builds `IgnoreRules` on demand, with no caching. -actor IgnoreRulesManager { - static let shared = IgnoreRulesManager() +package actor IgnoreRulesManager { private let fileManager = FileManager.default + private let globalIgnoreDefaults: String #if DEBUG private var fileManagerOverride: (any FileSystemProviding)? @@ -124,7 +71,7 @@ actor IgnoreRulesManager { } #endif - private let ioSemaphore = TaskSemaphore(4) // Max 4 concurrent file reads + private let ioSemaphore = WorkspaceTaskSemaphore(4) // Max 4 concurrent file reads /// Compile-result cache keyed by (dev, ino, mtime) to avoid duplicate work across symlinks. private struct FileMetaKey: Hashable { let dev: UInt64 @@ -136,7 +83,9 @@ actor IgnoreRulesManager { capacity: 500 ) // metadata → task - private init() {} + package init(globalIgnoreDefaults: String) { + self.globalIgnoreDefaults = globalIgnoreDefaults + } #if DEBUG /// Detect if we're running under XCTest to make ignore behavior deterministic @@ -148,27 +97,17 @@ actor IgnoreRulesManager { /// Compute a unique cache key based on (device, inode, modification time). /// Falls back to a hash of the path if `stat()` fails. private func fileMetaKey(for url: URL) -> FileMetaKey { - var st = stat() - if stat(url.path, &st) == 0 { - let dev = UInt64(st.st_dev) - let ino = UInt64(st.st_ino) - #if os(macOS) || os(iOS) || os(tvOS) || os(watchOS) - let mtime = UInt64(st.st_mtimespec.tv_sec) - #else - let mtime = UInt64(st.st_mtim.tv_sec) - #endif - return FileMetaKey(dev: dev, ino: ino, mtime: mtime) - } - // Fallback – rare (e.g. file deleted between calls) + let values = try? url.resourceValues(forKeys: [.fileResourceIdentifierKey, .contentModificationDateKey]) + let identifier = values?.fileResourceIdentifier.map { String(describing: $0) } ?? url.path return FileMetaKey( dev: 0, - ino: UInt64(url.path.hashValue), - mtime: 0 + ino: UInt64(bitPattern: Int64(identifier.hashValue)), + mtime: UInt64(max(0, values?.contentModificationDate?.timeIntervalSince1970 ?? 0)) ) } /// Loads .gitignore and/or .repo_ignore content from disk, merges them into a single IgnoreRules. - func getIgnoreRules( + package func getIgnoreRules( for path: String, respectGitignore: Bool = true, respectRepoIgnore: Bool = true, @@ -223,20 +162,17 @@ actor IgnoreRulesManager { private func fetchGlobalDefaults() -> String { #if DEBUG - // In test runs, always return canonical defaults to ensure deterministic behavior. - // This prevents user-customized patterns from leaking into tests. if Self.isRunningTests { return IgnoreSettingsDefaults.canonicalGlobalIgnoreDefaults } #endif - - return IgnoreSettingsDefaults.resolvedGlobalIgnoreDefaults(defaults: .standard) + return globalIgnoreDefaults } /// Asynchronously compile a `.gitignore` / `.repo_ignore` file. /// The first caller starts the compilation task; subsequent callers await /// the same task, ensuring the file is compiled exactly once. - func compiledIgnoreFile(at url: URL) async throws -> CompiledIgnoreRules { + package func compiledIgnoreFile(at url: URL) async throws -> CompiledIgnoreRules { let key = fileMetaKey(for: url) // Fast path: if we already have a task in-flight or completed, just await it. @@ -280,7 +216,7 @@ actor IgnoreRulesManager { /// Deprecated synchronous helper kept for backward compatibility. /// Internally forwards to the new async version. - func compileIgnoreFile(at url: URL) throws -> CompiledIgnoreRules { + package func compileIgnoreFile(at url: URL) throws -> CompiledIgnoreRules { // Blocking on async helper – acceptable because it is used only in tests. let semaphore = DispatchSemaphore(value: 0) var output: CompiledIgnoreRules! diff --git a/Sources/RepoPrompt/Infrastructure/FileSystem/LRUCache.swift b/Sources/RepoPromptCore/FileSystem/LRUCache.swift similarity index 89% rename from Sources/RepoPrompt/Infrastructure/FileSystem/LRUCache.swift rename to Sources/RepoPromptCore/FileSystem/LRUCache.swift index d35394eee..cac8ce163 100644 --- a/Sources/RepoPrompt/Infrastructure/FileSystem/LRUCache.swift +++ b/Sources/RepoPromptCore/FileSystem/LRUCache.swift @@ -6,7 +6,7 @@ import Foundation /// The implementation relies on a doubly-linked list stitched together with /// a dictionary for fast key look-ups. Designed for **single-threaded or /// actor-isolated** use; no internal locking. -struct LRUCache { +package struct LRUCache { // MARK: - Node private final class Node { @@ -29,14 +29,14 @@ struct LRUCache { // MARK: - Init - init(capacity: Int) { + package init(capacity: Int) { precondition(capacity > 0, "LRUCache capacity must be > 0") self.capacity = capacity } // MARK: - Public API - subscript(key: Key) -> Value? { + package subscript(key: Key) -> Value? { mutating get { value(forKey: key) } mutating set { if let newVal = newValue { @@ -47,15 +47,15 @@ struct LRUCache { } } - var count: Int { + package var count: Int { dict.count } - var keys: [Key] { + package var keys: [Key] { Array(dict.keys) } - func snapshot() -> [Key: Value] { + package func snapshot() -> [Key: Value] { var snapshot: [Key: Value] = [:] snapshot.reserveCapacity(dict.count) for (key, node) in dict { @@ -65,12 +65,12 @@ struct LRUCache { } @discardableResult - mutating func set(_ value: Value, forKey key: Key) -> Key? { + package mutating func set(_ value: Value, forKey key: Key) -> Key? { insert(key: key, value: value) } /// Clear all stored entries. - mutating func removeAll() { + package mutating func removeAll() { var node = head while let current = node { let next = current.next @@ -115,7 +115,7 @@ struct LRUCache { return nil } - mutating func removeValue(forKey key: Key) { + package mutating func removeValue(forKey key: Key) { guard let node = dict[key] else { return } dict.removeValue(forKey: key) removeNode(node) diff --git a/Sources/RepoPrompt/Infrastructure/FileSystem/PathComponentsCache.swift b/Sources/RepoPromptCore/FileSystem/PathComponentsCache.swift similarity index 82% rename from Sources/RepoPrompt/Infrastructure/FileSystem/PathComponentsCache.swift rename to Sources/RepoPromptCore/FileSystem/PathComponentsCache.swift index e30f79d5a..25026d372 100644 --- a/Sources/RepoPrompt/Infrastructure/FileSystem/PathComponentsCache.swift +++ b/Sources/RepoPromptCore/FileSystem/PathComponentsCache.swift @@ -5,12 +5,12 @@ import Foundation /// /// Designed to live for the duration of a directory walk; callers are /// expected to discard the instance afterwards to keep memory bounded. -struct PathComponentsCache { +package struct PathComponentsCache { private var storage = [String: [Substring]]() /// Return the cached components for `path`, computing and storing /// them on first request. - mutating func components(for path: String) -> [Substring] { + package mutating func components(for path: String) -> [Substring] { if let cached = storage[path] { return cached } @@ -20,7 +20,7 @@ struct PathComponentsCache { } /// Clear all cached entries. - mutating func removeAll() { + package mutating func removeAll() { storage.removeAll() } } diff --git a/Sources/RepoPrompt/Infrastructure/FileSystem/PatternPool.swift b/Sources/RepoPromptCore/FileSystem/PatternPool.swift similarity index 93% rename from Sources/RepoPrompt/Infrastructure/FileSystem/PatternPool.swift rename to Sources/RepoPromptCore/FileSystem/PatternPool.swift index 2928a9653..2ab6fdc2c 100644 --- a/Sources/RepoPrompt/Infrastructure/FileSystem/PatternPool.swift +++ b/Sources/RepoPromptCore/FileSystem/PatternPool.swift @@ -8,8 +8,8 @@ import Foundation /// Thread-safety: `intern(_:)` uses an `NSLock`, which is perfectly adequate /// here because pattern compilation happens far less frequently than pattern /// matching. -final class PatternPool { - static let shared = PatternPool() +package final class PatternPool { + package static let shared = PatternPool() private var set = Set() private let lock = NSLock() @@ -25,7 +25,7 @@ final class PatternPool { /// pool reaches its maximum unique-string count, it is cleared before /// inserting the next new string. Clearing only reduces future deduplication; /// compiled rules already hold independent `String` values. - func intern(_ pattern: String) -> String { + package func intern(_ pattern: String) -> String { lock.lock() defer { lock.unlock() } diff --git a/Sources/RepoPromptCore/FileSystem/WorkspaceDirectoryListingBackend.swift b/Sources/RepoPromptCore/FileSystem/WorkspaceDirectoryListingBackend.swift new file mode 100644 index 000000000..d2d4eb187 --- /dev/null +++ b/Sources/RepoPromptCore/FileSystem/WorkspaceDirectoryListingBackend.swift @@ -0,0 +1,60 @@ +import Foundation + +package struct WorkspaceDirectoryEntry: Equatable { + package let name: String + package let isDirectory: Bool + package let isSymbolicLink: Bool + + package var isDir: Bool { + isDirectory + } + + package var isSym: Bool { + isSymbolicLink + } + + package init(name: String, isDirectory: Bool, isSymbolicLink: Bool) { + self.name = name + self.isDirectory = isDirectory + self.isSymbolicLink = isSymbolicLink + } +} + +package struct WorkspaceDirectoryScanResult: Equatable { + package let entries: [WorkspaceDirectoryEntry] + package let hasGitignore: Bool + package let hasRepoIgnore: Bool + package let hasCursorignore: Bool + + package init( + entries: [WorkspaceDirectoryEntry], + hasGitignore: Bool, + hasRepoIgnore: Bool, + hasCursorignore: Bool + ) { + self.entries = entries + self.hasGitignore = hasGitignore + self.hasRepoIgnore = hasRepoIgnore + self.hasCursorignore = hasCursorignore + } +} + +package struct WorkspaceDirectoryIdentity: Hashable { + package let device: UInt64 + package let inode: UInt64 + + package init(device: UInt64, inode: UInt64) { + self.device = device + self.inode = inode + } +} + +package typealias DirEntry = WorkspaceDirectoryEntry +package typealias DirectoryScanResult = WorkspaceDirectoryScanResult +package typealias DirID = WorkspaceDirectoryIdentity + +package protocol WorkspaceDirectoryListingBackend: Sendable { + func listDirectoryWithIgnoreDetection(at path: String) throws -> WorkspaceDirectoryScanResult + func directoryIdentity(followingSymlinksAt path: String) -> WorkspaceDirectoryIdentity? + func canonicalPath(for path: String) -> String? +} diff --git a/Sources/RepoPromptCore/FileSystem/WorkspaceFileMutationBackend.swift b/Sources/RepoPromptCore/FileSystem/WorkspaceFileMutationBackend.swift new file mode 100644 index 000000000..0766ca765 --- /dev/null +++ b/Sources/RepoPromptCore/FileSystem/WorkspaceFileMutationBackend.swift @@ -0,0 +1,12 @@ +import Foundation + +package protocol WorkspaceFileMutationBackend: Sendable { + func createDirectory(at url: URL) throws + func createFile(at url: URL, contents: Data?) throws + func write(_ data: Data, to url: URL, atomically: Bool) throws + func moveItem(at sourceURL: URL, to destinationURL: URL) throws + func removeItem(at url: URL) throws + func trashItem(at url: URL) throws + func fileExists(atPath path: String, isDirectory: inout Bool) -> Bool + func modificationDate(at url: URL) throws -> Date +} diff --git a/Sources/RepoPromptCore/MCP/Platform/BundledHelperPeerVerifying.swift b/Sources/RepoPromptCore/MCP/Platform/BundledHelperPeerVerifying.swift new file mode 100644 index 000000000..792527b68 --- /dev/null +++ b/Sources/RepoPromptCore/MCP/Platform/BundledHelperPeerVerifying.swift @@ -0,0 +1,16 @@ +import Foundation + +/// Neutral input for verifying that a connected peer is the app-bundled helper. +package struct BundledHelperPeerVerificationInput: Equatable { + package let expectedExecutableURL: URL + package let peerPID: Int + + package init(expectedExecutableURL: URL, peerPID: Int) { + self.expectedExecutableURL = expectedExecutableURL + self.peerPID = peerPID + } +} + +package protocol BundledHelperPeerVerifying: Sendable { + func matches(_ input: BundledHelperPeerVerificationInput) -> Bool +} diff --git a/Sources/RepoPromptCore/MCP/Platform/MCPAppProxyTransportBoundary.swift b/Sources/RepoPromptCore/MCP/Platform/MCPAppProxyTransportBoundary.swift new file mode 100644 index 000000000..5fe5158c8 --- /dev/null +++ b/Sources/RepoPromptCore/MCP/Platform/MCPAppProxyTransportBoundary.swift @@ -0,0 +1,78 @@ +import Foundation + +/// Origin of the PID used for app-proxy admission policy. +package enum MCPPeerPIDProvenance: Equatable { + case socketPeer + case handshakeFallback +} + +/// Neutral peer identity produced by the app-proxy transport adapter. +package struct MCPPeerIdentity: Equatable { + package let socketObservedPID: Int? + package let handshakeClaimedPID: Int + + package init(socketObservedPID: Int?, handshakeClaimedPID: Int) { + self.socketObservedPID = socketObservedPID + self.handshakeClaimedPID = handshakeClaimedPID + } + + /// Trusted process identity for authorization. The handshake PID is diagnostic only. + package var trustedPID: Int? { + socketObservedPID + } + + /// Best-effort process identity for diagnostics only. Never use this value for authorization. + package var diagnosticPID: Int { + socketObservedPID ?? handshakeClaimedPID + } + + package var provenance: MCPPeerPIDProvenance { + socketObservedPID == nil ? .handshakeFallback : .socketPeer + } + + package static func isValidHandshakeClaimedPID(_ pid: Int) -> Bool { + pid > 0 && pid <= Int(Int32.max) + } +} + +package enum MCPAppProxyAcceptedTransportLeaseState: Equatable { + case listenerOwned + case admissionReserved + case transferred + case closed +} + +/// Opaque accepted transport published synchronously into the host lifecycle ledger. +/// Native descriptors and socket operations remain adapter-owned. +package protocol MCPAppProxyAcceptedTransport: AnyObject, Sendable { + func close() +} + +/// Ownership lease for one accepted app-proxy transport. +/// +/// Admission reserves the lease before returning acceptance. After the accepted response is +/// written, the listener transfers the opaque transport into lifecycle-visible storage. Any +/// failed path rolls the lease back and closes the native transport exactly once. +package protocol MCPAppProxyAcceptedTransportLease: AnyObject, Sendable { + var state: MCPAppProxyAcceptedTransportLeaseState { get } + + func reserveForAdmission() -> Bool + func transfer( + publish: @Sendable (any MCPAppProxyAcceptedTransport) -> Bool + ) -> Bool + func rollback() +} + +/// Opaque handoff from the app-proxy listener into reusable admission policy. +package struct MCPAppProxyInboundConnection { + package let transportLease: any MCPAppProxyAcceptedTransportLease + package let peerIdentity: MCPPeerIdentity + + package init( + transportLease: any MCPAppProxyAcceptedTransportLease, + peerIdentity: MCPPeerIdentity + ) { + self.transportLease = transportLease + self.peerIdentity = peerIdentity + } +} diff --git a/Sources/RepoPromptCore/MCP/Platform/ProcessAncestryInspecting.swift b/Sources/RepoPromptCore/MCP/Platform/ProcessAncestryInspecting.swift new file mode 100644 index 000000000..deebff9e0 --- /dev/null +++ b/Sources/RepoPromptCore/MCP/Platform/ProcessAncestryInspecting.swift @@ -0,0 +1,4 @@ +/// Platform-neutral parent-process lookup used by MCP admission policy. +package protocol ProcessAncestryInspecting: Sendable { + func parentPID(of pid: Int32) -> Int32? +} diff --git a/Sources/RepoPromptCore/MCP/Session/MCPSessionIdentifiers.swift b/Sources/RepoPromptCore/MCP/Session/MCPSessionIdentifiers.swift new file mode 100644 index 000000000..8c79425cd --- /dev/null +++ b/Sources/RepoPromptCore/MCP/Session/MCPSessionIdentifiers.swift @@ -0,0 +1,18 @@ +import Foundation + +package struct RepoPromptSessionID: Hashable { + package let rawValue: UUID + + package init(rawValue: UUID = UUID()) { + self.rawValue = rawValue + } +} + +/// Process-lifetime routing identity. Production allocators must never reuse a value. +package struct MCPRoutingSessionID: Hashable { + package let rawValue: Int + + package init(rawValue: Int) { + self.rawValue = rawValue + } +} diff --git a/Sources/RepoPromptCore/MCP/Session/MCPSessionToolVocabulary.swift b/Sources/RepoPromptCore/MCP/Session/MCPSessionToolVocabulary.swift new file mode 100644 index 000000000..c1239fb7f --- /dev/null +++ b/Sources/RepoPromptCore/MCP/Session/MCPSessionToolVocabulary.swift @@ -0,0 +1,99 @@ +import Foundation + +package enum MCPSessionToolName { + package static let bindContext = "bind_context" + package static let manageWorkspaces = "manage_workspaces" + package static let manageSelection = "manage_selection" + + package static let fileActions = "file_actions" + package static let getCodeStructure = "get_code_structure" + package static let getFileTree = "get_file_tree" + package static let readFile = "read_file" + package static let search = "file_search" + + package static let workspaceContext = "workspace_context" + package static let prompt = "prompt" + package static let applyEdits = "apply_edits" + + package static let oracleUtils = "oracle_utils" + package static let askOracle = "ask_oracle" + package static let oracleSend = "oracle_send" + package static let oracleChatLog = "oracle_chat_log" + + package static let git = "git" + package static let manageWorktree = "manage_worktree" + package static let contextBuilder = "context_builder" + package static let askUser = "ask_user" + + package static let agentExplore = "agent_explore" + package static let agentRun = "agent_run" + package static let agentManage = "agent_manage" + + package static let shareThoughts = "share_thoughts" + package static let setStatus = "set_status" + package static let waitForNextInstruction = "wait_for_next_user_instruction" + package static let appSettings = "app_settings" +} + +package enum MCPSessionToolGroup: CaseIterable, Hashable { + case routing + case selection + case files + case promptContext + case applyEdits + case oracle + case git + case contextBuilder + case askUser + case agentControl + case agentSessionControl + case settings + + package var orderedToolNames: [String] { + switch self { + case .routing: + [MCPSessionToolName.bindContext, MCPSessionToolName.manageWorkspaces] + case .selection: + [MCPSessionToolName.manageSelection] + case .files: + [ + MCPSessionToolName.fileActions, + MCPSessionToolName.getCodeStructure, + MCPSessionToolName.getFileTree, + MCPSessionToolName.readFile, + MCPSessionToolName.search + ] + case .promptContext: + [MCPSessionToolName.workspaceContext, MCPSessionToolName.prompt] + case .applyEdits: + [MCPSessionToolName.applyEdits] + case .oracle: + [ + MCPSessionToolName.oracleUtils, + MCPSessionToolName.askOracle, + MCPSessionToolName.oracleSend, + MCPSessionToolName.oracleChatLog + ] + case .git: + [MCPSessionToolName.git, MCPSessionToolName.manageWorktree] + case .contextBuilder: + [MCPSessionToolName.contextBuilder] + case .askUser: + [MCPSessionToolName.askUser] + case .agentControl: + [MCPSessionToolName.agentExplore, MCPSessionToolName.agentRun, MCPSessionToolName.agentManage] + case .agentSessionControl: + [ + MCPSessionToolName.shareThoughts, + MCPSessionToolName.setStatus, + MCPSessionToolName.waitForNextInstruction + ] + case .settings: + [MCPSessionToolName.appSettings] + } + } + + package static var orderedToolNames: [String] { + allCases.flatMap(\.orderedToolNames) + } +} diff --git a/Sources/RepoPromptCore/MCP/Session/ToolCapabilityPolicy.swift b/Sources/RepoPromptCore/MCP/Session/ToolCapabilityPolicy.swift new file mode 100644 index 000000000..c7ffc2ff0 --- /dev/null +++ b/Sources/RepoPromptCore/MCP/Session/ToolCapabilityPolicy.swift @@ -0,0 +1,41 @@ +import Foundation + +package struct ToolCapability: RawRepresentable, Hashable { + package let rawValue: String + + package init(rawValue: String) { + self.rawValue = rawValue + } + + package static let workspaceRead = Self(rawValue: "workspace_read") + package static let workspaceLifecycle = Self(rawValue: "workspace_lifecycle") + package static let selection = Self(rawValue: "selection") + package static let promptContext = Self(rawValue: "prompt_context") + package static let fileRead = Self(rawValue: "file_read") + package static let fileWrite = Self(rawValue: "file_write") + package static let codeStructure = Self(rawValue: "code_structure") + package static let vcs = Self(rawValue: "vcs") + package static let oracle = Self(rawValue: "oracle") + package static let contextBuilder = Self(rawValue: "context_builder") + package static let userInteraction = Self(rawValue: "user_interaction") + package static let agentControl = Self(rawValue: "agent_control") + package static let settings = Self(rawValue: "settings") + package static let appLifecycle = Self(rawValue: "app_lifecycle") +} + +/// Immutable capability projection used both for tool advertisement and pre-invocation checks. +package struct ToolCapabilityPolicy { + package let grantedCapabilities: Set + + package init(grantedCapabilities: Set) { + self.grantedCapabilities = grantedCapabilities + } + + package func allows(_ capability: ToolCapability) -> Bool { + grantedCapabilities.contains(capability) + } + + package func allowsAll(_ capabilities: Set) -> Bool { + capabilities.isSubset(of: grantedCapabilities) + } +} diff --git a/Sources/RepoPromptCore/Platform/FileSystemWatching.swift b/Sources/RepoPromptCore/Platform/FileSystemWatching.swift new file mode 100644 index 000000000..734dd9fc9 --- /dev/null +++ b/Sources/RepoPromptCore/Platform/FileSystemWatching.swift @@ -0,0 +1,73 @@ +import Foundation + +/// Platform-neutral filesystem event sequence used for watcher coalescing and scan deduplication. +package typealias FileSystemWatchEventID = UInt64 + +/// Semantic watcher flags consumed by reusable filesystem policy. +/// +/// Platform adapters translate native event bits into this stable vocabulary before events enter +/// the reusable mailbox. Keeping these semantics neutral lets coalescing, overflow recovery, +/// ignore evaluation, and delta generation move into the core target unchanged. +package struct FileSystemWatchEventFlags: OptionSet, Equatable { + package let rawValue: UInt32 + + package init(rawValue: UInt32) { + self.rawValue = rawValue + } + + package static let itemCreated = Self(rawValue: 1 << 0) + package static let itemRemoved = Self(rawValue: 1 << 1) + package static let itemRenamed = Self(rawValue: 1 << 2) + package static let contentChanged = Self(rawValue: 1 << 3) + package static let metadataChanged = Self(rawValue: 1 << 4) + package static let itemIsFile = Self(rawValue: 1 << 5) + package static let itemIsDirectory = Self(rawValue: 1 << 6) + package static let itemIsSymlink = Self(rawValue: 1 << 7) + package static let mustScanSubdirectories = Self(rawValue: 1 << 8) + package static let droppedEvents = Self(rawValue: 1 << 9) + package static let rootChanged = Self(rawValue: 1 << 10) + + package static let overflowRootRescan: Self = [.mustScanSubdirectories, .rootChanged] +} + +package struct FileSystemWatchEvent: Equatable { + package let path: String + package let flags: FileSystemWatchEventFlags + package let id: FileSystemWatchEventID + + package init(path: String, flags: FileSystemWatchEventFlags, id: FileSystemWatchEventID) { + self.path = path + self.flags = flags + self.id = id + } +} + +package struct FileSystemWatchEventPayload: Equatable { + package let entries: [FileSystemWatchEvent] + + package init(entries: [FileSystemWatchEvent]) { + self.entries = entries + } + + package var count: Int { + entries.count + } +} + +/// Injected lifecycle boundary for filesystem watching. +/// +/// The event handler must run synchronously from the platform callback after native payloads have +/// been deep-copied. `FileSystemService` accepts the payload into its bounded mailbox before +/// scheduling actor work, preserving callback-cut flush barriers under load. +package protocol FileSystemWatching: AnyObject, Sendable { + var isWatching: Bool { get } + + @discardableResult + func start(eventHandler: @escaping @Sendable (FileSystemWatchEventPayload) -> Void) -> Bool + + func stop() +} + +package protocol FileSystemWatcherCreating: Sendable { + func makeWatcher(path: String) -> any FileSystemWatching +} diff --git a/Sources/RepoPromptCore/Platform/ProcessLaunching.swift b/Sources/RepoPromptCore/Platform/ProcessLaunching.swift new file mode 100644 index 000000000..924fffa3f --- /dev/null +++ b/Sources/RepoPromptCore/Platform/ProcessLaunching.swift @@ -0,0 +1,37 @@ +import Foundation + +/// Platform-neutral description of a spawned child process. +package struct SpawnedProcess: @unchecked Sendable { + package let pid: Int32 + package let stdin: FileHandle? + package let stdinDescriptor: Int32? + package let stdout: FileHandle + package let stderr: FileHandle + + package init(pid: Int32, stdin: FileHandle?, stdinDescriptor: Int32?, stdout: FileHandle, stderr: FileHandle) { + self.pid = pid + self.stdin = stdin + self.stdinDescriptor = stdinDescriptor + self.stdout = stdout + self.stderr = stderr + } +} + +package enum ProcessLauncherError: Error { + case pipeCreationFailed(String) + case descriptorConfigurationFailed(operation: String, label: String, fd: Int32, errno: Int32) + case spawnFileActionsFailed(operation: String, errno: Int32) + case changeDirectoryFailed(path: String, errno: Int32) + case spawnAttributesFailed(operation: String, errno: Int32) + case spawnFailed(errno: Int32) +} + +/// Injected child-process boundary for reusable runtime owners. +package protocol ProcessLaunching: Sendable { + func spawn( + command: String, + arguments: [String], + environment: [String: String], + workingDirectory: String? + ) throws -> SpawnedProcess +} diff --git a/Sources/RepoPromptCore/Platform/RepoPromptCorePlatformDependencies.swift b/Sources/RepoPromptCore/Platform/RepoPromptCorePlatformDependencies.swift new file mode 100644 index 000000000..9402f1d43 --- /dev/null +++ b/Sources/RepoPromptCore/Platform/RepoPromptCorePlatformDependencies.swift @@ -0,0 +1,19 @@ +/// Platform dependencies consumed by the staged reusable core host. +/// +/// Item 5 gives this dependency record a physical SwiftPM owner while the host and +/// filesystem-runtime closure remain app-owned until their deferred stream split lands. +package struct RepoPromptCorePlatformDependencies { + package let fileSystemWatcherFactory: any FileSystemWatcherCreating + package let processLauncher: any ProcessLaunching + package let secureStorageBackend: () -> any SecureKeyValueStorageBackend + + package init( + fileSystemWatcherFactory: any FileSystemWatcherCreating, + processLauncher: any ProcessLaunching, + secureStorageBackend: @escaping () -> any SecureKeyValueStorageBackend + ) { + self.fileSystemWatcherFactory = fileSystemWatcherFactory + self.processLauncher = processLauncher + self.secureStorageBackend = secureStorageBackend + } +} diff --git a/Sources/RepoPromptCore/Platform/SecureKeyValueStorageBackend.swift b/Sources/RepoPromptCore/Platform/SecureKeyValueStorageBackend.swift new file mode 100644 index 000000000..3e8d0cf86 --- /dev/null +++ b/Sources/RepoPromptCore/Platform/SecureKeyValueStorageBackend.swift @@ -0,0 +1,73 @@ +import Foundation + +/// Controls whether secure-storage access may display authentication or approval UI. +package enum SecureStorageAccessMode: Equatable { + case interactive + case nonInteractive(reason: SecureStorageAccessReason) + + package var isNonInteractive: Bool { + if case .nonInteractive = self { + return true + } + return false + } +} + +/// Sanitized reason metadata for noninteractive secure-storage access. +package enum SecureStorageAccessReason: Equatable { + case launch + case bulkSettingsLoad + case permissionDecision + case backgroundAvailabilityCheck + case test +} + +/// Platform-neutral secure-storage failure vocabulary. +package enum SecureStorageError: Error, LocalizedError, Equatable { + case itemNotFound + case duplicateItem + case invalidData + case interactionNotAllowed + case userInteractionCancelled + case authenticationFailed + case unexpectedStatus(Int32) + + package var errorDescription: String? { + switch self { + case .itemNotFound: + "Item not found in secure storage" + case .duplicateItem: + "Item already exists" + case .invalidData: + "Invalid data format" + case .interactionNotAllowed: + "Secure-storage interaction is not allowed in the current access mode" + case .userInteractionCancelled: + "Secure-storage interaction was cancelled" + case .authenticationFailed: + "Secure-storage authentication failed" + case let .unexpectedStatus(status): + "Secure-storage error: \(status)" + } + } +} + +package protocol SecureKeyValueStorageBackend: AnyObject { + var persistsValuesAcrossLaunches: Bool { get } + + func save( + _ value: String, + for key: String, + accessMode: SecureStorageAccessMode + ) throws + + func get( + for key: String, + accessMode: SecureStorageAccessMode + ) throws -> String + + func delete( + for key: String, + accessMode: SecureStorageAccessMode + ) throws +} diff --git a/Sources/RepoPromptCore/Prompt/PromptAssemblyBuilder.swift b/Sources/RepoPromptCore/Prompt/PromptAssemblyBuilder.swift new file mode 100644 index 000000000..4bca9ad8c --- /dev/null +++ b/Sources/RepoPromptCore/Prompt/PromptAssemblyBuilder.swift @@ -0,0 +1,135 @@ +// +// PromptAssemblyBuilder.swift +// RepoPromptCore +// +// Created by Eric Provencher on 2025-04-16. +// + +import Foundation + +public enum PromptAssemblyLayout: Equatable, Sendable { + /// Existing behavior: append fragments as-is and ensure each included fragment ends in a newline. + case lineTerminatedFragments + + /// Normalize fragments by trimming trailing newlines and separating included fragments with one blank line. + case blankLineSeparatedFragments +} + +/// Combines independently produced snippets in a caller-supplied order. +public struct PromptAssemblyBuilder { + public static let defaultSectionOrder: [PromptSection] = [ + .fileMap, + .fileContents, + .gitDiff, + .metaPrompts, + .userInstructions + ] + + public let policy: PromptRenderPolicy + public let snippets: [PromptSection: String] + + public init(policy: PromptRenderPolicy, snippets: [PromptSection: String]) { + self.policy = policy + self.snippets = snippets + } + + public init( + order: [PromptSection], + disabled: Set, + duplicateUserInstructionsAtTop: Bool, + snippets: [PromptSection: String] + ) { + self.init( + policy: PromptRenderPolicy( + sectionOrder: order, + disabledSections: disabled, + duplicateUserInstructionsAtTop: duplicateUserInstructionsAtTop + ), + snippets: snippets + ) + } + + public func build() -> String { + build(layout: .lineTerminatedFragments) + } + + public func build(layout: PromptAssemblyLayout) -> String { + switch layout { + case .lineTerminatedFragments: + buildLineTerminatedFragments() + case .blankLineSeparatedFragments: + buildBlankLineSeparatedFragments() + } + } + + /// Convenience static wrapper. + public static func build( + order: [PromptSection], + disabled: Set, + duplicateUserInstructionsAtTop: Bool, + snippets: [PromptSection: String] + ) -> String { + build( + order: order, + disabled: disabled, + duplicateUserInstructionsAtTop: duplicateUserInstructionsAtTop, + snippets: snippets, + layout: .lineTerminatedFragments + ) + } + + public static func build( + order: [PromptSection], + disabled: Set, + duplicateUserInstructionsAtTop: Bool, + snippets: [PromptSection: String], + layout: PromptAssemblyLayout + ) -> String { + PromptAssemblyBuilder( + order: order, + disabled: disabled, + duplicateUserInstructionsAtTop: duplicateUserInstructionsAtTop, + snippets: snippets + ).build(layout: layout) + } + + private func buildLineTerminatedFragments() -> String { + var output = "" + for snippet in orderedSnippets() { + output += snippet + if !snippet.hasSuffix("\n") { output += "\n" } + } + return output + } + + private func buildBlankLineSeparatedFragments() -> String { + let fragments = orderedSnippets() + .map(Self.trimmingTrailingNewlines) + .filter { !$0.isEmpty } + return fragments.joined(separator: "\n\n") + } + + private func orderedSnippets() -> [String] { + var ordered: [String] = [] + if policy.duplicateUserInstructionsAtTop, + let user = snippets[.userInstructions], + user.isEmpty == false + { + ordered.append(user) + } + + for section in policy.sectionOrder where !policy.disabledSections.contains(section) { + guard let snippet = snippets[section], snippet.isEmpty == false else { continue } + ordered.append(snippet) + } + return ordered + } + + private static func trimmingTrailingNewlines(_ value: String) -> String { + var trimmed = value + while trimmed.hasSuffix("\n") || trimmed.hasSuffix("\r") { + trimmed.removeLast() + } + return trimmed + } +} diff --git a/Sources/RepoPromptCore/Prompt/PromptContextAccountingService.swift b/Sources/RepoPromptCore/Prompt/PromptContextAccountingService.swift new file mode 100644 index 000000000..ff351db0d --- /dev/null +++ b/Sources/RepoPromptCore/Prompt/PromptContextAccountingService.swift @@ -0,0 +1,642 @@ +import Foundation + +package struct PromptContextAccountingRequest { + package let selection: StoredSelection + package let promptText: String + package let selectedInstructionsText: String + package let duplicateUserInstructionsAtTop: Bool + package let fileTree: TokenCalculationFileTreeInput + package let codeMapUsage: CodeMapUsage + package let filePathDisplay: FilePathDisplay + package let rootScope: WorkspaceLookupRootScope + package let pathLocateProfile: PathLocateProfile + + package init( + selection: StoredSelection, + promptText: String = "", + selectedInstructionsText: String = "", + duplicateUserInstructionsAtTop: Bool = false, + fileTree: TokenCalculationFileTreeInput = .none, + codeMapUsage: CodeMapUsage = .auto, + filePathDisplay: FilePathDisplay = .relative, + rootScope: WorkspaceLookupRootScope = .allLoaded, + pathLocateProfile: PathLocateProfile = .uiAssisted + ) { + self.selection = selection + self.promptText = promptText + self.selectedInstructionsText = selectedInstructionsText + self.duplicateUserInstructionsAtTop = duplicateUserInstructionsAtTop + self.fileTree = fileTree + self.codeMapUsage = codeMapUsage + self.filePathDisplay = filePathDisplay + self.rootScope = rootScope + self.pathLocateProfile = pathLocateProfile + } + + package func withFileTree(_ fileTree: TokenCalculationFileTreeInput) -> PromptContextAccountingRequest { + PromptContextAccountingRequest( + selection: selection, + promptText: promptText, + selectedInstructionsText: selectedInstructionsText, + duplicateUserInstructionsAtTop: duplicateUserInstructionsAtTop, + fileTree: fileTree, + codeMapUsage: codeMapUsage, + filePathDisplay: filePathDisplay, + rootScope: rootScope, + pathLocateProfile: pathLocateProfile + ) + } +} + +package struct PromptContextAccountingResolution: Equatable { + package let entries: [ResolvedPromptFileEntry] + package let missingPaths: [String] + package let invalidPaths: [String] + + package init( + entries: [ResolvedPromptFileEntry], + missingPaths: [String], + invalidPaths: [String] + ) { + self.entries = entries + self.missingPaths = missingPaths + self.invalidPaths = invalidPaths + } + + package static let empty = PromptContextAccountingResolution( + entries: [], + missingPaths: [], + invalidPaths: [] + ) +} + +package struct PromptContextAccountingResult { + package let tokenResult: TokenCalculationResult + package let resolvedEntries: [ResolvedPromptFileEntry] + package let promptFileEntrySnapshots: [PromptFileEntrySnapshot] + package let tokenCalculationSnapshot: TokenCalculationSnapshot + package let missingPaths: [String] + package let invalidPaths: [String] + package let codemapSnapshotsUsed: [UUID: WorkspaceCodemapSnapshot] + package let captureProvenance: WorkspaceFileContextCapture.Provenance? + + package init( + tokenResult: TokenCalculationResult, + resolvedEntries: [ResolvedPromptFileEntry], + promptFileEntrySnapshots: [PromptFileEntrySnapshot], + tokenCalculationSnapshot: TokenCalculationSnapshot, + missingPaths: [String], + invalidPaths: [String], + codemapSnapshotsUsed: [UUID: WorkspaceCodemapSnapshot], + captureProvenance: WorkspaceFileContextCapture.Provenance? = nil + ) { + self.tokenResult = tokenResult + self.resolvedEntries = resolvedEntries + self.promptFileEntrySnapshots = promptFileEntrySnapshots + self.tokenCalculationSnapshot = tokenCalculationSnapshot + self.missingPaths = missingPaths + self.invalidPaths = invalidPaths + self.codemapSnapshotsUsed = codemapSnapshotsUsed + self.captureProvenance = captureProvenance + } +} + +package enum PromptContextAccountingError: Error, Equatable { + case captureSelectionMismatch + case captureRootScopeMismatch +} + +private struct PromptContextEntryIntent { + let file: WorkspaceFileRecord + let isCodemap: Bool + let lineRanges: [LineRange]? + let mode: PromptFileEntryMode + let rootFolderPath: String? + + var id: ResolvedPromptFileEntryID { + ResolvedPromptFileEntryID(fileID: file.id, mode: mode, lineRanges: lineRanges) + } +} + +private struct PromptContextContentReadRequest { + let intentIndex: Int + let file: WorkspaceFileRecord +} + +private struct PromptContextContentReadResult { + let intentIndex: Int + let content: String? +} + +package actor PromptContextAccountingService { + package nonisolated static let selectedFileReadConcurrencyLimit = 4 + + package init() {} + + package func calculatePromptStats( + request: PromptContextAccountingRequest, + store: WorkspaceFileContextStore, + fileTreeSnapshotRequest: WorkspaceFileTreeSnapshotRequest + ) async throws -> PromptContextAccountingResult { + try Task.checkCancellation() + let snapshot = await store.makeFileTreeSelectionSnapshot( + selection: request.selection, + request: fileTreeSnapshotRequest, + profile: request.pathLocateProfile + ) + try Task.checkCancellation() + return try await calculatePromptStats( + request: request.withFileTree(.snapshot(snapshot)), + store: store + ) + } + + package func calculatePromptStats( + request: PromptContextAccountingRequest, + store: WorkspaceFileContextStore + ) async throws -> PromptContextAccountingResult { + try Task.checkCancellation() + let codemapSnapshots = await store.codemapSnapshotDictionary() + try Task.checkCancellation() + let resolution = try await resolveEntries( + selection: request.selection, + store: store, + rootScope: request.rootScope, + profile: request.pathLocateProfile, + codeMapUsage: request.codeMapUsage, + codemapSnapshots: codemapSnapshots + ) + return try await calculatePromptStats( + request: request, + resolution: resolution, + codemapSnapshots: codemapSnapshots, + captureProvenance: nil + ) + } + + package func calculatePromptStats( + request: PromptContextAccountingRequest, + store: WorkspaceFileContextStore, + capture: WorkspaceFileContextCapture + ) async throws -> PromptContextAccountingResult { + try Task.checkCancellation() + guard capture.storedSelection == request.selection else { + throw PromptContextAccountingError.captureSelectionMismatch + } + guard capture.provenance.rootScope == request.rootScope else { + throw PromptContextAccountingError.captureRootScopeMismatch + } + + let plan = try WorkspaceContextProjectionService.makePlan( + capture: capture, + request: .init( + sections: [.selection], + filePathDisplay: request.filePathDisplay, + codeMapUsage: request.codeMapUsage + ) + ) + try Task.checkCancellation() + let resolution = try await resolveEntries(plan: plan, store: store) + let codemapSnapshots = Dictionary( + uniqueKeysWithValues: capture.codemapSnapshots.map { ($0.fileID, $0) } + ) + return try await calculatePromptStats( + request: request, + resolution: resolution, + codemapSnapshots: codemapSnapshots, + captureProvenance: capture.provenance + ) + } + + private func calculatePromptStats( + request: PromptContextAccountingRequest, + resolution: PromptContextAccountingResolution, + codemapSnapshots: [UUID: WorkspaceCodemapSnapshot], + captureProvenance: WorkspaceFileContextCapture.Provenance? + ) async throws -> PromptContextAccountingResult { + let snapshots = makePromptFileEntrySnapshots( + from: resolution.entries, + codemapSnapshots: codemapSnapshots, + filePathDisplay: request.filePathDisplay + ) + let calculationSnapshot = TokenCalculationSnapshot( + promptText: request.promptText, + selectedInstructionsText: request.selectedInstructionsText, + duplicateUserInstructionsAtTop: request.duplicateUserInstructionsAtTop, + promptEntries: snapshots, + fileTree: request.fileTree + ) + try Task.checkCancellation() + + let tokenCalculationService = TokenCalculationService() + let tokenResult = try await tokenCalculationService.calculatePromptStatsScoped( + snapshot: calculationSnapshot + ) + try Task.checkCancellation() + + let usedCodemaps = codemapSnapshots.filter { fileID, _ in + snapshots.contains { $0.fileID == fileID && $0.isCodemapRequested && $0.codeMapContent != nil } + } + return PromptContextAccountingResult( + tokenResult: tokenResult, + resolvedEntries: resolution.entries, + promptFileEntrySnapshots: snapshots, + tokenCalculationSnapshot: calculationSnapshot, + missingPaths: resolution.missingPaths, + invalidPaths: resolution.invalidPaths, + codemapSnapshotsUsed: usedCodemaps, + captureProvenance: captureProvenance + ) + } + + package func resolveEntries( + selection: StoredSelection, + store: WorkspaceFileContextStore, + rootScope: WorkspaceLookupRootScope = .allLoaded, + profile: PathLocateProfile = .uiAssisted, + codeMapUsage: CodeMapUsage = .auto + ) async throws -> PromptContextAccountingResolution { + try Task.checkCancellation() + let codemapSnapshots = await store.codemapSnapshotDictionary() + try Task.checkCancellation() + return try await resolveEntries( + selection: selection, + store: store, + rootScope: rootScope, + profile: profile, + codeMapUsage: codeMapUsage, + codemapSnapshots: codemapSnapshots + ) + } + + package func makePromptFileEntrySnapshots( + from entries: [ResolvedPromptFileEntry], + codemapSnapshots: [UUID: WorkspaceCodemapSnapshot], + filePathDisplay: FilePathDisplay = .relative + ) -> [PromptFileEntrySnapshot] { + let hasMultipleRoots = Set(entries.map(\.file.rootID)).count > 1 + return entries.map { entry in + let codeMapContent: String? + let availableCodeMapTokenCount: Int + if let api = codemapSnapshots[entry.file.id]?.fileAPI { + availableCodeMapTokenCount = api.apiTokenCount + if entry.isCodemap { + let displayPath = Self.selectedPath( + for: entry, + filePathDisplay: filePathDisplay, + hasMultipleRoots: hasMultipleRoots + ) + let description = api.getFullAPIDescription(displayPath: displayPath) + codeMapContent = description.isEmpty ? nil : description + } else { + codeMapContent = nil + } + } else { + availableCodeMapTokenCount = 0 + codeMapContent = nil + } + return PromptFileEntrySnapshot( + fileID: entry.file.id, + relativePath: entry.file.relativePath, + isCodemapRequested: entry.isCodemap, + ranges: entry.lineRanges, + cachedFullTokenCount: entry.loadedContent.map(TokenCalculationService.estimateTokens(for:)), + loadedContent: entry.loadedContent, + codeMapContent: codeMapContent, + availableCodeMapTokenCount: availableCodeMapTokenCount + ) + } + } + + private func resolveEntries( + selection: StoredSelection, + store: WorkspaceFileContextStore, + rootScope: WorkspaceLookupRootScope, + profile: PathLocateProfile, + codeMapUsage: CodeMapUsage, + codemapSnapshots: [UUID: WorkspaceCodemapSnapshot] + ) async throws -> PromptContextAccountingResolution { + var intents: [PromptContextEntryIntent] = [] + var missingPaths: [String] = [] + var invalidPaths: [String] = [] + var seenIDs = Set() + var selectedFileIDs = Set() + + func appendIntent(_ intent: PromptContextEntryIntent) { + guard seenIDs.insert(intent.id).inserted else { return } + intents.append(intent) + } + + var selectedPathLookupInputs: [String] = [] + var invalidSelectedPathIndexes = Set() + selectedPathLookupInputs.reserveCapacity(selection.selectedPaths.count) + for (index, path) in selection.selectedPaths.enumerated() { + try Task.checkCancellation() + if await store.exactPathResolutionIssue(for: path, kind: .either, rootScope: rootScope) != nil { + invalidPaths.append(path) + invalidSelectedPathIndexes.insert(index) + } else { + selectedPathLookupInputs.append(path) + } + } + + let selectedPathLookupRequests = selectedPathLookupInputs.map { + WorkspacePathLookupRequest(userPath: $0, profile: profile, rootScope: rootScope) + } + let selectedPathLookupResults = await store.lookupPaths(selectedPathLookupRequests) + try Task.checkCancellation() + + var selectedPathResultsByIndex: [Int: WorkspacePathLookupResult] = [:] + let rootRefs = await store.rootRefs(scope: rootScope) + for (selectedPathIndex, path) in selection.selectedPaths.enumerated() { + try Task.checkCancellation() + guard !invalidSelectedPathIndexes.contains(selectedPathIndex) else { continue } + if let result = selectedPathLookupResults[path] { + selectedPathResultsByIndex[selectedPathIndex] = result + } else if let result = await store.lookupPath(path, profile: profile, rootScope: rootScope) { + selectedPathResultsByIndex[selectedPathIndex] = result + } else { + let folderResolution = await store.resolveFolderInput( + path, + rootScope: rootScope, + profile: profile + ) + if let folder = folderResolution.folder, + let root = rootRefs.first(where: { $0.id == folder.rootID }) + { + selectedPathResultsByIndex[selectedPathIndex] = WorkspacePathLookupResult( + input: path, + location: WorkspacePathLocation( + rootID: root.id, + rootPath: root.fullPath, + correctedPath: folder.standardizedRelativePath + ), + file: nil, + folder: folder + ) + } else if folderResolution.issue != nil { + invalidPaths.append(path) + invalidSelectedPathIndexes.insert(selectedPathIndex) + } + } + } + + for (selectedPathIndex, path) in selection.selectedPaths.enumerated() { + try Task.checkCancellation() + guard !invalidSelectedPathIndexes.contains(selectedPathIndex) else { continue } + guard let result = selectedPathResultsByIndex[selectedPathIndex] else { + missingPaths.append(path) + continue + } + + if let file = result.file { + selectedFileIDs.insert(file.id) + let ranges = sliceRanges(for: path, file: file, location: result.location, in: selection.slices) + let useSelectedCodemap = codeMapUsage == .selected && codemapSnapshots[file.id]?.fileAPI != nil + appendIntent(PromptContextEntryIntent( + file: file, + isCodemap: useSelectedCodemap, + lineRanges: useSelectedCodemap ? nil : ranges, + mode: useSelectedCodemap ? .codemap : ((ranges?.isEmpty == false) ? .sliced : .fullFile), + rootFolderPath: result.location.rootPath + )) + } else if let folder = result.folder { + let files = await store.files(inRoot: folder.rootID) + let prefix = folder.standardizedRelativePath + for file in files where prefix.isEmpty || file.standardizedRelativePath == prefix || file.standardizedRelativePath.hasPrefix(prefix + "/") { + try Task.checkCancellation() + selectedFileIDs.insert(file.id) + let useSelectedCodemap = codeMapUsage == .selected && codemapSnapshots[file.id]?.fileAPI != nil + appendIntent(PromptContextEntryIntent( + file: file, + isCodemap: useSelectedCodemap, + lineRanges: nil, + mode: useSelectedCodemap ? .codemap : .fullFile, + rootFolderPath: result.location.rootPath + )) + } + } else { + invalidPaths.append(path) + } + } + + for (path, ranges) in selection.slices { + try Task.checkCancellation() + if await store.exactPathResolutionIssue(for: path, kind: .file, rootScope: rootScope) != nil { + invalidPaths.append(path) + continue + } + guard let result = await store.lookupPath(path, profile: profile, rootScope: rootScope) else { + missingPaths.append(path) + continue + } + guard let file = result.file else { + invalidPaths.append(path) + continue + } + guard !selectedFileIDs.contains(file.id) else { continue } + selectedFileIDs.insert(file.id) + appendIntent(PromptContextEntryIntent( + file: file, + isCodemap: false, + lineRanges: ranges, + mode: .sliced, + rootFolderPath: result.location.rootPath + )) + } + + let codemapPaths: [String] = switch codeMapUsage { + case .none, .selected: + [] + case .auto: + selection.autoCodemapPaths + case .complete: + codemapSnapshots.compactMap { fileID, snapshot in + guard !selectedFileIDs.contains(fileID), snapshot.fileAPI != nil else { return nil } + return snapshot.fullPath + } + } + + for path in codemapPaths { + try Task.checkCancellation() + if await store.exactPathResolutionIssue(for: path, kind: .file, rootScope: rootScope) != nil { + invalidPaths.append(path) + continue + } + guard let result = await store.lookupPath(path, profile: profile, rootScope: rootScope) else { + missingPaths.append(path) + continue + } + guard let file = result.file else { + invalidPaths.append(path) + continue + } + guard !selectedFileIDs.contains(file.id), codemapSnapshots[file.id]?.fileAPI != nil else { continue } + appendIntent(PromptContextEntryIntent( + file: file, + isCodemap: true, + lineRanges: nil, + mode: .codemap, + rootFolderPath: result.location.rootPath + )) + } + + let contentByIntentIndex = try await readContents(for: intents, store: store) + try Task.checkCancellation() + let entries = intents.enumerated().map { index, intent in + ResolvedPromptFileEntry( + file: intent.file, + isCodemap: intent.isCodemap, + lineRanges: intent.lineRanges, + mode: intent.mode, + loadedContent: contentByIntentIndex[index] ?? nil, + rootFolderPath: intent.rootFolderPath + ) + } + return PromptContextAccountingResolution( + entries: entries, + missingPaths: Array(Set(missingPaths)).sorted(), + invalidPaths: Array(Set(invalidPaths)).sorted() + ) + } + + private func resolveEntries( + plan: WorkspaceContextProjectionPlan, + store: WorkspaceFileContextStore + ) async throws -> PromptContextAccountingResolution { + try Task.checkCancellation() + let intents = plan.occurrences.map { prepared in + let occurrence = prepared.value + let mode: PromptFileEntryMode = switch occurrence.mode { + case .full: + .fullFile + case .slice: + .sliced + case .codemap: + .codemap + } + return PromptContextEntryIntent( + file: occurrence.file, + isCodemap: occurrence.mode == .codemap, + lineRanges: occurrence.mode == .slice ? occurrence.ranges : nil, + mode: mode, + rootFolderPath: occurrence.metadata.rootPath + ) + } + let contentByIntentIndex = try await readContents(for: intents, store: store) + try Task.checkCancellation() + let entries = intents.enumerated().map { index, intent in + ResolvedPromptFileEntry( + file: intent.file, + isCodemap: intent.isCodemap, + lineRanges: intent.lineRanges, + mode: intent.mode, + loadedContent: contentByIntentIndex[index] ?? nil, + rootFolderPath: intent.rootFolderPath + ) + } + return PromptContextAccountingResolution( + entries: entries, + missingPaths: plan.missingPaths, + invalidPaths: plan.invalidPaths + ) + } + + private func readContents( + for intents: [PromptContextEntryIntent], + store: WorkspaceFileContextStore + ) async throws -> [Int: String?] { + let requests = intents.enumerated().compactMap { index, intent -> PromptContextContentReadRequest? in + guard !intent.isCodemap else { return nil } + return PromptContextContentReadRequest(intentIndex: index, file: intent.file) + } + guard !requests.isEmpty else { return [:] } + try Task.checkCancellation() + + return try await withThrowingTaskGroup( + of: PromptContextContentReadResult.self, + returning: [Int: String?].self + ) { group in + var iterator = requests.makeIterator() + var activeReads = 0 + var results: [Int: String?] = [:] + + func enqueueNextReadIfAvailable() { + guard activeReads < Self.selectedFileReadConcurrencyLimit, + let request = iterator.next() + else { return } + activeReads += 1 + group.addTask { + try Task.checkCancellation() + do { + let content = try await store.readContent( + rootID: request.file.rootID, + relativePath: request.file.standardizedRelativePath + ) + try Task.checkCancellation() + return PromptContextContentReadResult( + intentIndex: request.intentIndex, + content: content + ) + } catch is CancellationError { + throw CancellationError() + } catch { + return PromptContextContentReadResult( + intentIndex: request.intentIndex, + content: nil + ) + } + } + } + + for _ in 0 ..< Self.selectedFileReadConcurrencyLimit { + enqueueNextReadIfAvailable() + } + while activeReads > 0 { + try Task.checkCancellation() + guard let result = try await group.next() else { break } + activeReads -= 1 + results[result.intentIndex] = result.content + enqueueNextReadIfAvailable() + } + try Task.checkCancellation() + return results + } + } + + private nonisolated static func selectedPath( + for entry: ResolvedPromptFileEntry, + filePathDisplay: FilePathDisplay, + hasMultipleRoots: Bool + ) -> String { + if filePathDisplay == .relative { + if hasMultipleRoots, let rootFolderPath = entry.rootFolderPath, !rootFolderPath.isEmpty { + let rootFolderName = (StandardizedPath.absolute(rootFolderPath) as NSString).lastPathComponent + return rootFolderName.isEmpty ? entry.file.relativePath : "\(rootFolderName)/\(entry.file.relativePath)" + } + return entry.file.relativePath + } + return entry.file.fullPath + } + + private nonisolated func sliceRanges( + for path: String, + file: WorkspaceFileRecord, + location: WorkspacePathLocation, + in slices: [String: [LineRange]] + ) -> [LineRange]? { + let candidateKeys = [ + path, + StandardizedPath.absolute(path), + file.relativePath, + file.standardizedRelativePath, + file.fullPath, + file.standardizedFullPath, + location.absolutePath + ] + for key in candidateKeys { + if let ranges = slices[key] { return ranges } + } + return nil + } +} diff --git a/Sources/RepoPromptCore/Prompt/PromptRenderPolicy.swift b/Sources/RepoPromptCore/Prompt/PromptRenderPolicy.swift new file mode 100644 index 000000000..0df566960 --- /dev/null +++ b/Sources/RepoPromptCore/Prompt/PromptRenderPolicy.swift @@ -0,0 +1,15 @@ +public struct PromptRenderPolicy: Equatable, Sendable { + public let sectionOrder: [PromptSection] + public let disabledSections: Set + public let duplicateUserInstructionsAtTop: Bool + + public init( + sectionOrder: [PromptSection], + disabledSections: Set, + duplicateUserInstructionsAtTop: Bool + ) { + self.sectionOrder = sectionOrder + self.disabledSections = disabledSections + self.duplicateUserInstructionsAtTop = duplicateUserInstructionsAtTop + } +} diff --git a/Sources/RepoPromptCore/Prompt/PromptRenderingService.swift b/Sources/RepoPromptCore/Prompt/PromptRenderingService.swift new file mode 100644 index 000000000..1a47d2c4f --- /dev/null +++ b/Sources/RepoPromptCore/Prompt/PromptRenderingService.swift @@ -0,0 +1,207 @@ +import Foundation + +package enum PromptRenderingService { + @inline(__always) + package static func codeFenceStart(for fileName: String) -> String { + let fileExtension = URL(fileURLWithPath: fileName).pathExtension + return fileExtension.isEmpty ? "```" : "```\(fileExtension)" + } + + package static func renderFileBlocks( + _ values: [PromptRenderingFileValue] + ) -> [PromptRenderedFileBlock] { + var blocks: [PromptRenderedFileBlock] = [] + blocks.reserveCapacity(values.count) + + for (index, value) in values.enumerated() { + if let codemapText = value.codemapText { + blocks.append( + PromptRenderedFileBlock( + inputIndex: index, + text: codemapText, + kind: .codemap + ) + ) + continue + } + + guard let content = value.content else { continue } + let assembly = SliceAssemblyBuilder.build(from: content, ranges: value.ranges) + let startFence = codeFenceStart(for: value.fileName) + let text = if assembly.isFullFile { + renderFullFileBlock( + displayPath: value.displayPath, + startFence: startFence, + content: assembly.combinedText + ) + } else { + renderSliceFileBlock( + displayPath: value.displayPath, + startFence: startFence, + segments: assembly.segments + ) + } + blocks.append( + PromptRenderedFileBlock( + inputIndex: index, + text: text, + kind: .content + ) + ) + } + + return blocks + } + + package static func renderPartitionedFileBlocks( + _ values: [PromptRenderingFileValue] + ) -> PromptPartitionedFileBlocks { + let blocks = renderFileBlocks(values) + var codemapBlocks: [String] = [] + var contentBlocks: [String] = [] + codemapBlocks.reserveCapacity(blocks.count) + contentBlocks.reserveCapacity(blocks.count) + + for block in blocks where !block.text.isEmpty { + switch block.kind { + case .codemap: + codemapBlocks.append(block.text) + case .content: + contentBlocks.append(block.text) + } + } + + return PromptPartitionedFileBlocks( + codemapBlocks: codemapBlocks, + contentBlocks: contentBlocks + ) + } + + package static func renderDiffParts( + _ values: [PromptRenderingDiffValue] + ) -> [String] { + var parts: [String] = [] + parts.reserveCapacity(values.count) + + for value in values { + guard let content = value.content, !content.isEmpty else { continue } + let assembly = SliceAssemblyBuilder.build(from: content, ranges: value.ranges) + let text = assembly.isFullFile + ? assembly.combinedText + : assembly.segments.map(\.text).joined(separator: "\n") + if !text.isEmpty { + parts.append(text) + } + } + + return parts + } + + package static func renderSelectedDiffText( + _ values: [PromptRenderingDiffValue] + ) -> String? { + let parts = renderDiffParts(values) + return parts.isEmpty ? nil : parts.joined(separator: "\n\n") + } + + package static func renderFactualSnippets( + fileTreeContent: String?, + codemapBlocks: [String], + contentBlocks: [String], + gitDiff: String?, + envelopePolicy: PromptFactualEnvelopePolicy = .canonical + ) -> PromptRenderedFactualSnippets { + let codemapText = codemapBlocks.joined(separator: "\n\n") + let fileMapBody = [fileTreeContent ?? "", codemapText] + .filter { !$0.isEmpty } + .joined(separator: "\n\n") + let fileMap = wrapFactualBody( + fileMapBody, + tag: envelopePolicy.fileMapTag, + closeSpacing: envelopePolicy.fileMapCloseSpacing, + terminator: envelopePolicy.fragmentTerminator + ) + + let fileContents = wrapFactualBody( + contentBlocks.joined(separator: "\n\n"), + tag: envelopePolicy.fileContentsTag, + closeSpacing: envelopePolicy.fileContentsCloseSpacing, + terminator: envelopePolicy.fragmentTerminator, + allowEmptyBody: !contentBlocks.isEmpty + ) + + let gitDiffSnippet = wrapFactualBody( + gitDiff ?? "", + tag: envelopePolicy.gitDiffTag, + closeSpacing: envelopePolicy.gitDiffCloseSpacing, + terminator: envelopePolicy.fragmentTerminator + ) + + return PromptRenderedFactualSnippets( + fileMap: fileMap, + fileContents: fileContents, + gitDiff: gitDiffSnippet + ) + } + + private static func wrapFactualBody( + _ body: String, + tag: String, + closeSpacing: PromptFactualEnvelopePolicy.WrapperCloseSpacing, + terminator: PromptFactualEnvelopePolicy.FragmentTerminator, + allowEmptyBody: Bool = false + ) -> String? { + guard allowEmptyBody || !body.isEmpty else { return nil } + let closePrefix = switch closeSpacing { + case .direct: + "\n" + case .blankLine: + "\n\n" + } + let suffix = switch terminator { + case .lineFeed: + "\n" + case .none: + "" + } + return "<\(tag)>\n\(body)\(closePrefix)\(suffix)" + } + + private static func renderFullFileBlock( + displayPath: String, + startFence: String, + content: String + ) -> String { + """ + File: \(displayPath) + \(startFence) + \(content) + ``` + """ + } + + private static func renderSliceFileBlock( + displayPath: String, + startFence: String, + segments: [WorkspaceSliceSegment] + ) -> String { + var lines = ["File: \(displayPath)"] + for (index, segment) in segments.enumerated() { + let rangeLabel = segment.range.start == segment.range.end + ? "\(segment.range.start)" + : "\(segment.range.start)-\(segment.range.end)" + if let description = segment.range.description, !description.isEmpty { + lines.append("(lines \(rangeLabel): \(description))") + } else { + lines.append("(lines \(rangeLabel))") + } + lines.append(startFence) + lines.append(segment.text) + lines.append("```") + if index != segments.count - 1 { + lines.append("") + } + } + return lines.joined(separator: "\n") + } +} diff --git a/Sources/RepoPromptCore/Prompt/PromptRenderingValues.swift b/Sources/RepoPromptCore/Prompt/PromptRenderingValues.swift new file mode 100644 index 000000000..8d104b35f --- /dev/null +++ b/Sources/RepoPromptCore/Prompt/PromptRenderingValues.swift @@ -0,0 +1,142 @@ +import Foundation + +package struct PromptRenderingFileValue: Equatable { + package let displayPath: String + package let fileName: String + package let content: String? + package let ranges: [LineRange]? + package let codemapText: String? + + package init( + displayPath: String, + fileName: String, + content: String?, + ranges: [LineRange]? = nil, + codemapText: String? = nil + ) { + self.displayPath = displayPath + self.fileName = fileName + self.content = content + self.ranges = ranges + self.codemapText = codemapText + } +} + +package struct PromptRenderingDiffValue: Equatable { + package let content: String? + package let ranges: [LineRange]? + + package init(content: String?, ranges: [LineRange]? = nil) { + self.content = content + self.ranges = ranges + } +} + +package enum PromptRenderedFileBlockKind: Equatable { + case codemap + case content +} + +package struct PromptRenderedFileBlock: Equatable { + package let inputIndex: Int + package let text: String + package let kind: PromptRenderedFileBlockKind + + package init(inputIndex: Int, text: String, kind: PromptRenderedFileBlockKind) { + self.inputIndex = inputIndex + self.text = text + self.kind = kind + } +} + +package struct PromptPartitionedFileBlocks: Equatable { + package let codemapBlocks: [String] + package let contentBlocks: [String] + + package init(codemapBlocks: [String], contentBlocks: [String]) { + self.codemapBlocks = codemapBlocks + self.contentBlocks = contentBlocks + } +} + +package struct PromptRenderedFactualSnippets: Equatable { + package let fileMap: String? + package let fileContents: String? + package let gitDiff: String? + + package init(fileMap: String?, fileContents: String?, gitDiff: String?) { + self.fileMap = fileMap + self.fileContents = fileContents + self.gitDiff = gitDiff + } +} + +package struct PromptFactualEnvelopePolicy: Equatable { + package enum FileMapEnvelope: Equatable { + case canonicalFileMap + case chatStyleFileTree + } + + package enum WrapperCloseSpacing: Equatable { + case direct + case blankLine + } + + package enum FragmentTerminator: Equatable { + case lineFeed + case none + } + + package let fileMapEnvelope: FileMapEnvelope + package let fileMapCloseSpacing: WrapperCloseSpacing + package let fileContentsCloseSpacing: WrapperCloseSpacing + package let gitDiffCloseSpacing: WrapperCloseSpacing + package let fragmentTerminator: FragmentTerminator + + package init( + fileMapEnvelope: FileMapEnvelope, + fileMapCloseSpacing: WrapperCloseSpacing, + fileContentsCloseSpacing: WrapperCloseSpacing, + gitDiffCloseSpacing: WrapperCloseSpacing, + fragmentTerminator: FragmentTerminator + ) { + self.fileMapEnvelope = fileMapEnvelope + self.fileMapCloseSpacing = fileMapCloseSpacing + self.fileContentsCloseSpacing = fileContentsCloseSpacing + self.gitDiffCloseSpacing = gitDiffCloseSpacing + self.fragmentTerminator = fragmentTerminator + } + + package static let canonical = PromptFactualEnvelopePolicy( + fileMapEnvelope: .canonicalFileMap, + fileMapCloseSpacing: .direct, + fileContentsCloseSpacing: .direct, + gitDiffCloseSpacing: .direct, + fragmentTerminator: .lineFeed + ) + + package static let chatStyleTree = PromptFactualEnvelopePolicy( + fileMapEnvelope: .chatStyleFileTree, + fileMapCloseSpacing: .direct, + fileContentsCloseSpacing: .blankLine, + gitDiffCloseSpacing: .direct, + fragmentTerminator: .none + ) + + package var fileMapTag: String { + switch fileMapEnvelope { + case .canonicalFileMap: + "file_map" + case .chatStyleFileTree: + "file_tree" + } + } + + package var fileContentsTag: String { + "file_contents" + } + + package var gitDiffTag: String { + "git_diff" + } +} diff --git a/Sources/RepoPromptCore/Prompt/PromptSection.swift b/Sources/RepoPromptCore/Prompt/PromptSection.swift new file mode 100644 index 000000000..ca2b46cb0 --- /dev/null +++ b/Sources/RepoPromptCore/Prompt/PromptSection.swift @@ -0,0 +1,15 @@ +import Foundation + +/// All blocks that can appear in the final prompt, in logical order. +/// Raw values are persisted by the app, so they must remain stable. +public enum PromptSection: String, CaseIterable, Identifiable, Codable, Sendable { + case fileMap + case fileContents + case metaPrompts + case userInstructions + case gitDiff + + public var id: String { + rawValue + } +} diff --git a/Sources/RepoPromptCore/Regex/PCRE2Error.swift b/Sources/RepoPromptCore/Regex/PCRE2Error.swift new file mode 100644 index 000000000..a6bb29ec5 --- /dev/null +++ b/Sources/RepoPromptCore/Regex/PCRE2Error.swift @@ -0,0 +1,57 @@ +import CSwiftPCRE2 +import Foundation + +public enum PCRE2LimitKind: Sendable, Equatable { + case match + case depth + case heap + case jitStack + + var description: String { + switch self { + case .match: + "MATCHLIMIT" + case .depth: + "DEPTHLIMIT" + case .heap: + "HEAPLIMIT" + case .jitStack: + "JIT_STACKLIMIT" + } + } +} + +public enum PCRE2Error: Error, LocalizedError, Sendable, Equatable { + case compile(pattern: String, offset: Int, code: Int32, message: String) + case match(code: Int32, message: String) + case matchLimitExceeded(kind: PCRE2LimitKind, code: Int32, message: String) + case jitRequiredButUnavailable(String) + case internalInvariant(String) + + public var errorDescription: String? { + switch self { + case let .compile(pattern, offset, code, message): + "PCRE2 compile error at byte offset \(offset) for pattern \(String(reflecting: pattern)) (\(code)): \(message)" + case let .match(code, message): + "PCRE2 match error (\(code)): \(message)" + case let .matchLimitExceeded(kind, code, message): + "PCRE2 match limit exceeded (\(kind.description), \(code)): \(message)" + case let .jitRequiredButUnavailable(message): + "PCRE2 JIT required but unavailable: \(message)" + case let .internalInvariant(message): + "PCRE2 wrapper invariant failed: \(message)" + } + } +} + +func pcre2ErrorMessage(_ code: Int32) -> String { + var buffer = [UInt8](repeating: 0, count: 512) + let rc = buffer.withUnsafeMutableBufferPointer { pointer in + rp_pcre2_get_error_message_8(Int32(code), pointer.baseAddress, pointer.count) + } + guard rc >= 0 else { + return "unknown PCRE2 error \(code)" + } + let length = buffer.firstIndex(of: 0) ?? Int(rc) + return String(decoding: buffer[.. String { + if literal.isEmpty { + return "" + } + return "\\Q" + literal.replacingOccurrences(of: "\\E", with: "\\E\\\\E\\Q") + "\\E" + } +} diff --git a/Sources/RepoPromptCore/Regex/PCRE2Match.swift b/Sources/RepoPromptCore/Regex/PCRE2Match.swift new file mode 100644 index 000000000..23d653a5c --- /dev/null +++ b/Sources/RepoPromptCore/Regex/PCRE2Match.swift @@ -0,0 +1,9 @@ +public struct PCRE2Match: Sendable, Equatable { + public let byteRange: Range + public let captureByteRanges: [Range?] + + public init(byteRange: Range, captureByteRanges: [Range?]) { + self.byteRange = byteRange + self.captureByteRanges = captureByteRanges + } +} diff --git a/Sources/RepoPromptCore/Regex/PCRE2Options.swift b/Sources/RepoPromptCore/Regex/PCRE2Options.swift new file mode 100644 index 000000000..ad98f8912 --- /dev/null +++ b/Sources/RepoPromptCore/Regex/PCRE2Options.swift @@ -0,0 +1,49 @@ +import CSwiftPCRE2 + +public struct PCRE2CompileOptions: OptionSet, Sendable { + public let rawValue: UInt32 + + public init(rawValue: UInt32) { + self.rawValue = rawValue + } + + public static let utf = PCRE2CompileOptions(rawValue: rp_pcre2_option_utf_8()) + public static let unicodeProperties = PCRE2CompileOptions(rawValue: rp_pcre2_option_ucp_8()) + public static let caseless = PCRE2CompileOptions(rawValue: rp_pcre2_option_caseless_8()) + public static let multiline = PCRE2CompileOptions(rawValue: rp_pcre2_option_multiline_8()) + public static let dotMatchesNewline = PCRE2CompileOptions(rawValue: rp_pcre2_option_dotall_8()) + + public static let defaultRegex: PCRE2CompileOptions = [.utf, .unicodeProperties] +} + +public struct PCRE2MatchOptions: OptionSet, Sendable { + public let rawValue: UInt32 + + public init(rawValue: UInt32) { + self.rawValue = rawValue + } + + public static let noUTFCheck = PCRE2MatchOptions(rawValue: rp_pcre2_option_no_utf_check_8()) + public static let notBOL = PCRE2MatchOptions(rawValue: rp_pcre2_option_notbol_8()) + public static let notEOL = PCRE2MatchOptions(rawValue: rp_pcre2_option_noteol_8()) + + public static let trustedSwiftString: PCRE2MatchOptions = [.noUTFCheck] +} + +public struct PCRE2MatchLimits: Sendable, Equatable { + public let matchLimit: UInt32? + public let depthLimit: UInt32? + public let heapLimitKiB: UInt32? + + public init(matchLimit: UInt32? = nil, depthLimit: UInt32? = nil, heapLimitKiB: UInt32? = nil) { + self.matchLimit = matchLimit + self.depthLimit = depthLimit + self.heapLimitKiB = heapLimitKiB + } +} + +public enum PCRE2JITMode: Sendable, Equatable { + case disabled + case auto + case required +} diff --git a/Sources/RepoPromptCore/Regex/PCRE2Regex.swift b/Sources/RepoPromptCore/Regex/PCRE2Regex.swift new file mode 100644 index 000000000..bc08250c5 --- /dev/null +++ b/Sources/RepoPromptCore/Regex/PCRE2Regex.swift @@ -0,0 +1,1214 @@ +import CSwiftPCRE2 + +public final class PCRE2Regex: @unchecked Sendable { + /// A reusable, single-consumer matching session. + /// + /// A session may be reused across multiple subjects to avoid per-match allocation + /// churn, but it owns mutable PCRE2 match state and is not thread-safe. Do not use + /// the same session concurrently from multiple tasks or threads. + public final class MatchSession { + fileprivate let regex: PCRE2Regex + fileprivate let matchData: OpaquePointer + fileprivate let matchContext: OpaquePointer? + + fileprivate init(regex: PCRE2Regex, matchLimits: PCRE2MatchLimits?) throws { + let createdMatchData = try regex.makeMatchData() + do { + let createdMatchContext = try regex.makeMatchContext(limits: matchLimits) + self.regex = regex + matchData = createdMatchData + matchContext = createdMatchContext + } catch { + rp_pcre2_match_data_free_8(createdMatchData) + throw error + } + } + + deinit { + rp_pcre2_match_data_free_8(matchData) + if let matchContext { + rp_pcre2_match_context_free_8(matchContext) + } + } + + public func firstMatch( + in subject: String, + startOffset: Int = 0, + options: PCRE2MatchOptions = .trustedSwiftString + ) throws -> PCRE2Match? { + try regex.withSubjectBuffer(for: subject) { buffer in + try regex.match(in: buffer, startOffset: startOffset, options: options, matchData: matchData, matchContext: matchContext) + } + } + + public func firstMatch( + in subject: Substring, + startOffset: Int = 0, + options: PCRE2MatchOptions = .trustedSwiftString + ) throws -> PCRE2Match? { + try regex.withSubjectBuffer(for: subject) { buffer in + try regex.match(in: buffer, startOffset: startOffset, options: options, matchData: matchData, matchContext: matchContext) + } + } + + public func containsMatch( + in subject: String, + startOffset: Int = 0, + options: PCRE2MatchOptions = .trustedSwiftString + ) throws -> Bool { + try regex.withSubjectBuffer(for: subject) { buffer in + try regex.containsMatch(in: buffer, startOffset: startOffset, options: options, matchData: matchData, matchContext: matchContext) + } + } + + public func containsMatch( + in subject: Substring, + startOffset: Int = 0, + options: PCRE2MatchOptions = .trustedSwiftString + ) throws -> Bool { + try regex.withSubjectBuffer(for: subject) { buffer in + try regex.containsMatch(in: buffer, startOffset: startOffset, options: options, matchData: matchData, matchContext: matchContext) + } + } + } + + public let pattern: String + public let compileOptions: PCRE2CompileOptions + public let jitStatus: PCRE2JITStatus + + private let code: OpaquePointer + + public init( + _ pattern: String, + options: PCRE2CompileOptions = .defaultRegex, + jit: PCRE2JITMode = .auto + ) throws { + self.pattern = pattern + compileOptions = options + + var errorCode: Int32 = 0 + var errorOffset = 0 + let patternBytes = Array(pattern.utf8) + let compiled: OpaquePointer? = patternBytes.withUnsafeBufferPointer { pointer in + withPCRE2BytePointer(for: pointer) { base in + rp_pcre2_compile_8(base, pointer.count, options.rawValue, &errorCode, &errorOffset) + } + } + + guard let compiled else { + throw PCRE2Error.compile( + pattern: pattern, + offset: errorOffset, + code: errorCode, + message: pcre2ErrorMessage(errorCode) + ) + } + + let resolvedJITStatus: PCRE2JITStatus + switch jit { + case .disabled: + resolvedJITStatus = .disabled + case .auto, .required: + let status = Self.compileJITIfPossible(compiled) + switch (jit, status) { + case (.required, .compiled): + resolvedJITStatus = status + case (.required, .disabled), (.required, .unavailable), (.required, .fallback): + rp_pcre2_code_free_8(compiled) + throw PCRE2Error.jitRequiredButUnavailable(status.descriptionForRequiredMode) + default: + resolvedJITStatus = status + } + } + + code = compiled + jitStatus = resolvedJITStatus + } + + deinit { + rp_pcre2_code_free_8(code) + } + + public func withMatchSession( + matchLimits: PCRE2MatchLimits? = nil, + _ body: (MatchSession) throws -> R + ) throws -> R { + let session = try MatchSession(regex: self, matchLimits: matchLimits) + return try body(session) + } + + public func firstMatch( + in subject: String, + options: PCRE2MatchOptions = .trustedSwiftString, + matchLimits: PCRE2MatchLimits? = nil + ) throws -> PCRE2Match? { + try withMatchSession(matchLimits: matchLimits) { session in + try session.firstMatch(in: subject, options: options) + } + } + + public func firstMatch( + in subject: Substring, + options: PCRE2MatchOptions = .trustedSwiftString, + matchLimits: PCRE2MatchLimits? = nil + ) throws -> PCRE2Match? { + try withMatchSession(matchLimits: matchLimits) { session in + try session.firstMatch(in: subject, options: options) + } + } + + public func enumerateMatches( + in subject: String, + options: PCRE2MatchOptions = .trustedSwiftString, + limit: Int? = nil, + matchLimits: PCRE2MatchLimits? = nil, + _ body: (PCRE2Match) throws -> Bool + ) throws { + let byteCount = subject.utf8.count + var startOffset = 0 + var emitted = 0 + + let matchData = try makeMatchData() + defer { rp_pcre2_match_data_free_8(matchData) } + + let matchContext = try makeMatchContext(limits: matchLimits) + defer { + if let matchContext { + rp_pcre2_match_context_free_8(matchContext) + } + } + + try withSubjectBuffer(for: subject) { buffer in + while startOffset <= byteCount { + if let limit, emitted >= limit { return } + guard let match = try match(in: buffer, startOffset: startOffset, options: options, matchData: matchData, matchContext: matchContext) else { + return + } + + emitted += 1 + let shouldContinue = try body(match) + if !shouldContinue { return } + + if match.byteRange.isEmpty { + let next = Self.nextUTF8ScalarBoundary(in: subject, after: startOffset) + if next <= startOffset { return } + startOffset = next + } else { + startOffset = match.byteRange.upperBound + } + } + } + } + + private func withSubjectBuffer( + for subject: String, + _ body: (UnsafeBufferPointer) throws -> R + ) throws -> R { + if let result = try subject.utf8.withContiguousStorageIfAvailable({ buffer in + try body(buffer) + }) { + return result + } + + let subjectBytes = Array(subject.utf8) + return try subjectBytes.withUnsafeBufferPointer { buffer in + try body(buffer) + } + } + + private func withSubjectBuffer( + for subject: Substring, + _ body: (UnsafeBufferPointer) throws -> R + ) throws -> R { + if let result = try subject.utf8.withContiguousStorageIfAvailable({ buffer in + try body(buffer) + }) { + return result + } + + let subjectBytes = Array(subject.utf8) + return try subjectBytes.withUnsafeBufferPointer { buffer in + try body(buffer) + } + } + + private func makeMatchData() throws -> OpaquePointer { + guard let matchData = rp_pcre2_match_data_create_from_pattern_8(code) else { + throw PCRE2Error.internalInvariant("pcre2_match_data_create_from_pattern returned nil") + } + return matchData + } + + private func makeMatchContext(limits: PCRE2MatchLimits?) throws -> OpaquePointer? { + guard let limits else { return nil } + guard let context = rp_pcre2_match_context_create_8() else { + throw PCRE2Error.internalInvariant("pcre2_match_context_create returned nil") + } + + do { + if let limit = limits.matchLimit { + try applyMatchContextLimit(rp_pcre2_set_match_limit_8(context, limit), name: "match") + } + if let limit = limits.depthLimit { + try applyMatchContextLimit(rp_pcre2_set_depth_limit_8(context, limit), name: "depth") + } + if let limit = limits.heapLimitKiB { + try applyMatchContextLimit(rp_pcre2_set_heap_limit_8(context, limit), name: "heap") + } + return context + } catch { + rp_pcre2_match_context_free_8(context) + throw error + } + } + + private func applyMatchContextLimit(_ rc: Int32, name: String) throws { + guard rc == 0 else { + throw PCRE2Error.internalInvariant("pcre2_set_\(name)_limit failed (\(rc)): \(pcre2ErrorMessage(rc))") + } + } + + private func match( + in buffer: UnsafeBufferPointer, + startOffset: Int, + options: PCRE2MatchOptions, + matchData: OpaquePointer, + matchContext: OpaquePointer? + ) throws -> PCRE2Match? { + let rc = rawMatchReturnCode(in: buffer, startOffset: startOffset, options: options, matchData: matchData, matchContext: matchContext) + + if rc == rp_pcre2_error_nomatch_8() { + return nil + } + guard rc >= 0 else { + throw Self.matchError(for: rc) + } + + let count = Int(rp_pcre2_get_ovector_count_8(matchData)) + guard let ovector = rp_pcre2_get_ovector_pointer_8(matchData) else { + throw PCRE2Error.internalInvariant("pcre2_get_ovector_pointer returned nil") + } + let unset = Int(rp_pcre2_unset_8()) + var ranges: [Range?] = [] + ranges.reserveCapacity(count) + + for index in 0 ..< count { + let lower = Int(ovector[index * 2]) + let upper = Int(ovector[index * 2 + 1]) + if lower == unset || upper == unset { + ranges.append(nil) + } else { + ranges.append(lower ..< upper) + } + } + + guard let fullRange = ranges.first ?? nil else { + throw PCRE2Error.internalInvariant("match succeeded without a full-match range") + } + return PCRE2Match(byteRange: fullRange, captureByteRanges: ranges) + } + + private func containsMatch( + in buffer: UnsafeBufferPointer, + startOffset: Int, + options: PCRE2MatchOptions, + matchData: OpaquePointer, + matchContext: OpaquePointer? + ) throws -> Bool { + let rc = rawMatchReturnCode(in: buffer, startOffset: startOffset, options: options, matchData: matchData, matchContext: matchContext) + + if rc == rp_pcre2_error_nomatch_8() { + return false + } + guard rc >= 0 else { + throw Self.matchError(for: rc) + } + return true + } + + private func rawMatchReturnCode( + in buffer: UnsafeBufferPointer, + startOffset: Int, + options: PCRE2MatchOptions, + matchData: OpaquePointer, + matchContext: OpaquePointer? + ) -> Int32 { + withPCRE2BytePointer(for: buffer) { base in + if jitStatus.isCompiled { + let jitRC = rp_pcre2_jit_match_with_context_8(code, base, buffer.count, startOffset, options.rawValue, matchData, matchContext) + if jitRC != rp_pcre2_error_jit_badoption_8() { + return jitRC + } + } + return rp_pcre2_match_with_context_8(code, base, buffer.count, startOffset, options.rawValue, matchData, matchContext) + } + } + + private static func matchError(for code: Int32) -> PCRE2Error { + let message = pcre2ErrorMessage(code) + if let kind = limitKind(for: code) { + return .matchLimitExceeded(kind: kind, code: code, message: message) + } + return .match(code: code, message: message) + } + + private static func limitKind(for code: Int32) -> PCRE2LimitKind? { + if code == rp_pcre2_error_matchlimit_8() { + return .match + } + if code == rp_pcre2_error_depthlimit_8() { + return .depth + } + if code == rp_pcre2_error_heaplimit_8() { + return .heap + } + if code == rp_pcre2_error_jit_stacklimit_8() { + return .jitStack + } + return nil + } + + private static func compileJITIfPossible(_ code: OpaquePointer) -> PCRE2JITStatus { + let configured = rp_pcre2_config_jit_8() + if configured <= 0 { + return .unavailable(reason: configured == 0 ? "PCRE2 was built without JIT support" : pcre2ErrorMessage(configured)) + } + + let rc = rp_pcre2_jit_compile_8(code, rp_pcre2_jit_complete_8()) + guard rc == 0 else { + return .fallback(errorCode: rc, message: pcre2ErrorMessage(rc)) + } + + var size = 0 + let infoRC = rp_pcre2_jit_size_8(code, &size) + guard infoRC == 0 else { + return .fallback(errorCode: infoRC, message: pcre2ErrorMessage(infoRC)) + } + if size > 0 { + return .compiled(sizeBytes: size) + } + return .unavailable(reason: "PCRE2 accepted JIT compilation but reported no JIT code size") + } + + private static func nextUTF8ScalarBoundary(in subject: String, after byteOffset: Int) -> Int { + let byteCount = subject.utf8.count + if byteOffset >= byteCount { return byteCount } + + var lower = 0 + for scalar in subject.unicodeScalars { + let upper = lower + scalar.utf8.count + if byteOffset < upper { + return upper + } + lower = upper + } + return byteCount + } +} + +public struct PCRE2LinePrefilter: Sendable, Equatable { + public let asciiRequiredAlternatives: [String] + public let caseInsensitive: Bool + + public init(asciiRequiredAlternatives: [String], caseInsensitive: Bool) { + self.asciiRequiredAlternatives = asciiRequiredAlternatives + self.caseInsensitive = caseInsensitive + } +} + +public struct PCRE2LineScanOptions: Sendable, Equatable { + public let maxLineUTF8Length: Int? + public let collectMatches: Bool + public let maxCollectedMatches: Int? + public let cancellationCheckStride: Int + public let prefilter: PCRE2LinePrefilter? + + public init( + maxLineUTF8Length: Int? = nil, + collectMatches: Bool = true, + maxCollectedMatches: Int? = nil, + cancellationCheckStride: Int = 256, + prefilter: PCRE2LinePrefilter? = nil + ) { + self.maxLineUTF8Length = maxLineUTF8Length + self.collectMatches = collectMatches + self.maxCollectedMatches = maxCollectedMatches + self.cancellationCheckStride = max(1, cancellationCheckStride) + self.prefilter = prefilter + } +} + +public struct PCRE2LineScanResult: Sendable, Equatable { + public let matchingLineNumbers: [Int] + public let lineMatchCount: Int + + public init(matchingLineNumbers: [Int], lineMatchCount: Int) { + self.matchingLineNumbers = matchingLineNumbers + self.lineMatchCount = lineMatchCount + } +} + +public struct PCRE2LineRangeHit: Sendable, Equatable { + public let lineNumber: Int + public let byteRange: Range + + public init(lineNumber: Int, byteRange: Range) { + self.lineNumber = lineNumber + self.byteRange = byteRange + } +} + +public struct PCRE2LineRangeScanResult: Sendable, Equatable { + public let hits: [PCRE2LineRangeHit] + public let lineMatchCount: Int + + public init(hits: [PCRE2LineRangeHit], lineMatchCount: Int) { + self.hits = hits + self.lineMatchCount = lineMatchCount + } +} + +public enum PCRE2LineMode: Sendable, Equatable { + case crlf +} + +public struct PCRE2ASCIIMarkerLinePattern: Sendable, Equatable { + private static let speculativeCollectCapacity = 64 + private static let maximumRequestedCollectCapacity = 16384 + + public let marker: String + public let digitCount: UInt32 + public let requiredPrefix: String + public let caseInsensitive: Bool + private let markerBytes: [UInt8] + private let requiredPrefixBytes: [UInt8] + + public init?(marker: String, digitCount: UInt32, requiredPrefix: String, caseInsensitive: Bool) { + guard digitCount > 0, + !marker.isEmpty, + !requiredPrefix.isEmpty, + marker.utf8.allSatisfy({ PCRE2ASCIIWholeWordLiteral.isASCIIWordByte($0) }), + requiredPrefix.utf8.allSatisfy({ PCRE2ASCIIWholeWordLiteral.isASCIIWordByte($0) }) + else { + return nil + } + self.marker = marker + self.digitCount = digitCount + self.requiredPrefix = requiredPrefix + self.caseInsensitive = caseInsensitive + markerBytes = marker.utf8.map { caseInsensitive ? PCRE2ASCIIWholeWordLiteral.asciiLowercase($0) : $0 } + requiredPrefixBytes = requiredPrefix.utf8.map { caseInsensitive ? PCRE2ASCIIWholeWordLiteral.asciiLowercase($0) : $0 } + } + + public func countMatchingLines(in subject: String) -> Int? { + scanMatchingLines(in: subject, collectMatches: false)?.lineMatchCount + } + + public func scanMatchingLineRanges( + in subject: String, + maxCollectedMatches: Int, + shouldCancel: () -> Bool = { false } + ) -> PCRE2LineRangeScanResult? { + guard maxCollectedMatches > 0 else { return PCRE2LineRangeScanResult(hits: [], lineMatchCount: 0) } + if shouldCancel() { return PCRE2LineRangeScanResult(hits: [], lineMatchCount: 0) } + guard !markerBytes.isEmpty, !requiredPrefixBytes.isEmpty else { return nil } + + func scan(_ buffer: UnsafeBufferPointer) -> PCRE2LineRangeScanResult? { + func collect(capacity requestedCapacity: Int) -> PCRE2LineRangeScanResult? { + let capacity = max(0, min(maxCollectedMatches, requestedCapacity)) + var lineNumbers = Array(repeating: 0, count: capacity) + var lineStarts = Array(repeating: 0, count: capacity) + var lineEnds = Array(repeating: 0, count: capacity) + var collectedCount = 0 + var lineCount = 0 + var nonASCII: Int32 = 0 + let rc = lineNumbers.withUnsafeMutableBufferPointer { lineNumberBuffer in + lineStarts.withUnsafeMutableBufferPointer { lineStartBuffer in + lineEnds.withUnsafeMutableBufferPointer { lineEndBuffer in + markerBytes.withUnsafeBufferPointer { markerBuffer in + requiredPrefixBytes.withUnsafeBufferPointer { prefixBuffer in + withPCRE2BytePointer(for: buffer) { subjectBase in + withPCRE2BytePointer(for: markerBuffer) { markerBase in + withPCRE2BytePointer(for: prefixBuffer) { prefixBase in + rp_pcre2_ascii_marker_line_range_scan_8( + subjectBase, + buffer.count, + markerBase, + markerBuffer.count, + digitCount, + prefixBase, + prefixBuffer.count, + caseInsensitive ? 1 : 0, + lineNumberBuffer.baseAddress, + lineStartBuffer.baseAddress, + lineEndBuffer.baseAddress, + lineNumberBuffer.count, + &collectedCount, + &lineCount, + &nonASCII + ) + } + } + } + } + } + } + } + } + guard rc == 0, nonASCII == 0 else { return nil } + let hits = (0 ..< collectedCount).map { index in + PCRE2LineRangeHit(lineNumber: lineNumbers[index], byteRange: lineStarts[index] ..< lineEnds[index]) + } + return PCRE2LineRangeScanResult(hits: hits, lineMatchCount: lineCount) + } + + let speculativeCapacity = min(maxCollectedMatches, Self.speculativeCollectCapacity) + guard let speculative = collect(capacity: speculativeCapacity) else { return nil } + if speculative.lineMatchCount <= speculativeCapacity || speculativeCapacity == maxCollectedMatches { + return speculative + } + return collect(capacity: min(maxCollectedMatches, speculative.lineMatchCount)) + } + + if let contiguous = subject.utf8.withContiguousStorageIfAvailable({ buffer in + scan(buffer) + }) { + return contiguous + } + let bytes = Array(subject.utf8) + return bytes.withUnsafeBufferPointer { scan($0) } + } + + public func scanMatchingLines( + in subject: String, + collectMatches: Bool, + maxCollectedMatches: Int? = nil, + shouldCancel: () -> Bool = { false } + ) -> PCRE2LineScanResult? { + if shouldCancel() { return PCRE2LineScanResult(matchingLineNumbers: [], lineMatchCount: 0) } + guard !markerBytes.isEmpty, !requiredPrefixBytes.isEmpty else { return nil } + + func scan(_ buffer: UnsafeBufferPointer) -> PCRE2LineScanResult? { + var lineCount = 0 + var nonASCII: Int32 = 0 + + func runScan(lineNumbers: UnsafeMutablePointer?, capacity: Int, collectedCount: inout Int) -> Int32 { + markerBytes.withUnsafeBufferPointer { markerBuffer in + requiredPrefixBytes.withUnsafeBufferPointer { prefixBuffer in + withPCRE2BytePointer(for: buffer) { subjectBase in + withPCRE2BytePointer(for: markerBuffer) { markerBase in + withPCRE2BytePointer(for: prefixBuffer) { prefixBase in + rp_pcre2_ascii_marker_line_scan_8( + subjectBase, + buffer.count, + markerBase, + markerBuffer.count, + digitCount, + prefixBase, + prefixBuffer.count, + caseInsensitive ? 1 : 0, + lineNumbers, + capacity, + &collectedCount, + &lineCount, + &nonASCII + ) + } + } + } + } + } + } + + func countOnlyResult() -> PCRE2LineScanResult? { + var ignoredCollectedCount = 0 + lineCount = 0 + nonASCII = 0 + let rc = runScan(lineNumbers: nil, capacity: 0, collectedCount: &ignoredCollectedCount) + guard rc == 0, nonASCII == 0 else { return nil } + return PCRE2LineScanResult(matchingLineNumbers: [], lineMatchCount: lineCount) + } + + func collectResult(capacity: Int) -> PCRE2LineScanResult? { + guard capacity > 0 else { return countOnlyResult() } + var collectedLines = Array(repeating: 0, count: capacity) + var collectedCount = 0 + lineCount = 0 + nonASCII = 0 + let rc = collectedLines.withUnsafeMutableBufferPointer { lineBuffer in + runScan(lineNumbers: lineBuffer.baseAddress, capacity: lineBuffer.count, collectedCount: &collectedCount) + } + guard rc == 0, nonASCII == 0 else { return nil } + return PCRE2LineScanResult(matchingLineNumbers: Array(collectedLines.prefix(collectedCount)), lineMatchCount: lineCount) + } + + guard collectMatches else { return countOnlyResult() } + if let maxCollectedMatches { + guard maxCollectedMatches > 0 else { return countOnlyResult() } + if maxCollectedMatches > Self.maximumRequestedCollectCapacity { + guard let counted = countOnlyResult() else { return nil } + let exactCapacity = min(maxCollectedMatches, counted.lineMatchCount) + guard exactCapacity > 0 else { return counted } + return collectResult(capacity: exactCapacity) + } + return collectResult(capacity: maxCollectedMatches) + } + + guard let speculative = collectResult(capacity: Self.speculativeCollectCapacity) else { return nil } + if speculative.lineMatchCount <= Self.speculativeCollectCapacity { + return speculative + } + return collectResult(capacity: speculative.lineMatchCount) + } + + if let contiguous = subject.utf8.withContiguousStorageIfAvailable({ buffer in + scan(buffer) + }) { + return contiguous + } + let bytes = Array(subject.utf8) + return bytes.withUnsafeBufferPointer { scan($0) } + } +} + +public struct PCRE2ASCIIWholeWordLiteral: Sendable, Equatable { + public let needle: String + public let caseInsensitive: Bool + + public init?(needle: String, caseInsensitive: Bool) { + guard !needle.isEmpty, + needle.utf8.allSatisfy({ PCRE2ASCIIWholeWordLiteral.isASCIIWordByte($0) }) + else { + return nil + } + self.needle = needle + self.caseInsensitive = caseInsensitive + } + + public func countMatchingLines(in subject: String) -> Int? { + scanMatchingLines(in: subject, collectMatches: false)?.lineMatchCount + } + + public func scanMatchingLines( + in subject: String, + lineMode: PCRE2LineMode = .crlf, + collectMatches: Bool, + maxCollectedMatches: Int? = nil, + cancellationCheckStride: Int = 256, + shouldCancel: () -> Bool = { false } + ) -> PCRE2LineScanResult? { + guard lineMode == .crlf else { return nil } + if collectMatches, subject.utf8.count > Self.cScanCollectByteLimit { + return nil + } + if shouldCancel() { return PCRE2LineScanResult(matchingLineNumbers: [], lineMatchCount: 0) } + let needleBytes = needle.utf8.map { caseInsensitive ? Self.asciiLowercase($0) : $0 } + guard !needleBytes.isEmpty else { return nil } + + func scan(_ buffer: UnsafeBufferPointer) -> PCRE2LineScanResult? { + var lineCount = 0 + var nonASCII: Int32 = 0 + + func runScan(lineNumbers: UnsafeMutablePointer?, capacity: Int, collectedCount: inout Int) -> Int32 { + needleBytes.withUnsafeBufferPointer { needleBuffer in + withPCRE2BytePointer(for: buffer) { subjectBase in + withPCRE2BytePointer(for: needleBuffer) { needleBase in + rp_pcre2_ascii_whole_word_line_scan_8( + subjectBase, + buffer.count, + needleBase, + needleBuffer.count, + caseInsensitive ? 1 : 0, + lineNumbers, + capacity, + &collectedCount, + &lineCount, + &nonASCII + ) + } + } + } + } + + var ignoredCollectedCount = 0 + let countRC = runScan(lineNumbers: nil, capacity: 0, collectedCount: &ignoredCollectedCount) + guard countRC == 0, nonASCII == 0 else { return nil } + guard collectMatches, lineCount > 0 else { + return PCRE2LineScanResult(matchingLineNumbers: [], lineMatchCount: lineCount) + } + + let capacity = min(maxCollectedMatches ?? lineCount, lineCount) + guard capacity > 0 else { + return PCRE2LineScanResult(matchingLineNumbers: [], lineMatchCount: lineCount) + } + + var collectedLines = Array(repeating: 0, count: capacity) + var collectedCount = 0 + lineCount = 0 + nonASCII = 0 + let collectRC = collectedLines.withUnsafeMutableBufferPointer { lineBuffer in + runScan(lineNumbers: lineBuffer.baseAddress, capacity: lineBuffer.count, collectedCount: &collectedCount) + } + guard collectRC == 0, nonASCII == 0 else { return nil } + return PCRE2LineScanResult( + matchingLineNumbers: Array(collectedLines.prefix(collectedCount)), + lineMatchCount: lineCount + ) + } + + if let contiguous = subject.utf8.withContiguousStorageIfAvailable({ buffer in + scan(buffer) + }) { + return contiguous + } + let bytes = Array(subject.utf8) + return bytes.withUnsafeBufferPointer { scan($0) } + } + + private static let cScanCollectByteLimit = 4 * 1024 * 1024 + + private static func matchesWholeWordNeedle( + at index: Int, + in buffer: UnsafeBufferPointer, + lineStart: Int, + needle: [UInt8], + caseInsensitive: Bool + ) -> Bool { + let first = caseInsensitive ? asciiLowercase(buffer[index]) : buffer[index] + guard first == needle[0] else { return false } + if needle.count > 1 { + for offset in 1 ..< needle.count { + let byte = buffer[index + offset] + if byte == 10 || byte == 13 { return false } + let hay = caseInsensitive ? asciiLowercase(byte) : byte + if hay != needle[offset] { return false } + } + } + + let previousIsWord = index > lineStart && isASCIIWordByte(buffer[index - 1]) + let nextIndex = index + needle.count + let nextIsWord = nextIndex < buffer.count && buffer[nextIndex] != 10 && buffer[nextIndex] != 13 && isASCIIWordByte(buffer[nextIndex]) + return !previousIsWord && !nextIsWord + } + + private static func lineContainsWholeWordNeedle( + _ buffer: UnsafeBufferPointer, + range: Range, + needle: [UInt8], + caseInsensitive: Bool + ) -> Bool { + let count = needle.count + guard count > 0, range.count >= count else { return false } + var index = range.lowerBound + let lastStart = range.upperBound - count + while index <= lastStart { + let current = caseInsensitive ? asciiLowercase(buffer[index]) : buffer[index] + if current == needle[0] { + var matched = true + if count > 1 { + for offset in 1 ..< count { + let hay = caseInsensitive ? asciiLowercase(buffer[index + offset]) : buffer[index + offset] + if hay != needle[offset] { + matched = false + break + } + } + } + if matched { + let previousIsWord = index > range.lowerBound && isASCIIWordByte(buffer[index - 1]) + let nextIndex = index + count + let nextIsWord = nextIndex < range.upperBound && isASCIIWordByte(buffer[nextIndex]) + if !previousIsWord, !nextIsWord { + return true + } + } + } + index += 1 + } + return false + } + + fileprivate static func isASCIIWordByte(_ byte: UInt8) -> Bool { + (byte >= 65 && byte <= 90) || (byte >= 97 && byte <= 122) || (byte >= 48 && byte <= 57) || byte == 95 + } + + fileprivate static func asciiLowercase(_ byte: UInt8) -> UInt8 { + (byte >= 65 && byte <= 90) ? byte + 32 : byte + } +} + +public struct PCRE2AnchoredDeclarationLinePattern: Sendable, Equatable { + public let caseInsensitive: Bool + + public init(caseInsensitive: Bool) { + self.caseInsensitive = caseInsensitive + } + + public func scanMatchingLines( + in subject: String, + collectMatches: Bool, + maxCollectedMatches: Int? = nil, + cancellationCheckStride: Int = 256, + shouldCancel: () -> Bool = { false } + ) -> PCRE2LineScanResult? { + if subject.utf8.count <= Self.cScanByteLimit { + return scanMatchingLinesWithC( + in: subject, + collectMatches: collectMatches, + maxCollectedMatches: maxCollectedMatches, + shouldCancel: shouldCancel + ) + } + return scanMatchingLinesWithSwift( + in: subject, + collectMatches: collectMatches, + maxCollectedMatches: maxCollectedMatches, + cancellationCheckStride: cancellationCheckStride, + shouldCancel: shouldCancel + ) + } + + private func scanMatchingLinesWithC( + in subject: String, + collectMatches: Bool, + maxCollectedMatches: Int?, + shouldCancel: () -> Bool + ) -> PCRE2LineScanResult? { + if shouldCancel() { return PCRE2LineScanResult(matchingLineNumbers: [], lineMatchCount: 0) } + + func scan(_ buffer: UnsafeBufferPointer) -> PCRE2LineScanResult? { + var lineCount = 0 + var fallbackRequired: Int32 = 0 + + func runScan(lineNumbers: UnsafeMutablePointer?, capacity: Int, collectedCount: inout Int) -> Int32 { + withPCRE2BytePointer(for: buffer) { subjectBase in + rp_pcre2_ascii_declaration_line_scan_8( + subjectBase, + buffer.count, + caseInsensitive ? 1 : 0, + lineNumbers, + capacity, + &collectedCount, + &lineCount, + &fallbackRequired + ) + } + } + + var ignoredCollectedCount = 0 + let countRC = runScan(lineNumbers: nil, capacity: 0, collectedCount: &ignoredCollectedCount) + guard countRC == 0, fallbackRequired == 0 else { return nil } + guard collectMatches, lineCount > 0 else { + return PCRE2LineScanResult(matchingLineNumbers: [], lineMatchCount: lineCount) + } + + let capacity = min(maxCollectedMatches ?? lineCount, lineCount) + guard capacity > 0 else { + return PCRE2LineScanResult(matchingLineNumbers: [], lineMatchCount: lineCount) + } + + var collectedLines = Array(repeating: 0, count: capacity) + var collectedCount = 0 + lineCount = 0 + fallbackRequired = 0 + let collectRC = collectedLines.withUnsafeMutableBufferPointer { lineBuffer in + runScan(lineNumbers: lineBuffer.baseAddress, capacity: lineBuffer.count, collectedCount: &collectedCount) + } + guard collectRC == 0, fallbackRequired == 0 else { return nil } + return PCRE2LineScanResult( + matchingLineNumbers: Array(collectedLines.prefix(collectedCount)), + lineMatchCount: lineCount + ) + } + + if let contiguous = subject.utf8.withContiguousStorageIfAvailable({ buffer in + scan(buffer) + }) { + return contiguous + } + let bytes = Array(subject.utf8) + return bytes.withUnsafeBufferPointer { scan($0) } + } + + private func scanMatchingLinesWithSwift( + in subject: String, + collectMatches: Bool, + maxCollectedMatches: Int?, + cancellationCheckStride: Int, + shouldCancel: () -> Bool + ) -> PCRE2LineScanResult? { + guard subject.utf8.allSatisfy({ $0 < 0x80 }) else { return nil } + var matchingLines: [Int] = [] + if collectMatches { matchingLines.reserveCapacity(8) } + var matchCount = 0 + + func scan(_ buffer: UnsafeBufferPointer) { + forEachPCRE2CRLFLine(in: buffer) { lineNumber, range in + if lineNumber % max(1, cancellationCheckStride) == 0, shouldCancel() { + return false + } + if Self.matchesDeclarationLine(buffer, range: range, caseInsensitive: caseInsensitive) { + matchCount += 1 + if collectMatches, maxCollectedMatches.map({ matchingLines.count < $0 }) ?? true { + matchingLines.append(lineNumber) + } + } + return true + } + } + + var usedContiguousStorage = false + subject.utf8.withContiguousStorageIfAvailable { buffer in + usedContiguousStorage = true + scan(buffer) + } + if !usedContiguousStorage { + let bytes = Array(subject.utf8) + bytes.withUnsafeBufferPointer { scan($0) } + } + + return PCRE2LineScanResult(matchingLineNumbers: matchingLines, lineMatchCount: matchCount) + } + + private static let cScanByteLimit = 4 * 1024 * 1024 + + private static func matchesDeclarationLine(_ buffer: UnsafeBufferPointer, range: Range, caseInsensitive: Bool) -> Bool { + var index = range.lowerBound + while index < range.upperBound, isHorizontalWhitespace(buffer[index]) { + index += 1 + } + + let saved = index + if consumeWord("final", in: buffer, range: range, index: &index, caseInsensitive: caseInsensitive) { + guard index < range.upperBound, isHorizontalWhitespace(buffer[index]) else { return false } + repeat { + index += 1 + } while index < range.upperBound && isHorizontalWhitespace(buffer[index]) + } else { + index = saved + } + + let matchedKeyword = consumeWord("class", in: buffer, range: range, index: &index, caseInsensitive: caseInsensitive) + || consumeWord("struct", in: buffer, range: range, index: &index, caseInsensitive: caseInsensitive) + || consumeWord("func", in: buffer, range: range, index: &index, caseInsensitive: caseInsensitive) + guard matchedKeyword, + index < range.upperBound, + isHorizontalWhitespace(buffer[index]) else { return false } + repeat { + index += 1 + } while index < range.upperBound && isHorizontalWhitespace(buffer[index]) + guard index < range.upperBound else { + return false + } + let first = buffer[index] + guard (first >= 65 && first <= 90) || (first >= 97 && first <= 122) || first == 95 else { return false } + return true + } + + private static func consumeWord(_ word: String, in buffer: UnsafeBufferPointer, range: Range, index: inout Int, caseInsensitive: Bool) -> Bool { + let start = index + for expected in word.utf8 { + guard index < range.upperBound else { index = start + return false + } + let hay = caseInsensitive ? PCRE2ASCIIWholeWordLiteral.asciiLowercase(buffer[index]) : buffer[index] + guard hay == expected else { index = start + return false + } + index += 1 + } + return true + } + + private static func isHorizontalWhitespace(_ byte: UInt8) -> Bool { + switch byte { + case 9, 11, 12, 32: + true + default: + false + } + } +} + +public struct PCRE2PathSuffixPattern: Sendable, Equatable { + public let suffixes: [String] + public let basenamePrefix: String? + public let singleDigitRange: ClosedRange? + + public init(suffixes: [String], basenamePrefix: String? = nil, singleDigitRange: ClosedRange? = nil) { + self.suffixes = suffixes + self.basenamePrefix = basenamePrefix + self.singleDigitRange = singleDigitRange + } + + public func matches(_ candidate: String, caseInsensitive: Bool) -> Bool { + let haystack = caseInsensitive ? candidate.lowercased() : candidate + let basenameStart = haystack.lastIndex(of: "/").map { haystack.index(after: $0) } ?? haystack.startIndex + let basename = haystack[basenameStart...] + for suffix in suffixes { + let effectiveSuffix = caseInsensitive ? suffix.lowercased() : suffix + if let basenamePrefix { + let effectivePrefix = caseInsensitive ? basenamePrefix.lowercased() : basenamePrefix + if let singleDigitRange { + for digit in singleDigitRange { + let scalar = UnicodeScalar(Int(digit)) ?? UnicodeScalar(48)! + if basename.hasSuffix(effectivePrefix + String(scalar) + effectiveSuffix) { + return true + } + } + continue + } + if basename.hasSuffix(effectivePrefix + effectiveSuffix) { + return true + } + continue + } + if haystack.hasSuffix(effectiveSuffix) { + return true + } + } + return false + } +} + +public extension PCRE2Regex.MatchSession { + func scanMatchingLines( + in subject: String, + options: PCRE2LineScanOptions, + shouldCancel: () -> Bool = { false } + ) throws -> PCRE2LineScanResult { + let prefilterNeedles = options.prefilter?.preparedNeedles() ?? [] + var matchingLines: [Int] = [] + if options.collectMatches { + matchingLines.reserveCapacity(8) + } + var matchCount = 0 + var cancelled = false + + try regex.withSubjectBuffer(for: subject) { buffer in + try forEachPCRE2CRLFLine(in: buffer) { lineNumber, range in + if lineNumber % options.cancellationCheckStride == 0, shouldCancel() { + cancelled = true + return false + } + if let maxLineUTF8Length = options.maxLineUTF8Length, range.count > maxLineUTF8Length { + return true + } + if !prefilterNeedles.isEmpty, !lineContainsAnyPrefilterNeedle(buffer, range: range, needles: prefilterNeedles, caseInsensitive: options.prefilter?.caseInsensitive ?? false) { + return true + } + let base = buffer.baseAddress?.advanced(by: range.lowerBound) + let lineBuffer = UnsafeBufferPointer(start: base, count: range.count) + if try regex.containsMatch(in: lineBuffer, startOffset: 0, options: .trustedSwiftString, matchData: matchData, matchContext: matchContext) { + matchCount += 1 + if options.collectMatches, options.maxCollectedMatches.map({ matchingLines.count < $0 }) ?? true { + matchingLines.append(lineNumber) + } + } + return true + } + } + + if cancelled { + return PCRE2LineScanResult(matchingLineNumbers: matchingLines, lineMatchCount: matchCount) + } + return PCRE2LineScanResult(matchingLineNumbers: matchingLines, lineMatchCount: matchCount) + } +} + +private extension PCRE2LinePrefilter { + func preparedNeedles() -> [[UInt8]] { + asciiRequiredAlternatives.compactMap { alternative in + let bytes = alternative.utf8.map { caseInsensitive ? PCRE2ASCIIWholeWordLiteral.asciiLowercase($0) : $0 } + guard !bytes.isEmpty, bytes.allSatisfy({ $0 < 0x80 }) else { return nil } + return bytes + } + } +} + +private func lineContainsAnyPrefilterNeedle( + _ buffer: UnsafeBufferPointer, + range: Range, + needles: [[UInt8]], + caseInsensitive: Bool +) -> Bool { + for needle in needles { + guard range.count >= needle.count else { continue } + var index = range.lowerBound + let lastStart = range.upperBound - needle.count + while index <= lastStart { + var matched = true + for offset in 0 ..< needle.count { + let hay = caseInsensitive ? PCRE2ASCIIWholeWordLiteral.asciiLowercase(buffer[index + offset]) : buffer[index + offset] + if hay != needle[offset] { + matched = false + break + } + } + if matched { return true } + index += 1 + } + } + return false +} + +@discardableResult +private func forEachPCRE2CRLFLine( + in buffer: UnsafeBufferPointer, + _ body: (Int, Range) throws -> Bool +) rethrows -> Bool { + guard buffer.count > 0 else { return true } + + var lineNumber = 0 + var lineStart = 0 + var index = 0 + while index < buffer.count { + let byte = buffer[index] + if byte == 10 || byte == 13 { + if try !body(lineNumber, lineStart ..< index) { + return false + } + lineNumber += 1 + index += 1 + if byte == 13, index < buffer.count, buffer[index] == 10 { + index += 1 + } + lineStart = index + } else { + index += 1 + } + } + + if lineStart < buffer.count { + return try body(lineNumber, lineStart ..< buffer.count) + } + return true +} + +private func withPCRE2BytePointer( + for buffer: UnsafeBufferPointer, + _ body: (UnsafePointer) -> R +) -> R { + if let base = buffer.baseAddress { + return body(base) + } + var emptyByte: UInt8 = 0 + return withUnsafePointer(to: &emptyByte) { pointer in + body(pointer) + } +} + +private extension PCRE2JITStatus { + var descriptionForRequiredMode: String { + switch self { + case .disabled: + "JIT mode is disabled" + case let .unavailable(reason): + reason + case let .compiled(sizeBytes): + "compiled (\(sizeBytes) bytes)" + case let .fallback(errorCode, message): + "JIT compile failed (\(errorCode)): \(message)" + } + } +} diff --git a/Sources/RepoPrompt/Infrastructure/Regex/PCRE2RegexAdapter.swift b/Sources/RepoPromptCore/Regex/PCRE2RegexAdapter.swift similarity index 88% rename from Sources/RepoPrompt/Infrastructure/Regex/PCRE2RegexAdapter.swift rename to Sources/RepoPromptCore/Regex/PCRE2RegexAdapter.swift index f8d0a7727..cdf8e5b28 100644 --- a/Sources/RepoPrompt/Infrastructure/Regex/PCRE2RegexAdapter.swift +++ b/Sources/RepoPromptCore/Regex/PCRE2RegexAdapter.swift @@ -1,7 +1,7 @@ import Foundation -enum RepoPromptRegexRuntime { - static var pcre2JITMode: PCRE2JITMode { +package enum RepoPromptRegexRuntime { + package static var pcre2JITMode: PCRE2JITMode { let rawValue = ProcessInfo.processInfo.environment["REPOPROMPT_PCRE2_JIT"]? .trimmingCharacters(in: .whitespacesAndNewlines) .lowercased() @@ -16,7 +16,7 @@ enum RepoPromptRegexRuntime { } } - static var pcre2SearchMatchLimitsEnabled: Bool { + package static var pcre2SearchMatchLimitsEnabled: Bool { let rawValue = ProcessInfo.processInfo.environment["REPOPROMPT_PCRE2_MATCH_LIMITS"]? .trimmingCharacters(in: .whitespacesAndNewlines) .lowercased() @@ -30,36 +30,36 @@ enum RepoPromptRegexRuntime { } } -enum RepoPromptPCRE2MatchPolicy { - static var fileSearchFullBuffer: PCRE2MatchLimits? { +package enum RepoPromptPCRE2MatchPolicy { + package static var fileSearchFullBuffer: PCRE2MatchLimits? { guard RepoPromptRegexRuntime.pcre2SearchMatchLimitsEnabled else { return nil } return PCRE2MatchLimits(matchLimit: 10_000_000, depthLimit: 100_000, heapLimitKiB: 64 * 1024) } - static var fileSearchLine: PCRE2MatchLimits? { + package static var fileSearchLine: PCRE2MatchLimits? { guard RepoPromptRegexRuntime.pcre2SearchMatchLimitsEnabled else { return nil } return PCRE2MatchLimits(matchLimit: 1_000_000, depthLimit: 10000, heapLimitKiB: 16 * 1024) } - static var pathSearchShortSubject: PCRE2MatchLimits? { + package static var pathSearchShortSubject: PCRE2MatchLimits? { guard RepoPromptRegexRuntime.pcre2SearchMatchLimitsEnabled else { return nil } return PCRE2MatchLimits(matchLimit: 100_000, depthLimit: 1000, heapLimitKiB: 4 * 1024) } } -struct RepoPromptPCRE2CompileResult { - let regex: PCRE2Regex - let compiledPattern: String - let wasRepaired: Bool +package struct RepoPromptPCRE2CompileResult { + package let regex: PCRE2Regex + package let compiledPattern: String + package let wasRepaired: Bool } -struct RepoPromptPCRE2CompileRequest { - let pattern: String - let caseInsensitive: Bool - let multilineAnchors: Bool - let jitMode: PCRE2JITMode +package struct RepoPromptPCRE2CompileRequest { + package let pattern: String + package let caseInsensitive: Bool + package let multilineAnchors: Bool + package let jitMode: PCRE2JITMode - init( + package init( pattern: String, caseInsensitive: Bool, multilineAnchors: Bool, @@ -72,8 +72,8 @@ struct RepoPromptPCRE2CompileRequest { } } -enum RepoPromptPCRE2Adapter { - static func compile(_ request: RepoPromptPCRE2CompileRequest) throws -> PCRE2Regex { +package enum RepoPromptPCRE2Adapter { + package static func compile(_ request: RepoPromptPCRE2CompileRequest) throws -> PCRE2Regex { var options = PCRE2CompileOptions.defaultRegex if request.caseInsensitive { options.insert(.caseless) @@ -84,7 +84,7 @@ enum RepoPromptPCRE2Adapter { return try PCRE2Regex(request.pattern, options: options, jit: request.jitMode) } - static func searchPatternError(from error: Error, pattern: String) -> RegexPatternFailure { + package static func searchPatternError(from error: Error, pattern: String) -> RegexPatternFailure { if let failure = error as? RegexPatternFailure { return failure } @@ -108,7 +108,7 @@ enum RepoPromptPCRE2Adapter { return SearchPatternError.invalidRegex(pattern, error.localizedDescription) } - static func isVariableLengthLookbehindError(pattern: String, details: String) -> Bool { + package static func isVariableLengthLookbehindError(pattern: String, details: String) -> Bool { guard pattern.contains("(?<=") || pattern.contains("(? String? { + package static func variableLengthLookbehindSuggestion(pattern: String) -> String? { guard pattern.contains("(?<=") || pattern.contains("(? String { + package static func escapedLiteral(_ literal: String) -> String { PCRE2Literal.escapedPattern(for: literal) } - static func compressDoubleEscapesBeforeMeta(_ pattern: String) -> String { + package static func compressDoubleEscapesBeforeMeta(_ pattern: String) -> String { let regexMeta: Set = ["(", ")", "[", "]", "{", "}", ".", "*", "+", "?", "|", "^", "$"] let chars = Array(pattern) var out: [Character] = [] @@ -176,7 +176,7 @@ enum RepoPromptPCRE2Adapter { return String(out) } - static func anchoredDeclarationLinePlan( + package static func anchoredDeclarationLinePlan( for pattern: String, caseInsensitive: Bool ) -> PCRE2AnchoredDeclarationLinePattern? { @@ -186,7 +186,7 @@ enum RepoPromptPCRE2Adapter { return PCRE2AnchoredDeclarationLinePattern(caseInsensitive: caseInsensitive) } - static func linePrefilterForAnchoredPattern( + package static func linePrefilterForAnchoredPattern( _ pattern: String, caseInsensitive: Bool ) -> PCRE2LinePrefilter? { @@ -205,7 +205,7 @@ enum RepoPromptPCRE2Adapter { return PCRE2LinePrefilter(asciiRequiredAlternatives: requiredAlternatives, caseInsensitive: caseInsensitive) } - static func asciiMarkerLinePatternPlan( + package static func asciiMarkerLinePatternPlan( forRegex pattern: String, caseInsensitive: Bool ) -> PCRE2ASCIIMarkerLinePattern? { @@ -213,7 +213,7 @@ enum RepoPromptPCRE2Adapter { return PCRE2ASCIIMarkerLinePattern(marker: "TODO", digitCount: 3, requiredPrefix: "Search", caseInsensitive: caseInsensitive) } - static func asciiWholeWordLiteralPlan( + package static func asciiWholeWordLiteralPlan( pattern: String, isRegex: Bool, wholeWord: Bool, @@ -229,7 +229,7 @@ enum RepoPromptPCRE2Adapter { return PCRE2ASCIIWholeWordLiteral(needle: pattern, caseInsensitive: caseInsensitive) } - static func pathSuffixPattern(forRegex pattern: String) -> PCRE2PathSuffixPattern? { + package static func pathSuffixPattern(forRegex pattern: String) -> PCRE2PathSuffixPattern? { if pattern.hasPrefix(#".*\."#), pattern.hasSuffix("$") { let inner = String(pattern.dropFirst(4).dropLast()) if inner.hasPrefix("("), inner.hasSuffix(")") { @@ -305,7 +305,7 @@ enum RepoPromptPCRE2Adapter { } } - static func compileSearchRegexWithRepairsResult( + package static func compileSearchRegexWithRepairsResult( pattern: String, caseInsensitive: Bool, wholeWord: Bool, @@ -362,7 +362,7 @@ enum RepoPromptPCRE2Adapter { ) } - static func compileSearchRegexWithRepairs( + package static func compileSearchRegexWithRepairs( pattern: String, caseInsensitive: Bool, wholeWord: Bool, diff --git a/Sources/RepoPrompt/Infrastructure/Regex/RegexToolkit.swift b/Sources/RepoPromptCore/Regex/RegexToolkit.swift similarity index 99% rename from Sources/RepoPrompt/Infrastructure/Regex/RegexToolkit.swift rename to Sources/RepoPromptCore/Regex/RegexToolkit.swift index 3e1bbd931..6341839d0 100644 --- a/Sources/RepoPrompt/Infrastructure/Regex/RegexToolkit.swift +++ b/Sources/RepoPromptCore/Regex/RegexToolkit.swift @@ -438,8 +438,8 @@ public enum RegexToolkit { } } -enum SearchPatternErrorFormatter { - static func parts(for pattern: String, isRegex: Bool, error: SearchPatternError) -> (issue: String, suggestion: String?) { +package enum SearchPatternErrorFormatter { + package static func parts(for pattern: String, isRegex: Bool, error: SearchPatternError) -> (issue: String, suggestion: String?) { let base = error.localizedDescription switch error { case .unmatchedParentheses: diff --git a/Sources/RepoPrompt/Infrastructure/Security/EphemeralSecureKeyValueStore.swift b/Sources/RepoPromptCore/Security/EphemeralSecureKeyValueStore.swift similarity index 63% rename from Sources/RepoPrompt/Infrastructure/Security/EphemeralSecureKeyValueStore.swift rename to Sources/RepoPromptCore/Security/EphemeralSecureKeyValueStore.swift index 16bb06e70..9e7be2cf6 100644 --- a/Sources/RepoPrompt/Infrastructure/Security/EphemeralSecureKeyValueStore.swift +++ b/Sources/RepoPromptCore/Security/EphemeralSecureKeyValueStore.swift @@ -1,23 +1,23 @@ import Foundation /// Process-local secure storage used whenever runtime signing cannot select a persistent domain. -final class EphemeralSecureKeyValueStore: SecureKeyValueStorageBackend, @unchecked Sendable { - static let shared = EphemeralSecureKeyValueStore() +package final class EphemeralSecureKeyValueStore: SecureKeyValueStorageBackend { + package static let shared = EphemeralSecureKeyValueStore() - let persistsValuesAcrossLaunches = false + package let persistsValuesAcrossLaunches = false private var entries: [String: Data] = [:] private let lock = NSRecursiveLock() - init() {} + package init() {} - func save( + package func save( _ value: String, for key: String, - accessMode: KeychainAccessMode + accessMode: SecureStorageAccessMode ) throws { guard let data = value.data(using: .utf8) else { - throw KeychainService.KeychainError.invalidData + throw SecureStorageError.invalidData } withLock { @@ -25,25 +25,25 @@ final class EphemeralSecureKeyValueStore: SecureKeyValueStorageBackend, @uncheck } } - func get( + package func get( for key: String, - accessMode: KeychainAccessMode + accessMode: SecureStorageAccessMode ) throws -> String { let data = try withLock { guard let data = entries[key] else { - throw KeychainService.KeychainError.itemNotFound + throw SecureStorageError.itemNotFound } return data } guard let value = String(data: data, encoding: .utf8) else { - throw KeychainService.KeychainError.invalidData + throw SecureStorageError.invalidData } return value } - func delete( + package func delete( for key: String, - accessMode: KeychainAccessMode + accessMode: SecureStorageAccessMode ) throws { _ = withLock { entries.removeValue(forKey: key) diff --git a/Sources/RepoPromptCore/Security/SecureKeyService.swift b/Sources/RepoPromptCore/Security/SecureKeyService.swift new file mode 100644 index 000000000..7ed8bc0ee --- /dev/null +++ b/Sources/RepoPromptCore/Security/SecureKeyService.swift @@ -0,0 +1,94 @@ +import Foundation + +package protocol SecurePlainStringStoring { + var persistsValuesAcrossLaunches: Bool { get } + + func getPlainValue(for key: String, accessMode: SecureStorageAccessMode) throws -> String? + func savePlainValue(_ value: String, for key: String, accessMode: SecureStorageAccessMode) throws + func deletePlainValue(for key: String, accessMode: SecureStorageAccessMode) throws +} + +package extension SecurePlainStringStoring { + var persistsValuesAcrossLaunches: Bool { + true + } + + func getPlainValue(for key: String) throws -> String? { + try getPlainValue(for: key, accessMode: .interactive) + } + + func savePlainValue(_ value: String, for key: String) throws { + try savePlainValue(value, for: key, accessMode: .interactive) + } + + func deletePlainValue(for key: String) throws { + try deletePlainValue(for: key, accessMode: .interactive) + } +} + +/// Secure key storage service backed by canonical Keychain/plain UTF-8 values. +package final class SecureKeysService { + private let secureStorage: SecureKeyValueStorageBackend + + package init(secureStorage: any SecureKeyValueStorageBackend) { + self.secureStorage = secureStorage + } + + package func saveAPIKey( + _ key: String, + for identifier: String, + accessMode: SecureStorageAccessMode = .interactive + ) throws { + try secureStorage.save(key, for: identifier, accessMode: accessMode) + } + + package func getAPIKey( + for identifier: String, + accessMode: SecureStorageAccessMode = .interactive + ) async throws -> String? { + do { + return try secureStorage.get(for: identifier, accessMode: accessMode) + } catch SecureStorageError.itemNotFound { + return nil + } + } + + package func deleteAPIKey( + for identifier: String, + accessMode: SecureStorageAccessMode = .interactive + ) throws { + try secureStorage.delete(for: identifier, accessMode: accessMode) + } + + package func savePlainValue( + _ value: String, + for key: String, + accessMode: SecureStorageAccessMode = .interactive + ) throws { + try secureStorage.save(value, for: key, accessMode: accessMode) + } + + package func getPlainValue( + for key: String, + accessMode: SecureStorageAccessMode = .interactive + ) throws -> String? { + do { + return try secureStorage.get(for: key, accessMode: accessMode) + } catch SecureStorageError.itemNotFound { + return nil + } + } + + package func deletePlainValue( + for key: String, + accessMode: SecureStorageAccessMode = .interactive + ) throws { + try secureStorage.delete(for: key, accessMode: accessMode) + } +} + +extension SecureKeysService: SecurePlainStringStoring { + package var persistsValuesAcrossLaunches: Bool { + secureStorage.persistsValuesAcrossLaunches + } +} diff --git a/Sources/RepoPrompt/Infrastructure/SyntaxParsing/Queries/DartQueries.swift b/Sources/RepoPromptCore/SyntaxParsing/Queries/DartQueries.swift similarity index 98% rename from Sources/RepoPrompt/Infrastructure/SyntaxParsing/Queries/DartQueries.swift rename to Sources/RepoPromptCore/SyntaxParsing/Queries/DartQueries.swift index de5a87e8b..b5ecb6ef8 100644 --- a/Sources/RepoPrompt/Infrastructure/SyntaxParsing/Queries/DartQueries.swift +++ b/Sources/RepoPromptCore/SyntaxParsing/Queries/DartQueries.swift @@ -9,7 +9,7 @@ import Foundation -let dartQuery = """ +package let dartQuery = """ ; Variable (identifier) @variable @@ -258,7 +258,7 @@ let dartQuery = """ (comment) @comment """ -let dartCodeMapQuery = """ +package let dartCodeMapQuery = """ ; =================================== ; 1) Class Declarations ; =================================== diff --git a/Sources/RepoPrompt/Infrastructure/SyntaxParsing/Queries/GoQueries.swift b/Sources/RepoPromptCore/SyntaxParsing/Queries/GoQueries.swift similarity index 98% rename from Sources/RepoPrompt/Infrastructure/SyntaxParsing/Queries/GoQueries.swift rename to Sources/RepoPromptCore/SyntaxParsing/Queries/GoQueries.swift index 492d1edf1..fd87ba31b 100644 --- a/Sources/RepoPrompt/Infrastructure/SyntaxParsing/Queries/GoQueries.swift +++ b/Sources/RepoPromptCore/SyntaxParsing/Queries/GoQueries.swift @@ -10,7 +10,7 @@ import Foundation -let goQuery = """ +package let goQuery = """ ; Function calls (call_expression @@ -137,7 +137,7 @@ let goQuery = """ """ /// Code-map (structural) query for Go, capturing top-level declarations, imports, etc. -let goCodeMapQuery = #""" +package let goCodeMapQuery = #""" ; =================================== ; 1) Package Declarations ; =================================== diff --git a/Sources/RepoPrompt/Infrastructure/SyntaxParsing/Queries/JavaQueries.swift b/Sources/RepoPromptCore/SyntaxParsing/Queries/JavaQueries.swift similarity index 98% rename from Sources/RepoPrompt/Infrastructure/SyntaxParsing/Queries/JavaQueries.swift rename to Sources/RepoPromptCore/SyntaxParsing/Queries/JavaQueries.swift index 27f041e69..4b1edbad9 100644 --- a/Sources/RepoPrompt/Infrastructure/SyntaxParsing/Queries/JavaQueries.swift +++ b/Sources/RepoPromptCore/SyntaxParsing/Queries/JavaQueries.swift @@ -10,7 +10,7 @@ import Foundation -let javaQuery = """ +package let javaQuery = """ ; Variables (identifier) @variable @@ -162,7 +162,7 @@ let javaQuery = """ ] @keyword """ -let javaCodeMapQuery = #""" +package let javaCodeMapQuery = #""" ; =================================== ; 1) Class Declarations ; =================================== diff --git a/Sources/RepoPrompt/Infrastructure/SyntaxParsing/Queries/JavaScriptQueries.swift b/Sources/RepoPromptCore/SyntaxParsing/Queries/JavaScriptQueries.swift similarity index 99% rename from Sources/RepoPrompt/Infrastructure/SyntaxParsing/Queries/JavaScriptQueries.swift rename to Sources/RepoPromptCore/SyntaxParsing/Queries/JavaScriptQueries.swift index f1541a351..c6e9acbc5 100644 --- a/Sources/RepoPrompt/Infrastructure/SyntaxParsing/Queries/JavaScriptQueries.swift +++ b/Sources/RepoPromptCore/SyntaxParsing/Queries/JavaScriptQueries.swift @@ -9,7 +9,7 @@ import Foundation -let javascriptQuery = """ +package let javascriptQuery = """ ; Variables ;---------- @@ -216,7 +216,7 @@ let javascriptQuery = """ ] @keyword """ -let javascriptCodeMapQuery = #""" +package let javascriptCodeMapQuery = #""" ; ============================================================================= ; JavaScript code-map query • v5.0 • 2025-01-11 ; Further refined for better code mapping diff --git a/Sources/RepoPrompt/Infrastructure/SyntaxParsing/Queries/PythonQueries.swift b/Sources/RepoPromptCore/SyntaxParsing/Queries/PythonQueries.swift similarity index 98% rename from Sources/RepoPrompt/Infrastructure/SyntaxParsing/Queries/PythonQueries.swift rename to Sources/RepoPromptCore/SyntaxParsing/Queries/PythonQueries.swift index 18157c997..0b6e8760b 100644 --- a/Sources/RepoPrompt/Infrastructure/SyntaxParsing/Queries/PythonQueries.swift +++ b/Sources/RepoPromptCore/SyntaxParsing/Queries/PythonQueries.swift @@ -9,7 +9,7 @@ import Foundation -let pythonQuery = """ +package let pythonQuery = """ ; Identifier naming conventions (identifier) @variable @@ -149,7 +149,7 @@ let pythonQuery = """ ] @keyword """ -let pythonCodeMapQuery = #""" +package let pythonCodeMapQuery = #""" ; =================================== ; 1) Class Declarations ; =================================== diff --git a/Sources/RepoPrompt/Infrastructure/SyntaxParsing/Queries/RubyQueries.swift b/Sources/RepoPromptCore/SyntaxParsing/Queries/RubyQueries.swift similarity index 96% rename from Sources/RepoPrompt/Infrastructure/SyntaxParsing/Queries/RubyQueries.swift rename to Sources/RepoPromptCore/SyntaxParsing/Queries/RubyQueries.swift index 020c16ca2..d4567016d 100644 --- a/Sources/RepoPrompt/Infrastructure/SyntaxParsing/Queries/RubyQueries.swift +++ b/Sources/RepoPromptCore/SyntaxParsing/Queries/RubyQueries.swift @@ -7,7 +7,7 @@ import Foundation -let rubyHighlightQuery = """ +package let rubyHighlightQuery = """ ; Minimal, compiler-safe Ruby highlight query (string) @string @@ -29,7 +29,7 @@ let rubyHighlightQuery = """ "end" @keyword """ -let rubyCodeMapQuery = #""" +package let rubyCodeMapQuery = #""" ; ========================== ; 1) Imports (require) ; ========================== diff --git a/Sources/RepoPrompt/Infrastructure/SyntaxParsing/Queries/RustQueries.swift b/Sources/RepoPromptCore/SyntaxParsing/Queries/RustQueries.swift similarity index 98% rename from Sources/RepoPrompt/Infrastructure/SyntaxParsing/Queries/RustQueries.swift rename to Sources/RepoPromptCore/SyntaxParsing/Queries/RustQueries.swift index 75c17bff4..07713058d 100644 --- a/Sources/RepoPrompt/Infrastructure/SyntaxParsing/Queries/RustQueries.swift +++ b/Sources/RepoPromptCore/SyntaxParsing/Queries/RustQueries.swift @@ -10,7 +10,7 @@ import Foundation -let rustQuery = """ +package let rustQuery = """ ; Identifiers (type_identifier) @type @@ -174,7 +174,7 @@ let rustQuery = """ "'" @operator """ -let rustCodeMapQuery = #""" +package let rustCodeMapQuery = #""" ; =================================== ; 1) Struct Declarations ; =================================== diff --git a/Sources/RepoPrompt/Infrastructure/SyntaxParsing/Queries/SwiftQueries.swift b/Sources/RepoPromptCore/SyntaxParsing/Queries/SwiftQueries.swift similarity index 99% rename from Sources/RepoPrompt/Infrastructure/SyntaxParsing/Queries/SwiftQueries.swift rename to Sources/RepoPromptCore/SyntaxParsing/Queries/SwiftQueries.swift index 9e66e3c53..eb14a53d0 100644 --- a/Sources/RepoPrompt/Infrastructure/SyntaxParsing/Queries/SwiftQueries.swift +++ b/Sources/RepoPromptCore/SyntaxParsing/Queries/SwiftQueries.swift @@ -9,7 +9,7 @@ import Foundation -let swiftQuery = #""" +package let swiftQuery = #""" [ "." ";" ":" "," ] @punctuation.delimiter [ "\\(" "(" ")" "[" "]" "{" "}"] @punctuation.bracket ; TODO: "\\(" ")" in interpolations should be @punctuation.special @@ -201,7 +201,7 @@ let swiftQuery = #""" (type_pack_expansion ["repeat" @keyword]) """# -let swiftCodeMapQuery = #""" +package let swiftCodeMapQuery = #""" ; =================================== ; Swift CodeMap Query - Updated for range-based containment ; =================================== diff --git a/Sources/RepoPrompt/Infrastructure/SyntaxParsing/Queries/cQueries.swift b/Sources/RepoPromptCore/SyntaxParsing/Queries/cQueries.swift similarity index 98% rename from Sources/RepoPrompt/Infrastructure/SyntaxParsing/Queries/cQueries.swift rename to Sources/RepoPromptCore/SyntaxParsing/Queries/cQueries.swift index 6be3919a9..5d854f088 100644 --- a/Sources/RepoPrompt/Infrastructure/SyntaxParsing/Queries/cQueries.swift +++ b/Sources/RepoPromptCore/SyntaxParsing/Queries/cQueries.swift @@ -7,7 +7,7 @@ import Foundation -let cQuery = """ +package let cQuery = """ (identifier) @variable ((identifier) @constant @@ -91,7 +91,7 @@ let cQuery = """ (comment) @comment """ -let cCodeMapQuery = #""" +package let cCodeMapQuery = #""" ; =================================== ; 1) Import Declarations (#include) ; =================================== diff --git a/Sources/RepoPrompt/Infrastructure/SyntaxParsing/Queries/cSharpQueries.swift b/Sources/RepoPromptCore/SyntaxParsing/Queries/cSharpQueries.swift similarity index 98% rename from Sources/RepoPrompt/Infrastructure/SyntaxParsing/Queries/cSharpQueries.swift rename to Sources/RepoPromptCore/SyntaxParsing/Queries/cSharpQueries.swift index 96b98bb07..14cfb6c73 100644 --- a/Sources/RepoPrompt/Infrastructure/SyntaxParsing/Queries/cSharpQueries.swift +++ b/Sources/RepoPromptCore/SyntaxParsing/Queries/cSharpQueries.swift @@ -8,7 +8,7 @@ import Foundation -let csharpQuery = """ +package let csharpQuery = """ (identifier) @variable ;; Methods @@ -223,7 +223,7 @@ let csharpQuery = """ (invocation_expression (member_access_expression name: (identifier) @function)) """ -let csharpCodeMapQuery = #""" +package let csharpCodeMapQuery = #""" ; =================================== ; 1) Class Declarations ; =================================== diff --git a/Sources/RepoPrompt/Infrastructure/SyntaxParsing/Queries/cppQueries.swift b/Sources/RepoPromptCore/SyntaxParsing/Queries/cppQueries.swift similarity index 98% rename from Sources/RepoPrompt/Infrastructure/SyntaxParsing/Queries/cppQueries.swift rename to Sources/RepoPromptCore/SyntaxParsing/Queries/cppQueries.swift index e92e2d9e0..cd3855c82 100644 --- a/Sources/RepoPrompt/Infrastructure/SyntaxParsing/Queries/cppQueries.swift +++ b/Sources/RepoPromptCore/SyntaxParsing/Queries/cppQueries.swift @@ -10,7 +10,7 @@ import Foundation -let cppQuery = """ +package let cppQuery = """ ; Functions (call_expression @@ -98,7 +98,7 @@ let cppQuery = """ // New C++ Code‑Map Query modeled after the Swift version. // Adjusted for tree-sitter-cpp using union patterns for member variables. -let cppCodeMapQuery = #""" +package let cppCodeMapQuery = #""" ; =================================== ; 1) Import Declarations ; #include lines, captured as “@import” diff --git a/Sources/RepoPrompt/Infrastructure/SyntaxParsing/Queries/phpQueries.swift b/Sources/RepoPromptCore/SyntaxParsing/Queries/phpQueries.swift similarity index 97% rename from Sources/RepoPrompt/Infrastructure/SyntaxParsing/Queries/phpQueries.swift rename to Sources/RepoPromptCore/SyntaxParsing/Queries/phpQueries.swift index 1dddebc4a..f304922bc 100644 --- a/Sources/RepoPrompt/Infrastructure/SyntaxParsing/Queries/phpQueries.swift +++ b/Sources/RepoPromptCore/SyntaxParsing/Queries/phpQueries.swift @@ -8,7 +8,7 @@ import Foundation /// PHP highlight query based on tree-sitter-php's highlights.scm -let phpHighlightQuery = """ +package let phpHighlightQuery = """ ; Keywords "as" @keyword "break" @keyword @@ -96,7 +96,7 @@ let phpHighlightQuery = """ (comment) @comment """ -let phpTagQuery = """ +package let phpTagQuery = """ (namespace_definition name: (namespace_name) @name) @definition.module @@ -139,7 +139,7 @@ let phpTagQuery = """ name: (name) @name) @reference.call """ -let basicPhpQuery = """ +package let basicPhpQuery = """ ; === Minimal, compiler-safe PHP highlight query === ; NOTE: Each pattern is on its own line (no top-level [ ... ] lists) ; and only node names present in the embedded grammar are referenced. @@ -163,7 +163,7 @@ let basicPhpQuery = """ "else" @keyword """ -let phpCodeMapQuery = #""" +package let phpCodeMapQuery = #""" ; ========================== ; 1) Namespaces & Imports ; ========================== diff --git a/Sources/RepoPrompt/Infrastructure/SyntaxParsing/Queries/typeScript.swift b/Sources/RepoPromptCore/SyntaxParsing/Queries/typeScript.swift similarity index 99% rename from Sources/RepoPrompt/Infrastructure/SyntaxParsing/Queries/typeScript.swift rename to Sources/RepoPromptCore/SyntaxParsing/Queries/typeScript.swift index c7463e4a9..d9e069e49 100644 --- a/Sources/RepoPrompt/Infrastructure/SyntaxParsing/Queries/typeScript.swift +++ b/Sources/RepoPromptCore/SyntaxParsing/Queries/typeScript.swift @@ -20,7 +20,7 @@ import Foundation /// Main highlight query for TypeScript/TSX. /// Combines the logic from highlights.scm, locals.scm, and tags.scm. -let typeScriptHighlightQuery = #""" +package let typeScriptHighlightQuery = #""" ; ---- Types ---- (type_identifier) @type (predefined_type) @type.builtin @@ -94,7 +94,7 @@ let typeScriptHighlightQuery = #""" /// • @function.definition – functions and methods /// • @variable.global – variables and class properties /// • @typeAlias – type-alias declarations -let typeScriptCodeMapQuery = #""" +package let typeScriptCodeMapQuery = #""" ; ============================================================================= ; TypeScript code-map query • v3.0 • 2026-01-29 ; Works with tree-sitter-typescript 0.20.2 diff --git a/Sources/RepoPrompt/Infrastructure/SyntaxParsing/QueryResourceLoader.swift b/Sources/RepoPromptCore/SyntaxParsing/QueryResourceLoader.swift similarity index 89% rename from Sources/RepoPrompt/Infrastructure/SyntaxParsing/QueryResourceLoader.swift rename to Sources/RepoPromptCore/SyntaxParsing/QueryResourceLoader.swift index bbde5bf71..8f0e0085f 100644 --- a/Sources/RepoPrompt/Infrastructure/SyntaxParsing/QueryResourceLoader.swift +++ b/Sources/RepoPromptCore/SyntaxParsing/QueryResourceLoader.swift @@ -3,11 +3,11 @@ import Foundation /// Loads query `.scm` files that were copied into the binary as /// Swift-PM resources (e.g. via `.copy("queries")` in `Package.swift`). /// We currently only need PHP support, but the helper can be reused. -enum QueryResourceLoader { +package enum QueryResourceLoader { /// Returns the *raw Data* for *queries/highlights.scm* bundled with /// the `TreeSitterPHP` Swift-PM package, or `nil` if the file cannot /// be found. - static func phpHighlightData() -> Data? { + package static func phpHighlightData() -> Data? { let bundleNameKey = "treesitterphp" for bundle in Bundle.allBundles + Bundle.allFrameworks { let bundleName = bundle.bundleURL.lastPathComponent.lowercased() @@ -26,7 +26,7 @@ enum QueryResourceLoader { /// Convenience wrapper that converts the raw data into `String` /// when a textual representation is preferred by the caller. - static func phpHighlight() -> String? { + package static func phpHighlight() -> String? { guard let data = phpHighlightData() else { return nil } return String(data: data, encoding: .utf8) } diff --git a/Sources/RepoPrompt/Infrastructure/SyntaxParsing/SyntaxManager.swift b/Sources/RepoPromptCore/SyntaxParsing/SyntaxManager.swift similarity index 94% rename from Sources/RepoPrompt/Infrastructure/SyntaxParsing/SyntaxManager.swift rename to Sources/RepoPromptCore/SyntaxParsing/SyntaxManager.swift index 3588f8741..2e2d893f6 100644 --- a/Sources/RepoPrompt/Infrastructure/SyntaxParsing/SyntaxManager.swift +++ b/Sources/RepoPromptCore/SyntaxParsing/SyntaxManager.swift @@ -6,23 +6,14 @@ // import Foundation +import RepoPromptSyntaxCBridge import SwiftTreeSitter -import TreeSitterC -import TreeSitterDart -import TreeSitterGo -import TreeSitterJava -import TreeSitterJavaScript -import TreeSitterPython -import TreeSitterRuby -import TreeSitterRust -import TreeSitterTSX -import TreeSitterTypeScript - -enum LanguageType: String, Comparable, Codable { + +package enum LanguageType: String, Comparable, Codable { case swift, js, c_sharp, python, c, rust, cpp, go, java, dart, ts, tsx, php, ruby // ➜ NEW - var displayName: String { + package var displayName: String { switch self { case .swift: "Swift" case .js: "JavaScript" @@ -43,15 +34,15 @@ enum LanguageType: String, Comparable, Codable { // MARK: - Comparable - static func < (lhs: LanguageType, rhs: LanguageType) -> Bool { + package static func < (lhs: LanguageType, rhs: LanguageType) -> Bool { lhs.displayName.localizedCompare(rhs.displayName) == .orderedAscending // If you’d rather sort by declaration order instead, use: // lhs.rawValue < rhs.rawValue } } -final class SyntaxManager { - static let shared = SyntaxManager() +package final class SyntaxManager { + package static let shared = SyntaxManager() private enum CodeMapQueryLookupStatus { // Static-slot retrieval is reported as a hit even when Swift performs the slot's first lazy initialization. @@ -138,16 +129,16 @@ final class SyntaxManager { } // Large-file safety thresholds (tuned to avoid common real-world files). - static let parseLineLimit = 25000 - static let parseUTF16Limit = 1_500_000 - static let parseUTF8Limit = 5_000_000 + package static let parseLineLimit = 25000 + package static let parseUTF16Limit = 1_500_000 + package static let parseUTF8Limit = 5_000_000 - enum ParseOversizeReason: Equatable, CustomStringConvertible { + package enum ParseOversizeReason: Equatable, CustomStringConvertible { case lineCountExceeded(actual: Int) case utf16LengthExceeded(actual: Int) case utf8SizeExceeded(actual: Int) - var description: String { + package var description: String { switch self { case let .lineCountExceeded(actual): "line count \(actual) exceeded limit \(SyntaxManager.parseLineLimit)" @@ -160,7 +151,7 @@ final class SyntaxManager { } /// Maps file extension to LanguageType. - let extensionToLanguage: [String: LanguageType] = [ + package let extensionToLanguage: [String: LanguageType] = [ "swift": .swift, "js": .js, "cs": .c_sharp, @@ -178,7 +169,7 @@ final class SyntaxManager { ] /// Optimized Tree‑sitter highlight queries. - let optimizedQueries: [LanguageType: String] = [ + package let optimizedQueries: [LanguageType: String] = [ .swift: swiftQuery, .js: javascriptQuery, .c_sharp: csharpQuery, @@ -196,7 +187,7 @@ final class SyntaxManager { ] /// Code‑map queries for extracting structure. - let codeMapQueries: [LanguageType: String] = [ + package let codeMapQueries: [LanguageType: String] = [ .c: cCodeMapQuery, .cpp: cppCodeMapQuery, .c_sharp: csharpCodeMapQuery, @@ -232,7 +223,7 @@ final class SyntaxManager { } /// Returns a reason if the provided content should skip Tree-sitter parsing. - func parsingOversizeReason(for content: String) -> ParseOversizeReason? { + package func parsingOversizeReason(for content: String) -> ParseOversizeReason? { let utf8View = content.utf8 // (anchor) keep as first line for stable patching // 1) Fast-path: UTF‑8 byte size (O(1) when contiguous, otherwise fallback) @@ -341,7 +332,7 @@ final class SyntaxManager { } } - init() { + package init() { let pipelineStats = CodeMapPerfRuntime.sharedPipelineStats let collectStartupPerf = pipelineStats != nil var startupStats = CodeMapSyntaxStartupPerfStats() @@ -378,7 +369,7 @@ final class SyntaxManager { /// Returns the LanguageConfiguration for a given file extension, or nil if unsupported. /// Do not use the returned SwiftTreeSitter wrappers for parser/query execution outside SyntaxManager's gate. - func languageConfig(forFileExtension ext: String) -> LanguageConfiguration? { + package func languageConfig(forFileExtension ext: String) -> LanguageConfiguration? { withTreeSitterExecution { languageConfigUnlocked(forFileExtension: ext) } @@ -430,7 +421,7 @@ final class SyntaxManager { } /// Parses file content into a MutableTree using SwiftTreeSitter. - func parse(content: String, fileExtension: String) throws -> MutableTree? { + package func parse(content: String, fileExtension: String) throws -> MutableTree? { guard extensionToLanguage[fileExtension.lowercased()] != nil else { return nil } if let reason = parsingOversizeReason(for: content) { print("[SyntaxManager] Skipping parse for .\(fileExtension): \(reason)") @@ -446,7 +437,7 @@ final class SyntaxManager { } /// Runs the highlight query for a given file's content. - func highlight(content: String, fileExtension: String) throws -> [NamedRange] { + package func highlight(content: String, fileExtension: String) throws -> [NamedRange] { // Fast, zero-allocation line guard (bails early once past 5k) guard exceededLineCount(in: content.utf8, limit: 5000) == nil else { return [] @@ -528,7 +519,7 @@ final class SyntaxManager { } /// Runs the code‑map query for a given file's content. - func codeMap(content: String, fileExtension: String) throws -> [NamedRange] { + package func codeMap(content: String, fileExtension: String) throws -> [NamedRange] { let pipelineStats = CodeMapPerfRuntime.sharedPipelineStats let collectSyntaxPerf = pipelineStats != nil var syntaxPerf = CodeMapSyntaxPerfStats() @@ -654,7 +645,7 @@ final class SyntaxManager { } } - static func isSupportedFileExtension(_ fileExt: String) -> Bool { + package static func isSupportedFileExtension(_ fileExt: String) -> Bool { switch fileExt.lowercased() { case "swift", "js", "cs", "py", "c", "rs", "cpp", "go", "java", "dart", "ts", "tsx", "php", "rb": // NEW @@ -666,7 +657,7 @@ final class SyntaxManager { /// Returns `true` if the file extension has a codemap query available. /// This is stricter than `isSupportedFileExtension` which only checks syntax highlighting. - static func supportsCodeMap(fileExtension: String) -> Bool { + package static func supportsCodeMap(fileExtension: String) -> Bool { guard let langType = shared.extensionToLanguage[fileExtension.lowercased()] else { return false } @@ -674,7 +665,7 @@ final class SyntaxManager { } /// Instance method variant for codemap support check. - func supportsCodeMap(fileExtension: String) -> Bool { + package func supportsCodeMap(fileExtension: String) -> Bool { guard let langType = extensionToLanguage[fileExtension.lowercased()] else { return false } @@ -685,7 +676,7 @@ final class SyntaxManager { /// Returns `true` for languages whose code-map extraction skips /// full regex/type parsing and instead relies on raw declaration text. - static func isLightweight(language: LanguageType) -> Bool { + package static func isLightweight(language: LanguageType) -> Bool { switch language { case .php, .ruby, .ts, .tsx, .js: true diff --git a/Sources/RepoPrompt/Infrastructure/Utilities/RelativePath.swift b/Sources/RepoPromptCore/Utilities/RelativePath.swift similarity index 85% rename from Sources/RepoPrompt/Infrastructure/Utilities/RelativePath.swift rename to Sources/RepoPromptCore/Utilities/RelativePath.swift index 981fac0f6..ec0be3ac3 100644 --- a/Sources/RepoPrompt/Infrastructure/Utilities/RelativePath.swift +++ b/Sources/RepoPromptCore/Utilities/RelativePath.swift @@ -1,16 +1,16 @@ import Foundation /// Pure string-based relative path computation (no filesystem I/O). -enum RelativePath { +package enum RelativePath { @inline(__always) - static func from(absolutePath: String, rootPath: String) -> String { + package static func from(absolutePath: String, rootPath: String) -> String { let abs = (absolutePath as NSString).standardizingPath let root = (rootPath as NSString).standardizingPath return fromStandardized(standardizedAbsolutePath: abs, standardizedRootPath: root) } @inline(__always) - static func fromStandardized( + package static func fromStandardized( standardizedAbsolutePath abs: String, standardizedRootPath root: String ) -> String { diff --git a/Sources/RepoPrompt/Infrastructure/Utilities/StandardizedPath.swift b/Sources/RepoPromptCore/Utilities/StandardizedPath.swift similarity index 82% rename from Sources/RepoPrompt/Infrastructure/Utilities/StandardizedPath.swift rename to Sources/RepoPromptCore/Utilities/StandardizedPath.swift index f69dfc64d..299272779 100644 --- a/Sources/RepoPrompt/Infrastructure/Utilities/StandardizedPath.swift +++ b/Sources/RepoPromptCore/Utilities/StandardizedPath.swift @@ -2,14 +2,14 @@ import Foundation private let standardizedPathSlashTrim = CharacterSet(charactersIn: "/") -enum StandardizedPath { +package enum StandardizedPath { @inline(__always) - static func absolute(_ path: String) -> String { + package static func absolute(_ path: String) -> String { (path as NSString).standardizingPath } @inline(__always) - static func relative(_ path: String) -> String { + package static func relative(_ path: String) -> String { let trimmed = path.trimmingCharacters(in: standardizedPathSlashTrim) guard !trimmed.isEmpty, trimmed != "." else { return "" } @@ -33,7 +33,7 @@ enum StandardizedPath { } @inline(__always) - static func join(standardizedRoot: String, standardizedRelativePath: String) -> String { + package static func join(standardizedRoot: String, standardizedRelativePath: String) -> String { guard !standardizedRelativePath.isEmpty else { return standardizedRoot } return standardizedRoot.hasSuffix("/") ? standardizedRoot + standardizedRelativePath @@ -41,11 +41,11 @@ enum StandardizedPath { } @inline(__always) - static func containsNUL(_ path: String) -> Bool { + package static func containsNUL(_ path: String) -> Bool { path.unicodeScalars.contains { $0.value == 0 } } - static func diagnosticEscaped(_ path: String) -> String { + package static func diagnosticEscaped(_ path: String) -> String { var escaped = "" escaped.reserveCapacity(path.count) for scalar in path.unicodeScalars { @@ -76,23 +76,23 @@ enum StandardizedPath { } @inline(__always) - static func isDescendant(_ standardizedPath: String, of standardizedParent: String) -> Bool { + package static func isDescendant(_ standardizedPath: String, of standardizedParent: String) -> Bool { if standardizedPath == standardizedParent { return true } let prefix = standardizedParent.hasSuffix("/") ? standardizedParent : standardizedParent + "/" return standardizedPath.hasPrefix(prefix) } } -enum StoredSelectionPathNormalization { +package enum StoredSelectionPathNormalization { /// Canonicalizes stored selection path state. /// Policy: canonical absolute keys win over legacy/raw variants for the same file. - static func standardizedPath(_ rawPath: String) -> String? { + package static func standardizedPath(_ rawPath: String) -> String? { let trimmed = rawPath.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { return nil } return StandardizedPath.absolute(trimmed) } - static func standardizedPaths(_ paths: [String]) -> [String] { + package static func standardizedPaths(_ paths: [String]) -> [String] { var seen = Set() var result: [String] = [] result.reserveCapacity(paths.count) @@ -103,7 +103,7 @@ enum StoredSelectionPathNormalization { return result } - static func standardizedSlices(_ slices: [String: [LineRange]]) -> [String: [LineRange]] { + package static func standardizedSlices(_ slices: [String: [LineRange]]) -> [String: [LineRange]] { guard !slices.isEmpty else { return [:] } var canonical: [String: [LineRange]] = [:] @@ -131,17 +131,17 @@ enum StoredSelectionPathNormalization { } } -enum GitDiffPathNormalization { +package enum GitDiffPathNormalization { @inline(__always) - static func normalizedAbsolutePath(_ path: String) -> String { + package static func normalizedAbsolutePath(_ path: String) -> String { StandardizedPath.absolute(path).precomposedStringWithCanonicalMapping } - static func normalizedAbsolutePaths(_ paths: [String]) -> [String] { + package static func normalizedAbsolutePaths(_ paths: [String]) -> [String] { paths.map(normalizedAbsolutePath) } - static func gitRelativePaths(from absolutePaths: [String], repoRootPath: String) -> [String] { + package static func gitRelativePaths(from absolutePaths: [String], repoRootPath: String) -> [String] { let standardizedRoot = normalizedAbsolutePath(repoRootPath) var results: [String] = [] results.reserveCapacity(absolutePaths.count) diff --git a/Sources/RepoPromptCore/Utilities/StringFNV.swift b/Sources/RepoPromptCore/Utilities/StringFNV.swift new file mode 100644 index 000000000..1e39191db --- /dev/null +++ b/Sources/RepoPromptCore/Utilities/StringFNV.swift @@ -0,0 +1,9 @@ +import RepoPromptC + +package extension String { + /// 64-bit FNV-1a hash used for stable content cache identity. + @inline(__always) + func fnv1a64() -> UInt64 { + withCString { repo_fnv1a64($0) } + } +} diff --git a/Sources/RepoPromptCore/Utilities/StringLineEndingUtilities.swift b/Sources/RepoPromptCore/Utilities/StringLineEndingUtilities.swift new file mode 100644 index 000000000..1e5d5a412 --- /dev/null +++ b/Sources/RepoPromptCore/Utilities/StringLineEndingUtilities.swift @@ -0,0 +1,56 @@ +import Foundation +import RepoPromptC + +package extension String { + static func splitContentPreservingLineEndings(_ content: String) -> ([String], String) { + content.withCString { pointer in + guard let result = repo_split_content_preserving_endings(pointer) else { + return ([], "\n") + } + defer { repo_free_split_result(result) } + var lines: [String] = [] + lines.reserveCapacity(Int(result.pointee.line_count)) + for index in 0 ..< result.pointee.line_count { + if let line = result.pointee.lines.advanced(by: Int(index)).pointee { + lines.append(String(cString: line)) + } + } + let ending = String(cString: result.pointee.detected_ending) + return (lines, ending) + } + } + + static func splitContentPreservingAllLineEndings(_ content: String) -> [(line: String, ending: String)] { + guard !content.isEmpty else { return [] } + var result: [(String, String)] = [] + result.reserveCapacity(32) + let scalars = content.unicodeScalars + var lineStart = scalars.startIndex + var index = scalars.startIndex + while index < scalars.endIndex { + let scalar = scalars[index] + if scalar == "\r" { + let line = String(scalars[lineStart ..< index]) + let next = scalars.index(after: index) + if next < scalars.endIndex, scalars[next] == "\n" { + result.append((line, "\r\n")) + index = scalars.index(after: next) + } else { + result.append((line, "\r")) + index = next + } + lineStart = index + } else if scalar == "\n" { + result.append((String(scalars[lineStart ..< index]), "\n")) + index = scalars.index(after: index) + lineStart = index + } else { + index = scalars.index(after: index) + } + } + if lineStart < scalars.endIndex { + result.append((String(scalars[lineStart ..< scalars.endIndex]), "")) + } + return result + } +} diff --git a/Sources/RepoPromptCore/Utilities/WorkspaceTaskSemaphore.swift b/Sources/RepoPromptCore/Utilities/WorkspaceTaskSemaphore.swift new file mode 100644 index 000000000..7d78b60e9 --- /dev/null +++ b/Sources/RepoPromptCore/Utilities/WorkspaceTaskSemaphore.swift @@ -0,0 +1,37 @@ +package actor WorkspaceTaskSemaphore { + private let capacity: Int + private var permits: Int + private var waiters: [CheckedContinuation] = [] + + package init(_ permits: Int) { + precondition(permits > 0) + capacity = permits + self.permits = permits + } + + package func acquire() async { + if permits > 0 { + permits -= 1 + return + } + await withCheckedContinuation { waiters.append($0) } + } + + package func release() { + if let continuation = waiters.first { + waiters.removeFirst() + continuation.resume() + } else { + assert(permits < capacity) + permits += 1 + } + } + + package func withPermit( + _ body: @Sendable () async throws -> T + ) async rethrows -> T { + await acquire() + defer { release() } + return try await body() + } +} diff --git a/Sources/RepoPrompt/Infrastructure/WorkspaceContext/Indexing/DeferredReplayBufferActor.swift b/Sources/RepoPromptCore/WorkspaceContext/Indexing/DeferredReplayBufferActor.swift similarity index 83% rename from Sources/RepoPrompt/Infrastructure/WorkspaceContext/Indexing/DeferredReplayBufferActor.swift rename to Sources/RepoPromptCore/WorkspaceContext/Indexing/DeferredReplayBufferActor.swift index f5e863729..95b438034 100644 --- a/Sources/RepoPrompt/Infrastructure/WorkspaceContext/Indexing/DeferredReplayBufferActor.swift +++ b/Sources/RepoPromptCore/WorkspaceContext/Indexing/DeferredReplayBufferActor.swift @@ -1,6 +1,6 @@ import Foundation -protocol DeltaReplayPreparing: Actor { +package protocol DeltaReplayPreparing: Actor { func prepare( rootKey: String, deltas: [FileSystemDelta], @@ -13,19 +13,19 @@ private struct ImmediatePreparedIngressReservation: Equatable { let rootKey: String } -struct PreparedImmediateReplay { +package struct PreparedImmediateReplay { fileprivate let reservation: ImmediatePreparedIngressReservation - let rootGeneration: UInt64 - let sourceDeltas: [FileSystemDelta] - let preparedBatch: PreparedFileSystemReplayBatch - let routingVersion: UInt64 + package let rootGeneration: UInt64 + package let sourceDeltas: [FileSystemDelta] + package let preparedBatch: PreparedFileSystemReplayBatch + package let routingVersion: UInt64 - var rootKey: String { + package var rootKey: String { reservation.rootKey } } -enum DeferredReplayIngressResult { +package enum DeferredReplayIngressResult { case preparedImmediate(PreparedImmediateReplay) case queued case overflowRequiresRefresh(rootKey: String) @@ -33,27 +33,27 @@ enum DeferredReplayIngressResult { case droppedStaleGeneration(rootKey: String) } -struct DeferredReplayPendingWorkSnapshot: Equatable { - let pendingRootCount: Int - let pendingDeltaCount: Int - let overflowedRootCount: Int +package struct DeferredReplayPendingWorkSnapshot: Equatable { + package let pendingRootCount: Int + package let pendingDeltaCount: Int + package let overflowedRootCount: Int } #if DEBUG - struct DeferredReplayBufferDiagnostics: Equatable { - let pendingRootCount: Int - let pendingDeltaCount: Int - let overflowedRootCount: Int - let routingVersion: UInt64 - let immediatePreparedIngressInFlight: Bool - let immediateIngressCount: UInt64 - let deferredIngressCount: UInt64 - let overflowIngressCount: UInt64 - let preparedDrainCount: UInt64 + package struct DeferredReplayBufferDiagnostics: Equatable { + package let pendingRootCount: Int + package let pendingDeltaCount: Int + package let overflowedRootCount: Int + package let routingVersion: UInt64 + package let immediatePreparedIngressInFlight: Bool + package let immediateIngressCount: UInt64 + package let deferredIngressCount: UInt64 + package let overflowIngressCount: UInt64 + package let preparedDrainCount: UInt64 } #endif -actor DeferredReplayBufferActor { +package actor DeferredReplayBufferActor { private static let defaultImmediateReplayChunkSize = 100 private let maxPendingDeltasPerRoot: Int @@ -75,7 +75,7 @@ actor DeferredReplayBufferActor { private var preparedDrainCount: UInt64 = 0 #endif - init( + package init( maxPendingDeltasPerRoot: Int, preparationActor: any DeltaReplayPreparing = DeltaReplayPreparationActor() ) { @@ -83,7 +83,7 @@ actor DeferredReplayBufferActor { self.preparationActor = preparationActor } - func updateRoutingState( + package func updateRoutingState( isWindowFocused: Bool, isReplayActive: Bool, routingVersion incomingRoutingVersion: UInt64 @@ -94,7 +94,7 @@ actor DeferredReplayBufferActor { routingVersion = incomingRoutingVersion } - func updateImmediateReplayChunkSizeOverride(_ chunkSize: Int?) { + package func updateImmediateReplayChunkSizeOverride(_ chunkSize: Int?) { if let chunkSize { immediateReplayChunkSizeOverride = max(chunkSize, 1) } else { @@ -102,17 +102,17 @@ actor DeferredReplayBufferActor { } } - func registerActiveRootGeneration(_ generation: UInt64, forRootKey rootKey: String) { + package func registerActiveRootGeneration(_ generation: UInt64, forRootKey rootKey: String) { activeRootGenerations[rootKey] = generation clearBufferedState(forRootKey: rootKey) } - func unregisterActiveRootGeneration(forRootKey rootKey: String) { + package func unregisterActiveRootGeneration(forRootKey rootKey: String) { activeRootGenerations.removeValue(forKey: rootKey) clearBufferedState(forRootKey: rootKey) } - func ingestLiveDeltas( + package func ingestLiveDeltas( _ deltas: [FileSystemDelta], forRootKey rootKey: String, rootGeneration: UInt64 @@ -123,7 +123,7 @@ actor DeferredReplayBufferActor { return await routeIngress(deltas, forRootKey: rootKey, rootGeneration: rootGeneration) } - func ingestLiveDeltas( + package func ingestLiveDeltas( _ deltas: [FileSystemDelta], forRootKey rootKey: String ) async -> DeferredReplayIngressResult { @@ -184,11 +184,11 @@ actor DeferredReplayBufferActor { ) } - func finishPreparedImmediateIngress(_ immediateReplay: PreparedImmediateReplay) { + package func finishPreparedImmediateIngress(_ immediateReplay: PreparedImmediateReplay) { releaseImmediatePreparedIngressReservation(immediateReplay.reservation) } - func enqueueDeferredDeltas( + package func enqueueDeferredDeltas( _ deltas: [FileSystemDelta], forRootKey rootKey: String ) -> DeferredReplayIngressResult { @@ -216,7 +216,7 @@ actor DeferredReplayBufferActor { return .queued } - func drainPreparedBatches( + package func drainPreparedBatches( preferredRootOrder: [String], chunkSize: Int ) async -> [PreparedFileSystemReplayBatch] { @@ -247,26 +247,26 @@ actor DeferredReplayBufferActor { return batches } - func clearRoot(_ rootKey: String) { + package func clearRoot(_ rootKey: String) { clearBufferedState(forRootKey: rootKey) } - func clearAll() { + package func clearAll() { pendingDeltasByRoot.removeAll(keepingCapacity: false) overflowedRoots.removeAll(keepingCapacity: false) activeRootGenerations.removeAll(keepingCapacity: false) immediatePreparedIngressReservation = nil } - func hasPendingWork() -> Bool { + package func hasPendingWork() -> Bool { !pendingDeltasByRoot.isEmpty } - func pendingDeltaCount(forRootKey rootKey: String) -> Int { + package func pendingDeltaCount(forRootKey rootKey: String) -> Int { pendingDeltasByRoot[rootKey]?.count ?? 0 } - func pendingWorkSnapshot() -> DeferredReplayPendingWorkSnapshot { + package func pendingWorkSnapshot() -> DeferredReplayPendingWorkSnapshot { DeferredReplayPendingWorkSnapshot( pendingRootCount: pendingDeltasByRoot.count, pendingDeltaCount: pendingDeltasByRoot.values.reduce(0) { $0 + $1.count }, @@ -275,7 +275,7 @@ actor DeferredReplayBufferActor { } #if DEBUG - func diagnosticsSnapshot() -> DeferredReplayBufferDiagnostics { + package func diagnosticsSnapshot() -> DeferredReplayBufferDiagnostics { let snapshot = pendingWorkSnapshot() return DeferredReplayBufferDiagnostics( pendingRootCount: snapshot.pendingRootCount, diff --git a/Sources/RepoPrompt/Infrastructure/WorkspaceContext/Indexing/DeltaReplayPreparationActor.swift b/Sources/RepoPromptCore/WorkspaceContext/Indexing/DeltaReplayPreparationActor.swift similarity index 76% rename from Sources/RepoPrompt/Infrastructure/WorkspaceContext/Indexing/DeltaReplayPreparationActor.swift rename to Sources/RepoPromptCore/WorkspaceContext/Indexing/DeltaReplayPreparationActor.swift index 942b762a9..8e5101fb8 100644 --- a/Sources/RepoPrompt/Infrastructure/WorkspaceContext/Indexing/DeltaReplayPreparationActor.swift +++ b/Sources/RepoPromptCore/WorkspaceContext/Indexing/DeltaReplayPreparationActor.swift @@ -1,100 +1,76 @@ import Foundation -#if DEBUG || EDIT_FLOW_PERF - import os -#endif -enum RepoFileReplayPerf { - #if DEBUG || EDIT_FLOW_PERF - typealias State = OSSignpostIntervalState - static let signposter = OSSignposter(subsystem: "com.repoprompt.workspace", category: "file-replay") - static var isEnabled: Bool { - UserDefaults.standard.bool(forKey: "enableRepoFileReplaySignposts") - } - - static func begin(_ name: StaticString) -> State? { - guard isEnabled else { return nil } - return signposter.beginInterval(name) - } +package enum WorkspaceReplayDiagnostics { + package struct State {} - static func end(_ name: StaticString, _ state: State?) { - guard isEnabled, let state else { return } - signposter.endInterval(name, state) - } - #else - struct State {} - static var isEnabled: Bool { - false - } - - static func begin(_ name: StaticString) -> State? { - nil - } + package static func begin(_: StaticString) -> State? { + nil + } - static func end(_ name: StaticString, _ state: State?) {} - #endif + package static func end(_: StaticString, _: State?) {} } -struct PreparedFileSystemDelta { - let delta: FileSystemDelta - let relativePath: String - let absolutePath: String +package struct PreparedFileSystemDelta { + package let delta: FileSystemDelta + package let relativePath: String + package let absolutePath: String - var isFolderAdded: Bool { + package var isFolderAdded: Bool { if case .folderAdded = delta { return true } return false } - var isFolderRemoved: Bool { + package var isFolderRemoved: Bool { if case .folderRemoved = delta { return true } return false } } -struct PreparedFolderRenameTransfer: Equatable { - let oldAbsolutePath: String - let newAbsolutePath: String +package struct PreparedFolderRenameTransfer: Equatable { + package let oldAbsolutePath: String + package let newAbsolutePath: String } -struct PreparedFileSystemReplayChunkSummary: Equatable { - var fileAddedCount: Int = 0 - var fileRemovedCount: Int = 0 - var folderAddedCount: Int = 0 - var folderRemovedCount: Int = 0 - var fileModifiedCount: Int = 0 - var folderModifiedCount: Int = 0 +package struct PreparedFileSystemReplayChunkSummary: Equatable { + package var fileAddedCount: Int = 0 + package var fileRemovedCount: Int = 0 + package var folderAddedCount: Int = 0 + package var folderRemovedCount: Int = 0 + package var fileModifiedCount: Int = 0 + package var folderModifiedCount: Int = 0 - var modifiedCount: Int { + package var modifiedCount: Int { fileModifiedCount + folderModifiedCount } } -struct PreparedFileSystemReplayChunk: Equatable { - let range: Range - let deltaCount: Int - let summary: PreparedFileSystemReplayChunkSummary - let renameTransfers: [PreparedFolderRenameTransfer] +package struct PreparedFileSystemReplayChunk: Equatable { + package let range: Range + package let deltaCount: Int + package let summary: PreparedFileSystemReplayChunkSummary + package let renameTransfers: [PreparedFolderRenameTransfer] } -struct PreparedFileSystemReplayBatch { - let rootKey: String - let queuedDeltaCount: Int - let coalescedDeltaCount: Int - let preparedDeltas: [PreparedFileSystemDelta] - let chunks: [PreparedFileSystemReplayChunk] - let coalesceDurationMS: Double - let preparationDurationMS: Double +package struct PreparedFileSystemReplayBatch { + package let rootKey: String + package let queuedDeltaCount: Int + package let coalescedDeltaCount: Int + package let preparedDeltas: [PreparedFileSystemDelta] + package let chunks: [PreparedFileSystemReplayChunk] + package let coalesceDurationMS: Double + package let preparationDurationMS: Double - var discardedDeltaCount: Int { + package var discardedDeltaCount: Int { max(queuedDeltaCount - coalescedDeltaCount, 0) } } -enum FileSystemDeltaPreparation { +package enum FileSystemDeltaPreparation { private static func timestampMS() -> Double { ProcessInfo.processInfo.systemUptime * 1000 } - static func rawRelativePath(for delta: FileSystemDelta) -> String { + package static func rawRelativePath(for delta: FileSystemDelta) -> String { switch delta { case let .fileAdded(rel), let .fileRemoved(rel), let .folderAdded(rel), let .folderRemoved(rel), @@ -103,11 +79,11 @@ enum FileSystemDeltaPreparation { } } - static func standardizedRelativePath(for delta: FileSystemDelta) -> String { + package static func standardizedRelativePath(for delta: FileSystemDelta) -> String { StandardizedPath.relative(rawRelativePath(for: delta)) } - static func containedPaths( + package static func containedPaths( for delta: FileSystemDelta, inRoot standardizedRoot: String ) -> (relativePath: String, absolutePath: String)? { @@ -125,7 +101,7 @@ enum FileSystemDeltaPreparation { return (relativePath, absolutePath) } - static func prepare( + package static func prepare( _ delta: FileSystemDelta, inRoot standardizedRoot: String ) -> PreparedFileSystemDelta? { @@ -137,7 +113,7 @@ enum FileSystemDeltaPreparation { ) } - static func coalesce( + package static func coalesce( _ deltas: [FileSystemDelta], inRoot standardizedRoot: String? = nil ) -> [FileSystemDelta] { @@ -217,7 +193,7 @@ enum FileSystemDeltaPreparation { return chosen.sorted { $0.idx < $1.idx }.map(\.delta) } - static func makeChunkRanges(totalCount: Int, chunkSize: Int) -> [Range] { + package static func makeChunkRanges(totalCount: Int, chunkSize: Int) -> [Range] { guard totalCount > 0 else { return [] } let safeChunkSize = max(chunkSize, 1) var ranges: [Range] = [] @@ -284,7 +260,7 @@ enum FileSystemDeltaPreparation { ) } - static func prepareBatch( + package static func prepareBatch( rootKey: String, deltas: [FileSystemDelta], chunkSize: Int @@ -308,14 +284,16 @@ enum FileSystemDeltaPreparation { } } -actor DeltaReplayPreparationActor: DeltaReplayPreparing { - func prepare( +package actor DeltaReplayPreparationActor: DeltaReplayPreparing { + package init() {} + + package func prepare( rootKey: String, deltas: [FileSystemDelta], chunkSize: Int ) async -> PreparedFileSystemReplayBatch { - let signpost = RepoFileReplayPerf.begin("prepareReplayBatch") - defer { RepoFileReplayPerf.end("prepareReplayBatch", signpost) } + let signpost = WorkspaceReplayDiagnostics.begin("prepareReplayBatch") + defer { WorkspaceReplayDiagnostics.end("prepareReplayBatch", signpost) } return FileSystemDeltaPreparation.prepareBatch( rootKey: rootKey, deltas: deltas, diff --git a/Sources/RepoPromptCore/WorkspaceContext/Models/FilePathDisplay.swift b/Sources/RepoPromptCore/WorkspaceContext/Models/FilePathDisplay.swift new file mode 100644 index 000000000..f58776049 --- /dev/null +++ b/Sources/RepoPromptCore/WorkspaceContext/Models/FilePathDisplay.swift @@ -0,0 +1,4 @@ +package enum FilePathDisplay: String, CaseIterable { + case full = "Full" + case relative = "Relative" +} diff --git a/Sources/RepoPromptCore/WorkspaceContext/Models/WorkspaceFileContextModels.swift b/Sources/RepoPromptCore/WorkspaceContext/Models/WorkspaceFileContextModels.swift new file mode 100644 index 000000000..6366b0857 --- /dev/null +++ b/Sources/RepoPromptCore/WorkspaceContext/Models/WorkspaceFileContextModels.swift @@ -0,0 +1,595 @@ +import Foundation + +/// Root scopes shared by UI and headless workspace file lookup paths. +package enum WorkspaceLookupRootScope: Hashable { + case visibleWorkspace + case visibleWorkspacePlusGitData + case allLoaded + case sessionBoundWorkspace(logicalRootPaths: Set, physicalRootPaths: Set) +} + +package enum WorkspaceLookupRootScopeAvailability: Equatable { + case available + case sessionWorktreeUnavailable(missingPhysicalRootPaths: [String]) +} + +package enum WorkspaceSearchCatalogAccess: Equatable { + case available(WorkspaceSearchCatalogSnapshot) + case unavailable(WorkspaceLookupRootScopeAvailability) +} + +package typealias LookupRootScope = WorkspaceLookupRootScope + +package enum WorkspaceRootKind: Hashable { + case primaryWorkspace + case workspaceGitData + case supplementalSystem + case sessionWorktree +} + +package enum WorkspaceExactPathLookupKind: Hashable { + case file + case folder + case either +} + +package struct WorkspaceFolderExpansionResult: Equatable { + package let files: [WorkspaceFileRecord] + package let handled: Bool + package let displayPath: String? + package let issue: PathResolutionIssue? +} + +package struct WorkspaceRootLoadFailure: Equatable, Identifiable { + package let id: UUID + package let rootPath: String + package let standardizedRootPath: String + package let kind: WorkspaceRootKind + package let errorDescription: String + + package init(id: UUID = UUID(), rootPath: String, kind: WorkspaceRootKind, errorDescription: String) { + self.id = id + self.rootPath = rootPath + standardizedRootPath = StandardizedPath.absolute(rootPath) + self.kind = kind + self.errorDescription = errorDescription + } + + package static func == (lhs: WorkspaceRootLoadFailure, rhs: WorkspaceRootLoadFailure) -> Bool { + lhs.standardizedRootPath == rhs.standardizedRootPath && + lhs.kind == rhs.kind && + lhs.errorDescription == rhs.errorDescription + } +} + +package enum WorkspaceSearchReadinessState: Equatable { + case idle + case activating(workspaceID: UUID?, generation: UInt64) + case loadingCatalog(workspaceID: UUID?, generation: UInt64, loadedRootCount: Int, expectedRootCount: Int, failures: [WorkspaceRootLoadFailure]) + case buildingIndexes(workspaceID: UUID?, generation: UInt64, catalogGeneration: UInt64, failures: [WorkspaceRootLoadFailure]) + case ready(workspaceID: UUID?, generation: UInt64, catalogGeneration: UInt64, indexedGeneration: UInt64, diagnostics: WorkspaceCatalogDiagnostics) + case degraded(workspaceID: UUID?, generation: UInt64, catalogGeneration: UInt64?, indexedGeneration: UInt64?, failures: [WorkspaceRootLoadFailure], diagnostics: WorkspaceCatalogDiagnostics?) +} + +package struct WorkspaceCatalogDiagnostics: Equatable { + package let generation: UInt64 + package let rootScope: WorkspaceLookupRootScope + package let rootCount: Int + package let folderCount: Int + package let fileCount: Int + package let totalItemCount: Int + + package init( + generation: UInt64, + rootScope: WorkspaceLookupRootScope, + rootCount: Int, + folderCount: Int, + fileCount: Int + ) { + self.generation = generation + self.rootScope = rootScope + self.rootCount = rootCount + self.folderCount = folderCount + self.fileCount = fileCount + totalItemCount = folderCount + fileCount + } +} + +package struct WorkspaceSearchCatalogEntry: Identifiable, Equatable, Hashable { + package let id: UUID + package let rootID: UUID + package let rootPath: String + package let rootName: String + package let name: String + package let relativePath: String + package let standardizedRelativePath: String + package let fullPath: String + package let standardizedFullPath: String + package let displayPath: String + + package init(file: WorkspaceFileRecord, root: WorkspaceRootRecord, displayPath: String? = nil) { + id = file.id + rootID = file.rootID + rootPath = root.standardizedFullPath + rootName = root.name + name = file.name + relativePath = file.relativePath + standardizedRelativePath = file.standardizedRelativePath + fullPath = file.fullPath + standardizedFullPath = file.standardizedFullPath + self.displayPath = displayPath ?? WorkspaceSearchCatalogEntry.defaultDisplayPath(file: file, root: root) + } + + private static func defaultDisplayPath(file: WorkspaceFileRecord, root: WorkspaceRootRecord) -> String { + guard !file.standardizedRelativePath.isEmpty else { return root.name } + return root.name + "/" + file.standardizedRelativePath + } +} + +package struct WorkspaceSearchCatalogSnapshot: Equatable { + package let generation: UInt64 + package let rootScope: WorkspaceLookupRootScope + package let roots: [WorkspaceRootRecord] + package let files: [WorkspaceFileRecord] + package let entries: [WorkspaceSearchCatalogEntry] + package let diagnostics: WorkspaceCatalogDiagnostics +} + +package enum WorkspaceFileContextCaptureCoverage: Equatable { + package enum CodemapCoverage: Equatable { + case referenced + case allAvailable + } + + case complete + case projection(codemapCoverage: CodemapCoverage) +} + +/// Immutable store-owned inputs captured for later workspace projection composition. +/// +/// File identities, selection resolution, codemap state, and the file tree are coherent at +/// `provenance.captureGeneration`. File contents are intentionally excluded and may be read live later. +package struct WorkspaceFileContextCapture { + package struct Provenance: Equatable { + package let captureGeneration: UInt64 + package let catalogGeneration: UInt64 + package let catalogValidationToken: UInt64 + package let rootScope: WorkspaceLookupRootScope + package let ingressSamples: [WorkspaceIngressBarrierSample] + } + + package struct SelectionPath: Equatable { + package enum Resolution: Equatable { + case file(WorkspaceFileRecord) + case folder(WorkspaceFolderRecord, descendantFiles: [WorkspaceFileRecord]) + case unresolved(PathResolutionIssue) + } + + package let input: String + package let resolution: Resolution + } + + package struct Slice: Equatable { + package let path: String + package let ranges: [LineRange] + package let file: WorkspaceFileRecord? + package let issue: PathResolutionIssue? + } + + package let coverage: WorkspaceFileContextCaptureCoverage + package let provenance: Provenance + package let storedSelection: StoredSelection + package let selectedPaths: [SelectionPath] + package let autoCodemapPaths: [SelectionPath] + package let slices: [Slice] + package let catalog: WorkspaceSearchCatalogSnapshot + package let materializedFolders: [WorkspaceFolderRecord] + package let materializedFiles: [WorkspaceFileRecord] + package let codemapSnapshots: [WorkspaceCodemapSnapshot] + package let fileTree: FileTreeSelectionSnapshot + + package init( + coverage: WorkspaceFileContextCaptureCoverage = .complete, + provenance: Provenance, + storedSelection: StoredSelection, + selectedPaths: [SelectionPath], + autoCodemapPaths: [SelectionPath], + slices: [Slice], + catalog: WorkspaceSearchCatalogSnapshot, + materializedFolders: [WorkspaceFolderRecord], + materializedFiles: [WorkspaceFileRecord], + codemapSnapshots: [WorkspaceCodemapSnapshot], + fileTree: FileTreeSelectionSnapshot + ) { + self.coverage = coverage + self.provenance = provenance + self.storedSelection = storedSelection + self.selectedPaths = selectedPaths + self.autoCodemapPaths = autoCodemapPaths + self.slices = slices + self.catalog = catalog + self.materializedFolders = materializedFolders + self.materializedFiles = materializedFiles + self.codemapSnapshots = codemapSnapshots + self.fileTree = fileTree + } +} + +package struct WorkspaceDirectFolderChildrenSnapshot: Equatable { + package let generation: UInt64 + package let root: WorkspaceRootRecord + package let folder: WorkspaceFolderRecord + package let childFolders: [WorkspaceFolderRecord] + package let childFiles: [WorkspaceFileRecord] + + package var isEmpty: Bool { + childFolders.isEmpty && childFiles.isEmpty + } +} + +package struct WorkspaceSearchQueryResult: Equatable { + package let query: String + package let indexedGeneration: UInt64? + package let snapshotGeneration: UInt64? + package let pendingGeneration: UInt64? + package let observedGeneration: UInt64? + package let results: [WorkspaceSearchCatalogEntry] + package let isIndexReady: Bool + package let isStale: Bool + + package init( + query: String, + indexedGeneration: UInt64?, + snapshotGeneration: UInt64?, + pendingGeneration: UInt64? = nil, + observedGeneration: UInt64? = nil, + results: [WorkspaceSearchCatalogEntry], + isIndexReady: Bool, + isStale: Bool = false + ) { + self.query = query + self.indexedGeneration = indexedGeneration + self.snapshotGeneration = snapshotGeneration + self.pendingGeneration = pendingGeneration + self.observedGeneration = observedGeneration + self.results = results + self.isIndexReady = isIndexReady + self.isStale = isStale + } +} + +package struct WorkspaceResolvedCandidates: Equatable { + package let candidates: [WorkspaceFileRecord] + package let resolvedMap: [String: String] + package let invalidPaths: [String] + + package init(candidates: [WorkspaceFileRecord], resolvedMap: [String: String], invalidPaths: [String]) { + self.candidates = candidates + self.resolvedMap = resolvedMap + self.invalidPaths = invalidPaths + } +} + +package struct WorkspaceCodemapOnlyCandidates: Equatable { + package let candidates: [WorkspaceFileRecord] + package let resolvedMap: [String: String] + package let invalidPaths: [String] + package let codemapUnavailable: [String] + + package init( + candidates: [WorkspaceFileRecord], + resolvedMap: [String: String], + invalidPaths: [String], + codemapUnavailable: [String] + ) { + self.candidates = candidates + self.resolvedMap = resolvedMap + self.invalidPaths = invalidPaths + self.codemapUnavailable = codemapUnavailable + } +} + +package struct WorkspaceRootRecord: Identifiable, Equatable, Hashable { + package let id: UUID + package let name: String + package let fullPath: String + package let standardizedFullPath: String + package let isSystemRoot: Bool + package let kind: WorkspaceRootKind + + package init(id: UUID = UUID(), name: String, fullPath: String, isSystemRoot: Bool = false) { + self.init( + id: id, + name: name, + fullPath: fullPath, + kind: isSystemRoot ? .supplementalSystem : .primaryWorkspace, + isSystemRoot: isSystemRoot + ) + } + + package init(id: UUID = UUID(), name: String, fullPath: String, kind: WorkspaceRootKind) { + self.init( + id: id, + name: name, + fullPath: fullPath, + kind: kind, + isSystemRoot: kind != .primaryWorkspace + ) + } + + private init(id: UUID, name: String, fullPath: String, kind: WorkspaceRootKind, isSystemRoot: Bool) { + self.id = id + self.name = name + self.fullPath = fullPath + standardizedFullPath = (fullPath as NSString).standardizingPath + self.isSystemRoot = isSystemRoot + self.kind = kind + } +} + +package struct WorkspaceFolderRecord: Identifiable, Equatable, Hashable { + package let id: UUID + package let rootID: UUID + package let name: String + package let relativePath: String + package let standardizedRelativePath: String + package let fullPath: String + package let standardizedFullPath: String + package let parentFolderID: UUID? + package let modificationDate: Date? + + package init( + id: UUID = UUID(), + rootID: UUID, + name: String, + relativePath: String, + fullPath: String, + parentFolderID: UUID?, + modificationDate: Date? = nil + ) { + self.id = id + self.rootID = rootID + self.name = name + self.relativePath = relativePath + standardizedRelativePath = StandardizedPath.relative(relativePath) + self.fullPath = fullPath + standardizedFullPath = (fullPath as NSString).standardizingPath + self.parentFolderID = parentFolderID + self.modificationDate = modificationDate + } +} + +package struct WorkspaceFileRecord: Identifiable, Equatable, Hashable { + package let id: UUID + package let rootID: UUID + package let name: String + package let relativePath: String + package let standardizedRelativePath: String + package let fullPath: String + package let standardizedFullPath: String + package let parentFolderID: UUID? + package let modificationDate: Date? + + package init( + id: UUID = UUID(), + rootID: UUID, + name: String, + relativePath: String, + fullPath: String, + parentFolderID: UUID?, + modificationDate: Date? = nil + ) { + self.id = id + self.rootID = rootID + self.name = name + self.relativePath = relativePath + standardizedRelativePath = StandardizedPath.relative(relativePath) + self.fullPath = fullPath + standardizedFullPath = (fullPath as NSString).standardizingPath + self.parentFolderID = parentFolderID + self.modificationDate = modificationDate + } +} + +package struct ResolvedWorkspaceSelection: Equatable { + package let files: [WorkspaceFileRecord] + package let folders: [WorkspaceFolderRecord] + package let missingPaths: [String] +} + +package struct ResolvedPromptFileEntry: Identifiable, Equatable { + package let id: ResolvedPromptFileEntryID + package let file: WorkspaceFileRecord + package let isCodemap: Bool + package let lineRanges: [LineRange]? + package let mode: PromptFileEntryMode + package let loadedContent: String? + package let rootFolderPath: String? + + package init( + file: WorkspaceFileRecord, + isCodemap: Bool = false, + lineRanges: [LineRange]? = nil, + mode: PromptFileEntryMode = .fullFile, + loadedContent: String? = nil, + rootFolderPath: String? = nil + ) { + id = ResolvedPromptFileEntryID(fileID: file.id, mode: mode, lineRanges: lineRanges) + self.file = file + self.isCodemap = isCodemap + self.lineRanges = lineRanges + self.mode = mode + self.loadedContent = loadedContent + self.rootFolderPath = rootFolderPath + } +} + +package struct ResolvedPromptFileBlockRecord: Equatable { + package let entry: ResolvedPromptFileEntry + package let file: WorkspaceFileRecord + package let text: String + package let isCodemap: Bool + + package init(entry: ResolvedPromptFileEntry, file: WorkspaceFileRecord, text: String, isCodemap: Bool) { + self.entry = entry + self.file = file + self.text = text + self.isCodemap = isCodemap + } +} + +package struct ResolvedPromptFileEntryID: Hashable { + package let fileID: UUID + package let mode: PromptFileEntryMode + package let lineRanges: [LineRange]? +} + +package enum PromptFileEntryMode: Hashable { + case fullFile + case sliced + case codemap +} + +package struct WorkspaceExternalReadableFile: Equatable, Hashable { + package let absolutePath: String + package let displayPath: String +} + +package enum WorkspaceReadableFileHandle: Equatable { + case workspace(WorkspaceFileRecord) + case external(WorkspaceExternalReadableFile) +} + +package struct WorkspaceFileSystemDeltaEvent: Equatable { + package let rootID: UUID + package let rootPath: String + package let delta: FileSystemDelta +} + +package struct WorkspaceIngressBarrierSample: Equatable { + package let rootID: UUID + package let rootPath: String + package let pendingRawEventCountBeforeFlush: Int + package let acceptedWatcherWatermark: UInt64 + package let publishedServicePublicationSequence: UInt64 + package let appliedServicePublicationSequence: UInt64 + package let appliedWatcherWatermark: UInt64 +} + +package struct WorkspaceAppliedIndexBatchEvent: Equatable { + package let rootID: UUID + package let rootPath: String + package let generation: UInt64 + package let upsertedFiles: [WorkspaceFileRecord] + package let upsertedFolders: [WorkspaceFolderRecord] + package let removedFileIDs: [UUID] + package let removedFolderIDs: [UUID] + package let removedFilePaths: [String] + package let removedFolderPaths: [String] + package let modifiedFileIDs: [UUID] + package let modifiedFolderIDs: [UUID] + package let requiresFullResync: Bool + package let isRootUnload: Bool + + package init( + rootID: UUID, + rootPath: String, + generation: UInt64, + upsertedFiles: [WorkspaceFileRecord] = [], + upsertedFolders: [WorkspaceFolderRecord] = [], + removedFileIDs: [UUID] = [], + removedFolderIDs: [UUID] = [], + removedFilePaths: [String] = [], + removedFolderPaths: [String] = [], + modifiedFileIDs: [UUID] = [], + modifiedFolderIDs: [UUID] = [], + requiresFullResync: Bool = false, + isRootUnload: Bool = false + ) { + self.rootID = rootID + self.rootPath = rootPath + self.generation = generation + self.upsertedFiles = upsertedFiles + self.upsertedFolders = upsertedFolders + self.removedFileIDs = removedFileIDs + self.removedFolderIDs = removedFolderIDs + self.removedFilePaths = removedFilePaths + self.removedFolderPaths = removedFolderPaths + self.modifiedFileIDs = modifiedFileIDs + self.modifiedFolderIDs = modifiedFolderIDs + self.requiresFullResync = requiresFullResync + self.isRootUnload = isRootUnload + } +} + +package struct WorkspaceCodemapSnapshot { + package let fileID: UUID + package let rootID: UUID + package let rootPath: String + package let relativePath: String + package let fullPath: String + package let modificationDate: Date + package let fileAPI: FileAPI? +} + +package struct WorkspaceCodemapUpdateEvent { + package let rootID: UUID + package let rootPath: String + package let snapshots: [WorkspaceCodemapSnapshot] + package let removedFileIDs: [UUID] + package let isRootUnload: Bool + + package init( + rootID: UUID, + rootPath: String, + snapshots: [WorkspaceCodemapSnapshot], + removedFileIDs: [UUID] = [], + isRootUnload: Bool = false + ) { + self.rootID = rootID + self.rootPath = rootPath + self.snapshots = snapshots + self.removedFileIDs = removedFileIDs + self.isRootUnload = isRootUnload + } +} + +package struct WorkspacePathLookupRequest: Equatable { + package let userPath: String + package let profile: PathLocateProfile + package let rootScope: WorkspaceLookupRootScope + package let selectedFileFullPaths: Set + + package init( + userPath: String, + profile: PathLocateProfile = .uiAssisted, + rootScope: WorkspaceLookupRootScope = .allLoaded, + selectedFileFullPaths: Set = [] + ) { + self.userPath = userPath + self.profile = profile + self.rootScope = rootScope + self.selectedFileFullPaths = selectedFileFullPaths + } +} + +package struct WorkspacePathLocation: Equatable, Hashable { + package let rootID: UUID + package let rootPath: String + package let correctedPath: String + + package var absolutePath: String { + let standardizedRoot = (rootPath as NSString).standardizingPath + if correctedPath.hasPrefix("/") { + return (correctedPath as NSString).standardizingPath + } + return ((standardizedRoot as NSString).appendingPathComponent(correctedPath) as NSString).standardizingPath + } +} + +package struct WorkspacePathLookupResult: Equatable { + package let input: String + package let location: WorkspacePathLocation + package let file: WorkspaceFileRecord? + package let folder: WorkspaceFolderRecord? +} diff --git a/Sources/RepoPrompt/Infrastructure/WorkspaceContext/PathLookup/PathCharPolicy.swift b/Sources/RepoPromptCore/WorkspaceContext/PathLookup/PathCharPolicy.swift similarity index 93% rename from Sources/RepoPrompt/Infrastructure/WorkspaceContext/PathLookup/PathCharPolicy.swift rename to Sources/RepoPromptCore/WorkspaceContext/PathLookup/PathCharPolicy.swift index 29d6437c6..d02639402 100644 --- a/Sources/RepoPrompt/Infrastructure/WorkspaceContext/PathLookup/PathCharPolicy.swift +++ b/Sources/RepoPromptCore/WorkspaceContext/PathLookup/PathCharPolicy.swift @@ -5,9 +5,9 @@ import Foundation /// /// - Allowed ASCII remains: [A–Z a–z 0–9 . _ - [ ] ( ) { } + ! # % @ /] /// - We *fold* common lookalikes into their ASCII equivalents (e.g., EN DASH → '-') -enum PathCharPolicy { +package enum PathCharPolicy { @inline(__always) - static func isAllowedASCIIByte(_ b: UInt8) -> Bool { + package static func isAllowedASCIIByte(_ b: UInt8) -> Bool { switch b { case 0x30 ... 0x39, // 0-9 0x41 ... 0x5A, // A-Z @@ -31,14 +31,14 @@ enum PathCharPolicy { } @inline(__always) - static func toLowerASCII(_ b: UInt8) -> UInt8 { + package static func toLowerASCII(_ b: UInt8) -> UInt8 { (0x41 ... 0x5A).contains(b) ? b &+ 0x20 : b } // MARK: - Drop invisible/format characters @inline(__always) - static func isZeroWidthOrFormat(_ sc: UnicodeScalar) -> Bool { + package static func isZeroWidthOrFormat(_ sc: UnicodeScalar) -> Bool { switch sc.value { case 0x200B, // ZERO WIDTH SPACE 0x200C, // ZERO WIDTH NON-JOINER @@ -56,7 +56,7 @@ enum PathCharPolicy { /// Emit folded ASCII for a single scalar (supports 1→N mappings). /// If no folding applies, appends the original scalar. @inline(__always) - static func emitFolded(_ sc: UnicodeScalar, into view: inout String.UnicodeScalarView) { + package static func emitFolded(_ sc: UnicodeScalar, into view: inout String.UnicodeScalarView) { let v = sc.value let hyphen = UnicodeScalar(0x2D)! // '-' let slash = UnicodeScalar(0x2F)! // '/' @@ -150,7 +150,7 @@ enum PathCharPolicy { } /// Fold when non-ASCII is present; also normalize composition. - static func foldHomoglyphsIfNeeded(_ s: String) -> String { + package static func foldHomoglyphsIfNeeded(_ s: String) -> String { var hasNonASCII = false for b in s.utf8 where b >= 0x80 { hasNonASCII = true diff --git a/Sources/RepoPrompt/Infrastructure/WorkspaceContext/PathLookup/PathMatchTypes.swift b/Sources/RepoPromptCore/WorkspaceContext/PathLookup/PathMatchTypes.swift similarity index 83% rename from Sources/RepoPrompt/Infrastructure/WorkspaceContext/PathLookup/PathMatchTypes.swift rename to Sources/RepoPromptCore/WorkspaceContext/PathLookup/PathMatchTypes.swift index e136634ee..3336832d9 100644 --- a/Sources/RepoPrompt/Infrastructure/WorkspaceContext/PathLookup/PathMatchTypes.swift +++ b/Sources/RepoPromptCore/WorkspaceContext/PathLookup/PathMatchTypes.swift @@ -4,9 +4,9 @@ import Foundation /// Pure-value result coming from the background PathMatcher. /// No actor references – therefore `Sendable` by default. -struct PathMatchLocation { - let rootPath: String // absolute path of the owning repo root - let correctedPath: String // final relative path inside that root +package struct PathMatchLocation { + package let rootPath: String // absolute path of the owning repo root + package let correctedPath: String // final relative path inside that root } // MARK: - Immutable Snapshot Types @@ -14,23 +14,23 @@ struct PathMatchLocation { /// Static part of the snapshot (expensive to build, cached). /// Immutable, cross-actor-safe snapshot used by PathMatcher and PathMatchWorker. /// All references to UI/ViewModel types are stripped; uses frozen records only. -struct StaticPathMatchData { - let filesByFullPath: [String: FileRecord] - let foldersByFullPath: [String: FolderRecord] - let rootFolders: [FolderRecord] +package struct StaticPathMatchData { + package let filesByFullPath: [String: FileRecord] + package let foldersByFullPath: [String: FolderRecord] + package let rootFolders: [FolderRecord] // Case-insensitive dictionaries (no duplicates in original maps) - let filesByLowerFullPath: [String: FileRecord] - let foldersByLowerFullPath: [String: FolderRecord] + package let filesByLowerFullPath: [String: FileRecord] + package let foldersByLowerFullPath: [String: FolderRecord] /// Matching policy - current default is case-insensitive - let caseSensitive: Bool + package let caseSensitive: Bool /// Monotonic id to allow caching of indexes per snapshot generation. /// Bumped by WorkspaceFilesViewModel when the file hierarchy changes. - let id: UInt64 + package let id: UInt64 - init( + package init( filesByFullPath: [String: FileRecord], foldersByFullPath: [String: FolderRecord], rootFolders: [FolderRecord], @@ -67,29 +67,29 @@ struct StaticPathMatchData { /// Immutable snapshot of file hierarchy state for path matching. /// All references to UI/ViewModel types are stripped; uses frozen records only. -struct PathMatchSnapshot { - let filesByFullPath: [String: FileRecord] - let foldersByFullPath: [String: FolderRecord] - let rootFolders: [FolderRecord] +package struct PathMatchSnapshot { + package let filesByFullPath: [String: FileRecord] + package let foldersByFullPath: [String: FolderRecord] + package let rootFolders: [FolderRecord] // Case-insensitive dictionaries copied from StaticPathMatchData or computed on the fly - let filesByLowerFullPath: [String: FileRecord] - let foldersByLowerFullPath: [String: FolderRecord] + package let filesByLowerFullPath: [String: FileRecord] + package let foldersByLowerFullPath: [String: FolderRecord] - let selectedFileFullPaths: Set + package let selectedFileFullPaths: Set /// Fully computed indexes – no internal locking, built on the worker actor. private let storedIndexes: PathMatchIndexes - var indexes: PathMatchIndexes { + package var indexes: PathMatchIndexes { storedIndexes } /// Matching policy flag (current default is case-insensitive) - let caseSensitive: Bool + package let caseSensitive: Bool /// Primary initializer used by PathMatchWorker. /// Accepts pre-computed indexes from the worker's cache. - init( + package init( staticData: StaticPathMatchData, selectedFileFullPaths: Set, indexes: PathMatchIndexes @@ -106,7 +106,7 @@ struct PathMatchSnapshot { /// Convenience initializer for tests and legacy code. /// Builds indexes synchronously (expensive for large file trees). - init( + package init( filesByFullPath: [String: FileRecord], foldersByFullPath: [String: FolderRecord], rootFolders: [FolderRecord], @@ -206,38 +206,38 @@ struct PathMatchSnapshot { // MARK: − Convenience helpers - func fileRecord(forFullPath path: String) -> FileRecord? { + package func fileRecord(forFullPath path: String) -> FileRecord? { let std = (path as NSString).standardizingPath return fileRecord(forStandardizedFullPath: std) } - func fileRecord(forStandardizedFullPath path: String) -> FileRecord? { + package func fileRecord(forStandardizedFullPath path: String) -> FileRecord? { filesByFullPath[path] ?? filesByLowerFullPath[path.lowercased()] } - func folderRecord(forFullPath path: String) -> FolderRecord? { + package func folderRecord(forFullPath path: String) -> FolderRecord? { let std = (path as NSString).standardizingPath return folderRecord(forStandardizedFullPath: std) } - func folderRecord(forStandardizedFullPath path: String) -> FolderRecord? { + package func folderRecord(forStandardizedFullPath path: String) -> FolderRecord? { foldersByFullPath[path] ?? foldersByLowerFullPath[path.lowercased()] } } -extension PathMatchSnapshot { +package extension PathMatchSnapshot { func canonical(_ s: String) -> String { PathMatchIndexes.canonical(s, caseSensitive: caseSensitive) } } -struct PathMatchIndexes { - let byFileName: [String: [FileRecord]] - let byLastTwo: [String: [FileRecord]] - let byExtension: [String: [FileRecord]] - let foldersByLastComponent: [String: [FolderRecord]] +package struct PathMatchIndexes { + package let byFileName: [String: [FileRecord]] + package let byLastTwo: [String: [FileRecord]] + package let byExtension: [String: [FileRecord]] + package let foldersByLastComponent: [String: [FolderRecord]] - static func canonical(_ s: String, caseSensitive: Bool) -> String { + package static func canonical(_ s: String, caseSensitive: Bool) -> String { // Quick ASCII probe: if all bytes < 0x80, skip folding entirely var isASCII = true for b in s.utf8 { @@ -283,7 +283,7 @@ struct PathMatchIndexes { return caseSensitive ? filtered : filtered.lowercased() } - static func build( + package static func build( files: [String: FileRecord], folders: [String: FolderRecord], caseSensitive: Bool @@ -326,7 +326,7 @@ struct PathMatchIndexes { } } -enum PathLocateProfile: Hashable { +package enum PathLocateProfile: Hashable { case uiAssisted case mcpRead case mcpSelection @@ -335,7 +335,7 @@ enum PathLocateProfile: Hashable { case createBestEffort case createRequireUnambiguous - var options: PathLocateOptions { + package var options: PathLocateOptions { switch self { case .uiAssisted: PathLocateOptions( @@ -381,29 +381,34 @@ enum PathLocateProfile: Hashable { } } -struct PathLocateOptions: Equatable { - let exactMatchOnly: Bool - let allowLeadingRootAliasTrim: Bool - let allowHeadTrimAliases: Bool - let allowAbsoluteSuffixFallback: Bool - let useSelectedRootBias: Bool +package struct PathLocateOptions: Equatable { + package let exactMatchOnly: Bool + package let allowLeadingRootAliasTrim: Bool + package let allowHeadTrimAliases: Bool + package let allowAbsoluteSuffixFallback: Bool + package let useSelectedRootBias: Bool } /// Result of finding a path for file creation -struct FileCreationResult { - let rootFolder: FolderRecord - let componentsToCreate: [String] +package struct FileCreationResult { + package let rootFolder: FolderRecord + package let componentsToCreate: [String] + + package init(rootFolder: FolderRecord, componentsToCreate: [String]) { + self.rootFolder = rootFolder + self.componentsToCreate = componentsToCreate + } } extension FileCreationResult: Equatable { - static func == (lhs: FileCreationResult, rhs: FileCreationResult) -> Bool { + package static func == (lhs: FileCreationResult, rhs: FileCreationResult) -> Bool { lhs.rootFolder.fullPath == rhs.rootFolder.fullPath && lhs.componentsToCreate == rhs.componentsToCreate } } /// Controls how `resolveCreationPath` handles ties between candidate roots. -enum CreationResolutionMode { +package enum CreationResolutionMode { /// Best-effort heuristic tie-breaking (current behavior): always returns a single winner. case bestEffort /// Report ambiguity: if multiple roots tie on structural signals, return `.ambiguous`. @@ -411,7 +416,7 @@ enum CreationResolutionMode { } /// Result of path resolution for file creation with ambiguity detection. -enum FileCreationResolution: Equatable { +package enum FileCreationResolution: Equatable { /// Unambiguous resolution to a single root. case unique(FileCreationResult) /// Multiple roots are equally valid candidates; caller should request disambiguation. @@ -419,25 +424,25 @@ enum FileCreationResolution: Equatable { } /// Helper enum for handling heterogeneous file/folder collections -enum AnyItem { +package enum AnyItem { case folder(FolderRecord) case file(FileRecord) - var name: String { + package var name: String { switch self { case let .folder(f): f.name case let .file(f): f.name } } - var rootPath: String { + package var rootPath: String { switch self { case let .folder(f): f.rootPath case let .file(f): f.rootFolderPath } } - var relativePath: String { + package var relativePath: String { switch self { case let .folder(f): f.relativePath case let .file(f): f.relativePath diff --git a/Sources/RepoPrompt/Infrastructure/WorkspaceContext/PathLookup/PathMatchWorker.swift b/Sources/RepoPromptCore/WorkspaceContext/PathLookup/PathMatchWorker.swift similarity index 94% rename from Sources/RepoPrompt/Infrastructure/WorkspaceContext/PathLookup/PathMatchWorker.swift rename to Sources/RepoPromptCore/WorkspaceContext/PathLookup/PathMatchWorker.swift index e14e47a10..1688d2d75 100644 --- a/Sources/RepoPrompt/Infrastructure/WorkspaceContext/PathLookup/PathMatchWorker.swift +++ b/Sources/RepoPromptCore/WorkspaceContext/PathLookup/PathMatchWorker.swift @@ -4,11 +4,11 @@ import Foundation /// Lightweight signature for a set of selected file paths. /// Used as a cache key component; precomputed on MainActor to avoid work on worker. -struct SelectionSig: Equatable { - let count: Int - let hash: UInt64 +package struct SelectionSig: Equatable { + package let count: Int + package let hash: UInt64 - static let empty = SelectionSig(count: 0, hash: 0) + package static let empty = SelectionSig(count: 0, hash: 0) } /// Computes a deterministic, order-independent signature for a set of paths. @@ -19,7 +19,7 @@ struct SelectionSig: Equatable { /// Note: Previous implementation used rotate after XOR which made the result /// order-dependent. This version uses separate XOR and sum accumulators /// that are truly commutative. -func selectionSignature(for paths: Set) -> SelectionSig { +package func selectionSignature(for paths: Set) -> SelectionSig { guard !paths.isEmpty else { return .empty } var xorAcc: UInt64 = 0 @@ -49,7 +49,7 @@ func selectionSignature(for paths: Set) -> SelectionSig { /// Owns index building & caching; callers pass pure snapshot data. /// Each WorkspaceFilesViewModel owns its own PathMatchWorker instance /// to maintain per-window isolation in multi-window scenarios. -actor PathMatchWorker { +package actor PathMatchWorker { // MARK: - Index Cache (single-entry, keyed by generation) private var lastIndexID: UInt64? @@ -141,7 +141,7 @@ actor PathMatchWorker { /// Use this when a workspace catalog becomes search-ready so later lookup calls do not pay /// index construction cost on their first query. @discardableResult - func prepare(staticData: StaticPathMatchData) -> UInt64 { + package func prepare(staticData: StaticPathMatchData) -> UInt64 { _ = indexes(for: staticData) return staticData.id } @@ -149,7 +149,7 @@ actor PathMatchWorker { /// Runs PathMatcher.locate off MainActor. /// - Parameters: /// - selectionSig: Precomputed on MainActor via `selectionSignature(for:)` - func locate( + package func locate( userPath: String, profile: PathLocateProfile, staticData: StaticPathMatchData, @@ -167,7 +167,7 @@ actor PathMatchWorker { ) } - func locate( + package func locate( userPath: String, exactMatchOnly: Bool, staticData: StaticPathMatchData, @@ -183,7 +183,7 @@ actor PathMatchWorker { ) } - func locateMany( + package func locateMany( userPaths: [String], profile: PathLocateProfile, staticData: StaticPathMatchData, @@ -207,7 +207,7 @@ actor PathMatchWorker { /// Finds the best root folder for creating a new file. /// - Parameters: /// - selectionSig: Precomputed on MainActor via `selectionSignature(for:)` - func findCreationPath( + package func findCreationPath( userPath: String, staticData: StaticPathMatchData, selectedFileFullPaths: Set, @@ -224,7 +224,7 @@ actor PathMatchWorker { /// - Parameters: /// - mode: Resolution mode controlling tie-breaking behavior /// - selectionSig: Precomputed on MainActor via `selectionSignature(for:)` - func resolveCreationPath( + package func resolveCreationPath( userPath: String, staticData: StaticPathMatchData, selectedFileFullPaths: Set, @@ -245,7 +245,7 @@ actor PathMatchWorker { } /// Clears all cached data. - func invalidateCache() { + package func invalidateCache() { lastIndexID = nil lastIndexes = nil snapshotCache.removeAll(keepingCapacity: true) diff --git a/Sources/RepoPrompt/Infrastructure/WorkspaceContext/PathLookup/PathMatcher.swift b/Sources/RepoPromptCore/WorkspaceContext/PathLookup/PathMatcher.swift similarity index 99% rename from Sources/RepoPrompt/Infrastructure/WorkspaceContext/PathLookup/PathMatcher.swift rename to Sources/RepoPromptCore/WorkspaceContext/PathLookup/PathMatcher.swift index 8d9acde1e..0bf4954f4 100644 --- a/Sources/RepoPrompt/Infrastructure/WorkspaceContext/PathLookup/PathMatcher.swift +++ b/Sources/RepoPromptCore/WorkspaceContext/PathLookup/PathMatcher.swift @@ -1,9 +1,9 @@ import Foundation /// Pure static helper for path matching logic without UI dependencies -enum PathMatcher { +package enum PathMatcher { /// Controls whether debug logging is enabled for path matching operations - static let isLoggingEnabled = false + package static let isLoggingEnabled = false /// Fast ASCII-first probe: returns the lowercased ASCII byte of the first alphanumeric char, if any. /// Avoids per-scalar `String(...)`/`lowercased()` allocations in hot loops. @@ -294,11 +294,11 @@ enum PathMatcher { } // Absolute-path fallback tuning - static let absoluteSuffixFallbackEnabled = true - static let absSuffixMinComponents = 2 - static let absSuffixMaxComponents = 20 + package static let absoluteSuffixFallbackEnabled = true + package static let absSuffixMinComponents = 2 + package static let absSuffixMaxComponents = 20 - static func locate( + package static func locate( userPath: String, exactMatchOnly: Bool = false, snapshot: PathMatchSnapshot @@ -317,7 +317,7 @@ enum PathMatcher { } /// Main entry point - equivalent to pathLocation - static func locate( + package static func locate( userPath: String, options: PathLocateOptions, snapshot: PathMatchSnapshot @@ -656,7 +656,7 @@ enum PathMatcher { } /// Find the best root folder for creating a new file - static func findCreationPath( + package static func findCreationPath( userPath: String, snapshot: PathMatchSnapshot ) -> FileCreationResult? { @@ -1260,7 +1260,7 @@ enum PathMatcher { /// - snapshot: Current workspace state snapshot /// - mode: Resolution mode controlling tie-breaking behavior /// - Returns: Resolution result, or `nil` if path cannot be resolved within the workspace - static func resolveCreationPath( + package static func resolveCreationPath( userPath: String, snapshot: PathMatchSnapshot, mode: CreationResolutionMode @@ -1663,7 +1663,7 @@ enum PathMatcher { } } - static func absolutePathCandidates(forRelativePath relPath: String, snapshot: PathMatchSnapshot) -> [String] { + package static func absolutePathCandidates(forRelativePath relPath: String, snapshot: PathMatchSnapshot) -> [String] { let standardizedRelativePath = StandardizedPath.relative(relPath) return snapshot.rootFolders.map { root in standardizedLookupPath(rootPath: root.fullPath, relativePath: standardizedRelativePath) diff --git a/Sources/RepoPrompt/Infrastructure/WorkspaceContext/PathLookup/PathMatchingInterfaces.swift b/Sources/RepoPromptCore/WorkspaceContext/PathLookup/PathMatchingInterfaces.swift similarity index 75% rename from Sources/RepoPrompt/Infrastructure/WorkspaceContext/PathLookup/PathMatchingInterfaces.swift rename to Sources/RepoPromptCore/WorkspaceContext/PathLookup/PathMatchingInterfaces.swift index f6abfa476..42b66cda0 100644 --- a/Sources/RepoPrompt/Infrastructure/WorkspaceContext/PathLookup/PathMatchingInterfaces.swift +++ b/Sources/RepoPromptCore/WorkspaceContext/PathLookup/PathMatchingInterfaces.swift @@ -3,7 +3,7 @@ import Foundation // MARK: - Minimal Read-Only Protocols /// Represents a file with essential properties for path matching -protocol FileRecord: Sendable { +package protocol FileRecord: Sendable { var name: String { get } var relativePath: String { get } var fullPath: String { get } @@ -13,7 +13,7 @@ protocol FileRecord: Sendable { /// Represents a folder with essential properties for path matching. /// Note: This protocol intentionally excludes `files` to prevent UI type dependencies /// and ensure frozen records cannot accidentally traverse live view model graphs. -protocol FolderRecord: Sendable { +package protocol FolderRecord: Sendable { var name: String { get } var displayName: String { get } var relativePath: String { get } @@ -22,7 +22,7 @@ protocol FolderRecord: Sendable { } /// Read-only view of the file hierarchy index -protocol FileHierarchyReadable { +package protocol FileHierarchyReadable { var filesByFullPath: [String: FileRecord] { get } var foldersByFullPath: [String: FolderRecord] { get } var rootFolders: [FolderRecord] { get } @@ -45,14 +45,6 @@ public struct FrozenFileRecord: FileRecord { self.fullPath = fullPath self.rootFolderPath = rootFolderPath } - - /// Internal convenience initializer from a FileViewModel - init(from vm: FileViewModel) { - name = vm.name - relativePath = vm.relativePath - fullPath = vm.standardizedFullPath - rootFolderPath = vm.standardizedRootFolderPath - } } public struct FrozenFolderRecord: FolderRecord { @@ -69,13 +61,4 @@ public struct FrozenFolderRecord: FolderRecord { self.fullPath = (fullPath as NSString).standardizingPath self.rootPath = (rootPath as NSString).standardizingPath } - - /// Internal convenience initializer from a FolderViewModel - init(from vm: FolderViewModel) { - name = vm.name - displayName = vm.name - relativePath = vm.relativePath - fullPath = vm.standardizedFullPath - rootPath = (vm.rootPath as NSString).standardizingPath - } } diff --git a/Sources/RepoPrompt/Infrastructure/WorkspaceContext/PathResolution/AgentSupportDirectoryCatalog.swift b/Sources/RepoPromptCore/WorkspaceContext/PathResolution/AgentSupportDirectoryCatalog.swift similarity index 65% rename from Sources/RepoPrompt/Infrastructure/WorkspaceContext/PathResolution/AgentSupportDirectoryCatalog.swift rename to Sources/RepoPromptCore/WorkspaceContext/PathResolution/AgentSupportDirectoryCatalog.swift index 8f60141f8..abc40a4b1 100644 --- a/Sources/RepoPrompt/Infrastructure/WorkspaceContext/PathResolution/AgentSupportDirectoryCatalog.swift +++ b/Sources/RepoPromptCore/WorkspaceContext/PathResolution/AgentSupportDirectoryCatalog.swift @@ -1,7 +1,7 @@ import Foundation -struct AlwaysReadableDirectory: Hashable { - enum Source: Hashable { +package struct AlwaysReadableDirectory: Hashable { + package enum Source: Hashable { case globalAgentsSkills case globalAgentsSlash case globalClaudeSkills @@ -9,24 +9,24 @@ struct AlwaysReadableDirectory: Hashable { case userConfigured } - let url: URL - let source: Source + package let url: URL + package let source: Source - var standardizedPath: String { + package var standardizedPath: String { AgentSupportDirectoryCatalog.normalizedPath(for: url.path) } } -struct AgentSupportGlobalRootURLs { - let agentsSkills: URL - let agentsSlash: URL - let claudeSkills: URL - let claudeCommands: URL - let codexPrompts: URL +package struct AgentSupportGlobalRootURLs { + package let agentsSkills: URL + package let agentsSlash: URL + package let claudeSkills: URL + package let claudeCommands: URL + package let codexPrompts: URL } -enum AgentSupportDirectoryCatalog { - static func globalRootURLs(homeDirectoryURL: URL = FileManager.default.homeDirectoryForCurrentUser) -> AgentSupportGlobalRootURLs { +package enum AgentSupportDirectoryCatalog { + package static func globalRootURLs(homeDirectoryURL: URL = FileManager.default.homeDirectoryForCurrentUser) -> AgentSupportGlobalRootURLs { let home = homeDirectoryURL.standardizedFileURL let agentsDir = home.appendingPathComponent(".agents", isDirectory: true) let claudeDir = home.appendingPathComponent(".claude", isDirectory: true) @@ -40,7 +40,7 @@ enum AgentSupportDirectoryCatalog { ) } - static func builtInAlwaysReadableDirectories( + package static func builtInAlwaysReadableDirectories( homeDirectoryURL: URL = FileManager.default.homeDirectoryForCurrentUser ) -> [AlwaysReadableDirectory] { let roots = globalRootURLs(homeDirectoryURL: homeDirectoryURL) @@ -52,7 +52,7 @@ enum AgentSupportDirectoryCatalog { ]) } - static func effectiveAlwaysReadableDirectories( + package static func effectiveAlwaysReadableDirectories( homeDirectoryURL: URL = FileManager.default.homeDirectoryForCurrentUser, additionalAbsolutePaths: [String] = [] ) -> [AlwaysReadableDirectory] { @@ -63,7 +63,7 @@ enum AgentSupportDirectoryCatalog { return dedupe(builtInAlwaysReadableDirectories(homeDirectoryURL: homeDirectoryURL) + configured) } - static func displayPath(for absolutePath: String, homeDirectoryURL: URL = FileManager.default.homeDirectoryForCurrentUser) -> String { + package static func displayPath(for absolutePath: String, homeDirectoryURL: URL = FileManager.default.homeDirectoryForCurrentUser) -> String { let normalizedAbsolute = normalizedPath(for: absolutePath) let homePath = normalizedPath(for: homeDirectoryURL.path) guard normalizedAbsolute == homePath || normalizedAbsolute.hasPrefix(homePath + "/") else { @@ -73,7 +73,7 @@ enum AgentSupportDirectoryCatalog { return suffix.isEmpty ? "~" : "~" + suffix } - static func contains( + package static func contains( absolutePath: String, in directory: AlwaysReadableDirectory, fileManager: FileManager = .default @@ -81,22 +81,30 @@ enum AgentSupportDirectoryCatalog { contains(absolutePath: absolutePath, inDirectoryPath: directory.standardizedPath, fileManager: fileManager) } - static func contains( + package static func contains( absolutePath: String, inDirectoryPath directoryPath: String, fileManager: FileManager = .default ) -> Bool { let normalizedDirectory = normalizedPath(for: directoryPath) - guard normalizedDirectory.hasPrefix("/") else { return false } - for candidate in containmentCandidates(for: absolutePath, fileManager: fileManager) { - if candidate == normalizedDirectory || candidate.hasPrefix(normalizedDirectory + "/") { - return true - } + let normalizedCandidate = normalizedPath(for: absolutePath) + guard normalizedDirectory.hasPrefix("/"), normalizedCandidate.hasPrefix("/") else { return false } + + if fileManager.fileExists(atPath: normalizedCandidate) { + guard fileManager.fileExists(atPath: normalizedDirectory) else { return false } + let resolvedDirectory = normalizedPath( + for: URL(fileURLWithPath: normalizedDirectory).resolvingSymlinksInPath().standardizedFileURL.path + ) + let resolvedCandidate = normalizedPath( + for: URL(fileURLWithPath: normalizedCandidate).resolvingSymlinksInPath().standardizedFileURL.path + ) + return resolvedCandidate == resolvedDirectory || resolvedCandidate.hasPrefix(resolvedDirectory + "/") } - return false + + return normalizedCandidate == normalizedDirectory || normalizedCandidate.hasPrefix(normalizedDirectory + "/") } - static func normalizedPath(for path: String) -> String { + package static func normalizedPath(for path: String) -> String { var normalized = ((path as NSString).expandingTildeInPath as NSString).standardizingPath while normalized.count > 1, normalized.hasSuffix("/") { normalized.removeLast() @@ -110,19 +118,4 @@ enum AgentSupportDirectoryCatalog { seen.insert(directory.standardizedPath).inserted } } - - private static func containmentCandidates(for absolutePath: String, fileManager: FileManager) -> [String] { - let normalized = normalizedPath(for: absolutePath) - guard normalized.hasPrefix("/") else { return [] } - - var candidates: [String] = [normalized] - if fileManager.fileExists(atPath: normalized) { - let resolved = URL(fileURLWithPath: normalized).resolvingSymlinksInPath().standardizedFileURL.path - let normalizedResolved = normalizedPath(for: resolved) - if normalizedResolved != normalized { - candidates.insert(normalizedResolved, at: 0) - } - } - return candidates - } } diff --git a/Sources/RepoPrompt/Infrastructure/WorkspaceContext/PathResolution/CreatePathPreflight.swift b/Sources/RepoPromptCore/WorkspaceContext/PathResolution/CreatePathPreflight.swift similarity index 82% rename from Sources/RepoPrompt/Infrastructure/WorkspaceContext/PathResolution/CreatePathPreflight.swift rename to Sources/RepoPromptCore/WorkspaceContext/PathResolution/CreatePathPreflight.swift index 4a15af957..0652915a0 100644 --- a/Sources/RepoPrompt/Infrastructure/WorkspaceContext/PathResolution/CreatePathPreflight.swift +++ b/Sources/RepoPromptCore/WorkspaceContext/PathResolution/CreatePathPreflight.swift @@ -1,9 +1,9 @@ import Foundation /// Pure helper for create preflight validation and root-alias checks. -enum CreatePathPreflight { +package enum CreatePathPreflight { /// Controls how strict the preflight validation is for multi-root workspaces. - enum Mode { + package enum Mode { /// Current behavior: always require alias prefix or absolute path when multiple roots are loaded. case strictRequireAliasInMultiRoot /// Relaxed mode for tool flows: allow relative paths without alias if they can be resolved @@ -11,27 +11,33 @@ enum CreatePathPreflight { case allowImplicitRootIfUnambiguous } - typealias Root = WorkspaceRootRef + package typealias Root = WorkspaceRootRef - enum AliasPrefixCheck: Equatable { + package enum AliasPrefixCheck: Equatable { case notPrefixed case uniqueRoot(root: Root, alias: String) case ambiguous(alias: String, matchingRoots: [Root]) } - enum Error: Swift.Error, Equatable { + package enum Error: Swift.Error, Equatable { case emptyPath case ambiguousAlias(alias: String, matchingRoots: [Root]) case missingAliasWithMultipleRoots(loadedRoots: [Root]) } - struct Result: Equatable { - let normalizedPath: String - let aliasCheck: AliasPrefixCheck - let isAbsolute: Bool + package struct Result: Equatable { + package let normalizedPath: String + package let aliasCheck: AliasPrefixCheck + package let isAbsolute: Bool + + package init(normalizedPath: String, aliasCheck: AliasPrefixCheck, isAbsolute: Bool) { + self.normalizedPath = normalizedPath + self.aliasCheck = aliasCheck + self.isAbsolute = isAbsolute + } } - static func validate( + package static func validate( userPath: String, visibleRoots: [Root], mode: Mode = .strictRequireAliasInMultiRoot @@ -68,7 +74,7 @@ enum CreatePathPreflight { return Result(normalizedPath: standardized, aliasCheck: aliasCheck, isAbsolute: isAbsolute) } - static func checkAliasPrefix( + package static func checkAliasPrefix( _ userPath: String, visibleRoots: [Root], requireRemainder: Bool diff --git a/Sources/RepoPrompt/Infrastructure/WorkspaceContext/PathResolution/MovePathResolver.swift b/Sources/RepoPromptCore/WorkspaceContext/PathResolution/MovePathResolver.swift similarity index 94% rename from Sources/RepoPrompt/Infrastructure/WorkspaceContext/PathResolution/MovePathResolver.swift rename to Sources/RepoPromptCore/WorkspaceContext/PathResolution/MovePathResolver.swift index ebb43905b..3be58c106 100644 --- a/Sources/RepoPrompt/Infrastructure/WorkspaceContext/PathResolution/MovePathResolver.swift +++ b/Sources/RepoPromptCore/WorkspaceContext/PathResolution/MovePathResolver.swift @@ -1,23 +1,23 @@ import Foundation /// Root-scoped move/rename destination resolution with alias disambiguation. -enum MovePathResolver { - typealias Root = WorkspaceRootRef +package enum MovePathResolver { + package typealias Root = WorkspaceRootRef - enum AliasPrefixCheck { + package enum AliasPrefixCheck { case notPrefixed case uniqueRoot(root: Root, alias: String) case ambiguous(alias: String, matchingRoots: [Root]) } - enum Error: Swift.Error, Equatable { + package enum Error: Swift.Error, Equatable { case emptyDestination case destinationOutsideRoot(root: Root) case ambiguousAlias(alias: String, matchingRoots: [Root]) case crossRootAlias(alias: String, resolvedRoot: Root) } - static func resolveRelativePathInRoot( + package static func resolveRelativePathInRoot( userPath: String, sourceRoot: Root, visibleRoots: [Root] @@ -67,7 +67,7 @@ enum MovePathResolver { return try validatedRelativeDestination(standardized, within: sourceRoot) } - static func checkAliasPrefix( + package static func checkAliasPrefix( _ userPath: String, visibleRoots: [Root], requireRemainder: Bool diff --git a/Sources/RepoPromptCore/WorkspaceContext/PathResolution/WorkspaceExternalFileReading.swift b/Sources/RepoPromptCore/WorkspaceContext/PathResolution/WorkspaceExternalFileReading.swift new file mode 100644 index 000000000..e30e88fbd --- /dev/null +++ b/Sources/RepoPromptCore/WorkspaceContext/PathResolution/WorkspaceExternalFileReading.swift @@ -0,0 +1,67 @@ +import Foundation + +package protocol WorkspaceExternalFileReading: Sendable { + func resolveRegularFile( + atAbsolutePath path: String, + allowedDirectories: [AlwaysReadableDirectory] + ) throws -> String? + + func resolveDirectory( + atAbsolutePath path: String, + allowedDirectories: [AlwaysReadableDirectory] + ) throws -> String? + + func readRegularFile( + atAbsolutePath path: String, + allowedDirectories: [AlwaysReadableDirectory] + ) throws -> Data +} + +package enum WorkspaceExternalFileReaderProvider { + package typealias Factory = @Sendable () -> any WorkspaceExternalFileReading + + private final class State: @unchecked Sendable { + let lock = NSLock() + var factory: Factory = { UnavailableWorkspaceExternalFileReader() } + } + + private static let state = State() + + package static func install(_ factory: @escaping Factory) { + state.lock.lock() + state.factory = factory + state.lock.unlock() + } + + package static func makeReader() -> any WorkspaceExternalFileReading { + state.lock.lock() + let factory = state.factory + state.lock.unlock() + return factory() + } +} + +package struct UnavailableWorkspaceExternalFileReader: WorkspaceExternalFileReading { + package init() {} + + package func resolveRegularFile( + atAbsolutePath _: String, + allowedDirectories _: [AlwaysReadableDirectory] + ) throws -> String? { + nil + } + + package func resolveDirectory( + atAbsolutePath _: String, + allowedDirectories _: [AlwaysReadableDirectory] + ) throws -> String? { + nil + } + + package func readRegularFile( + atAbsolutePath path: String, + allowedDirectories _: [AlwaysReadableDirectory] + ) throws -> Data { + throw CocoaError(.fileReadNoPermission, userInfo: [NSFilePathErrorKey: path]) + } +} diff --git a/Sources/RepoPrompt/Infrastructure/WorkspaceContext/PathResolution/WorkspacePathPolicy.swift b/Sources/RepoPromptCore/WorkspaceContext/PathResolution/WorkspacePathPolicy.swift similarity index 88% rename from Sources/RepoPrompt/Infrastructure/WorkspaceContext/PathResolution/WorkspacePathPolicy.swift rename to Sources/RepoPromptCore/WorkspaceContext/PathResolution/WorkspacePathPolicy.swift index 66f0e9286..1e8471bfa 100644 --- a/Sources/RepoPrompt/Infrastructure/WorkspaceContext/PathResolution/WorkspacePathPolicy.swift +++ b/Sources/RepoPromptCore/WorkspaceContext/PathResolution/WorkspacePathPolicy.swift @@ -1,45 +1,45 @@ import Foundation -struct WorkspaceRootRef: Hashable { - let id: UUID - let name: String - let fullPath: String - let standardizedFullPath: String +package struct WorkspaceRootRef: Hashable { + package let id: UUID + package let name: String + package let fullPath: String + package let standardizedFullPath: String - init(id: UUID, name: String, fullPath: String) { + package init(id: UUID, name: String, fullPath: String) { self.id = id self.name = name self.fullPath = fullPath standardizedFullPath = StandardizedPath.absolute(fullPath) } - var compatibilityAlias: String { + package var compatibilityAlias: String { (standardizedFullPath as NSString).lastPathComponent } - var renderedLabel: String { + package var renderedLabel: String { "\(name) → \(fullPath)" } } -enum RootAliasResolution: Equatable { +package enum RootAliasResolution: Equatable { case notAliasPrefixed case bareRoot(root: WorkspaceRootRef, alias: String) case prefixed(root: WorkspaceRootRef, alias: String, remainder: String) case ambiguous(alias: String, matchingRoots: [WorkspaceRootRef]) } -struct RootAliasOptions { - let requireRemainder: Bool - let allowCompatibilityAlias: Bool +package struct RootAliasOptions { + package let requireRemainder: Bool + package let allowCompatibilityAlias: Bool /// When true, suppresses alias interpretation only if a same-name top-level subpath /// exists under the matched root. This is a shallow top-level check only; it does not /// compare the full remainder chain or score deeper structure. /// Tool-create flows use richer literal-vs-alias depth scoring in /// `WorkspaceFilesViewModel.resolvedLiteralCreateResult(...)`. - let disambiguateRealSubpath: Bool + package let disambiguateRealSubpath: Bool - init( + package init( requireRemainder: Bool, allowCompatibilityAlias: Bool = true, disambiguateRealSubpath: Bool = false @@ -50,8 +50,8 @@ struct RootAliasOptions { } } -enum WorkspaceAliasResolver { - static func resolve( +package enum WorkspaceAliasResolver { + package static func resolve( userPath: String, roots: [WorkspaceRootRef], options: RootAliasOptions, @@ -106,7 +106,7 @@ enum WorkspaceAliasResolver { } } -enum PathResolutionIssue: Equatable { +package enum PathResolutionIssue: Equatable { case emptyInput case invalidPathCharacters(input: String, reason: String) case ambiguousAlias(alias: String, matchingRoots: [WorkspaceRootRef]) @@ -117,8 +117,8 @@ enum PathResolutionIssue: Equatable { case unresolved(input: String) } -enum PathResolutionIssueRenderer { - static func message(for issue: PathResolutionIssue) -> String { +package enum PathResolutionIssueRenderer { + package static func message(for issue: PathResolutionIssue) -> String { switch issue { case .emptyInput: return "Path is required." @@ -143,8 +143,8 @@ enum PathResolutionIssueRenderer { } } -enum ClientPathFormatter { - static func displayPath( +package enum ClientPathFormatter { + package static func displayPath( root: WorkspaceRootRef, relativePath: String, visibleRoots: [WorkspaceRootRef] @@ -168,7 +168,7 @@ enum ClientPathFormatter { ) } - static func displayAbsolutePath( + package static func displayAbsolutePath( fullPath: String, visibleRoots: [WorkspaceRootRef] ) -> String { diff --git a/Sources/RepoPromptCore/WorkspaceContext/Projection/CodeStructureProjection.swift b/Sources/RepoPromptCore/WorkspaceContext/Projection/CodeStructureProjection.swift new file mode 100644 index 000000000..2c431fb1a --- /dev/null +++ b/Sources/RepoPromptCore/WorkspaceContext/Projection/CodeStructureProjection.swift @@ -0,0 +1,155 @@ +import Foundation + +package struct CodeStructureProjection: Equatable { + package struct Omissions: Equatable { + package let resultLimit: Int + package let tokenBudget: Int + + package var total: Int { + resultLimit + tokenBudget + } + + package init(resultLimit: Int, tokenBudget: Int) { + self.resultLimit = resultLimit + self.tokenBudget = tokenBudget + } + } + + package struct BudgetCandidate: Equatable { + package let key: String + package let estimatedTokens: Int + + package init(key: String, estimatedTokens: Int) { + self.key = key + self.estimatedTokens = estimatedTokens + } + } + + package struct BudgetSelection: Equatable { + package let includedKeys: [String] + package let omissions: Omissions + + package init(includedKeys: [String], omissions: Omissions) { + self.includedKeys = includedKeys + self.omissions = omissions + } + } + + package let content: String + package let renderedPaths: [String] + package let unmappedPaths: [String] + package let omissions: Omissions + + package var fileCount: Int { + renderedPaths.count + } + + package var tokenBudgetHit: Bool { + omissions.tokenBudget > 0 + } + + package init( + content: String, + renderedPaths: [String], + unmappedPaths: [String], + omissions: Omissions + ) { + self.content = content + self.renderedPaths = renderedPaths + self.unmappedPaths = unmappedPaths + self.omissions = omissions + } +} + +package struct CodeStructureProjectionRequest { + package struct Entry { + package let physicalPath: String + package let displayPath: String + package let fileAPI: FileAPI? + + package init(physicalPath: String, displayPath: String, fileAPI: FileAPI?) { + self.physicalPath = physicalPath + self.displayPath = displayPath + self.fileAPI = fileAPI + } + } + + package struct Budget: Equatable { + package let resultLimit: Int + package let tokenBudget: Int + package let separatorTokenCost: Int + + package init( + resultLimit: Int, + tokenBudget: Int = CodeStructureProjectionService.defaultTokenBudget, + separatorTokenCost: Int = CodeStructureProjectionService.defaultSeparatorTokenCost + ) { + self.resultLimit = resultLimit + self.tokenBudget = tokenBudget + self.separatorTokenCost = separatorTokenCost + } + } + + package let entries: [Entry] + package let budget: Budget + package let includeUnmappedPaths: Bool + + package init( + entries: [Entry], + budget: Budget, + includeUnmappedPaths: Bool + ) { + self.entries = entries + self.budget = budget + self.includeUnmappedPaths = includeUnmappedPaths + } +} + +package struct LocalDefinitionProjection: Equatable { + package let text: String + package let fileCount: Int + + package init(text: String, fileCount: Int) { + self.text = text + self.fileCount = fileCount + } + + package static let empty = LocalDefinitionProjection(text: "", fileCount: 0) +} + +package struct LocalDefinitionProjectionRequest { + package enum PathDisplay: Equatable { + case full + case relative + } + + package struct Root: Equatable { + package let standardizedPath: String + package let displayName: String + + package init(standardizedPath: String, displayName: String) { + self.standardizedPath = standardizedPath + self.displayName = displayName + } + } + + package let codeMapUsage: CodeMapUsage + package let selectedFiles: [WorkspaceFileRecord] + package let availableFileAPIs: [FileAPI] + package let pathDisplay: PathDisplay + package let roots: [Root] + + package init( + codeMapUsage: CodeMapUsage, + selectedFiles: [WorkspaceFileRecord], + availableFileAPIs: [FileAPI], + pathDisplay: PathDisplay, + roots: [Root] + ) { + self.codeMapUsage = codeMapUsage + self.selectedFiles = selectedFiles + self.availableFileAPIs = availableFileAPIs + self.pathDisplay = pathDisplay + self.roots = roots + } +} diff --git a/Sources/RepoPromptCore/WorkspaceContext/Projection/CodeStructureProjectionService.swift b/Sources/RepoPromptCore/WorkspaceContext/Projection/CodeStructureProjectionService.swift new file mode 100644 index 000000000..14c1a19d9 --- /dev/null +++ b/Sources/RepoPromptCore/WorkspaceContext/Projection/CodeStructureProjectionService.swift @@ -0,0 +1,222 @@ +import Foundation + +package enum CodeStructureProjectionService { + package static let defaultTokenBudget = 6000 + package static let outputSeparator = "\n\n" + package static let defaultSeparatorTokenCost = TokenCalculationService.estimateTokens(for: outputSeparator) + + private struct RenderableEntry { + let key: String + let displayPath: String + let fileAPI: FileAPI + let estimatedTokens: Int + } + + package static func project( + _ request: CodeStructureProjectionRequest + ) -> CodeStructureProjection { + var renderable: [RenderableEntry] = [] + var unmappedPaths: [String] = [] + var seenPaths = Set() + renderable.reserveCapacity(request.entries.count) + unmappedPaths.reserveCapacity(request.entries.count) + + for entry in request.entries { + let key = StandardizedPath.absolute(entry.physicalPath) + guard seenPaths.insert(key).inserted else { continue } + if let fileAPI = entry.fileAPI { + renderable.append(RenderableEntry( + key: key, + displayPath: entry.displayPath, + fileAPI: fileAPI, + estimatedTokens: fileAPI.estimatedFullAPIDescriptionTokens(displayPath: entry.displayPath) + )) + } else if request.includeUnmappedPaths { + unmappedPaths.append(entry.displayPath) + } + } + + renderable.sort { lhs, rhs in + if lhs.displayPath == rhs.displayPath { return lhs.key < rhs.key } + return lhs.displayPath < rhs.displayPath + } + + let budgetSelection = selectBudgetedCandidates( + renderable.map { .init(key: $0.key, estimatedTokens: $0.estimatedTokens) }, + resultLimit: request.budget.resultLimit, + tokenBudget: request.budget.tokenBudget, + separatorTokenCost: request.budget.separatorTokenCost + ) + let renderableByKey = Dictionary(uniqueKeysWithValues: renderable.map { ($0.key, $0) }) + let included = budgetSelection.includedKeys.compactMap { renderableByKey[$0] } + + return CodeStructureProjection( + content: included + .map { $0.fileAPI.getFullAPIDescription(displayPath: $0.displayPath) } + .joined(separator: outputSeparator), + renderedPaths: included.map(\.displayPath), + unmappedPaths: request.includeUnmappedPaths ? unmappedPaths.sorted() : [], + omissions: budgetSelection.omissions + ) + } + + package static func selectBudgetedCandidates( + _ candidates: [CodeStructureProjection.BudgetCandidate], + resultLimit: Int, + tokenBudget: Int = defaultTokenBudget, + separatorTokenCost: Int = defaultSeparatorTokenCost + ) -> CodeStructureProjection.BudgetSelection { + let effectiveResultLimit = max(0, resultLimit) + let effectiveTokenBudget = max(0, tokenBudget) + let countCapped = Array(candidates.prefix(effectiveResultLimit)) + let omittedByResultLimit = max(0, candidates.count - countCapped.count) + + var includedKeys: [String] = [] + var usedTokens = 0 + + for candidate in countCapped { + let isFirstEntry = includedKeys.isEmpty + let entryCost = isFirstEntry + ? candidate.estimatedTokens + : candidate.estimatedTokens + max(0, separatorTokenCost) + if !isFirstEntry, usedTokens + entryCost > effectiveTokenBudget { + break + } + includedKeys.append(candidate.key) + usedTokens += entryCost + } + + return CodeStructureProjection.BudgetSelection( + includedKeys: includedKeys, + omissions: .init( + resultLimit: omittedByResultLimit, + tokenBudget: max(0, countCapped.count - includedKeys.count) + ) + ) + } + + package static func projectLocalDefinitions( + _ request: LocalDefinitionProjectionRequest + ) -> LocalDefinitionProjection { + guard request.codeMapUsage != .none else { return .empty } + + let selectedAPIs = acceptedFileAPIs( + from: request.selectedFiles, + availableFileAPIs: request.availableFileAPIs + ) + let selectedPaths = Set(request.selectedFiles.map(\.standardizedFullPath)) + let rootFilteredAPIs = filterAPIsToCurrentRoots( + request.availableFileAPIs, + roots: request.roots + ) + let unselectedAPIs = rootFilteredAPIs.filter { + !selectedPaths.contains(standardizedAPIFilePath($0)) + } + + switch request.codeMapUsage { + case .none, .selected: + return .empty + case .auto: + let included = CodeMapExtractor.getAutoReferencedAPIs( + selectedAPIs: selectedAPIs, + unselectedAPIs: unselectedAPIs + ) + guard !included.isEmpty else { return .empty } + let ordered = included.sorted { + standardizedAPIFilePath($0) < standardizedAPIFilePath($1) + } + var output = "\n" + for fileAPI in ordered { + output += "\n" + output += fileAPI.getFullAPIDescription(displayPath: displayPath( + for: fileAPI.filePath, + pathDisplay: request.pathDisplay, + roots: request.roots + )) + output += "\n" + } + output += "" + return LocalDefinitionProjection(text: output, fileCount: included.count) + case .complete: + guard !unselectedAPIs.isEmpty else { return .empty } + var output = "\n" + for fileAPI in unselectedAPIs { + output += "\n" + output += fileAPI.getFullAPIDescription(displayPath: displayPath( + for: fileAPI.filePath, + pathDisplay: request.pathDisplay, + roots: request.roots + )) + output += "\n" + } + output += "" + return LocalDefinitionProjection(text: output, fileCount: unselectedAPIs.count) + } + } + + private static func acceptedFileAPIs( + from files: [WorkspaceFileRecord], + availableFileAPIs: [FileAPI] + ) -> [FileAPI] { + guard !files.isEmpty, !availableFileAPIs.isEmpty else { return [] } + let fileAPIsByPath = Dictionary(grouping: availableFileAPIs, by: standardizedAPIFilePath) + return files.compactMap { fileAPIsByPath[$0.standardizedFullPath]?.first } + } + + private static func filterAPIsToCurrentRoots( + _ fileAPIs: [FileAPI], + roots: [LocalDefinitionProjectionRequest.Root] + ) -> [FileAPI] { + guard !fileAPIs.isEmpty, !roots.isEmpty else { return [] } + + var seen = Set() + var filtered: [FileAPI] = [] + filtered.reserveCapacity(fileAPIs.count) + for fileAPI in fileAPIs { + let standardizedPath = standardizedAPIFilePath(fileAPI) + guard roots.contains(where: { + StandardizedPath.isDescendant(standardizedPath, of: $0.standardizedPath) + }), seen.insert(standardizedPath).inserted + else { continue } + filtered.append(fileAPI) + } + return filtered + } + + private static func displayPath( + for absolutePath: String, + pathDisplay: LocalDefinitionProjectionRequest.PathDisplay, + roots: [LocalDefinitionProjectionRequest.Root] + ) -> String { + guard pathDisplay == .relative else { return absolutePath } + let standardizedAbsolutePath = StandardizedPath.absolute(absolutePath) + let matchingRoots = roots + .filter { + standardizedAbsolutePath == $0.standardizedPath + || standardizedAbsolutePath.hasPrefix($0.standardizedPath + "/") + } + .sorted { $0.standardizedPath.count > $1.standardizedPath.count } + + guard let root = matchingRoots.first else { + return (standardizedAbsolutePath as NSString).lastPathComponent + } + let relativePath: String + if standardizedAbsolutePath == root.standardizedPath { + relativePath = "" + } else if standardizedAbsolutePath.hasPrefix(root.standardizedPath + "/") { + let start = standardizedAbsolutePath.index(root.standardizedPath.endIndex, offsetBy: 1) + relativePath = String(standardizedAbsolutePath[start...]) + } else { + relativePath = standardizedAbsolutePath + } + + guard roots.count > 1 else { return relativePath } + guard !root.displayName.isEmpty else { return relativePath } + return relativePath.isEmpty ? root.displayName : "\(root.displayName)/\(relativePath)" + } + + @inline(__always) + private static func standardizedAPIFilePath(_ fileAPI: FileAPI) -> String { + StandardizedPath.absolute(fileAPI.filePath) + } +} diff --git a/Sources/RepoPromptCore/WorkspaceContext/Projection/TokenProjection.swift b/Sources/RepoPromptCore/WorkspaceContext/Projection/TokenProjection.swift new file mode 100644 index 000000000..7cde8e607 --- /dev/null +++ b/Sources/RepoPromptCore/WorkspaceContext/Projection/TokenProjection.swift @@ -0,0 +1,81 @@ +package struct TokenProjection: Equatable { + package enum View: Equatable { + case normalized + case userConfigured + } + + package enum Scope: Equatable { + case selection + case workspace + case export + } + + package enum Source: Equatable { + case activeLive + case virtualRecomputed + case immutableSnapshot + } + + package enum Basis: Equatable { + case componentEstimate + case renderedPayloadEstimate + case exactRenderedPayload + } + + package struct Provenance: Equatable { + package let view: View + package let scope: Scope + package let source: Source + package let basis: Basis + + package init(view: View, scope: Scope, source: Source, basis: Basis) { + self.view = view + self.scope = scope + self.source = source + self.basis = basis + } + } + + /// Optional presence is semantic: `nil` is unavailable/omitted and zero is a known value. + /// `filesContent` and `codemaps` subdivide `files` and are not added to `total`. + package struct Components: Equatable { + package let files: Int? + package let prompt: Int? + package let fileTree: Int? + package let meta: Int? + package let git: Int? + package let other: Int? + package let filesContent: Int? + package let codemaps: Int? + + package init( + files: Int? = nil, + prompt: Int? = nil, + fileTree: Int? = nil, + meta: Int? = nil, + git: Int? = nil, + other: Int? = nil, + filesContent: Int? = nil, + codemaps: Int? = nil + ) { + self.files = files + self.prompt = prompt + self.fileTree = fileTree + self.meta = meta + self.git = git + self.other = other + self.filesContent = filesContent + self.codemaps = codemaps + } + } + + package let provenance: Provenance + package let components: Components + package let total: Int + + package init(provenance: Provenance, components: Components, total: Int) { + self.provenance = provenance + self.components = components + self.total = total + } +} diff --git a/Sources/RepoPromptCore/WorkspaceContext/Projection/TokenProjectionService.swift b/Sources/RepoPromptCore/WorkspaceContext/Projection/TokenProjectionService.swift new file mode 100644 index 000000000..ffedfe9a1 --- /dev/null +++ b/Sources/RepoPromptCore/WorkspaceContext/Projection/TokenProjectionService.swift @@ -0,0 +1,314 @@ +package enum TokenProjectionService { + package struct WorkspaceNonFileComponents: Equatable { + package let prompt: Int + package let fileTree: Int + package let meta: Int + package let git: Int + package let other: Int + + package init( + prompt: Int, + fileTree: Int, + meta: Int, + git: Int, + other: Int = 0 + ) { + self.prompt = prompt + self.fileTree = fileTree + self.meta = meta + self.git = git + self.other = other + } + + package init(breakdown: TokenComponentBreakdown) { + self.init( + prompt: breakdown.promptDisplay, + fileTree: breakdown.fileTree, + meta: breakdown.instructions, + git: breakdown.gitDiff, + other: breakdown.other + ) + } + } + + package struct ActiveLiveWorkspaceInput: Equatable { + package let reportedTotal: Int + package let prompt: Int + package let fileTree: Int + package let meta: Int + package let git: Int + package let requestedFileTreeEstimate: Int? + + package init( + reportedTotal: Int, + prompt: Int, + fileTree: Int, + meta: Int, + git: Int, + requestedFileTreeEstimate: Int? = nil + ) { + self.reportedTotal = reportedTotal + self.prompt = prompt + self.fileTree = fileTree + self.meta = meta + self.git = git + self.requestedFileTreeEstimate = requestedFileTreeEstimate + } + } + + package struct WorkspaceViews: Equatable { + package let normalized: TokenProjection + package let userConfigured: TokenProjection? + + package init(normalized: TokenProjection, userConfigured: TokenProjection?) { + self.normalized = normalized + self.userConfigured = userConfigured + } + } + + package static func componentEstimate( + view: TokenProjection.View, + scope: TokenProjection.Scope, + source: TokenProjection.Source, + components: TokenProjection.Components + ) -> TokenProjection { + TokenProjection( + provenance: .init( + view: view, + scope: scope, + source: source, + basis: .componentEstimate + ), + components: components, + total: componentTotal(components) + ) + } + + package static func selectionProjection( + from selection: WorkspaceSelectionProjection, + view: TokenProjection.View, + source: TokenProjection.Source + ) -> TokenProjection? { + let components: TokenProjection.Components + switch view { + case .normalized: + components = .init( + files: selection.summary.totalTokens, + filesContent: selection.summary.fullTokens + selection.summary.sliceTokens, + codemaps: selection.summary.codemapTokens + ) + case .userConfigured: + guard let alternate = selection.alternate else { return nil } + components = .init( + files: alternate.includedTotalTokens, + filesContent: alternate.includesFiles ? alternate.contentTokens : 0, + codemaps: alternate.includesFiles ? alternate.codemapTokens : alternate.includedTotalTokens + ) + } + return componentEstimate( + view: view, + scope: .selection, + source: source, + components: components + ) + } + + package static func workspaceComponentEstimates( + from selection: WorkspaceSelectionProjection, + source: TokenProjection.Source, + nonFile: WorkspaceNonFileComponents + ) -> WorkspaceViews { + let normalizedSelection = normalizedSelectionProjection(from: selection, source: source) + let normalized = workspaceComponentEstimate( + selection: normalizedSelection, + view: .normalized, + source: source, + nonFile: nonFile + ) + let userConfigured = selectionProjection( + from: selection, + view: .userConfigured, + source: source + ).map { + workspaceComponentEstimate( + selection: $0, + view: .userConfigured, + source: source, + nonFile: nonFile + ) + } + return WorkspaceViews(normalized: normalized, userConfigured: userConfigured) + } + + package static func activeLiveWorkspaceEstimates( + from selection: WorkspaceSelectionProjection, + input: ActiveLiveWorkspaceInput + ) -> WorkspaceViews { + let source = TokenProjection.Source.activeLive + let normalizedSelection = normalizedSelectionProjection(from: selection, source: source) + let normalizedFiles = normalizedSelection.total + let tree = if input.fileTree == 0, let requested = input.requestedFileTreeEstimate, requested > 0 { + requested + } else { + input.fileTree + } + let normalizedComponentSum = input.prompt + normalizedFiles + tree + input.meta + input.git + let normalizedTotal = max(input.reportedTotal, normalizedComponentSum) + let normalizedOther = max(normalizedTotal - normalizedComponentSum, 0) + let normalized = TokenProjection( + provenance: .init( + view: .normalized, + scope: .workspace, + source: source, + basis: .componentEstimate + ), + components: .init( + files: normalizedFiles, + prompt: input.prompt, + fileTree: tree, + meta: input.meta, + git: input.git, + other: normalizedOther, + filesContent: positiveOptional(normalizedSelection.components.filesContent), + codemaps: positiveOptional(normalizedSelection.components.codemaps) + ), + total: normalizedTotal + ) + + let userConfigured = selectionProjection( + from: selection, + view: .userConfigured, + source: source + ).map { userSelection in + let userFiles = userSelection.total + let userComponentSum = input.prompt + userFiles + tree + input.meta + input.git + let replacementTotal = normalizedTotal - normalizedFiles + userFiles + let userTotal = max(userComponentSum, replacementTotal) + return TokenProjection( + provenance: .init( + view: .userConfigured, + scope: .workspace, + source: source, + basis: .componentEstimate + ), + components: .init( + files: userFiles, + prompt: input.prompt, + fileTree: tree, + meta: input.meta, + git: input.git, + other: max(userTotal - userComponentSum, 0), + filesContent: positiveOptional(userSelection.components.filesContent), + codemaps: positiveOptional(userSelection.components.codemaps) + ), + total: userTotal + ) + } + return WorkspaceViews(normalized: normalized, userConfigured: userConfigured) + } + + /// Estimates a rendered payload whose bytes may still change at a later transport boundary. + package static func renderedPayloadEstimate( + _ renderedText: String, + view: TokenProjection.View, + source: TokenProjection.Source + ) -> TokenProjection { + renderedPayloadProjection( + renderedText, + view: view, + source: source, + basis: .renderedPayloadEstimate + ) + } + + /// Estimates the complete emitted payload; the basis is exact, while tokenization remains heuristic. + package static func exactRenderedPayload( + _ renderedText: String, + view: TokenProjection.View, + source: TokenProjection.Source + ) -> TokenProjection { + renderedPayloadProjection( + renderedText, + view: view, + source: source, + basis: .exactRenderedPayload + ) + } + + private static func renderedPayloadProjection( + _ renderedText: String, + view: TokenProjection.View, + source: TokenProjection.Source, + basis: TokenProjection.Basis + ) -> TokenProjection { + TokenProjection( + provenance: .init( + view: view, + scope: .export, + source: source, + basis: basis + ), + components: .init(), + total: TokenCalculationService.estimateTokens(for: renderedText) + ) + } + + private static func workspaceComponentEstimate( + selection: TokenProjection, + view: TokenProjection.View, + source: TokenProjection.Source, + nonFile: WorkspaceNonFileComponents + ) -> TokenProjection { + let components = TokenProjection.Components( + files: selection.components.files, + prompt: positiveOptional(nonFile.prompt), + fileTree: positiveOptional(nonFile.fileTree), + meta: positiveOptional(nonFile.meta), + git: positiveOptional(nonFile.git), + other: positiveOptional(nonFile.other), + filesContent: positiveOptional(selection.components.filesContent), + codemaps: positiveOptional(selection.components.codemaps) + ) + return componentEstimate( + view: view, + scope: .workspace, + source: source, + components: components + ) + } + + private static func normalizedSelectionProjection( + from selection: WorkspaceSelectionProjection, + source: TokenProjection.Source + ) -> TokenProjection { + componentEstimate( + view: .normalized, + scope: .selection, + source: source, + components: .init( + files: selection.summary.totalTokens, + filesContent: selection.summary.fullTokens + selection.summary.sliceTokens, + codemaps: selection.summary.codemapTokens + ) + ) + } + + private static func componentTotal(_ components: TokenProjection.Components) -> Int { + let fileTotal = components.files ?? 0 + let promptTotal = components.prompt ?? 0 + let treeTotal = components.fileTree ?? 0 + let metaTotal = components.meta ?? 0 + let gitTotal = components.git ?? 0 + let otherTotal = components.other ?? 0 + return fileTotal + promptTotal + treeTotal + metaTotal + gitTotal + otherTotal + } + + private static func positiveOptional(_ value: Int?) -> Int? { + guard let value, value > 0 else { return nil } + return value + } + + private static func positiveOptional(_ value: Int) -> Int? { + value > 0 ? value : nil + } +} diff --git a/Sources/RepoPromptCore/WorkspaceContext/Projection/WorkspaceContextProjection.swift b/Sources/RepoPromptCore/WorkspaceContext/Projection/WorkspaceContextProjection.swift new file mode 100644 index 000000000..7caee8dc6 --- /dev/null +++ b/Sources/RepoPromptCore/WorkspaceContext/Projection/WorkspaceContextProjection.swift @@ -0,0 +1,258 @@ +import Foundation + +package struct WorkspaceContextProjection: Equatable { + package struct Section: Equatable { + package let provenance: WorkspaceFileContextCapture.Provenance + package let value: Value + + package init(provenance: WorkspaceFileContextCapture.Provenance, value: Value) { + self.provenance = provenance + self.value = value + } + } + + package struct FileTree: Equatable { + package let rootCount: Int + package let usesLegend: Bool + package let content: String + + package init(rootCount: Int, usesLegend: Bool, content: String) { + self.rootCount = rootCount + self.usesLegend = usesLegend + self.content = content + } + } + + package struct TokenViews: Equatable { + package let normalized: TokenProjection + package let userConfigured: TokenProjection? + + package init(normalized: TokenProjection, userConfigured: TokenProjection?) { + self.normalized = normalized + self.userConfigured = userConfigured + } + } + + package let prompt: Section? + package let selection: Section? + package let fileBlocks: Section<[String]>? + package let codeStructure: Section? + package let fileTree: Section? + package let tokens: Section? + + package init( + prompt: Section?, + selection: Section?, + fileBlocks: Section<[String]>?, + codeStructure: Section?, + fileTree: Section?, + tokens: Section? + ) { + self.prompt = prompt + self.selection = selection + self.fileBlocks = fileBlocks + self.codeStructure = codeStructure + self.fileTree = fileTree + self.tokens = tokens + } +} + +package enum WorkspaceTokenProjectionInput: Equatable { + case componentEstimate( + source: TokenProjection.Source, + nonFile: TokenProjectionService.WorkspaceNonFileComponents + ) + case activeLive(TokenProjectionService.ActiveLiveWorkspaceInput) + + package static let emptyVirtual = WorkspaceTokenProjectionInput.componentEstimate( + source: .virtualRecomputed, + nonFile: .init(prompt: 0, fileTree: 0, meta: 0, git: 0) + ) +} + +package struct WorkspaceContextProjectionRequest: Equatable { + package struct Sections: OptionSet, Equatable { + package let rawValue: Int + + package init(rawValue: Int) { + self.rawValue = rawValue + } + + package static let prompt = Sections(rawValue: 1 << 0) + package static let selection = Sections(rawValue: 1 << 1) + package static let files = Sections(rawValue: 1 << 2) + package static let codeStructure = Sections(rawValue: 1 << 3) + package static let fileTree = Sections(rawValue: 1 << 4) + package static let tokens = Sections(rawValue: 1 << 5) + package static let all: Sections = [.prompt, .selection, .files, .codeStructure, .fileTree, .tokens] + } + + package let sections: Sections + package let promptText: String + package let filePathDisplay: FilePathDisplay + package let codeMapUsage: CodeMapUsage + package let alternatePolicy: WorkspaceSelectionProjectionRequest.AlternatePolicy? + package let codeStructureBudget: CodeStructureProjectionRequest.Budget + package let includeUnmappedCodeStructurePaths: Bool + package let tokenProjectionInput: WorkspaceTokenProjectionInput + + package init( + sections: Sections = .all, + promptText: String = "", + filePathDisplay: FilePathDisplay = .relative, + codeMapUsage: CodeMapUsage = .auto, + alternatePolicy: WorkspaceSelectionProjectionRequest.AlternatePolicy? = nil, + codeStructureBudget: CodeStructureProjectionRequest.Budget = .init(resultLimit: 25), + includeUnmappedCodeStructurePaths: Bool = true, + tokenProjectionInput: WorkspaceTokenProjectionInput = .emptyVirtual + ) { + self.sections = sections + self.promptText = promptText + self.filePathDisplay = filePathDisplay + self.codeMapUsage = codeMapUsage + self.alternatePolicy = alternatePolicy + self.codeStructureBudget = codeStructureBudget + self.includeUnmappedCodeStructurePaths = includeUnmappedCodeStructurePaths + self.tokenProjectionInput = tokenProjectionInput + } +} + +package struct WorkspaceContextProjectionPlan { + package struct Occurrence { + package let value: WorkspaceContextProjectionMaterializationRequest.Occurrence + package let fileAPI: FileAPI? + + package init( + value: WorkspaceContextProjectionMaterializationRequest.Occurrence, + fileAPI: FileAPI? + ) { + self.value = value + self.fileAPI = fileAPI + } + } + + package let provenance: WorkspaceFileContextCapture.Provenance + package let occurrences: [Occurrence] + package let completeAlternateOccurrences: [Occurrence] + package let missingPaths: [String] + package let invalidPaths: [String] + + package init( + provenance: WorkspaceFileContextCapture.Provenance, + occurrences: [Occurrence], + completeAlternateOccurrences: [Occurrence], + missingPaths: [String], + invalidPaths: [String] + ) { + self.provenance = provenance + self.occurrences = occurrences + self.completeAlternateOccurrences = completeAlternateOccurrences + self.missingPaths = missingPaths + self.invalidPaths = invalidPaths + } +} + +package struct WorkspaceContextProjectionMaterializationRequest: Equatable { + package struct OccurrenceID: Hashable, Comparable { + package let rawValue: Int + + package init(rawValue: Int) { + self.rawValue = rawValue + } + + package static func < (lhs: OccurrenceID, rhs: OccurrenceID) -> Bool { + lhs.rawValue < rhs.rawValue + } + } + + package struct Codemap: Equatable { + package let content: String + package let tokens: Int + + package init(content: String, tokens: Int) { + self.content = content + self.tokens = tokens + } + } + + package struct Occurrence: Equatable { + package let id: OccurrenceID + package let file: WorkspaceFileRecord + package let metadata: WorkspaceSelectionProjection.PathMetadata + package let mode: WorkspaceSelectionProjection.BaseMode + package let ranges: [LineRange] + package let codemap: Codemap? + + package init( + id: OccurrenceID, + file: WorkspaceFileRecord, + metadata: WorkspaceSelectionProjection.PathMetadata, + mode: WorkspaceSelectionProjection.BaseMode, + ranges: [LineRange], + codemap: Codemap? + ) { + self.id = id + self.file = file + self.metadata = metadata + self.mode = mode + self.ranges = ranges + self.codemap = codemap + } + } + + package let provenance: WorkspaceFileContextCapture.Provenance + package let occurrences: [Occurrence] + package let requiresContent: Bool + package let requiresTokenFacts: Bool + + package init( + provenance: WorkspaceFileContextCapture.Provenance, + occurrences: [Occurrence], + requiresContent: Bool, + requiresTokenFacts: Bool + ) { + self.provenance = provenance + self.occurrences = occurrences + self.requiresContent = requiresContent + self.requiresTokenFacts = requiresTokenFacts + } +} + +package struct WorkspaceContextProjectionMaterialization: Equatable { + package struct TokenFacts: Equatable { + package let displayTokens: Int + package let fullTokens: Int + + package init(displayTokens: Int, fullTokens: Int) { + self.displayTokens = displayTokens + self.fullTokens = fullTokens + } + } + + package struct Occurrence: Equatable { + package let id: WorkspaceContextProjectionMaterializationRequest.OccurrenceID + package let content: String? + package let tokenFacts: TokenFacts? + + package init( + id: WorkspaceContextProjectionMaterializationRequest.OccurrenceID, + content: String?, + tokenFacts: TokenFacts? + ) { + self.id = id + self.content = content + self.tokenFacts = tokenFacts + } + } + + package let provenance: WorkspaceFileContextCapture.Provenance + package let occurrences: [Occurrence] + + package init( + provenance: WorkspaceFileContextCapture.Provenance, + occurrences: [Occurrence] + ) { + self.provenance = provenance + self.occurrences = occurrences + } +} diff --git a/Sources/RepoPromptCore/WorkspaceContext/Projection/WorkspaceContextProjectionService.swift b/Sources/RepoPromptCore/WorkspaceContext/Projection/WorkspaceContextProjectionService.swift new file mode 100644 index 000000000..b7b073129 --- /dev/null +++ b/Sources/RepoPromptCore/WorkspaceContext/Projection/WorkspaceContextProjectionService.swift @@ -0,0 +1,742 @@ +import Foundation + +package enum WorkspaceContextProjectionError: Error, Equatable { + case captureProvenanceMismatch + case duplicateRootID(UUID) + case rootAssociationMismatch(recordID: UUID, rootID: UUID) + case recordAssociationMismatch(UUID) + case duplicateCodemapFileID(UUID) + case codemapAssociationMismatch(UUID) + case materializationProvenanceMismatch + case duplicateOccurrenceID(WorkspaceContextProjectionMaterializationRequest.OccurrenceID) + case missingOccurrenceIDs([WorkspaceContextProjectionMaterializationRequest.OccurrenceID]) + case unexpectedOccurrenceIDs([WorkspaceContextProjectionMaterializationRequest.OccurrenceID]) + case missingTokenFacts(WorkspaceContextProjectionMaterializationRequest.OccurrenceID) + case invalidTokenFacts(WorkspaceContextProjectionMaterializationRequest.OccurrenceID) +} + +package struct WorkspaceContextProjectionService { + package typealias CaptureOperation = @Sendable () async throws -> WorkspaceFileContextCapture + package typealias Materializer = @Sendable ( + WorkspaceContextProjectionMaterializationRequest + ) async throws -> WorkspaceContextProjectionMaterialization + + private struct OccurrenceKey: Hashable { + enum Mode: Hashable { + case full + case slice + case codemap + } + + let fileID: UUID + let mode: Mode + let ranges: [LineRange] + } + + private let captureOperation: CaptureOperation + private let materializer: Materializer + #if DEBUG + private let willReturnForTesting: (@Sendable () -> Void)? + #endif + + package init( + capture: @escaping CaptureOperation, + materializer: @escaping Materializer + ) { + captureOperation = capture + self.materializer = materializer + #if DEBUG + willReturnForTesting = nil + #endif + } + + #if DEBUG + package init( + capture: @escaping CaptureOperation, + materializer: @escaping Materializer, + willReturnForTesting: @escaping @Sendable () -> Void + ) { + captureOperation = capture + self.materializer = materializer + self.willReturnForTesting = willReturnForTesting + } + #endif + + package func project( + _ request: WorkspaceContextProjectionRequest + ) async throws -> WorkspaceContextProjection { + try Task.checkCancellation() + let capture = try await captureOperation() + try Task.checkCancellation() + + let plan = try Self.makePlan(capture: capture, request: request) + let preparedOccurrences = plan.occurrences + let completeAlternateOccurrences = plan.completeAlternateOccurrences + let occurrences = preparedOccurrences.map(\.value) + + let needsContent = request.sections.contains(.files) + let needsTokenFacts = request.sections.contains(.selection) || request.sections.contains(.tokens) + let needsMaterialization = needsContent || needsTokenFacts + let materializedByID: [WorkspaceContextProjectionMaterializationRequest.OccurrenceID: WorkspaceContextProjectionMaterialization.Occurrence] + if needsMaterialization { + try Task.checkCancellation() + let materialization = try await materializer(.init( + provenance: capture.provenance, + occurrences: occurrences, + requiresContent: needsContent, + requiresTokenFacts: needsTokenFacts + )) + try Task.checkCancellation() + materializedByID = try Self.validateMaterialization( + materialization, + expectedProvenance: capture.provenance, + expected: occurrences, + requiresTokenFacts: needsTokenFacts + ) + } else { + materializedByID = [:] + } + + let selectionProjection: WorkspaceSelectionProjection? + if request.sections.contains(.selection) || request.sections.contains(.tokens) { + try Task.checkCancellation() + selectionProjection = try WorkspaceSelectionProjectionService.project(.init( + entries: occurrences.map { occurrence in + guard let tokenFacts = materializedByID[occurrence.id]?.tokenFacts else { + throw WorkspaceContextProjectionError.missingTokenFacts(occurrence.id) + } + return WorkspaceSelectionProjectionRequest.Entry( + file: occurrence.file, + metadata: occurrence.metadata, + mode: occurrence.mode, + ranges: occurrence.ranges, + tokens: .init( + displayTokens: tokenFacts.displayTokens, + fullTokens: tokenFacts.fullTokens, + codemapTokens: occurrence.codemap?.tokens ?? 0 + ), + codemapAvailable: occurrence.codemap != nil, + codemapContent: occurrence.codemap?.content + ) + }, + completeAlternateEntries: completeAlternateOccurrences.compactMap { prepared in + let occurrence = prepared.value + guard let codemap = occurrence.codemap else { return nil } + return WorkspaceSelectionProjectionRequest.Entry( + file: occurrence.file, + metadata: occurrence.metadata, + mode: .codemap, + tokens: .init( + displayTokens: codemap.tokens, + fullTokens: 0, + codemapTokens: codemap.tokens + ), + codemapAvailable: true, + codemapContent: codemap.content + ) + }, + codeMapUsage: request.codeMapUsage, + codemapAutoEnabled: capture.storedSelection.codemapAutoEnabled, + missingPaths: plan.missingPaths, + invalidPaths: plan.invalidPaths, + alternatePolicy: request.alternatePolicy + )) + try Task.checkCancellation() + } else { + selectionProjection = nil + } + + let promptSection: WorkspaceContextProjection.Section? + if request.sections.contains(.prompt) { + try Task.checkCancellation() + promptSection = .init(provenance: capture.provenance, value: request.promptText) + try Task.checkCancellation() + } else { + promptSection = nil + } + + let selectionSection: WorkspaceContextProjection.Section? + if request.sections.contains(.selection), let selectionProjection { + try Task.checkCancellation() + selectionSection = .init(provenance: capture.provenance, value: selectionProjection) + try Task.checkCancellation() + } else { + selectionSection = nil + } + + let fileBlocksSection: WorkspaceContextProjection.Section<[String]>? + if request.sections.contains(.files) { + try Task.checkCancellation() + let values = occurrences.map { occurrence in + PromptRenderingFileValue( + displayPath: occurrence.metadata.displayPath, + fileName: occurrence.file.name, + content: occurrence.mode == .codemap ? nil : materializedByID[occurrence.id]?.content, + ranges: occurrence.mode == .slice ? occurrence.ranges : nil, + codemapText: occurrence.mode == .codemap ? occurrence.codemap?.content : nil + ) + } + let blocks = PromptRenderingService.renderFileBlocks(values).map(\.text) + fileBlocksSection = .init(provenance: capture.provenance, value: blocks) + try Task.checkCancellation() + } else { + fileBlocksSection = nil + } + + let codeStructureSection: WorkspaceContextProjection.Section? + if request.sections.contains(.codeStructure) { + try Task.checkCancellation() + let projection = CodeStructureProjectionService.project(.init( + entries: preparedOccurrences.map { + CodeStructureProjectionRequest.Entry( + physicalPath: $0.value.file.standardizedFullPath, + displayPath: $0.value.metadata.displayPath, + fileAPI: $0.fileAPI + ) + }, + budget: request.codeStructureBudget, + includeUnmappedPaths: request.includeUnmappedCodeStructurePaths + )) + codeStructureSection = .init(provenance: capture.provenance, value: projection) + try Task.checkCancellation() + } else { + codeStructureSection = nil + } + + let fileTreeSection: WorkspaceContextProjection.Section? + if request.sections.contains(.fileTree) { + try Task.checkCancellation() + let content = FileTreeSnapshotRenderer.generateFileTree(using: capture.fileTree) + fileTreeSection = .init( + provenance: capture.provenance, + value: .init( + rootCount: capture.fileTree.roots.count, + usesLegend: capture.fileTree.includeLegend, + content: content + ) + ) + try Task.checkCancellation() + } else { + fileTreeSection = nil + } + + let tokensSection: WorkspaceContextProjection.Section? + if request.sections.contains(.tokens), let selectionProjection { + try Task.checkCancellation() + let views: TokenProjectionService.WorkspaceViews = switch request.tokenProjectionInput { + case let .componentEstimate(source, nonFile): + TokenProjectionService.workspaceComponentEstimates( + from: selectionProjection, + source: source, + nonFile: nonFile + ) + case let .activeLive(input): + TokenProjectionService.activeLiveWorkspaceEstimates( + from: selectionProjection, + input: input + ) + } + tokensSection = .init( + provenance: capture.provenance, + value: .init( + normalized: views.normalized, + userConfigured: views.userConfigured + ) + ) + try Task.checkCancellation() + } else { + tokensSection = nil + } + + let projection = WorkspaceContextProjection( + prompt: promptSection, + selection: selectionSection, + fileBlocks: fileBlocksSection, + codeStructure: codeStructureSection, + fileTree: fileTreeSection, + tokens: tokensSection + ) + #if DEBUG + willReturnForTesting?() + #endif + try Task.checkCancellation() + return projection + } + + package static func makePlan( + capture: WorkspaceFileContextCapture, + request: WorkspaceContextProjectionRequest + ) throws -> WorkspaceContextProjectionPlan { + let validated = try validateCapture(capture) + let preparedOccurrences = makeOccurrences( + capture: capture, + rootsByID: validated.rootsByID, + filesByID: validated.filesByID, + codemapsByFileID: validated.codemapsByFileID, + request: request + ) + let completeAlternateOccurrences = makeCompleteAlternateOccurrences( + capture: capture, + rootsByID: validated.rootsByID, + filesByID: validated.filesByID, + codemapsByFileID: validated.codemapsByFileID, + normalized: preparedOccurrences, + request: request + ) + let missingPaths = Array(Set( + validated.selectedMissingPaths + + validated.sliceMissingPaths + + (request.codeMapUsage == .auto ? validated.autoCodemapMissingPaths : []) + )).sorted() + let invalidPaths = Array(Set( + validated.selectedInvalidPaths + + validated.sliceInvalidPaths + + (request.codeMapUsage == .auto ? validated.autoCodemapInvalidPaths : []) + )).sorted() + return WorkspaceContextProjectionPlan( + provenance: capture.provenance, + occurrences: preparedOccurrences, + completeAlternateOccurrences: completeAlternateOccurrences, + missingPaths: missingPaths, + invalidPaths: invalidPaths + ) + } + + private struct ValidatedCapture { + let rootsByID: [UUID: WorkspaceRootRecord] + let filesByID: [UUID: WorkspaceFileRecord] + let codemapsByFileID: [UUID: WorkspaceCodemapSnapshot] + let selectedMissingPaths: [String] + let selectedInvalidPaths: [String] + let autoCodemapMissingPaths: [String] + let autoCodemapInvalidPaths: [String] + let sliceMissingPaths: [String] + let sliceInvalidPaths: [String] + } + + private static func validateCapture( + _ capture: WorkspaceFileContextCapture + ) throws -> ValidatedCapture { + guard capture.provenance.catalogGeneration == capture.catalog.generation, + capture.provenance.rootScope == capture.catalog.rootScope, + capture.catalog.diagnostics.generation == capture.catalog.generation, + capture.catalog.diagnostics.rootScope == capture.catalog.rootScope + else { + throw WorkspaceContextProjectionError.captureProvenanceMismatch + } + + var rootsByID: [UUID: WorkspaceRootRecord] = [:] + for root in capture.catalog.roots { + guard rootsByID.updateValue(root, forKey: root.id) == nil else { + throw WorkspaceContextProjectionError.duplicateRootID(root.id) + } + } + + var filesByID: [UUID: WorkspaceFileRecord] = [:] + for file in capture.materializedFiles { + guard rootsByID[file.rootID] != nil else { + throw WorkspaceContextProjectionError.rootAssociationMismatch( + recordID: file.id, + rootID: file.rootID + ) + } + guard filesByID.updateValue(file, forKey: file.id) == nil else { + throw WorkspaceContextProjectionError.recordAssociationMismatch(file.id) + } + } + var foldersByID: [UUID: WorkspaceFolderRecord] = [:] + for folder in capture.materializedFolders { + guard rootsByID[folder.rootID] != nil else { + throw WorkspaceContextProjectionError.rootAssociationMismatch( + recordID: folder.id, + rootID: folder.rootID + ) + } + guard foldersByID.updateValue(folder, forKey: folder.id) == nil else { + throw WorkspaceContextProjectionError.recordAssociationMismatch(folder.id) + } + } + for file in capture.catalog.files { + guard rootsByID[file.rootID] != nil else { + throw WorkspaceContextProjectionError.rootAssociationMismatch( + recordID: file.id, + rootID: file.rootID + ) + } + } + + var codemapsByFileID: [UUID: WorkspaceCodemapSnapshot] = [:] + for codemap in capture.codemapSnapshots { + guard codemapsByFileID.updateValue(codemap, forKey: codemap.fileID) == nil else { + throw WorkspaceContextProjectionError.duplicateCodemapFileID(codemap.fileID) + } + guard let file = filesByID[codemap.fileID], + let root = rootsByID[codemap.rootID], + file.rootID == codemap.rootID, + StandardizedPath.absolute(codemap.rootPath) == root.standardizedFullPath, + StandardizedPath.relative(codemap.relativePath) == file.standardizedRelativePath, + StandardizedPath.absolute(codemap.fullPath) == file.standardizedFullPath, + codemap.fileAPI.map({ StandardizedPath.absolute($0.filePath) == file.standardizedFullPath }) ?? true + else { + throw WorkspaceContextProjectionError.codemapAssociationMismatch(codemap.fileID) + } + } + + var selectedMissingPaths: [String] = [] + var selectedInvalidPaths: [String] = [] + try validateSelectionPaths( + capture.selectedPaths, + foldersByID: foldersByID, + filesByID: filesByID, + missingPaths: &selectedMissingPaths, + invalidPaths: &selectedInvalidPaths + ) + + var autoCodemapMissingPaths: [String] = [] + var autoCodemapInvalidPaths: [String] = [] + try validateSelectionPaths( + capture.autoCodemapPaths, + foldersByID: foldersByID, + filesByID: filesByID, + missingPaths: &autoCodemapMissingPaths, + invalidPaths: &autoCodemapInvalidPaths + ) + + var sliceMissingPaths: [String] = [] + var sliceInvalidPaths: [String] = [] + for slice in capture.slices { + if let file = slice.file { + try validateCapturedFile(file, filesByID: filesByID) + } else { + appendPath( + slice.path, + issue: slice.issue ?? .unresolved(input: slice.path), + missingPaths: &sliceMissingPaths, + invalidPaths: &sliceInvalidPaths + ) + } + } + + try validateTree( + capture.fileTree.roots, + rootsByID: rootsByID, + foldersByID: foldersByID, + filesByID: filesByID + ) + + return ValidatedCapture( + rootsByID: rootsByID, + filesByID: filesByID, + codemapsByFileID: codemapsByFileID, + selectedMissingPaths: Array(Set(selectedMissingPaths)).sorted(), + selectedInvalidPaths: Array(Set(selectedInvalidPaths)).sorted(), + autoCodemapMissingPaths: Array(Set(autoCodemapMissingPaths)).sorted(), + autoCodemapInvalidPaths: Array(Set(autoCodemapInvalidPaths)).sorted(), + sliceMissingPaths: Array(Set(sliceMissingPaths)).sorted(), + sliceInvalidPaths: Array(Set(sliceInvalidPaths)).sorted() + ) + } + + private static func validateSelectionPaths( + _ paths: [WorkspaceFileContextCapture.SelectionPath], + foldersByID: [UUID: WorkspaceFolderRecord], + filesByID: [UUID: WorkspaceFileRecord], + missingPaths: inout [String], + invalidPaths: inout [String] + ) throws { + for path in paths { + switch path.resolution { + case let .file(file): + try validateCapturedFile(file, filesByID: filesByID) + case let .folder(folder, descendantFiles): + guard foldersByID[folder.id] == folder else { + throw WorkspaceContextProjectionError.recordAssociationMismatch(folder.id) + } + for file in descendantFiles { + try validateCapturedFile(file, filesByID: filesByID) + } + case let .unresolved(issue): + appendPath( + path.input, + issue: issue, + missingPaths: &missingPaths, + invalidPaths: &invalidPaths + ) + } + } + } + + private static func validateCapturedFile( + _ file: WorkspaceFileRecord, + filesByID: [UUID: WorkspaceFileRecord] + ) throws { + guard filesByID[file.id] == file else { + throw WorkspaceContextProjectionError.recordAssociationMismatch(file.id) + } + } + + private static func appendPath( + _ path: String, + issue: PathResolutionIssue, + missingPaths: inout [String], + invalidPaths: inout [String] + ) { + if case .unresolved = issue { + missingPaths.append(path) + } else { + invalidPaths.append(path) + } + } + + private static func validateTree( + _ folders: [FileTreeFolderSnapshot], + rootsByID: [UUID: WorkspaceRootRecord], + foldersByID: [UUID: WorkspaceFolderRecord], + filesByID: [UUID: WorkspaceFileRecord] + ) throws { + for folder in folders { + guard let capturedFolder = foldersByID[folder.id], + let root = rootsByID[capturedFolder.rootID], + folder.standardizedFullPath == capturedFolder.standardizedFullPath, + folder.standardizedRootPath == root.standardizedFullPath + else { + throw WorkspaceContextProjectionError.recordAssociationMismatch(folder.id) + } + for child in folder.children { + switch child { + case let .folder(childFolder): + try validateTree( + [childFolder], + rootsByID: rootsByID, + foldersByID: foldersByID, + filesByID: filesByID + ) + case let .file(file): + guard filesByID[file.id] != nil else { + throw WorkspaceContextProjectionError.recordAssociationMismatch(file.id) + } + } + } + } + } + + private static func makeOccurrences( + capture: WorkspaceFileContextCapture, + rootsByID: [UUID: WorkspaceRootRecord], + filesByID: [UUID: WorkspaceFileRecord], + codemapsByFileID: [UUID: WorkspaceCodemapSnapshot], + request: WorkspaceContextProjectionRequest + ) -> [WorkspaceContextProjectionPlan.Occurrence] { + let multipleRoots = capture.catalog.roots.count > 1 + + var prepared: [WorkspaceContextProjectionPlan.Occurrence] = [] + var seenKeys = Set() + var selectedFileIDs = Set() + + func append(_ file: WorkspaceFileRecord, ranges: [LineRange], forceCodemap: Bool) { + selectedFileIDs.insert(file.id) + let codemapSnapshot = codemapsByFileID[file.id] + let fileAPI = codemapSnapshot?.fileAPI + let mode: WorkspaceSelectionProjection.BaseMode = if forceCodemap, fileAPI != nil { + .codemap + } else if !ranges.isEmpty { + .slice + } else { + .full + } + let keyMode: OccurrenceKey.Mode = switch mode { + case .full: .full + case .slice: .slice + case .codemap: .codemap + } + let effectiveRanges = mode == .slice ? ranges : [] + let key = OccurrenceKey(fileID: file.id, mode: keyMode, ranges: effectiveRanges) + guard seenKeys.insert(key).inserted, let root = rootsByID[file.rootID] else { return } + + let displayPath: String = switch request.filePathDisplay { + case .full: + file.fullPath + case .relative: + multipleRoots && !root.name.isEmpty + ? root.name + "/" + file.standardizedRelativePath + : file.standardizedRelativePath + } + let metadata = WorkspaceSelectionProjection.PathMetadata( + displayPath: displayPath, + rootPath: root.fullPath, + pathWithinRoot: file.standardizedRelativePath + ) + let codemap: WorkspaceContextProjectionMaterializationRequest.Codemap? = fileAPI.map { + .init( + content: $0.getFullAPIDescription(displayPath: displayPath), + tokens: $0.apiTokenCount + ) + } + let occurrence = WorkspaceContextProjectionMaterializationRequest.Occurrence( + id: .init(rawValue: prepared.count), + file: file, + metadata: metadata, + mode: mode, + ranges: effectiveRanges, + codemap: codemap + ) + prepared.append(.init(value: occurrence, fileAPI: fileAPI)) + } + + let selectedForceCodemap = request.codeMapUsage == .selected + for path in capture.selectedPaths { + switch path.resolution { + case let .file(file): + append( + file, + ranges: sliceRanges( + for: path.input, + file: file, + slices: capture.storedSelection.slices + ) ?? [], + forceCodemap: selectedForceCodemap + ) + case let .folder(_, descendantFiles): + for file in descendantFiles { + append(file, ranges: [], forceCodemap: selectedForceCodemap) + } + case .unresolved: + break + } + } + + for slice in capture.slices { + guard let file = slice.file, !selectedFileIDs.contains(file.id) else { continue } + append(file, ranges: slice.ranges, forceCodemap: false) + } + + switch request.codeMapUsage { + case .none, .selected: + break + case .auto: + for path in capture.autoCodemapPaths { + switch path.resolution { + case let .file(file): + guard !selectedFileIDs.contains(file.id), codemapsByFileID[file.id]?.fileAPI != nil else { continue } + append(file, ranges: [], forceCodemap: true) + case let .folder(_, descendantFiles): + for file in descendantFiles where !selectedFileIDs.contains(file.id) && codemapsByFileID[file.id]?.fileAPI != nil { + append(file, ranges: [], forceCodemap: true) + } + case .unresolved: + break + } + } + case .complete: + for codemap in capture.codemapSnapshots where codemap.fileAPI != nil && !selectedFileIDs.contains(codemap.fileID) { + guard let file = filesByID[codemap.fileID] else { continue } + append(file, ranges: [], forceCodemap: true) + } + } + + return prepared + } + + private static func makeCompleteAlternateOccurrences( + capture: WorkspaceFileContextCapture, + rootsByID: [UUID: WorkspaceRootRecord], + filesByID: [UUID: WorkspaceFileRecord], + codemapsByFileID: [UUID: WorkspaceCodemapSnapshot], + normalized: [WorkspaceContextProjectionPlan.Occurrence], + request: WorkspaceContextProjectionRequest + ) -> [WorkspaceContextProjectionPlan.Occurrence] { + guard request.alternatePolicy?.codeMapUsage == .complete, + request.codeMapUsage != .complete, + request.sections.contains(.selection) || request.sections.contains(.tokens) + else { return [] } + + let normalizedFileIDs = Set(normalized.map(\.value.file.id)) + let complete = makeOccurrences( + capture: capture, + rootsByID: rootsByID, + filesByID: filesByID, + codemapsByFileID: codemapsByFileID, + request: .init( + sections: request.sections, + filePathDisplay: request.filePathDisplay, + codeMapUsage: .complete + ) + ) + return complete.filter { + $0.value.mode == .codemap && !normalizedFileIDs.contains($0.value.file.id) + } + } + + private static func sliceRanges( + for input: String, + file: WorkspaceFileRecord, + slices: [String: [LineRange]] + ) -> [LineRange]? { + let candidateKeys = [ + input, + StandardizedPath.absolute(input), + file.relativePath, + file.standardizedRelativePath, + file.fullPath, + file.standardizedFullPath + ] + for key in candidateKeys { + if let ranges = slices[key] { return ranges } + } + return nil + } + + private static func validateMaterialization( + _ materialization: WorkspaceContextProjectionMaterialization, + expectedProvenance: WorkspaceFileContextCapture.Provenance, + expected: [WorkspaceContextProjectionMaterializationRequest.Occurrence], + requiresTokenFacts: Bool + ) throws -> [WorkspaceContextProjectionMaterializationRequest.OccurrenceID: WorkspaceContextProjectionMaterialization.Occurrence] { + guard materialization.provenance == expectedProvenance else { + throw WorkspaceContextProjectionError.materializationProvenanceMismatch + } + + let expectedByID = Dictionary(uniqueKeysWithValues: expected.map { ($0.id, $0) }) + var materializedByID: [WorkspaceContextProjectionMaterializationRequest.OccurrenceID: WorkspaceContextProjectionMaterialization.Occurrence] = [:] + var unexpectedIDs: [WorkspaceContextProjectionMaterializationRequest.OccurrenceID] = [] + + for occurrence in materialization.occurrences { + guard materializedByID.updateValue(occurrence, forKey: occurrence.id) == nil else { + throw WorkspaceContextProjectionError.duplicateOccurrenceID(occurrence.id) + } + guard let expectedOccurrence = expectedByID[occurrence.id] else { + unexpectedIDs.append(occurrence.id) + continue + } + if requiresTokenFacts { + guard let tokenFacts = occurrence.tokenFacts else { + throw WorkspaceContextProjectionError.missingTokenFacts(occurrence.id) + } + guard tokenFacts.displayTokens >= 0, tokenFacts.fullTokens >= 0 else { + throw WorkspaceContextProjectionError.invalidTokenFacts(occurrence.id) + } + switch expectedOccurrence.mode { + case .full: + guard tokenFacts.displayTokens == tokenFacts.fullTokens else { + throw WorkspaceContextProjectionError.invalidTokenFacts(occurrence.id) + } + case .slice: + break + case .codemap: + guard tokenFacts.displayTokens == expectedOccurrence.codemap?.tokens else { + throw WorkspaceContextProjectionError.invalidTokenFacts(occurrence.id) + } + } + } + } + + if !unexpectedIDs.isEmpty { + throw WorkspaceContextProjectionError.unexpectedOccurrenceIDs(unexpectedIDs.sorted()) + } + let missingIDs = expectedByID.keys.filter { materializedByID[$0] == nil }.sorted() + if !missingIDs.isEmpty { + throw WorkspaceContextProjectionError.missingOccurrenceIDs(missingIDs) + } + return materializedByID + } +} diff --git a/Sources/RepoPromptCore/WorkspaceContext/Projection/WorkspaceSelectionProjection.swift b/Sources/RepoPromptCore/WorkspaceContext/Projection/WorkspaceSelectionProjection.swift new file mode 100644 index 000000000..6bcf01078 --- /dev/null +++ b/Sources/RepoPromptCore/WorkspaceContext/Projection/WorkspaceSelectionProjection.swift @@ -0,0 +1,309 @@ +import Foundation + +package struct WorkspaceSelectionProjection: Equatable { + package enum BaseMode: Equatable { + case full + case slice + case codemap + } + + package enum RenderMode: Equatable { + case full + case slice + case codemap + case hidden + } + + package enum CodemapOrigin: Equatable { + case auto + case manual + case selectedMode + case completeMode + } + + package struct PathMetadata: Equatable { + package let displayPath: String + package let rootPath: String + package let pathWithinRoot: String + + package init(displayPath: String, rootPath: String, pathWithinRoot: String) { + self.displayPath = displayPath + self.rootPath = rootPath + self.pathWithinRoot = pathWithinRoot + } + } + + package struct FileAlternate: Equatable { + package let mode: RenderMode + package let tokens: Int + package let codemapOrigin: CodemapOrigin? + + package init(mode: RenderMode, tokens: Int, codemapOrigin: CodemapOrigin?) { + self.mode = mode + self.tokens = tokens + self.codemapOrigin = codemapOrigin + } + } + + package struct File: Equatable { + package let file: WorkspaceFileRecord + package let metadata: PathMetadata + package let mode: RenderMode + package let ranges: [LineRange]? + package let tokens: Int + package let codemapAvailable: Bool + package let codemapOrigin: CodemapOrigin? + package let alternate: FileAlternate? + + package var isAuto: Bool { + codemapOrigin == .auto + } + + package init( + file: WorkspaceFileRecord, + metadata: PathMetadata, + mode: RenderMode, + ranges: [LineRange]?, + tokens: Int, + codemapAvailable: Bool, + codemapOrigin: CodemapOrigin?, + alternate: FileAlternate? + ) { + self.file = file + self.metadata = metadata + self.mode = mode + self.ranges = ranges + self.tokens = tokens + self.codemapAvailable = codemapAvailable + self.codemapOrigin = codemapOrigin + self.alternate = alternate + } + } + + package struct Slice: Equatable { + package let file: WorkspaceFileRecord + package let metadata: PathMetadata + package let ranges: [LineRange] + + package init(file: WorkspaceFileRecord, metadata: PathMetadata, ranges: [LineRange]) { + self.file = file + self.metadata = metadata + self.ranges = ranges + } + } + + package struct Summary: Equatable { + package let fullCount: Int + package let sliceCount: Int + package let codemapCount: Int + package let fullTokens: Int + package let sliceTokens: Int + package let codemapTokens: Int + + package var totalCount: Int { + fullCount + sliceCount + codemapCount + } + + package var totalTokens: Int { + fullTokens + sliceTokens + codemapTokens + } + + package init( + fullCount: Int, + sliceCount: Int, + codemapCount: Int, + fullTokens: Int, + sliceTokens: Int, + codemapTokens: Int + ) { + self.fullCount = fullCount + self.sliceCount = sliceCount + self.codemapCount = codemapCount + self.fullTokens = fullTokens + self.sliceTokens = sliceTokens + self.codemapTokens = codemapTokens + } + + package static let empty = Summary( + fullCount: 0, + sliceCount: 0, + codemapCount: 0, + fullTokens: 0, + sliceTokens: 0, + codemapTokens: 0 + ) + } + + package struct IncludedFile: Equatable { + package let file: WorkspaceFileRecord + package let metadata: PathMetadata + package let mode: RenderMode + package let ranges: [LineRange]? + package let tokens: Int + package let fullTokens: Int? + package let codemapTokens: Int + package let codemapOrigin: CodemapOrigin? + package let codemapContent: String? + + package init( + file: WorkspaceFileRecord, + metadata: PathMetadata, + mode: RenderMode, + ranges: [LineRange]?, + tokens: Int, + fullTokens: Int?, + codemapTokens: Int, + codemapOrigin: CodemapOrigin?, + codemapContent: String? + ) { + self.file = file + self.metadata = metadata + self.mode = mode + self.ranges = ranges + self.tokens = tokens + self.fullTokens = fullTokens + self.codemapTokens = codemapTokens + self.codemapOrigin = codemapOrigin + self.codemapContent = codemapContent + } + } + + package struct Alternate: Equatable { + package let codeMapUsage: CodeMapUsage + package let includesFiles: Bool + package let contentTokens: Int + package let codemapTokens: Int + package let totalTokens: Int + package let includedTotalTokens: Int + package let includedFiles: [IncludedFile] + + package init( + codeMapUsage: CodeMapUsage, + includesFiles: Bool, + contentTokens: Int, + codemapTokens: Int, + totalTokens: Int, + includedTotalTokens: Int, + includedFiles: [IncludedFile] = [] + ) { + self.codeMapUsage = codeMapUsage + self.includesFiles = includesFiles + self.contentTokens = contentTokens + self.codemapTokens = codemapTokens + self.totalTokens = totalTokens + self.includedTotalTokens = includedTotalTokens + self.includedFiles = includedFiles + } + } + + package let files: [File] + package let normalizedFiles: [IncludedFile] + package let slices: [Slice] + package let summary: Summary + package let invalidPaths: [String] + package let codeMapUsage: CodeMapUsage + package let codemapAutoEnabled: Bool + package let alternate: Alternate? + + package var totalTokens: Int { + summary.totalTokens + } + + package init( + files: [File], + normalizedFiles: [IncludedFile] = [], + slices: [Slice], + summary: Summary, + invalidPaths: [String], + codeMapUsage: CodeMapUsage, + codemapAutoEnabled: Bool, + alternate: Alternate? + ) { + self.files = files + self.normalizedFiles = normalizedFiles + self.slices = slices + self.summary = summary + self.invalidPaths = invalidPaths + self.codeMapUsage = codeMapUsage + self.codemapAutoEnabled = codemapAutoEnabled + self.alternate = alternate + } +} + +package struct WorkspaceSelectionProjectionRequest: Equatable { + package struct TokenFacts: Equatable { + package let displayTokens: Int + package let fullTokens: Int + package let codemapTokens: Int + + package init(displayTokens: Int, fullTokens: Int, codemapTokens: Int) { + self.displayTokens = displayTokens + self.fullTokens = fullTokens + self.codemapTokens = codemapTokens + } + } + + package struct Entry: Equatable { + package let file: WorkspaceFileRecord + package let metadata: WorkspaceSelectionProjection.PathMetadata + package let mode: WorkspaceSelectionProjection.BaseMode + package let ranges: [LineRange] + package let tokens: TokenFacts + package let codemapAvailable: Bool + package let codemapContent: String? + + package init( + file: WorkspaceFileRecord, + metadata: WorkspaceSelectionProjection.PathMetadata, + mode: WorkspaceSelectionProjection.BaseMode, + ranges: [LineRange] = [], + tokens: TokenFacts, + codemapAvailable: Bool, + codemapContent: String? = nil + ) { + self.file = file + self.metadata = metadata + self.mode = mode + self.ranges = ranges + self.tokens = tokens + self.codemapAvailable = codemapAvailable + self.codemapContent = codemapContent + } + } + + package struct AlternatePolicy: Equatable { + package let includeFiles: Bool + package let codeMapUsage: CodeMapUsage + + package init(includeFiles: Bool, codeMapUsage: CodeMapUsage) { + self.includeFiles = includeFiles + self.codeMapUsage = codeMapUsage + } + } + + package let entries: [Entry] + package let completeAlternateEntries: [Entry] + package let codeMapUsage: CodeMapUsage + package let codemapAutoEnabled: Bool + package let missingPaths: [String] + package let invalidPaths: [String] + package let alternatePolicy: AlternatePolicy? + + package init( + entries: [Entry], + completeAlternateEntries: [Entry] = [], + codeMapUsage: CodeMapUsage, + codemapAutoEnabled: Bool, + missingPaths: [String] = [], + invalidPaths: [String] = [], + alternatePolicy: AlternatePolicy? = nil + ) { + self.entries = entries + self.completeAlternateEntries = completeAlternateEntries + self.codeMapUsage = codeMapUsage + self.codemapAutoEnabled = codemapAutoEnabled + self.missingPaths = missingPaths + self.invalidPaths = invalidPaths + self.alternatePolicy = alternatePolicy + } +} diff --git a/Sources/RepoPromptCore/WorkspaceContext/Projection/WorkspaceSelectionProjectionService.swift b/Sources/RepoPromptCore/WorkspaceContext/Projection/WorkspaceSelectionProjectionService.swift new file mode 100644 index 000000000..21236351d --- /dev/null +++ b/Sources/RepoPromptCore/WorkspaceContext/Projection/WorkspaceSelectionProjectionService.swift @@ -0,0 +1,271 @@ +import Foundation + +package enum WorkspaceSelectionProjectionService { + private struct AlternateState { + let mode: WorkspaceSelectionProjection.RenderMode + let tokens: Int + let codemapOrigin: WorkspaceSelectionProjection.CodemapOrigin? + } + + package static func project( + _ request: WorkspaceSelectionProjectionRequest + ) -> WorkspaceSelectionProjection { + var files: [WorkspaceSelectionProjection.File] = [] + var normalizedFiles: [WorkspaceSelectionProjection.IncludedFile] = [] + var slices: [WorkspaceSelectionProjection.Slice] = [] + var alternateCandidates: [WorkspaceSelectionProjection.IncludedFile] = [] + files.reserveCapacity(request.entries.count) + normalizedFiles.reserveCapacity(request.entries.count) + slices.reserveCapacity(request.entries.count) + alternateCandidates.reserveCapacity(request.entries.count + request.completeAlternateEntries.count) + + var fullCount = 0 + var sliceCount = 0 + var codemapCount = 0 + var fullTokens = 0 + var sliceTokens = 0 + var codemapTokens = 0 + + for entry in request.entries { + let mode = renderMode(for: entry.mode) + let origin = codemapOrigin( + for: entry.mode, + codeMapUsage: request.codeMapUsage, + codemapAutoEnabled: request.codemapAutoEnabled + ) + + switch mode { + case .full: + fullCount += 1 + fullTokens += entry.tokens.displayTokens + case .slice: + sliceCount += 1 + sliceTokens += entry.tokens.displayTokens + slices.append(WorkspaceSelectionProjection.Slice( + file: entry.file, + metadata: entry.metadata, + ranges: entry.ranges + )) + case .codemap: + codemapCount += 1 + codemapTokens += entry.tokens.displayTokens + case .hidden: + break + } + + normalizedFiles.append(makeIncludedFile( + entry: entry, + mode: mode, + tokens: entry.tokens.displayTokens, + fullTokens: entry.tokens.fullTokens, + codemapOrigin: origin + )) + + let alternateState: AlternateState? = request.alternatePolicy.map { + makeAlternateState( + for: entry, + baseMode: mode, + baseOrigin: origin, + codeMapUsage: $0.codeMapUsage + ) + } + if let alternateState, alternateState.mode != .hidden { + alternateCandidates.append(makeIncludedFile( + entry: entry, + mode: alternateState.mode, + tokens: alternateState.tokens, + fullTokens: entry.tokens.fullTokens, + codemapOrigin: alternateState.codemapOrigin + )) + } + + let alternate = alternateState.flatMap { state -> WorkspaceSelectionProjection.FileAlternate? in + guard state.mode != mode || state.tokens != entry.tokens.displayTokens else { return nil } + return WorkspaceSelectionProjection.FileAlternate( + mode: state.mode, + tokens: state.tokens, + codemapOrigin: state.codemapOrigin + ) + } + files.append(WorkspaceSelectionProjection.File( + file: entry.file, + metadata: entry.metadata, + mode: mode, + ranges: mode == .slice ? entry.ranges : nil, + tokens: entry.tokens.displayTokens, + codemapAvailable: entry.codemapAvailable, + codemapOrigin: origin, + alternate: alternate + )) + } + + if request.alternatePolicy?.codeMapUsage == .complete { + for entry in request.completeAlternateEntries where entry.codemapAvailable { + alternateCandidates.append(makeIncludedFile( + entry: entry, + mode: .codemap, + tokens: entry.tokens.codemapTokens, + fullTokens: nil, + codemapOrigin: .completeMode + )) + } + } + + let summary = WorkspaceSelectionProjection.Summary( + fullCount: fullCount, + sliceCount: sliceCount, + codemapCount: codemapCount, + fullTokens: fullTokens, + sliceTokens: sliceTokens, + codemapTokens: codemapTokens + ) + let alternate = request.alternatePolicy.map { policy in + let alternateContentTokens = alternateCandidates.reduce(into: 0) { total, file in + if file.mode == .full || file.mode == .slice { + total += file.tokens + } + } + let alternateCodemapTokens = alternateCandidates.reduce(into: 0) { total, file in + if file.mode == .codemap { + total += file.tokens + } + } + let includedFiles: [WorkspaceSelectionProjection.IncludedFile] = if policy.includeFiles { + alternateCandidates + } else if policy.codeMapUsage == .none { + [] + } else { + normalizedFiles.filter { $0.mode == .codemap } + } + return WorkspaceSelectionProjection.Alternate( + codeMapUsage: policy.codeMapUsage, + includesFiles: policy.includeFiles, + contentTokens: alternateContentTokens, + codemapTokens: alternateCodemapTokens, + totalTokens: alternateContentTokens + alternateCodemapTokens, + includedTotalTokens: includedFiles.reduce(0) { $0 + $1.tokens }, + includedFiles: includedFiles + ) + } + + return WorkspaceSelectionProjection( + files: files, + normalizedFiles: normalizedFiles, + slices: slices, + summary: summary, + invalidPaths: request.missingPaths + request.invalidPaths, + codeMapUsage: request.codeMapUsage, + codemapAutoEnabled: request.codemapAutoEnabled, + alternate: alternate + ) + } + + private static func makeIncludedFile( + entry: WorkspaceSelectionProjectionRequest.Entry, + mode: WorkspaceSelectionProjection.RenderMode, + tokens: Int, + fullTokens: Int?, + codemapOrigin: WorkspaceSelectionProjection.CodemapOrigin? + ) -> WorkspaceSelectionProjection.IncludedFile { + WorkspaceSelectionProjection.IncludedFile( + file: entry.file, + metadata: entry.metadata, + mode: mode, + ranges: mode == .slice ? entry.ranges : nil, + tokens: tokens, + fullTokens: fullTokens, + codemapTokens: entry.tokens.codemapTokens, + codemapOrigin: codemapOrigin, + codemapContent: mode == .codemap ? entry.codemapContent : nil + ) + } + + private static func renderMode( + for mode: WorkspaceSelectionProjection.BaseMode + ) -> WorkspaceSelectionProjection.RenderMode { + switch mode { + case .full: + .full + case .slice: + .slice + case .codemap: + .codemap + } + } + + private static func codemapOrigin( + for mode: WorkspaceSelectionProjection.BaseMode, + codeMapUsage: CodeMapUsage, + codemapAutoEnabled: Bool + ) -> WorkspaceSelectionProjection.CodemapOrigin? { + guard mode == .codemap else { return nil } + switch codeMapUsage { + case .selected: + return .selectedMode + case .complete: + return .auto + case .auto: + return codemapAutoEnabled ? .auto : .manual + case .none: + return .manual + } + } + + private static func makeAlternateState( + for entry: WorkspaceSelectionProjectionRequest.Entry, + baseMode: WorkspaceSelectionProjection.RenderMode, + baseOrigin: WorkspaceSelectionProjection.CodemapOrigin?, + codeMapUsage: CodeMapUsage + ) -> AlternateState { + switch codeMapUsage { + case .auto: + return AlternateState( + mode: baseMode, + tokens: entry.tokens.displayTokens, + codemapOrigin: baseOrigin + ) + case .selected: + if baseMode == .codemap { + return AlternateState( + mode: .codemap, + tokens: entry.tokens.displayTokens, + codemapOrigin: baseOrigin + ) + } + if entry.codemapAvailable { + return AlternateState( + mode: .codemap, + tokens: entry.tokens.codemapTokens, + codemapOrigin: .selectedMode + ) + } + return AlternateState( + mode: baseMode, + tokens: entry.tokens.displayTokens, + codemapOrigin: baseOrigin + ) + case .complete: + if baseMode != .codemap, entry.codemapAvailable { + return AlternateState( + mode: .codemap, + tokens: entry.tokens.codemapTokens, + codemapOrigin: .completeMode + ) + } + return AlternateState( + mode: baseMode, + tokens: entry.tokens.displayTokens, + codemapOrigin: baseOrigin + ) + case .none: + if baseMode == .codemap { + return AlternateState(mode: .hidden, tokens: 0, codemapOrigin: nil) + } + return AlternateState( + mode: baseMode, + tokens: entry.tokens.displayTokens, + codemapOrigin: baseOrigin + ) + } + } +} diff --git a/Sources/RepoPromptCore/WorkspaceContext/Search/FileSearchContentSnapshot.swift b/Sources/RepoPromptCore/WorkspaceContext/Search/FileSearchContentSnapshot.swift new file mode 100644 index 000000000..9dcf80584 --- /dev/null +++ b/Sources/RepoPromptCore/WorkspaceContext/Search/FileSearchContentSnapshot.swift @@ -0,0 +1,28 @@ +import Foundation + +package enum FileContentFreshnessPolicy { + /// Trust existing metadata/cache fast paths. + case cachedMetadata + /// Validate disk metadata before trusting cached content; never return stale fallback on validation/load failure. + case validateDiskMetadata +} + +/// Snapshot of file content plus a stable in-memory revision for search cache identity. +package struct FileSearchContentSnapshot { + package let content: String? + package let contentRevision: UInt64? + package let modificationDate: Date + package let isFresh: Bool + + package init( + content: String?, + contentRevision: UInt64?, + modificationDate: Date, + isFresh: Bool + ) { + self.content = content + self.contentRevision = contentRevision + self.modificationDate = modificationDate + self.isFresh = isFresh + } +} diff --git a/Sources/RepoPrompt/Infrastructure/WorkspaceContext/Search/PathSearchIndex.swift b/Sources/RepoPromptCore/WorkspaceContext/Search/PathSearchIndex.swift similarity index 68% rename from Sources/RepoPrompt/Infrastructure/WorkspaceContext/Search/PathSearchIndex.swift rename to Sources/RepoPromptCore/WorkspaceContext/Search/PathSearchIndex.swift index 942e946e7..5212c4be9 100644 --- a/Sources/RepoPrompt/Infrastructure/WorkspaceContext/Search/PathSearchIndex.swift +++ b/Sources/RepoPromptCore/WorkspaceContext/Search/PathSearchIndex.swift @@ -1,32 +1,36 @@ import Foundation - -/// Define size_t for C interop -typealias size_t = Int +import RepoPromptC /// High-performance path search index using C implementation for binary search /// Optimized for >1k files with O(log n + k*m) search complexity -actor PathSearchIndex { +package actor PathSearchIndex { // MARK: - Types - struct Candidate { - let index: Int - let path: String - let filename: String + package struct Candidate { + package let index: Int + package let path: String + package let filename: String + + package init(index: Int, path: String, filename: String) { + self.index = index + self.path = path + self.filename = filename + } } // MARK: - Private State - private var cIndex: OpaquePointer? // path_search_index_t* + private var cIndex: UnsafeMutablePointer? private var originalPaths: [String] = [] private var filenames: [String] = [] // MARK: - Initialization - init(paths: [String]) async { + package init(paths: [String]) async { await rebuild(paths: paths) } - init() { + package init() { cIndex = nil } @@ -43,7 +47,7 @@ actor PathSearchIndex { /// - pattern: Search pattern (supports wildcards and regex) /// - limit: Maximum number of results to return /// - Returns: Array of matching candidates with indices - func search(_ pattern: String, limit: Int = 300) async -> [Candidate] { + package func search(_ pattern: String, limit: Int = 300) async -> [Candidate] { guard let index = cIndex else { return [] } // Call C implementation @@ -56,7 +60,7 @@ actor PathSearchIndex { // Convert C results to Swift candidates var candidates: [Candidate] = [] - let resultPtr = UnsafePointer(searchResult) + let resultPtr = UnsafePointer(searchResult) let count = Int(resultPtr.pointee.count) candidates.reserveCapacity(count) @@ -77,7 +81,7 @@ actor PathSearchIndex { } /// Rebuild the index with new paths - func rebuild(paths: [String]) async { + package func rebuild(paths: [String]) async { // Clean up old index if let oldIndex = cIndex { path_search_destroy(oldIndex) @@ -99,32 +103,32 @@ actor PathSearchIndex { } // Create C string array - let cPaths = paths.map { strdup($0) } + let cPaths = paths.map { repo_strdup($0) } defer { - cPaths.forEach { free($0) } + cPaths.forEach { repo_free($0) } } // Create index using C implementation - let cPathsPointers = cPaths.map { UnsafePointer($0) } - cPathsPointers.withUnsafeBufferPointer { buffer in + var cPathsPointers = cPaths.map { UnsafePointer($0) } + cPathsPointers.withUnsafeMutableBufferPointer { buffer in cIndex = path_search_create(buffer.baseAddress, paths.count) } } /// Get path at specific index - func path(at index: Int) -> String? { + package func path(at index: Int) -> String? { guard index >= 0, index < originalPaths.count else { return nil } return originalPaths[index] } /// Get filename at specific index - func filename(at index: Int) -> String? { + package func filename(at index: Int) -> String? { guard index >= 0, index < filenames.count else { return nil } return filenames[index] } /// Get total number of indexed paths - var count: Int { + package var count: Int { originalPaths.count } } @@ -132,7 +136,7 @@ actor PathSearchIndex { // MARK: - LRU Cache Actor /// Thread-safe LRU cache implementation using actors -actor LRUCacheActor { +package actor LRUCacheActor { private struct Entry { let value: Value var timestamp: Date @@ -141,11 +145,11 @@ actor LRUCacheActor { private var cache: [Key: Entry] = [:] private let capacity: Int - init(capacity: Int) { + package init(capacity: Int) { self.capacity = capacity } - func value(for key: Key) -> Value? { + package func value(for key: Key) -> Value? { if var entry = cache[key] { entry.timestamp = Date() cache[key] = entry @@ -154,7 +158,7 @@ actor LRUCacheActor { return nil } - func set(_ value: Value, for key: Key) { + package func set(_ value: Value, for key: Key) { cache[key] = Entry(value: value, timestamp: Date()) // Evict oldest if over capacity @@ -166,29 +170,7 @@ actor LRUCacheActor { } } - func clear() { + package func clear() { cache.removeAll() } } - -// MARK: - C Bridge Functions - -/// Import the C functions -@_silgen_name("path_search_create") -func path_search_create(_ paths: UnsafePointer?>?, _ count: Int) -> OpaquePointer? - -@_silgen_name("path_search_destroy") -func path_search_destroy(_ index: OpaquePointer?) - -@_silgen_name("path_search_find") -func path_search_find(_ index: OpaquePointer?, _ pattern: UnsafePointer?, _ limit: Int) -> OpaquePointer? - -@_silgen_name("search_result_destroy") -func search_result_destroy(_ result: OpaquePointer?) - -/// C struct definitions for bridging -struct search_result_t { - var indices: UnsafeMutablePointer? - var count: size_t - var capacity: size_t -} diff --git a/Sources/RepoPrompt/Infrastructure/WorkspaceContext/Search/RepoSearchBatchScorer.swift b/Sources/RepoPromptCore/WorkspaceContext/Search/RepoSearchBatchScorer.swift similarity index 87% rename from Sources/RepoPrompt/Infrastructure/WorkspaceContext/Search/RepoSearchBatchScorer.swift rename to Sources/RepoPromptCore/WorkspaceContext/Search/RepoSearchBatchScorer.swift index 697527d11..5e92bd7d1 100644 --- a/Sources/RepoPrompt/Infrastructure/WorkspaceContext/Search/RepoSearchBatchScorer.swift +++ b/Sources/RepoPromptCore/WorkspaceContext/Search/RepoSearchBatchScorer.swift @@ -1,14 +1,22 @@ import Foundation +import RepoPromptC -enum RepoSearchBatchScorer { - struct Candidate { - let name: String - let path: String - let nameLower: String - let pathLower: String +package enum RepoSearchBatchScorer { + package struct Candidate { + package let name: String + package let path: String + package let nameLower: String + package let pathLower: String + + package init(name: String, path: String, nameLower: String, pathLower: String) { + self.name = name + self.path = path + self.nameLower = nameLower + self.pathLower = pathLower + } } - static func scores( + package static func scores( for candidates: [Candidate], query: RepoSearchQuery, fuzzyThreshold: Double diff --git a/Sources/RepoPrompt/Infrastructure/WorkspaceContext/Search/RepoSearchQuery.swift b/Sources/RepoPromptCore/WorkspaceContext/Search/RepoSearchQuery.swift similarity index 79% rename from Sources/RepoPrompt/Infrastructure/WorkspaceContext/Search/RepoSearchQuery.swift rename to Sources/RepoPromptCore/WorkspaceContext/Search/RepoSearchQuery.swift index a9a2fc55b..c33fff80d 100644 --- a/Sources/RepoPrompt/Infrastructure/WorkspaceContext/Search/RepoSearchQuery.swift +++ b/Sources/RepoPromptCore/WorkspaceContext/Search/RepoSearchQuery.swift @@ -1,20 +1,20 @@ import Foundation -struct RepoSearchQuery: Equatable { - let raw: String - let lowered: String - let hasSlash: Bool - let isWildcard: Bool +package struct RepoSearchQuery: Equatable { + package let raw: String + package let lowered: String + package let hasSlash: Bool + package let isWildcard: Bool - var isEmpty: Bool { + package var isEmpty: Bool { raw.isEmpty } } -enum RepoSearchQueryFactory { +package enum RepoSearchQueryFactory { private static let defaultMaxLength = 1000 - static func make( + package static func make( _ input: String, maxLength: Int = defaultMaxLength, supportsWildcards: Bool = true diff --git a/Sources/RepoPrompt/Features/Search/SearchMatch.swift b/Sources/RepoPromptCore/WorkspaceContext/Search/SearchMatch.swift similarity index 91% rename from Sources/RepoPrompt/Features/Search/SearchMatch.swift rename to Sources/RepoPromptCore/WorkspaceContext/Search/SearchMatch.swift index 3eaedecb2..e76176865 100644 --- a/Sources/RepoPrompt/Features/Search/SearchMatch.swift +++ b/Sources/RepoPromptCore/WorkspaceContext/Search/SearchMatch.swift @@ -1,4 +1,5 @@ import Foundation +import RepoPromptC // Wildmatch flags for pattern matching private let WM_NOESCAPE: UInt32 = 0x01 @@ -62,14 +63,14 @@ private actor RegexCache { /// `lineNumber` is **0‑based** (the first line in the file is numbered `0`). /// MCP tool output converts to 1-based line numbers for display (`file_search`), /// while internal indexing stays 0-based for array operations. -struct SearchMatch: Hashable, Codable { - let filePath: String - let lineNumber: Int // 0‑based - let lineText: String // original text (no newline) - let contextBefore: [String]? // Lines before the match (when contextLines > 0) - let contextAfter: [String]? // Lines after the match (when contextLines > 0) - - init(filePath: String, lineNumber: Int, lineText: String, contextBefore: [String]? = nil, contextAfter: [String]? = nil) { +package struct SearchMatch: Hashable, Codable { + package let filePath: String + package let lineNumber: Int // 0‑based + package let lineText: String // original text (no newline) + package let contextBefore: [String]? // Lines before the match (when contextLines > 0) + package let contextAfter: [String]? // Lines after the match (when contextLines > 0) + + package init(filePath: String, lineNumber: Int, lineText: String, contextBefore: [String]? = nil, contextAfter: [String]? = nil) { self.filePath = filePath self.lineNumber = lineNumber self.lineText = lineText @@ -88,71 +89,97 @@ struct SearchMatch: Hashable, Codable { * `.content` – search **only** inside files. * `.both` – execute *both* path and content search stages. */ -enum SearchMode: String, Codable { +package enum SearchMode: String, Codable { case auto, path, content, both } /// Enhanced search options for fine-grained control -struct SearchOptions { - var mode: SearchMode = .auto - var caseInsensitive: Bool = true - var wholeWord: Bool = false - var includeExtensions: [String] = [] // e.g., [".js", ".ts", ".swift"] - var excludePatterns: [String] = [] // e.g., ["node_modules", ".git", "*.log"] - var contextLines: Int = 0 // Number of lines before/after match - var maxResults: Int = 250 - var countOnly: Bool = false - var fuzzySpaceMatching: Bool = true // Enable/disable fuzzy space matching - var allowLiteralUnescapeFallback: Bool = true // Helpful rescue for over-escaped literals in auto flows - var contentFreshnessPolicy: FileContentFreshnessPolicy = .cachedMetadata +package struct SearchOptions { + package var mode: SearchMode + package var caseInsensitive: Bool + package var wholeWord: Bool + package var includeExtensions: [String] // e.g., [".js", ".ts", ".swift"] + package var excludePatterns: [String] // e.g., ["node_modules", ".git", "*.log"] + package var contextLines: Int // Number of lines before/after match + package var maxResults: Int + package var countOnly: Bool + package var fuzzySpaceMatching: Bool // Enable/disable fuzzy space matching + package var allowLiteralUnescapeFallback: Bool // Helpful rescue for over-escaped literals in auto flows + package var contentFreshnessPolicy: FileContentFreshnessPolicy + + package init( + mode: SearchMode = .auto, + caseInsensitive: Bool = true, + wholeWord: Bool = false, + includeExtensions: [String] = [], + excludePatterns: [String] = [], + contextLines: Int = 0, + maxResults: Int = 250, + countOnly: Bool = false, + fuzzySpaceMatching: Bool = true, + allowLiteralUnescapeFallback: Bool = true, + contentFreshnessPolicy: FileContentFreshnessPolicy = .cachedMetadata + ) { + self.mode = mode + self.caseInsensitive = caseInsensitive + self.wholeWord = wholeWord + self.includeExtensions = includeExtensions + self.excludePatterns = excludePatterns + self.contextLines = contextLines + self.maxResults = maxResults + self.countOnly = countOnly + self.fuzzySpaceMatching = fuzzySpaceMatching + self.allowLiteralUnescapeFallback = allowLiteralUnescapeFallback + self.contentFreshnessPolicy = contentFreshnessPolicy + } } /// Codable wrapper for regex pattern errors -struct PatternErrorInfo: Codable { - let errorType: String - let description: String +package struct PatternErrorInfo: Codable { + package let errorType: String + package let description: String - init(_ error: RegexPatternFailure) { + package init(_ error: RegexPatternFailure) { errorType = String(reflecting: Swift.type(of: error)) description = error.localizedDescription } } /// Codable wrapper for per-file errors -struct PerFileError: Codable { - let filePath: String - let error: PatternErrorInfo +package struct PerFileError: Codable { + package let filePath: String + package let error: PatternErrorInfo - init(filePath: String, error: RegexPatternFailure) { + package init(filePath: String, error: RegexPatternFailure) { self.filePath = filePath self.error = PatternErrorInfo(error) } } /// Result returned by the new `FileSearchActor.searchUnified`. -struct SearchResults: Codable { +package struct SearchResults: Codable { /// Absolute file paths whose *path* matched the pattern (may be omitted). - var paths: [String]? + package var paths: [String]? /// Individual in-file hits (same payload as `SearchMatch`, may be omitted). - var matches: [SearchMatch]? + package var matches: [SearchMatch]? /// Number of files that contained content matches (optional when count-only) - var contentFileCount: Int? + package var contentFileCount: Int? /// Total count of matches (useful for count-only mode) - var totalCount: Int? + package var totalCount: Int? /// Number of files actually searched after all filters are applied. - var searchedFileCount: Int? + package var searchedFileCount: Int? /// Number of files admitted by the path scope before extension/exclude filtering. - var scopedFileCount: Int? + package var scopedFileCount: Int? /// Error that occurred during path search phase - var pathError: PatternErrorInfo? + package var pathError: PatternErrorInfo? /// Error that occurred during content search phase - var contentError: PatternErrorInfo? + package var contentError: PatternErrorInfo? /// Per-file errors that occurred during scanning - var perFileErrors: [PerFileError]? + package var perFileErrors: [PerFileError]? /// Optional warning surfaced when the requested pattern was implicitly repaired. - var warningMessage: String? + package var warningMessage: String? - init( + package init( paths: [String] = [], matches: [SearchMatch] = [], contentFileCount: Int? = nil, @@ -420,32 +447,40 @@ private struct SearchScanPlan { let contentFreshnessPolicy: FileContentFreshnessPolicy } -private struct SearchFileDescriptor { - let id: UUID - let name: String - let relativePath: String - let standardizedRelativePath: String - let fullPath: String - let standardizedFullPath: String - let standardizedRootFolderPath: String - let fileExtension: String? - let contentSnapshot: (FileContentFreshnessPolicy) async throws -> FileSearchContentSnapshot - - init(file: FileViewModel) { - id = file.id - name = file.name - relativePath = file.relativePath - standardizedRelativePath = file.standardizedRelativePath - fullPath = file.fullPath - standardizedFullPath = file.standardizedFullPath - standardizedRootFolderPath = file.standardizedRootFolderPath - fileExtension = file.fileExtension - contentSnapshot = { policy in - await file.searchContentSnapshot(freshnessPolicy: policy) - } - } - - init( +package struct SearchFileDescriptor { + package let id: UUID + package let name: String + package let relativePath: String + package let standardizedRelativePath: String + package let fullPath: String + package let standardizedFullPath: String + package let standardizedRootFolderPath: String + package let fileExtension: String? + package let contentSnapshot: (FileContentFreshnessPolicy) async throws -> FileSearchContentSnapshot + + package init( + id: UUID, + name: String, + relativePath: String, + standardizedRelativePath: String, + fullPath: String, + standardizedFullPath: String, + standardizedRootFolderPath: String, + fileExtension: String?, + contentSnapshot: @escaping (FileContentFreshnessPolicy) async throws -> FileSearchContentSnapshot + ) { + self.id = id + self.name = name + self.relativePath = relativePath + self.standardizedRelativePath = standardizedRelativePath + self.fullPath = fullPath + self.standardizedFullPath = standardizedFullPath + self.standardizedRootFolderPath = standardizedRootFolderPath + self.fileExtension = fileExtension + self.contentSnapshot = contentSnapshot + } + + package init( record: WorkspaceFileRecord, rootPath: String, store: WorkspaceFileContextStore @@ -468,19 +503,19 @@ private struct SearchFileDescriptor { case .cachedMetadata: "cachedMetadata" } - let freshnessState = EditFlowPerf.begin( - EditFlowPerf.Stage.Search.contentFreshnessValidation, - EditFlowPerf.Dimensions( + let freshnessState = WorkspaceRuntimePerf.begin( + WorkspaceRuntimePerf.Stage.Search.contentFreshnessValidation, + WorkspaceRuntimePerf.Dimensions( contentSource: "storeSnapshot", freshnessPolicy: freshnessPolicy ) ) var outcome = "error" defer { - EditFlowPerf.end( - EditFlowPerf.Stage.Search.contentFreshnessValidation, + WorkspaceRuntimePerf.end( + WorkspaceRuntimePerf.Stage.Search.contentFreshnessValidation, freshnessState, - EditFlowPerf.Dimensions( + WorkspaceRuntimePerf.Dimensions( outcome: outcome, contentSource: "storeSnapshot", freshnessPolicy: freshnessPolicy @@ -572,7 +607,9 @@ struct OrderedSearchBatchWindow { } /// Ripgrep-style asynchronous searcher, fully cancellable. -actor FileSearchActor { +package actor FileSearchActor { + package init() {} + private static func descriptors( for files: [WorkspaceFileRecord], rootsByID: [UUID: WorkspaceRootRecord], @@ -881,9 +918,9 @@ actor FileSearchActor { return .versioned(fileID: file.id, contentRevision: contentRevision, utf16Length: utf16Length) } - return EditFlowPerf.measure( - EditFlowPerf.Stage.Search.lineIndexCacheKey, - EditFlowPerf.Dimensions(fileBytes: content.utf8.count, scanKind: "hash-fallback") + return WorkspaceRuntimePerf.measure( + WorkspaceRuntimePerf.Stage.Search.lineIndexCacheKey, + WorkspaceRuntimePerf.Dimensions(fileBytes: content.utf8.count, scanKind: "hash-fallback") ) { .hashed(filePath: file.fullPath, utf16Length: utf16Length, hash: content.fnv1a64()) } @@ -905,15 +942,15 @@ actor FileSearchActor { cacheIdentity: SearchLineIndexCacheIdentity ) -> SearchDocumentBuildResult { let key = lineIndexCacheKey(identity: cacheIdentity) - let lookupState = EditFlowPerf.begin( - EditFlowPerf.Stage.Search.lineIndexLookup, - EditFlowPerf.Dimensions(fileBytes: content.utf8.count, scanKind: cacheIdentity.scanKind) + let lookupState = WorkspaceRuntimePerf.begin( + WorkspaceRuntimePerf.Stage.Search.lineIndexLookup, + WorkspaceRuntimePerf.Dimensions(fileBytes: content.utf8.count, scanKind: cacheIdentity.scanKind) ) if let cached = lineIndexCache.object(forKey: key) { - EditFlowPerf.end( - EditFlowPerf.Stage.Search.lineIndexLookup, + WorkspaceRuntimePerf.end( + WorkspaceRuntimePerf.Stage.Search.lineIndexLookup, lookupState, - EditFlowPerf.Dimensions( + WorkspaceRuntimePerf.Dimensions( fileBytes: content.utf8.count, lineCount: cached.lineCount, scanKind: cacheIdentity.scanKind, @@ -924,21 +961,21 @@ actor FileSearchActor { document: SearchDocument(filePath: filePath, text: content, lineIndex: cached.lineIndex, contextLines: contextLines) ) } - EditFlowPerf.end( - EditFlowPerf.Stage.Search.lineIndexLookup, + WorkspaceRuntimePerf.end( + WorkspaceRuntimePerf.Stage.Search.lineIndexLookup, lookupState, - EditFlowPerf.Dimensions(fileBytes: content.utf8.count, scanKind: cacheIdentity.scanKind, cacheHit: false) + WorkspaceRuntimePerf.Dimensions(fileBytes: content.utf8.count, scanKind: cacheIdentity.scanKind, cacheHit: false) ) - let buildState = EditFlowPerf.begin( - EditFlowPerf.Stage.Search.lineIndexBuild, - EditFlowPerf.Dimensions(fileBytes: content.utf8.count, scanKind: cacheIdentity.scanKind) + let buildState = WorkspaceRuntimePerf.begin( + WorkspaceRuntimePerf.Stage.Search.lineIndexBuild, + WorkspaceRuntimePerf.Dimensions(fileBytes: content.utf8.count, scanKind: cacheIdentity.scanKind) ) let lineIndex = SearchLineIndex(content: content) - EditFlowPerf.end( - EditFlowPerf.Stage.Search.lineIndexBuild, + WorkspaceRuntimePerf.end( + WorkspaceRuntimePerf.Stage.Search.lineIndexBuild, buildState, - EditFlowPerf.Dimensions( + WorkspaceRuntimePerf.Dimensions( fileBytes: content.utf8.count, lineCount: lineIndex.lineRanges.count, scanKind: cacheIdentity.scanKind @@ -956,9 +993,9 @@ actor FileSearchActor { ) -> [SearchMatch] { guard remaining > 0, let document = batch.document else { return [] } let matchLimit = min(remaining, batch.summary.hits.count) - return EditFlowPerf.measure( - EditFlowPerf.Stage.Search.materializeMatches, - EditFlowPerf.Dimensions( + return WorkspaceRuntimePerf.measure( + WorkspaceRuntimePerf.Stage.Search.materializeMatches, + WorkspaceRuntimePerf.Dimensions( lineCount: document.lineRanges.count, matchCount: matchLimit, contextLines: document.contextLines @@ -970,13 +1007,12 @@ actor FileSearchActor { // MARK: - Public entry points ---------------------------------------------- - /// Enhanced search with full options support and auto-correction reporting - func search( + package func search( pattern: String, isRegex: Bool = false, wasAutoCorrected: inout Bool?, options: SearchOptions = SearchOptions(), - in files: [FileViewModel] + in files: [SearchFileDescriptor] ) async throws -> [SearchMatch] { var materializingOptions = options materializingOptions.countOnly = false @@ -985,7 +1021,7 @@ actor FileSearchActor { isRegex: isRegex, wasAutoCorrected: &wasAutoCorrected, options: materializingOptions, - in: files.map(SearchFileDescriptor.init(file:)) + in: files ) return result.matches } @@ -1150,23 +1186,6 @@ actor FileSearchActor { return primary } - /// Enhanced search with full options support (backward compatibility) - func search( - pattern: String, - isRegex: Bool = false, - options: SearchOptions = SearchOptions(), - in files: [FileViewModel] - ) async throws -> [SearchMatch] { - var autoCorrected: Bool? = nil - return try await search( - pattern: pattern, - isRegex: isRegex, - wasAutoCorrected: &autoCorrected, - options: options, - in: files - ) - } - func search( pattern: String, isRegex: Bool = false, @@ -1325,9 +1344,9 @@ actor FileSearchActor { ) let batches = Self.makeContentBatches(entries, batchSize: contentBatchSize) let scanKind = Self.scanKind(for: plan) - let contentScanState = EditFlowPerf.begin( - EditFlowPerf.Stage.Search.contentScanTotal, - EditFlowPerf.Dimensions( + let contentScanState = WorkspaceRuntimePerf.begin( + WorkspaceRuntimePerf.Stage.Search.contentScanTotal, + WorkspaceRuntimePerf.Dimensions( taskCount: batches.count, workerCount: Self.maxConcurrentTasks, admittedFileCount: files.count, @@ -1350,10 +1369,10 @@ actor FileSearchActor { var matchedFileCount = 0 var perFileErrors: [(String, RegexPatternFailure)] = [] defer { - EditFlowPerf.end( - EditFlowPerf.Stage.Search.contentScanTotal, + WorkspaceRuntimePerf.end( + WorkspaceRuntimePerf.Stage.Search.contentScanTotal, contentScanState, - EditFlowPerf.Dimensions( + WorkspaceRuntimePerf.Dimensions( outcome: contentScanOutcome, matchCount: totalCount, taskCount: batches.count, @@ -1478,9 +1497,9 @@ actor FileSearchActor { plan: SearchScanPlan ) async throws -> SearchContentBatchResult { let batchSize = batch.range.count - let perfState = EditFlowPerf.begin( - EditFlowPerf.Stage.Search.contentBatch, - EditFlowPerf.Dimensions( + let perfState = WorkspaceRuntimePerf.begin( + WorkspaceRuntimePerf.Stage.Search.contentBatch, + WorkspaceRuntimePerf.Dimensions( workerCount: Self.maxConcurrentTasks, scanKind: scanKind(for: plan), batchSize: batchSize, @@ -1494,10 +1513,10 @@ actor FileSearchActor { var matchCount = 0 var scannedFileCount = 0 defer { - EditFlowPerf.end( - EditFlowPerf.Stage.Search.contentBatch, + WorkspaceRuntimePerf.end( + WorkspaceRuntimePerf.Stage.Search.contentBatch, perfState, - EditFlowPerf.Dimensions( + WorkspaceRuntimePerf.Dimensions( matchCount: matchCount, workerCount: Self.maxConcurrentTasks, scannedFileCount: scannedFileCount, @@ -1539,9 +1558,9 @@ actor FileSearchActor { errors: [] ) } - let snapshot = try await EditFlowPerf.measure( - EditFlowPerf.Stage.Search.fileContentFetch, - EditFlowPerf.Dimensions( + let snapshot = try await WorkspaceRuntimePerf.measure( + WorkspaceRuntimePerf.Stage.Search.fileContentFetch, + WorkspaceRuntimePerf.Dimensions( scanKind: scanKind(for: plan), isRegex: plan.engine != nil, countOnly: plan.countOnly, @@ -1570,9 +1589,9 @@ actor FileSearchActor { } if plan.countOnly { - let fastPathState = EditFlowPerf.begin( - EditFlowPerf.Stage.Search.countOnlyFastPath, - EditFlowPerf.Dimensions( + let fastPathState = WorkspaceRuntimePerf.begin( + WorkspaceRuntimePerf.Stage.Search.countOnlyFastPath, + WorkspaceRuntimePerf.Dimensions( fileBytes: text.utf8.count, scanKind: scanKind(for: plan), isRegex: plan.engine != nil, @@ -1580,10 +1599,10 @@ actor FileSearchActor { ) ) if let summary = try Self.scanCountOnlyFastPath(plan: plan, text: text) { - EditFlowPerf.end( - EditFlowPerf.Stage.Search.countOnlyFastPath, + WorkspaceRuntimePerf.end( + WorkspaceRuntimePerf.Stage.Search.countOnlyFastPath, fastPathState, - EditFlowPerf.Dimensions( + WorkspaceRuntimePerf.Dimensions( status: "hit", fileBytes: text.utf8.count, matchCount: summary.lineMatchCount, @@ -1599,10 +1618,10 @@ actor FileSearchActor { errors: [] ) } - EditFlowPerf.end( - EditFlowPerf.Stage.Search.countOnlyFastPath, + WorkspaceRuntimePerf.end( + WorkspaceRuntimePerf.Stage.Search.countOnlyFastPath, fastPathState, - EditFlowPerf.Dimensions( + WorkspaceRuntimePerf.Dimensions( status: "miss", fileBytes: text.utf8.count, scanKind: scanKind(for: plan), @@ -1957,9 +1976,9 @@ actor FileSearchActor { countOnly: Bool = false, maxCollectedMatches: Int? = nil ) throws -> SearchScanSummary { - let perfState = EditFlowPerf.begin( - EditFlowPerf.Stage.Search.literalScan, - EditFlowPerf.Dimensions( + let perfState = WorkspaceRuntimePerf.begin( + WorkspaceRuntimePerf.Stage.Search.literalScan, + WorkspaceRuntimePerf.Dimensions( fileBytes: document.text.utf8.count, lineCount: document.lineRanges.count, scanKind: fuzzySpaceMatching && needle.contains(" ") ? "literal-fuzzy" : "literal", @@ -1971,10 +1990,10 @@ actor FileSearchActor { ) var perfMatchCount: Int? defer { - EditFlowPerf.end( - EditFlowPerf.Stage.Search.literalScan, + WorkspaceRuntimePerf.end( + WorkspaceRuntimePerf.Stage.Search.literalScan, perfState, - EditFlowPerf.Dimensions( + WorkspaceRuntimePerf.Dimensions( fileBytes: document.text.utf8.count, lineCount: document.lineRanges.count, matchCount: perfMatchCount, @@ -2097,9 +2116,9 @@ actor FileSearchActor { countOnly: Bool, maxCollectedMatches: Int? = nil ) throws -> SearchScanSummary { - let perfState = EditFlowPerf.begin( - EditFlowPerf.Stage.Search.regexFullBufferScan, - EditFlowPerf.Dimensions( + let perfState = WorkspaceRuntimePerf.begin( + WorkspaceRuntimePerf.Stage.Search.regexFullBufferScan, + WorkspaceRuntimePerf.Dimensions( fileBytes: document.text.utf8.count, lineCount: document.lineRanges.count, scanKind: "regex-full-buffer", @@ -2109,10 +2128,10 @@ actor FileSearchActor { ) var perfMatchCount: Int? defer { - EditFlowPerf.end( - EditFlowPerf.Stage.Search.regexFullBufferScan, + WorkspaceRuntimePerf.end( + WorkspaceRuntimePerf.Stage.Search.regexFullBufferScan, perfState, - EditFlowPerf.Dimensions( + WorkspaceRuntimePerf.Dimensions( fileBytes: document.text.utf8.count, lineCount: document.lineRanges.count, matchCount: perfMatchCount, @@ -2171,9 +2190,9 @@ actor FileSearchActor { highRisk: Bool = false, linePrefilter: PCRE2LinePrefilter? = nil ) throws -> SearchScanSummary { - let perfState = EditFlowPerf.begin( - EditFlowPerf.Stage.Search.regexLineByLineScan, - EditFlowPerf.Dimensions( + let perfState = WorkspaceRuntimePerf.begin( + WorkspaceRuntimePerf.Stage.Search.regexLineByLineScan, + WorkspaceRuntimePerf.Dimensions( fileBytes: document.text.utf8.count, lineCount: document.lineRanges.count, scanKind: highRisk ? "regex-line-high-risk" : "regex-line", @@ -2183,10 +2202,10 @@ actor FileSearchActor { ) var perfMatchCount: Int? defer { - EditFlowPerf.end( - EditFlowPerf.Stage.Search.regexLineByLineScan, + WorkspaceRuntimePerf.end( + WorkspaceRuntimePerf.Stage.Search.regexLineByLineScan, perfState, - EditFlowPerf.Dimensions( + WorkspaceRuntimePerf.Dimensions( fileBytes: document.text.utf8.count, lineCount: document.lineRanges.count, matchCount: perfMatchCount, @@ -2232,24 +2251,6 @@ actor FileSearchActor { /// downstream identity, deduplication, and display formatting via `mcpDisplayPath`. /// /// The work is fanned-out in parallel – similar to the grep implementation. - func searchPaths( - pattern: String, - limit: Int = 100, - in files: [FileViewModel], - caseInsensitive: Bool = true, - isRegex: Bool = false, // ← NEW - aliasByRootPath: [String: String]? = nil - ) async throws -> [String] { - try await searchPaths( - pattern: pattern, - limit: limit, - in: files.map(SearchFileDescriptor.init(file:)), - caseInsensitive: caseInsensitive, - isRegex: isRegex, - aliasByRootPath: aliasByRootPath - ) - } - func searchPaths( pattern: String, limit: Int = 100, @@ -2274,7 +2275,7 @@ actor FileSearchActor { ) } - private func searchPaths( + package func searchPaths( pattern: String, limit: Int = 100, in files: [SearchFileDescriptor], @@ -2400,9 +2401,9 @@ actor FileSearchActor { let batchSize = batch.range.count var hits: [(ordinal: Int, path: String)] = [] hits.reserveCapacity(min(batchSize, 8)) - let perfState = EditFlowPerf.begin( - EditFlowPerf.Stage.Search.pathBatch, - EditFlowPerf.Dimensions( + let perfState = WorkspaceRuntimePerf.begin( + WorkspaceRuntimePerf.Stage.Search.pathBatch, + WorkspaceRuntimePerf.Dimensions( scanKind: pathScanKind(for: plan), batchSize: batchSize, isRegex: plan.isRegex, @@ -2410,10 +2411,10 @@ actor FileSearchActor { ) ) defer { - EditFlowPerf.end( - EditFlowPerf.Stage.Search.pathBatch, + WorkspaceRuntimePerf.end( + WorkspaceRuntimePerf.Stage.Search.pathBatch, perfState, - EditFlowPerf.Dimensions( + WorkspaceRuntimePerf.Dimensions( matchCount: hits.count, scanKind: pathScanKind(for: plan), batchSize: batchSize, @@ -2548,25 +2549,6 @@ actor FileSearchActor { // MARK: – NEW unified search entry point –––––––––––––––––––––––––––– - /// Unified search combining path and content search with full SearchOptions support and auto-correction reporting - func searchUnified( - pattern: String, - isRegex: Bool = false, - wasAutoCorrected: inout Bool?, - options: SearchOptions = SearchOptions(), - in files: [FileViewModel], - aliasByRootPath: [String: String]? = nil - ) async throws -> SearchResults { - try await searchUnified( - pattern: pattern, - isRegex: isRegex, - wasAutoCorrected: &wasAutoCorrected, - options: options, - in: files.map(SearchFileDescriptor.init(file:)), - aliasByRootPath: aliasByRootPath - ) - } - func searchUnified( pattern: String, isRegex: Bool = false, @@ -2591,7 +2573,7 @@ actor FileSearchActor { ) } - private func searchUnified( + package func searchUnified( pattern: String, isRegex: Bool = false, wasAutoCorrected: inout Bool?, @@ -2612,9 +2594,9 @@ actor FileSearchActor { if options.mode != .auto { return options.mode } return Self.inferMode(pattern) }() - let perfState = EditFlowPerf.begin( - EditFlowPerf.Stage.Search.actorSearchUnified, - EditFlowPerf.Dimensions( + let perfState = WorkspaceRuntimePerf.begin( + WorkspaceRuntimePerf.Stage.Search.actorSearchUnified, + WorkspaceRuntimePerf.Dimensions( searchMode: effectiveMode.rawValue, fileCount: filteredFiles.count, maxResults: options.maxResults, @@ -2628,10 +2610,10 @@ actor FileSearchActor { var perfStatus = "ok" var perfMatchCount: Int? defer { - EditFlowPerf.end( - EditFlowPerf.Stage.Search.actorSearchUnified, + WorkspaceRuntimePerf.end( + WorkspaceRuntimePerf.Stage.Search.actorSearchUnified, perfState, - EditFlowPerf.Dimensions( + WorkspaceRuntimePerf.Dimensions( status: perfStatus, matchCount: perfMatchCount, searchMode: effectiveMode.rawValue, @@ -2736,9 +2718,9 @@ actor FileSearchActor { } perfMatchCount = totalCount ?? (pathHits.count + contentHits.count) - let resultConstructionState = EditFlowPerf.begin( - EditFlowPerf.Stage.Search.resultConstruction, - EditFlowPerf.Dimensions( + let resultConstructionState = WorkspaceRuntimePerf.begin( + WorkspaceRuntimePerf.Stage.Search.resultConstruction, + WorkspaceRuntimePerf.Dimensions( matchCount: perfMatchCount, admittedFileCount: searchedFileCount, matchedFileCount: contentFileCount, @@ -2759,10 +2741,10 @@ actor FileSearchActor { contentError: contentError, perFileErrors: perFileErrors ) - EditFlowPerf.end( - EditFlowPerf.Stage.Search.resultConstruction, + WorkspaceRuntimePerf.end( + WorkspaceRuntimePerf.Stage.Search.resultConstruction, resultConstructionState, - EditFlowPerf.Dimensions( + WorkspaceRuntimePerf.Dimensions( outcome: "completed", matchCount: perfMatchCount, admittedFileCount: searchedFileCount, @@ -2777,27 +2759,6 @@ actor FileSearchActor { return results } - /// Unified search combining path and content search with full SearchOptions support (backward compatibility) - func searchUnified( - pattern: String, - isRegex: Bool = false, - options: SearchOptions = SearchOptions(), - in files: [FileViewModel], - aliasByRootPath: [String: String]? = nil - ) async throws -> SearchResults { - // Entry point for MCP tool integration - - var autoCorrected: Bool? = nil - return try await searchUnified( - pattern: pattern, - isRegex: isRegex, - wasAutoCorrected: &autoCorrected, - options: options, - in: files, - aliasByRootPath: aliasByRootPath - ) - } - func searchUnified( pattern: String, isRegex: Bool = false, @@ -2822,7 +2783,7 @@ actor FileSearchActor { // MARK: – Helper to choose automatic mode –––––––––––––––––––––––– - static func inferredAutoMode(_ raw: String) -> SearchMode { + package static func inferredAutoMode(_ raw: String) -> SearchMode { // Quick heuristics (order matters) - designed for intuitive user experience // REGEX PATTERNS should search content, not paths @@ -2933,7 +2894,7 @@ actor FileSearchActor { // MARK: - Helper to detect regex patterns ------------------------------ /// Detects if a pattern contains regex syntax that should trigger regex mode - static func containsRegexSyntax(_ pattern: String) -> Bool { + package static func containsRegexSyntax(_ pattern: String) -> Bool { if RegexToolkit.usesPCREOnlyFeatures(pattern) { return true } diff --git a/Sources/RepoPrompt/Features/Search/SearchPathFiltering.swift b/Sources/RepoPromptCore/WorkspaceContext/Search/SearchPathFiltering.swift similarity index 84% rename from Sources/RepoPrompt/Features/Search/SearchPathFiltering.swift rename to Sources/RepoPromptCore/WorkspaceContext/Search/SearchPathFiltering.swift index 603a831ee..2e9456501 100644 --- a/Sources/RepoPrompt/Features/Search/SearchPathFiltering.swift +++ b/Sources/RepoPromptCore/WorkspaceContext/Search/SearchPathFiltering.swift @@ -1,38 +1,56 @@ import Foundation +import RepoPromptC -enum SearchPathClause: Equatable { +package enum SearchPathClause: Equatable { case exactFile(absPath: String, relPath: String, restrictedRootPath: String?) case exactFolder(absLower: String, relLower: String, restrictedRootPath: String?) case glob(pattern: String, restrictedRootPath: String?) case legacyPrefix(candidateLower: String) } -struct SearchPathFilterSpec: Equatable { - let caseInsensitive: Bool - let clauses: [SearchPathClause] +package struct SearchPathFilterSpec: Equatable { + package let caseInsensitive: Bool + package let clauses: [SearchPathClause] + + package init(caseInsensitive: Bool, clauses: [SearchPathClause]) { + self.caseInsensitive = caseInsensitive + self.clauses = clauses + } } -struct FileSearchPathSnapshot { - let standardizedFullPath: String - let standardizedRelativePath: String - let standardizedRootPath: String - let clientDisplayPath: String +package struct FileSearchPathSnapshot { + package let standardizedFullPath: String + package let standardizedRelativePath: String + package let standardizedRootPath: String + package let clientDisplayPath: String + + package init( + standardizedFullPath: String, + standardizedRelativePath: String, + standardizedRootPath: String, + clientDisplayPath: String + ) { + self.standardizedFullPath = standardizedFullPath + self.standardizedRelativePath = standardizedRelativePath + self.standardizedRootPath = standardizedRootPath + self.clientDisplayPath = clientDisplayPath + } } -struct FileSearchPathFilterResult: Equatable { - let matchedFullPaths: [String] - let visitedSnapshotCount: Int - let cancelled: Bool +package struct FileSearchPathFilterResult: Equatable { + package let matchedFullPaths: [String] + package let visitedSnapshotCount: Int + package let cancelled: Bool } /// Index-returning variant of `FileSearchPathFilterResult`. `matchedSnapshotIndices` /// holds indices into the input `snapshots` array, in snapshot iteration order, with /// each snapshot appearing at most once. Lets callers map matches directly back to /// their source array without a full-path string round trip. -struct FileSearchPathIndexFilterResult: Equatable { - let matchedSnapshotIndices: [Int] - let visitedSnapshotCount: Int - let cancelled: Bool +package struct FileSearchPathIndexFilterResult: Equatable { + package let matchedSnapshotIndices: [Int] + package let visitedSnapshotCount: Int + package let cancelled: Bool } @inline(__always) @@ -84,14 +102,14 @@ private func compileSearchPathClauses(_ clauses: [SearchPathClause]) -> [Compile } } -func filterPaths( +package func filterPaths( snapshots: [FileSearchPathSnapshot], spec: SearchPathFilterSpec ) -> [String] { filterPathsResult(snapshots: snapshots, spec: spec).matchedFullPaths } -func filterPathsResult( +package func filterPathsResult( snapshots: [FileSearchPathSnapshot], spec: SearchPathFilterSpec ) -> FileSearchPathFilterResult { @@ -112,7 +130,7 @@ func filterPathsResult( /// deduplicated) plus visited/cancellation metadata. Lowercase path variants are /// computed lazily per snapshot so exact-file and glob clauses — which never need /// them — do not pay for lowercasing. -func filterPathIndicesResult( +package func filterPathIndicesResult( snapshots: [FileSearchPathSnapshot], spec: SearchPathFilterSpec ) -> FileSearchPathIndexFilterResult { @@ -223,21 +241,21 @@ func filterPathIndicesResult( private let folderSuffixSlashTrim = CharacterSet(charactersIn: "/") @inline(__always) -func normalizedFolderSuffixFragment(_ fragment: String, caseInsensitive: Bool = true) -> String? { +package func normalizedFolderSuffixFragment(_ fragment: String, caseInsensitive: Bool = true) -> String? { let standardized = (fragment as NSString).standardizingPath as String let trimmed = standardized.trimmingCharacters(in: folderSuffixSlashTrim) guard !trimmed.isEmpty else { return nil } return caseInsensitive ? trimmed.lowercased() : trimmed } -struct SearchFolderSuffixIndexEntry { - let folder: T - let normalizedRelativePath: String +package struct SearchFolderSuffixIndexEntry { + package let folder: T + package let normalizedRelativePath: String } -typealias SearchFolderSuffixIndex = [String: [SearchFolderSuffixIndexEntry]] +package typealias SearchFolderSuffixIndex = [String: [SearchFolderSuffixIndexEntry]] -func buildFolderSuffixIndex( +package func buildFolderSuffixIndex( in foldersByFullPath: [String: T], relativePath: (T) -> String, caseInsensitive: Bool = true @@ -261,7 +279,7 @@ func buildFolderSuffixIndex( return index } -func resolveFoldersBySuffixFragment( +package func resolveFoldersBySuffixFragment( _ fragment: String, using suffixIndex: SearchFolderSuffixIndex, caseInsensitive: Bool = true @@ -287,7 +305,7 @@ func resolveFoldersBySuffixFragment( return out } -func resolveFoldersBySuffixFragment( +package func resolveFoldersBySuffixFragment( _ fragment: String, in foldersByFullPath: [String: T], relativePath: (T) -> String, diff --git a/Sources/RepoPrompt/Features/Search/StoreBackedWorkspaceSearch.swift b/Sources/RepoPromptCore/WorkspaceContext/Search/StoreBackedWorkspaceSearch.swift similarity index 82% rename from Sources/RepoPrompt/Features/Search/StoreBackedWorkspaceSearch.swift rename to Sources/RepoPromptCore/WorkspaceContext/Search/StoreBackedWorkspaceSearch.swift index 174fc7da5..7f28ba32b 100644 --- a/Sources/RepoPrompt/Features/Search/StoreBackedWorkspaceSearch.swift +++ b/Sources/RepoPromptCore/WorkspaceContext/Search/StoreBackedWorkspaceSearch.swift @@ -1,17 +1,17 @@ import Foundation -enum StoreBackedWorkspaceSearchError: LocalizedError, Equatable { +package enum StoreBackedWorkspaceSearchError: LocalizedError, Equatable { case worktreeScopeUnavailable(missingPhysicalRootPaths: [String]) - var retryAfterMilliseconds: Int { + package var retryAfterMilliseconds: Int { 1000 } - var suggestion: String { + package var suggestion: String { "Retry after the suggested delay. If the worktree remains unavailable, restore it or rebind the Agent session to an available worktree." } - var errorDescription: String? { + package var errorDescription: String? { switch self { case let .worktreeScopeUnavailable(missingPhysicalRootPaths): let count = missingPhysicalRootPaths.count @@ -23,10 +23,10 @@ enum StoreBackedWorkspaceSearchError: LocalizedError, Equatable { /// Store-backed runtime search facade for MCP and other non-UI consumers. /// -/// This intentionally works from `WorkspaceFileContextStore` catalog snapshots rather than -/// `WorkspaceFilesViewModel` tree projections. -enum StoreBackedWorkspaceSearch { - static func search( +/// This intentionally works from `WorkspaceFileContextStore` catalog snapshots and +/// `WorkspaceSearchService` readiness/index state rather than UI tree projections. +package enum StoreBackedWorkspaceSearch { + package static func search( pattern: String, mode: SearchMode = .auto, isRegex: Bool = false, @@ -43,15 +43,56 @@ enum StoreBackedWorkspaceSearch { allowLiteralUnescapeFallback: Bool = true, rootScope: WorkspaceLookupRootScope = .allLoaded, store: WorkspaceFileContextStore, - workspaceManager: WorkspaceManagerViewModel? + searchService _: WorkspaceSearchService, + readinessSource: (any WorkspaceSearchReadinessSource)? + ) async throws -> SearchResults { + try await search( + pattern: pattern, + mode: mode, + isRegex: isRegex, + caseInsensitive: caseInsensitive, + maxPaths: maxPaths, + maxMatches: maxMatches, + paths: paths, + includeExtensions: includeExtensions, + excludePatterns: excludePatterns, + contextLines: contextLines, + wholeWord: wholeWord, + countOnly: countOnly, + fuzzySpaceMatching: fuzzySpaceMatching, + allowLiteralUnescapeFallback: allowLiteralUnescapeFallback, + rootScope: rootScope, + store: store, + readinessSource: readinessSource + ) + } + + package static func search( + pattern: String, + mode: SearchMode = .auto, + isRegex: Bool = false, + caseInsensitive: Bool = false, + maxPaths: Int = 100, + maxMatches: Int = 250, + paths: [String]? = nil, + includeExtensions: [String] = [], + excludePatterns: [String] = [], + contextLines: Int = 0, + wholeWord: Bool = false, + countOnly: Bool = false, + fuzzySpaceMatching: Bool = true, + allowLiteralUnescapeFallback: Bool = true, + rootScope: WorkspaceLookupRootScope = .allLoaded, + store: WorkspaceFileContextStore, + readinessSource: (any WorkspaceSearchReadinessSource)? ) async throws -> SearchResults { try Task.checkCancellation() try await ensureRootScopeAvailable(rootScope, store: store) - try await ensureSearchReady(store: store, workspaceManager: workspaceManager) + try await ensureSearchReady(store: store, readinessSource: readinessSource) - let ingressFreshnessState = EditFlowPerf.begin(EditFlowPerf.Stage.Search.ingressFreshnessWait) + let ingressFreshnessState = WorkspaceRuntimePerf.begin(WorkspaceRuntimePerf.Stage.Search.ingressFreshnessWait) _ = await store.awaitAppliedIngress(rootScope: rootScope) - EditFlowPerf.end(EditFlowPerf.Stage.Search.ingressFreshnessWait, ingressFreshnessState) + WorkspaceRuntimePerf.end(WorkspaceRuntimePerf.Stage.Search.ingressFreshnessWait, ingressFreshnessState) try Task.checkCancellation() let effectiveMode = mode == .auto ? FileSearchActor.inferredAutoMode(pattern) : mode @@ -83,7 +124,7 @@ enum StoreBackedWorkspaceSearch { } } - static func requiresBroadSearchAdmission( + package static func requiresBroadSearchAdmission( pattern: String, mode: SearchMode, paths: [String]? @@ -91,7 +132,7 @@ enum StoreBackedWorkspaceSearch { broadSearchAdmissionClass(pattern: pattern, mode: mode, paths: paths) != nil } - static func broadSearchAdmissionClass( + package static func broadSearchAdmissionClass( pattern: String, mode: SearchMode, paths: [String]? @@ -131,9 +172,9 @@ enum StoreBackedWorkspaceSearch { store: WorkspaceFileContextStore, fileSearchActor: FileSearchActor ) async throws -> SearchResults { - let entryPerfState = EditFlowPerf.begin( - EditFlowPerf.Stage.Search.entrypoint, - EditFlowPerf.Dimensions( + let entryPerfState = WorkspaceRuntimePerf.begin( + WorkspaceRuntimePerf.Stage.Search.entrypoint, + WorkspaceRuntimePerf.Dimensions( searchMode: mode.rawValue, maxResults: max(maxPaths, maxMatches), isRegex: isRegex, @@ -145,10 +186,10 @@ enum StoreBackedWorkspaceSearch { ) var entryPerfStatus = "ok" defer { - EditFlowPerf.end( - EditFlowPerf.Stage.Search.entrypoint, + WorkspaceRuntimePerf.end( + WorkspaceRuntimePerf.Stage.Search.entrypoint, entryPerfState, - EditFlowPerf.Dimensions( + WorkspaceRuntimePerf.Dimensions( status: entryPerfStatus, searchMode: mode.rawValue, maxResults: max(maxPaths, maxMatches), @@ -175,9 +216,9 @@ enum StoreBackedWorkspaceSearch { let visibleRootIDs = Set(visibleRootRefs.map(\.id)) let visibleRootRecords = snapshot.roots.filter { visibleRootIDs.contains($0.id) } let allFiles = snapshot.files - let scopePerfState = EditFlowPerf.begin( - EditFlowPerf.Stage.Search.scopeFiltering, - EditFlowPerf.Dimensions(status: (paths?.isEmpty == false) ? "explicit" : "all", fileCount: allFiles.count) + let scopePerfState = WorkspaceRuntimePerf.begin( + WorkspaceRuntimePerf.Stage.Search.scopeFiltering, + WorkspaceRuntimePerf.Dimensions(status: (paths?.isEmpty == false) ? "explicit" : "all", fileCount: allFiles.count) ) let filesToSearch: [WorkspaceFileRecord] @@ -185,10 +226,10 @@ enum StoreBackedWorkspaceSearch { let parsed = await parseSearchScopePaths(rawPaths, caseInsensitive: caseInsensitive, rootScope: rootScope, store: store) if parsed.spec.clauses.isEmpty, let issue = parsed.issues.first { entryPerfStatus = "error" - EditFlowPerf.end( - EditFlowPerf.Stage.Search.scopeFiltering, + WorkspaceRuntimePerf.end( + WorkspaceRuntimePerf.Stage.Search.scopeFiltering, scopePerfState, - EditFlowPerf.Dimensions(status: "error", fileCount: allFiles.count) + WorkspaceRuntimePerf.Dimensions(status: "error", fileCount: allFiles.count) ) throw FileManagerError.fileSystemServiceNotFoundWithContext( PathResolutionIssueRenderer.message(for: issue) @@ -223,10 +264,10 @@ enum StoreBackedWorkspaceSearch { } else { filesToSearch = allFiles } - EditFlowPerf.end( - EditFlowPerf.Stage.Search.scopeFiltering, + WorkspaceRuntimePerf.end( + WorkspaceRuntimePerf.Stage.Search.scopeFiltering, scopePerfState, - EditFlowPerf.Dimensions(status: (paths?.isEmpty == false) ? "explicit" : "all", fileCount: filesToSearch.count) + WorkspaceRuntimePerf.Dimensions(status: (paths?.isEmpty == false) ? "explicit" : "all", fileCount: filesToSearch.count) ) let contentFreshnessPolicy: FileContentFreshnessPolicy = (effectiveMode == .content || effectiveMode == .both) @@ -236,9 +277,9 @@ enum StoreBackedWorkspaceSearch { var wasAutoCorrected: Bool? = nil var results: SearchResults do { - results = try await EditFlowPerf.measure( - EditFlowPerf.Stage.Search.actorSearchCall, - EditFlowPerf.Dimensions( + results = try await WorkspaceRuntimePerf.measure( + WorkspaceRuntimePerf.Stage.Search.actorSearchCall, + WorkspaceRuntimePerf.Dimensions( searchMode: mode.rawValue, fileCount: filesToSearch.count, maxResults: max(maxPaths, maxMatches), @@ -306,19 +347,16 @@ enum StoreBackedWorkspaceSearch { private static func ensureSearchReady( store: WorkspaceFileContextStore, - workspaceManager: WorkspaceManagerViewModel? + readinessSource: (any WorkspaceSearchReadinessSource)? ) async throws { let roots = await store.rootRefs(scope: .visibleWorkspace) guard !roots.isEmpty else { let msg = "No workspace is currently loaded in this window. Use the 'manage_workspaces' tool with action: 'list' to see available workspaces, then action: 'switch' to load one." throw FileManagerError.fileSystemServiceNotFoundWithContext(msg) } - guard let workspaceManager else { return } - let state = await MainActor.run { workspaceManager.workspaceSearchReadinessState } - switch state { - case .ready, .degraded: - return - case .idle: + guard let readinessSource else { return } + switch await readinessSource.readinessSnapshot().phase { + case .ready, .degraded, .idle: return case .activating, .loadingCatalog, .buildingIndexes: throw FileManagerError.fileSystemServiceNotFoundWithContext( diff --git a/Sources/RepoPrompt/Features/Search/StoreBackedWorkspaceSearchLane.swift b/Sources/RepoPromptCore/WorkspaceContext/Search/StoreBackedWorkspaceSearchLane.swift similarity index 85% rename from Sources/RepoPrompt/Features/Search/StoreBackedWorkspaceSearchLane.swift rename to Sources/RepoPromptCore/WorkspaceContext/Search/StoreBackedWorkspaceSearchLane.swift index b329d08da..3e2dc2517 100644 --- a/Sources/RepoPrompt/Features/Search/StoreBackedWorkspaceSearchLane.swift +++ b/Sources/RepoPromptCore/WorkspaceContext/Search/StoreBackedWorkspaceSearchLane.swift @@ -1,12 +1,12 @@ import Foundation -enum BroadSearchAdmissionClass: String { +package enum BroadSearchAdmissionClass: String { case unscopedContent case unscopedBoth } -enum StoreBackedWorkspaceSearchAdmissionError: LocalizedError, Equatable { - enum QueueScope: String { +package enum StoreBackedWorkspaceSearchAdmissionError: LocalizedError, Equatable { + package enum QueueScope: String { case perStore } @@ -14,7 +14,7 @@ enum StoreBackedWorkspaceSearchAdmissionError: LocalizedError, Equatable { case waitExpired(retryAfterMilliseconds: Int) case contentReadQueueFull(retryAfterMilliseconds: Int) - var retryAfterMilliseconds: Int { + package var retryAfterMilliseconds: Int { switch self { case let .queueFull(_, retryAfterMilliseconds), let .waitExpired(retryAfterMilliseconds), @@ -23,11 +23,11 @@ enum StoreBackedWorkspaceSearchAdmissionError: LocalizedError, Equatable { } } - var suggestion: String { + package var suggestion: String { "Retry after the suggested delay, or use filter.paths to narrow the content search when a smaller scope is acceptable." } - var errorDescription: String? { + package var errorDescription: String? { switch self { case .queueFull: "Broad content search capacity is temporarily busy and the per-workspace wait queue is full." @@ -43,23 +43,23 @@ enum StoreBackedWorkspaceSearchAdmissionError: LocalizedError, Equatable { /// /// Path-only and explicitly scoped searches bypass broad admission. Unscoped content-capable /// searches use a fixed one-active/one-queued gate so one workspace cannot monopolize another. -actor StoreBackedWorkspaceSearchLane { - struct Configuration: Equatable { - static let production = Configuration( +package actor StoreBackedWorkspaceSearchLane { + package struct Configuration: Equatable { + package static let production = Configuration( maxQueueWait: .milliseconds(1500), retryAfterMilliseconds: 1000 ) - let maxQueueWait: Duration - let retryAfterMilliseconds: Int + package let maxQueueWait: Duration + package let retryAfterMilliseconds: Int - var maxQueueWaitMilliseconds: Int { + package var maxQueueWaitMilliseconds: Int { let components = maxQueueWait.components let milliseconds = components.seconds * 1000 + components.attoseconds / 1_000_000_000_000_000 return Int(clamping: milliseconds) } - init(maxQueueWait: Duration, retryAfterMilliseconds: Int = 1000) { + package init(maxQueueWait: Duration, retryAfterMilliseconds: Int = 1000) { precondition(maxQueueWait > .zero) precondition(retryAfterMilliseconds >= 0) self.maxQueueWait = maxQueueWait @@ -84,23 +84,23 @@ actor StoreBackedWorkspaceSearchLane { } #if DEBUG - struct Snapshot: Equatable { - let configuration: Configuration - let activePermitCount: Int - let waiterCount: Int - let grantCount: Int - let overloadCount: Int - let waitExpiryCount: Int - let queuedCancellationCount: Int - let maximumActivePermitCount: Int - let maximumWaiterCount: Int - - var isIdle: Bool { + package struct Snapshot: Equatable { + package let configuration: Configuration + package let activePermitCount: Int + package let waiterCount: Int + package let grantCount: Int + package let overloadCount: Int + package let waitExpiryCount: Int + package let queuedCancellationCount: Int + package let maximumActivePermitCount: Int + package let maximumWaiterCount: Int + + package var isIdle: Bool { activePermitCount == 0 && waiterCount == 0 } } - enum DebugConfigurationUpdateResult: Equatable { + package enum DebugConfigurationUpdateResult: Equatable { case applied(Snapshot) case busy(Snapshot) } @@ -115,7 +115,7 @@ actor StoreBackedWorkspaceSearchLane { let leaseID: UUID let searchMode: SearchMode let admissionClass: BroadSearchAdmissionClass - let lifecycleCorrelation: EditFlowPerf.LifecycleCorrelation? + let lifecycleCorrelation: WorkspaceRuntimePerf.LifecycleCorrelation? let waited: Bool let queueAgeBucket: String let metrics: AdmissionMetrics @@ -125,7 +125,7 @@ actor StoreBackedWorkspaceSearchLane { let continuation: CheckedContinuation let searchMode: SearchMode let admissionClass: BroadSearchAdmissionClass - let lifecycleCorrelation: EditFlowPerf.LifecycleCorrelation? + let lifecycleCorrelation: WorkspaceRuntimePerf.LifecycleCorrelation? let enqueuedAtUptimeNanoseconds: UInt64 let deadline: Duration var timeoutTask: Task? @@ -165,9 +165,9 @@ actor StoreBackedWorkspaceSearchLane { return try await operation(fileSearchActor) } - let lifecycleCorrelation = EditFlowPerf.currentLifecycleCorrelation - let waitState = EditFlowPerf.begin( - EditFlowPerf.Stage.Search.broadAdmissionWait, + let lifecycleCorrelation = WorkspaceRuntimePerf.currentLifecycleCorrelation + let waitState = WorkspaceRuntimePerf.begin( + WorkspaceRuntimePerf.Stage.Search.broadAdmissionWait, admissionDimensions( searchMode: searchMode, admissionClass: admissionClass, @@ -183,8 +183,8 @@ actor StoreBackedWorkspaceSearchLane { admissionClass: admissionClass, lifecycleCorrelation: lifecycleCorrelation ) - EditFlowPerf.end( - EditFlowPerf.Stage.Search.broadAdmissionWait, + WorkspaceRuntimePerf.end( + WorkspaceRuntimePerf.Stage.Search.broadAdmissionWait, waitState, admissionDimensions( outcome: acquisition.waited ? "acquiredAfterWait" : "immediate", @@ -195,8 +195,8 @@ actor StoreBackedWorkspaceSearchLane { ) ) } catch { - EditFlowPerf.end( - EditFlowPerf.Stage.Search.broadAdmissionWait, + WorkspaceRuntimePerf.end( + WorkspaceRuntimePerf.Stage.Search.broadAdmissionWait, waitState, admissionDimensions( outcome: Self.waitOutcome(for: error), @@ -209,8 +209,8 @@ actor StoreBackedWorkspaceSearchLane { throw error } - let leaseHoldState = EditFlowPerf.begin( - EditFlowPerf.Stage.Search.broadAdmissionLeaseHold, + let leaseHoldState = WorkspaceRuntimePerf.begin( + WorkspaceRuntimePerf.Stage.Search.broadAdmissionLeaseHold, admissionDimensions( searchMode: searchMode, admissionClass: admissionClass, @@ -220,8 +220,8 @@ actor StoreBackedWorkspaceSearchLane { ) var leaseHoldOutcome = "completed" defer { - EditFlowPerf.end( - EditFlowPerf.Stage.Search.broadAdmissionLeaseHold, + WorkspaceRuntimePerf.end( + WorkspaceRuntimePerf.Stage.Search.broadAdmissionLeaseHold, leaseHoldState, admissionDimensions( outcome: leaseHoldOutcome, @@ -252,7 +252,7 @@ actor StoreBackedWorkspaceSearchLane { private func acquire( searchMode: SearchMode, admissionClass: BroadSearchAdmissionClass, - lifecycleCorrelation: EditFlowPerf.LifecycleCorrelation? + lifecycleCorrelation: WorkspaceRuntimePerf.LifecycleCorrelation? ) async throws -> PermitAcquisition { try Task.checkCancellation() if activeLeaseID == nil { @@ -265,8 +265,8 @@ actor StoreBackedWorkspaceSearchLane { } guard waiterState == nil else { overloadCount &+= 1 - EditFlowPerf.lifecycleEvent( - EditFlowPerf.Lifecycle.Search.broadAdmissionOverloaded, + WorkspaceRuntimePerf.lifecycleEvent( + WorkspaceRuntimePerf.Lifecycle.Search.broadAdmissionOverloaded, correlation: lifecycleCorrelation, admissionDimensions( outcome: StoreBackedWorkspaceSearchAdmissionError.QueueScope.perStore.rawValue, @@ -303,7 +303,7 @@ actor StoreBackedWorkspaceSearchLane { continuation: CheckedContinuation, searchMode: SearchMode, admissionClass: BroadSearchAdmissionClass, - lifecycleCorrelation: EditFlowPerf.LifecycleCorrelation? + lifecycleCorrelation: WorkspaceRuntimePerf.LifecycleCorrelation? ) { if activeLeaseID == nil { continuation.resume(returning: allocatePermit( @@ -336,8 +336,8 @@ actor StoreBackedWorkspaceSearchLane { timeoutTask: nil ) maximumWaiterCount = max(maximumWaiterCount, 1) - EditFlowPerf.lifecycleEvent( - EditFlowPerf.Lifecycle.Search.broadAdmissionWaitBegan, + WorkspaceRuntimePerf.lifecycleEvent( + WorkspaceRuntimePerf.Lifecycle.Search.broadAdmissionWaitBegan, correlation: lifecycleCorrelation, admissionDimensions( searchMode: searchMode, @@ -369,8 +369,8 @@ actor StoreBackedWorkspaceSearchLane { waiterState = nil state.timeoutTask?.cancel() queuedCancellationCount &+= 1 - EditFlowPerf.lifecycleEvent( - EditFlowPerf.Lifecycle.Search.broadAdmissionPermitCancelled, + WorkspaceRuntimePerf.lifecycleEvent( + WorkspaceRuntimePerf.Lifecycle.Search.broadAdmissionPermitCancelled, correlation: state.lifecycleCorrelation, admissionDimensions( outcome: "cancelled", @@ -388,8 +388,8 @@ actor StoreBackedWorkspaceSearchLane { waiterID = nil waiterState = nil waitExpiryCount &+= 1 - EditFlowPerf.lifecycleEvent( - EditFlowPerf.Lifecycle.Search.broadAdmissionWaitExpired, + WorkspaceRuntimePerf.lifecycleEvent( + WorkspaceRuntimePerf.Lifecycle.Search.broadAdmissionWaitExpired, correlation: state.lifecycleCorrelation, admissionDimensions( outcome: "waitExpired", @@ -407,8 +407,8 @@ actor StoreBackedWorkspaceSearchLane { private func release(_ acquisition: PermitAcquisition) { guard activeLeaseID == acquisition.leaseID else { return } activeLeaseID = nil - EditFlowPerf.lifecycleEvent( - EditFlowPerf.Lifecycle.Search.broadAdmissionPermitReleased, + WorkspaceRuntimePerf.lifecycleEvent( + WorkspaceRuntimePerf.Lifecycle.Search.broadAdmissionPermitReleased, correlation: acquisition.lifecycleCorrelation, admissionDimensions( outcome: "released", @@ -442,7 +442,7 @@ actor StoreBackedWorkspaceSearchLane { private func allocatePermit( searchMode: SearchMode, admissionClass: BroadSearchAdmissionClass, - lifecycleCorrelation: EditFlowPerf.LifecycleCorrelation?, + lifecycleCorrelation: WorkspaceRuntimePerf.LifecycleCorrelation?, waited: Bool, queueAgeBucket: String = "immediate" ) -> PermitAcquisition { @@ -460,8 +460,8 @@ actor StoreBackedWorkspaceSearchLane { queueAgeBucket: queueAgeBucket, metrics: metrics() ) - EditFlowPerf.lifecycleEvent( - EditFlowPerf.Lifecycle.Search.broadAdmissionPermitAcquired, + WorkspaceRuntimePerf.lifecycleEvent( + WorkspaceRuntimePerf.Lifecycle.Search.broadAdmissionPermitAcquired, correlation: lifecycleCorrelation, admissionDimensions( outcome: waited ? "acquiredAfterWait" : "immediate", @@ -487,8 +487,8 @@ actor StoreBackedWorkspaceSearchLane { admissionClass: BroadSearchAdmissionClass, metrics: AdmissionMetrics, queueAgeBucket: String - ) -> EditFlowPerf.Dimensions { - EditFlowPerf.Dimensions( + ) -> WorkspaceRuntimePerf.Dimensions { + WorkspaceRuntimePerf.Dimensions( outcome: outcome, storeCapacity: 1, globalCapacity: 0, diff --git a/Sources/RepoPromptCore/WorkspaceContext/Search/WorkspaceSearchReadinessSource.swift b/Sources/RepoPromptCore/WorkspaceContext/Search/WorkspaceSearchReadinessSource.swift new file mode 100644 index 000000000..066676a09 --- /dev/null +++ b/Sources/RepoPromptCore/WorkspaceContext/Search/WorkspaceSearchReadinessSource.swift @@ -0,0 +1,33 @@ +import Foundation + +package enum WorkspaceSearchReadinessPhase: String, Equatable { + case idle + case activating + case loadingCatalog + case buildingIndexes + case ready + case degraded +} + +package struct WorkspaceSearchReadinessSnapshot: Equatable { + package let workspaceID: UUID? + package let phase: WorkspaceSearchReadinessPhase + package let generation: UInt64 + package let failureCount: Int + + package init( + workspaceID: UUID?, + phase: WorkspaceSearchReadinessPhase, + generation: UInt64, + failureCount: Int = 0 + ) { + self.workspaceID = workspaceID + self.phase = phase + self.generation = generation + self.failureCount = max(0, failureCount) + } +} + +package protocol WorkspaceSearchReadinessSource: Sendable { + func readinessSnapshot() async -> WorkspaceSearchReadinessSnapshot +} diff --git a/Sources/RepoPrompt/Infrastructure/WorkspaceContext/Search/WorkspaceSearchService.swift b/Sources/RepoPromptCore/WorkspaceContext/Search/WorkspaceSearchService.swift similarity index 94% rename from Sources/RepoPrompt/Infrastructure/WorkspaceContext/Search/WorkspaceSearchService.swift rename to Sources/RepoPromptCore/WorkspaceContext/Search/WorkspaceSearchService.swift index 10b1c3ef1..01e0870d4 100644 --- a/Sources/RepoPrompt/Infrastructure/WorkspaceContext/Search/WorkspaceSearchService.swift +++ b/Sources/RepoPromptCore/WorkspaceContext/Search/WorkspaceSearchService.swift @@ -5,7 +5,7 @@ import Foundation /// The service keeps `PathSearchIndex` and all path-entry arrays off the main actor. Callers /// provide immutable `WorkspaceSearchCatalogSnapshot` values from `WorkspaceFileContextStore`, /// then query by text and receive pure value results. -actor WorkspaceSearchService { +package actor WorkspaceSearchService { private struct PreparedIndex { let generation: UInt64 let diagnostics: WorkspaceCatalogDiagnostics @@ -32,7 +32,7 @@ actor WorkspaceSearchService { private var discardedAutomaticRebuildCompletions = 0 private var isReadyIndexUsable = true - init(automaticIndexBuildDelayNanoseconds: UInt64 = 0) { + package init(automaticIndexBuildDelayNanoseconds: UInt64 = 0) { self.automaticIndexBuildDelayNanoseconds = automaticIndexBuildDelayNanoseconds } @@ -41,35 +41,35 @@ actor WorkspaceSearchService { pendingRebuildTask?.cancel() } - var indexedGeneration: UInt64? { + package var indexedGeneration: UInt64? { currentIndexedGeneration } - var snapshotGeneration: UInt64? { + package var snapshotGeneration: UInt64? { currentSnapshotGeneration } - var diagnostics: WorkspaceCatalogDiagnostics? { + package var diagnostics: WorkspaceCatalogDiagnostics? { currentDiagnostics } - var indexedPathCount: Int { + package var indexedPathCount: Int { indexedPaths.count } - var pendingGeneration: UInt64? { + package var pendingGeneration: UInt64? { pendingRebuildGeneration ?? activeRebuildGeneration } - var observedCatalogGeneration: UInt64? { + package var observedCatalogGeneration: UInt64? { latestObservedCatalogGeneration } - var discardedStaleRebuildCount: Int { + package var discardedStaleRebuildCount: Int { discardedAutomaticRebuildCompletions } - func startKeepingFresh( + package func startKeepingFresh( with store: WorkspaceFileContextStore, rootScope: WorkspaceLookupRootScope = .visibleWorkspace, debounceNanoseconds: UInt64 = 50_000_000 @@ -102,7 +102,7 @@ actor WorkspaceSearchService { } } - func stopKeepingFresh() { + package func stopKeepingFresh() { appliedIndexListenerTask?.cancel() appliedIndexListenerTask = nil pendingRebuildTask?.cancel() @@ -112,7 +112,7 @@ actor WorkspaceSearchService { } @discardableResult - func rebuildIndex(from snapshot: WorkspaceSearchCatalogSnapshot) async -> UInt64 { + package func rebuildIndex(from snapshot: WorkspaceSearchCatalogSnapshot) async -> UInt64 { rebuildSerial &+= 1 let serial = rebuildSerial pendingRebuildTask?.cancel() @@ -132,11 +132,11 @@ actor WorkspaceSearchService { } @discardableResult - func prepareIndex(from snapshot: WorkspaceSearchCatalogSnapshot) async -> UInt64 { + package func prepareIndex(from snapshot: WorkspaceSearchCatalogSnapshot) async -> UInt64 { await rebuildIndex(from: snapshot) } - func reset() async { + package func reset() async { rebuildSerial &+= 1 appliedIndexListenerTask?.cancel() appliedIndexListenerTask = nil @@ -155,7 +155,7 @@ actor WorkspaceSearchService { readyIndex = PathSearchIndex() } - func search(_ query: String, limit: Int = 300) async -> WorkspaceSearchQueryResult { + package func search(_ query: String, limit: Int = 300) async -> WorkspaceSearchQueryResult { let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines) let boundedLimit = max(0, limit) let stale = isSearchStale diff --git a/Sources/RepoPromptCore/WorkspaceContext/Selection/WorkspaceSelectionController.swift b/Sources/RepoPromptCore/WorkspaceContext/Selection/WorkspaceSelectionController.swift new file mode 100644 index 000000000..fcc7b0fd7 --- /dev/null +++ b/Sources/RepoPromptCore/WorkspaceContext/Selection/WorkspaceSelectionController.swift @@ -0,0 +1,328 @@ +import Foundation + +@MainActor +package final class WorkspaceSelectionObservationToken { + private var cancelAction: (() -> Void)? + + package init(cancelAction: @escaping () -> Void) { + self.cancelAction = cancelAction + } + + package func cancel() { + cancelAction?() + cancelAction = nil + } + + deinit { + MainActor.assumeIsolated { cancel() } + } +} + +/// Canonical session-backed selection owner. UI flushing and mirroring remain app adapters. +@MainActor +package final class WorkspaceSelectionController { + package struct Target: Hashable { + package let workspaceID: UUID + package let tabID: UUID + + package init(workspaceID: UUID, tabID: UUID) { + self.workspaceID = workspaceID + self.tabID = tabID + } + } + + package struct Snapshot: Equatable { + package let tabID: UUID? + package let selection: StoredSelection + package let isVirtual: Bool + + package init(tabID: UUID?, selection: StoredSelection, isVirtual: Bool) { + self.tabID = tabID + self.selection = selection + self.isVirtual = isVirtual + } + } + + package struct Change: Equatable { + package let tabID: UUID? + package let selection: StoredSelection + package let source: Source + + package init(tabID: UUID?, selection: StoredSelection, source: Source) { + self.tabID = tabID + self.selection = selection + self.source = source + } + } + + package enum Source: String, Equatable { + case uiFlush + case runtimeMutation + case virtual + case mirror + } + + package typealias Observer = @MainActor (Change) -> Void + + package let sessionController: WorkspaceSessionController + package let mutationService: WorkspaceSelectionMutationService + private var observers: [UUID: Observer] = [:] + private var selectionsByTarget: [Target: StoredSelection] + private var pendingSourceByTarget: [Target: Source] = [:] + private var suppressedObserverTargets: Set = [] + private var sessionObservationToken: WorkspaceSessionObservationToken? + + package init( + sessionController: WorkspaceSessionController, + mutationService: WorkspaceSelectionMutationService + ) { + self.sessionController = sessionController + self.mutationService = mutationService + selectionsByTarget = Self.selectionMap(from: sessionController.snapshot) + sessionObservationToken = sessionController.observe { [weak self] snapshot in + self?.handleSessionSnapshot(snapshot) + } + } + + package func activeTarget() -> Target? { + guard let workspace = sessionController.activeWorkspace, + let tab = workspace.composeTabs.first(where: { $0.id == workspace.activeComposeTabID }) ?? workspace.composeTabs.first + else { return nil } + return Target(workspaceID: workspace.id, tabID: tab.id) + } + + package func activeTabID() -> UUID? { + activeTarget()?.tabID + } + + package func activeSelectionSnapshot() -> Snapshot { + guard let target = activeTarget(), + let tab = sessionController.workspace(id: target.workspaceID)?.composeTabs.first(where: { $0.id == target.tabID }) + else { + return Snapshot(tabID: nil, selection: StoredSelection(), isVirtual: false) + } + return Snapshot(tabID: target.tabID, selection: tab.selection, isVirtual: false) + } + + package func virtualSelectionSnapshot(tabID: UUID, selection: StoredSelection) -> Snapshot { + Snapshot(tabID: tabID, selection: selection, isVirtual: true) + } + + @discardableResult + package func persistActiveSelection( + _ selection: StoredSelection, + source: Source = .runtimeMutation, + publishChange: Bool = true + ) -> StoredSelection { + guard let target = activeTarget() else { return selection } + persist(selection, for: target, source: source, publishChange: publishChange) + return selection + } + + @discardableResult + package func persistVirtualSelection( + _ selection: StoredSelection, + for tabID: UUID, + publishChange: Bool = true + ) -> StoredSelection { + guard let target = target(forTabID: tabID) else { return selection } + persist(selection, for: target, source: .virtual, publishChange: publishChange) + return selection + } + + @discardableResult + package func replaceActiveSelection(_ selection: StoredSelection) -> StoredSelection { + persistActiveSelection(selection, source: .runtimeMutation) + } + + @discardableResult + package func addPathsToActiveSelection( + paths: [String], + mode: String = "full", + rootScope: WorkspaceLookupRootScope = .visibleWorkspace, + publishChange: Bool = true + ) async -> WorkspaceAddSelectionResult { + guard let target = activeTarget(), let current = selection(for: target) else { + return WorkspaceAddSelectionResult( + selection: StoredSelection(), + invalidPaths: [], + resolvedMap: [:], + mutated: false, + codemapUnavailable: [] + ) + } + let result = await mutationService.addPaths( + existing: current, + paths: paths, + rawPaths: paths, + mode: mode, + rootScope: rootScope + ) + if result.mutated { + persist(result.selection, for: target, source: .runtimeMutation, publishChange: publishChange) + } + return result + } + + @discardableResult + package func removePathsFromActiveSelection( + paths: [String], + mode: String = "full", + rootScope: WorkspaceLookupRootScope = .visibleWorkspace, + publishChange: Bool = true + ) async -> WorkspaceRemoveSelectionResult { + guard let target = activeTarget(), let current = selection(for: target) else { + return WorkspaceRemoveSelectionResult( + selection: StoredSelection(), + invalidPaths: [], + resolvedMap: [:], + mutated: false + ) + } + let result = await mutationService.removePaths( + existing: current, + paths: paths, + rawPaths: paths, + mode: mode, + rootScope: rootScope + ) + if result.mutated { + persist(result.selection, for: target, source: .runtimeMutation, publishChange: publishChange) + } + return result + } + + /// Marks the active target so the next session selection change is classified as an app UI flush. + package func beginExternallyCommittedSelection(source: Source) -> (target: Target, previous: StoredSelection)? { + guard let target = activeTarget(), let previous = selection(for: target) else { return nil } + pendingSourceByTarget[target] = source + return (target, previous) + } + + /// Completes an external in-memory commit and allocates the persistence revision without dirtying the workspace. + package func finishExternallyCommittedSelection(target: Target, previous: StoredSelection) { + guard let current = selection(for: target) else { + pendingSourceByTarget.removeValue(forKey: target) + return + } + if current != previous { + sessionController.recordExternallyCommittedSelectionRevision( + workspaceID: target.workspaceID, + tabID: target.tabID, + previous: previous, + current: current + ) + } else if let source = pendingSourceByTarget.removeValue(forKey: target) { + publish(Change(tabID: target.tabID, selection: current, source: source)) + } + } + + package func observe(_ observer: @escaping Observer) -> WorkspaceSelectionObservationToken { + let id = UUID() + observers[id] = observer + return WorkspaceSelectionObservationToken { [weak self] in + self?.observers.removeValue(forKey: id) + } + } + + private func persist( + _ selection: StoredSelection, + for target: Target, + source: Source, + publishChange: Bool + ) { + guard let previous = self.selection(for: target) else { return } + guard previous != selection else { + if publishChange { + publish(Change(tabID: target.tabID, selection: selection, source: source)) + } + return + } + if publishChange { + pendingSourceByTarget[target] = source + } else { + suppressedObserverTargets.insert(target) + } + _ = sessionController.mutateComposeTab( + workspaceID: target.workspaceID, + tabID: target.tabID, + options: .storedOnly + ) { tab in + tab.selection = selection + tab.lastModified = Date() + } + if publishChange, pendingSourceByTarget.removeValue(forKey: target) != nil { + // Defensive fallback if the session did not publish a changed snapshot. + selectionsByTarget[target] = selection + publish(Change(tabID: target.tabID, selection: selection, source: source)) + } else if !publishChange { + suppressedObserverTargets.remove(target) + selectionsByTarget[target] = selection + } + } + + package func selectionSnapshot(for target: Target) -> StoredSelection? { + selection(for: target) + } + + package func target(forTabID tabID: UUID) -> Target? { + if let active = activeTarget(), active.tabID == tabID { return active } + let matches = sessionController.workspaces.compactMap { workspace -> Target? in + workspace.composeTabs.contains(where: { $0.id == tabID }) + ? Target(workspaceID: workspace.id, tabID: tabID) + : nil + } + return matches.count == 1 ? matches[0] : nil + } + + private func selection(for target: Target) -> StoredSelection? { + sessionController.workspace(id: target.workspaceID)? + .composeTabs.first(where: { $0.id == target.tabID })? + .selection + } + + private func handleSessionSnapshot(_ snapshot: WorkspaceSessionSnapshot) { + let next = Self.selectionMap(from: snapshot) + let changedTargets = Set(selectionsByTarget.keys).union(next.keys).filter { + selectionsByTarget[$0] != next[$0] + } + selectionsByTarget = next + for target in changedTargets.sorted(by: Self.targetOrdering) { + guard let selection = next[target] else { + pendingSourceByTarget.removeValue(forKey: target) + suppressedObserverTargets.remove(target) + continue + } + if suppressedObserverTargets.remove(target) != nil { + pendingSourceByTarget.removeValue(forKey: target) + continue + } + let source = pendingSourceByTarget.removeValue(forKey: target) ?? .mirror + publish(Change(tabID: target.tabID, selection: selection, source: source)) + } + } + + private func publish(_ change: Change) { + let callbacks = Array(observers.values) + for observer in callbacks { + observer(change) + } + } + + private static func selectionMap(from snapshot: WorkspaceSessionSnapshot) -> [Target: StoredSelection] { + var result: [Target: StoredSelection] = [:] + for workspace in snapshot.workspaces { + for tab in workspace.composeTabs { + result[Target(workspaceID: workspace.id, tabID: tab.id)] = tab.selection + } + } + return result + } + + private static func targetOrdering(_ lhs: Target, _ rhs: Target) -> Bool { + if lhs.workspaceID != rhs.workspaceID { + return lhs.workspaceID.uuidString < rhs.workspaceID.uuidString + } + return lhs.tabID.uuidString < rhs.tabID.uuidString + } +} diff --git a/Sources/RepoPrompt/Infrastructure/WorkspaceContext/Selection/WorkspaceSelectionMutationService.swift b/Sources/RepoPromptCore/WorkspaceContext/Selection/WorkspaceSelectionMutationService.swift similarity index 89% rename from Sources/RepoPrompt/Infrastructure/WorkspaceContext/Selection/WorkspaceSelectionMutationService.swift rename to Sources/RepoPromptCore/WorkspaceContext/Selection/WorkspaceSelectionMutationService.swift index 067aa264c..64f7268fb 100644 --- a/Sources/RepoPrompt/Infrastructure/WorkspaceContext/Selection/WorkspaceSelectionMutationService.swift +++ b/Sources/RepoPromptCore/WorkspaceContext/Selection/WorkspaceSelectionMutationService.swift @@ -1,51 +1,56 @@ import Foundation -struct WorkspaceSelectionSliceInput: Equatable { - let path: String - let ranges: [LineRange] +package struct WorkspaceSelectionSliceInput: Equatable { + package let path: String + package let ranges: [LineRange] + + package init(path: String, ranges: [LineRange]) { + self.path = path + self.ranges = ranges + } } -struct WorkspaceBuildSelectionResult: Equatable { - let selection: StoredSelection - let invalidPaths: [String] - let codemapUnavailable: [String] +package struct WorkspaceBuildSelectionResult: Equatable { + package let selection: StoredSelection + package let invalidPaths: [String] + package let codemapUnavailable: [String] } -struct WorkspaceAddSelectionResult: Equatable { - let selection: StoredSelection - let invalidPaths: [String] - let resolvedMap: [String: String] - let mutated: Bool - let codemapUnavailable: [String] +package struct WorkspaceAddSelectionResult: Equatable { + package let selection: StoredSelection + package let invalidPaths: [String] + package let resolvedMap: [String: String] + package let mutated: Bool + package let codemapUnavailable: [String] } -struct WorkspaceRemoveSelectionResult: Equatable { - let selection: StoredSelection - let invalidPaths: [String] - let resolvedMap: [String: String] - let mutated: Bool +package struct WorkspaceRemoveSelectionResult: Equatable { + package let selection: StoredSelection + package let invalidPaths: [String] + package let resolvedMap: [String: String] + package let mutated: Bool } -struct WorkspaceDemoteSelectionResult: Equatable { - let selection: StoredSelection - let invalidPaths: [String] - let codemapUnavailable: [String] - let mutated: Bool +package struct WorkspaceDemoteSelectionResult: Equatable { + package let selection: StoredSelection + package let invalidPaths: [String] + package let codemapUnavailable: [String] + package let mutated: Bool } -struct WorkspaceSliceSelectionMutationResult: Equatable { - let selection: StoredSelection - let invalidPaths: [String] - let resolvedMap: [String: String] - let mutated: Bool +package struct WorkspaceSliceSelectionMutationResult: Equatable { + package let selection: StoredSelection + package let invalidPaths: [String] + package let resolvedMap: [String: String] + package let mutated: Bool } -struct WorkspaceSelectionMutationService { - let store: WorkspaceFileContextStore - let codemapsGloballyDisabled: Bool - let codemapsGloballyDisabledMessage: String +package struct WorkspaceSelectionMutationService { + package let store: WorkspaceFileContextStore + package let codemapsGloballyDisabled: Bool + package let codemapsGloballyDisabledMessage: String - init( + package init( store: WorkspaceFileContextStore, codemapsGloballyDisabled: Bool = false, codemapsGloballyDisabledMessage: String = "Code maps are disabled for this tool." @@ -55,7 +60,7 @@ struct WorkspaceSelectionMutationService { self.codemapsGloballyDisabledMessage = codemapsGloballyDisabledMessage } - func buildSelection( + package func buildSelection( paths: [String], slices sliceInputs: [WorkspaceSelectionSliceInput] = [], sliceErrors: [String] = [], @@ -140,7 +145,7 @@ struct WorkspaceSelectionMutationService { return WorkspaceBuildSelectionResult(selection: selection, invalidPaths: invalid, codemapUnavailable: codemapUnavailable) } - func buildManageSelectionSet( + package func buildManageSelectionSet( paths: [String], slices sliceInputs: [WorkspaceSelectionSliceInput] = [], sliceErrors: [String] = [], @@ -212,7 +217,7 @@ struct WorkspaceSelectionMutationService { ) } - func mutateSlices( + package func mutateSlices( base: StoredSelection, entries: [WorkspaceSelectionSliceInput], mode: SliceMutationMode, @@ -317,7 +322,7 @@ struct WorkspaceSelectionMutationService { return WorkspaceSliceSelectionMutationResult(selection: nextSelection, invalidPaths: invalid, resolvedMap: resolved, mutated: mutated) } - func addPaths( + package func addPaths( existing: StoredSelection, paths: [String], rawPaths: [String], @@ -328,7 +333,7 @@ struct WorkspaceSelectionMutationService { if codemapOnly, codemapsGloballyDisabled { return WorkspaceAddSelectionResult(selection: existing, invalidPaths: [codemapsGloballyDisabledMessage], resolvedMap: [:], mutated: false, codemapUnavailable: []) } - let candidateResolutionTotal = EditFlowPerf.begin(EditFlowPerf.Stage.ReadFile.AutoSelect.candidateResolutionTotal) + let candidateResolutionTotal = WorkspaceRuntimePerf.begin(WorkspaceRuntimePerf.Stage.ReadFile.AutoSelect.candidateResolutionTotal) let resolution: (files: [WorkspaceFileRecord], invalid: [String], resolvedMap: [String: String], unavailable: [String]) if codemapOnly { let value = await resolveCodemapOnlyCandidates(paths: paths, rawPaths: rawPaths, expandFolders: true, rootScope: rootScope) @@ -337,9 +342,9 @@ struct WorkspaceSelectionMutationService { let value = await resolveSelectionCandidates(paths: paths, rawPaths: rawPaths, expandFolders: true, rootScope: rootScope) resolution = (value.candidates, value.invalidPaths, value.resolvedMap, []) } - EditFlowPerf.end(EditFlowPerf.Stage.ReadFile.AutoSelect.candidateResolutionTotal, candidateResolutionTotal) + WorkspaceRuntimePerf.end(WorkspaceRuntimePerf.Stage.ReadFile.AutoSelect.candidateResolutionTotal, candidateResolutionTotal) - let structuralMerge = EditFlowPerf.begin(EditFlowPerf.Stage.ReadFile.AutoSelect.structuralMerge) + let structuralMerge = WorkspaceRuntimePerf.begin(WorkspaceRuntimePerf.Stage.ReadFile.AutoSelect.structuralMerge) var selectedPaths = existing.selectedPaths var codemapPaths = existing.autoCodemapPaths var slices = existing.slices @@ -381,26 +386,26 @@ struct WorkspaceSelectionMutationService { slices: slices, codemapAutoEnabled: codemapOnly ? false : existing.codemapAutoEnabled ) - EditFlowPerf.end(EditFlowPerf.Stage.ReadFile.AutoSelect.structuralMerge, structuralMerge) - let autoCodemapRecomputeTotal = EditFlowPerf.begin(EditFlowPerf.Stage.ReadFile.AutoSelect.autoCodemapRecomputeTotal) + WorkspaceRuntimePerf.end(WorkspaceRuntimePerf.Stage.ReadFile.AutoSelect.structuralMerge, structuralMerge) + let autoCodemapRecomputeTotal = WorkspaceRuntimePerf.begin(WorkspaceRuntimePerf.Stage.ReadFile.AutoSelect.autoCodemapRecomputeTotal) if selection.codemapAutoEnabled { selection = await recomputeAutoCodemaps(selection, rootScope: rootScope) - EditFlowPerf.end( - EditFlowPerf.Stage.ReadFile.AutoSelect.autoCodemapRecomputeTotal, + WorkspaceRuntimePerf.end( + WorkspaceRuntimePerf.Stage.ReadFile.AutoSelect.autoCodemapRecomputeTotal, autoCodemapRecomputeTotal, - EditFlowPerf.Dimensions(outcome: "attempted") + WorkspaceRuntimePerf.Dimensions(outcome: "attempted") ) } else { - EditFlowPerf.end( - EditFlowPerf.Stage.ReadFile.AutoSelect.autoCodemapRecomputeTotal, + WorkspaceRuntimePerf.end( + WorkspaceRuntimePerf.Stage.ReadFile.AutoSelect.autoCodemapRecomputeTotal, autoCodemapRecomputeTotal, - EditFlowPerf.Dimensions(outcome: "skipped") + WorkspaceRuntimePerf.Dimensions(outcome: "skipped") ) } return WorkspaceAddSelectionResult(selection: selection, invalidPaths: resolution.invalid, resolvedMap: resolution.resolvedMap, mutated: mutated, codemapUnavailable: resolution.unavailable) } - func removePaths( + package func removePaths( existing: StoredSelection, paths: [String], rawPaths: [String], @@ -444,7 +449,7 @@ struct WorkspaceSelectionMutationService { return WorkspaceRemoveSelectionResult(selection: selection, invalidPaths: resolution.invalidPaths, resolvedMap: resolution.resolvedMap, mutated: mutated) } - func promotePaths( + package func promotePaths( existing: StoredSelection, paths: [String], rawPaths: [String], @@ -476,7 +481,7 @@ struct WorkspaceSelectionMutationService { return (StoredSelection(selectedPaths: selectedPaths, autoCodemapPaths: codemapPaths, slices: slices, codemapAutoEnabled: false), resolution.invalidPaths, mutated) } - func demotePaths( + package func demotePaths( existing: StoredSelection, paths: [String], rawPaths: [String], @@ -517,7 +522,7 @@ struct WorkspaceSelectionMutationService { return WorkspaceDemoteSelectionResult(selection: selection, invalidPaths: resolution.invalidPaths, codemapUnavailable: unavailable, mutated: mutated) } - func resolveSelectionCandidates( + package func resolveSelectionCandidates( paths: [String], rawPaths: [String], expandFolders: Bool, @@ -576,7 +581,7 @@ struct WorkspaceSelectionMutationService { return WorkspaceResolvedCandidates(candidates: candidates, resolvedMap: resolvedMap, invalidPaths: invalid) } - func resolveCodemapOnlyCandidates( + package func resolveCodemapOnlyCandidates( paths: [String], rawPaths: [String], expandFolders: Bool, @@ -647,7 +652,7 @@ struct WorkspaceSelectionMutationService { return WorkspaceCodemapOnlyCandidates(candidates: candidates, resolvedMap: resolvedMap, invalidPaths: invalid, codemapUnavailable: unavailable) } - func recomputeAutoCodemaps( + package func recomputeAutoCodemaps( _ base: StoredSelection, rootScope: WorkspaceLookupRootScope = .visibleWorkspace ) async -> StoredSelection { @@ -655,26 +660,26 @@ struct WorkspaceSelectionMutationService { guard !codemapsGloballyDisabled else { return StoredSelection(selectedPaths: base.selectedPaths, autoCodemapPaths: [], slices: base.slices, codemapAutoEnabled: base.codemapAutoEnabled) } - let selectedFileLookup = EditFlowPerf.begin(EditFlowPerf.Stage.ReadFile.AutoSelect.selectedFileLookup) + let selectedFileLookup = WorkspaceRuntimePerf.begin(WorkspaceRuntimePerf.Stage.ReadFile.AutoSelect.selectedFileLookup) let resolved = await store.lookupFiles(atPaths: base.selectedPaths, rootScope: rootScope) - EditFlowPerf.end(EditFlowPerf.Stage.ReadFile.AutoSelect.selectedFileLookup, selectedFileLookup) + WorkspaceRuntimePerf.end(WorkspaceRuntimePerf.Stage.ReadFile.AutoSelect.selectedFileLookup, selectedFileLookup) let selected = base.selectedPaths.compactMap { resolved[$0] } guard !selected.isEmpty else { return StoredSelection(selectedPaths: base.selectedPaths, autoCodemapPaths: [], slices: base.slices, codemapAutoEnabled: base.codemapAutoEnabled) } - let codemapAPILoad = EditFlowPerf.begin(EditFlowPerf.Stage.ReadFile.AutoSelect.codemapAPILoad) + let codemapAPILoad = WorkspaceRuntimePerf.begin(WorkspaceRuntimePerf.Stage.ReadFile.AutoSelect.codemapAPILoad) let aggregate = await store.codemapFileAPIAggregate() - EditFlowPerf.end(EditFlowPerf.Stage.ReadFile.AutoSelect.codemapAPILoad, codemapAPILoad) + WorkspaceRuntimePerf.end(WorkspaceRuntimePerf.Stage.ReadFile.AutoSelect.codemapAPILoad, codemapAPILoad) guard !aggregate.orderedFileAPIs.isEmpty else { return StoredSelection(selectedPaths: base.selectedPaths, autoCodemapPaths: [], slices: base.slices, codemapAutoEnabled: base.codemapAutoEnabled) } - let referencedPathResolution = EditFlowPerf.begin(EditFlowPerf.Stage.ReadFile.AutoSelect.referencedPathResolution) + let referencedPathResolution = WorkspaceRuntimePerf.begin(WorkspaceRuntimePerf.Stage.ReadFile.AutoSelect.referencedPathResolution) let referenced = CodeMapExtractor.resolveReferencedFilePaths( from: selected, among: aggregate.orderedFileAPIs, firstFileAPIByStandardizedNestedPath: aggregate.firstFileAPIByStandardizedNestedPath ) - EditFlowPerf.end(EditFlowPerf.Stage.ReadFile.AutoSelect.referencedPathResolution, referencedPathResolution) + WorkspaceRuntimePerf.end(WorkspaceRuntimePerf.Stage.ReadFile.AutoSelect.referencedPathResolution, referencedPathResolution) return StoredSelection(selectedPaths: base.selectedPaths, autoCodemapPaths: referenced, slices: base.slices, codemapAutoEnabled: base.codemapAutoEnabled) } diff --git a/Sources/RepoPrompt/Infrastructure/WorkspaceContext/Slices/LineRange.swift b/Sources/RepoPromptCore/WorkspaceContext/Slices/LineRange.swift similarity index 58% rename from Sources/RepoPrompt/Infrastructure/WorkspaceContext/Slices/LineRange.swift rename to Sources/RepoPromptCore/WorkspaceContext/Slices/LineRange.swift index fb6810e38..91efff74d 100644 --- a/Sources/RepoPrompt/Infrastructure/WorkspaceContext/Slices/LineRange.swift +++ b/Sources/RepoPromptCore/WorkspaceContext/Slices/LineRange.swift @@ -1,13 +1,13 @@ import Foundation /// Represents a 1-based inclusive line range within a file. -public struct LineRange: Codable, Equatable, Hashable, Sendable { - public let start: Int - public let end: Int - /// Optional description explaining what this slice contains and why it's relevant - public let description: String? +package struct LineRange: Codable, Equatable, Hashable { + package let start: Int + package let end: Int + /// Optional description explaining what this slice contains and why it's relevant. + package let description: String? - public init(start: Int, end: Int, description: String? = nil) { + package init(start: Int, end: Int, description: String? = nil) { let clampedStart = max(1, start) let clampedEnd = max(clampedStart, end) self.start = clampedStart diff --git a/Sources/RepoPrompt/Infrastructure/WorkspaceContext/Slices/PartitionStore.swift b/Sources/RepoPromptCore/WorkspaceContext/Slices/PartitionStore.swift similarity index 82% rename from Sources/RepoPrompt/Infrastructure/WorkspaceContext/Slices/PartitionStore.swift rename to Sources/RepoPromptCore/WorkspaceContext/Slices/PartitionStore.swift index 590883149..e1415c644 100644 --- a/Sources/RepoPrompt/Infrastructure/WorkspaceContext/Slices/PartitionStore.swift +++ b/Sources/RepoPromptCore/WorkspaceContext/Slices/PartitionStore.swift @@ -8,12 +8,12 @@ public enum SliceMutationMode: Sendable { case setPaths // file-scoped replacement: replace slices only for specified files } -struct SliceAnchor: Codable, Equatable { - var range: LineRange - var startSignature: [String] - var endSignature: [String] +package struct SliceAnchor: Codable, Equatable { + package var range: LineRange + package var startSignature: [String] + package var endSignature: [String] - init( + package init( range: LineRange, startSignature: [String] = [], endSignature: [String] = [] @@ -34,20 +34,16 @@ public struct PartitionScope: Sendable, Equatable { } } -actor PartitionStore { - /// Posted **after** a successful save so other windows/tabs reload in-memory slices. - static let didSaveNotification = Notification.Name("RepoPrompt.PartitionStoreDidSave") - static let notifRootPathKey = "rootPath" - static let notifWorkspaceIDKey = "workspaceID" - static let notifTabIDKey = "tabID" - static let notifSourceIDKey = "sourceID" - nonisolated let notificationSourceID = UUID() - - /// /RepoPrompt/Partitions - private static func partitionsBaseURL() -> URL { - let base = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first! - return base.appendingPathComponent("RepoPrompt/Partitions", isDirectory: true) - } +package struct PartitionStoreSaveEvent: Equatable { + package let rootPath: String + package let scope: PartitionScope + package let sourceID: UUID +} + +package typealias PartitionStoreSaveEventSink = @Sendable (PartitionStoreSaveEvent) -> Void + +package actor PartitionStore { + package nonisolated let notificationSourceID = UUID() /// repoKey = "-" private func repoKey(forRoot rootPath: String) -> String { @@ -59,12 +55,12 @@ actor PartitionStore { return "\(leaf)-\(short)" } - struct StoredSlices: Codable, Equatable { - var ranges: [LineRange] - var fileModificationTime: Double? - var anchors: [SliceAnchor]? + package struct StoredSlices: Codable, Equatable { + package var ranges: [LineRange] + package var fileModificationTime: Double? + package var anchors: [SliceAnchor]? - init( + package init( ranges: [LineRange], fileModificationTime: Double?, anchors: [SliceAnchor]? = nil @@ -75,12 +71,12 @@ actor PartitionStore { } } - struct SliceUpdate { - var ranges: [LineRange] - var fileModificationTime: Double? - var anchors: [SliceAnchor]? + package struct SliceUpdate { + package var ranges: [LineRange] + package var fileModificationTime: Double? + package var anchors: [SliceAnchor]? - init( + package init( ranges: [LineRange], fileModificationTime: Double?, anchors: [SliceAnchor]? = nil @@ -91,7 +87,7 @@ actor PartitionStore { } } - struct PartitionData: Codable { + package struct PartitionData: Codable { var version: Int var files: [String: StoredSlices] var updatedAt: String? @@ -112,7 +108,7 @@ actor PartitionStore { case updatedAt } - init(from decoder: Decoder) throws { + package init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) version = try container.decodeIfPresent(Int.self, forKey: .version) ?? 1 updatedAt = try container.decodeIfPresent(String.self, forKey: .updatedAt) @@ -124,7 +120,7 @@ actor PartitionStore { } } - func encode(to encoder: Encoder) throws { + package func encode(to encoder: Encoder) throws { var container = encoder.container(keyedBy: CodingKeys.self) try container.encode(version, forKey: .version) try container.encode(files, forKey: .files) @@ -133,19 +129,21 @@ actor PartitionStore { } private let baseURL: URL + private let saveEventSink: PartitionStoreSaveEventSink private let encoder: JSONEncoder private let decoder: JSONDecoder private let dateFormatter = ISO8601DateFormatter() - init(baseURL: URL? = nil) { - self.baseURL = baseURL ?? Self.partitionsBaseURL() + package init(baseURL: URL, saveEventSink: @escaping PartitionStoreSaveEventSink = { _ in }) { + self.baseURL = baseURL.standardizedFileURL + self.saveEventSink = saveEventSink let encoder = JSONEncoder() encoder.outputFormatting = [.prettyPrinted, .sortedKeys] self.encoder = encoder decoder = JSONDecoder() } - func load(forRoot rootPath: String, scope: PartitionScope) async -> PartitionData { + package func load(forRoot rootPath: String, scope: PartitionScope) async -> PartitionData { let primaryURL = partitionURL(forRoot: rootPath, scope: scope) if let partition = loadPartition(at: primaryURL) { return partition @@ -153,7 +151,7 @@ actor PartitionStore { return PartitionData.empty() } - func save(forRoot rootPath: String, scope: PartitionScope, data: PartitionData) async throws { + package func save(forRoot rootPath: String, scope: PartitionScope, data: PartitionData) async throws { let url = partitionURL(forRoot: rootPath, scope: scope) // Ensure directories exist: .../Application Support/RepoPrompt/Partitions// @@ -166,14 +164,17 @@ actor PartitionStore { let encoded = try encoder.encode(dataToPersist) try encoded.write(to: url, options: [.atomic]) - // Inform other windows/tabs within the process to reload slices for this scope - postSaveNotification(rootPath: rootPath, scope: scope) + saveEventSink(PartitionStoreSaveEvent( + rootPath: (rootPath as NSString).standardizingPath, + scope: scope, + sourceID: notificationSourceID + )) } /// High-level mutation helper that loads, mutates, persists, and returns the merged range map. /// Paths are expected to be standardized by the caller; ranges are normalized before persistence. @discardableResult - func apply( + package func apply( forRoot rootPath: String, scope: PartitionScope, updates: [String: SliceUpdate], @@ -333,16 +334,16 @@ actor PartitionStore { let end: Int } - func load(forRoot rootPath: String, workspaceID: UUID) async -> PartitionData { + package func load(forRoot rootPath: String, workspaceID: UUID) async -> PartitionData { await load(forRoot: rootPath, scope: PartitionScope(workspaceID: workspaceID)) } - func save(forRoot rootPath: String, workspaceID: UUID, data: PartitionData) async throws { + package func save(forRoot rootPath: String, workspaceID: UUID, data: PartitionData) async throws { try await save(forRoot: rootPath, scope: PartitionScope(workspaceID: workspaceID), data: data) } @discardableResult - func apply( + package func apply( forRoot rootPath: String, workspaceID: UUID, updates: [String: SliceUpdate], @@ -365,20 +366,6 @@ actor PartitionStore { return folder.appendingPathComponent(fileName, isDirectory: false) } - private func postSaveNotification(rootPath: String, scope: PartitionScope) { - let stdRoot = (rootPath as NSString).standardizingPath - NotificationCenter.default.post( - name: Self.didSaveNotification, - object: nil, - userInfo: [ - Self.notifRootPathKey: stdRoot, - Self.notifWorkspaceIDKey: scope.workspaceID, - Self.notifTabIDKey: scope.tabID as Any, - Self.notifSourceIDKey: notificationSourceID - ] - ) - } - private func loadPartition(at url: URL) -> PartitionData? { do { let data = try Data(contentsOf: url) diff --git a/Sources/RepoPrompt/Infrastructure/WorkspaceContext/Slices/SelectionSliceCoordinator.swift b/Sources/RepoPromptCore/WorkspaceContext/Slices/SelectionSliceCoordinator.swift similarity index 94% rename from Sources/RepoPrompt/Infrastructure/WorkspaceContext/Slices/SelectionSliceCoordinator.swift rename to Sources/RepoPromptCore/WorkspaceContext/Slices/SelectionSliceCoordinator.swift index d3a529dac..23d48d96e 100644 --- a/Sources/RepoPrompt/Infrastructure/WorkspaceContext/Slices/SelectionSliceCoordinator.swift +++ b/Sources/RepoPromptCore/WorkspaceContext/Slices/SelectionSliceCoordinator.swift @@ -5,8 +5,8 @@ import Foundation /// This service intentionally does not read or write compose-tab `StoredSelection` values. /// `WorkspaceManagerViewModel` remains the only writer of compose-tab selections; this /// coordinator only loads and mutates partition-backed slice state. -actor SelectionSliceCoordinator { - struct RootScopeRequest { +package actor SelectionSliceCoordinator { + package struct RootScopeRequest { let rootPath: String let scope: PartitionScope @@ -20,7 +20,7 @@ actor SelectionSliceCoordinator { } } - struct SliceUpdate { + package struct SliceUpdate { let relativePath: String let ranges: [LineRange] let fileModificationTime: Double? @@ -49,16 +49,16 @@ actor SelectionSliceCoordinator { } private let store: PartitionStore - nonisolated let notificationSourceID: UUID + package nonisolated let notificationSourceID: UUID - init(store: PartitionStore = PartitionStore()) { + package init(store: PartitionStore) { self.store = store notificationSourceID = store.notificationSourceID } // MARK: - Loading - func loadSlices( + package func loadSlices( forRootPath rootPath: String, scope: PartitionScope ) async -> [String: PartitionStore.StoredSlices] { @@ -67,14 +67,14 @@ actor SelectionSliceCoordinator { return data.files } - func loadSlices( + package func loadSlices( forRoot root: WorkspaceRootRecord, scope: PartitionScope ) async -> [String: PartitionStore.StoredSlices] { await loadSlices(forRootPath: root.standardizedFullPath, scope: scope) } - func loadSlices( + package func loadSlices( forRoots roots: [WorkspaceRootRecord], scope: PartitionScope ) async -> [String: [String: PartitionStore.StoredSlices]] { @@ -83,7 +83,7 @@ actor SelectionSliceCoordinator { ) } - func loadSlices( + package func loadSlices( for requests: [RootScopeRequest] ) async -> [String: [String: PartitionStore.StoredSlices]] { var result: [String: [String: PartitionStore.StoredSlices]] = [:] @@ -97,7 +97,7 @@ actor SelectionSliceCoordinator { // MARK: - Applying updates @discardableResult - func applyPartitionUpdates( + package func applyPartitionUpdates( forRootPath rootPath: String, scope: PartitionScope, updates: [String: PartitionStore.SliceUpdate], @@ -112,7 +112,7 @@ actor SelectionSliceCoordinator { } @discardableResult - func applySliceUpdates( + package func applySliceUpdates( groupedByRootPath updatesByRootPath: [String: [SliceUpdate]], scope: PartitionScope, mode: SliceMutationMode @@ -133,7 +133,7 @@ actor SelectionSliceCoordinator { } @discardableResult - func applySliceUpdates( + package func applySliceUpdates( groupedByRootPath updatesByRootPath: [String: [String: SliceUpdate]], scope: PartitionScope, mode: SliceMutationMode @@ -145,7 +145,7 @@ actor SelectionSliceCoordinator { // MARK: - Moves and clears @discardableResult - func moveSliceState( + package func moveSliceState( rootPath: String, oldRelativePath: String, newRelativePath: String, @@ -189,7 +189,7 @@ actor SelectionSliceCoordinator { } @discardableResult - func clearSlices( + package func clearSlices( forRootPaths rootPaths: [String], scope: PartitionScope ) async throws -> [String: [String: PartitionStore.StoredSlices]] { @@ -208,7 +208,7 @@ actor SelectionSliceCoordinator { } @discardableResult - func clearSlices( + package func clearSlices( forRoots roots: [WorkspaceRootRecord], scope: PartitionScope ) async throws -> [String: [String: PartitionStore.StoredSlices]] { @@ -216,7 +216,7 @@ actor SelectionSliceCoordinator { } @discardableResult - func removeSlices( + package func removeSlices( forPaths pathsByRootPath: [String: [String]], scope: PartitionScope ) async throws -> [String: [String: PartitionStore.StoredSlices]] { @@ -230,7 +230,7 @@ actor SelectionSliceCoordinator { } @discardableResult - func removeSlices( + package func removeSlices( forRootPaths rootPaths: [String], scope: PartitionScope ) async throws -> [String: [String: PartitionStore.StoredSlices]] { @@ -245,7 +245,7 @@ actor SelectionSliceCoordinator { // MARK: - Projection - nonisolated static func buildFileIDProjection( + package nonisolated static func buildFileIDProjection( from sliceMapsByRootPath: [String: [String: PartitionStore.StoredSlices]], files: [WorkspaceFileRecord] ) -> [UUID: [LineRange]] { diff --git a/Sources/RepoPrompt/Infrastructure/WorkspaceContext/Slices/SliceAssembly.swift b/Sources/RepoPromptCore/WorkspaceContext/Slices/SliceAssembly.swift similarity index 77% rename from Sources/RepoPrompt/Infrastructure/WorkspaceContext/Slices/SliceAssembly.swift rename to Sources/RepoPromptCore/WorkspaceContext/Slices/SliceAssembly.swift index 69ea47783..16e7c086b 100644 --- a/Sources/RepoPrompt/Infrastructure/WorkspaceContext/Slices/SliceAssembly.swift +++ b/Sources/RepoPromptCore/WorkspaceContext/Slices/SliceAssembly.swift @@ -1,25 +1,46 @@ import Foundation -struct WorkspaceSliceSegment: Equatable { - let range: LineRange - let text: String +package struct WorkspaceSliceSegment: Equatable { + package let range: LineRange + package let text: String + + package init(range: LineRange, text: String) { + self.range = range + self.text = text + } } -struct WorkspaceSliceAssembly: Equatable { - let segments: [WorkspaceSliceSegment] - let combinedText: String - let totalLines: Int - let detectedLineEnding: String - let usedRanges: [LineRange] - let isFullFile: Bool +package struct WorkspaceSliceAssembly: Equatable { + package let segments: [WorkspaceSliceSegment] + package let combinedText: String + package let totalLines: Int + package let detectedLineEnding: String + package let usedRanges: [LineRange] + package let isFullFile: Bool - var totalCharacters: Int { + package var totalCharacters: Int { combinedText.count } + + package init( + segments: [WorkspaceSliceSegment], + combinedText: String, + totalLines: Int, + detectedLineEnding: String, + usedRanges: [LineRange], + isFullFile: Bool + ) { + self.segments = segments + self.combinedText = combinedText + self.totalLines = totalLines + self.detectedLineEnding = detectedLineEnding + self.usedRanges = usedRanges + self.isFullFile = isFullFile + } } -enum SliceAssemblyBuilder { - static func build(from content: String, ranges: [LineRange]?) -> WorkspaceSliceAssembly { +package enum SliceAssemblyBuilder { + package static func build(from content: String, ranges: [LineRange]?) -> WorkspaceSliceAssembly { let pairs = String.splitContentPreservingAllLineEndings(content) let (_, detectedEnding) = String.splitContentPreservingLineEndings(content) let totalLines = pairs.count diff --git a/Sources/RepoPrompt/Infrastructure/WorkspaceContext/Slices/SliceRangeMath.swift b/Sources/RepoPromptCore/WorkspaceContext/Slices/SliceRangeMath.swift similarity index 91% rename from Sources/RepoPrompt/Infrastructure/WorkspaceContext/Slices/SliceRangeMath.swift rename to Sources/RepoPromptCore/WorkspaceContext/Slices/SliceRangeMath.swift index e47d5f771..889f3025d 100644 --- a/Sources/RepoPrompt/Infrastructure/WorkspaceContext/Slices/SliceRangeMath.swift +++ b/Sources/RepoPromptCore/WorkspaceContext/Slices/SliceRangeMath.swift @@ -1,7 +1,7 @@ import Foundation -enum SliceRangeMath { - static func normalize(_ ranges: [LineRange]) -> [LineRange] { +package enum SliceRangeMath { + package static func normalize(_ ranges: [LineRange]) -> [LineRange] { let filtered = ranges.filter { $0.start <= $0.end } guard !filtered.isEmpty else { return [] } func mergedDescription(_ lhs: LineRange, _ rhs: LineRange) -> String? { @@ -34,11 +34,11 @@ enum SliceRangeMath { return merged } - static func coalesce(_ lhs: [LineRange], _ rhs: [LineRange]) -> [LineRange] { + package static func coalesce(_ lhs: [LineRange], _ rhs: [LineRange]) -> [LineRange] { normalize(lhs + rhs) } - static func subtract(_ base: [LineRange], removing: [LineRange]) -> [LineRange] { + package static func subtract(_ base: [LineRange], removing: [LineRange]) -> [LineRange] { let baseNormalized = normalize(base) let removingNormalized = normalize(removing) guard !baseNormalized.isEmpty, !removingNormalized.isEmpty else { diff --git a/Sources/RepoPrompt/Infrastructure/WorkspaceContext/Slices/SliceRebaseEngine.swift b/Sources/RepoPromptCore/WorkspaceContext/Slices/SliceRebaseEngine.swift similarity index 95% rename from Sources/RepoPrompt/Infrastructure/WorkspaceContext/Slices/SliceRebaseEngine.swift rename to Sources/RepoPromptCore/WorkspaceContext/Slices/SliceRebaseEngine.swift index b9caa1d9a..e91866ae2 100644 --- a/Sources/RepoPrompt/Infrastructure/WorkspaceContext/Slices/SliceRebaseEngine.swift +++ b/Sources/RepoPromptCore/WorkspaceContext/Slices/SliceRebaseEngine.swift @@ -1,11 +1,17 @@ import CryptoKit import Foundation -enum SliceRebaseEngine { - struct Result { - let rebased: [LineRange] - let dropped: [LineRange] - let didChange: Bool +package enum SliceRebaseEngine { + package struct Result { + package let rebased: [LineRange] + package let dropped: [LineRange] + package let didChange: Bool + + package init(rebased: [LineRange], dropped: [LineRange], didChange: Bool) { + self.rebased = rebased + self.dropped = dropped + self.didChange = didChange + } } private struct RangeKey: Hashable { @@ -18,7 +24,7 @@ enum SliceRebaseEngine { let unresolved: [LineRange] } - static func rebase( + package static func rebase( oldText: String?, newText: String, oldRanges: [LineRange], @@ -104,7 +110,7 @@ enum SliceRebaseEngine { ) } - static func buildAnchors(content: String, ranges: [LineRange], maxSignatureLines: Int = 3) -> [SliceAnchor] { + package static func buildAnchors(content: String, ranges: [LineRange], maxSignatureLines: Int = 3) -> [SliceAnchor] { let normalized = SliceRangeMath.normalize(ranges) guard !normalized.isEmpty else { return [] } diff --git a/Sources/RepoPrompt/Infrastructure/WorkspaceContext/TokenAccounting/TokenCalculationService.swift b/Sources/RepoPromptCore/WorkspaceContext/TokenAccounting/TokenCalculationService.swift similarity index 66% rename from Sources/RepoPrompt/Infrastructure/WorkspaceContext/TokenAccounting/TokenCalculationService.swift rename to Sources/RepoPromptCore/WorkspaceContext/TokenAccounting/TokenCalculationService.swift index 0d878fd3c..30acea485 100644 --- a/Sources/RepoPrompt/Infrastructure/WorkspaceContext/TokenAccounting/TokenCalculationService.swift +++ b/Sources/RepoPromptCore/WorkspaceContext/TokenAccounting/TokenCalculationService.swift @@ -5,23 +5,22 @@ // Created by Eric Provencher on 2025-01-21. // -import Combine import Foundation /// Info for each file/folder's tokens /// Stores both full file tokens and codemap tokens to support different rendering modes. -struct TokenInfo: Identifiable, Equatable { - let id = UUID() +package struct TokenInfo: Identifiable, Equatable { + package let id = UUID() /// The "display" token count based on current rendering mode (full, slice, or codemap) - let count: Int + package let count: Int /// Always stores the full file content tokens (for `fullTokens()` lookups) - let fullCount: Int + package let fullCount: Int /// Stores the codemap token count if available (0 if no codemap) - let codemapCount: Int - let formatted: String - let percentage: Double + package let codemapCount: Int + package let formatted: String + package let percentage: Double - init(count: Int, fullCount: Int? = nil, codemapCount: Int = 0, totalTokens: Int) { + package init(count: Int, fullCount: Int? = nil, codemapCount: Int = 0, totalTokens: Int) { self.count = count self.fullCount = fullCount ?? count self.codemapCount = codemapCount @@ -32,76 +31,92 @@ struct TokenInfo: Identifiable, Equatable { /// High-level struct for returning all relevant token results. /// Now includes codeMapFileCount and codeMapTokenCount. -struct TokenCalculationResult { - let totalTokenCount: Int +package struct TokenCalculationResult { + package let totalTokenCount: Int // NEW – raw integer count of tokens for **files only** - let totalTokenCountFilesOnly: Int - let fileTokenInfo: [UUID: TokenInfo] - let folderTokenInfo: [String: TokenInfo] - let tokenCountString: String - let tokenCountFilesOnlyString: String - let charCount: Int - let fileTreeContent: String - let fileTreeTokenCount: Double - let fileTreeTokenCountRaw: Int - let codeMapContent: String // NEW – raw code-map block text - let codeMapFileCount: Int - let codeMapTokenCount: Int + package let totalTokenCountFilesOnly: Int + package let fileTokenInfo: [UUID: TokenInfo] + package let folderTokenInfo: [String: TokenInfo] + package let tokenCountString: String + package let tokenCountFilesOnlyString: String + package let charCount: Int + package let fileTreeContent: String + package let fileTreeTokenCount: Double + package let fileTreeTokenCountRaw: Int + package let codeMapContent: String // NEW – raw code-map block text + package let codeMapFileCount: Int + package let codeMapTokenCount: Int } -struct TokenComponentBreakdown { - let prompt: Int - let duplicatePrompt: Int - let instructions: Int - let fileTree: Int - let gitDiff: Int - let metadata: Int +package struct TokenComponentBreakdown { + package let prompt: Int + package let duplicatePrompt: Int + package let instructions: Int + package let fileTree: Int + package let gitDiff: Int + package let metadata: Int + + package init( + prompt: Int, + duplicatePrompt: Int, + instructions: Int, + fileTree: Int, + gitDiff: Int, + metadata: Int + ) { + self.prompt = prompt + self.duplicatePrompt = duplicatePrompt + self.instructions = instructions + self.fileTree = fileTree + self.gitDiff = gitDiff + self.metadata = metadata + } - var promptDisplay: Int { + package var promptDisplay: Int { prompt + duplicatePrompt } - var other: Int { + package var other: Int { metadata } - var totalNonFile: Int { + package var totalNonFile: Int { prompt + duplicatePrompt + instructions + fileTree + gitDiff + metadata } } -struct PromptEntriesEvaluation { - enum RenderMode: String { +package struct PromptEntriesEvaluation { + package enum RenderMode: String { case full case slice case codemap } - struct EntryResult { - let fileID: UUID - let renderMode: RenderMode - let displayTokens: Int - let fullTokens: Int - let codemapTokens: Int + package struct EntryResult { + package let fileID: UUID + package let renderMode: RenderMode + package let displayTokens: Int + package let fullTokens: Int + package let codemapTokens: Int } - let entryResultsByFileID: [UUID: EntryResult] - let totalDisplayTokens: Int - let totalContentTokens: Int - let fullCount: Int - let sliceCount: Int - let codemapCount: Int - let fullTokens: Int - let sliceTokens: Int - let codemapTokens: Int - let fileTokenInfo: [UUID: TokenInfo] - let folderTokenInfo: [String: TokenInfo] - let charCount: Int - let codeMapContent: String - let codeMapFileCount: Int - let codeMapTokenCount: Int - - static let empty = PromptEntriesEvaluation( + package let entryResultsByFileID: [UUID: EntryResult] + package let totalDisplayTokens: Int + package let totalContentTokens: Int + package let fullCount: Int + package let sliceCount: Int + package let codemapCount: Int + package let fullTokens: Int + package let sliceTokens: Int + package let codemapTokens: Int + package let fileTokenInfo: [UUID: TokenInfo] + package let folderTokenInfo: [String: TokenInfo] + package let charCount: Int + package let codeMapContent: String + package let codeMapFileCount: Int + package let codeMapTokenCount: Int + + package static let empty = PromptEntriesEvaluation( entryResultsByFileID: [:], totalDisplayTokens: 0, totalContentTokens: 0, @@ -121,13 +136,31 @@ struct PromptEntriesEvaluation { } /// An actor for gathering file contents and running token calculations. -actor TokenCalculationService { +package actor TokenCalculationService { + typealias CalculationOperation = @Sendable (TokenCalculationSnapshot) async throws -> TokenCalculationResult + + package init() { + calculationOperation = Self.performCalculation + } + + init(calculationOperation: @escaping CalculationOperation) { + self.calculationOperation = calculationOperation + } + + private struct LegacyCalculationTask { + let generation: UInt64 + let task: Task + } + + private let calculationOperation: CalculationOperation + private var nextLegacyCalculationGeneration: UInt64 = 0 + /// Hold the currently running calculation task. - private var currentCalculationTask: Task? + private var currentCalculationTask: LegacyCalculationTask? /// Compute tokens from raw text using a cheap UTF-8 byte count plus a safety multiplier. @inline(__always) - static func estimateTokens(for text: String) -> Int { + package static func estimateTokens(for text: String) -> Int { let bytes = text.utf8.count return Int((Double(bytes) / 4.0) * 1.05) } @@ -137,7 +170,7 @@ actor TokenCalculationService { /// /// Uses a simple 4-bytes-per-token heuristic. Cut points are aligned to grapheme /// cluster boundaries to avoid splitting characters. - static func middleTruncate( + package static func middleTruncate( text: String, maxTokens: Int, marker: String = "\n\n[content truncated]\n\n" @@ -193,6 +226,10 @@ actor TokenCalculationService { } } + package static func composeCodemapContent(_ parts: [String]) -> String { + joinWithNewlines(parts.filter { !$0.isEmpty }) + } + /// Join an array of strings with newline separators using reserved capacity to avoid churn. @inline(__always) private static func joinWithNewlines(_ parts: [String]) -> String { @@ -218,7 +255,7 @@ actor TokenCalculationService { return joined } - static func calculateComponentBreakdown( + package static func calculateComponentBreakdown( promptText: String, selectedInstructionsText: String, fileTreeText: String, @@ -242,7 +279,7 @@ actor TokenCalculationService { ) } - func evaluatePromptEntries( + package func evaluatePromptEntries( _ fileEntries: [PromptFileEntrySnapshot] ) -> PromptEntriesEvaluation { Self.evaluatePromptEntries(fileEntries) @@ -268,7 +305,7 @@ actor TokenCalculationService { fileCount += 1 tokenCount += entry.availableCodeMapTokenCount } - return (Self.joinWithNewlines(snippets), fileCount, tokenCount) + return (Self.composeCodemapContent(snippets), fileCount, tokenCount) }() let aggregated = Self.calculateEntryTokens( @@ -297,71 +334,107 @@ actor TokenCalculationService { } /// Calculate token statistics. Heavy work is offloaded and cancellation is checked. - func calculatePromptStats( + package func calculatePromptStats( snapshot: TokenCalculationSnapshot ) async -> TokenCalculationResult { - currentCalculationTask?.cancel() - - currentCalculationTask = Task.detached { - let evaluation = Self.evaluatePromptEntries(snapshot.promptEntries) - if Task.isCancelled { return Self.defaultResult } - - let fileTreeContent: String = switch snapshot.fileTree { - case .none: - "" - case let .rendered(content): - content - case let .snapshot(treeSnapshot): - CodeMapExtractor.generateFileTree(using: treeSnapshot) + currentCalculationTask?.task.cancel() + + nextLegacyCalculationGeneration &+= 1 + let generation = nextLegacyCalculationGeneration + let calculationOperation = calculationOperation + let task = Task.detached { + do { + return try await calculationOperation(snapshot) + } catch { + return Self.defaultResult } - let fileTreeTokens = fileTreeContent.isEmpty ? 0 : Self.estimateTokens(for: fileTreeContent) - let components = Self.calculateComponentBreakdown( - promptText: snapshot.promptText, - selectedInstructionsText: snapshot.selectedInstructionsText, - fileTreeText: fileTreeContent, - gitDiffText: nil, - metadataText: nil, - duplicateUserInstructionsAtTop: snapshot.duplicateUserInstructionsAtTop - ) - - let finalTotalTokens = evaluation.totalDisplayTokens + components.totalNonFile - let finalCharCount = evaluation.charCount - + snapshot.promptText.count - + (snapshot.duplicateUserInstructionsAtTop ? snapshot.promptText.count : 0) - + snapshot.selectedInstructionsText.count - - return TokenCalculationResult( - totalTokenCount: finalTotalTokens, - totalTokenCountFilesOnly: evaluation.totalContentTokens, - fileTokenInfo: evaluation.fileTokenInfo, - folderTokenInfo: evaluation.folderTokenInfo, - tokenCountString: String(format: "%.2fk", Double(finalTotalTokens) / 1000.0), - tokenCountFilesOnlyString: String(format: "%.2fk", Double(evaluation.totalContentTokens) / 1000.0), - charCount: finalCharCount, - fileTreeContent: fileTreeContent, - fileTreeTokenCount: Double(fileTreeTokens) / 1000.0, - fileTreeTokenCountRaw: fileTreeTokens, - codeMapContent: evaluation.codeMapContent, - codeMapFileCount: evaluation.codeMapFileCount, - codeMapTokenCount: evaluation.codeMapTokenCount - ) } + currentCalculationTask = LegacyCalculationTask(generation: generation, task: task) - let result = await currentCalculationTask!.value - currentCalculationTask = nil + let result = await task.value + if currentCalculationTask?.generation == generation { + currentCalculationTask = nil + } return result } + /// Calculate token statistics without participating in the shared latest-call-wins task lifecycle. + package func calculatePromptStatsScoped( + snapshot: TokenCalculationSnapshot + ) async throws -> TokenCalculationResult { + let calculationOperation = calculationOperation + let task = Task.detached { + try await calculationOperation(snapshot) + } + return try await withTaskCancellationHandler { + let result = try await task.value + try Task.checkCancellation() + return result + } onCancel: { + task.cancel() + } + } + + private static func performCalculation( + snapshot: TokenCalculationSnapshot + ) async throws -> TokenCalculationResult { + let evaluation = Self.evaluatePromptEntries(snapshot.promptEntries) + try Task.checkCancellation() + + let fileTreeContent: String = switch snapshot.fileTree { + case .none: + "" + case let .rendered(content): + content + case let .snapshot(treeSnapshot): + FileTreeSnapshotRenderer.generateFileTree(using: treeSnapshot) + } + let fileTreeTokens = fileTreeContent.isEmpty ? 0 : Self.estimateTokens(for: fileTreeContent) + let components = Self.calculateComponentBreakdown( + promptText: snapshot.promptText, + selectedInstructionsText: snapshot.selectedInstructionsText, + fileTreeText: fileTreeContent, + gitDiffText: nil, + metadataText: nil, + duplicateUserInstructionsAtTop: snapshot.duplicateUserInstructionsAtTop + ) + + let finalTotalTokens = evaluation.totalDisplayTokens + components.totalNonFile + let finalCharCount = evaluation.charCount + + snapshot.promptText.count + + (snapshot.duplicateUserInstructionsAtTop ? snapshot.promptText.count : 0) + + snapshot.selectedInstructionsText.count + + return TokenCalculationResult( + totalTokenCount: finalTotalTokens, + totalTokenCountFilesOnly: evaluation.totalContentTokens, + fileTokenInfo: evaluation.fileTokenInfo, + folderTokenInfo: evaluation.folderTokenInfo, + tokenCountString: String(format: "%.2fk", Double(finalTotalTokens) / 1000.0), + tokenCountFilesOnlyString: String(format: "%.2fk", Double(evaluation.totalContentTokens) / 1000.0), + charCount: finalCharCount, + fileTreeContent: fileTreeContent, + fileTreeTokenCount: Double(fileTreeTokens) / 1000.0, + fileTreeTokenCountRaw: fileTreeTokens, + codeMapContent: evaluation.codeMapContent, + codeMapFileCount: evaluation.codeMapFileCount, + codeMapTokenCount: evaluation.codeMapTokenCount + ) + } + /// Cancel any pending token calculations. - func shutdown() async { - currentCalculationTask?.cancel() - _ = await currentCalculationTask?.value - currentCalculationTask = nil + package func shutdown() async { + guard let calculation = currentCalculationTask else { return } + calculation.task.cancel() + _ = await calculation.task.value + if currentCalculationTask?.generation == calculation.generation { + currentCalculationTask = nil + } } private static func buildSliceAssemblies( for entries: [PromptFileEntrySnapshot] - ) -> [UUID: FileViewModel.SliceAssembly] { + ) -> [UUID: WorkspaceSliceAssembly] { let candidates = entries.filter { entry in if let ranges = entry.ranges { return !ranges.isEmpty @@ -370,12 +443,12 @@ actor TokenCalculationService { } guard !candidates.isEmpty else { return [:] } - var result: [UUID: FileViewModel.SliceAssembly] = [:] + var result: [UUID: WorkspaceSliceAssembly] = [:] result.reserveCapacity(candidates.count) for entry in candidates { if Task.isCancelled { break } guard let content = entry.loadedContent else { continue } - result[entry.fileID] = FileViewModel.buildSliceAssembly(from: content, ranges: entry.ranges) + result[entry.fileID] = SliceAssemblyBuilder.build(from: content, ranges: entry.ranges) } return result } @@ -386,9 +459,9 @@ actor TokenCalculationService { let fullCount: Int let sliceCount: Int let codemapCount: Int - let fullTokens: Int + package let fullTokens: Int let sliceTokens: Int - let codemapTokens: Int + package let codemapTokens: Int let fileTokenInfo: [UUID: TokenInfo] let folderTokenInfo: [String: TokenInfo] let charCount: Int @@ -398,7 +471,7 @@ actor TokenCalculationService { contentEntries: [PromptFileEntrySnapshot], codemapEntries: [PromptFileEntrySnapshot], unresolvedCodemapEntries: [PromptFileEntrySnapshot], - sliceAssemblies: [UUID: FileViewModel.SliceAssembly] + sliceAssemblies: [UUID: WorkspaceSliceAssembly] ) -> AggregatedEntryTokens { var entryResultsByFileID: [UUID: PromptEntriesEvaluation.EntryResult] = [:] var folderTokenAccum: [String: Int] = [:] diff --git a/Sources/RepoPromptCore/WorkspaceContext/TokenAccounting/TokenCalculationSnapshot.swift b/Sources/RepoPromptCore/WorkspaceContext/TokenAccounting/TokenCalculationSnapshot.swift new file mode 100644 index 000000000..fffae4887 --- /dev/null +++ b/Sources/RepoPromptCore/WorkspaceContext/TokenAccounting/TokenCalculationSnapshot.swift @@ -0,0 +1,60 @@ +import Foundation + +package struct PromptFileEntrySnapshot { + package let fileID: UUID + package let relativePath: String + package let isCodemapRequested: Bool + package let ranges: [LineRange]? + package let cachedFullTokenCount: Int? + package let loadedContent: String? + package let codeMapContent: String? + package let availableCodeMapTokenCount: Int + + package init( + fileID: UUID, + relativePath: String, + isCodemapRequested: Bool, + ranges: [LineRange]?, + cachedFullTokenCount: Int?, + loadedContent: String?, + codeMapContent: String?, + availableCodeMapTokenCount: Int + ) { + self.fileID = fileID + self.relativePath = relativePath + self.isCodemapRequested = isCodemapRequested + self.ranges = ranges + self.cachedFullTokenCount = cachedFullTokenCount + self.loadedContent = loadedContent + self.codeMapContent = codeMapContent + self.availableCodeMapTokenCount = availableCodeMapTokenCount + } +} + +package enum TokenCalculationFileTreeInput { + case none + case rendered(String) + case snapshot(FileTreeSelectionSnapshot) +} + +package struct TokenCalculationSnapshot { + package let promptText: String + package let selectedInstructionsText: String + package let duplicateUserInstructionsAtTop: Bool + package let promptEntries: [PromptFileEntrySnapshot] + package let fileTree: TokenCalculationFileTreeInput + + package init( + promptText: String, + selectedInstructionsText: String, + duplicateUserInstructionsAtTop: Bool, + promptEntries: [PromptFileEntrySnapshot], + fileTree: TokenCalculationFileTreeInput + ) { + self.promptText = promptText + self.selectedInstructionsText = selectedInstructionsText + self.duplicateUserInstructionsAtTop = duplicateUserInstructionsAtTop + self.promptEntries = promptEntries + self.fileTree = fileTree + } +} diff --git a/Sources/RepoPrompt/Infrastructure/WorkspaceContext/WorkspaceFileContextStore.swift b/Sources/RepoPromptCore/WorkspaceContext/WorkspaceFileContextStore.swift similarity index 79% rename from Sources/RepoPrompt/Infrastructure/WorkspaceContext/WorkspaceFileContextStore.swift rename to Sources/RepoPromptCore/WorkspaceContext/WorkspaceFileContextStore.swift index f39bd7418..7cff8adc6 100644 --- a/Sources/RepoPrompt/Infrastructure/WorkspaceContext/WorkspaceFileContextStore.swift +++ b/Sources/RepoPromptCore/WorkspaceContext/WorkspaceFileContextStore.swift @@ -1,16 +1,14 @@ -import Combine -import CoreServices import Dispatch import Foundation -enum WorkspaceFileTreeSnapshotMode: String { +package enum WorkspaceFileTreeSnapshotMode: String { case none case selected case full case folders case auto - init(fileTreeOption: FileTreeOption) { + package init(fileTreeOption: FileTreeOption) { switch fileTreeOption { case .none: self = .none @@ -24,18 +22,18 @@ enum WorkspaceFileTreeSnapshotMode: String { } } -struct WorkspaceFileTreeSnapshotRequest { +package struct WorkspaceFileTreeSnapshotRequest { fileprivate let selectedFileIDs: Set - let mode: WorkspaceFileTreeSnapshotMode - let filePathDisplay: FilePathDisplay - let onlyIncludeRootsWithSelectedFiles: Bool - let includeLegend: Bool - let showCodeMapMarkers: Bool - let rootScope: WorkspaceLookupRootScope - let startPath: String? - let maxDepth: Int? - - init( + package let mode: WorkspaceFileTreeSnapshotMode + package let filePathDisplay: FilePathDisplay + package let onlyIncludeRootsWithSelectedFiles: Bool + package let includeLegend: Bool + package let showCodeMapMarkers: Bool + package let rootScope: WorkspaceLookupRootScope + package let startPath: String? + package let maxDepth: Int? + + package init( mode: WorkspaceFileTreeSnapshotMode, filePathDisplay: FilePathDisplay, onlyIncludeRootsWithSelectedFiles: Bool, @@ -57,58 +55,58 @@ struct WorkspaceFileTreeSnapshotRequest { } } -struct WorkspaceObservedCodemapResult: @unchecked Sendable { - let fullPath: String - let modificationDate: Date - let fileAPI: FileAPI? +package struct WorkspaceObservedCodemapResult: @unchecked Sendable { + package let fullPath: String + package let modificationDate: Date + package let fileAPI: FileAPI? - init(fullPath: String, modificationDate: Date, fileAPI: FileAPI?) { + package init(fullPath: String, modificationDate: Date, fileAPI: FileAPI?) { self.fullPath = StandardizedPath.absolute(fullPath) self.modificationDate = modificationDate self.fileAPI = fileAPI } } -struct WorkspaceCodemapFileAPIAggregate { - let orderedFileAPIs: [FileAPI] - let firstFileAPIByStandardizedNestedPath: [String: FileAPI] +package struct WorkspaceCodemapFileAPIAggregate { + package let orderedFileAPIs: [FileAPI] + package let firstFileAPIByStandardizedNestedPath: [String: FileAPI] } -enum WorkspaceFileCatalogMaterializationResult: Equatable { +package enum WorkspaceFileCatalogMaterializationResult: Equatable { case materialized(WorkspaceFileRecord) case ineligible(CatalogRegularFileIneligibilityReason) - var file: WorkspaceFileRecord? { + package var file: WorkspaceFileRecord? { if case let .materialized(file) = self { return file } return nil } - var ineligibilityReason: CatalogRegularFileIneligibilityReason? { + package var ineligibilityReason: CatalogRegularFileIneligibilityReason? { if case let .ineligible(reason) = self { return reason } return nil } } -enum WorkspaceExplicitFileMaterializationResult: Equatable { +package enum WorkspaceExplicitFileMaterializationResult: Equatable { case materialized(WorkspaceFileRecord) case noCandidate case blocked case ambiguous } -enum WorkspaceExplicitCatalogFileLookupResult: Equatable { +package enum WorkspaceExplicitCatalogFileLookupResult: Equatable { case matched(WorkspaceFileRecord) case noCandidate case blocked case ambiguous } -struct WorkspaceDisplayRootRefsSnapshot: Equatable { - let visibleRoots: [WorkspaceRootRef] - let allRoots: [WorkspaceRootRef] +package struct WorkspaceDisplayRootRefsSnapshot: Equatable { + package let visibleRoots: [WorkspaceRootRef] + package let allRoots: [WorkspaceRootRef] } -actor WorkspaceFileContextStore { +package actor WorkspaceFileContextStore { private struct RootState { let root: WorkspaceRootRecord let service: FileSystemService @@ -161,6 +159,7 @@ actor WorkspaceFileContextStore { private var watcherServiceStateWillReconcileHandler: (@Sendable (UUID, Bool) async -> Void)? private var appliedIngressDidCaptureWatermarksHandler: (@Sendable ([UUID: UInt64]) async -> Void)? private var scopedIngressBarrierWillFlushHandler: (@Sendable (UUID) async -> Void)? + private var workspaceCaptureDidPrepareHandler: (@Sendable (Int, UInt64) async -> Void)? private var scopedIngressBarrierLaunchCountsByRootID: [UUID: Int] = [:] private var scopedIngressBarrierJoinCountsByRootID: [UUID: Int] = [:] private var scopedIngressBarrierSuccessorCountsByRootID: [UUID: Int] = [:] @@ -201,6 +200,10 @@ actor WorkspaceFileContextStore { scopedIngressBarrierWillFlushHandler = handler } + func setWorkspaceCaptureDidPrepareHandler(_ handler: (@Sendable (Int, UInt64) async -> Void)?) { + workspaceCaptureDidPrepareHandler = handler + } + func scopedIngressBarrierStatsForTesting(rootID: UUID) -> ScopedIngressBarrierStats { ScopedIngressBarrierStats( launchCount: scopedIngressBarrierLaunchCountsByRootID[rootID] ?? 0, @@ -213,11 +216,11 @@ actor WorkspaceFileContextStore { await searchDecodedContentCache.snapshotForTesting() } - func searchLaneSnapshotForTesting() async -> StoreBackedWorkspaceSearchLane.Snapshot { + package func searchLaneSnapshotForTesting() async -> StoreBackedWorkspaceSearchLane.Snapshot { await storeBackedSearchLane.snapshotForTesting() } - func configureSearchLaneForTesting( + package func configureSearchLaneForTesting( _ configuration: StoreBackedWorkspaceSearchLane.Configuration ) async -> StoreBackedWorkspaceSearchLane.DebugConfigurationUpdateResult { await storeBackedSearchLane.configureForTesting(configuration) @@ -259,6 +262,49 @@ actor WorkspaceFileContextStore { let snapshot: WorkspaceSearchCatalogSnapshot } + private struct WorkspaceCaptureCatalogIdentity: Equatable { + let validationToken: UInt64 + let rootIDs: [UUID] + } + + private enum PreparedWorkspaceCapturePathResolution { + case file(UUID) + case folder(UUID, descendantFileIDs: [UUID]) + case unresolved(PathResolutionIssue) + + var selectedFileIDs: Set { + switch self { + case let .file(fileID): + [fileID] + case let .folder(_, descendantFileIDs): + Set(descendantFileIDs) + case .unresolved: + [] + } + } + } + + private struct PreparedWorkspaceCapturePath { + let input: String + let resolution: PreparedWorkspaceCapturePathResolution + } + + private struct PreparedWorkspaceCaptureSlice { + let path: String + let ranges: [LineRange] + let fileID: UUID? + let issue: PathResolutionIssue? + } + + private struct PreparedWorkspaceCapture { + let selectedPaths: [PreparedWorkspaceCapturePath] + let autoCodemapPaths: [PreparedWorkspaceCapturePath] + let slices: [PreparedWorkspaceCaptureSlice] + let selectedFileIDs: Set + let startFolderID: UUID? + } + + private let runtimeDependencies: WorkspaceRuntimeDependencies private var rootStatesByID: [UUID: RootState] = [:] private var rootIDsByStandardizedPath: [String: UUID] = [:] private var foldersByID: [UUID: WorkspaceFolderRecord] = [:] @@ -289,7 +335,7 @@ actor WorkspaceFileContextStore { private static let maxCachedSearchCatalogSnapshotScopes = 16 private static let defaultMaxPendingDeltasPerRoot = 10000 private let pathMatchWorker = PathMatchWorker() - private let codeScanActor = CodeScanActor() + private let codeScanActor: CodeScanActor private let deferredReplayBuffer = DeferredReplayBufferActor( maxPendingDeltasPerRoot: WorkspaceFileContextStore.defaultMaxPendingDeltasPerRoot ) @@ -300,15 +346,21 @@ actor WorkspaceFileContextStore { private var fileSystemDeltaContinuations: [UUID: AsyncStream.Continuation] = [:] private var appliedIndexContinuations: [UUID: AsyncStream.Continuation] = [:] private var appliedIndexGenerationsByRootID: [UUID: UInt64] = [:] - private var watcherCancellablesByRootID: [UUID: AnyCancellable] = [:] + private var watcherSubscriptionsByRootID: [UUID: FileSystemDeltaPublicationSubscription] = [:] private let publisherIngressCoordinator = WorkspaceFileSystemIngressCoordinator() private var scopedIngressBarrierFlightsByRootID: [UUID: ScopedIngressBarrierFlight] = [:] private var nextScopedIngressBarrierToken: UInt64 = 0 + private var nextWorkspaceCaptureGeneration: UInt64 = 0 private static let maxConcurrentScopedIngressBarriers = 8 private var codeScanResultTask: Task? - init(searchLaneConfiguration: StoreBackedWorkspaceSearchLane.Configuration = .production) { + package init( + runtimeDependencies: WorkspaceRuntimeDependencies, + searchLaneConfiguration: StoreBackedWorkspaceSearchLane.Configuration = .production + ) { + self.runtimeDependencies = runtimeDependencies storeBackedSearchLane = StoreBackedWorkspaceSearchLane(configuration: searchLaneConfiguration) + codeScanActor = CodeScanActor(cacheRoot: runtimeDependencies.codeMapCacheRoot) #if os(macOS) let source = DispatchSource.makeMemoryPressureSource( eventMask: [.warning, .critical], @@ -327,8 +379,8 @@ actor WorkspaceFileContextStore { searchContentMemoryPressureSource.cancel() #endif codeScanResultTask?.cancel() - for cancellable in watcherCancellablesByRootID.values { - cancellable.cancel() + for subscription in watcherSubscriptionsByRootID.values { + subscription.cancel() } for continuation in codemapUpdateContinuations.values { continuation.finish() @@ -341,11 +393,11 @@ actor WorkspaceFileContextStore { } } - func roots() -> [WorkspaceRootRecord] { + package func roots() -> [WorkspaceRootRecord] { rootLoadOrder.compactMap { rootStatesByID[$0]?.root } } - func rootRecords(forRootFolderPaths rootFolderPaths: [String], includeSystemRoots: Bool = true) -> [WorkspaceRootRecord] { + package func rootRecords(forRootFolderPaths rootFolderPaths: [String], includeSystemRoots: Bool = true) -> [WorkspaceRootRecord] { let standardizedRootPaths = Set(rootFolderPaths.map { ($0 as NSString).standardizingPath }) guard !standardizedRootPaths.isEmpty else { return [] } return rootLoadOrder.compactMap { rootID in @@ -359,7 +411,7 @@ actor WorkspaceFileContextStore { } } - func fileSystemDeltaEvents() -> AsyncStream { + package func fileSystemDeltaEvents() -> AsyncStream { let streamID = UUID() return AsyncStream { continuation in fileSystemDeltaContinuations[streamID] = continuation @@ -373,7 +425,7 @@ actor WorkspaceFileContextStore { fileSystemDeltaContinuations.removeValue(forKey: id) } - func appliedIndexEvents() -> AsyncStream { + package func appliedIndexEvents() -> AsyncStream { let streamID = UUID() return AsyncStream { continuation in appliedIndexContinuations[streamID] = continuation @@ -387,20 +439,17 @@ actor WorkspaceFileContextStore { appliedIndexContinuations.removeValue(forKey: id) } - func startWatchingRoot(id rootID: UUID) async throws { + package func startWatchingRoot(id rootID: UUID) async throws { let state = try state(for: rootID) - if watcherCancellablesByRootID[rootID] != nil { - await reconcileWatcherServiceState(state.service, rootID: rootID) - return - } + if watcherSubscriptionsByRootID[rootID] != nil { return } + let root = state.root let diagnosticRootToken = state.service.diagnosticRootToken - let publisherIngressCoordinator = publisherIngressCoordinator - let subscription = publisherIngressCoordinator.openPublisherIngress(rootID: rootID) { [weak self] publication, publicationCorrelation in - EditFlowPerf.lifecycleEvent( - EditFlowPerf.Lifecycle.WorkspaceIngress.storeSinkBegan, - correlation: publicationCorrelation, - EditFlowPerf.Dimensions( + let ingress = publisherIngressCoordinator.openPublisherIngress(rootID: rootID) { [weak self] publication, correlation in + WorkspaceRuntimePerf.lifecycleEvent( + WorkspaceRuntimePerf.Lifecycle.WorkspaceIngress.storeSinkBegan, + correlation: correlation, + WorkspaceRuntimePerf.Dimensions( changeCount: publication.deltas.count, rootToken: diagnosticRootToken.uuidString, ingressSequence: publication.watcherAcceptedWatermark?.rawValue, @@ -410,76 +459,69 @@ actor WorkspaceFileContextStore { await self?.handleObservedPublisherFileSystemPublication( publication, root: root, - publicationCorrelation: publicationCorrelation, + publicationCorrelation: correlation, diagnosticRootToken: diagnosticRootToken ) } - let publisher = await state.service.publisherForChanges() - guard rootStatesByID[rootID] != nil, - publisherIngressCoordinator.isPublisherIngressOpen(subscription) - else { - await reconcileWatcherServiceState(state.service, rootID: rootID) - return - } - let cancellable = publisher.sink { publication in - #if DEBUG || EDIT_FLOW_PERF - let publicationCorrelation = EditFlowPerf.currentFileSystemPublicationCorrelation - #else - let publicationCorrelation: EditFlowPerf.LifecycleCorrelation? = nil - #endif - EditFlowPerf.lifecycleEvent( - EditFlowPerf.Lifecycle.WorkspaceIngress.storeSinkScheduled, - correlation: publicationCorrelation, - EditFlowPerf.Dimensions( - changeCount: publication.deltas.count, - rootToken: diagnosticRootToken.uuidString, - ingressSequence: publication.watcherAcceptedWatermark?.rawValue, - barrierSequence: publication.servicePublicationSequence - ) - ) + let source = state.service.subscribeToChanges { [publisherIngressCoordinator] publication in publisherIngressCoordinator.accept( - subscription, + ingress, publication: publication, - lifecycleCorrelation: publicationCorrelation + lifecycleCorrelation: nil ) } guard rootStatesByID[rootID] != nil, - publisherIngressCoordinator.isPublisherIngressOpen(subscription) + publisherIngressCoordinator.isPublisherIngressOpen(ingress) else { - cancellable.cancel() - await reconcileWatcherServiceState(state.service, rootID: rootID) + source.cancel() + publisherIngressCoordinator.closePublisherIngress(rootID: rootID) return } - watcherCancellablesByRootID[rootID] = cancellable - await reconcileWatcherServiceState(state.service, rootID: rootID) + watcherSubscriptionsByRootID[rootID] = source + await state.service.startWatchingForChanges() } - func stopWatchingRoot(id rootID: UUID) async { - watcherCancellablesByRootID.removeValue(forKey: rootID)?.cancel() - publisherIngressCoordinator.closePublisherIngress(rootID: rootID) + package func stopWatchingRoot(id rootID: UUID) async { guard let state = rootStatesByID[rootID] else { + watcherSubscriptionsByRootID.removeValue(forKey: rootID)?.cancel() + publisherIngressCoordinator.closePublisherIngress(rootID: rootID) await waitForCurrentPublisherIngress(rootIDs: [rootID]) + publisherIngressCoordinator.finishPublisherIngress(rootIDs: [rootID]) return } - await reconcileWatcherServiceState(state.service, rootID: rootID) - await waitForCurrentPublisherIngress(rootIDs: [rootID]) + await stopWatchingRoot(id: rootID, service: state.service) } - private func reconcileWatcherServiceState(_ service: FileSystemService, rootID: UUID) async { - while true { - let shouldWatch = publisherIngressCoordinator.hasOpenPublisherIngress(rootID: rootID) - #if DEBUG - if let watcherServiceStateWillReconcileHandler { - await watcherServiceStateWillReconcileHandler(rootID, shouldWatch) - } - #endif - if shouldWatch { - await service.startWatchingForChanges() - } else { - await service.stopWatchingForChanges() + private func stopWatchingRoot(id rootID: UUID, service: FileSystemService) async { + let detachedStop = await service.detachWatcherAndCaptureAcceptedWatermark() + let priorAcceptedSequence = publisherIngressCoordinator.appliedSnapshot(rootID: rootID) + .acceptedServicePublicationSequence + await waitForCurrentPublisherIngress(rootIDs: [rootID]) + await publisherIngressCoordinator.waitUntilApplied( + rootID: rootID, + servicePublicationSequence: priorAcceptedSequence + ) + _ = await service.flushPendingEventsNow( + throughAcceptedWatcherWatermark: detachedStop.acceptedWatermark + ) + let acceptedDownstreamCut = publisherIngressCoordinator.appliedSnapshot(rootID: rootID) + .acceptedServicePublicationSequence + await publisherIngressCoordinator.waitUntilApplied( + rootID: rootID, + servicePublicationSequence: max(priorAcceptedSequence, acceptedDownstreamCut) + ) + let applied = publisherIngressCoordinator.appliedSnapshot(rootID: rootID) + assert(applied.appliedWatcherWatermark >= detachedStop.acceptedWatermark) + watcherSubscriptionsByRootID.removeValue(forKey: rootID)?.cancel() + publisherIngressCoordinator.closePublisherIngress(rootID: rootID) + #if DEBUG + if let watcherServiceStateWillReconcileHandler { + await watcherServiceStateWillReconcileHandler(rootID, false) } - guard shouldWatch != publisherIngressCoordinator.hasOpenPublisherIngress(rootID: rootID) else { return } - } + #endif + await service.finishDetachedWatcherStop(detachedStop) + await waitForCurrentPublisherIngress(rootIDs: [rootID]) + publisherIngressCoordinator.finishPublisherIngress(rootIDs: [rootID]) } private func waitForCurrentPublisherIngress(rootIDs: Set) async { @@ -511,7 +553,7 @@ actor WorkspaceFileContextStore { return next } - func replayObservedFileSystemDeltas(rootID: UUID, deltas: [FileSystemDelta]) async { + package func replayObservedFileSystemDeltas(rootID: UUID, deltas: [FileSystemDelta]) async { guard let root = rootStatesByID[rootID]?.root else { return } await handleObservedFileSystemDeltas(deltas, root: root) } @@ -533,7 +575,7 @@ actor WorkspaceFileContextStore { func acceptWatcherPayloadForTesting( rootID: UUID, - events: [(absolutePath: String, flags: FSEventStreamEventFlags, eventId: FSEventStreamEventId)], + events: [(absolutePath: String, flags: FileSystemWatchEventFlags, eventId: FileSystemWatchEventID)], scheduleDrain: Bool = true ) async throws -> FileSystemWatcherIngressMailbox.Watermark? { let state = try state(for: rootID) @@ -548,7 +590,7 @@ actor WorkspaceFileContextStore { private func handleObservedPublisherFileSystemPublication( _ publication: FileSystemDeltaPublication, root: WorkspaceRootRecord, - publicationCorrelation: EditFlowPerf.LifecycleCorrelation?, + publicationCorrelation: WorkspaceRuntimePerf.LifecycleCorrelation?, diagnosticRootToken: UUID ) async { #if DEBUG @@ -569,7 +611,7 @@ actor WorkspaceFileContextStore { private func handleObservedFileSystemDeltas( _ deltas: [FileSystemDelta], root: WorkspaceRootRecord, - publicationCorrelation: EditFlowPerf.LifecycleCorrelation? = nil, + publicationCorrelation: WorkspaceRuntimePerf.LifecycleCorrelation? = nil, diagnosticRootToken: UUID? = nil, watcherAcceptedWatermark: FileSystemWatcherIngressMailbox.Watermark? = nil, servicePublicationSequence: UInt64? = nil @@ -586,10 +628,10 @@ actor WorkspaceFileContextStore { let preparedDeltas = FileSystemDeltaPreparation.coalesce(deltas, inRoot: root.standardizedFullPath) .compactMap { FileSystemDeltaPreparation.prepare($0, inRoot: root.standardizedFullPath) } await applyPreparedIndexDeltas(rootID: root.id, deltas: preparedDeltas) - EditFlowPerf.lifecycleEvent( - EditFlowPerf.Lifecycle.WorkspaceIngress.storeCanonicalApplyCompleted, + WorkspaceRuntimePerf.lifecycleEvent( + WorkspaceRuntimePerf.Lifecycle.WorkspaceIngress.storeCanonicalApplyCompleted, correlation: publicationCorrelation, - EditFlowPerf.Dimensions( + WorkspaceRuntimePerf.Dimensions( appliedCount: preparedDeltas.count, rootToken: diagnosticRootToken?.uuidString, ingressSequence: watcherAcceptedWatermark?.rawValue, @@ -614,7 +656,7 @@ actor WorkspaceFileContextStore { } } - func files(inRoot rootID: UUID) -> [WorkspaceFileRecord] { + package func files(inRoot rootID: UUID) -> [WorkspaceFileRecord] { guard let state = rootStatesByID[rootID] else { return [] } return state.fileIDsByRelativePath.values .filter(isDiscoverableFileID) @@ -622,7 +664,7 @@ actor WorkspaceFileContextStore { .sorted { $0.standardizedRelativePath < $1.standardizedRelativePath } } - func folders(inRoot rootID: UUID) -> [WorkspaceFolderRecord] { + package func folders(inRoot rootID: UUID) -> [WorkspaceFolderRecord] { guard let state = rootStatesByID[rootID] else { return [] } return state.folderIDsByRelativePath.values .filter(isDiscoverableFolderID) @@ -630,11 +672,11 @@ actor WorkspaceFileContextStore { .sorted { $0.standardizedRelativePath < $1.standardizedRelativePath } } - func catalogGeneration(rootScope: WorkspaceLookupRootScope = .visibleWorkspace) -> UInt64 { + package func catalogGeneration(rootScope: WorkspaceLookupRootScope = .visibleWorkspace) -> UInt64 { scopedSnapshotGeneration(scope: rootScope) } - func catalogDiagnostics(rootScope: WorkspaceLookupRootScope = .visibleWorkspace) -> WorkspaceCatalogDiagnostics { + package func catalogDiagnostics(rootScope: WorkspaceLookupRootScope = .visibleWorkspace) -> WorkspaceCatalogDiagnostics { let roots = rootsForPathLookup(scope: rootScope) let allowedRootIDs = Set(roots.map(\.id)) let folderCount = foldersByID.values.reduce(into: 0) { count, folder in @@ -652,7 +694,7 @@ actor WorkspaceFileContextStore { ) } - func withStoreBackedSearchAccess( + package func withStoreBackedSearchAccess( searchMode: SearchMode, admissionClass: BroadSearchAdmissionClass?, operation: @Sendable (FileSearchActor) async throws -> T @@ -664,7 +706,7 @@ actor WorkspaceFileContextStore { ) } - func rootScopeAvailability(_ rootScope: WorkspaceLookupRootScope) -> WorkspaceLookupRootScopeAvailability { + package func rootScopeAvailability(_ rootScope: WorkspaceLookupRootScope) -> WorkspaceLookupRootScopeAvailability { guard case let .sessionBoundWorkspace(_, requestedPhysicalRootPaths) = rootScope else { return .available } @@ -681,7 +723,7 @@ actor WorkspaceFileContextStore { : .sessionWorktreeUnavailable(missingPhysicalRootPaths: missing) } - func searchCatalogAccess(rootScope: WorkspaceLookupRootScope = .visibleWorkspace) -> WorkspaceSearchCatalogAccess { + package func searchCatalogAccess(rootScope: WorkspaceLookupRootScope = .visibleWorkspace) -> WorkspaceSearchCatalogAccess { let availability = rootScopeAvailability(rootScope) guard availability == .available else { return .unavailable(availability) @@ -689,15 +731,15 @@ actor WorkspaceFileContextStore { return .available(searchCatalogSnapshot(rootScope: rootScope)) } - func searchCatalogSnapshot(rootScope: WorkspaceLookupRootScope = .visibleWorkspace) -> WorkspaceSearchCatalogSnapshot { - let catalogSnapshotState = EditFlowPerf.begin(EditFlowPerf.Stage.Search.catalogSnapshot) + package func searchCatalogSnapshot(rootScope: WorkspaceLookupRootScope = .visibleWorkspace) -> WorkspaceSearchCatalogSnapshot { + let catalogSnapshotState = WorkspaceRuntimePerf.begin(WorkspaceRuntimePerf.Stage.Search.catalogSnapshot) let validationToken = searchCatalogSnapshotValidationToken(scope: rootScope) if let cached = searchCatalogSnapshotsByScope[rootScope] { if cached.validationToken == validationToken { - EditFlowPerf.end( - EditFlowPerf.Stage.Search.catalogSnapshot, + WorkspaceRuntimePerf.end( + WorkspaceRuntimePerf.Stage.Search.catalogSnapshot, catalogSnapshotState, - EditFlowPerf.Dimensions( + WorkspaceRuntimePerf.Dimensions( fileCount: cached.snapshot.diagnostics.fileCount, cacheHit: true, rootCount: cached.snapshot.diagnostics.rootCount, @@ -740,10 +782,10 @@ actor WorkspaceFileContextStore { entries: entries, diagnostics: diagnostics ) - EditFlowPerf.end( - EditFlowPerf.Stage.Search.catalogSnapshot, + WorkspaceRuntimePerf.end( + WorkspaceRuntimePerf.Stage.Search.catalogSnapshot, catalogSnapshotState, - EditFlowPerf.Dimensions( + WorkspaceRuntimePerf.Dimensions( fileCount: diagnostics.fileCount, cacheHit: false, rootCount: diagnostics.rootCount, @@ -754,7 +796,7 @@ actor WorkspaceFileContextStore { return snapshot } - func directFolderChildren( + package func directFolderChildren( rootID: UUID, relativePath: String = "" ) -> WorkspaceDirectFolderChildrenSnapshot? { @@ -764,7 +806,7 @@ actor WorkspaceFileContextStore { return directFolderChildren(folderID: folderID) } - func directFolderChildren(folderID: UUID) -> WorkspaceDirectFolderChildrenSnapshot? { + package func directFolderChildren(folderID: UUID) -> WorkspaceDirectFolderChildrenSnapshot? { guard isDiscoverableFolderID(folderID), let folder = foldersByID[folderID], let state = rootStatesByID[folder.rootID] @@ -805,7 +847,7 @@ actor WorkspaceFileContextStore { } @discardableResult - func warmPathLookupIndexes(rootScope: WorkspaceLookupRootScope = .visibleWorkspace) async -> UInt64 { + package func warmPathLookupIndexes(rootScope: WorkspaceLookupRootScope = .visibleWorkspace) async -> UInt64 { while true { let staticData = buildStaticSnapshot(scope: rootScope) let warmedGeneration = await pathMatchWorker.prepare(staticData: staticData) @@ -823,23 +865,77 @@ actor WorkspaceFileContextStore { /// lower bound rather than a strict exclusion boundary. Synthetic publications are ordered with /// watcher publications and included in the downstream service-publication cut, but they do not /// advance watcher-accepted watermarks. - func awaitAppliedIngressForAllRoots() async -> [WorkspaceIngressBarrierSample] { + package func awaitAppliedIngressForAllRoots() async -> [WorkspaceIngressBarrierSample] { await awaitAppliedIngress(rootIDs: rootLoadOrder) } /// Awaits freshness only for roots represented by `rootScope`. /// Concurrent requests for the same root share a watermark-keyed flight when the /// existing flight covers both the callback-accepted and publisher-accepted cuts. - func awaitAppliedIngress(rootScope: WorkspaceLookupRootScope) async -> [WorkspaceIngressBarrierSample] { + package func awaitAppliedIngress(rootScope: WorkspaceLookupRootScope) async -> [WorkspaceIngressBarrierSample] { await awaitAppliedIngress(rootIDs: rootsForPathLookup(scope: rootScope).map(\.id)) } + /// Captures immutable store-owned inputs for later workspace projection composition. + /// + /// The accepted-ingress barrier and any asynchronous path resolution happen before final assembly. + /// Catalog changes during preparation force a retry; catalog, codemap, and tree state are then read + /// without suspension in one actor turn. File bytes are intentionally not captured and may be read live later. + package func captureWorkspaceFileContext( + selection: StoredSelection, + fileTreeRequest: WorkspaceFileTreeSnapshotRequest, + profile: PathLocateProfile = .uiAssisted, + coverage requestedCoverage: WorkspaceFileContextCaptureCoverage = .complete + ) async throws -> WorkspaceFileContextCapture { + let coverage: WorkspaceFileContextCaptureCoverage = switch fileTreeRequest.mode { + case .none: + requestedCoverage + case .selected, .full, .folders, .auto: + .complete + } + var attempt = 0 + while true { + try Task.checkCancellation() + let ingressSamples = await awaitAppliedIngress(rootScope: fileTreeRequest.rootScope) + try Task.checkCancellation() + + let identity = workspaceCaptureCatalogIdentity(rootScope: fileTreeRequest.rootScope) + guard identity.rootIDs == ingressSamples.map(\.rootID) else { continue } + + attempt += 1 + let prepared = try await prepareWorkspaceCapture( + selection: selection, + fileTreeRequest: fileTreeRequest, + profile: profile + ) + + #if DEBUG + if let workspaceCaptureDidPrepareHandler { + await workspaceCaptureDidPrepareHandler(attempt, identity.validationToken) + } + #endif + try Task.checkCancellation() + + guard identity == workspaceCaptureCatalogIdentity(rootScope: fileTreeRequest.rootScope), + let capture = makeWorkspaceFileContextCapture( + selection: selection, + fileTreeRequest: fileTreeRequest, + prepared: prepared, + identity: identity, + ingressSamples: ingressSamples, + coverage: coverage + ) + else { continue } + return capture + } + } + /// Resolves the narrowest safe workspace freshness scope for an explicit request. /// Absolute paths await only their containing loaded root. Absolute paths outside all /// loaded roots (including always-readable support files) do not pay a workspace barrier. /// Relative and alias-shaped paths await the caller's fallback scope because resolution /// may depend on more than one candidate root. - func awaitAppliedIngressForExplicitRequest( + package func awaitAppliedIngressForExplicitRequest( userPath: String, fallbackScope: WorkspaceLookupRootScope ) async -> [WorkspaceIngressBarrierSample] { @@ -932,9 +1028,9 @@ actor WorkspaceFileContextStore { let publisherIngressWillWaitHandler = publisherIngressWillWaitHandler #endif #if DEBUG || EDIT_FLOW_PERF - let lifecycleCorrelation = EditFlowPerf.currentLifecycleCorrelation + let lifecycleCorrelation = WorkspaceRuntimePerf.currentLifecycleCorrelation #else - let lifecycleCorrelation: EditFlowPerf.LifecycleCorrelation? = nil + let lifecycleCorrelation: WorkspaceRuntimePerf.LifecycleCorrelation? = nil #endif let task = Task { #if DEBUG @@ -945,10 +1041,10 @@ actor WorkspaceFileContextStore { #else let pendingCount = 0 #endif - EditFlowPerf.lifecycleEvent( - EditFlowPerf.Lifecycle.WorkspaceIngress.rootFlushBegan, + WorkspaceRuntimePerf.lifecycleEvent( + WorkspaceRuntimePerf.Lifecycle.WorkspaceIngress.rootFlushBegan, correlation: lifecycleCorrelation, - EditFlowPerf.Dimensions( + WorkspaceRuntimePerf.Dimensions( pendingRawEventCount: pendingCount, rootToken: service.diagnosticRootToken.uuidString, ingressSequence: target.watcherAcceptedWatermark.rawValue @@ -972,13 +1068,16 @@ actor WorkspaceFileContextStore { .acceptedServicePublicationSequence await publisherIngressCoordinator.waitUntilApplied( rootID: rootID, - servicePublicationSequence: max(target.acceptedServicePublicationSequence, acceptedDownstreamCut) + servicePublicationSequence: max( + target.acceptedServicePublicationSequence, + acceptedDownstreamCut + ) ) let applied = publisherIngressCoordinator.appliedSnapshot(rootID: rootID) - EditFlowPerf.lifecycleEvent( - EditFlowPerf.Lifecycle.WorkspaceIngress.rootFlushEnded, + WorkspaceRuntimePerf.lifecycleEvent( + WorkspaceRuntimePerf.Lifecycle.WorkspaceIngress.rootFlushEnded, correlation: lifecycleCorrelation, - EditFlowPerf.Dimensions( + WorkspaceRuntimePerf.Dimensions( pendingRawEventCount: pendingCount, rootToken: service.diagnosticRootToken.uuidString, ingressSequence: applied.appliedWatcherWatermark.rawValue, @@ -1008,7 +1107,7 @@ actor WorkspaceFileContextStore { } /// Compatibility wrapper for callers that still consume the original diagnostic shape. - func flushPendingServiceEventsForAllRoots() async -> [(rootPath: String, pendingRawEventCountBeforeFlush: Int)] { + package func flushPendingServiceEventsForAllRoots() async -> [(rootPath: String, pendingRawEventCountBeforeFlush: Int)] { await awaitAppliedIngressForAllRoots().map { sample in (sample.rootPath, sample.pendingRawEventCountBeforeFlush) } @@ -1016,7 +1115,7 @@ actor WorkspaceFileContextStore { // MARK: - Deferred replay buffer ownership - func updateDeferredReplayRoutingState( + package func updateDeferredReplayRoutingState( isWindowFocused: Bool, isReplayActive: Bool, routingVersion: UInt64 @@ -1028,19 +1127,19 @@ actor WorkspaceFileContextStore { ) } - func updateDeferredReplayImmediateChunkSizeOverride(_ chunkSize: Int?) async { + package func updateDeferredReplayImmediateChunkSizeOverride(_ chunkSize: Int?) async { await deferredReplayBuffer.updateImmediateReplayChunkSizeOverride(chunkSize) } - func registerDeferredReplayRootGeneration(_ generation: UInt64, forRootKey rootKey: String) async { + package func registerDeferredReplayRootGeneration(_ generation: UInt64, forRootKey rootKey: String) async { await deferredReplayBuffer.registerActiveRootGeneration(generation, forRootKey: rootKey) } - func unregisterDeferredReplayRootGeneration(forRootKey rootKey: String) async { + package func unregisterDeferredReplayRootGeneration(forRootKey rootKey: String) async { await deferredReplayBuffer.unregisterActiveRootGeneration(forRootKey: rootKey) } - func ingestDeferredReplayLiveDeltas( + package func ingestDeferredReplayLiveDeltas( _ deltas: [FileSystemDelta], forRootKey rootKey: String, rootGeneration: UInt64 @@ -1048,25 +1147,25 @@ actor WorkspaceFileContextStore { await deferredReplayBuffer.ingestLiveDeltas(deltas, forRootKey: rootKey, rootGeneration: rootGeneration) } - func ingestDeferredReplayLiveDeltas( + package func ingestDeferredReplayLiveDeltas( _ deltas: [FileSystemDelta], forRootKey rootKey: String ) async -> DeferredReplayIngressResult { await deferredReplayBuffer.ingestLiveDeltas(deltas, forRootKey: rootKey) } - func finishDeferredReplayPreparedImmediateIngress(_ immediateReplay: PreparedImmediateReplay) async { + package func finishDeferredReplayPreparedImmediateIngress(_ immediateReplay: PreparedImmediateReplay) async { await deferredReplayBuffer.finishPreparedImmediateIngress(immediateReplay) } - func enqueueDeferredReplayDeltas( + package func enqueueDeferredReplayDeltas( _ deltas: [FileSystemDelta], forRootKey rootKey: String ) async -> DeferredReplayIngressResult { await deferredReplayBuffer.enqueueDeferredDeltas(deltas, forRootKey: rootKey) } - func drainDeferredReplayPreparedBatches( + package func drainDeferredReplayPreparedBatches( preferredRootOrder: [String], chunkSize: Int ) async -> [PreparedFileSystemReplayBatch] { @@ -1076,33 +1175,33 @@ actor WorkspaceFileContextStore { ) } - func clearDeferredReplayRoot(_ rootKey: String) async { + package func clearDeferredReplayRoot(_ rootKey: String) async { await deferredReplayBuffer.clearRoot(rootKey) } - func clearDeferredReplayBuffer() async { + package func clearDeferredReplayBuffer() async { await deferredReplayBuffer.clearAll() } - func hasDeferredReplayPendingWork() async -> Bool { + package func hasDeferredReplayPendingWork() async -> Bool { await deferredReplayBuffer.hasPendingWork() } - func pendingDeferredReplayDeltaCount(forRootKey rootKey: String) async -> Int { + package func pendingDeferredReplayDeltaCount(forRootKey rootKey: String) async -> Int { await deferredReplayBuffer.pendingDeltaCount(forRootKey: rootKey) } - func deferredReplayPendingWorkSnapshot() async -> DeferredReplayPendingWorkSnapshot { + package func deferredReplayPendingWorkSnapshot() async -> DeferredReplayPendingWorkSnapshot { await deferredReplayBuffer.pendingWorkSnapshot() } #if DEBUG - func deferredReplayDiagnosticsSnapshot() async -> DeferredReplayBufferDiagnostics { + package func deferredReplayDiagnosticsSnapshot() async -> DeferredReplayBufferDiagnostics { await deferredReplayBuffer.diagnosticsSnapshot() } #endif - func refreshFileSystemSettings( + package func refreshFileSystemSettings( rootID: UUID, respectGitignore: Bool, respectRepoIgnore: Bool, @@ -1120,31 +1219,31 @@ actor WorkspaceFileContextStore { return await state.service.takePendingIgnoreRulesChange() != nil } - func allCodemapSnapshots() -> [WorkspaceCodemapSnapshot] { + package func allCodemapSnapshots() -> [WorkspaceCodemapSnapshot] { codemapSnapshotsByFileID.values .filter { isDiscoverableFileID($0.fileID) } .sorted { $0.fullPath < $1.fullPath } } - func allCodemapFileAPIs() -> [FileAPI] { + package func allCodemapFileAPIs() -> [FileAPI] { codemapFileAPIAggregate().orderedFileAPIs } - func codemapFileAPIAggregate() -> WorkspaceCodemapFileAPIAggregate { - let actorBodyTotal = EditFlowPerf.begin(EditFlowPerf.Stage.ReadFile.AutoSelect.AllCodemapFileAPIs.actorBodyTotal) - defer { EditFlowPerf.end(EditFlowPerf.Stage.ReadFile.AutoSelect.AllCodemapFileAPIs.actorBodyTotal, actorBodyTotal) } + package func codemapFileAPIAggregate() -> WorkspaceCodemapFileAPIAggregate { + let actorBodyTotal = WorkspaceRuntimePerf.begin(WorkspaceRuntimePerf.Stage.ReadFile.AutoSelect.AllCodemapFileAPIs.actorBodyTotal) + defer { WorkspaceRuntimePerf.end(WorkspaceRuntimePerf.Stage.ReadFile.AutoSelect.AllCodemapFileAPIs.actorBodyTotal, actorBodyTotal) } if let cachedCodemapFileAPIAggregate { return cachedCodemapFileAPIAggregate } #if DEBUG || EDIT_FLOW_PERF - let stateSnapshot = EditFlowPerf.begin(EditFlowPerf.Stage.ReadFile.AutoSelect.AllCodemapFileAPIs.stateSnapshot) + let stateSnapshot = WorkspaceRuntimePerf.begin(WorkspaceRuntimePerf.Stage.ReadFile.AutoSelect.AllCodemapFileAPIs.stateSnapshot) let discoverableSnapshots = codemapSnapshotsByFileID.values .filter { isDiscoverableFileID($0.fileID) } - EditFlowPerf.end(EditFlowPerf.Stage.ReadFile.AutoSelect.AllCodemapFileAPIs.stateSnapshot, stateSnapshot) + WorkspaceRuntimePerf.end(WorkspaceRuntimePerf.Stage.ReadFile.AutoSelect.AllCodemapFileAPIs.stateSnapshot, stateSnapshot) - let materialization = EditFlowPerf.begin(EditFlowPerf.Stage.ReadFile.AutoSelect.AllCodemapFileAPIs.materialization) + let materialization = WorkspaceRuntimePerf.begin(WorkspaceRuntimePerf.Stage.ReadFile.AutoSelect.AllCodemapFileAPIs.materialization) let APIs = discoverableSnapshots .sorted { $0.fullPath < $1.fullPath } .compactMap(\.fileAPI) @@ -1164,17 +1263,17 @@ actor WorkspaceFileContextStore { firstFileAPIByStandardizedNestedPath: firstFileAPIByStandardizedNestedPath ) #if DEBUG || EDIT_FLOW_PERF - EditFlowPerf.end(EditFlowPerf.Stage.ReadFile.AutoSelect.AllCodemapFileAPIs.materialization, materialization) + WorkspaceRuntimePerf.end(WorkspaceRuntimePerf.Stage.ReadFile.AutoSelect.AllCodemapFileAPIs.materialization, materialization) #endif cachedCodemapFileAPIAggregate = aggregate return aggregate } - func codemapSnapshotDictionary() -> [UUID: WorkspaceCodemapSnapshot] { + package func codemapSnapshotDictionary() -> [UUID: WorkspaceCodemapSnapshot] { codemapSnapshotsByFileID.filter { isDiscoverableFileID($0.key) } } - func codemapSnapshots(inRoot rootID: UUID) -> [WorkspaceCodemapSnapshot] { + package func codemapSnapshots(inRoot rootID: UUID) -> [WorkspaceCodemapSnapshot] { guard let fileIDs = codemapFileIDsByRootID[rootID] else { return [] } return fileIDs .filter(isDiscoverableFileID) @@ -1182,18 +1281,18 @@ actor WorkspaceFileContextStore { .sorted { $0.relativePath < $1.relativePath } } - func codemapSnapshot(fileID: UUID) -> WorkspaceCodemapSnapshot? { + package func codemapSnapshot(fileID: UUID) -> WorkspaceCodemapSnapshot? { guard isDiscoverableFileID(fileID) else { return nil } return codemapSnapshotsByFileID[fileID] } - func codemapSnapshot(rootID: UUID, relativePath: String) -> WorkspaceCodemapSnapshot? { + package func codemapSnapshot(rootID: UUID, relativePath: String) -> WorkspaceCodemapSnapshot? { guard let file = file(rootID: rootID, relativePath: relativePath), isDiscoverableFileID(file.id) else { return nil } return codemapSnapshotsByFileID[file.id] } @discardableResult - func invalidateCodemapSnapshotsForCheckoutMutation(rootIDs: [UUID]) -> [UUID] { + package func invalidateCodemapSnapshotsForCheckoutMutation(rootIDs: [UUID]) -> [UUID] { var removedFileIDs: [UUID] = [] for rootID in rootIDs { removedFileIDs.append(contentsOf: removeCodemapSnapshots(forRootID: rootID)) @@ -1202,7 +1301,7 @@ actor WorkspaceFileContextStore { } @discardableResult - func applyObservedCodemapResults(_ results: [WorkspaceObservedCodemapResult]) -> [String] { + package func applyObservedCodemapResults(_ results: [WorkspaceObservedCodemapResult]) -> [String] { var snapshotsByRootID: [UUID: [WorkspaceCodemapSnapshot]] = [:] var droppedPaths: [String] = [] for result in results { @@ -1244,7 +1343,7 @@ actor WorkspaceFileContextStore { } @discardableResult - func reconcileLoadedRootCatalogWithDisk(rootID: UUID) async -> [FileSystemDelta] { + package func reconcileLoadedRootCatalogWithDisk(rootID: UUID) async -> [FileSystemDelta] { guard let state = rootStatesByID[rootID] else { return [] } let root = state.root let folderPaths = Set( @@ -1269,7 +1368,7 @@ actor WorkspaceFileContextStore { return deltas } - func ensureIndexedFiles(paths: [String]) async -> [String] { + package func ensureIndexedFiles(paths: [String]) async -> [String] { var indexed: [String] = [] var upsertedFilesByRoot: [UUID: [WorkspaceFileRecord]] = [:] for rawPath in paths { @@ -1280,8 +1379,6 @@ actor WorkspaceFileContextStore { else { continue } let originalRootID = root.id let originalRootPath = root.standardizedFullPath - var isDirectory: ObjCBool = false - guard FileManager.default.fileExists(atPath: fullPath, isDirectory: &isDirectory), !isDirectory.boolValue else { continue } let relativePath = relativePath(for: fullPath, rootPath: originalRootPath) guard !relativePath.isEmpty, await service.catalogEligibleRegularFileExists(relativePath: relativePath) @@ -1341,11 +1438,11 @@ actor WorkspaceFileContextStore { return StandardizedPath.relative(String(suffix).trimmingCharacters(in: CharacterSet(charactersIn: "/"))) } - func makeFileTreeSelectionSnapshot(_ request: WorkspaceFileTreeSnapshotRequest) -> FileTreeSelectionSnapshot { + package func makeFileTreeSelectionSnapshot(_ request: WorkspaceFileTreeSnapshotRequest) -> FileTreeSelectionSnapshot { makeFileTreeSelectionSnapshot(request, selectedStoreFileIDs: request.selectedFileIDs) } - func makeFileTreeSelectionSnapshot( + package func makeFileTreeSelectionSnapshot( selection: StoredSelection, request: WorkspaceFileTreeSnapshotRequest, profile: PathLocateProfile = .uiAssisted @@ -1476,9 +1573,11 @@ actor WorkspaceFileContextStore { : [] let explicitlyIncludedManagedOnlyFolderIDs = managedOnlyAncestorFolderIDs(for: explicitlyIncludedManagedOnlyFileIDs) let roots: [FileTreeFolderSnapshot] - if let startFolder, - let state = rootStatesByID[startFolder.rootID], - let root = rootStatesByID[startFolder.rootID]?.root + if request.mode == .none { + roots = [] + } else if let startFolder, + let state = rootStatesByID[startFolder.rootID], + let root = rootStatesByID[startFolder.rootID]?.root { var visited = Set() roots = makeFileTreeFolderSnapshot( @@ -1521,7 +1620,331 @@ actor WorkspaceFileContextStore { ) } - func codemapUpdates() -> AsyncStream { + private func prepareWorkspaceCapture( + selection: StoredSelection, + fileTreeRequest: WorkspaceFileTreeSnapshotRequest, + profile: PathLocateProfile + ) async throws -> PreparedWorkspaceCapture { + var selectedPaths: [PreparedWorkspaceCapturePath] = [] + var selectedFileIDs = Set() + selectedPaths.reserveCapacity(selection.selectedPaths.count) + for input in selection.selectedPaths { + let path = try await prepareWorkspaceCapturePath( + input, + profile: profile, + rootScope: fileTreeRequest.rootScope + ) + selectedPaths.append(path) + selectedFileIDs.formUnion(path.resolution.selectedFileIDs) + } + + var autoCodemapPaths: [PreparedWorkspaceCapturePath] = [] + autoCodemapPaths.reserveCapacity(selection.autoCodemapPaths.count) + for input in selection.autoCodemapPaths { + try await autoCodemapPaths.append(prepareWorkspaceCapturePath( + input, + profile: profile, + rootScope: fileTreeRequest.rootScope + )) + } + + var slices: [PreparedWorkspaceCaptureSlice] = [] + slices.reserveCapacity(selection.slices.count) + for (path, ranges) in selection.slices { + let result = await lookupSelectionPath(path, profile: profile, rootScope: fileTreeRequest.rootScope) + try Task.checkCancellation() + let fileID = result?.file?.id + if let fileID { selectedFileIDs.insert(fileID) } + let issue = fileID == nil + ? exactPathResolutionIssue(for: path, kind: .file, rootScope: fileTreeRequest.rootScope) ?? .unresolved(input: path) + : nil + slices.append(PreparedWorkspaceCaptureSlice(path: path, ranges: ranges, fileID: fileID, issue: issue)) + } + + let trimmedStartPath = fileTreeRequest.startPath?.trimmingCharacters(in: .whitespacesAndNewlines) + let startFolderID: UUID? + if let trimmedStartPath, !trimmedStartPath.isEmpty { + let result = await lookupSelectionPath( + trimmedStartPath, + profile: profile, + rootScope: fileTreeRequest.rootScope + ) + try Task.checkCancellation() + startFolderID = result?.folder?.id + } else { + startFolderID = nil + } + + return PreparedWorkspaceCapture( + selectedPaths: selectedPaths, + autoCodemapPaths: autoCodemapPaths, + slices: slices, + selectedFileIDs: selectedFileIDs, + startFolderID: startFolderID + ) + } + + private func prepareWorkspaceCapturePath( + _ input: String, + profile: PathLocateProfile, + rootScope: WorkspaceLookupRootScope + ) async throws -> PreparedWorkspaceCapturePath { + let result = await lookupSelectionPath(input, profile: profile, rootScope: rootScope) + try Task.checkCancellation() + guard let result else { + let issue = exactPathResolutionIssue(for: input, kind: .either, rootScope: rootScope) ?? .unresolved(input: input) + return PreparedWorkspaceCapturePath(input: input, resolution: .unresolved(issue)) + } + if let file = result.file { + return PreparedWorkspaceCapturePath(input: input, resolution: .file(file.id)) + } + if let folder = result.folder { + return PreparedWorkspaceCapturePath( + input: input, + resolution: .folder(folder.id, descendantFileIDs: descendantFiles(in: folder.id).map(\.id)) + ) + } + return PreparedWorkspaceCapturePath(input: input, resolution: .unresolved(.unresolved(input: input))) + } + + private func workspaceCaptureCatalogIdentity(rootScope: WorkspaceLookupRootScope) -> WorkspaceCaptureCatalogIdentity { + WorkspaceCaptureCatalogIdentity( + validationToken: searchCatalogSnapshotValidationToken(scope: rootScope), + rootIDs: rootsForPathLookup(scope: rootScope).map(\.id) + ) + } + + private func makeWorkspaceFileContextCapture( + selection: StoredSelection, + fileTreeRequest: WorkspaceFileTreeSnapshotRequest, + prepared: PreparedWorkspaceCapture, + identity: WorkspaceCaptureCatalogIdentity, + ingressSamples: [WorkspaceIngressBarrierSample], + coverage: WorkspaceFileContextCaptureCoverage + ) -> WorkspaceFileContextCapture? { + guard identity == workspaceCaptureCatalogIdentity(rootScope: fileTreeRequest.rootScope) else { return nil } + let catalog: WorkspaceSearchCatalogSnapshot + switch coverage { + case .complete: + catalog = searchCatalogSnapshot(rootScope: fileTreeRequest.rootScope) + case .projection: + let roots = identity.rootIDs.compactMap { rootStatesByID[$0]?.root } + let generation = scopedSnapshotGeneration(scope: fileTreeRequest.rootScope) + catalog = WorkspaceSearchCatalogSnapshot( + generation: generation, + rootScope: fileTreeRequest.rootScope, + roots: roots, + files: [], + entries: [], + diagnostics: WorkspaceCatalogDiagnostics( + generation: generation, + rootScope: fileTreeRequest.rootScope, + rootCount: roots.count, + folderCount: 0, + fileCount: 0 + ) + ) + } + guard catalog.roots.map(\.id) == identity.rootIDs, + searchCatalogSnapshotValidationToken(scope: fileTreeRequest.rootScope) == identity.validationToken + else { return nil } + + var selectedPaths: [WorkspaceFileContextCapture.SelectionPath] = [] + selectedPaths.reserveCapacity(prepared.selectedPaths.count) + for path in prepared.selectedPaths { + guard let materialized = materializeWorkspaceCapturePath(path) else { return nil } + selectedPaths.append(materialized) + } + + var autoCodemapPaths: [WorkspaceFileContextCapture.SelectionPath] = [] + autoCodemapPaths.reserveCapacity(prepared.autoCodemapPaths.count) + for path in prepared.autoCodemapPaths { + guard let materialized = materializeWorkspaceCapturePath(path) else { return nil } + autoCodemapPaths.append(materialized) + } + + var slices: [WorkspaceFileContextCapture.Slice] = [] + slices.reserveCapacity(prepared.slices.count) + for slice in prepared.slices { + let file: WorkspaceFileRecord? + if let fileID = slice.fileID { + guard let resolvedFile = filesByID[fileID] else { return nil } + file = resolvedFile + } else { + file = nil + } + slices.append(WorkspaceFileContextCapture.Slice( + path: slice.path, + ranges: slice.ranges, + file: file, + issue: slice.issue + )) + } + + guard prepared.selectedFileIDs.allSatisfy({ filesByID[$0] != nil }) else { return nil } + let startFolder: WorkspaceFolderRecord? + if let startFolderID = prepared.startFolderID { + guard let resolvedStartFolder = foldersByID[startFolderID] else { return nil } + startFolder = resolvedStartFolder + } else { + startFolder = nil + } + + let allowedRootIDs = Set(identity.rootIDs) + let materializedFolders: [WorkspaceFolderRecord] + let materializedFiles: [WorkspaceFileRecord] + let codemapSnapshots: [WorkspaceCodemapSnapshot] + switch coverage { + case .complete: + materializedFolders = foldersByID.values + .filter { allowedRootIDs.contains($0.rootID) } + .sorted(by: Self.compareWorkspaceCaptureFolders) + materializedFiles = filesByID.values + .filter { allowedRootIDs.contains($0.rootID) } + .sorted(by: Self.compareWorkspaceCaptureFiles) + codemapSnapshots = allCodemapSnapshots() + .filter { allowedRootIDs.contains($0.rootID) } + case let .projection(codemapCoverage): + var referencedFolderIDs = Set() + var referencedFileIDs = Set() + func include(_ path: WorkspaceFileContextCapture.SelectionPath) { + switch path.resolution { + case let .file(file): + referencedFileIDs.insert(file.id) + case let .folder(folder, descendantFiles): + referencedFolderIDs.insert(folder.id) + referencedFileIDs.formUnion(descendantFiles.map(\.id)) + case .unresolved: + break + } + } + selectedPaths.forEach(include) + autoCodemapPaths.forEach(include) + referencedFileIDs.formUnion(slices.compactMap { $0.file?.id }) + + switch codemapCoverage { + case .referenced: + codemapSnapshots = referencedFileIDs.compactMap { fileID in + guard isDiscoverableFileID(fileID), + let snapshot = codemapSnapshotsByFileID[fileID], + allowedRootIDs.contains(snapshot.rootID) + else { return nil } + return snapshot + } + .sorted { $0.fullPath < $1.fullPath } + case .allAvailable: + codemapSnapshots = allCodemapSnapshots() + .filter { allowedRootIDs.contains($0.rootID) } + referencedFileIDs.formUnion(codemapSnapshots.map(\.fileID)) + } + + materializedFolders = referencedFolderIDs.compactMap { foldersByID[$0] } + .sorted(by: Self.compareWorkspaceCaptureFolders) + materializedFiles = referencedFileIDs.compactMap { filesByID[$0] } + .sorted(by: Self.compareWorkspaceCaptureFiles) + } + let materializedFolderIDs = Set(materializedFolders.map(\.id)) + let materializedFileIDs = Set(materializedFiles.map(\.id)) + guard prepared.selectedFileIDs.isSubset(of: materializedFileIDs), + Set(codemapSnapshots.map(\.fileID)).isSubset(of: materializedFileIDs) + else { return nil } + + let fileTree = makeFileTreeSelectionSnapshot( + fileTreeRequest, + selectedStoreFileIDs: prepared.selectedFileIDs, + startFolder: startFolder + ) + guard workspaceCaptureTreeReferencesMaterializedRecords( + fileTree.roots, + folderIDs: materializedFolderIDs, + fileIDs: materializedFileIDs + ) else { return nil } + + nextWorkspaceCaptureGeneration &+= 1 + return WorkspaceFileContextCapture( + coverage: coverage, + provenance: WorkspaceFileContextCapture.Provenance( + captureGeneration: nextWorkspaceCaptureGeneration, + catalogGeneration: catalog.generation, + catalogValidationToken: identity.validationToken, + rootScope: fileTreeRequest.rootScope, + ingressSamples: ingressSamples + ), + storedSelection: selection, + selectedPaths: selectedPaths, + autoCodemapPaths: autoCodemapPaths, + slices: slices, + catalog: catalog, + materializedFolders: materializedFolders, + materializedFiles: materializedFiles, + codemapSnapshots: codemapSnapshots, + fileTree: fileTree + ) + } + + private static func compareWorkspaceCaptureFolders( + _ lhs: WorkspaceFolderRecord, + _ rhs: WorkspaceFolderRecord + ) -> Bool { + if lhs.standardizedFullPath == rhs.standardizedFullPath { + return lhs.id.uuidString < rhs.id.uuidString + } + return lhs.standardizedFullPath < rhs.standardizedFullPath + } + + private static func compareWorkspaceCaptureFiles( + _ lhs: WorkspaceFileRecord, + _ rhs: WorkspaceFileRecord + ) -> Bool { + if lhs.standardizedFullPath == rhs.standardizedFullPath { + return lhs.id.uuidString < rhs.id.uuidString + } + return lhs.standardizedFullPath < rhs.standardizedFullPath + } + + private func materializeWorkspaceCapturePath( + _ path: PreparedWorkspaceCapturePath + ) -> WorkspaceFileContextCapture.SelectionPath? { + let resolution: WorkspaceFileContextCapture.SelectionPath.Resolution + switch path.resolution { + case let .file(fileID): + guard let file = filesByID[fileID] else { return nil } + resolution = .file(file) + case let .folder(folderID, descendantFileIDs): + guard let folder = foldersByID[folderID] else { return nil } + let descendantFiles = descendantFileIDs.compactMap { filesByID[$0] } + guard descendantFiles.count == descendantFileIDs.count else { return nil } + resolution = .folder(folder, descendantFiles: descendantFiles) + case let .unresolved(issue): + resolution = .unresolved(issue) + } + return WorkspaceFileContextCapture.SelectionPath(input: path.input, resolution: resolution) + } + + private func workspaceCaptureTreeReferencesMaterializedRecords( + _ folders: [FileTreeFolderSnapshot], + folderIDs: Set, + fileIDs: Set + ) -> Bool { + for folder in folders { + guard folderIDs.contains(folder.id) else { return false } + for child in folder.children { + switch child { + case let .folder(childFolder): + guard workspaceCaptureTreeReferencesMaterializedRecords( + [childFolder], + folderIDs: folderIDs, + fileIDs: fileIDs + ) else { return false } + case let .file(file): + guard fileIDs.contains(file.id) else { return false } + } + } + } + return true + } + + package func codemapUpdates() -> AsyncStream { ensureCodeScanResultTask() let id = UUID() return AsyncStream { continuation in @@ -1532,31 +1955,31 @@ actor WorkspaceFileContextStore { } } - func codemapScanProgressUpdates() -> AsyncStream<(Int, Int)> { + package func codemapScanProgressUpdates() -> AsyncStream<(Int, Int)> { codeScanActor.subscribeToProgress() } - func cancelAllCodemapScans() async { + package func cancelAllCodemapScans() async { await codeScanActor.cancelAllScans() } - func cancelCodemapScansForCheckoutMutation(rootIDs: [UUID]) async { + package func cancelCodemapScansForCheckoutMutation(rootIDs: [UUID]) async { let rootFolderPaths = rootIDs.compactMap { rootStatesByID[$0]?.root.standardizedFullPath } guard !rootFolderPaths.isEmpty else { return } await codeScanActor.cancelAndUnloadScans(forRootFolders: rootFolderPaths) } - func clearAllCodemapCaches(rootFolders: [String]) async { + package func clearAllCodemapCaches(rootFolders: [String]) async { await codeScanActor.clearAllCaches(rootFolders: rootFolders) removeAllCodemapSnapshots() } - func purgeStaleCodemapCaches(keepingRootPaths: [String]) async { + package func purgeStaleCodemapCaches(keepingRootPaths: [String]) async { await codeScanActor.purgeStaleRootCaches(keepingRootPaths: keepingRootPaths) } #if DEBUG - func codemapMemoryCounters() async -> CodeScanActor.CodemapMemoryCounters { + package func codemapMemoryCounters() async -> CodeScanActor.CodemapMemoryCounters { await codeScanActor.codemapMemoryCounters() } #endif @@ -1566,7 +1989,7 @@ actor WorkspaceFileContextStore { } @discardableResult - func loadRoot( + package func loadRoot( path: String, isSystemRoot: Bool = false, kind: WorkspaceRootKind? = nil, @@ -1579,7 +2002,7 @@ actor WorkspaceFileContextStore { ) async throws -> WorkspaceRootRecord { let standardizedPath = (path as NSString).standardizingPath #if DEBUG - let rootLoadRouteStartMS = WorkspaceRestorePerfLog.timestampMSIfEnabled() + let rootLoadRouteStartMS = WorkspaceRuntimeDebugLog.timestampMSIfEnabled() let rootLoadName = URL(fileURLWithPath: standardizedPath).lastPathComponent #endif try Task.checkCancellation() @@ -1600,13 +2023,13 @@ actor WorkspaceFileContextStore { throw WorkspaceFileContextStoreError.rootAlreadyLoadedWithDifferentConfiguration(standardizedPath) } #if DEBUG - WorkspaceRestorePerfLog.event( + WorkspaceRuntimeDebugLog.event( "store.rootLoad.existing", fields: [ "rootName": rootLoadName, - "rootID": WorkspaceRestorePerfLog.shortID(existing.id), + "rootID": WorkspaceRuntimeDebugLog.shortID(existing.id), "kind": "\(loadConfiguration.kind)", - "duration": rootLoadRouteStartMS.map { WorkspaceRestorePerfLog.formatElapsedMS(since: $0) } ?? "notMeasured" + "duration": rootLoadRouteStartMS.map { WorkspaceRuntimeDebugLog.formatElapsedMS(since: $0) } ?? "notMeasured" ] ) #endif @@ -1617,7 +2040,7 @@ actor WorkspaceFileContextStore { throw WorkspaceFileContextStoreError.rootLoadInFlightWithDifferentConfiguration(standardizedPath) } #if DEBUG - WorkspaceRestorePerfLog.event( + WorkspaceRuntimeDebugLog.event( "store.rootLoad.joinInFlight", fields: [ "rootName": rootLoadName, @@ -1636,7 +2059,7 @@ actor WorkspaceFileContextStore { } #if DEBUG - WorkspaceRestorePerfLog.event( + WorkspaceRuntimeDebugLog.event( "store.rootLoad.scheduled", fields: [ "rootName": rootLoadName, @@ -1684,7 +2107,7 @@ actor WorkspaceFileContextStore { } } - func cancelRootLoad(path: String) { + package func cancelRootLoad(path: String) { let standardizedPath = (path as NSString).standardizingPath rootLoadTasksByPath[standardizedPath]?.cancel() rootLoadTasksByPath.removeValue(forKey: standardizedPath) @@ -1724,8 +2147,8 @@ actor WorkspaceFileContextStore { let rootURL = URL(fileURLWithPath: standardizedPath).standardizedFileURL #if DEBUG - let performLoadStartMS = WorkspaceRestorePerfLog.timestampMSIfEnabled() - WorkspaceRestorePerfLog.event( + let performLoadStartMS = WorkspaceRuntimeDebugLog.timestampMSIfEnabled() + WorkspaceRuntimeDebugLog.event( "store.rootLoad.begin", fields: [ "rootName": rootURL.lastPathComponent, @@ -1745,21 +2168,22 @@ actor WorkspaceFileContextStore { respectRepoIgnore: respectRepoIgnore, respectCursorignore: respectCursorignore, skipSymlinks: skipSymlinks, - enableHierarchicalIgnores: enableHierarchicalIgnores + enableHierarchicalIgnores: enableHierarchicalIgnores, + dependencies: runtimeDependencies ) #if DEBUG var rootRecordCreatedFields: [String: String] = [ "rootName": root.name, - "rootID": WorkspaceRestorePerfLog.shortID(root.id), + "rootID": WorkspaceRuntimeDebugLog.shortID(root.id), "kind": "\(root.kind)", - "durationSinceStoreRootLoadBegin": performLoadStartMS.map { WorkspaceRestorePerfLog.formatElapsedMS(since: $0) } ?? "notMeasured" + "durationSinceStoreRootLoadBegin": performLoadStartMS.map { WorkspaceRuntimeDebugLog.formatElapsedMS(since: $0) } ?? "notMeasured" ] rootRecordCreatedFields.merge( - WorkspaceRootLoadDiagnostics.rootRecordCreatedFields(forPath: standardizedPath), + WorkspaceRootLoadDiagnosticFields.rootRecordCreatedFields(forPath: standardizedPath), uniquingKeysWith: { _, diagnostic in diagnostic } ) - WorkspaceRestorePerfLog.event("store.rootLoad.rootRecordCreated", fields: rootRecordCreatedFields) + WorkspaceRuntimeDebugLog.event("store.rootLoad.rootRecordCreated", fields: rootRecordCreatedFields) #endif var state = RootState( @@ -1785,7 +2209,7 @@ actor WorkspaceFileContextStore { state.folderIDsByRelativePath[""] = rootFolder.id #if DEBUG - let walkStartMS = WorkspaceRestorePerfLog.timestampMSIfEnabled() + let walkStartMS = WorkspaceRuntimeDebugLog.timestampMSIfEnabled() var chunkCount = 0 #endif for try await event in await service.loadContentsInChunks(of: rootURL) { @@ -1796,16 +2220,16 @@ actor WorkspaceFileContextStore { if chunkCount == 1 { var firstChunkFields: [String: String] = [ "rootName": root.name, - "rootID": WorkspaceRestorePerfLog.shortID(root.id), + "rootID": WorkspaceRuntimeDebugLog.shortID(root.id), "chunkFolders": "\(chunk.folders.count)", "chunkFiles": "\(chunk.files.count)", - "durationSinceStoreRootLoadBegin": performLoadStartMS.map { WorkspaceRestorePerfLog.formatElapsedMS(since: $0) } ?? "notMeasured" + "durationSinceStoreRootLoadBegin": performLoadStartMS.map { WorkspaceRuntimeDebugLog.formatElapsedMS(since: $0) } ?? "notMeasured" ] firstChunkFields.merge( - WorkspaceRootLoadDiagnostics.firstPreparedChunkFields(forPath: standardizedPath), + WorkspaceRootLoadDiagnosticFields.firstPreparedChunkFields(forPath: standardizedPath), uniquingKeysWith: { _, diagnostic in diagnostic } ) - WorkspaceRestorePerfLog.event("store.rootLoad.firstPreparedChunk", fields: firstChunkFields) + WorkspaceRuntimeDebugLog.event("store.rootLoad.firstPreparedChunk", fields: firstChunkFields) } #endif indexFolders(chunk.folders, root: root, state: &state, indexes: &stagedIndexes) @@ -1813,28 +2237,28 @@ actor WorkspaceFileContextStore { } try Task.checkCancellation() #if DEBUG - WorkspaceRestorePerfLog.event( + WorkspaceRuntimeDebugLog.event( "store.rootLoad.walk", fields: [ "rootName": root.name, "chunkCount": "\(chunkCount)", "folders": "\(stagedIndexes.foldersByID.count)", "files": "\(stagedIndexes.filesByID.count)", - "duration": walkStartMS.map { WorkspaceRestorePerfLog.formatElapsedMS(since: $0) } ?? "notMeasured" + "duration": walkStartMS.map { WorkspaceRuntimeDebugLog.formatElapsedMS(since: $0) } ?? "notMeasured" ] ) - let commitStartMS = WorkspaceRestorePerfLog.timestampMSIfEnabled() + let commitStartMS = WorkspaceRuntimeDebugLog.timestampMSIfEnabled() #endif commit(stagedIndexes) #if DEBUG - WorkspaceRestorePerfLog.event( + WorkspaceRuntimeDebugLog.event( "store.rootLoad.commit", fields: [ "rootName": root.name, "folders": "\(stagedIndexes.foldersByID.count)", "files": "\(stagedIndexes.filesByID.count)", - "duration": commitStartMS.map { WorkspaceRestorePerfLog.formatElapsedMS(since: $0) } ?? "notMeasured" + "duration": commitStartMS.map { WorkspaceRuntimeDebugLog.formatElapsedMS(since: $0) } ?? "notMeasured" ] ) #endif @@ -1844,14 +2268,14 @@ actor WorkspaceFileContextStore { appliedIndexGenerationsByRootID[root.id] = 0 invalidatePathMatchSnapshot(affectedRootKinds: [root.kind]) #if DEBUG - WorkspaceRestorePerfLog.event( + WorkspaceRuntimeDebugLog.event( "store.rootLoad.end", fields: [ "rootName": root.name, - "rootID": WorkspaceRestorePerfLog.shortID(root.id), + "rootID": WorkspaceRuntimeDebugLog.shortID(root.id), "folders": "\(stagedIndexes.foldersByID.count)", "files": "\(stagedIndexes.filesByID.count)", - "duration": performLoadStartMS.map { WorkspaceRestorePerfLog.formatElapsedMS(since: $0) } ?? "notMeasured" + "duration": performLoadStartMS.map { WorkspaceRuntimeDebugLog.formatElapsedMS(since: $0) } ?? "notMeasured" ] ) #endif @@ -1891,23 +2315,27 @@ actor WorkspaceFileContextStore { } } - func unloadRoot(id rootID: UUID) async { + package func unloadRoot(id rootID: UUID) async { await unloadRoots(ids: [rootID]) } - func unloadRoots(ids rootIDs: [UUID]) async { + package func unloadRoots(ids rootIDs: [UUID]) async { var seenRootIDs = Set() let orderedRootIDs = rootIDs.filter { seenRootIDs.insert($0).inserted } guard !orderedRootIDs.isEmpty else { return } var statesToUnload: [(rootID: UUID, state: RootState)] = [] for rootID in orderedRootIDs { - watcherCancellablesByRootID.removeValue(forKey: rootID)?.cancel() - publisherIngressCoordinator.closePublisherIngress(rootID: rootID) guard let state = rootStatesByID.removeValue(forKey: rootID) else { continue } statesToUnload.append((rootID, state)) } guard !statesToUnload.isEmpty else { return } + let removedRootIDSet = Set(statesToUnload.map(\.rootID)) + rootLoadOrder.removeAll { removedRootIDSet.contains($0) } + for entry in statesToUnload { + rootIDsByStandardizedPath.removeValue(forKey: entry.state.root.standardizedFullPath) + rootLoadConfigurationsByPath.removeValue(forKey: entry.state.root.standardizedFullPath) + } clearSearchCatalogSnapshotCache() for entry in statesToUnload { for fileID in entry.state.fileIDsByRelativePath.values { @@ -1916,10 +2344,10 @@ actor WorkspaceFileContextStore { await searchDecodedContentCache.invalidate(rootID: entry.rootID) } #if DEBUG - let rootUnloadStartMS = WorkspaceRestorePerfLog.timestampMSIfEnabled() + let rootUnloadStartMS = WorkspaceRuntimeDebugLog.timestampMSIfEnabled() let rootUnloadFolderCount = statesToUnload.reduce(0) { $0 + $1.state.folderIDsByRelativePath.count } let rootUnloadFileCount = statesToUnload.reduce(0) { $0 + $1.state.fileIDsByRelativePath.count } - WorkspaceRestorePerfLog.event( + WorkspaceRuntimeDebugLog.event( "store.rootUnload.begin", fields: [ "rootCount": "\(statesToUnload.count)", @@ -1927,7 +2355,7 @@ actor WorkspaceFileContextStore { "fileCount": "\(rootUnloadFileCount)" ] ) - let detachStartMS = WorkspaceRestorePerfLog.timestampMSIfEnabled() + let detachStartMS = WorkspaceRuntimeDebugLog.timestampMSIfEnabled() #endif let unloadingPaths = statesToUnload.map(\.state.root.standardizedFullPath) @@ -1940,21 +2368,15 @@ actor WorkspaceFileContextStore { } #endif - let removedRootIDSet = Set(statesToUnload.map(\.rootID)) - rootLoadOrder.removeAll { removedRootIDSet.contains($0) } - for entry in statesToUnload { - rootIDsByStandardizedPath.removeValue(forKey: entry.state.root.standardizedFullPath) - rootLoadConfigurationsByPath.removeValue(forKey: entry.state.root.standardizedFullPath) - } #if DEBUG - WorkspaceRestorePerfLog.event( + WorkspaceRuntimeDebugLog.event( "store.rootUnload.detach", fields: [ "rootCount": "\(statesToUnload.count)", - "duration": detachStartMS.map { WorkspaceRestorePerfLog.formatElapsedMS(since: $0) } ?? "notMeasured" + "duration": detachStartMS.map { WorkspaceRuntimeDebugLog.formatElapsedMS(since: $0) } ?? "notMeasured" ] ) - let stopWatchersStartMS = WorkspaceRestorePerfLog.timestampMSIfEnabled() + let stopWatchersStartMS = WorkspaceRuntimeDebugLog.timestampMSIfEnabled() #endif // Stop watchers after the roots have been detached from actor lookup tables. @@ -1962,31 +2384,29 @@ actor WorkspaceFileContextStore { // preserving the existing awaited teardown semantics. for entry in statesToUnload { #if DEBUG - let stopWatcherRootStartMS = WorkspaceRestorePerfLog.timestampMSIfEnabled() + let stopWatcherRootStartMS = WorkspaceRuntimeDebugLog.timestampMSIfEnabled() #endif - await reconcileWatcherServiceState(entry.state.service, rootID: entry.rootID) + await stopWatchingRoot(id: entry.rootID, service: entry.state.service) #if DEBUG - WorkspaceRestorePerfLog.event( + WorkspaceRuntimeDebugLog.event( "store.rootUnload.stopWatcherRoot", fields: [ "rootName": entry.state.root.name, - "rootID": WorkspaceRestorePerfLog.shortID(entry.rootID), - "duration": stopWatcherRootStartMS.map { WorkspaceRestorePerfLog.formatElapsedMS(since: $0) } ?? "notMeasured" + "rootID": WorkspaceRuntimeDebugLog.shortID(entry.rootID), + "duration": stopWatcherRootStartMS.map { WorkspaceRuntimeDebugLog.formatElapsedMS(since: $0) } ?? "notMeasured" ] ) #endif } - await waitForCurrentPublisherIngress(rootIDs: removedRootIDSet) - publisherIngressCoordinator.finishPublisherIngress(rootIDs: removedRootIDSet) #if DEBUG - WorkspaceRestorePerfLog.event( + WorkspaceRuntimeDebugLog.event( "store.rootUnload.stopWatchers", fields: [ "rootCount": "\(statesToUnload.count)", - "duration": stopWatchersStartMS.map { WorkspaceRestorePerfLog.formatElapsedMS(since: $0) } ?? "notMeasured" + "duration": stopWatchersStartMS.map { WorkspaceRuntimeDebugLog.formatElapsedMS(since: $0) } ?? "notMeasured" ] ) - let indexCleanupStartMS = WorkspaceRestorePerfLog.timestampMSIfEnabled() + let indexCleanupStartMS = WorkspaceRuntimeDebugLog.timestampMSIfEnabled() #endif var rootPathsToUnload: [String] = [] @@ -2032,55 +2452,55 @@ actor WorkspaceFileContextStore { let unloadedRootKinds = Set(statesToUnload.map(\.state.root.kind)) invalidatePathMatchSnapshot(affectedRootKinds: unloadedRootKinds) #if DEBUG - WorkspaceRestorePerfLog.event( + WorkspaceRuntimeDebugLog.event( "store.rootUnload.indexCleanup", fields: [ "rootCount": "\(statesToUnload.count)", "removedFolders": "\(rootUnloadFolderCount)", "removedFiles": "\(rootUnloadFileCount)", - "duration": indexCleanupStartMS.map { WorkspaceRestorePerfLog.formatElapsedMS(since: $0) } ?? "notMeasured" + "duration": indexCleanupStartMS.map { WorkspaceRuntimeDebugLog.formatElapsedMS(since: $0) } ?? "notMeasured" ] ) - let codeScanCancelStartMS = WorkspaceRestorePerfLog.timestampMSIfEnabled() + let codeScanCancelStartMS = WorkspaceRuntimeDebugLog.timestampMSIfEnabled() #endif await codeScanActor.cancelAndUnloadScans(forRootFolders: rootPathsToUnload) #if DEBUG - WorkspaceRestorePerfLog.event( + WorkspaceRuntimeDebugLog.event( "store.rootUnload.codeScanCancel", fields: [ "rootCount": "\(statesToUnload.count)", - "duration": codeScanCancelStartMS.map { WorkspaceRestorePerfLog.formatElapsedMS(since: $0) } ?? "notMeasured" + "duration": codeScanCancelStartMS.map { WorkspaceRuntimeDebugLog.formatElapsedMS(since: $0) } ?? "notMeasured" ] ) #endif invalidatePathMatchCache() finishRootUnload(for: unloadingPaths) #if DEBUG - WorkspaceRestorePerfLog.event( + WorkspaceRuntimeDebugLog.event( "store.rootUnload.end", fields: [ "rootCount": "\(statesToUnload.count)", - "duration": rootUnloadStartMS.map { WorkspaceRestorePerfLog.formatElapsedMS(since: $0) } ?? "notMeasured" + "duration": rootUnloadStartMS.map { WorkspaceRuntimeDebugLog.formatElapsedMS(since: $0) } ?? "notMeasured" ] ) #endif } - func file(rootID: UUID, relativePath: String) -> WorkspaceFileRecord? { + package func file(rootID: UUID, relativePath: String) -> WorkspaceFileRecord? { guard let state = rootStatesByID[rootID] else { return nil } let key = StandardizedPath.relative(relativePath) guard let fileID = state.fileIDsByRelativePath[key] else { return nil } return filesByID[fileID] } - func folder(rootID: UUID, relativePath: String) -> WorkspaceFolderRecord? { + package func folder(rootID: UUID, relativePath: String) -> WorkspaceFolderRecord? { guard let state = rootStatesByID[rootID] else { return nil } let key = StandardizedPath.relative(relativePath) guard let folderID = state.folderIDsByRelativePath[key] else { return nil } return foldersByID[folderID] } - func searchContentSnapshot(for expectedRecord: WorkspaceFileRecord) async throws -> FileSearchContentSnapshot { + package func searchContentSnapshot(for expectedRecord: WorkspaceFileRecord) async throws -> FileSearchContentSnapshot { for attempt in 0 ..< 2 { try Task.checkCancellation() guard let state = rootStatesByID[expectedRecord.rootID], @@ -2165,39 +2585,39 @@ actor WorkspaceFileContextStore { await searchDecodedContentCache.clear() } - func readContent( + package func readContent( rootID: UUID, relativePath: String, workloadClass: ContentReadWorkloadClass = .unspecified ) async throws -> String? { let state = try state(for: rootID) - let lifecycleCorrelation = EditFlowPerf.currentLifecycleCorrelation - EditFlowPerf.lifecycleEvent( - EditFlowPerf.Lifecycle.ReadFile.storeReadContentEntered, + let lifecycleCorrelation = WorkspaceRuntimePerf.currentLifecycleCorrelation + WorkspaceRuntimePerf.lifecycleEvent( + WorkspaceRuntimePerf.Lifecycle.ReadFile.storeReadContentEntered, correlation: lifecycleCorrelation, - EditFlowPerf.Dimensions( + WorkspaceRuntimePerf.Dimensions( workloadClass: workloadClass.rawValue, rootToken: state.service.diagnosticRootToken.uuidString ) ) - let forwardState = EditFlowPerf.begin( - EditFlowPerf.Stage.ReadFile.storeReadContentForwardAwait, - EditFlowPerf.Dimensions(workloadClass: workloadClass.rawValue) + let forwardState = WorkspaceRuntimePerf.begin( + WorkspaceRuntimePerf.Stage.ReadFile.storeReadContentForwardAwait, + WorkspaceRuntimePerf.Dimensions(workloadClass: workloadClass.rawValue) ) do { let content = try await state.service.loadContent( ofRelativePath: StandardizedPath.relative(relativePath), workloadClass: workloadClass ) - EditFlowPerf.end( - EditFlowPerf.Stage.ReadFile.storeReadContentForwardAwait, + WorkspaceRuntimePerf.end( + WorkspaceRuntimePerf.Stage.ReadFile.storeReadContentForwardAwait, forwardState, - EditFlowPerf.Dimensions(outcome: "returned", workloadClass: workloadClass.rawValue) + WorkspaceRuntimePerf.Dimensions(outcome: "returned", workloadClass: workloadClass.rawValue) ) - EditFlowPerf.lifecycleEvent( - EditFlowPerf.Lifecycle.ReadFile.storeReadContentReturned, + WorkspaceRuntimePerf.lifecycleEvent( + WorkspaceRuntimePerf.Lifecycle.ReadFile.storeReadContentReturned, correlation: lifecycleCorrelation, - EditFlowPerf.Dimensions( + WorkspaceRuntimePerf.Dimensions( outcome: "returned", workloadClass: workloadClass.rawValue, rootToken: state.service.diagnosticRootToken.uuidString @@ -2205,15 +2625,15 @@ actor WorkspaceFileContextStore { ) return content } catch { - EditFlowPerf.end( - EditFlowPerf.Stage.ReadFile.storeReadContentForwardAwait, + WorkspaceRuntimePerf.end( + WorkspaceRuntimePerf.Stage.ReadFile.storeReadContentForwardAwait, forwardState, - EditFlowPerf.Dimensions(outcome: error is CancellationError ? "cancelled" : "error", workloadClass: workloadClass.rawValue) + WorkspaceRuntimePerf.Dimensions(outcome: error is CancellationError ? "cancelled" : "error", workloadClass: workloadClass.rawValue) ) - EditFlowPerf.lifecycleEvent( - EditFlowPerf.Lifecycle.ReadFile.storeReadContentReturned, + WorkspaceRuntimePerf.lifecycleEvent( + WorkspaceRuntimePerf.Lifecycle.ReadFile.storeReadContentReturned, correlation: lifecycleCorrelation, - EditFlowPerf.Dimensions( + WorkspaceRuntimePerf.Dimensions( outcome: error is CancellationError ? "cancelled" : "error", workloadClass: workloadClass.rawValue, rootToken: state.service.diagnosticRootToken.uuidString @@ -2223,39 +2643,49 @@ actor WorkspaceFileContextStore { } } - func readContentWithDate( + #if DEBUG + package func setContentReadChunkHandlerForTesting( + rootID: UUID, + _ handler: (@Sendable (String) async -> Void)? + ) async throws { + let state = try state(for: rootID) + await state.service.setContentReadChunkHandlerForTesting(handler) + } + #endif + + package func readContentWithDate( rootID: UUID, relativePath: String, workloadClass: ContentReadWorkloadClass = .unspecified ) async throws -> (content: String?, modificationDate: Date) { let state = try state(for: rootID) - let lifecycleCorrelation = EditFlowPerf.currentLifecycleCorrelation - EditFlowPerf.lifecycleEvent( - EditFlowPerf.Lifecycle.ReadFile.storeReadContentEntered, + let lifecycleCorrelation = WorkspaceRuntimePerf.currentLifecycleCorrelation + WorkspaceRuntimePerf.lifecycleEvent( + WorkspaceRuntimePerf.Lifecycle.ReadFile.storeReadContentEntered, correlation: lifecycleCorrelation, - EditFlowPerf.Dimensions( + WorkspaceRuntimePerf.Dimensions( workloadClass: workloadClass.rawValue, rootToken: state.service.diagnosticRootToken.uuidString ) ) - let forwardState = EditFlowPerf.begin( - EditFlowPerf.Stage.ReadFile.storeReadContentForwardAwait, - EditFlowPerf.Dimensions(workloadClass: workloadClass.rawValue) + let forwardState = WorkspaceRuntimePerf.begin( + WorkspaceRuntimePerf.Stage.ReadFile.storeReadContentForwardAwait, + WorkspaceRuntimePerf.Dimensions(workloadClass: workloadClass.rawValue) ) do { let loaded = try await state.service.loadContentWithDate( ofRelativePath: StandardizedPath.relative(relativePath), workloadClass: workloadClass ) - EditFlowPerf.end( - EditFlowPerf.Stage.ReadFile.storeReadContentForwardAwait, + WorkspaceRuntimePerf.end( + WorkspaceRuntimePerf.Stage.ReadFile.storeReadContentForwardAwait, forwardState, - EditFlowPerf.Dimensions(outcome: "returned", workloadClass: workloadClass.rawValue) + WorkspaceRuntimePerf.Dimensions(outcome: "returned", workloadClass: workloadClass.rawValue) ) - EditFlowPerf.lifecycleEvent( - EditFlowPerf.Lifecycle.ReadFile.storeReadContentReturned, + WorkspaceRuntimePerf.lifecycleEvent( + WorkspaceRuntimePerf.Lifecycle.ReadFile.storeReadContentReturned, correlation: lifecycleCorrelation, - EditFlowPerf.Dimensions( + WorkspaceRuntimePerf.Dimensions( outcome: "returned", workloadClass: workloadClass.rawValue, rootToken: state.service.diagnosticRootToken.uuidString @@ -2263,15 +2693,15 @@ actor WorkspaceFileContextStore { ) return loaded } catch { - EditFlowPerf.end( - EditFlowPerf.Stage.ReadFile.storeReadContentForwardAwait, + WorkspaceRuntimePerf.end( + WorkspaceRuntimePerf.Stage.ReadFile.storeReadContentForwardAwait, forwardState, - EditFlowPerf.Dimensions(outcome: error is CancellationError ? "cancelled" : "error", workloadClass: workloadClass.rawValue) + WorkspaceRuntimePerf.Dimensions(outcome: error is CancellationError ? "cancelled" : "error", workloadClass: workloadClass.rawValue) ) - EditFlowPerf.lifecycleEvent( - EditFlowPerf.Lifecycle.ReadFile.storeReadContentReturned, + WorkspaceRuntimePerf.lifecycleEvent( + WorkspaceRuntimePerf.Lifecycle.ReadFile.storeReadContentReturned, correlation: lifecycleCorrelation, - EditFlowPerf.Dimensions( + WorkspaceRuntimePerf.Dimensions( outcome: error is CancellationError ? "cancelled" : "error", workloadClass: workloadClass.rawValue, rootToken: state.service.diagnosticRootToken.uuidString @@ -2281,56 +2711,56 @@ actor WorkspaceFileContextStore { } } - func fileExistsOnDisk(rootID: UUID, relativePath: String) async throws -> Bool { + package func fileExistsOnDisk(rootID: UUID, relativePath: String) async throws -> Bool { let state = try state(for: rootID) return await state.service.fileExistsOnDisk(relativePath: StandardizedPath.relative(relativePath)) } - func fileModificationDate(rootID: UUID, relativePath: String) async throws -> Date { + package func fileModificationDate(rootID: UUID, relativePath: String) async throws -> Date { let state = try state(for: rootID) return try await state.service.getFileModificationDate(atRelativePath: StandardizedPath.relative(relativePath)) } - func itemModificationDateIfAvailable(rootID: UUID, relativePath: String) async throws -> Date? { + package func itemModificationDateIfAvailable(rootID: UUID, relativePath: String) async throws -> Date? { let state = try state(for: rootID) return await state.service.getItemModificationDateIfAvailable(atRelativePath: StandardizedPath.relative(relativePath)) } - func refreshIgnoreRules(rootID: UUID) async throws { + package func refreshIgnoreRules(rootID: UUID) async throws { let state = try state(for: rootID) try await state.service.refreshIgnoreRules() } - func fullPath(rootID: UUID, relativePath: String) async -> String? { + package func fullPath(rootID: UUID, relativePath: String) async -> String? { guard let state = rootStatesByID[rootID] else { return nil } return await state.service.fullPath(forRelativePath: StandardizedPath.relative(relativePath)) } - func requestCodemapScan(fileID: UUID) async throws { + package func requestCodemapScan(fileID: UUID) async throws { guard let file = filesByID[fileID] else { return } try await requestCodemapScans(for: [file]) } - func requestCodemapScan(rootID: UUID, relativePath: String) async throws { + package func requestCodemapScan(rootID: UUID, relativePath: String) async throws { guard let file = file(rootID: rootID, relativePath: relativePath) else { return } try await requestCodemapScans(for: [file]) } - func requestCodemapScans(inRoot rootID: UUID) async throws { + package func requestCodemapScans(inRoot rootID: UUID) async throws { try await requestCodemapScans(for: files(inRoot: rootID)) } - func requestCodemapScansForAllRoots() async throws { + package func requestCodemapScansForAllRoots() async throws { try await requestCodemapScans(for: rootLoadOrder.flatMap { files(inRoot: $0) }) } - func requestInitialRootCodemapScans( + package func requestInitialRootCodemapScans( rootFolderPaths: [String], purgeCachesOnEmptyInitialRequests: Bool = false ) async throws { ensureCodeScanResultTask() #if DEBUG - let collectFilesStartMS = WorkspaceRestorePerfLog.timestampMSIfEnabled() + let collectFilesStartMS = WorkspaceRuntimeDebugLog.timestampMSIfEnabled() #endif let standardizedRootPaths = rootFolderPaths.map { ($0 as NSString).standardizingPath } var filesToScan: [WorkspaceFileRecord] = [] @@ -2343,29 +2773,29 @@ actor WorkspaceFileContextStore { }) } #if DEBUG - WorkspaceRestorePerfLog.event( + WorkspaceRuntimeDebugLog.event( "store.initialCodemapScan.collectFiles", fields: [ "source": "paths", "rootCount": "\(standardizedRootPaths.count)", "supportedFiles": "\(filesToScan.count)", - "duration": collectFilesStartMS.map { WorkspaceRestorePerfLog.formatElapsedMS(since: $0) } ?? "notMeasured" + "duration": collectFilesStartMS.map { WorkspaceRuntimeDebugLog.formatElapsedMS(since: $0) } ?? "notMeasured" ] ) - let buildRequestsStartMS = WorkspaceRestorePerfLog.timestampMSIfEnabled() + let buildRequestsStartMS = WorkspaceRuntimeDebugLog.timestampMSIfEnabled() #endif let requests = try await codemapScanRequests(for: filesToScan) #if DEBUG - WorkspaceRestorePerfLog.event( + WorkspaceRuntimeDebugLog.event( "store.initialCodemapScan.buildRequests", fields: [ "source": "paths", "supportedFiles": "\(filesToScan.count)", "requests": "\(requests.count)", - "duration": buildRequestsStartMS.map { WorkspaceRestorePerfLog.formatElapsedMS(since: $0) } ?? "notMeasured" + "duration": buildRequestsStartMS.map { WorkspaceRuntimeDebugLog.formatElapsedMS(since: $0) } ?? "notMeasured" ] ) - let submitStartMS = WorkspaceRestorePerfLog.timestampMSIfEnabled() + let submitStartMS = WorkspaceRuntimeDebugLog.timestampMSIfEnabled() #endif await codeScanActor.requestScans( requests, @@ -2374,25 +2804,25 @@ actor WorkspaceFileContextStore { purgeCachesOnEmptyInitialRequests: purgeCachesOnEmptyInitialRequests ) #if DEBUG - WorkspaceRestorePerfLog.event( + WorkspaceRuntimeDebugLog.event( "store.initialCodemapScan.submit", fields: [ "source": "paths", "requests": "\(requests.count)", "rootCount": "\(standardizedRootPaths.count)", - "duration": submitStartMS.map { WorkspaceRestorePerfLog.formatElapsedMS(since: $0) } ?? "notMeasured" + "duration": submitStartMS.map { WorkspaceRuntimeDebugLog.formatElapsedMS(since: $0) } ?? "notMeasured" ] ) #endif } - func requestInitialRootCodemapScans( + package func requestInitialRootCodemapScans( rootIDs: [UUID], purgeCachesOnEmptyInitialRequests: Bool = false ) async throws { ensureCodeScanResultTask() #if DEBUG - let collectFilesStartMS = WorkspaceRestorePerfLog.timestampMSIfEnabled() + let collectFilesStartMS = WorkspaceRuntimeDebugLog.timestampMSIfEnabled() #endif var seenRootIDs = Set() var orderedRootIDs: [UUID] = [] @@ -2407,29 +2837,29 @@ actor WorkspaceFileContextStore { }) } #if DEBUG - WorkspaceRestorePerfLog.event( + WorkspaceRuntimeDebugLog.event( "store.initialCodemapScan.collectFiles", fields: [ "source": "rootIDs", "rootCount": "\(orderedRootIDs.count)", "supportedFiles": "\(filesToScan.count)", - "duration": collectFilesStartMS.map { WorkspaceRestorePerfLog.formatElapsedMS(since: $0) } ?? "notMeasured" + "duration": collectFilesStartMS.map { WorkspaceRuntimeDebugLog.formatElapsedMS(since: $0) } ?? "notMeasured" ] ) #endif guard !orderedRootIDs.isEmpty else { return } #if DEBUG - let buildRequestsStartMS = WorkspaceRestorePerfLog.timestampMSIfEnabled() + let buildRequestsStartMS = WorkspaceRuntimeDebugLog.timestampMSIfEnabled() #endif let requests = try await codemapScanRequests(for: filesToScan) #if DEBUG - WorkspaceRestorePerfLog.event( + WorkspaceRuntimeDebugLog.event( "store.initialCodemapScan.buildRequests", fields: [ "source": "rootIDs", "supportedFiles": "\(filesToScan.count)", "requests": "\(requests.count)", - "duration": buildRequestsStartMS.map { WorkspaceRestorePerfLog.formatElapsedMS(since: $0) } ?? "notMeasured" + "duration": buildRequestsStartMS.map { WorkspaceRuntimeDebugLog.formatElapsedMS(since: $0) } ?? "notMeasured" ] ) #endif @@ -2447,7 +2877,7 @@ actor WorkspaceFileContextStore { return currentRootIDs.contains(file.rootID) } #if DEBUG - let submitStartMS = WorkspaceRestorePerfLog.timestampMSIfEnabled() + let submitStartMS = WorkspaceRuntimeDebugLog.timestampMSIfEnabled() #endif await codeScanActor.requestScans( currentRequests, @@ -2456,19 +2886,19 @@ actor WorkspaceFileContextStore { purgeCachesOnEmptyInitialRequests: purgeCachesOnEmptyInitialRequests ) #if DEBUG - WorkspaceRestorePerfLog.event( + WorkspaceRuntimeDebugLog.event( "store.initialCodemapScan.submit", fields: [ "source": "rootIDs", "requests": "\(currentRequests.count)", "rootCount": "\(currentRootFolderPaths.count)", - "duration": submitStartMS.map { WorkspaceRestorePerfLog.formatElapsedMS(since: $0) } ?? "notMeasured" + "duration": submitStartMS.map { WorkspaceRuntimeDebugLog.formatElapsedMS(since: $0) } ?? "notMeasured" ] ) #endif } - func requestCodemapScans(for files: [WorkspaceFileRecord]) async throws { + package func requestCodemapScans(for files: [WorkspaceFileRecord]) async throws { ensureCodeScanResultTask() let requests = try await codemapScanRequests(for: files) let rootFolderPaths = Array(Set(requests.map(\.rootFolderPath))) @@ -2504,7 +2934,7 @@ actor WorkspaceFileContextStore { } @discardableResult - func createFile(rootID: UUID, relativePath: String, content: String) async throws -> WorkspaceFileCatalogMaterializationResult { + package func createFile(rootID: UUID, relativePath: String, content: String) async throws -> WorkspaceFileCatalogMaterializationResult { let state = try state(for: rootID) let standardizedRelativePath = StandardizedPath.relative(relativePath) try await state.service.createFile(atRelativePath: standardizedRelativePath, content: content) @@ -2512,7 +2942,7 @@ actor WorkspaceFileContextStore { } @discardableResult - func editFile(rootID: UUID, relativePath: String, newContent: String) async throws -> WorkspaceFileCatalogMaterializationResult? { + package func editFile(rootID: UUID, relativePath: String, newContent: String) async throws -> WorkspaceFileCatalogMaterializationResult? { let state = try state(for: rootID) let standardizedRelativePath = StandardizedPath.relative(relativePath) do { @@ -2532,7 +2962,7 @@ actor WorkspaceFileContextStore { return try await materializeCatalogFileAfterDiskWrite(rootID: rootID, relativePath: standardizedRelativePath) } - func moveFile(rootID: UUID, from oldRelativePath: String, to newRelativePath: String) async throws { + package func moveFile(rootID: UUID, from oldRelativePath: String, to newRelativePath: String) async throws { let state = try state(for: rootID) let oldPath = StandardizedPath.relative(oldRelativePath) let newPath = StandardizedPath.relative(newRelativePath) @@ -2562,7 +2992,7 @@ actor WorkspaceFileContextStore { ) } - func deleteFile(rootID: UUID, relativePath: String) async throws { + package func deleteFile(rootID: UUID, relativePath: String) async throws { let state = try state(for: rootID) let standardizedRelativePath = StandardizedPath.relative(relativePath) let oldFile = file(rootID: rootID, relativePath: standardizedRelativePath) @@ -2581,7 +3011,7 @@ actor WorkspaceFileContextStore { } } - func moveItemToTrash(rootID: UUID, relativePath: String) async throws { + package func moveItemToTrash(rootID: UUID, relativePath: String) async throws { let state = try state(for: rootID) let standardizedRelativePath = StandardizedPath.relative(relativePath) let oldFile = file(rootID: rootID, relativePath: standardizedRelativePath) @@ -2613,24 +3043,24 @@ actor WorkspaceFileContextStore { } } - func validateCatalogFileStillPresent(_ file: WorkspaceFileRecord) async -> WorkspaceFileRecord? { - let lifecycleCorrelation = EditFlowPerf.currentLifecycleCorrelation - EditFlowPerf.lifecycleEvent( - EditFlowPerf.Lifecycle.Search.contentFreshnessStoreEntered, + package func validateCatalogFileStillPresent(_ file: WorkspaceFileRecord) async -> WorkspaceFileRecord? { + let lifecycleCorrelation = WorkspaceRuntimePerf.currentLifecycleCorrelation + WorkspaceRuntimePerf.lifecycleEvent( + WorkspaceRuntimePerf.Lifecycle.Search.contentFreshnessStoreEntered, correlation: lifecycleCorrelation ) - let validationState = EditFlowPerf.begin(EditFlowPerf.Stage.Search.contentFreshnessValidationStoreActorBody) + let validationState = WorkspaceRuntimePerf.begin(WorkspaceRuntimePerf.Stage.Search.contentFreshnessValidationStoreActorBody) var outcome = "missing" defer { - EditFlowPerf.end( - EditFlowPerf.Stage.Search.contentFreshnessValidationStoreActorBody, + WorkspaceRuntimePerf.end( + WorkspaceRuntimePerf.Stage.Search.contentFreshnessValidationStoreActorBody, validationState, - EditFlowPerf.Dimensions(outcome: outcome) + WorkspaceRuntimePerf.Dimensions(outcome: outcome) ) - EditFlowPerf.lifecycleEvent( - EditFlowPerf.Lifecycle.Search.contentFreshnessStoreReturned, + WorkspaceRuntimePerf.lifecycleEvent( + WorkspaceRuntimePerf.Lifecycle.Search.contentFreshnessStoreReturned, correlation: lifecycleCorrelation, - EditFlowPerf.Dimensions(outcome: outcome) + WorkspaceRuntimePerf.Dimensions(outcome: outcome) ) } guard let state = rootStatesByID[file.rootID], @@ -2645,7 +3075,7 @@ actor WorkspaceFileContextStore { } @discardableResult - func pruneMissingCatalogFilesForExactMutationLookup( + package func pruneMissingCatalogFilesForExactMutationLookup( _ userPath: String, rootScope: WorkspaceLookupRootScope = .visibleWorkspace ) async -> Bool { @@ -2705,23 +3135,23 @@ actor WorkspaceFileContextStore { /// Returns an exact cataloged file without touching disk. Disk recovery for ignored /// files is intentionally reserved for absolute-path misses. - func lookupCatalogFileForExplicitRequest( + package func lookupCatalogFileForExplicitRequest( _ userPath: String, rootScope: WorkspaceLookupRootScope = .visibleWorkspace ) -> WorkspaceExplicitCatalogFileLookupResult { #if DEBUG || EDIT_FLOW_PERF var exactCatalogLookupOutcome = "noCandidate" var exactCatalogLookupRoute = "empty" - let exactCatalogLookupActorBody = EditFlowPerf.begin(EditFlowPerf.Stage.ReadFile.exactCatalogLookupActorBody) + let exactCatalogLookupActorBody = WorkspaceRuntimePerf.begin(WorkspaceRuntimePerf.Stage.ReadFile.exactCatalogLookupActorBody) defer { - let dimensions = EditFlowPerf.Dimensions(status: exactCatalogLookupRoute, outcome: exactCatalogLookupOutcome) - EditFlowPerf.end( - EditFlowPerf.Stage.ReadFile.exactCatalogLookupActorBody, + let dimensions = WorkspaceRuntimePerf.Dimensions(status: exactCatalogLookupRoute, outcome: exactCatalogLookupOutcome) + WorkspaceRuntimePerf.end( + WorkspaceRuntimePerf.Stage.ReadFile.exactCatalogLookupActorBody, exactCatalogLookupActorBody, dimensions ) - EditFlowPerf.lifecycleEvent( - EditFlowPerf.Lifecycle.ReadFile.exactCatalogLookupResolved, + WorkspaceRuntimePerf.lifecycleEvent( + WorkspaceRuntimePerf.Lifecycle.ReadFile.exactCatalogLookupResolved, dimensions ) } @@ -2812,7 +3242,7 @@ actor WorkspaceFileContextStore { /// Resolves an exact file path that the caller explicitly requested, even when /// discovery policy hides it. Ignore rules remain discovery filters: background scans, /// replay, tree rendering, search, and fuzzy matching still skip managed-only files. - func materializeExplicitlyRequestedFile( + package func materializeExplicitlyRequestedFile( _ userPath: String, rootScope: WorkspaceLookupRootScope = .visibleWorkspace ) async throws -> WorkspaceExplicitFileMaterializationResult { @@ -2861,7 +3291,7 @@ actor WorkspaceFileContextStore { case .ineligible: return .blocked } - return try .materialized(materializeCatalogRegularFile( + return try await .materialized(materializeCatalogRegularFile( rootID: candidate.rootID, relativePath: candidate.relativePath, managedOnly: managedOnly @@ -2869,7 +3299,7 @@ actor WorkspaceFileContextStore { } @discardableResult - func materializeCatalogFileAfterDiskWrite( + package func materializeCatalogFileAfterDiskWrite( rootID: UUID, relativePath: String ) async throws -> WorkspaceFileCatalogMaterializationResult { @@ -2881,7 +3311,7 @@ actor WorkspaceFileContextStore { // A direct app/MCP write is an explicit request to manage this exact file. // Keep it available for follow-up read_file/apply_edits calls without making // ignored siblings discoverable through scans or replay. - return try .materialized(materializeCatalogRegularFile(rootID: rootID, relativePath: standardizedRelativePath, managedOnly: true)) + return try await .materialized(materializeCatalogRegularFile(rootID: rootID, relativePath: standardizedRelativePath, managedOnly: true)) case let .ineligible(reason): guard isExpectedDiskWriteCatalogIneligibility(reason) else { throw WorkspaceFileContextStoreError.catalogMaterializationFailed( @@ -2890,7 +3320,7 @@ actor WorkspaceFileContextStore { } return .ineligible(reason) case .eligible: - return try .materialized(materializeCatalogRegularFile(rootID: rootID, relativePath: standardizedRelativePath, managedOnly: false)) + return try await .materialized(materializeCatalogRegularFile(rootID: rootID, relativePath: standardizedRelativePath, managedOnly: false)) } } @@ -2959,7 +3389,7 @@ actor WorkspaceFileContextStore { rootID: UUID, relativePath: String, managedOnly: Bool - ) throws -> WorkspaceFileRecord { + ) async throws -> WorkspaceFileRecord { let state = try state(for: rootID) let standardizedRelativePath = StandardizedPath.relative(relativePath) if let existing = file(rootID: rootID, relativePath: standardizedRelativePath) { @@ -2978,7 +3408,7 @@ actor WorkspaceFileContextStore { return existing } - guard regularFileAppearsPresentOnDisk(root: state.root, relativePath: standardizedRelativePath) else { + guard await regularFileAppearsPresentOnDisk(root: state.root, relativePath: standardizedRelativePath) else { throw WorkspaceFileContextStoreError.catalogMaterializationFailed( "eligible file disappeared before it could be added to the workspace catalog: \(standardizedRelativePath)" ) @@ -3008,15 +3438,9 @@ actor WorkspaceFileContextStore { } } - private func regularFileAppearsPresentOnDisk(root: WorkspaceRootRecord, relativePath: String) -> Bool { - let fullPath = StandardizedPath.join(standardizedRoot: root.standardizedFullPath, standardizedRelativePath: StandardizedPath.relative(relativePath)) - var isDirectory = ObjCBool(false) - guard FileManager.default.fileExists(atPath: fullPath, isDirectory: &isDirectory), !isDirectory.boolValue else { return false } - if let values = try? URL(fileURLWithPath: fullPath).resourceValues(forKeys: [.isRegularFileKey, .isSymbolicLinkKey]) { - if values.isSymbolicLink == true { return false } - if values.isRegularFile == false { return false } - } - return true + private func regularFileAppearsPresentOnDisk(root: WorkspaceRootRecord, relativePath: String) async -> Bool { + guard let service = rootStatesByID[root.id]?.service else { return false } + return await service.regularFileExistsOnDisk(relativePath: StandardizedPath.relative(relativePath)) } @discardableResult @@ -3079,7 +3503,7 @@ actor WorkspaceFileContextStore { case .fileAdded: guard let state = rootStatesByID[rootID], await state.service.catalogEligibleRegularFileExists(relativePath: relativePath), - regularFileAppearsPresentOnDisk(root: root, relativePath: relativePath) + await regularFileAppearsPresentOnDisk(root: root, relativePath: relativePath) else { continue } let existingFile = file(rootID: rootID, relativePath: relativePath) let existed = existingFile != nil @@ -3151,7 +3575,7 @@ actor WorkspaceFileContextStore { ) } - func lookupPath( + package func lookupPath( _ userPath: String, profile: PathLocateProfile = .uiAssisted, rootScope: WorkspaceLookupRootScope = .allLoaded @@ -3160,7 +3584,7 @@ actor WorkspaceFileContextStore { return await lookupPath(request) } - func lookupPath(_ request: WorkspacePathLookupRequest) async -> WorkspacePathLookupResult? { + package func lookupPath(_ request: WorkspacePathLookupRequest) async -> WorkspacePathLookupResult? { let normalizedPath = normalizeUserInputPath(request.userPath) guard !normalizedPath.isEmpty else { return nil } @@ -3176,7 +3600,7 @@ actor WorkspaceFileContextStore { return lookupResult(input: request.userPath, match: match) } - func lookupPaths(_ requests: [WorkspacePathLookupRequest]) async -> [String: WorkspacePathLookupResult] { + package func lookupPaths(_ requests: [WorkspacePathLookupRequest]) async -> [String: WorkspacePathLookupResult] { struct LookupBatchKey: Hashable { let rootScope: WorkspaceLookupRootScope let profile: PathLocateProfile @@ -3215,7 +3639,7 @@ actor WorkspaceFileContextStore { return results } - func findCreationPath( + package func findCreationPath( userPath: String, rootScope: WorkspaceLookupRootScope = .allLoaded, selectedFileFullPaths: Set = [] @@ -3231,7 +3655,7 @@ actor WorkspaceFileContextStore { ) } - func resolveCreationPath( + package func resolveCreationPath( userPath: String, rootScope: WorkspaceLookupRootScope = .allLoaded, selectedFileFullPaths: Set = [], @@ -3249,7 +3673,7 @@ actor WorkspaceFileContextStore { ) } - func lookupPath(rootID: UUID, relativePath: String) -> WorkspacePathLookupResult? { + package func lookupPath(rootID: UUID, relativePath: String) -> WorkspacePathLookupResult? { guard let state = rootStatesByID[rootID] else { return nil } let key = StandardizedPath.relative(relativePath) if let fileID = state.fileIDsByRelativePath[key], let file = filesByID[fileID] { @@ -3261,7 +3685,7 @@ actor WorkspaceFileContextStore { return nil } - func lookupDiscoverableCatalogPathForExactAbsoluteSearchScope( + package func lookupDiscoverableCatalogPathForExactAbsoluteSearchScope( _ userPath: String, rootScope: WorkspaceLookupRootScope ) -> WorkspacePathLookupResult? { @@ -3281,20 +3705,20 @@ actor WorkspaceFileContextStore { return result } - func rootRefs(scope: WorkspaceLookupRootScope = .allLoaded) -> [WorkspaceRootRef] { + package func rootRefs(scope: WorkspaceLookupRootScope = .allLoaded) -> [WorkspaceRootRef] { rootsForPathLookup(scope: scope).map { WorkspaceRootRef(id: $0.id, name: $0.name, fullPath: $0.standardizedFullPath) } } - func displayRootRefsSnapshot() -> WorkspaceDisplayRootRefsSnapshot { + package func displayRootRefsSnapshot() -> WorkspaceDisplayRootRefsSnapshot { WorkspaceDisplayRootRefsSnapshot( visibleRoots: rootRefs(scope: .visibleWorkspace), allRoots: rootRefs(scope: .allLoaded) ) } - func exactPathResolutionIssue( + package func exactPathResolutionIssue( for userPath: String, kind: WorkspaceExactPathLookupKind, rootScope: WorkspaceLookupRootScope = .visibleWorkspace @@ -3351,7 +3775,7 @@ actor WorkspaceFileContextStore { return .ambiguousRootMatch(input: trimmedInput, candidateRoots: matchingRoots) } - func lookupFiles( + package func lookupFiles( atPaths paths: [String], profile: PathLocateProfile = .mcpSelection, rootScope: WorkspaceLookupRootScope = .visibleWorkspace @@ -3387,7 +3811,7 @@ actor WorkspaceFileContextStore { return files } - func resolveFolderInput( + package func resolveFolderInput( _ path: String, rootScope: WorkspaceLookupRootScope = .visibleWorkspace, profile: PathLocateProfile = .mcpSelection @@ -3453,12 +3877,12 @@ actor WorkspaceFileContextStore { ) } - let generalLookupState = EditFlowPerf.begin(EditFlowPerf.Stage.ReadFile.folderResolutionGeneralLookupFallback) + let generalLookupState = WorkspaceRuntimePerf.begin(WorkspaceRuntimePerf.Stage.ReadFile.folderResolutionGeneralLookupFallback) let lookup = await lookupPath(WorkspacePathLookupRequest(userPath: cleaned, profile: profile, rootScope: rootScope)) - EditFlowPerf.end( - EditFlowPerf.Stage.ReadFile.folderResolutionGeneralLookupFallback, + WorkspaceRuntimePerf.end( + WorkspaceRuntimePerf.Stage.ReadFile.folderResolutionGeneralLookupFallback, generalLookupState, - EditFlowPerf.Dimensions(outcome: lookup?.folder == nil ? "noFolder" : "folder") + WorkspaceRuntimePerf.Dimensions(outcome: lookup?.folder == nil ? "noFolder" : "folder") ) if let folder = lookup?.folder, let root = roots.first(where: { $0.id == folder.rootID }) @@ -3468,7 +3892,7 @@ actor WorkspaceFileContextStore { return (nil, nil, nil) } - func expandFolderInputToFiles( + package func expandFolderInputToFiles( _ path: String, rootScope: WorkspaceLookupRootScope = .visibleWorkspace, profile: PathLocateProfile = .mcpSelection @@ -3537,8 +3961,8 @@ actor WorkspaceFileContextStore { } private func buildStaticSnapshot(scope: WorkspaceLookupRootScope) -> StaticPathMatchData { - let snapshotState = EditFlowPerf.begin(EditFlowPerf.Stage.ReadFile.pathLookupStaticSnapshotBuild) - defer { EditFlowPerf.end(EditFlowPerf.Stage.ReadFile.pathLookupStaticSnapshotBuild, snapshotState) } + let snapshotState = WorkspaceRuntimePerf.begin(WorkspaceRuntimePerf.Stage.ReadFile.pathLookupStaticSnapshotBuild) + defer { WorkspaceRuntimePerf.end(WorkspaceRuntimePerf.Stage.ReadFile.pathLookupStaticSnapshotBuild, snapshotState) } let roots = rootsForPathLookup(scope: scope) let allowedRootIDs = Set(roots.map(\.id)) var fileRecords: [String: FileRecord] = [:] @@ -4215,7 +4639,7 @@ actor WorkspaceFileContextStore { } } -enum WorkspaceFileContextStoreError: Error, Equatable { +package enum WorkspaceFileContextStoreError: Error, Equatable { case rootNotLoaded(UUID) case storeDeallocated case rootAlreadyLoadedWithDifferentConfiguration(String) @@ -4224,7 +4648,7 @@ enum WorkspaceFileContextStoreError: Error, Equatable { } extension WorkspaceFileContextStoreError: LocalizedError { - var errorDescription: String? { + package var errorDescription: String? { switch self { case let .rootNotLoaded(id): "Workspace root is not loaded: \(id)." diff --git a/Sources/RepoPromptCore/WorkspaceContext/WorkspaceFileManagerError.swift b/Sources/RepoPromptCore/WorkspaceContext/WorkspaceFileManagerError.swift new file mode 100644 index 000000000..c11b02561 --- /dev/null +++ b/Sources/RepoPromptCore/WorkspaceContext/WorkspaceFileManagerError.swift @@ -0,0 +1,24 @@ +import Foundation + +package enum FileManagerError: Error, LocalizedError { + case failedToLoadFolder(Error) + case failedToLoadFile(Error) + case fileSystemServiceNotFound + case failedToLoadContent + case fileSystemServiceNotFoundWithContext(String) + + package var errorDescription: String? { + switch self { + case let .failedToLoadFolder(error): + "Failed to load folder: \(error.localizedDescription)" + case let .failedToLoadFile(error): + "Failed to load file: \(error.localizedDescription)" + case .fileSystemServiceNotFound: + "No matching workspace folder for the requested path." + case .failedToLoadContent: + "Failed to load content." + case let .fileSystemServiceNotFoundWithContext(context): + context + } + } +} diff --git a/Sources/RepoPrompt/Infrastructure/WorkspaceContext/WorkspaceFileSystemIngressCoordinator.swift b/Sources/RepoPromptCore/WorkspaceContext/WorkspaceFileSystemIngressCoordinator.swift similarity index 89% rename from Sources/RepoPrompt/Infrastructure/WorkspaceContext/WorkspaceFileSystemIngressCoordinator.swift rename to Sources/RepoPromptCore/WorkspaceContext/WorkspaceFileSystemIngressCoordinator.swift index efd2c6165..6c1f82bb8 100644 --- a/Sources/RepoPrompt/Infrastructure/WorkspaceContext/WorkspaceFileSystemIngressCoordinator.swift +++ b/Sources/RepoPromptCore/WorkspaceContext/WorkspaceFileSystemIngressCoordinator.swift @@ -5,23 +5,23 @@ import Foundation /// Every accepted publication is queued before the sink returns. One retained drain task per /// root applies publications serially, preserving watcher and synthetic publication order while /// allowing barriers to await an exact service-publication cut through canonical application. -final class WorkspaceFileSystemIngressCoordinator: @unchecked Sendable { - struct Subscription: Hashable { +package final class WorkspaceFileSystemIngressCoordinator: @unchecked Sendable { + package struct Subscription: Hashable { let rootID: UUID let generation: UInt64 } - struct AppliedSnapshot: Equatable { + package struct AppliedSnapshot: Equatable { let acceptedServicePublicationSequence: UInt64 let appliedServicePublicationSequence: UInt64 let appliedWatcherWatermark: FileSystemWatcherIngressMailbox.Watermark } - typealias DrainHandler = @Sendable (FileSystemDeltaPublication, EditFlowPerf.LifecycleCorrelation?) async -> Void + package typealias DrainHandler = @Sendable (FileSystemDeltaPublication, WorkspaceRuntimePerf.LifecycleCorrelation?) async -> Void private struct QueuedPublication { let publication: FileSystemDeltaPublication - let lifecycleCorrelation: EditFlowPerf.LifecycleCorrelation? + let lifecycleCorrelation: WorkspaceRuntimePerf.LifecycleCorrelation? let drainHandler: DrainHandler } @@ -76,7 +76,7 @@ final class WorkspaceFileSystemIngressCoordinator: @unchecked Sendable { private var waitersByRootID: [UUID: [UUID: Waiter]] = [:] private var nextDrainToken: UInt64 = 0 - func openPublisherIngress(rootID: UUID, drainHandler: @escaping DrainHandler) -> Subscription { + package func openPublisherIngress(rootID: UUID, drainHandler: @escaping DrainHandler) -> Subscription { lock.lock() defer { lock.unlock() } @@ -88,7 +88,7 @@ final class WorkspaceFileSystemIngressCoordinator: @unchecked Sendable { return Subscription(rootID: rootID, generation: state.generation) } - func closePublisherIngress(rootID: UUID) { + package func closePublisherIngress(rootID: UUID) { lock.lock() defer { lock.unlock() } @@ -97,7 +97,7 @@ final class WorkspaceFileSystemIngressCoordinator: @unchecked Sendable { state.isOpen = false } - func isPublisherIngressOpen(_ subscription: Subscription) -> Bool { + package func isPublisherIngressOpen(_ subscription: Subscription) -> Bool { lock.lock() defer { lock.unlock() } @@ -105,17 +105,17 @@ final class WorkspaceFileSystemIngressCoordinator: @unchecked Sendable { return state.isOpen && state.generation == subscription.generation } - func hasOpenPublisherIngress(rootID: UUID) -> Bool { + package func hasOpenPublisherIngress(rootID: UUID) -> Bool { lock.lock() defer { lock.unlock() } return rootStatesByID[rootID]?.isOpen == true } @discardableResult - func accept( + package func accept( _ subscription: Subscription, publication: FileSystemDeltaPublication, - lifecycleCorrelation: EditFlowPerf.LifecycleCorrelation? + lifecycleCorrelation: WorkspaceRuntimePerf.LifecycleCorrelation? ) -> Bool { lock.lock() defer { lock.unlock() } @@ -140,7 +140,7 @@ final class WorkspaceFileSystemIngressCoordinator: @unchecked Sendable { return true } - func waitUntilApplied(rootID: UUID, servicePublicationSequence: UInt64) async { + package func waitUntilApplied(rootID: UUID, servicePublicationSequence: UInt64) async { guard servicePublicationSequence > 0 else { return } await withCheckedContinuation { continuation in lock.lock() @@ -159,7 +159,7 @@ final class WorkspaceFileSystemIngressCoordinator: @unchecked Sendable { } } - func waitForCurrentPublisherIngress(rootIDs: Set) async { + package func waitForCurrentPublisherIngress(rootIDs: Set) async { let targets: [(rootID: UUID, servicePublicationSequence: UInt64)] = { lock.lock() defer { lock.unlock() } @@ -176,7 +176,7 @@ final class WorkspaceFileSystemIngressCoordinator: @unchecked Sendable { } } - func appliedSnapshot(rootID: UUID) -> AppliedSnapshot { + package func appliedSnapshot(rootID: UUID) -> AppliedSnapshot { lock.lock() defer { lock.unlock() } guard let state = rootStatesByID[rootID] else { @@ -193,7 +193,7 @@ final class WorkspaceFileSystemIngressCoordinator: @unchecked Sendable { ) } - func pendingPublisherIngressCount(rootIDs: Set) -> Int { + package func pendingPublisherIngressCount(rootIDs: Set) -> Int { lock.lock() defer { lock.unlock() } return rootIDs.reduce(into: 0) { count, rootID in @@ -202,7 +202,7 @@ final class WorkspaceFileSystemIngressCoordinator: @unchecked Sendable { } } - func finishPublisherIngress(rootIDs: Set) { + package func finishPublisherIngress(rootIDs: Set) { var continuations: [CheckedContinuation] = [] lock.lock() for rootID in rootIDs { diff --git a/Sources/RepoPrompt/Infrastructure/WorkspaceContext/WorkspaceReadableFileService.swift b/Sources/RepoPromptCore/WorkspaceContext/WorkspaceReadableFileService.swift similarity index 60% rename from Sources/RepoPrompt/Infrastructure/WorkspaceContext/WorkspaceReadableFileService.swift rename to Sources/RepoPromptCore/WorkspaceContext/WorkspaceReadableFileService.swift index f68c1bdbd..9062c2e40 100644 --- a/Sources/RepoPrompt/Infrastructure/WorkspaceContext/WorkspaceReadableFileService.swift +++ b/Sources/RepoPromptCore/WorkspaceContext/WorkspaceReadableFileService.swift @@ -1,44 +1,47 @@ import Foundation -struct WorkspaceReadableFileService { - let store: WorkspaceFileContextStore - let homeDirectoryURL: URL +package struct WorkspaceReadableFileService { + package let store: WorkspaceFileContextStore + package let homeDirectoryURL: URL + package let externalFileReader: any WorkspaceExternalFileReading - init( + package init( store: WorkspaceFileContextStore, - homeDirectoryURL: URL = FileManager.default.homeDirectoryForCurrentUser + homeDirectoryURL: URL = FileManager.default.homeDirectoryForCurrentUser, + externalFileReader: (any WorkspaceExternalFileReading)? = nil ) { self.store = store self.homeDirectoryURL = homeDirectoryURL + self.externalFileReader = externalFileReader ?? WorkspaceExternalFileReaderProvider.makeReader() } - func awaitFreshnessForExplicitRequest( + package func awaitFreshnessForExplicitRequest( _ userPath: String, fallbackScope: WorkspaceLookupRootScope ) async { - let lifecycleCorrelation = EditFlowPerf.currentLifecycleCorrelation - EditFlowPerf.lifecycleEvent( - EditFlowPerf.Lifecycle.ReadFile.explicitFreshnessBegan, + let lifecycleCorrelation = WorkspaceRuntimePerf.currentLifecycleCorrelation + WorkspaceRuntimePerf.lifecycleEvent( + WorkspaceRuntimePerf.Lifecycle.ReadFile.explicitFreshnessBegan, correlation: lifecycleCorrelation ) - let freshnessState = EditFlowPerf.begin(EditFlowPerf.Stage.ReadFile.explicitIngressFreshnessWait) + let freshnessState = WorkspaceRuntimePerf.begin(WorkspaceRuntimePerf.Stage.ReadFile.explicitIngressFreshnessWait) let samples = await store.awaitAppliedIngressForExplicitRequest( userPath: userPath, fallbackScope: fallbackScope ) - EditFlowPerf.end( - EditFlowPerf.Stage.ReadFile.explicitIngressFreshnessWait, + WorkspaceRuntimePerf.end( + WorkspaceRuntimePerf.Stage.ReadFile.explicitIngressFreshnessWait, freshnessState, - EditFlowPerf.Dimensions( + WorkspaceRuntimePerf.Dimensions( rootCount: samples.count, pendingRootCount: samples.count(where: { $0.pendingRawEventCountBeforeFlush > 0 }), pendingRawEventCount: samples.reduce(0) { $0 + $1.pendingRawEventCountBeforeFlush } ) ) - EditFlowPerf.lifecycleEvent( - EditFlowPerf.Lifecycle.ReadFile.explicitFreshnessEnded, + WorkspaceRuntimePerf.lifecycleEvent( + WorkspaceRuntimePerf.Lifecycle.ReadFile.explicitFreshnessEnded, correlation: lifecycleCorrelation, - EditFlowPerf.Dimensions( + WorkspaceRuntimePerf.Dimensions( rootCount: samples.count, pendingRootCount: samples.count(where: { $0.pendingRawEventCountBeforeFlush > 0 }), pendingRawEventCount: samples.reduce(0) { $0 + $1.pendingRawEventCountBeforeFlush } @@ -46,7 +49,7 @@ struct WorkspaceReadableFileService { ) } - static func exactAbsoluteCatalogHitInput(_ rawPath: String) -> String? { + package static func exactAbsoluteCatalogHitInput(_ rawPath: String) -> String? { let trimmed = rawPath.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { return nil } let expanded = (trimmed as NSString).expandingTildeInPath @@ -54,7 +57,7 @@ struct WorkspaceReadableFileService { return expanded } - func resolveExactAbsoluteWorkspaceCatalogHit( + package func resolveExactAbsoluteWorkspaceCatalogHit( _ rawPath: String, rootScope: WorkspaceLookupRootScope ) async -> WorkspaceFileRecord? { @@ -62,7 +65,7 @@ struct WorkspaceReadableFileService { return await resolveExactWorkspaceCatalogHit(absolutePath, rootScope: rootScope) } - func resolveExactWorkspaceCatalogHit( + package func resolveExactWorkspaceCatalogHit( _ rawPath: String, rootScope: WorkspaceLookupRootScope ) async -> WorkspaceFileRecord? { @@ -72,19 +75,19 @@ struct WorkspaceReadableFileService { return file } - func resolveReadableFile( + package func resolveReadableFile( _ userPath: String, profile: PathLocateProfile = .mcpRead, rootScope: WorkspaceLookupRootScope = .visibleWorkspace ) async -> WorkspaceReadableFileHandle? { let trimmed = normalizedInput(userPath) guard !trimmed.isEmpty else { return nil } - let exactCatalogLookupAwait = EditFlowPerf.begin(EditFlowPerf.Stage.ReadFile.exactCatalogLookupAwait) + let exactCatalogLookupAwait = WorkspaceRuntimePerf.begin(WorkspaceRuntimePerf.Stage.ReadFile.exactCatalogLookupAwait) let exactCatalogLookup = await store.lookupCatalogFileForExplicitRequest(trimmed, rootScope: rootScope) - EditFlowPerf.end( - EditFlowPerf.Stage.ReadFile.exactCatalogLookupAwait, + WorkspaceRuntimePerf.end( + WorkspaceRuntimePerf.Stage.ReadFile.exactCatalogLookupAwait, exactCatalogLookupAwait, - EditFlowPerf.Dimensions(outcome: { + WorkspaceRuntimePerf.Dimensions(outcome: { switch exactCatalogLookup { case .matched: "matched" @@ -105,12 +108,12 @@ struct WorkspaceReadableFileService { case .noCandidate: break } - let explicitMaterialization = EditFlowPerf.begin(EditFlowPerf.Stage.ReadFile.explicitMaterialization) + let explicitMaterialization = WorkspaceRuntimePerf.begin(WorkspaceRuntimePerf.Stage.ReadFile.explicitMaterialization) let materialization = try? await store.materializeExplicitlyRequestedFile(trimmed, rootScope: rootScope) - EditFlowPerf.end( - EditFlowPerf.Stage.ReadFile.explicitMaterialization, + WorkspaceRuntimePerf.end( + WorkspaceRuntimePerf.Stage.ReadFile.explicitMaterialization, explicitMaterialization, - EditFlowPerf.Dimensions(outcome: { + WorkspaceRuntimePerf.Dimensions(outcome: { switch materialization { case .some(.materialized): "materialized" @@ -133,69 +136,72 @@ struct WorkspaceReadableFileService { case .some(.noCandidate), .none: break } - let generalLookupFallback = EditFlowPerf.begin(EditFlowPerf.Stage.ReadFile.generalLookupFallback) + let generalLookupFallback = WorkspaceRuntimePerf.begin(WorkspaceRuntimePerf.Stage.ReadFile.generalLookupFallback) let workspaceFile = await store.lookupPath( WorkspacePathLookupRequest(userPath: trimmed, profile: profile, rootScope: rootScope) )?.file - EditFlowPerf.end( - EditFlowPerf.Stage.ReadFile.generalLookupFallback, + WorkspaceRuntimePerf.end( + WorkspaceRuntimePerf.Stage.ReadFile.generalLookupFallback, generalLookupFallback, - EditFlowPerf.Dimensions(outcome: workspaceFile == nil ? "noCandidate" : "matched") + WorkspaceRuntimePerf.Dimensions(outcome: workspaceFile == nil ? "noCandidate" : "matched") ) if let workspaceFile { return .workspace(workspaceFile) } guard trimmed.hasPrefix("/") else { return nil } - let externalFileFallback = EditFlowPerf.begin(EditFlowPerf.Stage.ReadFile.externalFileFallback) + let externalFileFallback = WorkspaceRuntimePerf.begin(WorkspaceRuntimePerf.Stage.ReadFile.externalFileFallback) let externalFile = resolveAlwaysReadableExternalFile(atAbsolutePath: trimmed) - EditFlowPerf.end( - EditFlowPerf.Stage.ReadFile.externalFileFallback, + WorkspaceRuntimePerf.end( + WorkspaceRuntimePerf.Stage.ReadFile.externalFileFallback, externalFileFallback, - EditFlowPerf.Dimensions(outcome: externalFile == nil ? "noCandidate" : "external") + WorkspaceRuntimePerf.Dimensions(outcome: externalFile == nil ? "noCandidate" : "external") ) return externalFile.map { .external($0) } } - func resolveAlwaysReadableExternalFolderDisplayPath(_ userPath: String) -> String? { + package func resolveAlwaysReadableExternalFolderDisplayPath(_ userPath: String) -> String? { let normalized = normalizedInput(userPath) guard normalized.hasPrefix("/"), isAlwaysReadableExternalPath(normalized) else { return nil } - let absolutePath = normalizedAlwaysReadableAbsolutePath(for: normalized) - guard isAlwaysReadableExternalPath(absolutePath) else { return nil } - var isDirectory: ObjCBool = false - guard FileManager.default.fileExists(atPath: absolutePath, isDirectory: &isDirectory), isDirectory.boolValue else { + let directories = AgentSupportDirectoryCatalog.effectiveAlwaysReadableDirectories(homeDirectoryURL: homeDirectoryURL) + guard let absolutePath = try? externalFileReader.resolveDirectory( + atAbsolutePath: normalized, + allowedDirectories: directories + ) else { return nil } return displayPath(forExternalPath: absolutePath) } - func displayPath(forExternalPath userPath: String) -> String { + package func displayPath(forExternalPath userPath: String) -> String { AgentSupportDirectoryCatalog.displayPath(for: normalizedInput(userPath), homeDirectoryURL: homeDirectoryURL) } - func isAlwaysReadableExternalPath(_ userPath: String) -> Bool { + package func isAlwaysReadableExternalPath(_ userPath: String) -> Bool { let normalized = normalizedInput(userPath) guard normalized.hasPrefix("/") else { return false } let directories = AgentSupportDirectoryCatalog.effectiveAlwaysReadableDirectories(homeDirectoryURL: homeDirectoryURL) return directories.contains { AgentSupportDirectoryCatalog.contains(absolutePath: normalized, in: $0) } } - func readAlwaysReadableExternalFile(_ file: WorkspaceExternalReadableFile) async throws -> String { + package func readAlwaysReadableExternalFile(_ file: WorkspaceExternalReadableFile) async throws -> String { let path = file.absolutePath + let directories = AgentSupportDirectoryCatalog.effectiveAlwaysReadableDirectories(homeDirectoryURL: homeDirectoryURL) + let reader = externalFileReader return try await Task.detached(priority: .userInitiated) { - let url = URL(fileURLWithPath: path) - let data = try Data(contentsOf: url) + let data = try reader.readRegularFile(atAbsolutePath: path, allowedDirectories: directories) if let decoded = String(data: data, encoding: .utf8) { return decoded } if let decoded = String(data: data, encoding: .unicode) { return decoded } return String(decoding: data, as: UTF8.self) }.value } - func resolveAlwaysReadableExternalFile(atAbsolutePath path: String) -> WorkspaceExternalReadableFile? { + package func resolveAlwaysReadableExternalFile(atAbsolutePath path: String) -> WorkspaceExternalReadableFile? { guard isAlwaysReadableExternalPath(path) else { return nil } - let absolutePath = normalizedAlwaysReadableAbsolutePath(for: path) - guard isAlwaysReadableExternalPath(absolutePath) else { return nil } - var isDirectory: ObjCBool = false - guard FileManager.default.fileExists(atPath: absolutePath, isDirectory: &isDirectory), !isDirectory.boolValue else { + let directories = AgentSupportDirectoryCatalog.effectiveAlwaysReadableDirectories(homeDirectoryURL: homeDirectoryURL) + guard let absolutePath = try? externalFileReader.resolveRegularFile( + atAbsolutePath: path, + allowedDirectories: directories + ) else { return nil } return WorkspaceExternalReadableFile( @@ -204,16 +210,6 @@ struct WorkspaceReadableFileService { ) } - private func normalizedAlwaysReadableAbsolutePath(for path: String) -> String { - let normalized = AgentSupportDirectoryCatalog.normalizedPath(for: path) - if FileManager.default.fileExists(atPath: normalized) { - return AgentSupportDirectoryCatalog.normalizedPath( - for: URL(fileURLWithPath: normalized).resolvingSymlinksInPath().standardizedFileURL.path - ) - } - return normalized - } - private func normalizedInput(_ path: String) -> String { let trimmed = path.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { return trimmed } diff --git a/Sources/RepoPromptCore/WorkspaceContext/WorkspaceRuntimeDebugDiagnostics.swift b/Sources/RepoPromptCore/WorkspaceContext/WorkspaceRuntimeDebugDiagnostics.swift new file mode 100644 index 000000000..bc7ecc6c1 --- /dev/null +++ b/Sources/RepoPromptCore/WorkspaceContext/WorkspaceRuntimeDebugDiagnostics.swift @@ -0,0 +1,40 @@ +import Dispatch +import Foundation + +/// Debug-only compatibility surface for moved runtime milestones. Production +/// diagnostics flow through the injected `WorkspaceRuntimeDiagnosticsSink`. +package enum WorkspaceRuntimeDebugLog { + package static func timestampMSIfEnabled() -> UInt64? { + guard WorkspaceRuntimePerf.activeSink != nil else { return nil } + return DispatchTime.now().uptimeNanoseconds / 1_000_000 + } + + package static func event(_ name: String, fields: [String: String] = [:]) { + WorkspaceRuntimePerf.activeSink?.record(WorkspaceRuntimeDiagnosticEvent( + subsystem: "workspace", + name: name, + kind: .lifecycle, + correlationID: WorkspaceRuntimePerf.currentLifecycleCorrelation?.id, + fields: fields + )) + } + + package static func shortID(_ id: UUID) -> String { + String(id.uuidString.prefix(8)) + } + + package static func formatElapsedMS(since start: UInt64) -> String { + let now = DispatchTime.now().uptimeNanoseconds / 1_000_000 + return String(now >= start ? now - start : 0) + } +} + +package enum WorkspaceRootLoadDiagnosticFields { + package static func rootRecordCreatedFields(forPath _: String) -> [String: String] { + [:] + } + + package static func firstPreparedChunkFields(forPath _: String) -> [String: String] { + [:] + } +} diff --git a/Sources/RepoPromptCore/WorkspaceContext/WorkspaceRuntimeDependencies.swift b/Sources/RepoPromptCore/WorkspaceContext/WorkspaceRuntimeDependencies.swift new file mode 100644 index 000000000..30eb5bb17 --- /dev/null +++ b/Sources/RepoPromptCore/WorkspaceContext/WorkspaceRuntimeDependencies.swift @@ -0,0 +1,57 @@ +import Foundation + +package struct WorkspaceRuntimeConfiguration { + package let maxPendingWatcherEntries: Int + package let maxParallelScans: Int? + package let maxFoldersPerBatch: Int + package let agentSupportRoot: URL + package let globalIgnoreDefaults: String + + package init( + maxPendingWatcherEntries: Int = 50000, + maxParallelScans: Int? = nil, + maxFoldersPerBatch: Int = 256, + agentSupportRoot: URL, + globalIgnoreDefaults: String + ) { + self.maxPendingWatcherEntries = max(1, maxPendingWatcherEntries) + self.maxParallelScans = maxParallelScans.map { max(1, $0) } + self.maxFoldersPerBatch = max(1, maxFoldersPerBatch) + self.agentSupportRoot = agentSupportRoot.standardizedFileURL + self.globalIgnoreDefaults = globalIgnoreDefaults + } +} + +package struct WorkspaceRuntimeDependencies { + package let watcherFactory: any FileSystemWatcherCreating + package let directoryListingBackend: any WorkspaceDirectoryListingBackend + package let fileContentSnapshotReader: any FileContentSnapshotReading + package let mutationBackend: (any WorkspaceFileMutationBackend)? + package let partitionRoot: URL + package let partitionSaveEventSink: PartitionStoreSaveEventSink + package let codeMapCacheRoot: URL + package let configuration: WorkspaceRuntimeConfiguration + package let diagnostics: any WorkspaceRuntimeDiagnosticsSink + + package init( + watcherFactory: any FileSystemWatcherCreating, + directoryListingBackend: any WorkspaceDirectoryListingBackend, + fileContentSnapshotReader: any FileContentSnapshotReading, + mutationBackend: (any WorkspaceFileMutationBackend)?, + partitionRoot: URL, + partitionSaveEventSink: @escaping PartitionStoreSaveEventSink = { _ in }, + codeMapCacheRoot: URL, + configuration: WorkspaceRuntimeConfiguration, + diagnostics: any WorkspaceRuntimeDiagnosticsSink = NoopWorkspaceRuntimeDiagnosticsSink() + ) { + self.watcherFactory = watcherFactory + self.directoryListingBackend = directoryListingBackend + self.fileContentSnapshotReader = fileContentSnapshotReader + self.mutationBackend = mutationBackend + self.partitionRoot = partitionRoot.standardizedFileURL + self.partitionSaveEventSink = partitionSaveEventSink + self.codeMapCacheRoot = codeMapCacheRoot.standardizedFileURL + self.configuration = configuration + self.diagnostics = diagnostics + } +} diff --git a/Sources/RepoPromptCore/WorkspaceContext/WorkspaceRuntimeDiagnostics.swift b/Sources/RepoPromptCore/WorkspaceContext/WorkspaceRuntimeDiagnostics.swift new file mode 100644 index 000000000..81cdbd6fb --- /dev/null +++ b/Sources/RepoPromptCore/WorkspaceContext/WorkspaceRuntimeDiagnostics.swift @@ -0,0 +1,42 @@ +import Foundation + +package struct WorkspaceRuntimeDiagnosticEvent: Equatable { + package enum Kind: String { + case intervalBegan + case intervalEnded + case lifecycle + case counter + } + + package let subsystem: String + package let name: String + package let kind: Kind + package let correlationID: UUID? + package let intervalID: UUID? + package let fields: [String: String] + + package init( + subsystem: String, + name: String, + kind: Kind, + correlationID: UUID? = nil, + intervalID: UUID? = nil, + fields: [String: String] = [:] + ) { + self.subsystem = subsystem + self.name = name + self.kind = kind + self.correlationID = correlationID + self.intervalID = intervalID + self.fields = fields + } +} + +package protocol WorkspaceRuntimeDiagnosticsSink: Sendable { + func record(_ event: WorkspaceRuntimeDiagnosticEvent) +} + +package struct NoopWorkspaceRuntimeDiagnosticsSink: WorkspaceRuntimeDiagnosticsSink { + package init() {} + package func record(_: WorkspaceRuntimeDiagnosticEvent) {} +} diff --git a/Sources/RepoPromptCore/WorkspaceContext/WorkspaceRuntimePerf.swift b/Sources/RepoPromptCore/WorkspaceContext/WorkspaceRuntimePerf.swift new file mode 100644 index 000000000..25ad03534 --- /dev/null +++ b/Sources/RepoPromptCore/WorkspaceContext/WorkspaceRuntimePerf.swift @@ -0,0 +1,905 @@ +import Foundation + +private final class WorkspaceRuntimeProcessDiagnostics: @unchecked Sendable { + static let shared = WorkspaceRuntimeProcessDiagnostics() + + private let lock = NSLock() + private var sink: (any WorkspaceRuntimeDiagnosticsSink)? + + func install(_ sink: any WorkspaceRuntimeDiagnosticsSink) { + lock.lock() + self.sink = sink + lock.unlock() + } + + func current() -> (any WorkspaceRuntimeDiagnosticsSink)? { + lock.lock() + defer { lock.unlock() } + return sink + } +} + +/// Foundation-only diagnostic vocabulary for the shared runtime. The app may +/// install an injected sink around runtime entry points; Core owns no signposter. +package enum WorkspaceRuntimePerf { + package struct LifecycleCorrelation { + let id: UUID + init(id: UUID = UUID()) { + self.id = id + } + } + + @TaskLocal static var currentLifecycleCorrelation: LifecycleCorrelation? + @TaskLocal static var currentFileSystemPublicationCorrelation: LifecycleCorrelation? + @TaskLocal static var currentSink: (any WorkspaceRuntimeDiagnosticsSink)? + + package static var activeSink: (any WorkspaceRuntimeDiagnosticsSink)? { + currentSink ?? WorkspaceRuntimeProcessDiagnostics.shared.current() + } + + package static func installProcessSink(_ sink: any WorkspaceRuntimeDiagnosticsSink) { + WorkspaceRuntimeProcessDiagnostics.shared.install(sink) + } + + package static func withLifecycleCorrelation( + id: UUID?, + operation: () async throws -> T + ) async rethrows -> T { + guard let id else { return try await operation() } + return try await $currentLifecycleCorrelation.withValue(LifecycleCorrelation(id: id)) { + try await operation() + } + } + + package struct IntervalState { + let correlationID: UUID? + let intervalID: UUID + } + + package struct Dimensions { + var toolName: String? + var runPurpose: String? + var status: String? + var outcome: String? + var fileBytes: Int? + var lineCount: Int? + var diffLines: Int? + var editCount: Int? + var matchCount: Int? + var appliedCount: Int? + var chunkCount: Int? + var taskCount: Int? + var workerCount: Int? + var activeCount: Int? + var storeCapacity: Int? + var globalCapacity: Int? + var storeActiveCount: Int? + var globalActiveCount: Int? + var storeQueueDepth: Int? + var globalQueueDepth: Int? + var admittedFileCount: Int? + var scannedFileCount: Int? + var matchedFileCount: Int? + var contentMatchCount: Int? + var pathMatchCount: Int? + var errorCount: Int? + var isError: Bool? + var isForced: Bool? + var isAgentMode: Bool? + var includesToolCardDiff: Bool? + var limitHit: Bool? + var usesWorktreeProjection: Bool? + var searchMode: String? + var workloadClass: String? + var admissionClass: String? + var queueAgeBucket: String? + var contentSource: String? + var freshnessPolicy: String? + var scanKind: String? + var fileCount: Int? + var batchSize: Int? + var maxResults: Int? + var cacheHit: Bool? + var isRegex: Bool? + var countOnly: Bool? + var caseInsensitive: Bool? + var wholeWord: Bool? + var contextLines: Int? + var sourceItemCount: Int? + var sanitizedActivityCount: Int? + var retainedPayloadCount: Int? + var retainedPayloadBytes: Int? + var jsonParseAttemptCount: Int? + var jsonParseCacheHitCount: Int? + var jsonParseCacheMissCount: Int? + var jsonParseSuccessCount: Int? + var jsonParseFailureCount: Int? + var jsonParseByteCount: Int? + var toolExecutionCacheHitCount: Int? + var toolExecutionCacheMissCount: Int? + var bashMetadataCacheHitCount: Int? + var bashMetadataCacheMissCount: Int? + var regexCaptureCallCount: Int? + var inputBytes: Int? + var contentItemCount: Int? + var changeCount: Int? + var scopeCount: Int? + var warningCount: Int? + var fileAction: String? + var rootCount: Int? + var folderCount: Int? + var pendingRootCount: Int? + var pendingRawEventCount: Int? + var rootToken: String? + var queueDepth: Int? + var waiterCount: Int? + var ingressSequence: UInt64? + var barrierSequence: UInt64? + + init( + toolName: String? = nil, + runPurpose: String? = nil, + status: String? = nil, + outcome: String? = nil, + fileBytes: Int? = nil, + lineCount: Int? = nil, + diffLines: Int? = nil, + editCount: Int? = nil, + matchCount: Int? = nil, + appliedCount: Int? = nil, + chunkCount: Int? = nil, + taskCount: Int? = nil, + workerCount: Int? = nil, + activeCount: Int? = nil, + storeCapacity: Int? = nil, + globalCapacity: Int? = nil, + storeActiveCount: Int? = nil, + globalActiveCount: Int? = nil, + storeQueueDepth: Int? = nil, + globalQueueDepth: Int? = nil, + admittedFileCount: Int? = nil, + scannedFileCount: Int? = nil, + matchedFileCount: Int? = nil, + contentMatchCount: Int? = nil, + pathMatchCount: Int? = nil, + errorCount: Int? = nil, + isError: Bool? = nil, + isForced: Bool? = nil, + isAgentMode: Bool? = nil, + includesToolCardDiff: Bool? = nil, + limitHit: Bool? = nil, + usesWorktreeProjection: Bool? = nil, + searchMode: String? = nil, + workloadClass: String? = nil, + admissionClass: String? = nil, + queueAgeBucket: String? = nil, + contentSource: String? = nil, + freshnessPolicy: String? = nil, + scanKind: String? = nil, + fileCount: Int? = nil, + batchSize: Int? = nil, + maxResults: Int? = nil, + cacheHit: Bool? = nil, + isRegex: Bool? = nil, + countOnly: Bool? = nil, + caseInsensitive: Bool? = nil, + wholeWord: Bool? = nil, + contextLines: Int? = nil, + sourceItemCount: Int? = nil, + sanitizedActivityCount: Int? = nil, + retainedPayloadCount: Int? = nil, + retainedPayloadBytes: Int? = nil, + jsonParseAttemptCount: Int? = nil, + jsonParseCacheHitCount: Int? = nil, + jsonParseCacheMissCount: Int? = nil, + jsonParseSuccessCount: Int? = nil, + jsonParseFailureCount: Int? = nil, + jsonParseByteCount: Int? = nil, + toolExecutionCacheHitCount: Int? = nil, + toolExecutionCacheMissCount: Int? = nil, + bashMetadataCacheHitCount: Int? = nil, + bashMetadataCacheMissCount: Int? = nil, + regexCaptureCallCount: Int? = nil, + inputBytes: Int? = nil, + contentItemCount: Int? = nil, + changeCount: Int? = nil, + scopeCount: Int? = nil, + warningCount: Int? = nil, + fileAction: String? = nil, + rootCount: Int? = nil, + folderCount: Int? = nil, + pendingRootCount: Int? = nil, + pendingRawEventCount: Int? = nil, + rootToken: String? = nil, + queueDepth: Int? = nil, + waiterCount: Int? = nil, + ingressSequence: UInt64? = nil, + barrierSequence: UInt64? = nil + ) { + self.toolName = Self.sanitizedLabel(toolName) + self.runPurpose = Self.sanitizedLabel(runPurpose) + self.status = Self.sanitizedLabel(status) + self.outcome = Self.sanitizedLabel(outcome) + self.fileBytes = Self.nonNegative(fileBytes) + self.lineCount = Self.nonNegative(lineCount) + self.diffLines = Self.nonNegative(diffLines) + self.editCount = Self.nonNegative(editCount) + self.matchCount = Self.nonNegative(matchCount) + self.appliedCount = Self.nonNegative(appliedCount) + self.chunkCount = Self.nonNegative(chunkCount) + self.taskCount = Self.nonNegative(taskCount) + self.workerCount = Self.nonNegative(workerCount) + self.activeCount = Self.nonNegative(activeCount) + self.storeCapacity = Self.nonNegative(storeCapacity) + self.globalCapacity = Self.nonNegative(globalCapacity) + self.storeActiveCount = Self.nonNegative(storeActiveCount) + self.globalActiveCount = Self.nonNegative(globalActiveCount) + self.storeQueueDepth = Self.nonNegative(storeQueueDepth) + self.globalQueueDepth = Self.nonNegative(globalQueueDepth) + self.admittedFileCount = Self.nonNegative(admittedFileCount) + self.scannedFileCount = Self.nonNegative(scannedFileCount) + self.matchedFileCount = Self.nonNegative(matchedFileCount) + self.contentMatchCount = Self.nonNegative(contentMatchCount) + self.pathMatchCount = Self.nonNegative(pathMatchCount) + self.errorCount = Self.nonNegative(errorCount) + self.isError = isError + self.isForced = isForced + self.isAgentMode = isAgentMode + self.includesToolCardDiff = includesToolCardDiff + self.limitHit = limitHit + self.usesWorktreeProjection = usesWorktreeProjection + self.searchMode = Self.sanitizedLabel(searchMode) + self.workloadClass = Self.sanitizedLabel(workloadClass) + self.admissionClass = Self.sanitizedLabel(admissionClass) + self.queueAgeBucket = Self.sanitizedLabel(queueAgeBucket) + self.contentSource = Self.sanitizedLabel(contentSource) + self.freshnessPolicy = Self.sanitizedLabel(freshnessPolicy) + self.scanKind = Self.sanitizedLabel(scanKind) + self.fileCount = Self.nonNegative(fileCount) + self.batchSize = Self.nonNegative(batchSize) + self.maxResults = Self.nonNegative(maxResults) + self.cacheHit = cacheHit + self.isRegex = isRegex + self.countOnly = countOnly + self.caseInsensitive = caseInsensitive + self.wholeWord = wholeWord + self.contextLines = Self.nonNegative(contextLines) + self.sourceItemCount = Self.nonNegative(sourceItemCount) + self.sanitizedActivityCount = Self.nonNegative(sanitizedActivityCount) + self.retainedPayloadCount = Self.nonNegative(retainedPayloadCount) + self.retainedPayloadBytes = Self.nonNegative(retainedPayloadBytes) + self.jsonParseAttemptCount = Self.nonNegative(jsonParseAttemptCount) + self.jsonParseCacheHitCount = Self.nonNegative(jsonParseCacheHitCount) + self.jsonParseCacheMissCount = Self.nonNegative(jsonParseCacheMissCount) + self.jsonParseSuccessCount = Self.nonNegative(jsonParseSuccessCount) + self.jsonParseFailureCount = Self.nonNegative(jsonParseFailureCount) + self.jsonParseByteCount = Self.nonNegative(jsonParseByteCount) + self.toolExecutionCacheHitCount = Self.nonNegative(toolExecutionCacheHitCount) + self.toolExecutionCacheMissCount = Self.nonNegative(toolExecutionCacheMissCount) + self.bashMetadataCacheHitCount = Self.nonNegative(bashMetadataCacheHitCount) + self.bashMetadataCacheMissCount = Self.nonNegative(bashMetadataCacheMissCount) + self.regexCaptureCallCount = Self.nonNegative(regexCaptureCallCount) + self.inputBytes = Self.nonNegative(inputBytes) + self.contentItemCount = Self.nonNegative(contentItemCount) + self.changeCount = Self.nonNegative(changeCount) + self.scopeCount = Self.nonNegative(scopeCount) + self.warningCount = Self.nonNegative(warningCount) + self.fileAction = Self.sanitizedLabel(fileAction) + self.rootCount = Self.nonNegative(rootCount) + self.folderCount = Self.nonNegative(folderCount) + self.pendingRootCount = Self.nonNegative(pendingRootCount) + self.pendingRawEventCount = Self.nonNegative(pendingRawEventCount) + self.rootToken = Self.sanitizedLabel(rootToken) + self.queueDepth = Self.nonNegative(queueDepth) + self.waiterCount = Self.nonNegative(waiterCount) + self.ingressSequence = ingressSequence + self.barrierSequence = barrierSequence + } + + fileprivate var logDescription: String { + var parts: [String] = [] + append("tool", toolName, to: &parts) + append("purpose", runPurpose, to: &parts) + append("status", status, to: &parts) + append("outcome", outcome, to: &parts) + append("fileBytes", fileBytes, to: &parts) + append("lineCount", lineCount, to: &parts) + append("diffLines", diffLines, to: &parts) + append("editCount", editCount, to: &parts) + append("matchCount", matchCount, to: &parts) + append("appliedCount", appliedCount, to: &parts) + append("chunkCount", chunkCount, to: &parts) + append("taskCount", taskCount, to: &parts) + append("workerCount", workerCount, to: &parts) + append("activeCount", activeCount, to: &parts) + append("storeCapacity", storeCapacity, to: &parts) + append("globalCapacity", globalCapacity, to: &parts) + append("storeActiveCount", storeActiveCount, to: &parts) + append("globalActiveCount", globalActiveCount, to: &parts) + append("storeQueueDepth", storeQueueDepth, to: &parts) + append("globalQueueDepth", globalQueueDepth, to: &parts) + append("admittedFileCount", admittedFileCount, to: &parts) + append("scannedFileCount", scannedFileCount, to: &parts) + append("matchedFileCount", matchedFileCount, to: &parts) + append("contentMatchCount", contentMatchCount, to: &parts) + append("pathMatchCount", pathMatchCount, to: &parts) + append("errorCount", errorCount, to: &parts) + append("isError", isError, to: &parts) + append("isForced", isForced, to: &parts) + append("isAgentMode", isAgentMode, to: &parts) + append("includesToolCardDiff", includesToolCardDiff, to: &parts) + append("limitHit", limitHit, to: &parts) + append("usesWorktreeProjection", usesWorktreeProjection, to: &parts) + append("searchMode", searchMode, to: &parts) + append("workloadClass", workloadClass, to: &parts) + append("admissionClass", admissionClass, to: &parts) + append("queueAgeBucket", queueAgeBucket, to: &parts) + append("contentSource", contentSource, to: &parts) + append("freshnessPolicy", freshnessPolicy, to: &parts) + append("scanKind", scanKind, to: &parts) + append("fileCount", fileCount, to: &parts) + append("batchSize", batchSize, to: &parts) + append("maxResults", maxResults, to: &parts) + append("cacheHit", cacheHit, to: &parts) + append("isRegex", isRegex, to: &parts) + append("countOnly", countOnly, to: &parts) + append("caseInsensitive", caseInsensitive, to: &parts) + append("wholeWord", wholeWord, to: &parts) + append("contextLines", contextLines, to: &parts) + append("sourceItemCount", sourceItemCount, to: &parts) + append("sanitizedActivityCount", sanitizedActivityCount, to: &parts) + append("retainedPayloadCount", retainedPayloadCount, to: &parts) + append("retainedPayloadBytes", retainedPayloadBytes, to: &parts) + append("jsonParseAttemptCount", jsonParseAttemptCount, to: &parts) + append("jsonParseCacheHitCount", jsonParseCacheHitCount, to: &parts) + append("jsonParseCacheMissCount", jsonParseCacheMissCount, to: &parts) + append("jsonParseSuccessCount", jsonParseSuccessCount, to: &parts) + append("jsonParseFailureCount", jsonParseFailureCount, to: &parts) + append("jsonParseByteCount", jsonParseByteCount, to: &parts) + append("toolExecutionCacheHitCount", toolExecutionCacheHitCount, to: &parts) + append("toolExecutionCacheMissCount", toolExecutionCacheMissCount, to: &parts) + append("bashMetadataCacheHitCount", bashMetadataCacheHitCount, to: &parts) + append("bashMetadataCacheMissCount", bashMetadataCacheMissCount, to: &parts) + append("regexCaptureCallCount", regexCaptureCallCount, to: &parts) + append("inputBytes", inputBytes, to: &parts) + append("contentItemCount", contentItemCount, to: &parts) + append("changeCount", changeCount, to: &parts) + append("scopeCount", scopeCount, to: &parts) + append("warningCount", warningCount, to: &parts) + append("fileAction", fileAction, to: &parts) + append("rootCount", rootCount, to: &parts) + append("folderCount", folderCount, to: &parts) + append("pendingRootCount", pendingRootCount, to: &parts) + append("pendingRawEventCount", pendingRawEventCount, to: &parts) + append("rootToken", rootToken, to: &parts) + append("queueDepth", queueDepth, to: &parts) + append("waiterCount", waiterCount, to: &parts) + append("ingressSequence", ingressSequence, to: &parts) + append("barrierSequence", barrierSequence, to: &parts) + return parts.joined(separator: " ") + } + + fileprivate var isEmpty: Bool { + logDescription.isEmpty + } + + private static func nonNegative(_ value: Int?) -> Int? { + value.map { max(0, $0) } + } + + private static func sanitizedLabel(_ value: String?) -> String? { + guard let value else { return nil } + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return nil } + let allowed = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: "._-")) + let replacement = UnicodeScalar("_") + let scalars = trimmed.unicodeScalars.map { scalar in + allowed.contains(scalar) ? scalar : replacement + } + return String(String.UnicodeScalarView(scalars.prefix(64))) + } + + private func append(_ key: String, _ value: String?, to parts: inout [String]) { + guard let value else { return } + parts.append("\(key)=\(value)") + } + + private func append(_ key: String, _ value: Int?, to parts: inout [String]) { + guard let value else { return } + parts.append("\(key)=\(value)") + } + + private func append(_ key: String, _ value: UInt64?, to parts: inout [String]) { + guard let value else { return } + parts.append("\(key)=\(value)") + } + + private func append(_ key: String, _ value: Bool?, to parts: inout [String]) { + guard let value else { return } + parts.append("\(key)=\(value ? "true" : "false")") + } + } + + package enum Stage { + enum MCPToolCall { + static let total: StaticString = "EditFlow.MCPToolCall.Total" + static let normalizeArgs: StaticString = "EditFlow.MCPToolCall.NormalizeArgs" + static let logicalContextResolution: StaticString = "EditFlow.MCPToolCall.LogicalContextResolution" + static let policyGating: StaticString = "EditFlow.MCPToolCall.PolicyGating" + static let effectivePolicySnapshot: StaticString = "EditFlow.MCPToolCall.EffectivePolicySnapshot" + static let routingSnapshot: StaticString = "EditFlow.MCPToolCall.RoutingSnapshot" + static let preLimiterEnvelope: StaticString = "EditFlow.MCPToolCall.PreLimiterEnvelope" + static let limiterResolution: StaticString = "EditFlow.MCPToolCall.LimiterResolution" + static let limiterEnvelope: StaticString = "EditFlow.MCPToolCall.LimiterEnvelope" + static let limiterWait: StaticString = "EditFlow.MCPToolCall.LimiterWait" + static let permitBodyEnvelope: StaticString = "EditFlow.MCPToolCall.PermitBodyEnvelope" + static let permitPreDispatchEnvelope: StaticString = "EditFlow.MCPToolCall.PermitPreDispatchEnvelope" + static let enabledStateSnapshot: StaticString = "EditFlow.MCPToolCall.EnabledStateSnapshot" + static let windowRunResolution: StaticString = "EditFlow.MCPToolCall.WindowRunResolution" + static let observerCallbacks: StaticString = "EditFlow.MCPToolCall.ObserverCallbacks" + static let ownershipPurposeResolution: StaticString = "EditFlow.MCPToolCall.OwnershipPurposeResolution" + static let toolCallRecording: StaticString = "EditFlow.MCPToolCall.ToolCallRecording" + static let runScopedTabRebindFallback: StaticString = "EditFlow.MCPToolCall.RunScopedTabRebindFallback" + static let legacyTabBindingCompatibility: StaticString = "EditFlow.MCPToolCall.LegacyTabBindingCompatibility" + static let serviceToolLookup: StaticString = "EditFlow.MCPToolCall.ServiceToolLookup" + static let serviceToolLookupServiceToolsAwait: StaticString = "EditFlow.MCPToolCall.ServiceToolLookup.ServiceToolsAwait" + static let serviceToolLookupToolDefinitionScan: StaticString = "EditFlow.MCPToolCall.ServiceToolLookup.ToolDefinitionScan" + static let serviceToolLookupPublicWindowIDInjection: StaticString = "EditFlow.MCPToolCall.ServiceToolLookup.PublicWindowIDInjection" + static let serviceToolLookupAppSettingsToolsBuild: StaticString = "EditFlow.MCPToolCall.ServiceToolLookup.AppSettingsToolsBuild" + static let serviceToolLookupWindowRoutingToolsCacheActorBody: StaticString = "EditFlow.MCPToolCall.ServiceToolLookup.WindowRoutingToolsCacheActorBody" + static let serviceToolLookupWindowCatalogToolsActorBodyTotal: StaticString = "EditFlow.MCPToolCall.ServiceToolLookup.WindowCatalogToolsActorBodyTotal" + static let serviceToolLookupWindowCatalogToolsMaterialization: StaticString = "EditFlow.MCPToolCall.ServiceToolLookup.WindowCatalogToolsMaterialization" + static let dispatch: StaticString = "EditFlow.MCPToolCall.Dispatch" + static let resolvedProviderDispatch: StaticString = "EditFlow.MCPToolCall.ResolvedProviderDispatch" + static let handlerResultHandoff: StaticString = "EditFlow.MCPToolCall.HandlerResultHandoff" + static let permitPostDispatchEnvelope: StaticString = "EditFlow.MCPToolCall.PermitPostDispatchEnvelope" + static let completionObservers: StaticString = "EditFlow.MCPToolCall.CompletionObservers" + static let completionObserverResultEncoding: StaticString = "EditFlow.MCPToolCall.CompletionObserverResultEncoding" + static let completionObserverCallbacks: StaticString = "EditFlow.MCPToolCall.CompletionObserverCallbacks" + static let preToolFilesystemFlush: StaticString = "EditFlow.MCPToolCall.PreToolFilesystemFlush" + static let runToolSetup: StaticString = "EditFlow.MCPToolCall.RunToolSetup" + static let runToolRegistration: StaticString = "EditFlow.MCPToolCall.RunToolRegistration" + static let providerExecution: StaticString = "EditFlow.MCPToolCall.ProviderExecution" + static let runToolTimeoutEnvelope: StaticString = "EditFlow.MCPToolCall.RunToolTimeoutEnvelope" + static let runToolCompletionCleanup: StaticString = "EditFlow.MCPToolCall.RunToolCompletionCleanup" + static let formatResult: StaticString = "EditFlow.MCPToolCall.FormatResult" + } + + enum MCPWindowToolCatalog { + static let construction: StaticString = "EditFlow.MCPWindowToolCatalog.Construction" + static let invalidateToolsCache: StaticString = "EditFlow.MCPWindowToolCatalog.InvalidateToolsCache" + static let invalidationToolSummariesChange: StaticString = "EditFlow.MCPWindowToolCatalog.Invalidation.ToolSummariesChange" + static let invalidationToolRegistrationUpdate: StaticString = "EditFlow.MCPWindowToolCatalog.Invalidation.ToolRegistrationUpdate" + static let registrationUpdateWindowToolsEnabledDidSet: StaticString = "EditFlow.MCPWindowToolCatalog.RegistrationUpdate.WindowToolsEnabledDidSet" + static let registrationUpdateAgentBootstrap: StaticString = "EditFlow.MCPWindowToolCatalog.RegistrationUpdate.AgentBootstrap" + static let readinessWarmAccess: StaticString = "EditFlow.MCPWindowToolCatalog.ReadinessWarmAccess" + static let serviceRegistryToolsPublication: StaticString = "EditFlow.MCPWindowToolCatalog.ServiceRegistryToolsPublication" + static let codexTurnMCPServerEnable: StaticString = "EditFlow.MCPWindowToolCatalog.CodexTurnMCPServerEnable" + } + + enum ApplyEdits { + static let serviceRun: StaticString = "EditFlow.ApplyEdits.ServiceRun" + static let servicePreview: StaticString = "EditFlow.ApplyEdits.ServicePreview" + static let requestBuild: StaticString = "EditFlow.ApplyEdits.RequestBuild" + static let hostRead: StaticString = "EditFlow.ApplyEdits.HostRead" + static let hostWrite: StaticString = "EditFlow.ApplyEdits.HostWrite" + static let engineApply: StaticString = "EditFlow.ApplyEdits.EngineApply" + static let diffGeneration: StaticString = "EditFlow.ApplyEdits.DiffGeneration" + static let patchApply: StaticString = "EditFlow.ApplyEdits.PatchApply" + static let toolCardDiff: StaticString = "EditFlow.ApplyEdits.ToolCardDiff" + static let format: StaticString = "EditFlow.ApplyEdits.Format" + static let formatDecode: StaticString = "EditFlow.ApplyEdits.FormatDecode" + static let formatMarkdown: StaticString = "EditFlow.ApplyEdits.FormatMarkdown" + static let formatResource: StaticString = "EditFlow.ApplyEdits.FormatResource" + static let approvalWait: StaticString = "EditFlow.ApplyEdits.ApprovalWait" + static let flushDeltas: StaticString = "EditFlow.ApplyEdits.FlushDeltas" + } + + enum Search { + static let broadAdmissionWait: StaticString = "EditFlow.Search.BroadAdmissionWait" + static let broadAdmissionLeaseHold: StaticString = "EditFlow.Search.BroadAdmissionLeaseHold" + static let contentFetchAdmissionWait: StaticString = "EditFlow.Search.ContentFetchAdmissionWait" + static let contentFetchLeaseHold: StaticString = "EditFlow.Search.ContentFetchLeaseHold" + static let ingressFreshnessWait: StaticString = "EditFlow.Search.IngressFreshnessWait" + static let contentFreshnessValidation: StaticString = "EditFlow.Search.ContentFreshnessValidation" + static let contentFreshnessValidationStoreActorBody: StaticString = "EditFlow.Search.ContentFreshnessValidation.StoreActorBody" + static let contentFreshnessValidationRootActorBody: StaticString = "EditFlow.Search.ContentFreshnessValidation.RootActorBody" + static let contentScanTotal: StaticString = "EditFlow.Search.ContentScanTotal" + static let resultConstruction: StaticString = "EditFlow.Search.ResultConstruction" + static let entrypoint: StaticString = "EditFlow.Search.Entrypoint" + static let scopeFiltering: StaticString = "EditFlow.Search.ScopeFiltering" + static let actorSearchCall: StaticString = "EditFlow.Search.ActorSearchCall" + static let actorSearchUnified: StaticString = "EditFlow.Search.ActorSearchUnified" + static let contentBatch: StaticString = "EditFlow.Search.ContentBatch" + static let pathBatch: StaticString = "EditFlow.Search.PathBatch" + static let fileContentFetch: StaticString = "EditFlow.Search.FileContentFetch" + static let lineIndexCacheKey: StaticString = "EditFlow.Search.LineIndexCacheKey" + static let lineIndexLookup: StaticString = "EditFlow.Search.LineIndexLookup" + static let lineIndexBuild: StaticString = "EditFlow.Search.LineIndexBuild" + static let countOnlyFastPath: StaticString = "EditFlow.Search.CountOnlyFastPath" + static let regexFullBufferScan: StaticString = "EditFlow.Search.RegexFullBufferScan" + static let regexLineByLineScan: StaticString = "EditFlow.Search.RegexLineByLineScan" + static let literalScan: StaticString = "EditFlow.Search.LiteralScan" + static let materializeMatches: StaticString = "EditFlow.Search.MaterializeMatches" + static let catalogSnapshot: StaticString = "EditFlow.Search.CatalogSnapshot" + static let dtoBuild: StaticString = "EditFlow.Search.DTOBuild" + static let dtoRootRefSnapshotLookup: StaticString = "EditFlow.Search.DTOBuild.RootRefSnapshotLookup" + static let dtoDisplayResolverPreparation: StaticString = "EditFlow.Search.DTOBuild.DisplayResolverPreparation" + static let dtoPathDisplayProjection: StaticString = "EditFlow.Search.DTOBuild.PathDisplayProjection" + static let dtoCapAccounting: StaticString = "EditFlow.Search.DTOBuild.CapAccounting" + static let dtoAssembly: StaticString = "EditFlow.Search.DTOBuild.Assembly" + static let providerTotal: StaticString = "EditFlow.Search.ProviderTotal" + static let providerWorkspaceSearchAwait: StaticString = "EditFlow.Search.ProviderWorkspaceSearchAwait" + static let providerAutoSelection: StaticString = "EditFlow.Search.ProviderAutoSelection" + static let providerValueEncoding: StaticString = "EditFlow.Search.ProviderValueEncoding" + + enum AutoSelect { + static let shapeEligibility: StaticString = "EditFlow.Search.AutoSelect.ShapeEligibility" + static let agentEligibility: StaticString = "EditFlow.Search.AutoSelect.AgentEligibility" + static let mutation: StaticString = "EditFlow.Search.AutoSelect.Mutation" + } + } + + enum ReadFile { + static let providerTotal: StaticString = "EditFlow.ReadFile.ProviderTotal" + static let providerArgumentParsing: StaticString = "EditFlow.ReadFile.ProviderArgumentParsing" + static let providerRequestMetadata: StaticString = "EditFlow.ReadFile.ProviderRequestMetadata" + static let providerLookupContextResolution: StaticString = "EditFlow.ReadFile.ProviderLookupContextResolution" + static let providerPathTranslation: StaticString = "EditFlow.ReadFile.ProviderPathTranslation" + static let providerReadEnvelope: StaticString = "EditFlow.ReadFile.ProviderReadEnvelope" + static let providerReplyProjection: StaticString = "EditFlow.ReadFile.ProviderReplyProjection" + static let providerAutoSelect: StaticString = "EditFlow.ReadFile.ProviderAutoSelect" + static let providerValueEncoding: StaticString = "EditFlow.ReadFile.ProviderValueEncoding" + static let explicitIngressFreshnessWait: StaticString = "EditFlow.ReadFile.ExplicitIngressFreshnessWait" + static let exactCatalogShortcut: StaticString = "EditFlow.ReadFile.ExactCatalogShortcut" + static let storeReadContentForwardAwait: StaticString = "EditFlow.ReadFile.StoreReadContentForwardAwait" + static let folderResolutionGeneralLookupFallback: StaticString = "EditFlow.ReadFile.FolderResolutionGeneralLookupFallback" + static let pathLookupStaticSnapshotBuild: StaticString = "EditFlow.ReadFile.PathLookupStaticSnapshotBuild" + static let resolveReadableFile: StaticString = "EditFlow.ReadFile.ResolveReadableFile" + static let exactPathIssueDetection: StaticString = "EditFlow.ReadFile.ExactPathIssueDetection" + static let rootRefsLookup: StaticString = "EditFlow.ReadFile.RootRefsLookup" + static let folderResolution: StaticString = "EditFlow.ReadFile.FolderResolution" + static let externalFolderGuard: StaticString = "EditFlow.ReadFile.ExternalFolderGuard" + static let readableServiceResolution: StaticString = "EditFlow.ReadFile.ReadableServiceResolution" + static let exactCatalogLookupAwait: StaticString = "EditFlow.ReadFile.ExactCatalogLookupAwait" + static let exactCatalogLookupActorBody: StaticString = "EditFlow.ReadFile.ExactCatalogLookupActorBody" + static let explicitMaterialization: StaticString = "EditFlow.ReadFile.ExplicitMaterialization" + static let generalLookupFallback: StaticString = "EditFlow.ReadFile.GeneralLookupFallback" + static let externalFileFallback: StaticString = "EditFlow.ReadFile.ExternalFileFallback" + static let workspaceContentLoad: StaticString = "EditFlow.ReadFile.WorkspaceContentLoad" + static let splitPreservingLineEndings: StaticString = "EditFlow.ReadFile.SplitPreservingLineEndings" + static let buildSlice: StaticString = "EditFlow.ReadFile.BuildSlice" + + enum AutoSelect { + static let total: StaticString = "EditFlow.ReadFile.AutoSelect.Total" + static let eligibilityResolution: StaticString = "EditFlow.ReadFile.AutoSelect.EligibilityResolution" + static let selectionProjection: StaticString = "EditFlow.ReadFile.AutoSelect.SelectionProjection" + static let fullFlowTotal: StaticString = "EditFlow.ReadFile.AutoSelect.FullFlowTotal" + static let fullRequestMetadata: StaticString = "EditFlow.ReadFile.AutoSelect.FullRequestMetadata" + static let fullLookupContext: StaticString = "EditFlow.ReadFile.AutoSelect.FullLookupContext" + static let fullSnapshotResolution: StaticString = "EditFlow.ReadFile.AutoSelect.FullSnapshotResolution" + static let structuralAddTotal: StaticString = "EditFlow.ReadFile.AutoSelect.StructuralAddTotal" + static let candidateResolutionTotal: StaticString = "EditFlow.ReadFile.AutoSelect.CandidateResolutionTotal" + static let structuralMerge: StaticString = "EditFlow.ReadFile.AutoSelect.StructuralMerge" + static let autoCodemapRecomputeTotal: StaticString = "EditFlow.ReadFile.AutoSelect.AutoCodemapRecomputeTotal" + static let selectedFileLookup: StaticString = "EditFlow.ReadFile.AutoSelect.SelectedFileLookup" + static let codemapAPILoad: StaticString = "EditFlow.ReadFile.AutoSelect.CodemapAPILoad" + + enum AllCodemapFileAPIs { + static let actorBodyTotal: StaticString = "EditFlow.ReadFile.AutoSelect.AllCodemapFileAPIs.ActorBodyTotal" + static let stateSnapshot: StaticString = "EditFlow.ReadFile.AutoSelect.AllCodemapFileAPIs.StateSnapshot" + static let materialization: StaticString = "EditFlow.ReadFile.AutoSelect.AllCodemapFileAPIs.Materialization" + } + + static let referencedPathResolution: StaticString = "EditFlow.ReadFile.AutoSelect.ReferencedPathResolution" + static let acceptedFileAPIFilter: StaticString = "EditFlow.ReadFile.AutoSelect.AcceptedFileAPIFilter" + + enum AcceptedFileAPIFilter { + static let pathGrouping: StaticString = "EditFlow.ReadFile.AutoSelect.AcceptedFileAPIFilter.PathGrouping" + static let selectedRecordProjection: StaticString = "EditFlow.ReadFile.AutoSelect.AcceptedFileAPIFilter.SelectedRecordProjection" + } + + static let autoReferencedAPIComputation: StaticString = "EditFlow.ReadFile.AutoSelect.AutoReferencedAPIComputation" + static let fullSliceClearing: StaticString = "EditFlow.ReadFile.AutoSelect.FullSliceClearing" + static let finalSelectionEquality: StaticString = "EditFlow.ReadFile.AutoSelect.FinalSelectionEquality" + static let persistence: StaticString = "EditFlow.ReadFile.AutoSelect.Persistence" + static let responseEnqueue: StaticString = "EditFlow.ReadFile.AutoSelect.ResponseEnqueue" + static let canonicalQueueWait: StaticString = "EditFlow.ReadFile.AutoSelect.CanonicalQueueWait" + static let canonicalMutation: StaticString = "EditFlow.ReadFile.AutoSelect.CanonicalMutation" + static let canonicalStoredCommit: StaticString = "EditFlow.ReadFile.AutoSelect.CanonicalStoredCommit" + static let mirrorEnqueue: StaticString = "EditFlow.ReadFile.AutoSelect.MirrorEnqueue" + static let mirrorQueueWait: StaticString = "EditFlow.ReadFile.AutoSelect.MirrorQueueWait" + static let mirrorApply: StaticString = "EditFlow.ReadFile.AutoSelect.MirrorApply" + static let drainWait: StaticString = "EditFlow.ReadFile.AutoSelect.DrainWait" + static let sliceFlowTotal: StaticString = "EditFlow.ReadFile.AutoSelect.SliceFlowTotal" + } + } + + enum FileSystem { + static let contentLoadTotal: StaticString = "EditFlow.FileSystem.ContentLoadTotal" + static let contentLoadActorBody: StaticString = "EditFlow.FileSystem.ContentLoadActorBody" + static let contentReadRequestPreparation: StaticString = "EditFlow.FileSystem.ContentReadRequestPreparation" + static let contentReadOffActorAwait: StaticString = "EditFlow.FileSystem.ContentReadOffActorAwait" + static let contentModificationDateLookup: StaticString = "EditFlow.FileSystem.ContentModificationDateLookup" + static let contentReadWorkerPermitWait: StaticString = "EditFlow.FileSystem.ContentReadWorkerPermitWait" + static let contentReadWorkerBody: StaticString = "EditFlow.FileSystem.ContentReadWorkerBody" + } + + enum Bootstrap { + static let handshakeIOQueueEnvelope: StaticString = "EditFlow.Bootstrap.HandshakeIOQueueEnvelope" + static let handshakeIOBlockingRead: StaticString = "EditFlow.Bootstrap.HandshakeIOBlockingRead" + static let admission: StaticString = "EditFlow.Bootstrap.Admission" + static let postAcceptStartup: StaticString = "EditFlow.Bootstrap.PostAcceptStartup" + } + + enum WorkspaceDurability { + static let flushWait: StaticString = "EditFlow.WorkspaceDurability.FlushWait" + static let atomicWrite: StaticString = "EditFlow.WorkspaceDurability.AtomicWrite" + } + + enum Transcript { + static let scheduleRefresh: StaticString = "EditFlow.Transcript.ScheduleRefresh" + static let refreshTotal: StaticString = "EditFlow.Transcript.RefreshTotal" + static let importTranscript: StaticString = "EditFlow.Transcript.ImportTranscript" + static let incrementalImport: StaticString = "EditFlow.Transcript.IncrementalImport" + static let payloadMap: StaticString = "EditFlow.Transcript.PayloadMap" + static let sanitize: StaticString = "EditFlow.Transcript.Sanitize" + static let projectionBuild: StaticString = "EditFlow.Transcript.ProjectionBuild" + static let publish: StaticString = "EditFlow.Transcript.Publish" + static let toolProcessing: StaticString = "EditFlow.Transcript.ToolProcessing" + } + + enum Parser { + static let chatContentParse: StaticString = "EditFlow.Parser.ChatContentParse" + static let diffParseChanges: StaticString = "EditFlow.Parser.DiffParseChanges" + static let diffRegexCacheLookup: StaticString = "EditFlow.Parser.DiffRegexCacheLookup" + } + + enum Finalization { + static let watchdogArm: StaticString = "EditFlow.Finalization.WatchdogArm" + static let watchdogSkip: StaticString = "EditFlow.Finalization.WatchdogSkip" + static let watchdogCancel: StaticString = "EditFlow.Finalization.WatchdogCancel" + static let watchdogComplete: StaticString = "EditFlow.Finalization.WatchdogComplete" + } + + enum UnifiedDiff { + static let parseForRender: StaticString = "EditFlow.UnifiedDiff.ParseForRender" + static let attributedBuild: StaticString = "EditFlow.UnifiedDiff.AttributedBuild" + } + + enum Git { + static let hunkParsing: StaticString = "EditFlow.Git.HunkParsing" + } + } + + package enum Lifecycle { + enum MCPToolCall { + static let received: StaticString = "MCP.ToolCall.Received" + static let routingSnapshotCompleted: StaticString = "MCP.ToolCall.RoutingSnapshotCompleted" + static let limiterWaitBegan: StaticString = "MCP.ToolCall.LimiterWaitBegan" + static let limiterAcquired: StaticString = "MCP.ToolCall.LimiterAcquired" + static let completionObserverReturned: StaticString = "MCP.ToolCall.CompletionObserverReturned" + static let formatResultReturned: StaticString = "MCP.ToolCall.FormatResultReturned" + static let resolvedProviderBegan: StaticString = "MCP.ToolCall.ResolvedProviderBegan" + static let resolvedProviderEnded: StaticString = "MCP.ToolCall.ResolvedProviderEnded" + static let handlerResultReady: StaticString = "MCP.ToolCall.HandlerResultReady" + } + + enum MCPRunTool { + static let preflushBegan: StaticString = "MCP.RunTool.PreflushBegan" + static let preflushEnded: StaticString = "MCP.RunTool.PreflushEnded" + static let registrationScheduled: StaticString = "MCP.RunTool.RegistrationScheduled" + static let registrationMainActorEntered: StaticString = "MCP.RunTool.RegistrationMainActorEntered" + static let registrationEnded: StaticString = "MCP.RunTool.RegistrationEnded" + static let providerBegan: StaticString = "MCP.RunTool.ProviderBegan" + static let providerEnded: StaticString = "MCP.RunTool.ProviderEnded" + static let cleanupScheduled: StaticString = "MCP.RunTool.CleanupScheduled" + static let cleanupMainActorEntered: StaticString = "MCP.RunTool.CleanupMainActorEntered" + static let unregister: StaticString = "MCP.RunTool.Unregister" + static let idleWaitersResumed: StaticString = "MCP.RunTool.IdleWaitersResumed" + static let cleanupEnded: StaticString = "MCP.RunTool.CleanupEnded" + static let returned: StaticString = "MCP.RunTool.Return" + } + + enum FileSystem { + static let callbackAccepted: StaticString = "FileSystem.CallbackAccepted" + static let serviceEnqueueEntered: StaticString = "FileSystem.ServiceEnqueueEntered" + static let servicePublish: StaticString = "FileSystem.ServicePublish" + static let contentLoadEntered: StaticString = "FileSystem.ContentLoadEntered" + static let contentReadRequestPrepared: StaticString = "FileSystem.ContentReadRequestPrepared" + static let contentReadOffActorScheduled: StaticString = "FileSystem.ContentReadOffActorScheduled" + static let contentReadWorkerReturned: StaticString = "FileSystem.ContentReadWorkerReturned" + static let contentLoadReturned: StaticString = "FileSystem.ContentLoadReturned" + static let contentReadWorkerPermitWaitBegan: StaticString = "FileSystem.ContentReadWorkerPermitWaitBegan" + static let contentReadWorkerOverloaded: StaticString = "FileSystem.ContentReadWorkerOverloaded" + static let contentReadWorkerPermitAcquired: StaticString = "FileSystem.ContentReadWorkerPermitAcquired" + static let contentReadWorkerPermitCancelled: StaticString = "FileSystem.ContentReadWorkerPermitCancelled" + } + + enum Search { + static let contentFreshnessStoreEntered: StaticString = "Search.ContentFreshnessStoreEntered" + static let contentFreshnessStoreReturned: StaticString = "Search.ContentFreshnessStoreReturned" + static let contentFreshnessRootEntered: StaticString = "Search.ContentFreshnessRootEntered" + static let contentFreshnessRootReturned: StaticString = "Search.ContentFreshnessRootReturned" + static let broadAdmissionWaitBegan: StaticString = "Search.BroadAdmissionWaitBegan" + static let broadAdmissionPermitAcquired: StaticString = "Search.BroadAdmissionPermitAcquired" + static let broadAdmissionPermitCancelled: StaticString = "Search.BroadAdmissionPermitCancelled" + static let broadAdmissionPermitReleased: StaticString = "Search.BroadAdmissionPermitReleased" + static let broadAdmissionOverloaded: StaticString = "Search.BroadAdmissionOverloaded" + static let broadAdmissionWaitExpired: StaticString = "Search.BroadAdmissionWaitExpired" + static let contentFetchWaitBegan: StaticString = "Search.ContentFetchWaitBegan" + static let contentFetchPermitAcquired: StaticString = "Search.ContentFetchPermitAcquired" + static let contentFetchPermitCancelled: StaticString = "Search.ContentFetchPermitCancelled" + static let contentFetchPermitReleased: StaticString = "Search.ContentFetchPermitReleased" + static let contentFetchOverloaded: StaticString = "Search.ContentFetchOverloaded" + static let contentFetchWaitExpired: StaticString = "Search.ContentFetchWaitExpired" + static let providerEntered: StaticString = "Search.ProviderEntered" + static let providerWorkspaceSearchReturned: StaticString = "Search.ProviderWorkspaceSearchReturned" + static let providerDTOReady: StaticString = "Search.ProviderDTOReady" + static let providerAutoSelectionReturned: StaticString = "Search.ProviderAutoSelectionReturned" + static let providerResultReady: StaticString = "Search.ProviderResultReady" + } + + enum ReadFile { + static let providerEntered: StaticString = "ReadFile.ProviderEntered" + static let explicitFreshnessBegan: StaticString = "ReadFile.ExplicitFreshnessBegan" + static let explicitFreshnessEnded: StaticString = "ReadFile.ExplicitFreshnessEnded" + static let exactCatalogLookupResolved: StaticString = "ReadFile.ExactCatalogLookupResolved" + static let exactCatalogShortcutResolved: StaticString = "ReadFile.ExactCatalogShortcutResolved" + static let folderResolutionReturned: StaticString = "ReadFile.FolderResolutionReturned" + static let readableServiceResolutionReturned: StaticString = "ReadFile.ReadableServiceResolutionReturned" + static let storeReadContentEntered: StaticString = "ReadFile.StoreReadContentEntered" + static let storeReadContentReturned: StaticString = "ReadFile.StoreReadContentReturned" + static let providerResultReady: StaticString = "ReadFile.ProviderResultReady" + } + + enum Bootstrap { + static let socketAccepted: StaticString = "Bootstrap.SocketAccepted" + static let handshakeIOQueued: StaticString = "Bootstrap.HandshakeIOQueued" + static let handshakeIOBegan: StaticString = "Bootstrap.HandshakeIOBegan" + static let handshakeIOEnded: StaticString = "Bootstrap.HandshakeIOEnded" + static let admissionBegan: StaticString = "Bootstrap.AdmissionBegan" + static let admissionEnded: StaticString = "Bootstrap.AdmissionEnded" + static let acceptedResponseSent: StaticString = "Bootstrap.AcceptedResponseSent" + static let ownershipTransferred: StaticString = "Bootstrap.OwnershipTransferred" + static let postAcceptStartupBegan: StaticString = "Bootstrap.PostAcceptStartupBegan" + static let postAcceptStartupEnded: StaticString = "Bootstrap.PostAcceptStartupEnded" + } + + enum WorkspaceIngress { + static let storeSinkScheduled: StaticString = "WorkspaceIngress.StoreSinkScheduled" + static let storeSinkBegan: StaticString = "WorkspaceIngress.StoreSinkBegan" + static let storeCanonicalApplyCompleted: StaticString = "WorkspaceIngress.StoreCanonicalApplyCompleted" + static let rootFlushBegan: StaticString = "WorkspaceIngress.RootFlushBegan" + static let rootFlushEnded: StaticString = "WorkspaceIngress.RootFlushEnded" + } + + enum ReadFileAutoSelect { + static let enqueueAccepted: StaticString = "ReadFile.AutoSelect.EnqueueAccepted" + static let enqueueCoalesced: StaticString = "ReadFile.AutoSelect.EnqueueCoalesced" + static let canonicalApplyBegan: StaticString = "ReadFile.AutoSelect.CanonicalApplyBegan" + static let canonicalApplyEnded: StaticString = "ReadFile.AutoSelect.CanonicalApplyEnded" + static let mirrorScheduled: StaticString = "ReadFile.AutoSelect.MirrorScheduled" + static let mirrorCoalesced: StaticString = "ReadFile.AutoSelect.MirrorCoalesced" + static let mirrorApplyBegan: StaticString = "ReadFile.AutoSelect.MirrorApplyBegan" + static let mirrorApplyEnded: StaticString = "ReadFile.AutoSelect.MirrorApplyEnded" + static let drainBegan: StaticString = "ReadFile.AutoSelect.DrainBegan" + static let drainEnded: StaticString = "ReadFile.AutoSelect.DrainEnded" + } + + enum WorkspaceDurability { + static let flushBegan: StaticString = "WorkspaceDurability.FlushBegan" + static let flushEnded: StaticString = "WorkspaceDurability.FlushEnded" + static let writeBegan: StaticString = "WorkspaceDurability.WriteBegan" + static let writeEnded: StaticString = "WorkspaceDurability.WriteEnded" + } + } + + @discardableResult + package static func begin( + _ name: StaticString, + _ dimensions: @autoclosure () -> Dimensions = Dimensions() + ) -> IntervalState? { + guard let sink = activeSink else { return nil } + let correlation = currentLifecycleCorrelation + let intervalID = UUID() + sink.record(WorkspaceRuntimeDiagnosticEvent( + subsystem: subsystem(for: name), + name: String(describing: name), + kind: .intervalBegan, + correlationID: correlation?.id, + intervalID: intervalID, + fields: diagnosticFields(dimensions()) + )) + return IntervalState(correlationID: correlation?.id, intervalID: intervalID) + } + + package static func end( + _ name: StaticString, + _ state: IntervalState?, + _ dimensions: @autoclosure () -> Dimensions = Dimensions() + ) { + guard let sink = activeSink, let state else { return } + sink.record(WorkspaceRuntimeDiagnosticEvent( + subsystem: subsystem(for: name), + name: String(describing: name), + kind: .intervalEnded, + correlationID: state.correlationID, + intervalID: state.intervalID, + fields: diagnosticFields(dimensions()) + )) + } + + package static func makeLifecycleCorrelationIfActive() -> LifecycleCorrelation? { + activeSink == nil ? nil : LifecycleCorrelation() + } + + package static func measure( + _ name: StaticString, + operation: () throws -> T + ) rethrows -> T { + let state = begin(name) + defer { end(name, state) } + return try operation() + } + + package static func measure( + _ name: StaticString, + _ dimensions: @autoclosure () -> Dimensions, + operation: () throws -> T + ) rethrows -> T { + let state = begin(name, dimensions()) + defer { end(name, state) } + return try operation() + } + + package static func measure( + _ name: StaticString, + operation: () async throws -> T + ) async rethrows -> T { + let state = begin(name) + defer { end(name, state) } + return try await operation() + } + + package static func measure( + _ name: StaticString, + _ dimensions: @autoclosure () -> Dimensions, + operation: () async throws -> T + ) async rethrows -> T { + let state = begin(name, dimensions()) + defer { end(name, state) } + return try await operation() + } + + package static func lifecycleEvent( + _ name: StaticString, + correlation: LifecycleCorrelation? = currentLifecycleCorrelation, + sink: (any WorkspaceRuntimeDiagnosticsSink)? = nil, + _ dimensions: @autoclosure () -> Dimensions = Dimensions() + ) { + guard let sink = sink ?? activeSink else { return } + sink.record(WorkspaceRuntimeDiagnosticEvent( + subsystem: subsystem(for: name), + name: String(describing: name), + kind: .lifecycle, + correlationID: correlation?.id, + fields: diagnosticFields(dimensions()) + )) + } + + package static func withSink( + _ sink: any WorkspaceRuntimeDiagnosticsSink, + operation: () async throws -> T + ) async rethrows -> T { + try await $currentSink.withValue(sink) { try await operation() } + } + + private static func diagnosticFields(_ dimensions: Dimensions) -> [String: String] { + dimensions.logDescription.isEmpty ? [:] : ["dimensions": dimensions.logDescription] + } + + private static func subsystem(for name: StaticString) -> String { + String(describing: name).split(separator: ".").dropFirst().first.map(String.init) ?? "runtime" + } +} diff --git a/Sources/RepoPrompt/Infrastructure/WorkspaceContext/WorkspaceSearchDecodedContentCache.swift b/Sources/RepoPromptCore/WorkspaceContext/WorkspaceSearchDecodedContentCache.swift similarity index 100% rename from Sources/RepoPrompt/Infrastructure/WorkspaceContext/WorkspaceSearchDecodedContentCache.swift rename to Sources/RepoPromptCore/WorkspaceContext/WorkspaceSearchDecodedContentCache.swift diff --git a/Sources/RepoPromptCore/Workspaces/CodeMapUsage.swift b/Sources/RepoPromptCore/Workspaces/CodeMapUsage.swift new file mode 100644 index 000000000..9ad45014a --- /dev/null +++ b/Sources/RepoPromptCore/Workspaces/CodeMapUsage.swift @@ -0,0 +1,9 @@ +import Foundation + +/// Determines how code-map definitions are inserted. +package enum CodeMapUsage: String, CaseIterable, Codable { + case auto + case complete + case selected + case none +} diff --git a/Sources/RepoPromptCore/Workspaces/CopyCustomizations.swift b/Sources/RepoPromptCore/Workspaces/CopyCustomizations.swift new file mode 100644 index 000000000..dbea217e4 --- /dev/null +++ b/Sources/RepoPromptCore/Workspaces/CopyCustomizations.swift @@ -0,0 +1,55 @@ +import Foundation + +/// Workspace-specific customization overrides for a copy preset. +package struct CopyCustomizations: Codable, Equatable { + package var selectedPromptIDs: [UUID]? + package var fileTreeMode: FileTreeOption? + package var codeMapUsage: CodeMapUsage? + package var gitInclusion: GitInclusion? + package var includeFiles: Bool? + package var includeUserPrompt: Bool? + package var includeMetaPrompts: Bool? + package var includeFileTree: Bool? + + package init( + selectedPromptIDs: [UUID]? = nil, + fileTreeMode: FileTreeOption? = nil, + codeMapUsage: CodeMapUsage? = nil, + gitInclusion: GitInclusion? = nil, + includeFiles: Bool? = nil, + includeUserPrompt: Bool? = nil, + includeMetaPrompts: Bool? = nil, + includeFileTree: Bool? = nil + ) { + self.selectedPromptIDs = selectedPromptIDs + self.fileTreeMode = fileTreeMode + self.codeMapUsage = codeMapUsage + self.gitInclusion = gitInclusion + self.includeFiles = includeFiles + self.includeUserPrompt = includeUserPrompt + self.includeMetaPrompts = includeMetaPrompts + self.includeFileTree = includeFileTree + } + + package var hasCustomizations: Bool { + selectedPromptIDs != nil || fileTreeMode != nil || codeMapUsage != nil || gitInclusion != nil || + includeFiles != nil || includeUserPrompt != nil || includeMetaPrompts != nil || includeFileTree != nil + } + + package mutating func clear() { + selectedPromptIDs = nil + fileTreeMode = nil + codeMapUsage = nil + gitInclusion = nil + includeFiles = nil + includeUserPrompt = nil + includeMetaPrompts = nil + includeFileTree = nil + } + + package func removingCodeMapUsageOverride() -> CopyCustomizations { + var copy = self + copy.codeMapUsage = nil + return copy + } +} diff --git a/Sources/RepoPromptCore/Workspaces/EmbeddedWorkspaceCodecV1.swift b/Sources/RepoPromptCore/Workspaces/EmbeddedWorkspaceCodecV1.swift new file mode 100644 index 000000000..e404ddac1 --- /dev/null +++ b/Sources/RepoPromptCore/Workspaces/EmbeddedWorkspaceCodecV1.swift @@ -0,0 +1,20 @@ +import Foundation + +/// Current embedded-app workspace document codec. +/// +/// Decode normalization is reported as metadata only; neither this codec nor the repository writes on read. +package struct EmbeddedWorkspaceCodecV1: WorkspaceDocumentCodec { + package typealias Document = WorkspaceModel + + package static let formatVersion = WorkspaceDocumentFormatVersion(family: "embedded-app", version: 1) + + package init() {} + + package func decode(_ data: Data) throws -> WorkspaceDocumentDecodeResult { + try WorkspaceModel.decodeAppV1(data) + } + + package func encode(_ document: WorkspaceModel) throws -> WorkspaceDocumentEncodeResult { + try WorkspaceDocumentEncodeResult(data: JSONEncoder().encode(document), schemaVersion: Self.formatVersion) + } +} diff --git a/Sources/RepoPromptCore/Workspaces/FileTreeOption.swift b/Sources/RepoPromptCore/Workspaces/FileTreeOption.swift new file mode 100644 index 000000000..a238464e3 --- /dev/null +++ b/Sources/RepoPromptCore/Workspaces/FileTreeOption.swift @@ -0,0 +1,12 @@ +import Foundation + +package enum FileTreeOption: String, CaseIterable, Identifiable, Codable { + case auto = "Auto" + case files = "Full" + case selected = "Selected" + case none = "None" + + package var id: String { + rawValue + } +} diff --git a/Sources/RepoPromptCore/Workspaces/FilesTab.swift b/Sources/RepoPromptCore/Workspaces/FilesTab.swift new file mode 100644 index 000000000..68b3fa955 --- /dev/null +++ b/Sources/RepoPromptCore/Workspaces/FilesTab.swift @@ -0,0 +1,18 @@ +import Foundation + +/// Persisted file-selection surface for workspace compose-tab state. +package enum FilesTab: String, Codable { + case selected = "Selected Files" + case context = "Context Builder" + + private static let legacyApplyXMLRawValue = "Apply XML" + + package init(from decoder: Decoder) throws { + let rawValue = try decoder.singleValueContainer().decode(String.self) + if rawValue == Self.legacyApplyXMLRawValue { + self = .context + return + } + self = FilesTab(rawValue: rawValue) ?? .context + } +} diff --git a/Sources/RepoPromptCore/Workspaces/GitInclusion.swift b/Sources/RepoPromptCore/Workspaces/GitInclusion.swift new file mode 100644 index 000000000..6293ec5ac --- /dev/null +++ b/Sources/RepoPromptCore/Workspaces/GitInclusion.swift @@ -0,0 +1,7 @@ +import Foundation + +package enum GitInclusion: String, Codable, CaseIterable { + case none + case selected + case complete +} diff --git a/Sources/RepoPromptCore/Workspaces/WorkspaceAccessPolicy.swift b/Sources/RepoPromptCore/Workspaces/WorkspaceAccessPolicy.swift new file mode 100644 index 000000000..b8ec50a84 --- /dev/null +++ b/Sources/RepoPromptCore/Workspaces/WorkspaceAccessPolicy.swift @@ -0,0 +1,20 @@ +import Foundation + +/// Host-level admission policy for workspace roots. +/// +/// The embedded app keeps its existing unrestricted behavior; the future standalone +/// host supplies a fail-closed implementation when Item 6 lands. +@MainActor +package protocol WorkspaceAccessPolicy: AnyObject { + func allowsWorkspaceRoot(_ url: URL) -> Bool +} + +@MainActor +package final class UnrestrictedWorkspaceAccessPolicy: WorkspaceAccessPolicy { + package init() {} + + package func allowsWorkspaceRoot(_ url: URL) -> Bool { + _ = url + return true + } +} diff --git a/Sources/RepoPromptCore/Workspaces/WorkspaceDocumentCodec.swift b/Sources/RepoPromptCore/Workspaces/WorkspaceDocumentCodec.swift new file mode 100644 index 000000000..04f9bbccf --- /dev/null +++ b/Sources/RepoPromptCore/Workspaces/WorkspaceDocumentCodec.swift @@ -0,0 +1,61 @@ +import Foundation + +package struct WorkspaceDocumentFormatVersion: Hashable { + package let family: String + package let version: Int + + package init(family: String, version: Int) { + self.family = family + self.version = version + } +} + +package struct WorkspaceCodecWarning: Hashable { + package let code: String + package let message: String + + package init(code: String, message: String) { + self.code = code + self.message = message + } +} + +package struct WorkspaceDocumentDecodeResult { + package let document: Document + package let sourceVersion: WorkspaceDocumentFormatVersion + package let warnings: [WorkspaceCodecWarning] + package let requiresRewrite: Bool + + package init( + document: Document, + sourceVersion: WorkspaceDocumentFormatVersion, + warnings: [WorkspaceCodecWarning] = [], + requiresRewrite: Bool = false + ) { + self.document = document + self.sourceVersion = sourceVersion + self.warnings = warnings + self.requiresRewrite = requiresRewrite + } +} + +package struct WorkspaceDocumentEncodeResult { + package let data: Data + package let schemaVersion: WorkspaceDocumentFormatVersion + + package init(data: Data, schemaVersion: WorkspaceDocumentFormatVersion) { + self.data = data + self.schemaVersion = schemaVersion + } +} + +/// Version-aware serialization boundary for the canonical workspace domain introduced in Phase 2. +/// +/// Phase 1 intentionally keeps the document generic so Core does not invent a competing schema +/// before the app's canonical workspace value graph moves into this target. +package protocol WorkspaceDocumentCodec: Sendable { + associatedtype Document: Sendable + + func decode(_ data: Data) throws -> WorkspaceDocumentDecodeResult + func encode(_ document: Document) throws -> WorkspaceDocumentEncodeResult +} diff --git a/Sources/RepoPromptCore/Workspaces/WorkspaceLegacyMigrationContracts.swift b/Sources/RepoPromptCore/Workspaces/WorkspaceLegacyMigrationContracts.swift new file mode 100644 index 000000000..b7dcde07f --- /dev/null +++ b/Sources/RepoPromptCore/Workspaces/WorkspaceLegacyMigrationContracts.swift @@ -0,0 +1,40 @@ +import Foundation + +package struct WorkspaceLegacyMigrationRequest { + package let profileRoot: URL + package let destinationRoot: URL + + package init(profileRoot: URL, destinationRoot: URL) { + self.profileRoot = profileRoot + self.destinationRoot = destinationRoot + } +} + +package enum WorkspaceLegacyMigrationAssessment: Equatable { + case notRequired + case ready(documentCount: Int) + case blocked(reason: String) +} + +package enum WorkspaceLegacyMigrationResult: Equatable { + case notRequired + case migrated(documentCount: Int, backupURL: URL) + case repairedStorageVersionMarker +} + +package protocol WorkspaceLegacyMigrationServicing: Sendable { + func assess(_ request: WorkspaceLegacyMigrationRequest) async throws -> WorkspaceLegacyMigrationAssessment + func migrate(_ request: WorkspaceLegacyMigrationRequest) async throws -> WorkspaceLegacyMigrationResult +} + +package struct NoopWorkspaceLegacyMigrationService: WorkspaceLegacyMigrationServicing { + package init() {} + + package func assess(_: WorkspaceLegacyMigrationRequest) async throws -> WorkspaceLegacyMigrationAssessment { + .notRequired + } + + package func migrate(_: WorkspaceLegacyMigrationRequest) async throws -> WorkspaceLegacyMigrationResult { + .notRequired + } +} diff --git a/Sources/RepoPromptCore/Workspaces/WorkspaceModel.swift b/Sources/RepoPromptCore/Workspaces/WorkspaceModel.swift new file mode 100644 index 000000000..d5b3d7f2c --- /dev/null +++ b/Sources/RepoPromptCore/Workspaces/WorkspaceModel.swift @@ -0,0 +1,375 @@ +import Foundation + +private extension CodingUserInfoKey { + static let workspaceDecodeCollector = CodingUserInfoKey(rawValue: "RepoPromptCore.WorkspaceDecodeCollector")! +} + +final class WorkspaceDecodeCollector { + var warnings: [WorkspaceCodecWarning] = [] + var requiresRewrite = false + + func record(code: String, message: String) { + warnings.append(WorkspaceCodecWarning(code: code, message: message)) + } +} + +package struct WorkspacePreset: Codable, Identifiable, Equatable { + package let id: UUID + package var name: String + package var capturesFileSelection: Bool + package var capturesFileTreeExpansion: Bool + package var capturesSelectedPrompts: Bool + package var selectedFilePaths: [String] + package var expandedFolders: [String] + package var selectedPromptIDs: [UUID] + package var lastUpdated: Date + + package init( + id: UUID = UUID(), + name: String, + capturesFileSelection: Bool = true, + capturesFileTreeExpansion: Bool = true, + capturesSelectedPrompts: Bool = true, + selectedFilePaths: [String] = [], + expandedFolders: [String] = [], + selectedPromptIDs: [UUID] = [], + lastUpdated: Date = Date() + ) { + self.id = id + self.name = name + self.capturesFileSelection = capturesFileSelection + self.capturesFileTreeExpansion = capturesFileTreeExpansion + self.capturesSelectedPrompts = capturesSelectedPrompts + self.selectedFilePaths = selectedFilePaths + self.expandedFolders = expandedFolders + self.selectedPromptIDs = selectedPromptIDs + self.lastUpdated = lastUpdated + } + + package init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + id = (try? c.decode(UUID.self, forKey: .id)) ?? UUID() + name = (try? c.decode(String.self, forKey: .name)) ?? "Unnamed Preset" + capturesFileSelection = (try? c.decode(Bool.self, forKey: .capturesFileSelection)) ?? true + capturesFileTreeExpansion = (try? c.decode(Bool.self, forKey: .capturesFileTreeExpansion)) ?? true + capturesSelectedPrompts = (try? c.decode(Bool.self, forKey: .capturesSelectedPrompts)) ?? true + selectedFilePaths = (try? c.decode([String].self, forKey: .selectedFilePaths)) ?? [] + expandedFolders = (try? c.decode([String].self, forKey: .expandedFolders)) ?? [] + selectedPromptIDs = (try? c.decode([UUID].self, forKey: .selectedPromptIDs)) ?? [] + lastUpdated = (try? c.decode(Date.self, forKey: .lastUpdated)) ?? Date() + } +} + +package struct StoredSelection: Codable, Equatable { + package let selectedPaths: [String] + package let autoCodemapPaths: [String] + package let slices: [String: [LineRange]] + package let codemapAutoEnabled: Bool + + package init( + selectedPaths: [String] = [], + autoCodemapPaths: [String] = [], + slices: [String: [LineRange]] = [:], + codemapAutoEnabled: Bool = true + ) { + self.selectedPaths = selectedPaths + self.autoCodemapPaths = autoCodemapPaths + self.slices = slices + self.codemapAutoEnabled = codemapAutoEnabled + } +} + +package struct ContextBuilderOverrides: Codable, Equatable { + package var useOverridePrompt: Bool + package var overridePromptText: String + + package init(useOverridePrompt: Bool = false, overridePromptText: String = "") { + self.useOverridePrompt = useOverridePrompt + self.overridePromptText = overridePromptText + } +} + +package struct ContextBuilderTabConfig: Codable, Equatable { + package var instructions: String + package var autoGeneratePlan: Bool? + package var followUpTypeRaw: String? + package var selectedContextBuilderPromptIDs: [UUID] + + package init( + instructions: String = "", + autoGeneratePlan: Bool? = nil, + followUpTypeRaw: String? = nil, + selectedContextBuilderPromptIDs: [UUID] = [] + ) { + self.instructions = instructions + self.autoGeneratePlan = autoGeneratePlan + self.followUpTypeRaw = followUpTypeRaw + self.selectedContextBuilderPromptIDs = selectedContextBuilderPromptIDs + } +} + +package struct StashedTab: Codable, Identifiable, Equatable { + package var id: UUID + package var tab: ComposeTabState + package var stashedAt: Date + + package init(id: UUID = UUID(), tab: ComposeTabState, stashedAt: Date = Date()) { + self.id = id + self.tab = tab + self.stashedAt = stashedAt + } +} + +package struct ComposeTabState: Codable, Identifiable, Equatable { + package var id: UUID + package var name: String + package var lastModified: Date + package var isPinned: Bool + package var activeChatSessionID: UUID? + package var activeAgentSessionID: UUID? + package var selection: StoredSelection + package var expandedFolders: [String] + package var promptText: String + package var selectedMetaPromptIDs: [UUID] + package var activeSubView: FilesTab? + package var contextOverrides: ContextBuilderOverrides + /// Encodes and decodes under the legacy app-v1 JSON key `discover`. + package var contextBuilder: ContextBuilderTabConfig + + package init( + id: UUID = UUID(), + name: String = "T1", + lastModified: Date = Date(), + isPinned: Bool = false, + activeChatSessionID: UUID? = nil, + activeAgentSessionID: UUID? = nil, + selection: StoredSelection = .init(), + expandedFolders: [String] = [], + promptText: String = "", + selectedMetaPromptIDs: [UUID] = [], + activeSubView: FilesTab? = nil, + contextOverrides: ContextBuilderOverrides = .init(), + contextBuilder: ContextBuilderTabConfig = .init() + ) { + self.id = id + self.name = name + self.lastModified = lastModified + self.isPinned = isPinned + self.activeChatSessionID = activeChatSessionID + self.activeAgentSessionID = activeAgentSessionID + self.selection = selection + self.expandedFolders = expandedFolders + self.promptText = promptText + self.selectedMetaPromptIDs = selectedMetaPromptIDs + self.activeSubView = activeSubView + self.contextOverrides = contextOverrides + self.contextBuilder = contextBuilder + } + + package init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + id = try c.decodeIfPresent(UUID.self, forKey: .id) ?? UUID() + name = try c.decodeIfPresent(String.self, forKey: .name) ?? "T1" + lastModified = try c.decodeIfPresent(Date.self, forKey: .lastModified) ?? Date() + isPinned = try c.decodeIfPresent(Bool.self, forKey: .isPinned) ?? false + activeChatSessionID = try c.decodeIfPresent(UUID.self, forKey: .activeChatSessionID) + activeAgentSessionID = try c.decodeIfPresent(UUID.self, forKey: .activeAgentSessionID) + selection = try c.decodeIfPresent(StoredSelection.self, forKey: .selection) ?? .init() + expandedFolders = try c.decodeIfPresent([String].self, forKey: .expandedFolders) ?? [] + promptText = try c.decodeIfPresent(String.self, forKey: .promptText) ?? "" + selectedMetaPromptIDs = try c.decodeIfPresent([UUID].self, forKey: .selectedMetaPromptIDs) ?? [] + activeSubView = try c.decodeIfPresent(FilesTab.self, forKey: .activeSubView) + contextOverrides = try c.decodeIfPresent(ContextBuilderOverrides.self, forKey: .contextOverrides) ?? .init() + contextBuilder = try c.decodeIfPresent(ContextBuilderTabConfig.self, forKey: .discover) ?? .init() + } + + package func encode(to encoder: Encoder) throws { + var c = encoder.container(keyedBy: CodingKeys.self) + try c.encode(id, forKey: .id) + try c.encode(name, forKey: .name) + try c.encode(lastModified, forKey: .lastModified) + try c.encode(isPinned, forKey: .isPinned) + try c.encodeIfPresent(activeChatSessionID, forKey: .activeChatSessionID) + try c.encodeIfPresent(activeAgentSessionID, forKey: .activeAgentSessionID) + try c.encode(selection, forKey: .selection) + try c.encode(expandedFolders, forKey: .expandedFolders) + try c.encode(promptText, forKey: .promptText) + try c.encode(selectedMetaPromptIDs, forKey: .selectedMetaPromptIDs) + try c.encodeIfPresent(activeSubView, forKey: .activeSubView) + try c.encode(contextOverrides, forKey: .contextOverrides) + try c.encode(contextBuilder, forKey: .discover) + } + + private enum CodingKeys: String, CodingKey { + case id, name, lastModified, isPinned, activeChatSessionID, activeAgentSessionID + case selection, expandedFolders, promptText, selectedMetaPromptIDs, activeSubView, contextOverrides + case discover + } +} + +package struct WorkspaceModel: Codable, Identifiable, Equatable { + package let id: UUID + package var schemaVersion: Int + package var dateModified: Date + package var customStoragePath: URL? + package var isSystemWorkspace: Bool + package var isHiddenInMenus: Bool + package var ephemeralFlag: Bool? + package var name: String + package var repoPaths: [String] + package var presets: [WorkspacePreset] + package var activePresetID: UUID? + package var lastUsed: Date + package var customPath: String? + package var currentPromptText: String? + package var lastSearchQuery: String? + package var selectedMetaPromptIDs: [UUID] + package var copyPresetId: UUID? + package var copyCustomizations: CopyCustomizations? + package var chatPresetId: UUID? + package var composeTabs: [ComposeTabState] + package var activeComposeTabID: UUID? + package var stashedTabs: [StashedTab] + + package init( + id: UUID = UUID(), + schemaVersion: Int = 1, + dateModified: Date = Date(), + name: String, + repoPaths: [String], + presets: [WorkspacePreset] = [], + activePresetID: UUID? = nil, + lastUsed: Date = Date(), + customPath: String? = nil, + currentPromptText: String? = nil, + lastSearchQuery: String? = nil, + selectedMetaPromptIDs: [UUID] = [], + isSystemWorkspace: Bool = false, + customStoragePath: URL? = nil, + ephemeralFlag: Bool? = nil, + isHiddenInMenus: Bool = false, + copyPresetId: UUID? = nil, + copyCustomizations: CopyCustomizations? = nil, + chatPresetId: UUID? = nil, + composeTabs: [ComposeTabState] = [], + activeComposeTabID: UUID? = nil, + stashedTabs: [StashedTab] = [] + ) { + self.id = id + self.schemaVersion = schemaVersion + self.dateModified = dateModified + self.name = name + self.repoPaths = repoPaths + self.presets = presets + self.activePresetID = activePresetID + self.lastUsed = lastUsed + self.customPath = customPath + self.currentPromptText = currentPromptText + self.lastSearchQuery = lastSearchQuery + self.selectedMetaPromptIDs = selectedMetaPromptIDs + self.isSystemWorkspace = isSystemWorkspace + self.customStoragePath = customStoragePath + self.ephemeralFlag = ephemeralFlag + self.isHiddenInMenus = isHiddenInMenus + self.copyPresetId = copyPresetId + self.copyCustomizations = copyCustomizations + self.chatPresetId = chatPresetId + self.composeTabs = composeTabs + self.activeComposeTabID = activeComposeTabID + self.stashedTabs = stashedTabs + _ = normalizeComposeTabInvariants() + } + + package init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + id = (try? c.decode(UUID.self, forKey: .id)) ?? UUID() + schemaVersion = (try? c.decode(Int.self, forKey: .schemaVersion)) ?? 1 + dateModified = (try? c.decode(Date.self, forKey: .dateModified)) ?? Date() + customStoragePath = try? c.decode(URL.self, forKey: .customStoragePath) + isSystemWorkspace = (try? c.decode(Bool.self, forKey: .isSystemWorkspace)) ?? false + isHiddenInMenus = (try? c.decode(Bool.self, forKey: .isHiddenInMenus)) ?? false + ephemeralFlag = (try? c.decode(Bool?.self, forKey: .ephemeralFlag)) ?? nil + name = (try? c.decode(String.self, forKey: .name)) ?? "Untitled Workspace" + repoPaths = (try? c.decode([String].self, forKey: .repoPaths)) ?? [] + presets = (try? c.decode([WorkspacePreset].self, forKey: .presets)) ?? [] + activePresetID = try? c.decode(UUID.self, forKey: .activePresetID) + lastUsed = (try? c.decode(Date.self, forKey: .lastUsed)) ?? Date() + customPath = try? c.decode(String.self, forKey: .customPath) + currentPromptText = try? c.decode(String.self, forKey: .currentPromptText) + lastSearchQuery = try? c.decode(String.self, forKey: .lastSearchQuery) + selectedMetaPromptIDs = (try? c.decode([UUID].self, forKey: .selectedMetaPromptIDs)) ?? [] + copyPresetId = try? c.decode(UUID.self, forKey: .copyPresetId) + copyCustomizations = try? c.decode(CopyCustomizations.self, forKey: .copyCustomizations) + chatPresetId = try? c.decode(UUID.self, forKey: .chatPresetId) + do { + composeTabs = try c.decodeIfPresent([ComposeTabState].self, forKey: .composeTabs) ?? [] + } catch { + composeTabs = [] + (decoder.userInfo[.workspaceDecodeCollector] as? WorkspaceDecodeCollector)?.record( + code: "compose_tabs_decode_failed", + message: "Failed to decode composeTabs for workspace \(id.uuidString); used an empty array before normalization." + ) + } + activeComposeTabID = try? c.decode(UUID.self, forKey: .activeComposeTabID) + stashedTabs = (try? c.decode([StashedTab].self, forKey: .stashedTabs)) ?? [] + if normalizeComposeTabInvariants() { + (decoder.userInfo[.workspaceDecodeCollector] as? WorkspaceDecodeCollector)?.requiresRewrite = true + } + } + + package var isEphemeral: Bool { + get { ephemeralFlag ?? false } + set { ephemeralFlag = newValue } + } + + @discardableResult + package mutating func normalizeComposeTabInvariants() -> Bool { + var mutated = false + if composeTabs.isEmpty { + let tab = ComposeTabState( + name: "T1", + promptText: currentPromptText ?? "", + selectedMetaPromptIDs: selectedMetaPromptIDs, + activeSubView: nil + ) + composeTabs = [tab] + activeComposeTabID = tab.id + mutated = true + } + + let activeTabIDs = Set(composeTabs.map(\.id)) + if activeComposeTabID.map({ !activeTabIDs.contains($0) }) ?? true { + activeComposeTabID = composeTabs.first?.id + mutated = true + } + + let originalCount = stashedTabs.count + stashedTabs.removeAll { activeTabIDs.contains($0.tab.id) } + if stashedTabs.count != originalCount { + mutated = true + } + return mutated + } + + private enum CodingKeys: String, CodingKey { + case id, schemaVersion, dateModified, customStoragePath, isSystemWorkspace, isHiddenInMenus + case name, repoPaths, presets, activePresetID, lastUsed, customPath, currentPromptText + case lastSearchQuery, selectedMetaPromptIDs, ephemeralFlag, copyPresetId, copyCustomizations + case chatPresetId, composeTabs, activeComposeTabID, stashedTabs + } +} + +extension WorkspaceModel { + static func decodeAppV1(_ data: Data) throws -> WorkspaceDocumentDecodeResult { + let decoder = JSONDecoder() + let collector = WorkspaceDecodeCollector() + decoder.userInfo[.workspaceDecodeCollector] = collector + let workspace = try decoder.decode(WorkspaceModel.self, from: data) + return WorkspaceDocumentDecodeResult( + document: workspace, + sourceVersion: WorkspaceDocumentFormatVersion(family: "embedded-app", version: 1), + warnings: collector.warnings, + requiresRewrite: collector.requiresRewrite + ) + } +} diff --git a/Sources/RepoPromptCore/Workspaces/WorkspacePersistenceWriter.swift b/Sources/RepoPromptCore/Workspaces/WorkspacePersistenceWriter.swift new file mode 100644 index 000000000..1ed0858b8 --- /dev/null +++ b/Sources/RepoPromptCore/Workspaces/WorkspacePersistenceWriter.swift @@ -0,0 +1,472 @@ +import Foundation + +package actor WorkspacePersistenceWriter { + private struct PendingWrite { + var sequence: UInt64 + var data: Data + var metadata: WorkspaceSavePayloadMetadata? + } + + private struct Waiter { + let cut: UInt64 + let continuation: CheckedContinuation + } + + private struct URLState { + var nextSequence: UInt64 = 0 + var completedSequence: UInt64 = 0 + var queue: [PendingWrite] = [] + var workerRunning = false + var failures: [UInt64: String] = [:] + var waiters: [Waiter] = [] + } + + private struct LatestSelectionRecord { + let revision: UInt64 + let selection: StoredSelection + } + + private struct EffectiveWritePayload { + let data: Data + let metadata: WorkspaceSavePayloadMetadata? + let selectionRevisions: [WorkspaceTabSelectionKey: UInt64] + let shouldWrite: Bool + } + + private final class SelectionRevisionRegistry: @unchecked Sendable { + private let lock = NSLock() + private var nextRevision: UInt64 = 0 + + func allocate() -> UInt64 { + lock.lock() + defer { lock.unlock() } + nextRevision &+= 1 + return nextRevision + } + + func reset() { + lock.lock() + nextRevision = 0 + lock.unlock() + } + } + + private nonisolated let revisionRegistry = SelectionRevisionRegistry() + private let codec: EmbeddedWorkspaceCodecV1 + private let diagnostics: any WorkspaceRepositoryDiagnosticsSink + private var states: [URL: URLState] = [:] + private var diagnosticsIDByURL: [URL: UUID] = [:] + private var latestSelectionByWorkspaceTab: [WorkspaceTabSelectionKey: LatestSelectionRecord] = [:] + private var lastWrittenSelectionRevisionByWorkspaceTab: [WorkspaceTabSelectionKey: UInt64] = [:] + + #if DEBUG + private var atomicWriteGateForTesting: (@Sendable () async -> Void)? + #endif + + package init( + codec: EmbeddedWorkspaceCodecV1 = EmbeddedWorkspaceCodecV1(), + diagnostics: any WorkspaceRepositoryDiagnosticsSink = NoopWorkspaceRepositoryDiagnosticsSink() + ) { + self.codec = codec + self.diagnostics = diagnostics + } + + package nonisolated func allocateSelectionRevision() -> UInt64 { + revisionRegistry.allocate() + } + + @discardableResult + package func enqueue(data: Data, url: URL) -> WorkspaceWriteReceipt { + enqueue(data: data, url: url, metadata: nil) + } + + @discardableResult + package func enqueueWorkspace( + data: Data, + url: URL, + metadata: WorkspaceSavePayloadMetadata + ) -> WorkspaceWriteReceipt { + enqueue(data: data, url: url, metadata: metadata) + } + + @discardableResult + package func enqueueWorkspace( + _ workspace: WorkspaceModel, + url: URL, + metadata: WorkspaceSavePayloadMetadata + ) throws -> WorkspaceWriteReceipt { + let data = try codec.encode(workspace).data + return enqueue(data: data, url: url, metadata: metadata) + } + + package func flush(url: URL) async -> WorkspaceWriteCompletion? { + guard let state = states[url], state.nextSequence > 0 else { return nil } + return await flush(WorkspaceWriteReceipt(url: url, sequence: state.nextSequence)) + } + + package func flush(_ receipt: WorkspaceWriteReceipt) async -> WorkspaceWriteCompletion { + if let state = states[receipt.url], state.completedSequence >= receipt.sequence { + return completion(for: receipt, state: state) + } + emit( + "workspaceSave.flush.begin", + metadata: nil, + url: receipt.url, + fields: ["sequence": "\(receipt.sequence)"] + ) + let completion = await withCheckedContinuation { continuation in + var state = states[receipt.url] ?? URLState() + state.waiters.append(Waiter(cut: receipt.sequence, continuation: continuation)) + states[receipt.url] = state + } + emit( + "workspaceSave.flush.end", + metadata: nil, + url: receipt.url, + fields: ["sequence": "\(receipt.sequence)"] + ) + return completion + } + + #if DEBUG + package func setAtomicWriteGateForTesting(_ gate: (@Sendable () async -> Void)?) { + atomicWriteGateForTesting = gate + } + + package func removeAllForTesting() { + states.removeAll() + diagnosticsIDByURL.removeAll() + latestSelectionByWorkspaceTab.removeAll() + lastWrittenSelectionRevisionByWorkspaceTab.removeAll() + atomicWriteGateForTesting = nil + revisionRegistry.reset() + } + #endif + + private func enqueue( + data: Data, + url: URL, + metadata: WorkspaceSavePayloadMetadata? + ) -> WorkspaceWriteReceipt { + recordLatestSelectionIfNeeded(metadata) + var state = states[url] ?? URLState() + state.nextSequence &+= 1 + let sequence = state.nextSequence + let incoming = PendingWrite(sequence: sequence, data: data, metadata: metadata) + + // Every returned receipt identifies this exact payload. Keep it as a distinct queue item so + // a later enqueue cannot complete an earlier receipt with different bytes. + state.queue.append(incoming) + + let shouldStart = !state.workerRunning + if shouldStart { state.workerRunning = true } + states[url] = state + emit("workspaceSave.enqueue", metadata: metadata, url: url, fields: ["sequence": "\(sequence)"]) + + if shouldStart { + Task.detached(priority: .utility) { [weak self] in + await self?.drain(url: url) + } + } + return WorkspaceWriteReceipt(url: url, sequence: sequence) + } + + private func drain(url: URL) async { + while let work = takeNext(url: url) { + let selectionKeys = work.metadata.map { metadata in + metadata.selectionRecords.map { $0.key(workspaceID: metadata.workspaceID) } + } ?? [] + let latestRecords = Dictionary(uniqueKeysWithValues: selectionKeys.compactMap { key in + latestSelectionByWorkspaceTab[key].map { (key, $0) } + }) + let lastWrittenRevisions = Dictionary(uniqueKeysWithValues: selectionKeys.map { key in + (key, lastWrittenSelectionRevisionByWorkspaceTab[key, default: 0]) + }) + let codec = codec + #if DEBUG + let gate = atomicWriteGateForTesting + #endif + let effective = await Task.detached(priority: .utility) { + Self.effectivePayloadForWrite( + payload: work.data, + url: url, + metadata: work.metadata, + latestRecords: latestRecords, + lastWrittenRevisions: lastWrittenRevisions, + codec: codec + ) + }.value + + var errorDescription: String? + if effective.shouldWrite { + emit( + "workspaceSave.write.begin", + metadata: effective.metadata, + url: url, + fields: ["sequence": "\(work.sequence)"] + ) + #if DEBUG + await gate?() + #endif + do { + try effective.data.write(to: url, options: .atomic) + } catch { + errorDescription = error.localizedDescription + } + emit( + "workspaceSave.write.end", + metadata: effective.metadata, + url: url, + fields: [ + "sequence": "\(work.sequence)", + "error": errorDescription ?? "" + ] + ) + } + finish( + url: url, + sequence: work.sequence, + effective: effective, + errorDescription: errorDescription + ) + } + } + + private func takeNext(url: URL) -> PendingWrite? { + guard var state = states[url] else { return nil } + guard !state.queue.isEmpty else { + state.workerRunning = false + states[url] = state + resumeSatisfiedWaiters(url: url) + return nil + } + let work = state.queue.removeFirst() + states[url] = state + return work + } + + private func finish( + url: URL, + sequence: UInt64, + effective: EffectiveWritePayload, + errorDescription: String? + ) { + if errorDescription == nil, effective.shouldWrite { + for (key, revision) in effective.selectionRevisions where revision > 0 { + lastWrittenSelectionRevisionByWorkspaceTab[key] = max( + lastWrittenSelectionRevisionByWorkspaceTab[key, default: 0], + revision + ) + } + } + + guard var state = states[url] else { return } + state.completedSequence = max(state.completedSequence, sequence) + if let errorDescription { + state.failures[sequence] = errorDescription + } else if effective.shouldWrite { + state.failures = state.failures.filter { $0.key > sequence } + } + states[url] = state + emit( + errorDescription == nil ? "workspaceSave.write.finish" : "workspaceSave.write.failure", + metadata: effective.metadata, + url: url, + fields: [ + "sequence": "\(sequence)", + "shouldWrite": "\(effective.shouldWrite)", + "error": errorDescription ?? "" + ] + ) + resumeSatisfiedWaiters(url: url) + } + + private func resumeSatisfiedWaiters(url: URL) { + guard var state = states[url] else { return } + var pending: [Waiter] = [] + var ready: [Waiter] = [] + for waiter in state.waiters { + if state.completedSequence >= waiter.cut { + ready.append(waiter) + } else { + pending.append(waiter) + } + } + state.waiters = pending + states[url] = state + for waiter in ready { + waiter.continuation.resume( + returning: completion( + for: WorkspaceWriteReceipt(url: url, sequence: waiter.cut), + state: state + ) + ) + } + } + + private func completion(for receipt: WorkspaceWriteReceipt, state: URLState) -> WorkspaceWriteCompletion { + let failure = state.failures + .filter { $0.key <= receipt.sequence } + .max(by: { $0.key < $1.key })? + .value + return WorkspaceWriteCompletion(receipt: receipt, errorDescription: failure) + } + + private func recordLatestSelectionIfNeeded(_ metadata: WorkspaceSavePayloadMetadata?) { + guard let metadata else { return } + for record in metadata.selectionRecords where record.revision > 0 { + let key = record.key(workspaceID: metadata.workspaceID) + if let existing = latestSelectionByWorkspaceTab[key], existing.revision >= record.revision { + continue + } + latestSelectionByWorkspaceTab[key] = LatestSelectionRecord( + revision: record.revision, + selection: record.selection + ) + } + } + + private nonisolated static func effectivePayloadForWrite( + payload: Data, + url: URL, + metadata: WorkspaceSavePayloadMetadata?, + latestRecords: [WorkspaceTabSelectionKey: LatestSelectionRecord], + lastWrittenRevisions: [WorkspaceTabSelectionKey: UInt64], + codec: EmbeddedWorkspaceCodecV1 + ) -> EffectiveWritePayload { + guard let metadata, + let incomingWorkspace = try? codec.decode(payload).document, + incomingWorkspace.id == metadata.workspaceID + else { + return EffectiveWritePayload( + data: payload, + metadata: metadata, + selectionRevisions: [:], + shouldWrite: !isStaleComparedWithDisk(payload: payload, url: url, codec: codec) + ) + } + + let incomingRevisions = Dictionary(uniqueKeysWithValues: metadata.selectionRecords.map { record in + (record.key(workspaceID: metadata.workspaceID), record.revision) + }) + let diskWorkspace: WorkspaceModel? = if FileManager.default.fileExists(atPath: url.path), + let diskData = try? Data(contentsOf: url), + let decoded = try? codec.decode(diskData).document, + decoded.id == incomingWorkspace.id + { + decoded + } else { + nil + } + + if let diskWorkspace, diskWorkspace.dateModified > incomingWorkspace.dateModified { + var merged = diskWorkspace + var effectiveRevisions: [WorkspaceTabSelectionKey: UInt64] = [:] + for (key, latest) in latestRecords + where latest.revision > lastWrittenRevisions[key, default: 0] + { + guard let updated = workspaceByApplyingSelection( + latest.selection, + toTab: key.tabID, + in: merged + ) else { continue } + merged = updated + effectiveRevisions[key] = latest.revision + } + if !effectiveRevisions.isEmpty, + let encoded = try? codec.encode(withCurrentDate(merged)).data + { + return EffectiveWritePayload( + data: encoded, + metadata: metadata, + selectionRevisions: effectiveRevisions, + shouldWrite: true + ) + } + return EffectiveWritePayload( + data: payload, + metadata: metadata, + selectionRevisions: [:], + shouldWrite: false + ) + } + + var merged = incomingWorkspace + var effectiveRevisions = incomingRevisions + var didMerge = false + for (key, latest) in latestRecords + where latest.revision > incomingRevisions[key, default: 0] + { + guard let updated = workspaceByApplyingSelection( + latest.selection, + toTab: key.tabID, + in: merged + ) else { continue } + merged = updated + effectiveRevisions[key] = latest.revision + didMerge = true + } + let effectiveData = if didMerge, let encoded = try? codec.encode(merged).data { + encoded + } else { + payload + } + return EffectiveWritePayload( + data: effectiveData, + metadata: metadata, + selectionRevisions: effectiveRevisions, + shouldWrite: true + ) + } + + private nonisolated static func isStaleComparedWithDisk( + payload: Data, + url: URL, + codec: EmbeddedWorkspaceCodecV1 + ) -> Bool { + guard FileManager.default.fileExists(atPath: url.path), + let incoming = try? codec.decode(payload).document, + let diskData = try? Data(contentsOf: url), + let disk = try? codec.decode(diskData).document, + disk.id == incoming.id + else { return false } + return disk.dateModified > incoming.dateModified + } + + private nonisolated static func workspaceByApplyingSelection( + _ selection: StoredSelection, + toTab tabID: UUID, + in workspace: WorkspaceModel + ) -> WorkspaceModel? { + guard let tabIndex = workspace.composeTabs.firstIndex(where: { $0.id == tabID }) else { return nil } + var updated = workspace + updated.composeTabs[tabIndex].selection = selection + return updated + } + + private nonisolated static func withCurrentDate(_ workspace: WorkspaceModel) -> WorkspaceModel { + var updated = workspace + updated.dateModified = Date() + return updated + } + + private func emit( + _ name: String, + metadata: WorkspaceSavePayloadMetadata?, + url: URL, + fields: [String: String] = [:] + ) { + var payload = fields + payload["url"] = url.lastPathComponent + let standardizedURL = url.standardizedFileURL + let urlID = diagnosticsIDByURL[standardizedURL] ?? UUID() + diagnosticsIDByURL[standardizedURL] = urlID + payload["urlID"] = urlID.uuidString + if let metadata { + payload["source"] = metadata.source.rawValue + payload["workspaceID"] = metadata.workspaceID.uuidString + payload["payloadID"] = metadata.payloadID.uuidString + } + diagnostics.record(.event(name: name, fields: payload)) + } +} diff --git a/Sources/RepoPromptCore/Workspaces/WorkspaceRepository.swift b/Sources/RepoPromptCore/Workspaces/WorkspaceRepository.swift new file mode 100644 index 000000000..bf977c02f --- /dev/null +++ b/Sources/RepoPromptCore/Workspaces/WorkspaceRepository.swift @@ -0,0 +1,298 @@ +import Foundation + +package actor WorkspaceRepository: WorkspaceRepositoryContract { + package typealias Document = WorkspaceModel + + private struct DecodeCacheKey: Hashable { + let standardizedPath: String + let fileSize: Int64 + let modificationDate: Date + } + + private struct PendingIndexState { + var entries: [WorkspaceIndexEntry] + var mutation: UInt64 + var latestReceipt: WorkspaceWriteReceipt? + } + + private let rootProvider: any WorkspaceRepositoryRootProviding + private let codec: EmbeddedWorkspaceCodecV1 + private let writer: WorkspacePersistenceWriter + private let diagnostics: any WorkspaceRepositoryDiagnosticsSink + private let migrationService: any WorkspaceLegacyMigrationServicing + private var decodeCache: [DecodeCacheKey: WorkspaceDocumentDecodeResult] = [:] + private var pendingIndexStateByRoot: [URL: PendingIndexState] = [:] + private var nextIndexMutation: UInt64 = 0 + + package init( + rootProvider: any WorkspaceRepositoryRootProviding, + codec: EmbeddedWorkspaceCodecV1 = EmbeddedWorkspaceCodecV1(), + writer: WorkspacePersistenceWriter, + diagnostics: any WorkspaceRepositoryDiagnosticsSink = NoopWorkspaceRepositoryDiagnosticsSink(), + migrationService: any WorkspaceLegacyMigrationServicing = NoopWorkspaceLegacyMigrationService() + ) { + self.rootProvider = rootProvider + self.codec = codec + self.writer = writer + self.diagnostics = diagnostics + self.migrationService = migrationService + } + + package func currentRoot() async -> URL { + await rootProvider.repositoryRoot() + } + + package func currentLayout() async -> FixedWorkspaceRepositoryLayout { + await FixedWorkspaceRepositoryLayout(repositoryRoot: currentRoot()) + } + + package func loadInventory(baseRoot: URL? = nil) async -> WorkspaceRepositoryInventory { + let resolvedRoot = if let baseRoot { baseRoot } else { await currentRoot() } + let layout = FixedWorkspaceRepositoryLayout(repositoryRoot: resolvedRoot) + let rootKey = resolvedRoot.standardizedFileURL + let entries: [WorkspaceIndexEntry] + if let pending = pendingIndexStateByRoot[rootKey] { + entries = pending.entries + } else { + guard FileManager.default.fileExists(atPath: layout.indexURL.path) else { + return WorkspaceRepositoryInventory(entries: [], workspaces: []) + } + do { + entries = try JSONDecoder().decode([WorkspaceIndexEntry].self, from: Data(contentsOf: layout.indexURL)) + } catch { + diagnostics.record(.warning(code: "workspace_index_decode_failed", message: error.localizedDescription)) + return WorkspaceRepositoryInventory(entries: [], workspaces: []) + } + } + + var workspaces: [WorkspaceModel] = [] + var results: [UUID: WorkspaceDocumentDecodeResult] = [:] + for entry in entries { + let url = documentURL(for: entry, layout: layout) + guard FileManager.default.fileExists(atPath: url.path) else { + diagnostics.record(.warning(code: "workspace_document_missing", message: url.lastPathComponent)) + continue + } + do { + let result = try loadWorkspace(at: url) + workspaces.append(result.document) + results[result.document.id] = result + for warning in result.warnings { + diagnostics.record(.warning(code: warning.code, message: warning.message)) + } + } catch { + diagnostics.record(.warning(code: "workspace_document_decode_failed", message: error.localizedDescription)) + } + } + return WorkspaceRepositoryInventory(entries: entries, workspaces: workspaces, decodeResults: results) + } + + package func loadWorkspaceSnapshotFromDisk(baseRoot: URL? = nil) async -> [WorkspaceModel] { + await loadInventory(baseRoot: baseRoot).workspaces + } + + package func list() async throws -> [WorkspaceModel] { + await loadInventory().workspaces + } + + package func load(id: UUID) async throws -> WorkspaceModel? { + let inventory = await loadInventory() + return inventory.workspaces.first(where: { $0.id == id }) + } + + package func loadWorkspace(at url: URL) throws -> WorkspaceDocumentDecodeResult { + let key = try decodeCacheKey(for: url) + if let cached = decodeCache[key] { return cached } + let data = try Data(contentsOf: URL(fileURLWithPath: key.standardizedPath)) + let result = try codec.decode(data) + if let keyAfterRead = try? decodeCacheKey(for: url), keyAfterRead == key { + decodeCache[key] = result + } + return result + } + + package func save(_ document: WorkspaceModel) async throws { + let metadata = WorkspaceSavePayloadMetadata( + source: "repository.save", + owner: .none, + workspaceID: document.id, + workspaceName: document.name, + workspaceDateModified: document.dateModified, + activeTabID: document.activeComposeTabID, + activeSelectionRevision: 0, + activeSelection: activeSelection(in: document) + ) + let receipt = try await saveWorkspace(document, metadata: metadata) + let completion = await writer.flush(receipt) + if let errorDescription = completion.errorDescription { + throw CocoaError(.fileWriteUnknown, userInfo: [NSLocalizedDescriptionKey: errorDescription]) + } + let indexReceipt = try await saveIndex([WorkspaceIndexEntry(workspace: document)], mergingExisting: true) + try await flushIndex(indexReceipt, root: indexReceipt.url.deletingLastPathComponent()) + } + + @discardableResult + package func saveWorkspace( + _ workspace: WorkspaceModel, + metadata: WorkspaceSavePayloadMetadata + ) async throws -> WorkspaceWriteReceipt { + let layout = await currentLayout() + let directory = workspace.customStoragePath ?? layout.workspaceDirectory(id: workspace.id, name: workspace.name) + try ensureWorkspaceDirectory(directory) + let url = directory.appendingPathComponent("workspace.json") + invalidate(url: url) + return try await writer.enqueueWorkspace(workspace, url: url, metadata: metadata) + } + + @discardableResult + package func saveIndex( + _ entries: [WorkspaceIndexEntry], + mergingExisting: Bool = false, + baseRoot: URL? = nil + ) async throws -> WorkspaceWriteReceipt { + let resolvedRoot = if let baseRoot { baseRoot } else { await currentRoot() } + let layout = FixedWorkspaceRepositoryLayout(repositoryRoot: resolvedRoot) + let rootKey = resolvedRoot.standardizedFileURL + try FileManager.default.createDirectory(at: layout.repositoryRoot, withIntermediateDirectories: true) + let finalEntries: [WorkspaceIndexEntry] + if mergingExisting { + var existing: [WorkspaceIndexEntry] = if let pending = pendingIndexStateByRoot[rootKey] { + pending.entries + } else if FileManager.default.fileExists(atPath: layout.indexURL.path), + let data = try? Data(contentsOf: layout.indexURL), + let decoded = try? JSONDecoder().decode([WorkspaceIndexEntry].self, from: data) + { + decoded + } else { + [] + } + for entry in entries { + if let index = existing.firstIndex(where: { $0.id == entry.id }) { + existing[index] = entry + } else { + existing.append(entry) + } + } + finalEntries = existing + } else { + finalEntries = entries + } + + let data = try JSONEncoder().encode(finalEntries) + nextIndexMutation &+= 1 + let mutation = nextIndexMutation + pendingIndexStateByRoot[rootKey] = PendingIndexState( + entries: finalEntries, + mutation: mutation, + latestReceipt: nil + ) + let receipt = await writer.enqueue(data: data, url: layout.indexURL) + if var pending = pendingIndexStateByRoot[rootKey], pending.mutation == mutation { + pending.latestReceipt = receipt + pendingIndexStateByRoot[rootKey] = pending + } + return receipt + } + + package func flush(_ receipt: WorkspaceWriteReceipt) async -> WorkspaceWriteCompletion { + let completion = await writer.flush(receipt) + if completion.succeeded { + let matchingRoot = pendingIndexStateByRoot.first { $0.value.latestReceipt == receipt }?.key + if let matchingRoot { pendingIndexStateByRoot.removeValue(forKey: matchingRoot) } + } + return completion + } + + package func flushWorkspace(_ workspace: WorkspaceModel) async -> WorkspaceWriteCompletion? { + let url = await workspaceDocumentURL(for: workspace) + return await writer.flush(url: url) + } + + package func delete(id: UUID) async throws { + let layout = await currentLayout() + let inventory = await loadInventory(baseRoot: layout.repositoryRoot) + guard let entry = inventory.entries.first(where: { $0.id == id }) else { return } + if entry.customStoragePath == nil { + let directory = layout.workspaceDirectory(id: entry.id, name: entry.name) + if FileManager.default.fileExists(atPath: directory.path) { + try FileManager.default.removeItem(at: directory) + } + } + let remaining = inventory.entries.filter { $0.id != id } + let receipt = try await saveIndex(remaining, baseRoot: layout.repositoryRoot) + try await flushIndex(receipt, root: layout.repositoryRoot) + } + + package func migrateLegacyHeadlessProfileIfNeeded() async throws -> WorkspaceLegacyMigrationResult { + let root = await currentRoot() + return try await migrationService.migrate( + WorkspaceLegacyMigrationRequest(profileRoot: root, destinationRoot: root) + ) + } + + package func workspaceDocumentURL(for workspace: WorkspaceModel, baseRoot: URL? = nil) async -> URL { + let resolvedRoot = if let baseRoot { baseRoot } else { await currentRoot() } + let layout = FixedWorkspaceRepositoryLayout(repositoryRoot: resolvedRoot) + return workspace.customStoragePath?.appendingPathComponent("workspace.json") ?? + layout.workspaceDocumentURL(id: workspace.id, name: workspace.name) + } + + package func workspaceDirectory(for workspace: WorkspaceModel, baseRoot: URL? = nil) async -> URL { + let resolvedRoot = if let baseRoot { baseRoot } else { await currentRoot() } + let layout = FixedWorkspaceRepositoryLayout(repositoryRoot: resolvedRoot) + return workspace.customStoragePath ?? layout.workspaceDirectory(id: workspace.id, name: workspace.name) + } + + package func invalidate(url: URL) { + let path = url.standardizedFileURL.path + decodeCache = decodeCache.filter { $0.key.standardizedPath != path } + } + + #if DEBUG + package func removeAllCachedDocumentsForTesting() { + decodeCache.removeAll() + } + #endif + + private func flushIndex(_ receipt: WorkspaceWriteReceipt, root: URL) async throws { + let completion = await flush(receipt) + if let errorDescription = completion.errorDescription { + throw CocoaError(.fileWriteUnknown, userInfo: [NSLocalizedDescriptionKey: errorDescription]) + } + let rootKey = root.standardizedFileURL + if pendingIndexStateByRoot[rootKey]?.latestReceipt == receipt { + pendingIndexStateByRoot.removeValue(forKey: rootKey) + } + } + + private func documentURL(for entry: WorkspaceIndexEntry, layout: FixedWorkspaceRepositoryLayout) -> URL { + entry.customStoragePath?.appendingPathComponent("workspace.json") ?? + layout.workspaceDocumentURL(id: entry.id, name: entry.name) + } + + private func decodeCacheKey(for url: URL) throws -> DecodeCacheKey { + let standardized = url.standardizedFileURL + let values = try standardized.resourceValues(forKeys: [.fileSizeKey, .contentModificationDateKey]) + guard let fileSize = values.fileSize, let modificationDate = values.contentModificationDate else { + throw CocoaError(.fileReadUnknown) + } + return DecodeCacheKey( + standardizedPath: standardized.path, + fileSize: Int64(fileSize), + modificationDate: modificationDate + ) + } + + private func ensureWorkspaceDirectory(_ directory: URL) throws { + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + try FileManager.default.createDirectory( + at: directory.appendingPathComponent("Chats", isDirectory: true), + withIntermediateDirectories: true + ) + } + + private func activeSelection(in workspace: WorkspaceModel) -> StoredSelection? { + guard let activeID = workspace.activeComposeTabID else { return nil } + return workspace.composeTabs.first(where: { $0.id == activeID })?.selection + } +} diff --git a/Sources/RepoPromptCore/Workspaces/WorkspaceRepositoryContracts.swift b/Sources/RepoPromptCore/Workspaces/WorkspaceRepositoryContracts.swift new file mode 100644 index 000000000..d8e7cf91a --- /dev/null +++ b/Sources/RepoPromptCore/Workspaces/WorkspaceRepositoryContracts.swift @@ -0,0 +1,130 @@ +import Foundation + +package protocol WorkspaceRepositoryRootProviding: Sendable { + func repositoryRoot() async -> URL +} + +package protocol WorkspaceRepositoryLayout: Sendable { + var repositoryRoot: URL { get } + var indexURL: URL { get } + + func workspaceDirectory(id: UUID, name: String) -> URL + func workspaceDocumentURL(id: UUID, name: String) -> URL +} + +package struct FixedWorkspaceRepositoryLayout: WorkspaceRepositoryLayout { + package let repositoryRoot: URL + package var indexURL: URL { + repositoryRoot.appendingPathComponent("workspacesIndex.json") + } + + package init(repositoryRoot: URL) { + self.repositoryRoot = repositoryRoot + } + + package func workspaceDirectory(id: UUID, name: String) -> URL { + repositoryRoot.appendingPathComponent("Workspace-\(name)-\(id.uuidString)", isDirectory: true) + } + + package func workspaceDocumentURL(id: UUID, name: String) -> URL { + workspaceDirectory(id: id, name: name).appendingPathComponent("workspace.json") + } +} + +package enum WorkspaceRepositoryDiagnostic: Equatable { + case warning(code: String, message: String) + case recovery(code: String, message: String) + case event(name: String, fields: [String: String]) +} + +package protocol WorkspaceRepositoryDiagnosticsSink: Sendable { + func record(_ diagnostic: WorkspaceRepositoryDiagnostic) +} + +package struct NoopWorkspaceRepositoryDiagnosticsSink: WorkspaceRepositoryDiagnosticsSink { + package init() {} + package func record(_: WorkspaceRepositoryDiagnostic) {} +} + +package struct WorkspaceIndexEntry: Codable, Equatable { + package let id: UUID + package var name: String + package var customStoragePath: URL? + package var isSystemWorkspace: Bool + package var isHiddenInMenus: Bool + + package init( + id: UUID, + name: String, + customStoragePath: URL?, + isSystemWorkspace: Bool, + isHiddenInMenus: Bool + ) { + self.id = id + self.name = name + self.customStoragePath = customStoragePath + self.isSystemWorkspace = isSystemWorkspace + self.isHiddenInMenus = isHiddenInMenus + } + + package init(workspace: WorkspaceModel) { + self.init( + id: workspace.id, + name: workspace.name, + customStoragePath: workspace.customStoragePath, + isSystemWorkspace: workspace.isSystemWorkspace, + isHiddenInMenus: workspace.isHiddenInMenus + ) + } +} + +package struct WorkspaceRepositoryInventory { + package let entries: [WorkspaceIndexEntry] + package let workspaces: [WorkspaceModel] + package let decodeResults: [UUID: WorkspaceDocumentDecodeResult] + + package init( + entries: [WorkspaceIndexEntry], + workspaces: [WorkspaceModel], + decodeResults: [UUID: WorkspaceDocumentDecodeResult] = [:] + ) { + self.entries = entries + self.workspaces = workspaces + self.decodeResults = decodeResults + } +} + +package struct WorkspaceWriteReceipt: Hashable { + package let url: URL + package let sequence: UInt64 + + package init(url: URL, sequence: UInt64) { + self.url = url + self.sequence = sequence + } +} + +package struct WorkspaceWriteCompletion { + package let receipt: WorkspaceWriteReceipt + package let errorDescription: String? + + package var succeeded: Bool { + errorDescription == nil + } + + package init(receipt: WorkspaceWriteReceipt, errorDescription: String?) { + self.receipt = receipt + self.errorDescription = errorDescription + } +} + +/// Persistence boundary adopted by the canonical app workspace domain. +package protocol WorkspaceRepositoryContract: Sendable { + associatedtype Document: Identifiable & Sendable where Document.ID == UUID + + func list() async throws -> [Document] + func load(id: UUID) async throws -> Document? + func save(_ document: Document) async throws + func delete(id: UUID) async throws + func migrateLegacyHeadlessProfileIfNeeded() async throws -> WorkspaceLegacyMigrationResult +} diff --git a/Sources/RepoPrompt/Features/Workspaces/WorkspaceRootActions.swift b/Sources/RepoPromptCore/Workspaces/WorkspaceRootActions.swift similarity index 91% rename from Sources/RepoPrompt/Features/Workspaces/WorkspaceRootActions.swift rename to Sources/RepoPromptCore/Workspaces/WorkspaceRootActions.swift index a0296b756..0c066a80c 100644 --- a/Sources/RepoPrompt/Features/Workspaces/WorkspaceRootActions.swift +++ b/Sources/RepoPromptCore/Workspaces/WorkspaceRootActions.swift @@ -1,12 +1,12 @@ import Foundation -enum WorkspaceRootMoveDirection { +package enum WorkspaceRootMoveDirection { case up case down } -enum WorkspaceRootActions { - static func movedRepoPaths( +package enum WorkspaceRootActions { + package static func movedRepoPaths( repoPaths: [String], movingRootPath: String, direction: WorkspaceRootMoveDirection, @@ -48,7 +48,7 @@ enum WorkspaceRootActions { return moved } - static func standardizedUniqueRepoPaths(_ repoPaths: [String]) -> [String] { + package static func standardizedUniqueRepoPaths(_ repoPaths: [String]) -> [String] { var seen = Set() var uniquePaths: [String] = [] for path in repoPaths { diff --git a/Sources/RepoPromptCore/Workspaces/WorkspaceSaveMetadata.swift b/Sources/RepoPromptCore/Workspaces/WorkspaceSaveMetadata.swift new file mode 100644 index 000000000..f39e215b6 --- /dev/null +++ b/Sources/RepoPromptCore/Workspaces/WorkspaceSaveMetadata.swift @@ -0,0 +1,143 @@ +import Foundation + +package struct WorkspaceSaveSource: Equatable, Hashable, ExpressibleByStringLiteral, CustomStringConvertible { + package let rawValue: String + + package init(_ rawValue: String) { + self.rawValue = rawValue + } + + package init(stringLiteral value: StringLiteralType) { + rawValue = value + } + + package var description: String { + rawValue + } +} + +package struct WorkspaceSaveOwner: Equatable, Hashable { + package let windowID: Int? + package let managerID: UUID? + + package init(windowID: Int?, managerID: UUID?) { + self.windowID = windowID + self.managerID = managerID + } + + package static let none = WorkspaceSaveOwner(windowID: nil, managerID: nil) +} + +package struct WorkspaceTabSelectionKey: Hashable { + package let workspaceID: UUID + package let tabID: UUID + + package init(workspaceID: UUID, tabID: UUID) { + self.workspaceID = workspaceID + self.tabID = tabID + } +} + +package struct WorkspaceSaveSelectionRecord: Equatable { + package let tabID: UUID + package let revision: UInt64 + package let selection: StoredSelection + + package init(tabID: UUID, revision: UInt64, selection: StoredSelection) { + self.tabID = tabID + self.revision = revision + self.selection = selection + } + + package func key(workspaceID: UUID) -> WorkspaceTabSelectionKey { + WorkspaceTabSelectionKey(workspaceID: workspaceID, tabID: tabID) + } +} + +package struct WorkspaceSaveSelectionSummary: Equatable { + package let tabID: UUID? + package let selectedPaths: Int + package let autoCodemapPaths: Int + package let sliceFiles: Int + package let sliceRanges: Int + package let codemapAutoEnabled: Bool + + package init(tabID: UUID?, selection: StoredSelection?) { + self.tabID = tabID + selectedPaths = selection?.selectedPaths.count ?? 0 + autoCodemapPaths = selection?.autoCodemapPaths.count ?? 0 + sliceFiles = selection?.slices.count ?? 0 + sliceRanges = selection?.slices.values.reduce(0) { $0 + $1.count } ?? 0 + codemapAutoEnabled = selection?.codemapAutoEnabled ?? true + } +} + +package struct WorkspaceSavePayloadMetadata: Equatable { + package let payloadID: UUID + package let source: WorkspaceSaveSource + package let owner: WorkspaceSaveOwner + package let workspaceID: UUID + package let workspaceName: String + package let workspaceDateModified: Date + package let activeTabID: UUID? + package let activeSelectionRevision: UInt64 + package let activeSelection: StoredSelection? + package let selectionRecords: [WorkspaceSaveSelectionRecord] + package let selectionSummary: WorkspaceSaveSelectionSummary + package let createdAt: Date + + package init( + payloadID: UUID = UUID(), + source: WorkspaceSaveSource, + owner: WorkspaceSaveOwner, + workspaceID: UUID, + workspaceName: String, + workspaceDateModified: Date, + activeTabID: UUID?, + activeSelectionRevision: UInt64, + activeSelection: StoredSelection?, + selectionRecords: [WorkspaceSaveSelectionRecord]? = nil, + createdAt: Date = Date() + ) { + self.payloadID = payloadID + self.source = source + self.owner = owner + self.workspaceID = workspaceID + self.workspaceName = workspaceName + self.workspaceDateModified = workspaceDateModified + self.activeTabID = activeTabID + self.activeSelectionRevision = activeSelectionRevision + self.activeSelection = activeSelection + self.selectionRecords = selectionRecords ?? { + guard let activeTabID, let activeSelection else { return [] } + return [WorkspaceSaveSelectionRecord( + tabID: activeTabID, + revision: activeSelectionRevision, + selection: activeSelection + )] + }() + selectionSummary = WorkspaceSaveSelectionSummary(tabID: activeTabID, selection: activeSelection) + self.createdAt = createdAt + } + + package var selectionKey: WorkspaceTabSelectionKey? { + guard let activeTabID else { return nil } + return WorkspaceTabSelectionKey(workspaceID: workspaceID, tabID: activeTabID) + } +} + +package enum WorkspaceSelectionSaveOwner: String, Equatable { + case canonicalCoordinator + case storedComposeTab + case legacyLiveUI +} + +package struct WorkspaceSelectionForSaveDecision: Equatable { + package let selection: StoredSelection + package let owner: WorkspaceSelectionSaveOwner + + package init(selection: StoredSelection, owner: WorkspaceSelectionSaveOwner) { + self.selection = selection + self.owner = owner + } +} diff --git a/Sources/RepoPromptCore/Workspaces/WorkspaceSessionController.swift b/Sources/RepoPromptCore/Workspaces/WorkspaceSessionController.swift new file mode 100644 index 000000000..674f880c4 --- /dev/null +++ b/Sources/RepoPromptCore/Workspaces/WorkspaceSessionController.swift @@ -0,0 +1,501 @@ +import Foundation + +package struct WorkspaceSessionBindingCandidate: Equatable { + package let tabID: UUID + package let workspaceID: UUID + package let workspaceName: String + package let isActiveInWorkspace: Bool + package let repoPaths: [String] + + package init( + tabID: UUID, + workspaceID: UUID, + workspaceName: String, + isActiveInWorkspace: Bool, + repoPaths: [String] + ) { + self.tabID = tabID + self.workspaceID = workspaceID + self.workspaceName = workspaceName + self.isActiveInWorkspace = isActiveInWorkspace + self.repoPaths = repoPaths + } +} + +package struct WorkspaceSessionSnapshot: Equatable { + package let generation: UInt64 + package let workspaces: [WorkspaceModel] + package let activeWorkspaceID: UUID? + package let indexEntries: [WorkspaceIndexEntry] + + package init( + generation: UInt64, + workspaces: [WorkspaceModel], + activeWorkspaceID: UUID?, + indexEntries: [WorkspaceIndexEntry] + ) { + self.generation = generation + self.workspaces = workspaces + self.activeWorkspaceID = activeWorkspaceID + self.indexEntries = indexEntries + } + + package var activeWorkspace: WorkspaceModel? { + guard let activeWorkspaceID else { return nil } + return workspaces.first(where: { $0.id == activeWorkspaceID }) + } +} + +package struct WorkspaceSessionMutationOptions { + package var touchDateModified: Bool + package var markDirty: Bool + package var recordsSelectionRevisions: Bool + + package init( + touchDateModified: Bool = true, + markDirty: Bool = true, + recordsSelectionRevisions: Bool = true + ) { + self.touchDateModified = touchDateModified + self.markDirty = markDirty + self.recordsSelectionRevisions = recordsSelectionRevisions + } + + package static let hydration = WorkspaceSessionMutationOptions( + touchDateModified: false, + markDirty: false, + recordsSelectionRevisions: false + ) + package static let storedOnly = WorkspaceSessionMutationOptions(touchDateModified: false, markDirty: true) +} + +package struct WorkspaceSessionTransaction { + package var workspaces: [WorkspaceModel] + package var activeWorkspaceID: UUID? + + package init(workspaces: [WorkspaceModel], activeWorkspaceID: UUID?) { + self.workspaces = workspaces + self.activeWorkspaceID = activeWorkspaceID + } + + package mutating func workspaceIndex(id: UUID) -> Int? { + workspaces.lastIndex(where: { $0.id == id }) + } +} + +@MainActor +package final class WorkspaceSessionObservationToken { + private var cancelAction: (() -> Void)? + + init(cancelAction: @escaping () -> Void) { + self.cancelAction = cancelAction + } + + package func cancel() { + cancelAction?() + cancelAction = nil + } + + deinit { + MainActor.assumeIsolated { cancel() } + } +} + +@MainActor +package final class WorkspaceSessionController { + package typealias Observer = @MainActor (WorkspaceSessionSnapshot) -> Void + + package let repository: WorkspaceRepository + package let persistenceWriter: WorkspacePersistenceWriter + private let accessPolicy: any WorkspaceAccessPolicy + + private var orderedWorkspaces: [WorkspaceModel] = [] + private var activeID: UUID? + private var workspaceIndexMap: [UUID: Int] = [:] + private var snapshotGeneration: UInt64 = 0 + private var stateGenerationByWorkspaceID: [UUID: UInt64] = [:] + private var savedGenerationByWorkspaceID: [UUID: UInt64] = [:] + private var repoPathBaselineByWorkspaceID: [UUID: [String]] = [:] + private var selectionRevisionByKey: [WorkspaceTabSelectionKey: UInt64] = [:] + private var observers: [UUID: Observer] = [:] + + package init( + repository: WorkspaceRepository, + persistenceWriter: WorkspacePersistenceWriter, + accessPolicy: any WorkspaceAccessPolicy + ) { + self.repository = repository + self.persistenceWriter = persistenceWriter + self.accessPolicy = accessPolicy + } + + package var snapshot: WorkspaceSessionSnapshot { + WorkspaceSessionSnapshot( + generation: snapshotGeneration, + workspaces: orderedWorkspaces, + activeWorkspaceID: activeID, + indexEntries: orderedWorkspaces.filter { !$0.isEphemeral }.map(WorkspaceIndexEntry.init(workspace:)) + ) + } + + package var workspaces: [WorkspaceModel] { + orderedWorkspaces + } + + package var activeWorkspaceID: UUID? { + activeID + } + + package var activeWorkspace: WorkspaceModel? { + guard let activeID, let index = workspaceIndexMap[activeID] else { return nil } + return orderedWorkspaces[index] + } + + package func workspace(id: UUID) -> WorkspaceModel? { + workspaceIndexMap[id].map { orderedWorkspaces[$0] } + } + + package func composeTab(with id: UUID) -> ComposeTabState? { + for workspace in orderedWorkspaces { + if let tab = workspace.composeTabs.first(where: { $0.id == id }) { return tab } + } + return nil + } + + package func replaceAll( + _ workspaces: [WorkspaceModel], + activeWorkspaceID: UUID?, + repositoryBaselines: [UUID: [String]]? = nil + ) { + let previous = orderedWorkspaces + let previousSelectionRevisions = selectionRevisionByKey + orderedWorkspaces = workspaces + activeID = activeWorkspaceID.flatMap { id in workspaces.contains(where: { $0.id == id }) ? id : nil } + if activeID == nil { activeID = workspaces.first?.id } + rebuildIndexMap() + stateGenerationByWorkspaceID = Dictionary( + workspaces.map { ($0.id, stateGenerationByWorkspaceID[$0.id, default: 0]) }, + uniquingKeysWith: { _, last in last } + ) + savedGenerationByWorkspaceID = Dictionary( + workspaces.map { ($0.id, stateGenerationByWorkspaceID[$0.id, default: 0]) }, + uniquingKeysWith: { _, last in last } + ) + repoPathBaselineByWorkspaceID = repositoryBaselines ?? Dictionary( + workspaces.map { ($0.id, $0.repoPaths) }, + uniquingKeysWith: { _, last in last } + ) + selectionRevisionByKey = preservedHydrationSelectionRevisions( + previous: previous, + current: workspaces, + previousRevisions: previousSelectionRevisions + ) + publish() + } + + package func setActiveWorkspaceID(_ id: UUID?) { + let resolved = id.flatMap { workspaceIndexMap[$0] == nil ? nil : $0 } + guard activeID != resolved else { return } + activeID = resolved + publish() + } + + @discardableResult + package func mutateWorkspace( + id: UUID, + options: WorkspaceSessionMutationOptions = WorkspaceSessionMutationOptions(), + _ mutation: (inout WorkspaceModel) -> Void + ) -> WorkspaceModel? { + guard let index = workspaceIndexMap[id] else { return nil } + let old = orderedWorkspaces[index] + var updated = old + mutation(&updated) + if options.touchDateModified { updated.dateModified = Date() } + guard updated != old else { return old } + orderedWorkspaces[index] = updated + noteMutation( + workspaceID: id, + markDirty: options.markDirty, + recordsSelectionRevisions: options.recordsSelectionRevisions, + previous: old, + current: updated + ) + publish() + return updated + } + + @discardableResult + package func mutateActiveWorkspace( + options: WorkspaceSessionMutationOptions = WorkspaceSessionMutationOptions(), + _ mutation: (inout WorkspaceModel) -> Void + ) -> WorkspaceModel? { + guard let activeID else { return nil } + return mutateWorkspace(id: activeID, options: options, mutation) + } + + @discardableResult + package func mutateComposeTab( + workspaceID: UUID, + tabID: UUID, + options: WorkspaceSessionMutationOptions = WorkspaceSessionMutationOptions(), + _ mutation: (inout ComposeTabState) -> Void + ) -> ComposeTabState? { + var result: ComposeTabState? + _ = mutateWorkspace(id: workspaceID, options: options) { workspace in + guard let tabIndex = workspace.composeTabs.firstIndex(where: { $0.id == tabID }) else { return } + mutation(&workspace.composeTabs[tabIndex]) + result = workspace.composeTabs[tabIndex] + } + return result + } + + package func transaction( + options: WorkspaceSessionMutationOptions = WorkspaceSessionMutationOptions(), + _ mutation: (inout WorkspaceSessionTransaction) -> Void + ) { + let previous = orderedWorkspaces + let previousActiveID = activeID + var transaction = WorkspaceSessionTransaction(workspaces: previous, activeWorkspaceID: previousActiveID) + mutation(&transaction) + if options.touchDateModified { + let oldByID = Dictionary(previous.map { ($0.id, $0) }, uniquingKeysWith: { _, last in last }) + for index in transaction.workspaces.indices where oldByID[transaction.workspaces[index].id] != transaction.workspaces[index] { + transaction.workspaces[index].dateModified = Date() + } + } + guard transaction.workspaces != previous || transaction.activeWorkspaceID != previousActiveID else { return } + orderedWorkspaces = transaction.workspaces + activeID = transaction.activeWorkspaceID.flatMap { id in orderedWorkspaces.contains(where: { $0.id == id }) ? id : nil } + rebuildIndexMap() + let oldByID = Dictionary(previous.map { ($0.id, $0) }, uniquingKeysWith: { _, last in last }) + for workspace in orderedWorkspaces where oldByID[workspace.id] != workspace { + noteMutation( + workspaceID: workspace.id, + markDirty: options.markDirty, + recordsSelectionRevisions: options.recordsSelectionRevisions, + previous: oldByID[workspace.id], + current: workspace + ) + } + let currentIDs = Set(orderedWorkspaces.map(\.id)) + stateGenerationByWorkspaceID = stateGenerationByWorkspaceID.filter { currentIDs.contains($0.key) } + savedGenerationByWorkspaceID = savedGenerationByWorkspaceID.filter { currentIDs.contains($0.key) } + repoPathBaselineByWorkspaceID = repoPathBaselineByWorkspaceID.filter { currentIDs.contains($0.key) } + publish() + } + + package func markDirty(workspaceID: UUID) { + guard workspaceIndexMap[workspaceID] != nil else { return } + stateGenerationByWorkspaceID[workspaceID, default: 0] &+= 1 + } + + package func stateGeneration(workspaceID: UUID) -> UInt64 { + stateGenerationByWorkspaceID[workspaceID, default: 0] + } + + package func isDirty(workspaceID: UUID) -> Bool { + stateGenerationByWorkspaceID[workspaceID, default: 0] != savedGenerationByWorkspaceID[workspaceID, default: 0] + } + + package func recordSaveCompletion( + workspaceID: UUID, + capturedGeneration: UInt64, + persistedWorkspace: WorkspaceModel + ) { + guard stateGenerationByWorkspaceID[workspaceID, default: 0] == capturedGeneration else { return } + repoPathBaselineByWorkspaceID[workspaceID] = persistedWorkspace.repoPaths + savedGenerationByWorkspaceID[workspaceID] = capturedGeneration + } + + package func recordRepositoryBaseline(_ workspace: WorkspaceModel) { + repoPathBaselineByWorkspaceID[workspace.id] = workspace.repoPaths + } + + package func repositoryBaseline(workspaceID: UUID) -> [String]? { + repoPathBaselineByWorkspaceID[workspaceID] + } + + package func hasLocalRepoPathEdit(workspaceID: UUID) -> Bool { + guard let workspace = workspace(id: workspaceID), let baseline = repoPathBaselineByWorkspaceID[workspaceID] else { + return true + } + return Self.normalizedPaths(workspace.repoPaths) != Self.normalizedPaths(baseline) + } + + package func selectionRevision(workspaceID: UUID, tabID: UUID) -> UInt64 { + selectionRevisionByKey[WorkspaceTabSelectionKey(workspaceID: workspaceID, tabID: tabID), default: 0] + } + + package func recordExternallyCommittedSelectionRevision( + workspaceID: UUID, + tabID: UUID, + previous: StoredSelection, + current: StoredSelection + ) { + guard previous != current, + workspace(id: workspaceID)?.composeTabs.first(where: { $0.id == tabID })?.selection == current + else { return } + selectionRevisionByKey[WorkspaceTabSelectionKey(workspaceID: workspaceID, tabID: tabID)] = + persistenceWriter.allocateSelectionRevision() + } + + package func saveMetadata( + for workspace: WorkspaceModel, + source: WorkspaceSaveSource, + owner: WorkspaceSaveOwner + ) -> WorkspaceSavePayloadMetadata { + let activeTab = workspace.activeComposeTabID.flatMap { id in workspace.composeTabs.first(where: { $0.id == id }) } + let revision = activeTab.map { selectionRevision(workspaceID: workspace.id, tabID: $0.id) } ?? 0 + let selectionRecords = workspace.composeTabs.map { tab in + WorkspaceSaveSelectionRecord( + tabID: tab.id, + revision: selectionRevision(workspaceID: workspace.id, tabID: tab.id), + selection: tab.selection + ) + } + return WorkspaceSavePayloadMetadata( + source: source, + owner: owner, + workspaceID: workspace.id, + workspaceName: workspace.name, + workspaceDateModified: workspace.dateModified, + activeTabID: activeTab?.id, + activeSelectionRevision: revision, + activeSelection: activeTab?.selection, + selectionRecords: selectionRecords + ) + } + + package func observe(_ observer: @escaping Observer) -> WorkspaceSessionObservationToken { + let id = UUID() + observers[id] = observer + observer(snapshot) + return WorkspaceSessionObservationToken { [weak self] in self?.observers.removeValue(forKey: id) } + } + + package func bindingCandidate(forContextID id: UUID) -> WorkspaceSessionBindingCandidate? { + for workspace in orderedWorkspaces { + guard let tab = workspace.composeTabs.first(where: { $0.id == id }) else { continue } + return applyingAccessPolicy( + WorkspaceSessionBindingCandidate( + tabID: tab.id, + workspaceID: workspace.id, + workspaceName: workspace.name, + isActiveInWorkspace: workspace.activeComposeTabID == tab.id, + repoPaths: workspace.repoPaths + ) + ) + } + return nil + } + + package func bindingCandidates( + matchingWorkingDirs dirs: [String], + includeHidden: Bool = false + ) -> [WorkspaceSessionBindingCandidate] { + let normalizedDirs = dirs.map(Self.normalizePath).filter { !$0.isEmpty } + guard !normalizedDirs.isEmpty, + let workspace = activeWorkspace, + includeHidden || !workspace.isHiddenInMenus, + Self.workspaceMatches(workspace, normalizedDirs: normalizedDirs), + let tab = workspace.composeTabs.first(where: { $0.id == workspace.activeComposeTabID }) ?? workspace.composeTabs.first + else { return [] } + return [applyingAccessPolicy(WorkspaceSessionBindingCandidate( + tabID: tab.id, + workspaceID: workspace.id, + workspaceName: workspace.name, + isActiveInWorkspace: workspace.activeComposeTabID == tab.id, + repoPaths: workspace.repoPaths + ))] + } + + private func rebuildIndexMap() { + workspaceIndexMap = Dictionary( + orderedWorkspaces.enumerated().map { ($0.element.id, $0.offset) }, + uniquingKeysWith: { _, last in last } + ) + } + + private func noteMutation( + workspaceID: UUID, + markDirty: Bool, + recordsSelectionRevisions: Bool, + previous: WorkspaceModel?, + current: WorkspaceModel + ) { + if markDirty { stateGenerationByWorkspaceID[workspaceID, default: 0] &+= 1 } + if recordsSelectionRevisions, let previous { + recordSelectionRevisions(previous: [previous], current: [current]) + } + } + + private func recordSelectionRevisions(previous: [WorkspaceModel], current: [WorkspaceModel]) { + let previousByID = Dictionary(previous.map { ($0.id, $0) }, uniquingKeysWith: { _, last in last }) + for workspace in current { + let oldTabs = Dictionary( + (previousByID[workspace.id]?.composeTabs ?? []).map { ($0.id, $0.selection) }, + uniquingKeysWith: { _, last in last } + ) + for tab in workspace.composeTabs where oldTabs[tab.id] != tab.selection { + let key = WorkspaceTabSelectionKey(workspaceID: workspace.id, tabID: tab.id) + selectionRevisionByKey[key] = persistenceWriter.allocateSelectionRevision() + } + } + } + + private func preservedHydrationSelectionRevisions( + previous: [WorkspaceModel], + current: [WorkspaceModel], + previousRevisions: [WorkspaceTabSelectionKey: UInt64] + ) -> [WorkspaceTabSelectionKey: UInt64] { + let previousByID = Dictionary(previous.map { ($0.id, $0) }, uniquingKeysWith: { _, last in last }) + var preserved: [WorkspaceTabSelectionKey: UInt64] = [:] + for workspace in current { + let oldSelections = Dictionary( + (previousByID[workspace.id]?.composeTabs ?? []).map { ($0.id, $0.selection) }, + uniquingKeysWith: { _, last in last } + ) + for tab in workspace.composeTabs where oldSelections[tab.id] == tab.selection { + let key = WorkspaceTabSelectionKey(workspaceID: workspace.id, tabID: tab.id) + if let revision = previousRevisions[key] { preserved[key] = revision } + } + } + return preserved + } + + private func publish() { + snapshotGeneration &+= 1 + let value = snapshot + let callbacks = Array(observers.values) + for observer in callbacks { + observer(value) + } + } + + private func applyingAccessPolicy(_ candidate: WorkspaceSessionBindingCandidate) -> WorkspaceSessionBindingCandidate { + WorkspaceSessionBindingCandidate( + tabID: candidate.tabID, + workspaceID: candidate.workspaceID, + workspaceName: candidate.workspaceName, + isActiveInWorkspace: candidate.isActiveInWorkspace, + repoPaths: candidate.repoPaths.filter { accessPolicy.allowsWorkspaceRoot(URL(fileURLWithPath: $0)) } + ) + } + + private nonisolated static func normalizedPaths(_ paths: [String]) -> [String] { + paths.map { (($0 as NSString).standardizingPath).lowercased() } + } + + private nonisolated static func normalizePath(_ raw: String) -> String { + let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return "" } + return URL(fileURLWithPath: (trimmed as NSString).expandingTildeInPath).standardizedFileURL.path + } + + private nonisolated static func workspaceMatches(_ workspace: WorkspaceModel, normalizedDirs: [String]) -> Bool { + let roots = workspace.repoPaths.map(normalizePath).filter { !$0.isEmpty } + return !roots.isEmpty && normalizedDirs.allSatisfy { directory in + roots.contains { root in directory == root || directory.hasPrefix(root.hasSuffix("/") ? root : root + "/") } + } + } +} diff --git a/Sources/RepoPromptCoreMacOS/FileSystem/MacOSFSEventsWatcher.swift b/Sources/RepoPromptCoreMacOS/FileSystem/MacOSFSEventsWatcher.swift new file mode 100644 index 000000000..571e8f108 --- /dev/null +++ b/Sources/RepoPromptCoreMacOS/FileSystem/MacOSFSEventsWatcher.swift @@ -0,0 +1,451 @@ +import CoreFoundation +import CoreServices +import Dispatch +import Foundation +import RepoPromptCore + +package protocol MacOSFSEventStreamToken: AnyObject, Sendable {} + +package protocol MacOSFSEventStreamBackend: Sendable { + func createStream( + path: String, + callback: FSEventStreamCallback, + contextInfo: UnsafeMutableRawPointer, + callbackQueue: DispatchQueue + ) -> (any MacOSFSEventStreamToken)? + + func startStream(_ stream: any MacOSFSEventStreamToken) -> Bool + + func disposeStream(_ stream: any MacOSFSEventStreamToken, wasStarted: Bool) +} + +private final class CoreServicesFSEventStreamToken: MacOSFSEventStreamToken, @unchecked Sendable { + let stream: FSEventStreamRef + + init(stream: FSEventStreamRef) { + self.stream = stream + } +} + +private struct CoreServicesFSEventStreamBackend: MacOSFSEventStreamBackend { + func createStream( + path: String, + callback: FSEventStreamCallback, + contextInfo: UnsafeMutableRawPointer, + callbackQueue: DispatchQueue + ) -> (any MacOSFSEventStreamToken)? { + var streamContext = FSEventStreamContext( + version: 0, + info: contextInfo, + retain: nil, + release: nil, + copyDescription: nil + ) + let createFlags = FSEventStreamCreateFlags( + kFSEventStreamCreateFlagUseCFTypes + | kFSEventStreamCreateFlagFileEvents + | kFSEventStreamCreateFlagNoDefer + ) + guard let stream = FSEventStreamCreate( + kCFAllocatorDefault, + callback, + &streamContext, + [path] as CFArray, + FSEventStreamEventId(kFSEventStreamEventIdSinceNow), + 0, + createFlags + ) else { return nil } + + FSEventStreamSetDispatchQueue(stream, callbackQueue) + return CoreServicesFSEventStreamToken(stream: stream) + } + + func startStream(_ stream: any MacOSFSEventStreamToken) -> Bool { + guard let token = stream as? CoreServicesFSEventStreamToken else { + assertionFailure("Unexpected FSEvents stream token type") + return false + } + return FSEventStreamStart(token.stream) + } + + func disposeStream(_ stream: any MacOSFSEventStreamToken, wasStarted: Bool) { + guard let token = stream as? CoreServicesFSEventStreamToken else { + assertionFailure("Unexpected FSEvents stream token type") + return + } + if wasStarted { + FSEventStreamStop(token.stream) + FSEventStreamFlushSync(token.stream) + } + FSEventStreamInvalidate(token.stream) + FSEventStreamRelease(token.stream) + } +} + +/// macOS adapter for CoreServices FSEvents lifecycle and raw-flag translation. +/// +/// Reusable filesystem policy receives only deep-copied, semantic `FileSystemWatchEvent` values. +/// Stream references, callback context retention, and native bit mapping remain adapter-owned. +package final class MacOSFSEventsWatcher: FileSystemWatching, @unchecked Sendable { + private let path: String + private let callbackQueue: DispatchQueue + private let disposalQueue: DispatchQueue + private let streamBackend: any MacOSFSEventStreamBackend + private let callbackQueueToken = CallbackQueueToken() + private let condition = NSCondition() + private var lifecycleState: LifecycleState = .idle + private var nextGeneration: UInt64 = 0 + + package convenience init(path: String) { + self.init(path: path, streamBackend: CoreServicesFSEventStreamBackend()) + } + + package init(path: String, streamBackend: any MacOSFSEventStreamBackend) { + self.path = path + self.streamBackend = streamBackend + callbackQueue = DispatchQueue( + label: "com.repoprompt.filesystem.fsevents.\(UUID().uuidString)", + qos: .utility + ) + disposalQueue = DispatchQueue( + label: "com.repoprompt.filesystem.fsevents.dispose.\(UUID().uuidString)", + qos: .utility + ) + callbackQueue.setSpecific(key: Self.callbackQueueKey, value: callbackQueueToken) + } + + package var isWatching: Bool { + condition.lock() + defer { condition.unlock() } + if case .watching = lifecycleState { + return true + } + return false + } + + @discardableResult + package func start(eventHandler: @escaping @Sendable (FileSystemWatchEventPayload) -> Void) -> Bool { + while true { + condition.lock() + switch lifecycleState { + case .idle: + let generation = nextGenerationLocked() + let context = CallbackContext(watcher: self, generation: generation) + let attempt = StartAttempt( + generation: generation, + handler: eventHandler, + context: context + ) + lifecycleState = .starting(attempt) + condition.unlock() + return runStartAttempt(attempt) + case .watching: + condition.unlock() + return true + case .starting: + condition.wait() + condition.unlock() + } + } + } + + package func stop() { + let disposal: StreamDisposal? + condition.lock() + switch lifecycleState { + case .idle: + condition.unlock() + return + case .starting: + lifecycleState = .idle + condition.broadcast() + condition.unlock() + return + case let .watching(activeStream): + lifecycleState = .idle + condition.broadcast() + disposal = StreamDisposal( + stream: activeStream.stream, + wasStarted: true, + context: activeStream.context + ) + condition.unlock() + } + + if let disposal { + disposeStream(disposal) + } + } + + deinit { + stop() + } + + private func runStartAttempt(_ attempt: StartAttempt) -> Bool { + guard let stream = streamBackend.createStream( + path: path, + callback: Self.callback, + contextInfo: Unmanaged.passUnretained(attempt.context).toOpaque(), + callbackQueue: callbackQueue + ) else { + clearStartingAttemptIfCurrent(attempt) + print("Failed to create FSEventStream for \(path)") + return false + } + + guard isStartingAttemptCurrent(attempt) else { + disposeStream(StreamDisposal( + stream: stream, + wasStarted: false, + context: attempt.context + )) + return false + } + + guard streamBackend.startStream(stream) else { + clearStartingAttemptIfCurrent(attempt) + disposeStream(StreamDisposal( + stream: stream, + wasStarted: false, + context: attempt.context + )) + print("Failed to start FSEventStream for \(path)") + return false + } + + condition.lock() + let shouldCommit: Bool + if case let .starting(currentAttempt) = lifecycleState, + currentAttempt.generation == attempt.generation + { + lifecycleState = .watching(ActiveStream( + generation: attempt.generation, + handler: attempt.handler, + stream: stream, + context: attempt.context + )) + condition.broadcast() + shouldCommit = true + } else { + shouldCommit = false + } + condition.unlock() + + if shouldCommit { + return true + } + + disposeStream(StreamDisposal( + stream: stream, + wasStarted: true, + context: attempt.context + )) + return false + } + + @discardableResult + private func clearStartingAttemptIfCurrent(_ attempt: StartAttempt) -> Bool { + condition.lock() + defer { condition.unlock() } + guard case let .starting(currentAttempt) = lifecycleState, + currentAttempt.generation == attempt.generation + else { return false } + lifecycleState = .idle + condition.broadcast() + return true + } + + private func isStartingAttemptCurrent(_ attempt: StartAttempt) -> Bool { + condition.lock() + defer { condition.unlock() } + guard case let .starting(currentAttempt) = lifecycleState else { + return false + } + return currentAttempt.generation == attempt.generation + } + + private func accept(_ payload: FileSystemWatchEventPayload, generation: UInt64) { + let handler: (@Sendable (FileSystemWatchEventPayload) -> Void)? + condition.lock() + switch lifecycleState { + case let .starting(attempt) where attempt.generation == generation: + handler = attempt.handler + case let .watching(activeStream) where activeStream.generation == generation: + handler = activeStream.handler + case .idle, .starting, .watching: + handler = nil + } + condition.unlock() + + handler?(payload) + } + + private func disposeStream(_ disposal: StreamDisposal) { + let streamBackend = streamBackend + let disposalQueue = disposalQueue + let dispose: @Sendable () -> Void = { + withExtendedLifetime(disposal.context) { + streamBackend.disposeStream(disposal.stream, wasStarted: disposal.wasStarted) + } + } + + if DispatchQueue.getSpecific(key: Self.callbackQueueKey) === callbackQueueToken { + disposalQueue.async(execute: dispose) + } else { + dispose() + } + } + + private func nextGenerationLocked() -> UInt64 { + nextGeneration &+= 1 + if nextGeneration == 0 { + nextGeneration = 1 + } + return nextGeneration + } + + private static let callbackQueueKey = DispatchSpecificKey() + + private static let callback: FSEventStreamCallback = { + _, context, numEvents, eventPaths, eventFlags, eventIDs in + guard let context else { return } + let callbackContext = Unmanaged.fromOpaque(context).takeUnretainedValue() + guard let payload = buildOwnedPayload( + numEvents: Int(numEvents), + eventPaths: eventPaths, + eventFlags: eventFlags, + eventIDs: eventIDs + ) else { return } + callbackContext.watcher?.accept(payload, generation: callbackContext.generation) + } + + package nonisolated static func buildOwnedPayload( + numEvents: Int, + eventPaths: UnsafeMutableRawPointer, + eventFlags: UnsafePointer, + eventIDs: UnsafePointer + ) -> FileSystemWatchEventPayload? { + guard numEvents > 0 else { return nil } + let cfArray = Unmanaged.fromOpaque(eventPaths).takeUnretainedValue() + let safeCount = min(numEvents, CFArrayGetCount(cfArray)) + guard safeCount > 0 else { return nil } + + var entries: [FileSystemWatchEvent] = [] + entries.reserveCapacity(safeCount) + for index in 0 ..< safeCount { + guard let rawValue = CFArrayGetValueAtIndex(cfArray, index) else { continue } + let cfObject = unsafeBitCast(rawValue, to: CFTypeRef.self) + let copiedPath: String? = if CFGetTypeID(cfObject) == CFStringGetTypeID() { + deepCopyEventPath(unsafeBitCast(rawValue, to: CFString.self)) + } else if let string = cfObject as? String { + deepCopySwiftString(string) + } else { + nil + } + guard let copiedPath else { continue } + entries.append(FileSystemWatchEvent( + path: copiedPath, + flags: semanticFlags(for: eventFlags[index]), + id: FileSystemWatchEventID(eventIDs[index]) + )) + } + return entries.isEmpty ? nil : FileSystemWatchEventPayload(entries: entries) + } + + package nonisolated static func semanticFlags(for rawFlags: FSEventStreamEventFlags) -> FileSystemWatchEventFlags { + let raw = UInt32(rawFlags) + var flags: FileSystemWatchEventFlags = [] + func map(_ nativeFlag: Int, to semanticFlag: FileSystemWatchEventFlags) { + if raw & UInt32(nativeFlag) != 0 { + flags.insert(semanticFlag) + } + } + map(kFSEventStreamEventFlagItemCreated, to: .itemCreated) + map(kFSEventStreamEventFlagItemRemoved, to: .itemRemoved) + map(kFSEventStreamEventFlagItemRenamed, to: .itemRenamed) + map(kFSEventStreamEventFlagItemModified, to: .contentChanged) + map(kFSEventStreamEventFlagItemXattrMod, to: .contentChanged) + map(kFSEventStreamEventFlagItemInodeMetaMod, to: .metadataChanged) + map(kFSEventStreamEventFlagItemFinderInfoMod, to: .metadataChanged) + map(kFSEventStreamEventFlagItemChangeOwner, to: .metadataChanged) + map(kFSEventStreamEventFlagItemIsFile, to: .itemIsFile) + map(kFSEventStreamEventFlagItemIsDir, to: .itemIsDirectory) + map(kFSEventStreamEventFlagItemIsSymlink, to: .itemIsSymlink) + map(kFSEventStreamEventFlagMustScanSubDirs, to: .mustScanSubdirectories) + map(kFSEventStreamEventFlagUserDropped, to: .droppedEvents) + map(kFSEventStreamEventFlagKernelDropped, to: .droppedEvents) + map(kFSEventStreamEventFlagRootChanged, to: .rootChanged) + return flags + } + + package nonisolated static func deepCopySwiftString(_ source: String) -> String { + String(decoding: Array(source.utf8), as: UTF8.self) + } + + package nonisolated static func deepCopyEventPath(_ source: CFString) -> String? { + let length = CFStringGetLength(source) + if length == 0 { return "" } + + let utf8Encoding = CFStringBuiltInEncodings.UTF8.rawValue + if let directUTF8 = CFStringGetCStringPtr(source, utf8Encoding) { + return String(cString: directUTF8) + } + let maxBufferSize = max(CFStringGetMaximumSizeForEncoding(length, utf8Encoding) + 1, 1) + var utf8Buffer = [CChar](repeating: 0, count: maxBufferSize) + let copiedUTF8 = utf8Buffer.withUnsafeMutableBufferPointer { buffer in + CFStringGetCString(source, buffer.baseAddress, buffer.count, utf8Encoding) + } + if copiedUTF8 { + return String(cString: utf8Buffer) + } + + var utf16Buffer = [UniChar](repeating: 0, count: length) + CFStringGetCharacters(source, CFRange(location: 0, length: length), &utf16Buffer) + return String(utf16CodeUnits: utf16Buffer, count: utf16Buffer.count) + } + + private enum LifecycleState { + case idle + case starting(StartAttempt) + case watching(ActiveStream) + } + + private struct StartAttempt { + let generation: UInt64 + let handler: @Sendable (FileSystemWatchEventPayload) -> Void + let context: CallbackContext + } + + private struct ActiveStream { + let generation: UInt64 + let handler: @Sendable (FileSystemWatchEventPayload) -> Void + let stream: any MacOSFSEventStreamToken + let context: CallbackContext + } + + private struct StreamDisposal: @unchecked Sendable { + let stream: any MacOSFSEventStreamToken + let wasStarted: Bool + let context: CallbackContext + } + + private final class CallbackContext: @unchecked Sendable { + weak var watcher: MacOSFSEventsWatcher? + let generation: UInt64 + + init(watcher: MacOSFSEventsWatcher, generation: UInt64) { + self.watcher = watcher + self.generation = generation + } + } + + private final class CallbackQueueToken: @unchecked Sendable {} +} + +package struct MacOSFSEventsWatcherFactory: FileSystemWatcherCreating { + package init() {} + + package func makeWatcher(path: String) -> any FileSystemWatching { + MacOSFSEventsWatcher(path: path) + } +} diff --git a/Sources/RepoPromptCoreMacOS/FileSystem/MacOSFileContentSnapshotReader.swift b/Sources/RepoPromptCoreMacOS/FileSystem/MacOSFileContentSnapshotReader.swift new file mode 100644 index 000000000..1319fd643 --- /dev/null +++ b/Sources/RepoPromptCoreMacOS/FileSystem/MacOSFileContentSnapshotReader.swift @@ -0,0 +1,64 @@ +import Darwin +import Foundation +import RepoPromptCore +import RepoPromptPOSIXSupport + +package struct MacOSFileContentSnapshotReader: FileContentSnapshotReading { + package init() {} + + package func fingerprint(atPath path: String) throws -> FileContentFingerprint { + do { + return try fingerprint(from: POSIXFileContentSnapshotSupport.metadata(atPath: path)) + } catch { + throw map(error) + } + } + + package func fingerprint(fileDescriptor: Int32) throws -> FileContentFingerprint { + do { + return try fingerprint(from: POSIXFileContentSnapshotSupport.metadata(fileDescriptor: fileDescriptor)) + } catch { + throw map(error) + } + } + + package func openReadOnlyFileHandle(atPath path: String) throws -> FileHandle { + do { + let descriptor = try POSIXFileContentSnapshotSupport.openReadOnlyFileDescriptor(atPath: path) + return FileHandle(fileDescriptor: descriptor, closeOnDealloc: true) + } catch { + throw map(error) + } + } + + private func fingerprint(from metadata: POSIXFileContentMetadata) -> FileContentFingerprint { + FileContentFingerprint( + deviceID: metadata.deviceID, + fileNumber: metadata.fileNumber, + byteSize: metadata.byteSize, + modificationSeconds: metadata.modificationSeconds, + modificationNanoseconds: metadata.modificationNanoseconds, + statusChangeSeconds: metadata.statusChangeSeconds, + statusChangeNanoseconds: metadata.statusChangeNanoseconds + ) + } + + private func map(_ error: Error) -> FileSystemError { + guard let error = error as? POSIXFileContentSnapshotError else { + return .failedToReadFile + } + switch error { + case .notRegularFile: + return .invalidRelativePath + case let .operationFailed(errorNumber): + switch errorNumber { + case ENOENT, ENOTDIR: + return .fileNotFound + case ELOOP: + return .invalidRelativePath + default: + return .failedToReadFile + } + } + } +} diff --git a/Sources/RepoPromptCoreMacOS/FileSystem/MacOSWorkspaceDirectoryListingBackend.swift b/Sources/RepoPromptCoreMacOS/FileSystem/MacOSWorkspaceDirectoryListingBackend.swift new file mode 100644 index 000000000..0970844eb --- /dev/null +++ b/Sources/RepoPromptCoreMacOS/FileSystem/MacOSWorkspaceDirectoryListingBackend.swift @@ -0,0 +1,138 @@ +import Darwin +import Foundation +import RepoPromptCore + +package struct MacOSWorkspaceDirectoryListingBackend: WorkspaceDirectoryListingBackend { + package init() {} + + package func listDirectoryWithIgnoreDetection(at path: String) throws -> WorkspaceDirectoryScanResult { + guard let directory = opendir(path) else { + throw CocoaError(.fileReadNoSuchFile, userInfo: [NSFilePathErrorKey: path]) + } + defer { closedir(directory) } + + var entries: [WorkspaceDirectoryEntry] = [] + var hasGitignore = false + var hasRepoIgnore = false + var hasCursorignore = false + + while true { + errno = 0 + guard let pointer = readdir(directory) else { + if errno != 0 { + throw POSIXError(POSIXErrorCode(rawValue: errno) ?? .EIO) + } + break + } + let entry = pointer.pointee + guard let decoded = decodeName(entry), decoded.name != ".", decoded.name != ".." else { continue } + guard !decoded.name.hasPrefix(".repoprompt.tmp.") else { continue } + + switch decoded.name { + case ".gitignore": hasGitignore = true + case ".repo_ignore": hasRepoIgnore = true + case ".cursorignore": hasCursorignore = true + default: break + } + + let kind = fileType(directory: directory, entry: entry, nameLength: decoded.length) + entries.append( + WorkspaceDirectoryEntry( + name: decoded.name, + isDirectory: kind.isDirectory, + isSymbolicLink: kind.isSymbolicLink + ) + ) + } + + return WorkspaceDirectoryScanResult( + entries: entries, + hasGitignore: hasGitignore, + hasRepoIgnore: hasRepoIgnore, + hasCursorignore: hasCursorignore + ) + } + + package func directoryIdentity(followingSymlinksAt path: String) -> WorkspaceDirectoryIdentity? { + var info = stat() + guard stat(path, &info) == 0 else { return nil } + return WorkspaceDirectoryIdentity(device: UInt64(info.st_dev), inode: UInt64(info.st_ino)) + } + + package func canonicalPath(for path: String) -> String? { + path.withCString { pointer in + guard let resolved = realpath(pointer, nil) else { return nil } + defer { free(resolved) } + return String(cString: resolved) + } + } + + private struct DecodedName { + let name: String + let length: Int + } + + private func decodeName(_ entry: dirent) -> DecodedName? { + withUnsafeBytes(of: entry.d_name) { rawBuffer in + let buffer = rawBuffer.bindMemory(to: UInt8.self) + guard !buffer.isEmpty else { return nil } + var length = Int(entry.d_namlen) + if length > 0 { + length = min(length, buffer.count) + if buffer[length - 1] == 0 { length -= 1 } + } else { + guard let nul = buffer.firstIndex(of: 0) else { return nil } + length = nul + } + guard length > 0 else { return nil } + return DecodedName(name: String(decoding: buffer.prefix(length), as: UTF8.self), length: length) + } + } + + private func fileType( + directory: UnsafeMutablePointer, + entry: dirent, + nameLength: Int + ) -> (isDirectory: Bool, isSymbolicLink: Bool) { + switch Int32(entry.d_type) { + case DT_DIR: + (true, false) + case DT_LNK, DT_UNKNOWN: + fallbackFileType(directory: directory, entry: entry, nameLength: nameLength) + default: + (false, false) + } + } + + private func fallbackFileType( + directory: UnsafeMutablePointer, + entry: dirent, + nameLength: Int + ) -> (isDirectory: Bool, isSymbolicLink: Bool) { + let descriptor = dirfd(directory) + guard descriptor >= 0 else { return (false, false) } + var name = [CChar](repeating: 0, count: nameLength + 1) + withUnsafeBytes(of: entry.d_name) { rawBuffer in + let bytes = rawBuffer.bindMemory(to: UInt8.self) + for index in 0 ..< min(nameLength, bytes.count) { + name[index] = CChar(bitPattern: bytes[index]) + } + } + + var info = stat() + let noFollow = name.withUnsafeBufferPointer { buffer in + guard let base = buffer.baseAddress else { return Int32(-1) } + return fstatat(descriptor, base, &info, AT_SYMLINK_NOFOLLOW) + } + guard noFollow == 0 else { return (false, false) } + let isSymbolicLink = (info.st_mode & S_IFMT) == S_IFLNK + if isSymbolicLink { + let follow = name.withUnsafeBufferPointer { buffer in + guard let base = buffer.baseAddress else { return Int32(-1) } + return fstatat(descriptor, base, &info, 0) + } + return (follow == 0 && (info.st_mode & S_IFMT) == S_IFDIR, true) + } + return ((info.st_mode & S_IFMT) == S_IFDIR, false) + } +} diff --git a/Sources/RepoPromptCoreMacOS/FileSystem/MacOSWorkspaceExternalFileReader.swift b/Sources/RepoPromptCoreMacOS/FileSystem/MacOSWorkspaceExternalFileReader.swift new file mode 100644 index 000000000..bcfcbb90a --- /dev/null +++ b/Sources/RepoPromptCoreMacOS/FileSystem/MacOSWorkspaceExternalFileReader.swift @@ -0,0 +1,189 @@ +import Darwin +import Foundation +import RepoPromptCore +import RepoPromptPOSIXSupport + +package final class MacOSWorkspaceExternalFileReader: WorkspaceExternalFileReading, @unchecked Sendable { + package typealias ValidatedDescriptorHook = @Sendable (_ canonicalPath: String, _ descriptor: Int32) -> Void + + private enum ExpectedKind: Equatable { + case directory + case regularFile + } + + private struct OpenedNode { + let descriptor: Int32 + let canonicalPath: String + } + + private let validatedDescriptorHook: ValidatedDescriptorHook? + + package init(validatedDescriptorHook: ValidatedDescriptorHook? = nil) { + self.validatedDescriptorHook = validatedDescriptorHook + } + + package func resolveRegularFile( + atAbsolutePath path: String, + allowedDirectories: [AlwaysReadableDirectory] + ) throws -> String? { + do { + guard let opened = try openValidatedNode( + atAbsolutePath: path, + allowedDirectories: allowedDirectories, + expectedKind: .regularFile + ) else { + return nil + } + Darwin.close(opened.descriptor) + return opened.canonicalPath + } catch ReaderError.unsafeType { + return nil + } + } + + package func resolveDirectory( + atAbsolutePath path: String, + allowedDirectories: [AlwaysReadableDirectory] + ) throws -> String? { + do { + guard let opened = try openValidatedNode( + atAbsolutePath: path, + allowedDirectories: allowedDirectories, + expectedKind: .directory + ) else { + return nil + } + Darwin.close(opened.descriptor) + return opened.canonicalPath + } catch ReaderError.unsafeType { + return nil + } + } + + package func readRegularFile( + atAbsolutePath path: String, + allowedDirectories: [AlwaysReadableDirectory] + ) throws -> Data { + guard let opened = try openValidatedNode( + atAbsolutePath: path, + allowedDirectories: allowedDirectories, + expectedKind: .regularFile + ) else { + throw ReaderError.notAllowed(path) + } + defer { Darwin.close(opened.descriptor) } + return try readAll(from: opened.descriptor, path: opened.canonicalPath) + } + + private func openValidatedNode( + atAbsolutePath path: String, + allowedDirectories: [AlwaysReadableDirectory], + expectedKind: ExpectedKind + ) throws -> OpenedNode? { + guard path.hasPrefix("/"), let canonicalCandidate = canonicalExistingPath(path) else { + return nil + } + let allowedRoots = allowedDirectories.compactMap { directory -> (canonical: String, presented: String)? in + guard let canonical = canonicalExistingPath(directory.standardizedPath) else { return nil } + return (canonical, directory.standardizedPath) + } + guard let allowedRoot = allowedRoots.first(where: { contains(canonicalCandidate, in: $0.canonical) }) else { + return nil + } + + let flags = O_RDONLY | O_CLOEXEC | O_NOFOLLOW | (expectedKind == .directory ? O_DIRECTORY : O_NONBLOCK) + let descriptor = Darwin.open(canonicalCandidate, flags) + guard descriptor >= 0 else { + if errno == ENOENT || errno == ENOTDIR || errno == ELOOP { return nil } + throw posixError(operation: "open", path: canonicalCandidate, errorNumber: errno) + } + + do { + var status = stat() + guard Darwin.fstat(descriptor, &status) == 0 else { + throw posixError(operation: "inspect", path: canonicalCandidate, errorNumber: errno) + } + let actualKind = status.st_mode & S_IFMT + switch expectedKind { + case .directory: + guard actualKind == S_IFDIR else { throw ReaderError.unsafeType(canonicalCandidate) } + case .regularFile: + guard actualKind == S_IFREG else { throw ReaderError.unsafeType(canonicalCandidate) } + } + + let descriptorPath = try pathForDescriptor(descriptor, fallbackPath: canonicalCandidate) + guard contains(descriptorPath, in: allowedRoot.canonical) else { + throw ReaderError.escapedRoot(descriptorPath) + } + let suffix = String(descriptorPath.dropFirst(allowedRoot.canonical.count)) + let presentedPath = AgentSupportDirectoryCatalog.normalizedPath(for: allowedRoot.presented + suffix) + validatedDescriptorHook?(presentedPath, descriptor) + return OpenedNode(descriptor: descriptor, canonicalPath: presentedPath) + } catch { + Darwin.close(descriptor) + throw error + } + } + + private func pathForDescriptor(_ descriptor: Int32, fallbackPath: String) throws -> String { + do { + let path = try POSIXDescriptorSupport.path(for: descriptor) + return AgentSupportDirectoryCatalog.normalizedPath(for: path) + } catch let error as POSIXDescriptorPathError { + throw posixError(operation: "resolve opened descriptor", path: fallbackPath, errorNumber: error.errnoValue) + } + } + + private func canonicalExistingPath(_ path: String) -> String? { + path.withCString { pointer in + guard let resolved = Darwin.realpath(pointer, nil) else { return nil } + defer { Darwin.free(resolved) } + return AgentSupportDirectoryCatalog.normalizedPath(for: String(cString: resolved)) + } + } + + private func contains(_ path: String, in root: String) -> Bool { + path == root || path.hasPrefix(root + "/") + } + + private func readAll(from descriptor: Int32, path: String) throws -> Data { + var data = Data() + var buffer = [UInt8](repeating: 0, count: 64 * 1024) + while true { + let count = buffer.withUnsafeMutableBytes { rawBuffer -> Int in + guard let baseAddress = rawBuffer.baseAddress else { return 0 } + return Darwin.read(descriptor, baseAddress, rawBuffer.count) + } + if count == 0 { return data } + if count < 0 { + if errno == EINTR { continue } + throw posixError(operation: "read", path: path, errorNumber: errno) + } + data.append(contentsOf: buffer[0 ..< count]) + } + } + + private func posixError(operation: String, path: String, errorNumber: Int32) -> ReaderError { + ReaderError.posix(operation: operation, path: path, detail: String(cString: Darwin.strerror(errorNumber))) + } +} + +private enum ReaderError: LocalizedError { + case escapedRoot(String) + case notAllowed(String) + case posix(operation: String, path: String, detail: String) + case unsafeType(String) + + var errorDescription: String? { + switch self { + case let .escapedRoot(path): + "Opened external support path escaped its allowed root: \(path)" + case let .notAllowed(path): + "External support file is not inside an allowed root: \(path)" + case let .posix(operation, path, detail): + "Unable to \(operation) external support path '\(path)': \(detail)" + case let .unsafeType(path): + "External support path is not the expected regular file or directory: \(path)" + } + } +} diff --git a/Sources/RepoPromptCoreMacOS/MCP/PeerVerification/MacOSBundledHelperPeerVerifier.swift b/Sources/RepoPromptCoreMacOS/MCP/PeerVerification/MacOSBundledHelperPeerVerifier.swift new file mode 100644 index 000000000..51d2a260f --- /dev/null +++ b/Sources/RepoPromptCoreMacOS/MCP/PeerVerification/MacOSBundledHelperPeerVerifier.swift @@ -0,0 +1,27 @@ +import Darwin +import Foundation +import RepoPromptCore + +/// macOS `proc_pidpath` adapter for bundled-helper executable verification. +package struct MacOSBundledHelperPeerVerifier: BundledHelperPeerVerifying { + package init() {} + package func matches(_ input: BundledHelperPeerVerificationInput) -> Bool { + guard let actualPath = Self.executablePath(forPID: input.peerPID) else { + return false + } + return Self.pathsMatch(expectedURL: input.expectedExecutableURL, actualPath: actualPath) + } + + package static func pathsMatch(expectedURL: URL, actualPath: String) -> Bool { + let expected = expectedURL.resolvingSymlinksInPath().standardizedFileURL.path + let actual = URL(fileURLWithPath: actualPath).resolvingSymlinksInPath().standardizedFileURL.path + return actual == expected + } + + private static func executablePath(forPID pid: Int) -> String? { + var buffer = [CChar](repeating: 0, count: 4096) + let result = proc_pidpath(pid_t(pid), &buffer, UInt32(buffer.count)) + guard result > 0 else { return nil } + return String(cString: buffer) + } +} diff --git a/Sources/RepoPromptCoreMacOS/MCP/PeerVerification/MacOSProcessAncestryInspector.swift b/Sources/RepoPromptCoreMacOS/MCP/PeerVerification/MacOSProcessAncestryInspector.swift new file mode 100644 index 000000000..b0c5e16ee --- /dev/null +++ b/Sources/RepoPromptCoreMacOS/MCP/PeerVerification/MacOSProcessAncestryInspector.swift @@ -0,0 +1,17 @@ +import Darwin +import RepoPromptCore + +/// macOS `sysctl` adapter for parent-process inspection. +package struct MacOSProcessAncestryInspector: ProcessAncestryInspecting { + package init() {} + + package func parentPID(of pid: Int32) -> Int32? { + var info = kinfo_proc() + var size = MemoryLayout.stride(ofValue: info) + var mib: [Int32] = [CTL_KERN, KERN_PROC, KERN_PROC_PID, pid] + guard sysctl(&mib, u_int(mib.count), &info, &size, nil, 0) == 0, size > 0 else { + return nil + } + return info.kp_eproc.e_ppid + } +} diff --git a/Sources/RepoPrompt/Infrastructure/Process/FDWriteSupport.swift b/Sources/RepoPromptCoreMacOS/Process/FDWriteSupport.swift similarity index 87% rename from Sources/RepoPrompt/Infrastructure/Process/FDWriteSupport.swift rename to Sources/RepoPromptCoreMacOS/Process/FDWriteSupport.swift index 49ab3c43c..99dc9365a 100644 --- a/Sources/RepoPrompt/Infrastructure/Process/FDWriteSupport.swift +++ b/Sources/RepoPromptCoreMacOS/Process/FDWriteSupport.swift @@ -2,12 +2,12 @@ import Darwin import Darwin.POSIX.fcntl import Foundation -enum FDWriteError: Error, Equatable { +package enum FDWriteError: Error, Equatable { case brokenPipe(errno: Int32) case badDescriptor(errno: Int32) case system(errno: Int32) - var errnoValue: Int32 { + package var errnoValue: Int32 { switch self { case let .brokenPipe(errno), let .badDescriptor(errno), let .system(errno): errno @@ -15,9 +15,9 @@ enum FDWriteError: Error, Equatable { } } -enum FDWriteSupport { +package enum FDWriteSupport { @discardableResult - static func configureNoSigPipe(fd: Int32) -> Bool { + package static func configureNoSigPipe(fd: Int32) -> Bool { guard fd >= 0 else { return false } #if os(macOS) || os(iOS) || os(tvOS) || os(watchOS) return fcntl(fd, F_SETNOSIGPIPE, 1) != -1 @@ -26,7 +26,7 @@ enum FDWriteSupport { #endif } - static func writeAll(_ data: Data, to fd: Int32) throws { + package static func writeAll(_ data: Data, to fd: Int32) throws { guard fd >= 0 else { throw FDWriteError.badDescriptor(errno: EBADF) } diff --git a/Sources/RepoPrompt/Infrastructure/Process/ProcessLauncher.swift b/Sources/RepoPromptCoreMacOS/Process/POSIXProcessLauncher.swift similarity index 90% rename from Sources/RepoPrompt/Infrastructure/Process/ProcessLauncher.swift rename to Sources/RepoPromptCoreMacOS/Process/POSIXProcessLauncher.swift index 325bbc837..edf40de75 100644 --- a/Sources/RepoPrompt/Infrastructure/Process/ProcessLauncher.swift +++ b/Sources/RepoPromptCoreMacOS/Process/POSIXProcessLauncher.swift @@ -1,26 +1,10 @@ import Darwin import Foundation -import RepoPromptShared +import RepoPromptCore +import RepoPromptPOSIXSupport -struct SpawnedProcess: @unchecked Sendable { - let pid: pid_t - let stdin: FileHandle? - let stdinDescriptor: Int32? - let stdout: FileHandle - let stderr: FileHandle -} - -enum ProcessLauncherError: Error { - case pipeCreationFailed(String) - case descriptorConfigurationFailed(label: String, fd: Int32, underlying: POSIXDescriptorConfigurationError) - case spawnFileActionsFailed(operation: String, errno: Int32) - case changeDirectoryFailed(path: String, errno: Int32) - case spawnAttributesFailed(operation: String, errno: Int32) - case spawnFailed(errno: Int32) -} - -enum ProcessLauncher { - static func spawn( +package enum ProcessLauncher { + package static func spawn( command: String, arguments: [String], environment: [String: String], @@ -36,12 +20,12 @@ enum ProcessLauncher { } #if DEBUG - enum DebugInitializationFailure { + package enum DebugInitializationFailure { case fileActions(errno: Int32) case attributes(errno: Int32) } - static func debugSpawn( + package static func debugSpawn( command: String, arguments: [String], environment: [String: String], @@ -95,7 +79,12 @@ enum ProcessLauncher { do { try POSIXDescriptorSupport.setCloseOnExec(fd) } catch let error as POSIXDescriptorConfigurationError { - throw ProcessLauncherError.descriptorConfigurationFailed(label: label, fd: fd, underlying: error) + throw ProcessLauncherError.descriptorConfigurationFailed( + operation: "setCloseOnExec", + label: label, + fd: fd, + errno: error.errnoValue + ) } } } @@ -286,3 +275,22 @@ enum ProcessLauncher { ) } } + +/// macOS POSIX adapter exposed through the neutral process-launching contract. +package struct POSIXProcessLauncher: ProcessLaunching { + package init() {} + + package func spawn( + command: String, + arguments: [String], + environment: [String: String], + workingDirectory: String? + ) throws -> SpawnedProcess { + try ProcessLauncher.spawn( + command: command, + arguments: arguments, + environment: environment, + workingDirectory: workingDirectory + ) + } +} diff --git a/Sources/RepoPrompt/Infrastructure/Security/KeychainService.swift b/Sources/RepoPromptCoreMacOS/Security/KeychainService.swift similarity index 59% rename from Sources/RepoPrompt/Infrastructure/Security/KeychainService.swift rename to Sources/RepoPromptCoreMacOS/Security/KeychainService.swift index 25807ce69..1d75b5d8b 100644 --- a/Sources/RepoPrompt/Infrastructure/Security/KeychainService.swift +++ b/Sources/RepoPromptCoreMacOS/Security/KeychainService.swift @@ -6,89 +6,70 @@ // import Foundation +import RepoPromptCore import Security -/// Controls whether a Keychain operation may display macOS authentication/approval UI. -enum KeychainAccessMode: Equatable { - case interactive - case nonInteractive(reason: KeychainAccessReason) - - var isNonInteractive: Bool { - if case .nonInteractive = self { - return true - } - return false - } -} - -/// Sanitized reason metadata for noninteractive Keychain access. -enum KeychainAccessReason: Equatable { - case launch - case bulkSettingsLoad - case permissionDecision - case backgroundAvailabilityCheck - case test -} - -protocol SecItemClient { +package protocol SecItemClient { func copyMatching(_ query: CFDictionary, _ result: UnsafeMutablePointer?) -> OSStatus func add(_ query: CFDictionary, _ result: UnsafeMutablePointer?) -> OSStatus func update(_ query: CFDictionary, _ attributes: CFDictionary) -> OSStatus func delete(_ query: CFDictionary) -> OSStatus } -struct SystemSecItemClient: SecItemClient { - func copyMatching(_ query: CFDictionary, _ result: UnsafeMutablePointer?) -> OSStatus { +package struct SystemSecItemClient: SecItemClient { + package init() {} + + package func copyMatching(_ query: CFDictionary, _ result: UnsafeMutablePointer?) -> OSStatus { SecItemCopyMatching(query, result) } - func add(_ query: CFDictionary, _ result: UnsafeMutablePointer?) -> OSStatus { + package func add(_ query: CFDictionary, _ result: UnsafeMutablePointer?) -> OSStatus { SecItemAdd(query, result) } - func update(_ query: CFDictionary, _ attributes: CFDictionary) -> OSStatus { + package func update(_ query: CFDictionary, _ attributes: CFDictionary) -> OSStatus { SecItemUpdate(query, attributes) } - func delete(_ query: CFDictionary) -> OSStatus { + package func delete(_ query: CFDictionary) -> OSStatus { SecItemDelete(query) } } /// Secure storage service for one explicitly selected CE macOS Keychain domain. -final class KeychainService: SecureKeyValueStorageBackend, @unchecked Sendable { - static let legacyCanonicalServiceName = "com.pvncher.repoprompt.ce.keychain" - static let officialV2ServiceName = "com.pvncher.repoprompt.ce.developer-id.keychain.v2" - static let localSelfSignedServiceNamePrefix = "com.pvncher.repoprompt.ce.local-self-signed." - static let debugServiceName = "com.pvncher.repoprompt.ce.debug.keychain" +package final class KeychainService: SecureKeyValueStorageBackend, @unchecked Sendable { + package static let legacyCanonicalServiceName = "com.pvncher.repoprompt.ce.keychain" + package static let officialV2ServiceName = "com.pvncher.repoprompt.ce.developer-id.keychain.v2" + package static let localSelfSignedServiceNamePrefix = "com.pvncher.repoprompt.ce.local-self-signed." + package static let debugServiceName = "com.pvncher.repoprompt.ce.debug.keychain" - static let officialV2Shared = KeychainService(serviceName: officialV2ServiceName) - static let debugShared = KeychainService(serviceName: debugServiceName) + package static let officialV2Shared = KeychainService(serviceName: officialV2ServiceName) + package static let debugShared = KeychainService(serviceName: debugServiceName) - static func localSelfSignedServiceName(fingerprint: String, generation: Int) -> String { + package static func localSelfSignedServiceName(fingerprint: String, generation: Int) -> String { let normalizedFingerprint = fingerprint.filter(\.isHexDigit).lowercased() precondition(normalizedFingerprint.count == 64, "Local certificate fingerprint must be SHA-256") precondition(generation > 0, "Local secure-storage generation must be positive") return "\(localSelfSignedServiceNamePrefix)\(normalizedFingerprint).keychain.v\(generation)" } - static func localSelfSigned(fingerprint: String, generation: Int) -> KeychainService { + package static func localSelfSigned(fingerprint: String, generation: Int) -> KeychainService { KeychainService(serviceName: localSelfSignedServiceName(fingerprint: fingerprint, generation: generation)) } - static func legacyRepairSource(secItemClient: SecItemClient = SystemSecItemClient()) -> KeychainService { + package static func legacyRepairSource(secItemClient: any SecItemClient = SystemSecItemClient()) -> KeychainService { KeychainService(serviceName: legacyCanonicalServiceName, secItemClient: secItemClient) } - let serviceName: String - private let secItemClient: SecItemClient + package let serviceName: String + private let secItemClient: any SecItemClient private let operationLock = NSRecursiveLock() - let persistsValuesAcrossLaunches = true + package let persistsValuesAcrossLaunches = true - init( + package init( serviceName: String = KeychainService.officialV2ServiceName, - secItemClient: SecItemClient = SystemSecItemClient() + secItemClient: any SecItemClient = SystemSecItemClient() ) { self.serviceName = serviceName self.secItemClient = secItemClient @@ -100,7 +81,7 @@ final class KeychainService: SecureKeyValueStorageBackend, @unchecked Sendable { return try body() } - private func query(_ values: [String: Any], accessMode: KeychainAccessMode) -> [String: Any] { + private func query(_ values: [String: Any], accessMode: SecureStorageAccessMode) -> [String: Any] { guard accessMode.isNonInteractive else { return values } @@ -110,7 +91,7 @@ final class KeychainService: SecureKeyValueStorageBackend, @unchecked Sendable { return query } - private func keychainError(for status: OSStatus) -> KeychainError { + private func keychainError(for status: OSStatus) -> SecureStorageError { switch status { case errSecItemNotFound: .itemNotFound @@ -127,46 +108,15 @@ final class KeychainService: SecureKeyValueStorageBackend, @unchecked Sendable { } } - enum KeychainError: Error, LocalizedError, Equatable { - case itemNotFound - case duplicateItem - case invalidData - case interactionNotAllowed - case userInteractionCancelled - case authenticationFailed - case unexpectedStatus(OSStatus) - - var errorDescription: String? { - switch self { - case .itemNotFound: - "Item not found in keychain" - case .duplicateItem: - "Item already exists" - case .invalidData: - "Invalid data format" - case .interactionNotAllowed: - "Keychain interaction is not allowed in the current access mode" - case .userInteractionCancelled: - "Keychain interaction was cancelled" - case .authenticationFailed: - "Keychain authentication failed" - case let .unexpectedStatus(status): - "Keychain error: \(status)" - } - } - } - - // MARK: - Save to Keychain - /// Save a UTF-8 string to this service only. - func save( + package func save( _ value: String, for key: String, - accessMode: KeychainAccessMode = .interactive + accessMode: SecureStorageAccessMode = .interactive ) throws { try withLock { guard let data = value.data(using: .utf8) else { - throw KeychainError.invalidData + throw SecureStorageError.invalidData } let itemQuery = query([ @@ -205,12 +155,10 @@ final class KeychainService: SecureKeyValueStorageBackend, @unchecked Sendable { } } - // MARK: - Retrieve from Keychain - /// Retrieve a UTF-8 string from this service only. - func get( + package func get( for key: String, - accessMode: KeychainAccessMode = .interactive + accessMode: SecureStorageAccessMode = .interactive ) throws -> String { let data = try withLock { let itemQuery = query([ @@ -223,26 +171,25 @@ final class KeychainService: SecureKeyValueStorageBackend, @unchecked Sendable { var result: AnyObject? let status = secItemClient.copyMatching(itemQuery as CFDictionary, &result) - guard status == errSecSuccess else { throw keychainError(for: status) } - guard let data = result as? Data else { - throw KeychainError.invalidData + throw SecureStorageError.invalidData } return data } guard let value = String(data: data, encoding: .utf8) else { - throw KeychainError.invalidData + throw SecureStorageError.invalidData } return value } - // MARK: - Delete from Keychain - /// Delete an item from this service only. - func delete(for key: String, accessMode: KeychainAccessMode = .interactive) throws { + package func delete( + for key: String, + accessMode: SecureStorageAccessMode = .interactive + ) throws { try withLock { let itemQuery = query([ kSecClass as String: kSecClassGenericPassword, diff --git a/Sources/RepoPrompt/Infrastructure/Security/RuntimeCodeSigningDetector.swift b/Sources/RepoPromptCoreMacOS/Security/RuntimeCodeSigningDetector.swift similarity index 56% rename from Sources/RepoPrompt/Infrastructure/Security/RuntimeCodeSigningDetector.swift rename to Sources/RepoPromptCoreMacOS/Security/RuntimeCodeSigningDetector.swift index 5c51c1dbd..1267d0053 100644 --- a/Sources/RepoPrompt/Infrastructure/Security/RuntimeCodeSigningDetector.swift +++ b/Sources/RepoPromptCoreMacOS/Security/RuntimeCodeSigningDetector.swift @@ -2,18 +2,97 @@ import CryptoKit import Foundation import Security -struct RuntimeValidatedLocalSigningIdentity: Equatable { - let fingerprint: String - let serviceGeneration: Int +package enum RuntimeCodeSigningDomain: Hashable { + case developerID + case appleDevelopmentDebug + case localSelfSigned } -struct RuntimeLocalSigningExpectation: Equatable { - let bundleLeafCertificateSHA256: String - let registeredLeafCertificateSHA256: String - let bundleServiceGeneration: Int - let registeredServiceGeneration: Int +package enum RuntimeCodeSigningFailureCategory: Equatable { + case codeObjectUnavailable + case signatureInvalid + case signingInformationUnavailable + case requirementUnavailable +} + +package enum RuntimeCodeSigningValidationResult: Equatable { + case valid(domains: Set) + case invalid(RuntimeCodeSigningFailureCategory) + + package func validates(_ domain: RuntimeCodeSigningDomain) -> Bool { + guard case let .valid(domains) = self else { return false } + return domains.contains(domain) + } +} + +package struct RuntimeCodeSigningInfo: Equatable { + package let codeIdentifier: String? + package let teamIdentifier: String? + package let signingFlags: UInt32? + package let isAdHoc: Bool + package let leafCertificateSHA256: String? + package let validationResult: RuntimeCodeSigningValidationResult + + package init( + codeIdentifier: String?, + teamIdentifier: String?, + signingFlags: UInt32?, + isAdHoc: Bool, + leafCertificateSHA256: String?, + validationResult: RuntimeCodeSigningValidationResult + ) { + self.codeIdentifier = codeIdentifier + self.teamIdentifier = teamIdentifier + self.signingFlags = signingFlags + self.isAdHoc = isAdHoc + self.leafCertificateSHA256 = leafCertificateSHA256 + self.validationResult = validationResult + } + + package static func synthetic( + codeIdentifier: String? = nil, + teamIdentifier: String? = nil, + isAdHoc: Bool = false, + leafCertificateSHA256: String? = nil, + validatedDomains: Set = [], + failure: RuntimeCodeSigningFailureCategory? = nil + ) -> RuntimeCodeSigningInfo { + RuntimeCodeSigningInfo( + codeIdentifier: codeIdentifier, + teamIdentifier: teamIdentifier, + signingFlags: isAdHoc ? 0x2 : 0, + isAdHoc: isAdHoc, + leafCertificateSHA256: leafCertificateSHA256, + validationResult: failure.map(RuntimeCodeSigningValidationResult.invalid) + ?? .valid(domains: validatedDomains) + ) + } +} + +package struct RuntimeValidatedLocalSigningIdentity: Equatable { + package let fingerprint: String + package let serviceGeneration: Int +} + +package struct RuntimeLocalSigningExpectation: Equatable { + package let bundleLeafCertificateSHA256: String + package let registeredLeafCertificateSHA256: String + package let bundleServiceGeneration: Int + package let registeredServiceGeneration: Int + + package init( + bundleLeafCertificateSHA256: String, + registeredLeafCertificateSHA256: String, + bundleServiceGeneration: Int, + registeredServiceGeneration: Int + ) { + self.bundleLeafCertificateSHA256 = bundleLeafCertificateSHA256 + self.registeredLeafCertificateSHA256 = registeredLeafCertificateSHA256 + self.bundleServiceGeneration = bundleServiceGeneration + self.registeredServiceGeneration = registeredServiceGeneration + } - var validatedIdentity: RuntimeValidatedLocalSigningIdentity? { + package var validatedIdentity: RuntimeValidatedLocalSigningIdentity? { let bundleFingerprint = Self.normalizedFingerprint(bundleLeafCertificateSHA256) let registeredFingerprint = Self.normalizedFingerprint(registeredLeafCertificateSHA256) guard bundleFingerprint.count == 64, @@ -34,8 +113,25 @@ struct RuntimeLocalSigningExpectation: Equatable { } } -enum RuntimeCodeSigningDetector { - static func currentProcessSigningInfo( +package struct RuntimeCodeSigningRequirements { + package let developerIDRequirement: String + package let appleDevelopmentDebugRequirement: String + package let localCodeIdentifier: String + + package init( + developerIDRequirement: String, + appleDevelopmentDebugRequirement: String, + localCodeIdentifier: String + ) { + self.developerIDRequirement = developerIDRequirement + self.appleDevelopmentDebugRequirement = appleDevelopmentDebugRequirement + self.localCodeIdentifier = localCodeIdentifier + } +} + +package enum RuntimeCodeSigningDetector { + package static func currentProcessSigningInfo( + requirements: RuntimeCodeSigningRequirements, localSigningExpectation: RuntimeLocalSigningExpectation? = nil ) -> RuntimeCodeSigningInfo { var code: SecCode? @@ -76,8 +172,8 @@ enum RuntimeCodeSigningDetector { } ?? false let leafCertificateSHA256 = leafCertificateFingerprint(from: dictionary) - guard let developerIDRequirement = requirement(from: RuntimeCodeSigningPolicy.developerIDRequirement), - let debugRequirement = requirement(from: RuntimeCodeSigningPolicy.appleDevelopmentDebugRequirement) + guard let developerIDRequirement = requirement(from: requirements.developerIDRequirement), + let debugRequirement = requirement(from: requirements.appleDevelopmentDebugRequirement) else { return RuntimeCodeSigningInfo( codeIdentifier: codeIdentifier, @@ -98,7 +194,7 @@ enum RuntimeCodeSigningDetector { } if let expectedFingerprint = localSigningExpectation?.validatedIdentity?.fingerprint, !isAdHoc, - codeIdentifier == RuntimeCodeSigningPolicy.developerIDBundleIdentifier, + codeIdentifier == requirements.localCodeIdentifier, teamIdentifier == nil, leafCertificateSHA256 == expectedFingerprint { diff --git a/Sources/RepoPromptHeadless/CLI/HeadlessCLI.swift b/Sources/RepoPromptHeadless/CLI/HeadlessCLI.swift new file mode 100644 index 000000000..62c3dbc6a --- /dev/null +++ b/Sources/RepoPromptHeadless/CLI/HeadlessCLI.swift @@ -0,0 +1,303 @@ +import Foundation + +final class HeadlessCLI { + func run(arguments: [String], environment: [String: String]) async -> Int { + do { + return try await runThrowing(arguments: arguments, environment: environment) + } catch let error as HeadlessCommandError { + HeadlessOutput.stderr("ERROR: \(error.message)") + return error.exitCode + } catch { + HeadlessOutput.stderr("ERROR: \(error.localizedDescription)") + return 1 + } + } + + private func runThrowing(arguments: [String], environment: [String: String]) async throws -> Int { + let parsed = try parseGlobalOptions(arguments) + if parsed.printVersion { + HeadlessOutput.stdout("\(HeadlessVersion.executableName) \(HeadlessVersion.versionString)") + return 0 + } + if parsed.printHelp { + HeadlessOutput.stdout(Self.usage) + return 0 + } + + let command = parsed.remaining.first ?? "serve" + let commandArguments = parsed.remaining.isEmpty ? [] : Array(parsed.remaining.dropFirst()) + let paths = try HeadlessStatePaths.resolve(cliOverride: parsed.stateDirectoryOverride, environment: environment) + let store = HeadlessConfigurationStore(paths: paths) + + switch command { + case "serve": + guard commandArguments.isEmpty else { + throw HeadlessCommandError("Unexpected arguments for serve: \(commandArguments.joined(separator: " "))", exitCode: 2) + } + try await serve(store: store) + return 0 + case "doctor": + guard commandArguments.isEmpty else { + throw HeadlessCommandError("Unexpected arguments for doctor: \(commandArguments.joined(separator: " "))", exitCode: 2) + } + return try doctor(store: store) + case "config": + return try config(commandArguments, store: store) + default: + throw HeadlessCommandError("Unknown command '\(command)'.\n\n\(Self.usage)", exitCode: 2) + } + } + + private func serve(store: HeadlessConfigurationStore) async throws { + _ = try store.loadOrCreate() + HeadlessOutput.stderr("\(HeadlessVersion.displayName) \(HeadlessVersion.versionString) serving direct stdio MCP with the read-oriented safe tool profile.") + let server = HeadlessMCPServer(configurationStore: store) + let transport = HeadlessStdioTransport(server: server, writer: HeadlessStdoutWriter()) + try await transport.run() + } + + private func doctor(store: HeadlessConfigurationStore) throws -> Int { + let config = try store.loadOrCreate() + let validationFailures = HeadlessRootAccessPolicy.validationFailures(for: config.allowedRoots) + + HeadlessOutput.stdout("RepoPrompt Headless doctor") + HeadlessOutput.stdout("version: \(HeadlessVersion.versionString)") + HeadlessOutput.stdout("state_dir: \(store.paths.rootDirectory.path)") + HeadlessOutput.stdout("config: \(store.paths.configFile.path)") + HeadlessOutput.stdout("workspaces_dir: \(store.paths.workspacesDirectory.path)") + HeadlessOutput.stdout("exports_dir: \(store.paths.exportsDirectory.path)") + HeadlessOutput.stdout("secure_storage_namespace: \(HeadlessSecureStorage.namespace)") + HeadlessOutput.stdout("transport: direct stdio JSON-RPC") + HeadlessOutput.stdout("app_proxy_socket: unused") + if config.allowedRoots.isEmpty { + HeadlessOutput.stdout("allowed_roots: 0 (fail-closed; add one with `\(HeadlessVersion.executableName) config roots add /absolute/path --name NAME`)") + } else { + HeadlessOutput.stdout("allowed_roots: \(config.allowedRoots.count)") + } + HeadlessOutput.stdout("permissions: write_files=\(config.permissions.writeFiles), vcs_write=\(config.permissions.vcsWrite), launch_agents=\(config.permissions.launchAgents), export_outside_state_directory=\(config.permissions.exportOutsideStateDirectory)") + + guard validationFailures.isEmpty else { + HeadlessOutput.stdout("root_policy: invalid") + validationFailures.forEach { HeadlessOutput.stdout("- \($0)") } + return 2 + } + HeadlessOutput.stdout("root_policy: fail-closed ok") + return 0 + } + + private func config(_ arguments: [String], store: HeadlessConfigurationStore) throws -> Int { + guard let section = arguments.first else { + throw HeadlessCommandError(Self.configUsage, exitCode: 2) + } + let sectionArguments = Array(arguments.dropFirst()) + switch section { + case "roots": + return try configRoots(sectionArguments, store: store) + case "permissions": + return try configPermissions(sectionArguments, store: store) + default: + throw HeadlessCommandError("Unknown config section '\(section)'.\n\n\(Self.configUsage)", exitCode: 2) + } + } + + private func configRoots(_ arguments: [String], store: HeadlessConfigurationStore) throws -> Int { + guard let operation = arguments.first else { + throw HeadlessCommandError(Self.configRootsUsage, exitCode: 2) + } + let operationArguments = Array(arguments.dropFirst()) + switch operation { + case "list": + guard operationArguments.isEmpty else { + throw HeadlessCommandError("Unexpected arguments for config roots list: \(operationArguments.joined(separator: " "))", exitCode: 2) + } + let config = try store.loadOrCreate() + try HeadlessOutput.stdout(HeadlessJSONFormatting.string(HeadlessRootsListOutput(allowedRoots: config.allowedRoots))) + return 0 + case "add": + return try addRoot(operationArguments, store: store) + case "remove": + return try removeRoot(operationArguments, store: store) + default: + throw HeadlessCommandError("Unknown config roots operation '\(operation)'.\n\n\(Self.configRootsUsage)", exitCode: 2) + } + } + + private func addRoot(_ arguments: [String], store: HeadlessConfigurationStore) throws -> Int { + guard let rootPath = arguments.first else { + throw HeadlessCommandError(Self.configRootsUsage, exitCode: 2) + } + let rootOptions = try parseRootOptions(Array(arguments.dropFirst())) + let root = try HeadlessRootAccessPolicy.makeAllowedRoot(path: rootPath, name: rootOptions.name) + + let config = try store.update { document in + if document.allowedRoots.contains(where: { $0.resolvedPath == root.resolvedPath }) { + throw HeadlessCommandError("Allowed root already configured: \(root.resolvedPath)", exitCode: 2) + } + if document.allowedRoots.contains(where: { $0.name == root.name }) { + throw HeadlessCommandError("Allowed root name already configured: \(root.name)", exitCode: 2) + } + document.allowedRoots.append(root) + document.allowedRoots.sort { lhs, rhs in + lhs.name.localizedStandardCompare(rhs.name) == .orderedAscending + } + } + guard let added = config.allowedRoots.first(where: { $0.id == root.id }) else { + throw HeadlessCommandError("Root was added but could not be read back from config.") + } + try HeadlessOutput.stdout(HeadlessJSONFormatting.string(HeadlessRootMutationOutput(action: "added", root: added))) + return 0 + } + + private func removeRoot(_ arguments: [String], store: HeadlessConfigurationStore) throws -> Int { + guard arguments.count == 1, let token = arguments.first else { + throw HeadlessCommandError(Self.configRootsUsage, exitCode: 2) + } + var removedRoot: HeadlessAllowedRoot? + _ = try store.update { document in + guard let index = document.allowedRoots.firstIndex(where: { HeadlessRootAccessPolicy.rootMatches($0, token: token) }) else { + throw HeadlessCommandError("No allowed root matches '\(token)'.", exitCode: 2) + } + removedRoot = document.allowedRoots.remove(at: index) + } + guard let removedRoot else { + throw HeadlessCommandError("Root was removed but could not be reported.") + } + try HeadlessOutput.stdout(HeadlessJSONFormatting.string(HeadlessRootMutationOutput(action: "removed", root: removedRoot))) + return 0 + } + + private func configPermissions(_ arguments: [String], store: HeadlessConfigurationStore) throws -> Int { + guard let operation = arguments.first else { + throw HeadlessCommandError(Self.configPermissionsUsage, exitCode: 2) + } + let operationArguments = Array(arguments.dropFirst()) + switch operation { + case "list": + guard operationArguments.isEmpty else { + throw HeadlessCommandError("Unexpected arguments for config permissions list: \(operationArguments.joined(separator: " "))", exitCode: 2) + } + let config = try store.loadOrCreate() + try HeadlessOutput.stdout(HeadlessJSONFormatting.string(HeadlessPermissionsOutput(permissions: config.permissions))) + return 0 + case "set": + guard operationArguments.count == 2 else { + throw HeadlessCommandError(Self.configPermissionsUsage, exitCode: 2) + } + let permission = operationArguments[0] + let value = try parseBoolean(operationArguments[1]) + let config = try store.update { document in + try document.permissions.set(permission, to: value) + } + try HeadlessOutput.stdout(HeadlessJSONFormatting.string(HeadlessPermissionsOutput(permissions: config.permissions))) + return 0 + default: + throw HeadlessCommandError("Unknown config permissions operation '\(operation)'.\n\n\(Self.configPermissionsUsage)", exitCode: 2) + } + } + + private func parseGlobalOptions(_ arguments: [String]) throws -> ParsedGlobalOptions { + var remaining: [String] = [] + var stateDirectoryOverride: String? + var printVersion = false + var printHelp = false + var index = 0 + while index < arguments.count { + let argument = arguments[index] + switch argument { + case "--state-dir": + let valueIndex = index + 1 + guard valueIndex < arguments.count else { + throw HeadlessCommandError("--state-dir requires a path argument.", exitCode: 2) + } + stateDirectoryOverride = arguments[valueIndex] + index += 2 + case "--version", "-V": + printVersion = true + index += 1 + case "--help", "-h": + printHelp = true + index += 1 + default: + remaining.append(argument) + index += 1 + } + } + return ParsedGlobalOptions( + stateDirectoryOverride: stateDirectoryOverride, + printVersion: printVersion, + printHelp: printHelp, + remaining: remaining + ) + } + + private func parseRootOptions(_ arguments: [String]) throws -> RootOptions { + var name: String? + var index = 0 + while index < arguments.count { + let argument = arguments[index] + switch argument { + case "--name": + let valueIndex = index + 1 + guard valueIndex < arguments.count else { + throw HeadlessCommandError("--name requires a value.", exitCode: 2) + } + name = arguments[valueIndex] + index += 2 + default: + throw HeadlessCommandError("Unexpected config roots add argument: \(argument)", exitCode: 2) + } + } + return RootOptions(name: name) + } + + private func parseBoolean(_ value: String) throws -> Bool { + switch value.lowercased() { + case "true", "yes", "1", "on": true + case "false", "no", "0", "off": false + default: + throw HeadlessCommandError("Expected boolean true/false, received '\(value)'.", exitCode: 2) + } + } + + private struct ParsedGlobalOptions { + let stateDirectoryOverride: String? + let printVersion: Bool + let printHelp: Bool + let remaining: [String] + } + + private struct RootOptions { + let name: String? + } + + private static let usage = """ + Usage: repoprompt-headless [--state-dir PATH] [command] + + Commands: + serve Serve direct stdio JSON-RPC MCP (default; safe read-oriented tools) + doctor Validate state paths, config, fail-closed roots, and defaults + config roots list List configured allowed roots + config roots add PATH [--name NAME] + config roots remove ID|NAME|PATH + config permissions list List capability permissions (all default false) + config permissions set NAME true|false + --version Print version + """ + + private static let configUsage = """ + Usage: repoprompt-headless [--state-dir PATH] config ... + """ + + private static let configRootsUsage = """ + Usage: + repoprompt-headless [--state-dir PATH] config roots list + repoprompt-headless [--state-dir PATH] config roots add /absolute/path [--name NAME] + repoprompt-headless [--state-dir PATH] config roots remove ID|NAME|PATH + """ + + private static let configPermissionsUsage = """ + Usage: + repoprompt-headless [--state-dir PATH] config permissions list + repoprompt-headless [--state-dir PATH] config permissions set true|false + """ +} diff --git a/Sources/RepoPromptHeadless/CLI/HeadlessCommandError.swift b/Sources/RepoPromptHeadless/CLI/HeadlessCommandError.swift new file mode 100644 index 000000000..564633d6e --- /dev/null +++ b/Sources/RepoPromptHeadless/CLI/HeadlessCommandError.swift @@ -0,0 +1,15 @@ +import Foundation + +struct HeadlessCommandError: LocalizedError { + let message: String + let exitCode: Int + + init(_ message: String, exitCode: Int = 1) { + self.message = message + self.exitCode = exitCode + } + + var errorDescription: String? { + message + } +} diff --git a/Sources/RepoPromptHeadless/Configuration/HeadlessConfiguration.swift b/Sources/RepoPromptHeadless/Configuration/HeadlessConfiguration.swift new file mode 100644 index 000000000..6bc4fb787 --- /dev/null +++ b/Sources/RepoPromptHeadless/Configuration/HeadlessConfiguration.swift @@ -0,0 +1,122 @@ +import Foundation + +struct HeadlessConfigurationDocument: Codable, Equatable { + static let currentSchemaVersion = 1 + + var schemaVersion: Int + var allowedRoots: [HeadlessAllowedRoot] + var activeWorkspaceID: UUID? + var permissions: HeadlessPermissions + var createdAt: Date + var updatedAt: Date + + init(now: Date = Date()) { + schemaVersion = Self.currentSchemaVersion + allowedRoots = [] + activeWorkspaceID = nil + permissions = HeadlessPermissions() + createdAt = now + updatedAt = now + } + + mutating func touch(now: Date = Date()) { + updatedAt = now + } + + enum CodingKeys: String, CodingKey { + case schemaVersion = "schema_version" + case allowedRoots = "allowed_roots" + case activeWorkspaceID = "active_workspace_id" + case permissions + case createdAt = "created_at" + case updatedAt = "updated_at" + } +} + +struct HeadlessAllowedRoot: Codable, Equatable, Identifiable { + var id: UUID + var name: String + var path: String + var resolvedPath: String + var addedAt: Date + + enum CodingKeys: String, CodingKey { + case id + case name + case path + case resolvedPath = "resolved_path" + case addedAt = "added_at" + } +} + +struct HeadlessPermissions: Codable, Equatable { + static let supportedNames = [ + "write_files", + "vcs_write", + "launch_agents", + "export_outside_state_directory" + ] + + var writeFiles: Bool + var vcsWrite: Bool + var launchAgents: Bool + var exportOutsideStateDirectory: Bool + + init( + writeFiles: Bool = false, + vcsWrite: Bool = false, + launchAgents: Bool = false, + exportOutsideStateDirectory: Bool = false + ) { + self.writeFiles = writeFiles + self.vcsWrite = vcsWrite + self.launchAgents = launchAgents + self.exportOutsideStateDirectory = exportOutsideStateDirectory + } + + func value(for name: String) throws -> Bool { + switch name { + case "write_files": writeFiles + case "vcs_write": vcsWrite + case "launch_agents": launchAgents + case "export_outside_state_directory": exportOutsideStateDirectory + default: + throw HeadlessCommandError("Unknown permission '\(name)'. Supported permissions: \(Self.supportedNames.joined(separator: ", "))", exitCode: 2) + } + } + + mutating func set(_ name: String, to value: Bool) throws { + switch name { + case "write_files": writeFiles = value + case "vcs_write": vcsWrite = value + case "launch_agents": launchAgents = value + case "export_outside_state_directory": exportOutsideStateDirectory = value + default: + throw HeadlessCommandError("Unknown permission '\(name)'. Supported permissions: \(Self.supportedNames.joined(separator: ", "))", exitCode: 2) + } + } + + enum CodingKeys: String, CodingKey { + case writeFiles = "write_files" + case vcsWrite = "vcs_write" + case launchAgents = "launch_agents" + case exportOutsideStateDirectory = "export_outside_state_directory" + } +} + +struct HeadlessRootsListOutput: Codable { + let allowedRoots: [HeadlessAllowedRoot] + + enum CodingKeys: String, CodingKey { + case allowedRoots = "allowed_roots" + } +} + +struct HeadlessRootMutationOutput: Codable { + let action: String + let root: HeadlessAllowedRoot +} + +struct HeadlessPermissionsOutput: Codable { + let permissions: HeadlessPermissions +} diff --git a/Sources/RepoPromptHeadless/Configuration/HeadlessConfigurationStore.swift b/Sources/RepoPromptHeadless/Configuration/HeadlessConfigurationStore.swift new file mode 100644 index 000000000..a2703d2e4 --- /dev/null +++ b/Sources/RepoPromptHeadless/Configuration/HeadlessConfigurationStore.swift @@ -0,0 +1,71 @@ +import Foundation + +final class HeadlessConfigurationStore { + let paths: HeadlessStatePaths + private let fileManager: FileManager + + init(paths: HeadlessStatePaths, fileManager: FileManager = .default) { + self.paths = paths + self.fileManager = fileManager + } + + func loadOrCreate() throws -> HeadlessConfigurationDocument { + try HeadlessFileLock.withExclusiveLock(path: paths.configLockFile, stateRoot: paths.rootDirectory) { + try loadOrCreateUnlocked() + } + } + + /// Runs a state transaction while holding the cross-process catalog lock. + /// + /// Lock ordering is always `config.lock` first, followed by zero or more + /// workspace UUID locks. Code holding a workspace lock must never acquire + /// `config.lock`. This boundary serializes configuration mutations with + /// workspace catalog checks and updates across processes. + func withStateTransaction( + _ body: (inout HeadlessConfigurationDocument) throws -> T + ) throws -> (configuration: HeadlessConfigurationDocument, value: T) { + try HeadlessFileLock.withExclusiveLock(path: paths.configLockFile, stateRoot: paths.rootDirectory) { + var document = try loadOrCreateUnlocked() + let original = document + let value = try body(&document) + if document != original { + document.touch() + try saveUnlocked(document) + } + return (document, value) + } + } + + @discardableResult + func update(_ body: (inout HeadlessConfigurationDocument) throws -> Void) throws -> HeadlessConfigurationDocument { + try withStateTransaction(body).configuration + } + + private func loadOrCreateUnlocked() throws -> HeadlessConfigurationDocument { + try paths.ensureBaseDirectories(fileManager: fileManager) + guard let data = try HeadlessStateFileSecurity.readPrivateFileIfPresent(at: paths.configFile, stateRoot: paths.rootDirectory) else { + let document = HeadlessConfigurationDocument() + try saveUnlocked(document) + return document + } + let document = try HeadlessJSONFormatting.decoder().decode(HeadlessConfigurationDocument.self, from: data) + guard document.schemaVersion == HeadlessConfigurationDocument.currentSchemaVersion else { + throw HeadlessCommandError( + "Unsupported headless config schema_version \(document.schemaVersion); expected \(HeadlessConfigurationDocument.currentSchemaVersion).", + exitCode: 2 + ) + } + return document + } + + private func saveUnlocked(_ document: HeadlessConfigurationDocument) throws { + try paths.ensureBaseDirectories(fileManager: fileManager) + let data = try HeadlessJSONFormatting.encoder(prettyPrinted: true).encode(document) + try HeadlessStateFileSecurity.writePrivateFile( + data, + to: paths.configFile, + stateRoot: paths.rootDirectory, + fileManager: fileManager + ) + } +} diff --git a/Sources/RepoPromptHeadless/Configuration/HeadlessFileLock.swift b/Sources/RepoPromptHeadless/Configuration/HeadlessFileLock.swift new file mode 100644 index 000000000..67c02fe72 --- /dev/null +++ b/Sources/RepoPromptHeadless/Configuration/HeadlessFileLock.swift @@ -0,0 +1,52 @@ +import Darwin +import Foundation + +final class HeadlessFileLock { + typealias DescriptorOpenedHook = (Int32) -> Void + + private let path: URL + private let stateRoot: URL + private let descriptorOpenedHook: DescriptorOpenedHook? + private var descriptor: Int32 = -1 + + init(path: URL, stateRoot: URL? = nil, descriptorOpenedHook: DescriptorOpenedHook? = nil) { + self.path = path + let parent = path.deletingLastPathComponent() + self.stateRoot = stateRoot ?? (parent.lastPathComponent == "Workspaces" ? parent.deletingLastPathComponent() : parent) + self.descriptorOpenedHook = descriptorOpenedHook + } + + func lock() throws { + if descriptor >= 0 { + return + } + let fd = try HeadlessStateFileSecurity.openPrivateLockFile(at: path, stateRoot: stateRoot) + descriptorOpenedHook?(fd) + if flock(fd, LOCK_EX) != 0 { + let message = String(cString: strerror(errno)) + Darwin.close(fd) + throw HeadlessCommandError("Unable to lock \(path.path): \(message)", exitCode: 2) + } + descriptor = fd + } + + func unlock() { + guard descriptor >= 0 else { + return + } + _ = flock(descriptor, LOCK_UN) + Darwin.close(descriptor) + descriptor = -1 + } + + deinit { + unlock() + } + + static func withExclusiveLock(path: URL, stateRoot: URL? = nil, _ body: () throws -> T) throws -> T { + let lock = HeadlessFileLock(path: path, stateRoot: stateRoot) + try lock.lock() + defer { lock.unlock() } + return try body() + } +} diff --git a/Sources/RepoPromptHeadless/Configuration/HeadlessRootAccessPolicy.swift b/Sources/RepoPromptHeadless/Configuration/HeadlessRootAccessPolicy.swift new file mode 100644 index 000000000..e24041d47 --- /dev/null +++ b/Sources/RepoPromptHeadless/Configuration/HeadlessRootAccessPolicy.swift @@ -0,0 +1,115 @@ +import Foundation +import RepoPromptCore + +final class HeadlessRootAccessPolicy: WorkspaceAccessPolicy { + private let allowedRoots: [HeadlessAllowedRoot] + + init(allowedRoots: [HeadlessAllowedRoot]) { + self.allowedRoots = allowedRoots + } + + @MainActor + func allowsWorkspaceRoot(_ url: URL) -> Bool { + let resolvedPath = Self.resolvedPath(for: url) + return allowedRoots.contains { root in + Self.path(resolvedPath, isContainedInOrEqualTo: root.resolvedPath) + } + } + + nonisolated static func makeAllowedRoot(path: String, name: String?, fileManager: FileManager = .default) throws -> HeadlessAllowedRoot { + guard path.hasPrefix("/") else { + throw HeadlessCommandError("Allowed roots must be absolute paths. Received: \(path)", exitCode: 2) + } + + let displayURL = URL(fileURLWithPath: path, isDirectory: true).standardizedFileURL + let resolvedURL = try resolvedExistingDirectoryURL(for: displayURL, fileManager: fileManager) + guard resolvedURL.path != "/" else { + throw HeadlessCommandError("Refusing to add '/' as a headless allowed root.", exitCode: 2) + } + + let rootName = try normalizedName(name) ?? fallbackName(for: displayURL) + return HeadlessAllowedRoot( + id: UUID(), + name: rootName, + path: displayURL.path, + resolvedPath: resolvedURL.path, + addedAt: Date() + ) + } + + nonisolated static func validationFailures(for roots: [HeadlessAllowedRoot], fileManager: FileManager = .default) -> [String] { + roots.compactMap { root in + let url = URL(fileURLWithPath: root.path, isDirectory: true) + do { + let resolvedURL = try resolvedExistingDirectoryURL(for: url, fileManager: fileManager) + if resolvedURL.path != root.resolvedPath { + return "Root '\(root.name)' resolved path changed from \(root.resolvedPath) to \(resolvedURL.path). Remove and re-add it to accept the new target." + } + return nil + } catch { + return "Root '\(root.name)' is invalid: \(error.localizedDescription)" + } + } + } + + nonisolated static func rootMatches(_ root: HeadlessAllowedRoot, token: String, fileManager: FileManager = .default) -> Bool { + if root.id.uuidString.caseInsensitiveCompare(token) == .orderedSame || root.name == token || root.path == token || root.resolvedPath == token { + return true + } + guard token.hasPrefix("/") else { + return false + } + let tokenURL = URL(fileURLWithPath: token, isDirectory: true).standardizedFileURL + let resolvedToken = resolvedPath(for: tokenURL) + return root.path == tokenURL.path || root.resolvedPath == resolvedToken + } + + nonisolated static func resolvedExistingDirectoryURL(for url: URL, fileManager: FileManager) throws -> URL { + var isDirectory: ObjCBool = false + guard fileManager.fileExists(atPath: url.path, isDirectory: &isDirectory) else { + throw HeadlessCommandError("Directory does not exist: \(url.path)", exitCode: 2) + } + guard isDirectory.boolValue else { + throw HeadlessCommandError("Allowed root is not a directory: \(url.path)", exitCode: 2) + } + return url.resolvingSymlinksInPath().standardizedFileURL + } + + nonisolated static func resolvedPath(for url: URL) -> String { + url.resolvingSymlinksInPath().standardizedFileURL.path + } + + nonisolated static func path(_ candidate: String, isContainedInOrEqualTo root: String) -> Bool { + let candidateComponents = URL(fileURLWithPath: candidate).standardizedFileURL.pathComponents + let rootComponents = URL(fileURLWithPath: root).standardizedFileURL.pathComponents + guard candidateComponents.count >= rootComponents.count else { + return false + } + return zip(candidateComponents, rootComponents).allSatisfy(==) + } + + private nonisolated static func normalizedName(_ name: String?) throws -> String? { + guard let name else { + return nil + } + let trimmed = name.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { + return nil + } + guard trimmed != ".", trimmed != ".." else { + throw HeadlessCommandError("Allowed root name must not be '.' or '..'.", exitCode: 2) + } + guard !trimmed.contains("/"), !trimmed.contains("\\") else { + throw HeadlessCommandError("Allowed root name must not contain path separators: \(trimmed)", exitCode: 2) + } + guard trimmed.unicodeScalars.allSatisfy({ $0.value >= 32 && $0.value != 127 }) else { + throw HeadlessCommandError("Allowed root name must not contain control characters.", exitCode: 2) + } + return trimmed + } + + private nonisolated static func fallbackName(for url: URL) -> String { + let lastPathComponent = url.lastPathComponent.trimmingCharacters(in: .whitespacesAndNewlines) + return lastPathComponent.isEmpty ? url.path : lastPathComponent + } +} diff --git a/Sources/RepoPromptHeadless/Configuration/HeadlessStateFileSecurity.swift b/Sources/RepoPromptHeadless/Configuration/HeadlessStateFileSecurity.swift new file mode 100644 index 000000000..1bcc8a55f --- /dev/null +++ b/Sources/RepoPromptHeadless/Configuration/HeadlessStateFileSecurity.swift @@ -0,0 +1,421 @@ +import Darwin +import Foundation + +enum HeadlessStateFileSecurity { + typealias ParentDirectoryOpenedHook = (Int32) throws -> Void + + static let directoryMode: mode_t = S_IRWXU + static let fileMode: mode_t = S_IRUSR | S_IWUSR + + static func ensurePrivateDirectory(at url: URL, fileManager _: FileManager = .default) throws { + let descriptor = try openPrivateDirectory(at: url) + Darwin.close(descriptor) + } + + static func ensurePrivateDirectory( + at url: URL, + stateRoot: URL, + fileManager _: FileManager = .default + ) throws { + let descriptor = try openPrivateDirectory(at: url, stateRoot: stateRoot) + Darwin.close(descriptor) + } + + static func readPrivateFileIfPresent( + at url: URL, + stateRoot: URL, + parentDirectoryOpenedHook: ParentDirectoryOpenedHook? = nil + ) throws -> Data? { + let parentDescriptor = try openPrivateDirectory(at: url.deletingLastPathComponent(), stateRoot: stateRoot) + defer { Darwin.close(parentDescriptor) } + try parentDirectoryOpenedHook?(parentDescriptor) + let descriptor = try openLeaf( + url.lastPathComponent, + relativeTo: parentDescriptor, + flags: O_RDONLY | O_NONBLOCK | O_CLOEXEC | O_NOFOLLOW, + path: url.path, + allowMissing: true + ) + guard descriptor >= 0 else { return nil } + defer { Darwin.close(descriptor) } + try validateOpenedDescriptor( + descriptor, + path: url.path, + expectedKind: S_IFREG, + requiredMode: fileMode + ) + return try readAll(from: descriptor, path: url.path) + } + + static func writePrivateFile( + _ data: Data, + to url: URL, + stateRoot: URL, + fileManager _: FileManager = .default, + parentDirectoryOpenedHook: ParentDirectoryOpenedHook? = nil + ) throws { + let parentDescriptor = try openPrivateDirectory(at: url.deletingLastPathComponent(), stateRoot: stateRoot) + defer { Darwin.close(parentDescriptor) } + try parentDirectoryOpenedHook?(parentDescriptor) + try validateExistingPrivateFileIfPresent( + named: url.lastPathComponent, + relativeTo: parentDescriptor, + path: url.path + ) + + let temporaryName = ".\(url.lastPathComponent).\(UUID().uuidString).tmp" + let descriptor = temporaryName.withCString { pointer in + Darwin.openat( + parentDescriptor, + pointer, + O_CREAT | O_EXCL | O_WRONLY | O_CLOEXEC | O_NOFOLLOW, + fileMode + ) + } + guard descriptor >= 0 else { + throw posixError(operation: "create private state file", path: url.path, errorNumber: errno) + } + + var shouldRemoveTemporaryFile = true + defer { + Darwin.close(descriptor) + if shouldRemoveTemporaryFile { + temporaryName.withCString { pointer in _ = Darwin.unlinkat(parentDescriptor, pointer, 0) } + } + } + + try validateOpenedDescriptor( + descriptor, + path: url.path, + expectedKind: S_IFREG, + requiredMode: fileMode + ) + try writeAll(data, to: descriptor, path: url.path) + guard Darwin.fsync(descriptor) == 0 else { + throw posixError(operation: "sync private state file", path: url.path, errorNumber: errno) + } + let renameResult = temporaryName.withCString { temporaryPointer in + url.lastPathComponent.withCString { targetPointer in + Darwin.renameat(parentDescriptor, temporaryPointer, parentDescriptor, targetPointer) + } + } + guard renameResult == 0 else { + throw posixError(operation: "replace private state file", path: url.path, errorNumber: errno) + } + shouldRemoveTemporaryFile = false + try validateExistingPrivateFileIfPresent( + named: url.lastPathComponent, + relativeTo: parentDescriptor, + path: url.path, + requirePresent: true + ) + } + + static func openPrivateLockFile( + at url: URL, + stateRoot: URL, + fileManager _: FileManager = .default, + parentDirectoryOpenedHook: ParentDirectoryOpenedHook? = nil + ) throws -> Int32 { + let parentDescriptor = try openPrivateDirectory(at: url.deletingLastPathComponent(), stateRoot: stateRoot) + defer { Darwin.close(parentDescriptor) } + try parentDirectoryOpenedHook?(parentDescriptor) + let descriptor = url.lastPathComponent.withCString { pointer in + Darwin.openat( + parentDescriptor, + pointer, + O_CREAT | O_RDWR | O_CLOEXEC | O_NOFOLLOW, + fileMode + ) + } + guard descriptor >= 0 else { + throw posixError(operation: "open private lock file", path: url.path, errorNumber: errno) + } + do { + try validateOpenedDescriptor( + descriptor, + path: url.path, + expectedKind: S_IFREG, + requiredMode: fileMode + ) + return descriptor + } catch { + Darwin.close(descriptor) + throw error + } + } + + static func validateOpenedDescriptor( + _ descriptor: Int32, + path: String, + expectedKind: mode_t, + requiredMode: mode_t, + expectedOwner: uid_t = Darwin.geteuid() + ) throws { + var status = stat() + guard Darwin.fstat(descriptor, &status) == 0 else { + throw posixError(operation: "inspect private state path", path: path, errorNumber: errno) + } + guard status.st_uid == expectedOwner else { + throw HeadlessCommandError( + "Private state path has unexpected owner uid \(status.st_uid); expected \(expectedOwner): \(path)", + exitCode: 2 + ) + } + guard status.st_mode & S_IFMT == expectedKind else { + throw HeadlessCommandError("Private state path has an unsafe file type: \(path)", exitCode: 2) + } + guard status.st_nlink == 1 || expectedKind == S_IFDIR else { + throw HeadlessCommandError("Private state file must not have multiple hard links: \(path)", exitCode: 2) + } + guard Darwin.fchmod(descriptor, requiredMode) == 0 else { + throw posixError(operation: "enforce private permissions", path: path, errorNumber: errno) + } + } + + private static func openPrivateDirectory(at url: URL, stateRoot: URL) throws -> Int32 { + let standardizedRoot = stateRoot.standardizedFileURL + let standardizedURL = url.standardizedFileURL + let rootPath = standardizedRoot.path + let targetPath = standardizedURL.path + guard targetPath == rootPath || targetPath.hasPrefix(rootPath + "/") else { + throw HeadlessCommandError("Private state path escapes its state root: \(targetPath)", exitCode: 2) + } + + var descriptor = try openPrivateDirectory(at: standardizedRoot) + if targetPath == rootPath { return descriptor } + let suffix = String(targetPath.dropFirst(rootPath.count + 1)) + do { + for component in suffix.split(separator: "/").map(String.init) { + let mkdirResult = component.withCString { pointer in + Darwin.mkdirat(descriptor, pointer, directoryMode) + } + if mkdirResult != 0, errno != EEXIST { + throw posixError(operation: "create private state directory", path: targetPath, errorNumber: errno) + } + let nextDescriptor = try openLeaf( + component, + relativeTo: descriptor, + flags: O_RDONLY | O_DIRECTORY | O_CLOEXEC | O_NOFOLLOW, + path: targetPath, + allowMissing: false + ) + try validateOpenedDescriptor( + nextDescriptor, + path: targetPath, + expectedKind: S_IFDIR, + requiredMode: directoryMode + ) + Darwin.close(descriptor) + descriptor = nextDescriptor + } + return descriptor + } catch { + Darwin.close(descriptor) + throw error + } + } + + private static func openPrivateDirectory(at url: URL) throws -> Int32 { + let standardizedURL = url.standardizedFileURL + guard standardizedURL.path.hasPrefix("/"), !standardizedURL.lastPathComponent.isEmpty else { + throw HeadlessCommandError("Private state directory must be an absolute non-root path: \(url.path)", exitCode: 2) + } + let parentDescriptor = try openDirectoryPathCreatingMissing(standardizedURL.deletingLastPathComponent().path) + defer { Darwin.close(parentDescriptor) } + + let name = standardizedURL.lastPathComponent + let mkdirResult = name.withCString { pointer in + Darwin.mkdirat(parentDescriptor, pointer, directoryMode) + } + if mkdirResult != 0, errno != EEXIST { + throw posixError(operation: "create private state directory", path: standardizedURL.path, errorNumber: errno) + } + let descriptor = try openLeaf( + name, + relativeTo: parentDescriptor, + flags: O_RDONLY | O_DIRECTORY | O_CLOEXEC | O_NOFOLLOW, + path: standardizedURL.path, + allowMissing: false + ) + do { + try validateOpenedDescriptor( + descriptor, + path: standardizedURL.path, + expectedKind: S_IFDIR, + requiredMode: directoryMode + ) + return descriptor + } catch { + Darwin.close(descriptor) + throw error + } + } + + private static func openDirectoryPathCreatingMissing(_ path: String) throws -> Int32 { + var ancestor = URL(fileURLWithPath: path, isDirectory: true).standardizedFileURL + var missingComponents: [String] = [] + while !pathExistsWithoutFollowingLeaf(ancestor.path) { + let component = ancestor.lastPathComponent + guard !component.isEmpty else { + throw HeadlessCommandError("Unable to find an existing ancestor for private state path: \(path)", exitCode: 2) + } + missingComponents.insert(component, at: 0) + ancestor.deleteLastPathComponent() + } + guard let canonicalAncestor = canonicalExistingPath(ancestor.path) else { + throw HeadlessCommandError("Unable to resolve private state ancestor: \(ancestor.path)", exitCode: 2) + } + + var descriptor = try openCanonicalDirectoryPath(canonicalAncestor) + do { + for component in missingComponents { + let mkdirResult = component.withCString { pointer in + Darwin.mkdirat(descriptor, pointer, directoryMode) + } + if mkdirResult != 0, errno != EEXIST { + throw posixError(operation: "create private state ancestor", path: path, errorNumber: errno) + } + let nextDescriptor = try openLeaf( + component, + relativeTo: descriptor, + flags: O_RDONLY | O_DIRECTORY | O_CLOEXEC | O_NOFOLLOW, + path: path, + allowMissing: false + ) + try validateOpenedDescriptor( + nextDescriptor, + path: path, + expectedKind: S_IFDIR, + requiredMode: directoryMode + ) + Darwin.close(descriptor) + descriptor = nextDescriptor + } + return descriptor + } catch { + Darwin.close(descriptor) + throw error + } + } + + private static func openCanonicalDirectoryPath(_ path: String) throws -> Int32 { + var descriptor = Darwin.open("/", O_RDONLY | O_DIRECTORY | O_CLOEXEC) + guard descriptor >= 0 else { + throw posixError(operation: "open filesystem root", path: path, errorNumber: errno) + } + do { + let components = path.split(separator: "/").map(String.init) + for component in components { + let nextDescriptor = try openLeaf( + component, + relativeTo: descriptor, + flags: O_RDONLY | O_DIRECTORY | O_CLOEXEC | O_NOFOLLOW, + path: path, + allowMissing: false + ) + Darwin.close(descriptor) + descriptor = nextDescriptor + } + return descriptor + } catch { + Darwin.close(descriptor) + throw error + } + } + + private static func openLeaf( + _ name: String, + relativeTo parentDescriptor: Int32, + flags: Int32, + path: String, + allowMissing: Bool + ) throws -> Int32 { + guard !name.isEmpty, name != ".", name != "..", !name.utf8.contains(0) else { + throw HeadlessCommandError("Private state path contains an invalid component: \(path)", exitCode: 2) + } + let descriptor = name.withCString { pointer in + Darwin.openat(parentDescriptor, pointer, flags) + } + if descriptor < 0, allowMissing, errno == ENOENT { + return -1 + } + guard descriptor >= 0 else { + throw posixError(operation: "open private state path", path: path, errorNumber: errno) + } + return descriptor + } + + private static func validateExistingPrivateFileIfPresent( + named name: String, + relativeTo parentDescriptor: Int32, + path: String, + requirePresent: Bool = false + ) throws { + let descriptor = try openLeaf( + name, + relativeTo: parentDescriptor, + flags: O_RDONLY | O_NONBLOCK | O_CLOEXEC | O_NOFOLLOW, + path: path, + allowMissing: !requirePresent + ) + guard descriptor >= 0 else { return } + defer { Darwin.close(descriptor) } + try validateOpenedDescriptor( + descriptor, + path: path, + expectedKind: S_IFREG, + requiredMode: fileMode + ) + } + + private static func pathExistsWithoutFollowingLeaf(_ path: String) -> Bool { + var status = stat() + return Darwin.lstat(path, &status) == 0 + } + + private static func canonicalExistingPath(_ path: String) -> String? { + path.withCString { pointer in + guard let resolved = Darwin.realpath(pointer, nil) else { return nil } + defer { Darwin.free(resolved) } + return String(cString: resolved) + } + } + + private static func readAll(from descriptor: Int32, path: String) throws -> Data { + var data = Data() + var buffer = [UInt8](repeating: 0, count: 64 * 1024) + while true { + let count = buffer.withUnsafeMutableBytes { rawBuffer -> Int in + guard let baseAddress = rawBuffer.baseAddress else { return 0 } + return Darwin.read(descriptor, baseAddress, rawBuffer.count) + } + if count == 0 { return data } + if count < 0 { + if errno == EINTR { continue } + throw posixError(operation: "read private state file", path: path, errorNumber: errno) + } + data.append(contentsOf: buffer[0 ..< count]) + } + } + + private static func writeAll(_ data: Data, to descriptor: Int32, path: String) throws { + try data.withUnsafeBytes { rawBuffer in + guard let baseAddress = rawBuffer.baseAddress else { return } + var offset = 0 + while offset < rawBuffer.count { + let count = Darwin.write(descriptor, baseAddress.advanced(by: offset), rawBuffer.count - offset) + if count < 0 { + if errno == EINTR { continue } + throw posixError(operation: "write private state file", path: path, errorNumber: errno) + } + offset += count + } + } + } + + private static func posixError(operation: String, path: String, errorNumber: Int32) -> HeadlessCommandError { + let detail = String(cString: Darwin.strerror(errorNumber)) + return HeadlessCommandError("Unable to \(operation) '\(path)': \(detail)", exitCode: 2) + } +} diff --git a/Sources/RepoPromptHeadless/Configuration/HeadlessStatePaths.swift b/Sources/RepoPromptHeadless/Configuration/HeadlessStatePaths.swift new file mode 100644 index 000000000..b4610ce51 --- /dev/null +++ b/Sources/RepoPromptHeadless/Configuration/HeadlessStatePaths.swift @@ -0,0 +1,86 @@ +import Foundation + +struct HeadlessStatePaths: Equatable { + static let stateDirectoryEnvironmentVariable = "REPOPROMPT_HEADLESS_STATE_DIR" + + let rootDirectory: URL + + var configFile: URL { + rootDirectory.appendingPathComponent("config.json", isDirectory: false) + } + + var workspacesDirectory: URL { + rootDirectory.appendingPathComponent("Workspaces", isDirectory: true) + } + + var exportsDirectory: URL { + rootDirectory.appendingPathComponent("Exports", isDirectory: true) + } + + var configLockFile: URL { + rootDirectory.appendingPathComponent("config.lock", isDirectory: false) + } + + func workspaceLockFile(for id: UUID) -> URL { + workspacesDirectory.appendingPathComponent("\(id.uuidString).lock", isDirectory: false) + } + + static func resolve( + cliOverride: String?, + environment: [String: String] = ProcessInfo.processInfo.environment, + fileManager: FileManager = .default + ) throws -> HeadlessStatePaths { + if let cliOverride, !cliOverride.isEmpty { + return HeadlessStatePaths(rootDirectory: absoluteDirectoryURL(for: cliOverride, fileManager: fileManager)) + } + if let environmentOverride = environment[stateDirectoryEnvironmentVariable], !environmentOverride.isEmpty { + return HeadlessStatePaths(rootDirectory: absoluteDirectoryURL(for: environmentOverride, fileManager: fileManager)) + } + + let root = fileManager.homeDirectoryForCurrentUser + .appendingPathComponent("Library", isDirectory: true) + .appendingPathComponent("Application Support", isDirectory: true) + .appendingPathComponent("RepoPrompt CE", isDirectory: true) + .appendingPathComponent("Headless", isDirectory: true) + .standardizedFileURL + return HeadlessStatePaths(rootDirectory: root) + } + + func ensureBaseDirectories(fileManager: FileManager = .default) throws { + try HeadlessStateFileSecurity.ensurePrivateDirectory(at: rootDirectory, fileManager: fileManager) + try HeadlessStateFileSecurity.ensurePrivateDirectory( + at: workspacesDirectory, + stateRoot: rootDirectory, + fileManager: fileManager + ) + try HeadlessStateFileSecurity.ensurePrivateDirectory( + at: exportsDirectory, + stateRoot: rootDirectory, + fileManager: fileManager + ) + } + + private static func absoluteDirectoryURL(for path: String, fileManager: FileManager) -> URL { + let expanded = expandTilde(in: path, fileManager: fileManager) + let absolutePath: String = if expanded.hasPrefix("/") { + expanded + } else { + URL(fileURLWithPath: fileManager.currentDirectoryPath, isDirectory: true) + .appendingPathComponent(expanded, isDirectory: true) + .path + } + return URL(fileURLWithPath: absolutePath, isDirectory: true).standardizedFileURL + } + + private static func expandTilde(in path: String, fileManager: FileManager) -> String { + if path == "~" { + return fileManager.homeDirectoryForCurrentUser.path + } + if path.hasPrefix("~/") { + return fileManager.homeDirectoryForCurrentUser + .appendingPathComponent(String(path.dropFirst(2))) + .path + } + return path + } +} diff --git a/Sources/RepoPromptHeadless/HeadlessVersion.swift b/Sources/RepoPromptHeadless/HeadlessVersion.swift new file mode 100644 index 000000000..21ace45e3 --- /dev/null +++ b/Sources/RepoPromptHeadless/HeadlessVersion.swift @@ -0,0 +1,12 @@ +enum HeadlessVersion { + static let executableName = "repoprompt-headless" + static let displayName = "RepoPrompt Headless" + static let marketingVersion = "1.0.6" + static let buildNumber = "7" + static let mcpProtocolVersion = "2024-11-05" + static let secureStorageNamespace = "com.pvncher.repoprompt.ce.headless.keychain" + + static var versionString: String { + "\(marketingVersion) (build \(buildNumber))" + } +} diff --git a/Sources/RepoPromptHeadless/MCP/HeadlessJSONRPC.swift b/Sources/RepoPromptHeadless/MCP/HeadlessJSONRPC.swift new file mode 100644 index 000000000..1dc3b23ed --- /dev/null +++ b/Sources/RepoPromptHeadless/MCP/HeadlessJSONRPC.swift @@ -0,0 +1,92 @@ +import Foundation + +struct HeadlessRPCAction { + let responseData: Data? + /// True only after an `exit` notification is received following shutdown. + let shouldExit: Bool +} + +enum HeadlessRPCSubmission { + case completed(HeadlessRPCAction) + case pending(Task) +} + +enum HeadlessJSONRPCMessageKind { + case request(id: Any) + case notification +} + +enum HeadlessJSONRPC { + static func requestObject(from frame: Data) throws -> [String: Any] { + let json = try JSONSerialization.jsonObject(with: frame, options: []) + guard let object = json as? [String: Any] else { + throw HeadlessJSONRPCError.invalidRequest("JSON-RPC frame must be an object.") + } + return object + } + + static func messageKind(for object: [String: Any]) -> HeadlessJSONRPCMessageKind { + if object.keys.contains("id") { + return .request(id: object["id"] ?? NSNull()) + } + return .notification + } + + static func response(id: Any, result: Any) -> Data { + encode([ + "jsonrpc": "2.0", + "id": normalizedID(id), + "result": result + ]) + } + + static func errorResponse(id: Any, code: Int, message: String, data: Any? = nil) -> Data { + var error: [String: Any] = [ + "code": code, + "message": message + ] + if let data { + error["data"] = data + } + return encode([ + "jsonrpc": "2.0", + "id": normalizedID(id), + "error": error + ]) + } + + private static func encode(_ object: [String: Any]) -> Data { + do { + return try JSONSerialization.data(withJSONObject: object, options: []) + } catch { + let fallback: [String: Any] = [ + "jsonrpc": "2.0", + "id": NSNull(), + "error": [ + "code": -32603, + "message": "Failed to encode JSON-RPC response: \(error.localizedDescription)" + ] + ] + return (try? JSONSerialization.data(withJSONObject: fallback, options: [])) ?? Data() + } + } + + private static func normalizedID(_ id: Any) -> Any { + switch id { + case is String, is Int, is Int64, is Double, is NSNull: + id + default: + NSNull() + } + } +} + +enum HeadlessJSONRPCError: LocalizedError { + case invalidRequest(String) + + var errorDescription: String? { + switch self { + case let .invalidRequest(message): message + } + } +} diff --git a/Sources/RepoPromptHeadless/MCP/HeadlessMCPServer.swift b/Sources/RepoPromptHeadless/MCP/HeadlessMCPServer.swift new file mode 100644 index 000000000..f457bcc38 --- /dev/null +++ b/Sources/RepoPromptHeadless/MCP/HeadlessMCPServer.swift @@ -0,0 +1,349 @@ +import Foundation + +actor HeadlessMCPServer { + typealias ToolCallOverride = (String, HeadlessJSONObject) async throws -> HeadlessJSONObject + + private enum LifecycleState { + case uninitialized + case awaitingInitializedNotification + case ready + case shutdown + } + + private enum RequestKey: Hashable { + case string(String) + case integer(Int64) + case number(Double) + case null + + init?(id: Any) { + switch id { + case is NSNull: + self = .null + case let value as String: + self = .string(value) + case is Bool: + return nil + case let value as Int: + self = .integer(Int64(value)) + case let value as Int64: + self = .integer(value) + case let value as Double where value.isFinite: + self = .number(value) + default: + return nil + } + } + } + + private let configurationStore: HeadlessConfigurationStore + private let host: HeadlessHost + private let registry: HeadlessToolRegistry + private let toolCallOverride: ToolCallOverride? + private var lifecycleState: LifecycleState = .uninitialized + private var activeRequests: [RequestKey: Task] = [:] + private var serializedToolTail: Task? + + init( + configurationStore: HeadlessConfigurationStore, + toolCallOverride: ToolCallOverride? = nil + ) { + self.configurationStore = configurationStore + host = HeadlessHost(configurationStore: configurationStore) + registry = HeadlessToolRegistry(host: host, configurationStore: configurationStore) + self.toolCallOverride = toolCallOverride + } + + func handle(frame: Data) async -> HeadlessRPCAction { + switch submit(frame: frame) { + case let .completed(action): + action + case let .pending(task): + await task.value + } + } + + func submit(frame: Data) -> HeadlessRPCSubmission { + do { + let object = try HeadlessJSONRPC.requestObject(from: frame) + return submit(object: object) + } catch let error as HeadlessJSONRPCError { + return .completed(HeadlessRPCAction( + responseData: HeadlessJSONRPC.errorResponse(id: NSNull(), code: -32600, message: error.localizedDescription), + shouldExit: false + )) + } catch { + return .completed(HeadlessRPCAction( + responseData: HeadlessJSONRPC.errorResponse(id: NSNull(), code: -32700, message: "Parse error: \(error.localizedDescription)"), + shouldExit: false + )) + } + } + + func cancelActiveRequests() { + for task in activeRequests.values { + task.cancel() + } + } + + func activeRequestCountForTesting() -> Int { + activeRequests.count + } + + private func submit(object: [String: Any]) -> HeadlessRPCSubmission { + let hasID = object.keys.contains("id") + let id = object["id"] ?? NSNull() + guard object["jsonrpc"] as? String == "2.0" else { + return .completed(invalidRequest(id: hasID ? id : NSNull(), message: "Only JSON-RPC 2.0 requests are supported.")) + } + guard let method = object["method"] as? String, !method.isEmpty else { + return .completed(invalidRequest(id: hasID ? id : NSNull(), message: "JSON-RPC request is missing a method.")) + } + + switch HeadlessJSONRPC.messageKind(for: object) { + case .notification: + return .completed(handleNotification(method: method, object: object)) + case let .request(requestID): + return handleRequest(method: method, id: requestID, object: object) + } + } + + private func handleNotification(method: String, object: [String: Any]) -> HeadlessRPCAction { + switch method { + case "notifications/initialized": + if lifecycleState == .awaitingInitializedNotification { + lifecycleState = .ready + } + return HeadlessRPCAction(responseData: nil, shouldExit: false) + case "notifications/cancelled": + cancelRequest(from: object["params"]) + return HeadlessRPCAction(responseData: nil, shouldExit: false) + case "exit": + return HeadlessRPCAction(responseData: nil, shouldExit: lifecycleState == .shutdown) + default: + // MCP methods other than notifications/initialized, notifications/cancelled, + // and exit are request-only. Unknown notifications are ignored per JSON-RPC. + return HeadlessRPCAction(responseData: nil, shouldExit: false) + } + } + + private func handleRequest(method: String, id: Any, object: [String: Any]) -> HeadlessRPCSubmission { + if lifecycleState == .shutdown { + return .completed(requestError( + hasID: true, + id: id, + code: -32600, + message: "Server has shut down and no longer accepts requests." + )) + } + + switch method { + case "notifications/initialized", "notifications/cancelled": + return .completed(requestError( + hasID: true, + id: id, + code: -32600, + message: "\(method) must be sent as a notification without an id." + )) + case "exit": + return .completed(requestError( + hasID: true, + id: id, + code: -32600, + message: "exit must be sent as a notification without an id." + )) + case "initialize": + guard lifecycleState == .uninitialized else { + return .completed(requestError( + hasID: true, + id: id, + code: -32600, + message: "initialize may only be sent once." + )) + } + guard validInitializeParams(object["params"]) else { + return .completed(requestError( + hasID: true, + id: id, + code: -32602, + message: "initialize requires params.protocolVersion, params.capabilities, and params.clientInfo with non-empty name and version." + )) + } + lifecycleState = .awaitingInitializedNotification + return .completed(requestResult(hasID: true, id: id, result: initializeResult())) + default: + guard lifecycleState == .ready else { + return .completed(requestError( + hasID: true, + id: id, + code: -32002, + message: "Server not initialized. Send initialize, then notifications/initialized." + )) + } + return executeReadyRequest(method: method, id: id, object: object) + } + } + + private func executeReadyRequest(method: String, id: Any, object: [String: Any]) -> HeadlessRPCSubmission { + switch method { + case "ping": + return .completed(requestResult(hasID: true, id: id, result: [:])) + case "tools/list": + return .completed(requestResult(hasID: true, id: id, result: ["tools": registry.listDescriptors()])) + case "tools/call": + guard let params = object["params"] as? [String: Any] else { + return .completed(requestError(hasID: true, id: id, code: -32602, message: "tools/call requires params.")) + } + guard let name = params["name"] as? String, !name.isEmpty else { + return .completed(requestError(hasID: true, id: id, code: -32602, message: "tools/call requires params.name.")) + } + let arguments: [String: Any] + if let rawArguments = params["arguments"] { + if rawArguments is NSNull { + arguments = [:] + } else if let objectArguments = rawArguments as? [String: Any] { + arguments = objectArguments + } else { + return .completed(requestError(hasID: true, id: id, code: -32602, message: "tools/call params.arguments must be an object when provided.")) + } + } else { + arguments = [:] + } + return startToolRequest(id: id, name: name, arguments: arguments) + case "shutdown": + lifecycleState = .shutdown + return .completed(requestResult(hasID: true, id: id, result: NSNull())) + default: + return .completed(requestError(hasID: true, id: id, code: -32601, message: "Method not found: \(method)")) + } + } + + private func startToolRequest(id: Any, name: String, arguments: HeadlessJSONObject) -> HeadlessRPCSubmission { + guard let key = RequestKey(id: id) else { + return .completed(requestError(hasID: true, id: id, code: -32600, message: "JSON-RPC request id must be a string, number, or null.")) + } + guard activeRequests[key] == nil else { + return .completed(requestError(hasID: true, id: id, code: -32600, message: "A request with the same JSON-RPC id is already active.")) + } + + let previousSerializedTask = Self.runsConcurrently(toolName: name) ? nil : serializedToolTail + let task = Task { [weak self, registry, toolCallOverride] in + if let previousSerializedTask { + await previousSerializedTask.value + } + let action: HeadlessRPCAction + do { + try Task.checkCancellation() + let result: HeadlessJSONObject = if let toolCallOverride { + try await toolCallOverride(name, arguments) + } else { + await registry.call(name: name, arguments: arguments) + } + try Task.checkCancellation() + action = HeadlessRPCAction( + responseData: HeadlessJSONRPC.response(id: id, result: result), + shouldExit: false + ) + } catch is CancellationError { + action = HeadlessRPCAction( + responseData: HeadlessJSONRPC.errorResponse(id: id, code: -32800, message: "Request cancelled."), + shouldExit: false + ) + } catch { + action = HeadlessRPCAction( + responseData: HeadlessJSONRPC.errorResponse(id: id, code: -32603, message: "Tool request failed: \(error.localizedDescription)"), + shouldExit: false + ) + } + await self?.requestCompleted(key) + return action + } + activeRequests[key] = task + if previousSerializedTask != nil || !Self.runsConcurrently(toolName: name) { + serializedToolTail = Task { + _ = await task.value + } + } + return .pending(task) + } + + private func requestCompleted(_ key: RequestKey) { + activeRequests.removeValue(forKey: key) + } + + private func cancelRequest(from rawParams: Any?) { + guard let params = rawParams as? [String: Any], + let requestID = params["requestId"], + let key = RequestKey(id: requestID) + else { + return + } + activeRequests[key]?.cancel() + } + + private static func runsConcurrently(toolName: String) -> Bool { + switch toolName { + case "get_file_tree", "get_code_structure", "read_file", "file_search": + true + default: + false + } + } + + private func initializeResult() -> [String: Any] { + let configuredRootCount = (try? configurationStore.loadOrCreate().allowedRoots.count) ?? 0 + return [ + "protocolVersion": HeadlessVersion.mcpProtocolVersion, + "capabilities": [ + "tools": [:] + ], + "serverInfo": [ + "name": HeadlessVersion.displayName, + "version": HeadlessVersion.marketingVersion + ], + "instructions": "RepoPrompt Headless is running the standalone read-oriented safe profile over direct stdio. Configure allowed roots with `repoprompt-headless config roots add /absolute/path --name NAME`. Only bind_context, constrained manage_workspaces, manage_selection, workspace_context, get_file_tree, get_code_structure, read_file, file_search, and prompt are enabled.", + "headless": [ + "configuredRootCount": configuredRootCount, + "stateDirectory": configurationStore.paths.rootDirectory.path, + "safeToolsEnabled": true + ] + ] + } + + private func requestResult(hasID: Bool, id: Any, result: Any, shouldExit: Bool = false) -> HeadlessRPCAction { + guard hasID else { + return HeadlessRPCAction(responseData: nil, shouldExit: shouldExit) + } + return HeadlessRPCAction(responseData: HeadlessJSONRPC.response(id: id, result: result), shouldExit: shouldExit) + } + + private func requestError(hasID: Bool, id: Any, code: Int, message: String) -> HeadlessRPCAction { + guard hasID else { + return HeadlessRPCAction(responseData: nil, shouldExit: false) + } + return HeadlessRPCAction(responseData: HeadlessJSONRPC.errorResponse(id: id, code: code, message: message), shouldExit: false) + } + + private func invalidRequest(id: Any, message: String) -> HeadlessRPCAction { + HeadlessRPCAction( + responseData: HeadlessJSONRPC.errorResponse(id: id, code: -32600, message: message), + shouldExit: false + ) + } + + private func validInitializeParams(_ rawParams: Any?) -> Bool { + guard let params = rawParams as? [String: Any], + let protocolVersion = params["protocolVersion"] as? String, + !protocolVersion.isEmpty, + params["capabilities"] is [String: Any], + let clientInfo = params["clientInfo"] as? [String: Any], + let clientName = clientInfo["name"] as? String, + !clientName.isEmpty, + let clientVersion = clientInfo["version"] as? String, + !clientVersion.isEmpty + else { + return false + } + return true + } +} diff --git a/Sources/RepoPromptHeadless/MCP/HeadlessNewlineFrameDecoder.swift b/Sources/RepoPromptHeadless/MCP/HeadlessNewlineFrameDecoder.swift new file mode 100644 index 000000000..a74034eb5 --- /dev/null +++ b/Sources/RepoPromptHeadless/MCP/HeadlessNewlineFrameDecoder.swift @@ -0,0 +1,74 @@ +import Foundation + +struct HeadlessNewlineFrameDecoder { + enum Event: Equatable { + case frame(Data) + case parseError(message: String) + } + + static let defaultMaximumFrameBytes = 1024 * 1024 + + private let maximumFrameBytes: Int + private var pendingFrame = Data() + private var isDiscardingOversizedFrame = false + + init(maximumFrameBytes: Int = Self.defaultMaximumFrameBytes) { + precondition(maximumFrameBytes > 0) + self.maximumFrameBytes = maximumFrameBytes + } + + mutating func append(_ data: Data) -> [Event] { + var events: [Event] = [] + for byte in data { + if isDiscardingOversizedFrame { + if byte == 0x0A { + isDiscardingOversizedFrame = false + } + continue + } + + if byte == 0x0A { + if pendingFrame.last == 0x0D { + pendingFrame.removeLast() + } + if !pendingFrame.isEmpty { + events.append(.frame(pendingFrame)) + } + pendingFrame.removeAll(keepingCapacity: true) + continue + } + + guard pendingFrame.count < maximumFrameBytes else { + events.append(.parseError(message: oversizedFrameMessage)) + pendingFrame.removeAll(keepingCapacity: true) + isDiscardingOversizedFrame = true + continue + } + pendingFrame.append(byte) + } + return events + } + + mutating func finish() -> [Event] { + defer { + pendingFrame.removeAll(keepingCapacity: false) + isDiscardingOversizedFrame = false + } + + guard !isDiscardingOversizedFrame, !pendingFrame.isEmpty else { + return [] + } + guard !pendingFrame.allSatisfy(Self.isASCIIWhitespace) else { + return [] + } + return [.parseError(message: "Incomplete newline-delimited JSON-RPC frame at EOF.")] + } + + private var oversizedFrameMessage: String { + "JSON-RPC frame exceeds headless maximum of \(maximumFrameBytes) bytes." + } + + private static func isASCIIWhitespace(_ byte: UInt8) -> Bool { + byte == 0x20 || (0x09 ... 0x0D).contains(byte) + } +} diff --git a/Sources/RepoPromptHeadless/MCP/HeadlessStdioTransport.swift b/Sources/RepoPromptHeadless/MCP/HeadlessStdioTransport.swift new file mode 100644 index 000000000..8a8b0b811 --- /dev/null +++ b/Sources/RepoPromptHeadless/MCP/HeadlessStdioTransport.swift @@ -0,0 +1,107 @@ +import Foundation + +final class HeadlessStdioTransport { + private let server: HeadlessMCPServer + private let writer: HeadlessStdoutWriter + private let responseTracker = HeadlessTransportResponseTracker() + private var decoder = HeadlessNewlineFrameDecoder() + private var terminated = false + + init(server: HeadlessMCPServer, writer: HeadlessStdoutWriter) { + self.server = server + self.writer = writer + } + + func run() async throws { + while !terminated { + let chunk = FileHandle.standardInput.availableData + if chunk.isEmpty { + await finish() + return + } + if await receive(chunk) { + await waitForPendingResponses() + return + } + } + } + + /// Feeds bytes into the newline-delimited transport without waiting for long-running + /// request work. This is also the deterministic test seam for lifecycle interleaving. + @discardableResult + func receive(_ chunk: Data) async -> Bool { + guard !terminated else { return true } + return await handle(events: decoder.append(chunk)) + } + + func finish() async { + guard !terminated else { + await waitForPendingResponses() + return + } + _ = await handle(events: decoder.finish()) + await server.cancelActiveRequests() + await waitForPendingResponses() + terminated = true + } + + func waitForPendingResponses() async { + await responseTracker.waitForAll() + } + + private func handle(events: [HeadlessNewlineFrameDecoder.Event]) async -> Bool { + for event in events { + switch event { + case let .frame(frame): + let submission = await server.submit(frame: frame) + switch submission { + case let .completed(action): + if let responseData = action.responseData { + await writer.write(responseData) + } + if action.shouldExit { + terminated = true + return true + } + case let .pending(task): + await responseTracker.track(requestTask: task, writer: writer) + } + case let .parseError(message): + await writer.write( + HeadlessJSONRPC.errorResponse( + id: NSNull(), + code: -32700, + message: message + ) + ) + } + } + return false + } +} + +private actor HeadlessTransportResponseTracker { + private var deliveries: [UUID: Task] = [:] + + func track(requestTask: Task, writer: HeadlessStdoutWriter) { + let deliveryID = UUID() + let delivery = Task { [weak self] in + let action = await requestTask.value + if let responseData = action.responseData { + await writer.write(responseData) + } + await self?.finished(deliveryID) + } + deliveries[deliveryID] = delivery + } + + func waitForAll() async { + while let delivery = deliveries.values.first { + await delivery.value + } + } + + private func finished(_ deliveryID: UUID) { + deliveries.removeValue(forKey: deliveryID) + } +} diff --git a/Sources/RepoPromptHeadless/MCP/HeadlessStdoutWriter.swift b/Sources/RepoPromptHeadless/MCP/HeadlessStdoutWriter.swift new file mode 100644 index 000000000..e662cf100 --- /dev/null +++ b/Sources/RepoPromptHeadless/MCP/HeadlessStdoutWriter.swift @@ -0,0 +1,24 @@ +import Foundation + +actor HeadlessStdoutWriter { + private let writeHandler: (Data) -> Void + + init(fileHandle: FileHandle = .standardOutput) { + writeHandler = { data in + fileHandle.write(data) + } + } + + init(writeHandler: @escaping (Data) -> Void) { + self.writeHandler = writeHandler + } + + func write(_ data: Data) { + guard !data.isEmpty else { + return + } + var framed = data + framed.append(0x0A) + writeHandler(framed) + } +} diff --git a/Sources/RepoPromptHeadless/MCP/HeadlessToolRegistry.swift b/Sources/RepoPromptHeadless/MCP/HeadlessToolRegistry.swift new file mode 100644 index 000000000..973231e78 --- /dev/null +++ b/Sources/RepoPromptHeadless/MCP/HeadlessToolRegistry.swift @@ -0,0 +1,232 @@ +import Foundation + +final class HeadlessToolRegistry { + enum Capability: String, Equatable { + case safeProfile + case writeFiles + case vcsWrite + case launchAgents + case appOnly + + func isEnabled(by permissions: HeadlessPermissions) -> Bool { + switch self { + case .safeProfile: true + case .writeFiles: permissions.writeFiles + case .vcsWrite: permissions.vcsWrite + case .launchAgents: permissions.launchAgents + case .appOnly: false + } + } + } + + struct Registration { + let name: String + let capability: Capability + } + + static let registrations: [Registration] = [ + Registration(name: "bind_context", capability: .safeProfile), + Registration(name: "manage_workspaces", capability: .safeProfile), + Registration(name: "manage_selection", capability: .safeProfile), + Registration(name: "workspace_context", capability: .safeProfile), + Registration(name: "get_file_tree", capability: .safeProfile), + Registration(name: "get_code_structure", capability: .safeProfile), + Registration(name: "read_file", capability: .safeProfile), + Registration(name: "file_search", capability: .safeProfile), + Registration(name: "prompt", capability: .safeProfile) + ] + + static let blockedCapabilities: [String: Capability] = [ + "file_actions": .writeFiles, + "apply_edits": .writeFiles, + "git": .vcsWrite, + "manage_worktree": .vcsWrite, + "agent_run": .launchAgents, + "agent_explore": .launchAgents, + "agent_manage": .launchAgents, + "ask_oracle": .appOnly, + "oracle_send": .appOnly, + "oracle_chat_log": .appOnly, + "context_builder": .appOnly, + "ask_user": .appOnly, + "share_thoughts": .appOnly, + "set_status": .appOnly, + "wait_for_next_user_instruction": .appOnly, + "app_settings": .appOnly + ] + + private let host: HeadlessHost + private let configurationStore: HeadlessConfigurationStore + + init(host: HeadlessHost, configurationStore: HeadlessConfigurationStore) { + self.host = host + self.configurationStore = configurationStore + } + + func listDescriptors() -> [HeadlessJSONObject] { + guard let permissions = try? configurationStore.loadOrCreate().permissions else { return [] } + return Self.registrations + .filter { $0.capability.isEnabled(by: permissions) } + .map { descriptor(for: $0.name).json } + } + + func call(name: String, arguments: HeadlessJSONObject) async -> HeadlessJSONObject { + do { + guard let registration = Self.registrations.first(where: { $0.name == name }) else { + if let capability = Self.blockedCapabilities[name] { + return HeadlessToolResponse.error(blockedToolMessage(name: name, capability: capability)) + } + return HeadlessToolResponse.error("Unsupported headless tool: \(name). Use tools/list to see the enabled safe profile.") + } + let permissions = try configurationStore.loadOrCreate().permissions + guard registration.capability.isEnabled(by: permissions) else { + return HeadlessToolResponse.error(blockedToolMessage(name: name, capability: registration.capability)) + } + + switch name { + case "bind_context": + return try await HeadlessWorkspaceTools.bindContext(host: host, arguments: arguments) + case "manage_workspaces": + return try await HeadlessWorkspaceTools.manageWorkspaces(host: host, arguments: arguments) + case "manage_selection": + return try await HeadlessSelectionTools.manageSelection(host: host, arguments: arguments) + case "workspace_context": + return try await HeadlessPromptTools.workspaceContext(host: host, arguments: arguments) + case "prompt": + return try await HeadlessPromptTools.prompt(host: host, arguments: arguments) + case "get_file_tree": + return try await HeadlessFileTools.getFileTree(host: host, arguments: arguments) + case "get_code_structure": + return try await HeadlessFileTools.getCodeStructure(host: host, arguments: arguments) + case "read_file": + return try await HeadlessFileTools.readFile(host: host, arguments: arguments) + case "file_search": + return try await HeadlessFileTools.fileSearch(host: host, arguments: arguments) + default: + return HeadlessToolResponse.error("Headless tool '\(name)' has capability metadata but no dispatch implementation.") + } + } catch let error as HeadlessCommandError { + return HeadlessToolResponse.error(error.message) + } catch { + return HeadlessToolResponse.error(error.localizedDescription) + } + } + + private func blockedToolMessage(name: String, capability: Capability) -> String { + "Tool '\(name)' is not available in RepoPrompt Headless v1. Required capability: \(capability.rawValue). The standalone profile fails closed until both permission wiring and a registered implementation exist." + } + + private func descriptor(for name: String) -> HeadlessToolDescriptor { + switch name { + case "bind_context": + HeadlessToolDescriptor( + name: name, + description: "List, inspect, or bind the single headless session to a configured workspace.", + inputSchema: HeadlessToolSchemas.object(properties: [ + "op": HeadlessToolSchemas.string(enum: ["list", "get", "status", "bind"]), + "workspace": HeadlessToolSchemas.string(description: "Workspace id or name for bind.") + ]) + ) + case "manage_workspaces": + HeadlessToolDescriptor( + name: name, + description: "Manage headless workspaces without adding arbitrary filesystem roots.", + inputSchema: HeadlessToolSchemas.object(properties: [ + "op": HeadlessToolSchemas.string(enum: ["list", "get", "create", "select", "switch", "rename"]), + "action": HeadlessToolSchemas.string(description: "Alias for op."), + "workspace": HeadlessToolSchemas.string(description: "Workspace id or name."), + "name": HeadlessToolSchemas.string(description: "Workspace name."), + "new_name": HeadlessToolSchemas.string(description: "New workspace name for rename."), + "roots": HeadlessToolSchemas.stringArray(description: "Configured root ids/names/paths to include.") + ]) + ) + case "manage_selection": + HeadlessToolDescriptor( + name: name, + description: "Read or mutate the active workspace selection using allowed root-contained paths only.", + inputSchema: HeadlessToolSchemas.object(properties: [ + "op": HeadlessToolSchemas.string(enum: ["get", "preview", "add", "remove", "set", "clear"]), + "paths": HeadlessToolSchemas.stringArray(), + "path": HeadlessToolSchemas.string(description: "Single-path alias."), + "mode": HeadlessToolSchemas.string(enum: ["full", "slices", "codemap_only"]), + "slices": ["type": "array"], + "view": HeadlessToolSchemas.string(enum: ["summary", "files", "content", "codemaps"]) + ]) + ) + case "workspace_context": + HeadlessToolDescriptor( + name: name, + description: "Render or export the active workspace prompt/selection/code/files/tree context.", + inputSchema: HeadlessToolSchemas.object(properties: [ + "op": HeadlessToolSchemas.string(enum: ["snapshot", "export"]), + "include": HeadlessToolSchemas.stringArray(description: "prompt, selection, code, tokens, files, tree"), + "path": HeadlessToolSchemas.string(description: "Export path; relative paths stay under state Exports/."), + "path_display": HeadlessToolSchemas.string(enum: ["relative", "full"]) + ]) + ) + case "prompt": + HeadlessToolDescriptor( + name: name, + description: "Get, set, append, clear, export, or list the built-in headless prompt preset.", + inputSchema: HeadlessToolSchemas.object(properties: [ + "op": HeadlessToolSchemas.string(enum: ["get", "set", "append", "clear", "export", "list_presets"]), + "text": HeadlessToolSchemas.string(), + "path": HeadlessToolSchemas.string(description: "Export path; relative paths stay under state Exports/.") + ]) + ) + case "get_file_tree": + HeadlessToolDescriptor( + name: name, + description: "Return an ASCII tree for configured roots, a subpath, or selected files.", + inputSchema: HeadlessToolSchemas.object(properties: [ + "type": HeadlessToolSchemas.string(enum: ["files", "roots"]), + "mode": HeadlessToolSchemas.string(enum: ["auto", "full", "folders", "selected"]), + "path": HeadlessToolSchemas.string(), + "max_depth": HeadlessToolSchemas.integer() + ]), + readOnlyHint: true + ) + case "get_code_structure": + HeadlessToolDescriptor( + name: name, + description: "Return lightweight headless code signatures for paths or selected files.", + inputSchema: HeadlessToolSchemas.object(properties: [ + "scope": HeadlessToolSchemas.string(enum: ["paths", "selected"]), + "paths": HeadlessToolSchemas.stringArray(), + "max_results": HeadlessToolSchemas.integer() + ]), + readOnlyHint: true + ) + case "read_file": + HeadlessToolDescriptor( + name: name, + description: "Read a UTF-8 file under configured roots with optional line slicing.", + inputSchema: HeadlessToolSchemas.object(properties: [ + "path": HeadlessToolSchemas.string(), + "start_line": HeadlessToolSchemas.integer(), + "limit": HeadlessToolSchemas.integer() + ], required: ["path"]), + readOnlyHint: true + ) + case "file_search": + HeadlessToolDescriptor( + name: name, + description: "Search paths and/or UTF-8 file contents under configured roots.", + inputSchema: HeadlessToolSchemas.object(properties: [ + "pattern": HeadlessToolSchemas.string(), + "mode": HeadlessToolSchemas.string(enum: ["auto", "path", "content", "both"]), + "regex": HeadlessToolSchemas.boolean(), + "filter": ["type": "object"], + "path": HeadlessToolSchemas.string(), + "max_results": HeadlessToolSchemas.integer(), + "count_only": HeadlessToolSchemas.boolean(), + "context_lines": HeadlessToolSchemas.integer(), + "whole_word": HeadlessToolSchemas.boolean() + ], required: ["pattern"]), + readOnlyHint: true + ) + default: + HeadlessToolDescriptor(name: name, description: "RepoPrompt Headless tool", inputSchema: HeadlessToolSchemas.object()) + } + } +} diff --git a/Sources/RepoPromptHeadless/MCP/HeadlessToolSupport.swift b/Sources/RepoPromptHeadless/MCP/HeadlessToolSupport.swift new file mode 100644 index 000000000..cb5afe032 --- /dev/null +++ b/Sources/RepoPromptHeadless/MCP/HeadlessToolSupport.swift @@ -0,0 +1,151 @@ +import Foundation + +typealias HeadlessJSONObject = [String: Any] + +enum HeadlessToolResponse { + static func success(text: String, structured: Any? = nil) -> HeadlessJSONObject { + var result: HeadlessJSONObject = [ + "content": [["type": "text", "text": text]], + "isError": false + ] + if let structured { + result["structuredContent"] = structured + } + return result + } + + static func error(_ message: String, structured: Any? = nil) -> HeadlessJSONObject { + var result: HeadlessJSONObject = [ + "content": [["type": "text", "text": message]], + "isError": true + ] + if let structured { + result["structuredContent"] = structured + } + return result + } +} + +enum HeadlessJSONValue { + static func value(_ encodable: some Encodable) throws -> Any { + let data = try HeadlessJSONFormatting.encoder(prettyPrinted: false).encode(encodable) + return try JSONSerialization.jsonObject(with: data, options: []) + } +} + +enum HeadlessToolArguments { + static func requiredString(_ arguments: HeadlessJSONObject, key: String) throws -> String { + guard let value = string(arguments, key: key), !value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + throw HeadlessCommandError("Missing required argument '\(key)'.", exitCode: 2) + } + return value + } + + static func string(_ arguments: HeadlessJSONObject, key: String) -> String? { + guard let value = arguments[key], !(value is NSNull) else { return nil } + if let string = value as? String { return string } + return nil + } + + static func int(_ arguments: HeadlessJSONObject, key: String) -> Int? { + guard let value = arguments[key], !(value is NSNull) else { return nil } + if let int = value as? Int { return int } + if let number = value as? NSNumber { return number.intValue } + if let string = value as? String { return Int(string) } + return nil + } + + static func bool(_ arguments: HeadlessJSONObject, key: String) -> Bool? { + guard let value = arguments[key], !(value is NSNull) else { return nil } + if let bool = value as? Bool { return bool } + if let number = value as? NSNumber { return number.boolValue } + if let string = value as? String { + switch string.lowercased() { + case "true", "yes", "1", "on": return true + case "false", "no", "0", "off": return false + default: return nil + } + } + return nil + } + + static func stringArray(_ arguments: HeadlessJSONObject, key: String) -> [String]? { + guard let value = arguments[key], !(value is NSNull) else { return nil } + if let string = value as? String { return [string] } + if let strings = value as? [String] { return strings } + if let values = value as? [Any] { + return values.compactMap { $0 as? String } + } + return nil + } + + static func objectArray(_ arguments: HeadlessJSONObject, key: String) -> [HeadlessJSONObject]? { + guard let value = arguments[key], !(value is NSNull) else { return nil } + return value as? [HeadlessJSONObject] + } +} + +enum HeadlessToolSchemas { + static func object(properties: HeadlessJSONObject = [:], required: [String] = []) -> HeadlessJSONObject { + var schema: HeadlessJSONObject = [ + "type": "object", + "properties": properties, + "additionalProperties": true + ] + if !required.isEmpty { + schema["required"] = required + } + return schema + } + + static func string(enum values: [String]? = nil, description: String? = nil) -> HeadlessJSONObject { + var schema: HeadlessJSONObject = ["type": "string"] + if let values { schema["enum"] = values } + if let description { schema["description"] = description } + return schema + } + + static func integer(description: String? = nil) -> HeadlessJSONObject { + var schema: HeadlessJSONObject = ["type": "integer"] + if let description { schema["description"] = description } + return schema + } + + static func boolean(description: String? = nil) -> HeadlessJSONObject { + var schema: HeadlessJSONObject = ["type": "boolean"] + if let description { schema["description"] = description } + return schema + } + + static func stringArray(description: String? = nil) -> HeadlessJSONObject { + var schema: HeadlessJSONObject = ["type": "array", "items": ["type": "string"]] + if let description { schema["description"] = description } + return schema + } +} + +struct HeadlessToolDescriptor { + let name: String + let description: String + let inputSchema: HeadlessJSONObject + let readOnlyHint: Bool? + + init(name: String, description: String, inputSchema: HeadlessJSONObject, readOnlyHint: Bool? = nil) { + self.name = name + self.description = description + self.inputSchema = inputSchema + self.readOnlyHint = readOnlyHint + } + + var json: HeadlessJSONObject { + var payload: HeadlessJSONObject = [ + "name": name, + "description": description, + "inputSchema": inputSchema + ] + if let readOnlyHint { + payload["annotations"] = ["readOnlyHint": readOnlyHint] + } + return payload + } +} diff --git a/Sources/RepoPromptHeadless/MCP/Tools/HeadlessFileTools.swift b/Sources/RepoPromptHeadless/MCP/Tools/HeadlessFileTools.swift new file mode 100644 index 000000000..7265668c4 --- /dev/null +++ b/Sources/RepoPromptHeadless/MCP/Tools/HeadlessFileTools.swift @@ -0,0 +1,176 @@ +import Foundation + +enum HeadlessFileTools { + private static let maxReadableBytes = 2 * 1024 * 1024 + + static func readFile(host: HeadlessHost, arguments: HeadlessJSONObject) async throws -> HeadlessJSONObject { + let path = try HeadlessToolArguments.requiredString(arguments, key: "path") + let snapshot = try await host.snapshot(requireWorkspace: true) + let resolver = HeadlessPathResolver(roots: snapshot.roots) + let resolved = try resolver.resolve(path) + guard !resolved.isDirectory else { + throw HeadlessCommandError("read_file requires a file, not a directory: \(resolved.displayPath)", exitCode: 2) + } + let data = try HeadlessSecureFileAccess() + .readRegularFile(root: resolved.root, relativePath: resolved.relativePath, maximumBytes: maxReadableBytes) + .data + guard !data.contains(0), let text = String(data: data, encoding: .utf8) else { + throw HeadlessCommandError("File is binary or not valid UTF-8: \(resolved.displayPath)", exitCode: 2) + } + let slice = try HeadlessReadFileSlicer.slice( + text: text, + startLine: HeadlessToolArguments.int(arguments, key: "start_line"), + limit: HeadlessToolArguments.int(arguments, key: "limit") + ) + let language = resolved.url.pathExtension.isEmpty ? "text" : resolved.url.pathExtension + let textOutput = """ + ## File Read ✅ + - **Path**: `\(resolved.displayPath)` + - **Lines**: \(rangeDescription(firstLine: slice.firstLine, lastLine: slice.lastLine, totalLines: slice.totalLines)) + \(slice.message.map { "- **Message**: \($0)" } ?? "") + + ```\(language) + \(slice.content) + ``` + """ + var structured: HeadlessJSONObject = [ + "content": slice.content, + "total_lines": slice.totalLines, + "first_line": slice.firstLine, + "last_line": slice.lastLine, + "display_path": resolved.displayPath, + "path": resolved.resolvedURL.path + ] + if let message = slice.message { + structured["message"] = message + } else { + structured["message"] = NSNull() + } + return HeadlessToolResponse.success(text: textOutput, structured: structured) + } + + static func fileSearch(host: HeadlessHost, arguments: HeadlessJSONObject) async throws -> HeadlessJSONObject { + let snapshot = try await host.snapshot(requireWorkspace: true) + let resolver = HeadlessPathResolver(roots: snapshot.roots) + let result = try HeadlessSearchService().search(roots: snapshot.roots, resolver: resolver, arguments: arguments) + return HeadlessToolResponse.success(text: result.summary, structured: result.structured) + } + + static func getFileTree(host: HeadlessHost, arguments: HeadlessJSONObject) async throws -> HeadlessJSONObject { + let snapshot = try await host.snapshot(requireWorkspace: true) + let type = HeadlessToolArguments.string(arguments, key: "type") ?? "files" + let mode = HeadlessToolArguments.string(arguments, key: "mode") ?? "auto" + if type == "roots" { + var lines = ["## File Tree ✅", "- **Roots**: \(snapshot.roots.count)", ""] + for root in snapshot.roots { + lines.append("- \(root.name): `\(root.path)`") + } + return try HeadlessToolResponse.success(text: lines.joined(separator: "\n"), structured: [ + "roots_count": snapshot.roots.count, + "roots": HeadlessJSONValue.value(snapshot.roots) + ]) + } + guard type == "files" else { + throw HeadlessCommandError("Unsupported get_file_tree type '\(type)'. Expected files or roots.", exitCode: 2) + } + if mode == "selected" { + let selected = snapshot.workspace?.selection ?? [] + let tree = selected.isEmpty ? "(no selected files)" : selected.map { "* \(HeadlessSelectionTools.displayPath(for: $0, roots: snapshot.roots))" }.joined(separator: "\n") + let text = "## File Tree ✅\n- **Roots**: \(snapshot.roots.count)\n- **Mode**: selected\n\n```text\n\(tree)\n```" + return HeadlessToolResponse.success(text: text, structured: ["roots_count": snapshot.roots.count, "tree": tree, "was_truncated": false]) + } + guard ["auto", "full", "folders"].contains(mode) else { + throw HeadlessCommandError("Unsupported get_file_tree mode '\(mode)'. Expected auto, full, folders, or selected.", exitCode: 2) + } + let resolver = HeadlessPathResolver(roots: snapshot.roots) + let basePath = try HeadlessToolArguments.string(arguments, key: "path").map { try resolver.resolve($0) } + let depth = HeadlessToolArguments.int(arguments, key: "max_depth") ?? (mode == "full" ? 12 : 4) + let result = try HeadlessFileCatalog().tree(roots: snapshot.roots, basePath: basePath, mode: mode, maxDepth: depth) + let text = "## File Tree ✅\n- **Roots**: \(result.rootsCount)\n- **Mode**: \(mode)\n- **Truncated**: \(result.wasTruncated)\n\n```text\n\(result.tree)\n```" + return HeadlessToolResponse.success(text: text, structured: [ + "roots_count": result.rootsCount, + "tree": result.tree, + "was_truncated": result.wasTruncated, + "uses_legend": false + ]) + } + + static func getCodeStructure(host: HeadlessHost, arguments: HeadlessJSONObject) async throws -> HeadlessJSONObject { + let snapshot = try await host.snapshot(requireWorkspace: true) + let resolver = HeadlessPathResolver(roots: snapshot.roots) + let scope = HeadlessToolArguments.string(arguments, key: "scope") ?? "paths" + let maxResults = HeadlessToolArguments.int(arguments, key: "max_results") ?? 10 + let paths: [HeadlessResolvedPath] + switch scope { + case "paths": + let inputs = HeadlessToolArguments.stringArray(arguments, key: "paths") ?? [] + guard !inputs.isEmpty else { + throw HeadlessCommandError("get_code_structure with scope=paths requires paths.", exitCode: 2) + } + paths = try expandResolvedFiles(inputs: inputs, resolver: resolver) + case "selected": + let selected = snapshot.workspace?.selection ?? [] + guard !selected.isEmpty else { + throw HeadlessCommandError("get_code_structure scope=selected requires a non-empty selection.", exitCode: 2) + } + paths = try selected.compactMap { entry in + guard let root = snapshot.roots.first(where: { $0.id == entry.rootID }) else { return nil } + return try resolver.resolve(entry.relativePath.isEmpty ? root.name : "\(root.name)/\(entry.relativePath)") + } + default: + throw HeadlessCommandError("Unsupported get_code_structure scope '\(scope)'. Expected paths or selected.", exitCode: 2) + } + let result = try HeadlessCodeStructureService().structure(paths: paths, maxResults: maxResults) + return HeadlessToolResponse.success(text: result.text, structured: result.structured) + } + + static func expandResolvedFiles(inputs: [String], resolver: HeadlessPathResolver) throws -> [HeadlessResolvedPath] { + let catalog = HeadlessFileCatalog() + var files: [HeadlessResolvedPath] = [] + for input in inputs { + let resolved = try resolver.resolve(input) + try files.append(contentsOf: catalog.filesUnder(resolved)) + } + return files + } + + static func readSelectedContent(selection: [HeadlessSelectionEntry], roots: [HeadlessAllowedRoot], resolver: HeadlessPathResolver) throws -> [(path: String, content: String)] { + var output: [(String, String)] = [] + for entry in selection where entry.mode != .codemapOnly { + guard let root = roots.first(where: { $0.id == entry.rootID }) else { continue } + let displayPath = entry.relativePath.isEmpty ? root.name : "\(root.name)/\(entry.relativePath)" + let resolved = try resolver.resolve(displayPath) + guard !resolved.isDirectory else { continue } + guard let snapshot = try? HeadlessSecureFileAccess().readRegularFile( + root: resolved.root, + relativePath: resolved.relativePath, + maximumBytes: maxReadableBytes + ) else { + continue + } + let data = snapshot.data + guard !data.contains(0), let text = String(data: data, encoding: .utf8) else { + continue + } + if entry.mode == .slices { + guard !entry.ranges.isEmpty else { continue } + let chunks = try entry.ranges.map { range in + try HeadlessReadFileSlicer.slice( + text: text, + startLine: range.startLine, + limit: range.endLine - range.startLine + 1 + ).content + } + output.append((displayPath, chunks.joined(separator: "\n…\n"))) + } else { + output.append((displayPath, text)) + } + } + return output + } + + private static func rangeDescription(firstLine: Int, lastLine: Int, totalLines: Int) -> String { + guard firstLine > 0, lastLine >= firstLine else { return "0 of \(totalLines)" } + return "\(firstLine)–\(lastLine) of \(totalLines)" + } +} diff --git a/Sources/RepoPromptHeadless/MCP/Tools/HeadlessPromptTools.swift b/Sources/RepoPromptHeadless/MCP/Tools/HeadlessPromptTools.swift new file mode 100644 index 000000000..bfef2f763 --- /dev/null +++ b/Sources/RepoPromptHeadless/MCP/Tools/HeadlessPromptTools.swift @@ -0,0 +1,139 @@ +import Foundation + +enum HeadlessPromptTools { + static func prompt(host: HeadlessHost, arguments: HeadlessJSONObject) async throws -> HeadlessJSONObject { + let op = HeadlessToolArguments.string(arguments, key: "op") ?? "get" + switch op { + case "get": + let snapshot = try await host.snapshot(requireWorkspace: true) + let text = snapshot.workspace?.promptText ?? "" + return HeadlessToolResponse.success(text: "## Prompt ✅\n\n```text\n\(text)\n```", structured: ["op": op, "prompt": text]) + case "set": + let text = try HeadlessToolArguments.requiredString(arguments, key: "text") + let workspace = try await host.setPrompt(text) + return HeadlessToolResponse.success(text: "## Prompt Updated ✅\n- **Length**: \(workspace.promptText.count) characters", structured: ["op": op, "prompt": workspace.promptText]) + case "append": + let text = try HeadlessToolArguments.requiredString(arguments, key: "text") + let workspace = try await host.appendPrompt(text) + return HeadlessToolResponse.success(text: "## Prompt Updated ✅\n- **Length**: \(workspace.promptText.count) characters", structured: ["op": op, "prompt": workspace.promptText]) + case "clear": + let workspace = try await host.clearPrompt() + return HeadlessToolResponse.success(text: "## Prompt Cleared ✅", structured: ["op": op, "prompt": workspace.promptText]) + case "export": + let snapshot = try await host.snapshot(requireWorkspace: true) + let prompt = snapshot.workspace?.promptText ?? "" + let url = try await host.export( + Data(prompt.utf8), + to: HeadlessToolArguments.string(arguments, key: "path"), + defaultFileName: "prompt-export-\(timestamp()).md", + permissions: snapshot.config.permissions + ) + return HeadlessToolResponse.success(text: "## Prompt Export ✅\n- **Path**: `\(url.path)`", structured: ["op": op, "path": url.path]) + case "list_presets": + let preset: HeadlessJSONObject = [ + "name": "Headless Default", + "kind": "headless_default", + "description": "Built-in plain prompt/context renderer for RepoPrompt Headless v1." + ] + return HeadlessToolResponse.success(text: "## Copy Presets ✅\n- Headless Default (built-in)", structured: ["op": op, "presets": [preset]]) + case "select_preset": + throw HeadlessCommandError("prompt select_preset is not supported in headless v1; only the built-in Headless Default preset is available.", exitCode: 2) + default: + throw HeadlessCommandError("Unsupported prompt op '\(op)'. Supported ops: get, set, append, clear, export, list_presets.", exitCode: 2) + } + } + + static func workspaceContext(host: HeadlessHost, arguments: HeadlessJSONObject) async throws -> HeadlessJSONObject { + let op = HeadlessToolArguments.string(arguments, key: "op") ?? "snapshot" + switch op { + case "snapshot": + let rendered = try await renderWorkspaceContext(host: host, arguments: arguments) + return HeadlessToolResponse.success(text: rendered.text, structured: rendered.structured) + case "export": + let snapshot = try await host.snapshot(requireWorkspace: true) + let rendered = try await renderWorkspaceContext(host: host, arguments: arguments) + let url = try await host.export( + Data(rendered.text.utf8), + to: HeadlessToolArguments.string(arguments, key: "path"), + defaultFileName: "workspace-context-\(timestamp()).md", + permissions: snapshot.config.permissions + ) + var structured = rendered.structured + structured["export_path"] = url.path + return HeadlessToolResponse.success(text: "## Prompt Context Export ✅\n- **Path**: `\(url.path)`", structured: structured) + case "list_presets": + return HeadlessToolResponse.success(text: "## Copy Presets ✅\n- Headless Default (built-in)", structured: ["op": op, "presets": [["name": "Headless Default", "kind": "headless_default"]]]) + case "select_preset": + throw HeadlessCommandError("workspace_context select_preset is not supported in headless v1; only the built-in Headless Default preset is available.", exitCode: 2) + default: + throw HeadlessCommandError("Unsupported workspace_context op '\(op)'. Supported ops: snapshot, export.", exitCode: 2) + } + } + + private static func renderWorkspaceContext(host: HeadlessHost, arguments: HeadlessJSONObject) async throws -> (text: String, structured: HeadlessJSONObject) { + let snapshot = try await host.snapshot(requireWorkspace: true) + guard let workspace = snapshot.workspace else { + throw HeadlessCommandError("No active workspace is available.", exitCode: 2) + } + let includes = Set(HeadlessToolArguments.stringArray(arguments, key: "include") ?? ["prompt", "selection", "code", "tokens"]) + let resolver = HeadlessPathResolver(roots: snapshot.roots) + var sections: [String] = ["## Prompt Context ✅", "- **Workspace**: \(workspace.name)", "- **Copy preset**: Headless Default"] + var structured: HeadlessJSONObject = try [ + "workspace": HeadlessJSONValue.value(workspace), + "roots": HeadlessJSONValue.value(snapshot.roots), + "include": Array(includes).sorted() + ] + + if includes.contains("tokens") { + let promptTokens = approximateTokens(workspace.promptText) + let selectionTokens = workspace.selection.count * 8 + sections.append("\n### Tokens\n- prompt: \(promptTokens)\n- selection: \(selectionTokens)\n- total_estimate: \(promptTokens + selectionTokens)") + structured["token_stats"] = ["prompt": promptTokens, "selection": selectionTokens, "total_estimate": promptTokens + selectionTokens] + } + if includes.contains("prompt") { + sections.append("\n### Prompt\n```text\n\(workspace.promptText)\n```") + structured["prompt"] = workspace.promptText + } + if includes.contains("selection") { + let selectionLines = workspace.selection.isEmpty ? "(no selected files)" : workspace.selection.map { "- \(HeadlessSelectionTools.displayPath(for: $0, roots: snapshot.roots)) (\($0.mode.rawValue))" }.joined(separator: "\n") + sections.append("\n### Selection\n\(selectionLines)") + structured["selection"] = try HeadlessJSONValue.value(workspace.selection) + } + if includes.contains("tree") { + let tree = try HeadlessFileCatalog().tree(roots: snapshot.roots, basePath: nil, mode: "auto", maxDepth: 4).tree + sections.append("\n### File Tree\n```text\n\(tree)\n```") + structured["file_tree"] = tree + } + if includes.contains("code"), !workspace.selection.isEmpty { + let selectedPaths = try workspace.selection.compactMap { entry -> HeadlessResolvedPath? in + guard let root = snapshot.roots.first(where: { $0.id == entry.rootID }) else { return nil } + return try resolver.resolve(entry.relativePath.isEmpty ? root.name : "\(root.name)/\(entry.relativePath)") + } + let code = try HeadlessCodeStructureService().structure(paths: selectedPaths, maxResults: 50) + sections.append("\n### Code Structure\n\(code.text)") + structured["code_structure"] = code.structured + } + if includes.contains("files"), !workspace.selection.isEmpty { + let files = try HeadlessFileTools.readSelectedContent(selection: workspace.selection, roots: snapshot.roots, resolver: resolver) + var fileBlocks: [HeadlessJSONObject] = [] + var fileText: [String] = [] + for file in files { + fileBlocks.append(["path": file.path, "content": file.content]) + fileText.append("#### \(file.path)\n```text\n\(file.content)\n```") + } + sections.append("\n### Files\n\(fileText.joined(separator: "\n\n"))") + structured["file_blocks"] = fileBlocks + } + return (sections.joined(separator: "\n"), structured) + } + + private static func approximateTokens(_ text: String) -> Int { + max(0, (text.count + 3) / 4) + } + + private static func timestamp() -> String { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime] + return formatter.string(from: Date()).replacingOccurrences(of: ":", with: "-") + } +} diff --git a/Sources/RepoPromptHeadless/MCP/Tools/HeadlessSelectionTools.swift b/Sources/RepoPromptHeadless/MCP/Tools/HeadlessSelectionTools.swift new file mode 100644 index 000000000..4e216e63b --- /dev/null +++ b/Sources/RepoPromptHeadless/MCP/Tools/HeadlessSelectionTools.swift @@ -0,0 +1,238 @@ +import Foundation + +enum HeadlessSelectionTools { + static func manageSelection(host: HeadlessHost, arguments: HeadlessJSONObject) async throws -> HeadlessJSONObject { + let op = HeadlessToolArguments.string(arguments, key: "op") ?? "get" + let snapshot = try await host.snapshot(requireWorkspace: true) + guard let workspace = snapshot.workspace else { + throw HeadlessCommandError("No active workspace is available.", exitCode: 2) + } + let resolver = HeadlessPathResolver(roots: snapshot.roots) + let current = workspace.selection + + switch op { + case "get": + return try selectionResponse(selection: current, roots: snapshot.roots, title: "## Selection ✅", note: nil) + case "clear": + let updated = try await host.updateSelection(workspaceID: workspace.id) { selection in + selection = [] + } + return try selectionResponse(selection: updated.selection, roots: snapshot.roots, title: "## Selection Cleared ✅", note: nil) + case "preview": + let preview = try applySelectionMutation(arguments: arguments, current: current, resolver: resolver) + return try selectionResponse(selection: preview, roots: snapshot.roots, title: "## Selection Preview ✅", note: "Preview only; active workspace selection was not changed.") + case "add", "set": + let updated = try await host.updateSelection(workspaceID: workspace.id) { selection in + let base = op == "set" ? [] : selection + selection = try applySelectionMutation(arguments: arguments, current: base, resolver: resolver) + } + return try selectionResponse(selection: updated.selection, roots: snapshot.roots, title: "## Selection Updated ✅", note: nil) + case "remove": + let updated = try await host.updateSelection(workspaceID: workspace.id) { selection in + selection = try applyingRemovals(arguments: arguments, current: selection, resolver: resolver) + } + return try selectionResponse(selection: updated.selection, roots: snapshot.roots, title: "## Selection Updated ✅", note: nil) + case "promote", "demote": + throw HeadlessCommandError("manage_selection op '\(op)' is not supported in headless v1; use add/set with mode full or codemap_only.", exitCode: 2) + default: + throw HeadlessCommandError("Unsupported manage_selection op '\(op)'. Supported ops: get, preview, add, remove, set, clear.", exitCode: 2) + } + } + + private static func applySelectionMutation(arguments: HeadlessJSONObject, current: [HeadlessSelectionEntry], resolver: HeadlessPathResolver) throws -> [HeadlessSelectionEntry] { + let mode = try parseMode(HeadlessToolArguments.string(arguments, key: "mode") ?? "full") + let additions = try entries(from: arguments, resolver: resolver, defaultMode: mode) + guard !additions.isEmpty || !(HeadlessToolArguments.stringArray(arguments, key: "paths") ?? []).isEmpty || HeadlessToolArguments.string(arguments, key: "path") != nil || HeadlessToolArguments.objectArray(arguments, key: "slices") != nil else { + return current + } + var result = current + for addition in additions { + if let index = result.firstIndex(where: { $0.rootID == addition.rootID && $0.relativePath == addition.relativePath }) { + result[index] = addition + } else { + result.append(addition) + } + } + return HeadlessSelectionNormalizer.normalized(result) + } + + private static func entries(from arguments: HeadlessJSONObject, resolver: HeadlessPathResolver, defaultMode: HeadlessSelectionMode) throws -> [HeadlessSelectionEntry] { + var entries: [HeadlessSelectionEntry] = [] + let catalog = HeadlessFileCatalog() + let paths = (HeadlessToolArguments.stringArray(arguments, key: "paths") ?? []) + (HeadlessToolArguments.string(arguments, key: "path").map { [$0] } ?? []) + if defaultMode == .slices, !paths.isEmpty { + throw HeadlessCommandError("Selection mode slices requires slice objects with non-empty ranges; path/paths cannot create an empty slice selection.", exitCode: 2) + } + for path in paths { + let resolved = try resolver.resolve(path) + let files = try catalog.filesUnder(resolved) + for file in files { + entries.append(HeadlessSelectionEntry(rootID: file.root.id, relativePath: file.relativePath, mode: defaultMode)) + } + } + + for slice in try sliceObjects(from: arguments) { + let path = try HeadlessToolArguments.requiredString(slice, key: "path") + let resolved = try resolver.resolve(path) + guard !resolved.isDirectory else { + throw HeadlessCommandError("Slice selection requires a file path, not a directory: \(resolved.displayPath)", exitCode: 2) + } + let ranges = try parseRanges(slice) + entries.append(HeadlessSelectionEntry(rootID: resolved.root.id, relativePath: resolved.relativePath, mode: .slices, ranges: ranges)) + } + return HeadlessSelectionNormalizer.normalized(entries) + } + + private static func applyingRemovals( + arguments: HeadlessJSONObject, + current: [HeadlessSelectionEntry], + resolver: HeadlessPathResolver + ) throws -> [HeadlessSelectionEntry] { + let catalog = HeadlessFileCatalog() + let paths = (HeadlessToolArguments.stringArray(arguments, key: "paths") ?? []) + + (HeadlessToolArguments.string(arguments, key: "path").map { [$0] } ?? []) + if HeadlessToolArguments.string(arguments, key: "mode") == HeadlessSelectionMode.slices.rawValue, + !paths.isEmpty + { + throw HeadlessCommandError("Selection mode slices requires slice objects with non-empty ranges; path/paths cannot create an empty slice selection.", exitCode: 2) + } + var wholeFileKeys: Set = [] + for path in paths { + let resolved = try resolver.resolve(path) + for file in try catalog.filesUnder(resolved) { + wholeFileKeys.insert(selectionKey(rootID: file.root.id, relativePath: file.relativePath)) + } + } + + var sliceRemovals: [String: [HeadlessLineRange]] = [:] + for slice in try sliceObjects(from: arguments) { + let path = try HeadlessToolArguments.requiredString(slice, key: "path") + let resolved = try resolver.resolve(path) + guard !resolved.isDirectory else { + throw HeadlessCommandError("Slice removal requires a file path, not a directory: \(resolved.displayPath)", exitCode: 2) + } + let key = selectionKey(rootID: resolved.root.id, relativePath: resolved.relativePath) + try sliceRemovals[key, default: []].append(contentsOf: parseRanges(slice)) + } + + var result = HeadlessSelectionNormalizer.normalized(current) + .filter { !wholeFileKeys.contains(selectionKey(for: $0)) } + for (key, removals) in sliceRemovals where !wholeFileKeys.contains(key) { + guard let index = result.firstIndex(where: { selectionKey(for: $0) == key }), + result[index].mode == .slices + else { + continue + } + result[index].ranges = HeadlessSelectionNormalizer.subtracting(removals, from: result[index].ranges) + if result[index].ranges.isEmpty { + result.remove(at: index) + } + } + return HeadlessSelectionNormalizer.normalized(result) + } + + private static func sliceObjects(from arguments: HeadlessJSONObject) throws -> [HeadlessJSONObject] { + guard let rawValue = arguments["slices"], !(rawValue is NSNull) else { + return [] + } + guard let slices = HeadlessToolArguments.objectArray(arguments, key: "slices") else { + throw HeadlessCommandError("Selection slices must be an array of slice objects.", exitCode: 2) + } + guard !slices.isEmpty else { + throw HeadlessCommandError("Selection slices must not be empty.", exitCode: 2) + } + return slices + } + + private static func parseMode(_ value: String) throws -> HeadlessSelectionMode { + guard let mode = HeadlessSelectionMode(rawValue: value) else { + throw HeadlessCommandError("Unsupported selection mode '\(value)'. Expected full, slices, or codemap_only.", exitCode: 2) + } + return mode + } + + private static func parseRanges(_ slice: HeadlessJSONObject) throws -> [HeadlessLineRange] { + if let rangeObjects = HeadlessToolArguments.objectArray(slice, key: "ranges") { + let ranges = try rangeObjects.map { object in + guard let start = HeadlessToolArguments.int(object, key: "start_line") else { + throw HeadlessCommandError("Slice range is missing start_line.", exitCode: 2) + } + let end = HeadlessToolArguments.int(object, key: "end_line") ?? start + guard start > 0, end >= start else { + throw HeadlessCommandError("Invalid slice range \(start)-\(end).", exitCode: 2) + } + return HeadlessLineRange(startLine: start, endLine: end, description: HeadlessToolArguments.string(object, key: "description")) + } + guard !ranges.isEmpty else { + throw HeadlessCommandError("Slice selection requires at least one range.", exitCode: 2) + } + return ranges + } + if let lines = HeadlessToolArguments.string(slice, key: "lines") { + return try parseLineSpec(lines) + } + throw HeadlessCommandError("Slice selection requires ranges or lines.", exitCode: 2) + } + + private static func parseLineSpec(_ spec: String) throws -> [HeadlessLineRange] { + let pieces = spec.split(separator: ",").map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }.filter { !$0.isEmpty } + guard !pieces.isEmpty else { + throw HeadlessCommandError("Slice lines spec is empty.", exitCode: 2) + } + return try pieces.map { piece in + let bounds = piece.split(separator: "-", maxSplits: 1, omittingEmptySubsequences: false).map(String.init) + guard let start = Int(bounds[0]), start > 0 else { + throw HeadlessCommandError("Invalid slice line spec: \(piece)", exitCode: 2) + } + let end: Int + if bounds.count == 2 { + guard let parsedEnd = Int(bounds[1]) else { + throw HeadlessCommandError("Invalid slice line range: \(piece)", exitCode: 2) + } + end = parsedEnd + } else { + end = start + } + guard end >= start else { + throw HeadlessCommandError("Invalid slice line range: \(piece)", exitCode: 2) + } + return HeadlessLineRange(startLine: start, endLine: end) + } + } + + private static func selectionResponse(selection: [HeadlessSelectionEntry], roots: [HeadlessAllowedRoot], title: String, note: String?) throws -> HeadlessJSONObject { + let normalizedSelection = HeadlessSelectionNormalizer.normalized(selection) + var lines = [title, "- **Files**: \(normalizedSelection.count)", "- **Auto-codemap**: disabled in headless v1"] + if let note { lines.append("- **Note**: \(note)") } + for entry in normalizedSelection { + lines.append("- `\(displayPath(for: entry, roots: roots))` (\(entry.mode.rawValue)\(rangeSuffix(entry.ranges)))") + } + return try HeadlessToolResponse.success(text: lines.joined(separator: "\n"), structured: [ + "files": HeadlessJSONValue.value(normalizedSelection), + "codemap_auto_enabled": false, + "total_tokens": 0, + "summary": "\(normalizedSelection.count) selected entr\(normalizedSelection.count == 1 ? "y" : "ies")" + ]) + } + + static func displayPath(for entry: HeadlessSelectionEntry, roots: [HeadlessAllowedRoot]) -> String { + let rootName = roots.first(where: { $0.id == entry.rootID })?.name ?? entry.rootID.uuidString + return entry.relativePath.isEmpty ? rootName : "\(rootName)/\(entry.relativePath)" + } + + private static func rangeSuffix(_ ranges: [HeadlessLineRange]) -> String { + guard !ranges.isEmpty else { return "" } + let spec = ranges.map { range in + range.startLine == range.endLine ? "\(range.startLine)" : "\(range.startLine)-\(range.endLine)" + }.joined(separator: ",") + return ", lines \(spec)" + } + + private static func selectionKey(for entry: HeadlessSelectionEntry) -> String { + selectionKey(rootID: entry.rootID, relativePath: entry.relativePath) + } + + private static func selectionKey(rootID: UUID, relativePath: String) -> String { + "\(rootID.uuidString):\(relativePath)" + } +} diff --git a/Sources/RepoPromptHeadless/MCP/Tools/HeadlessWorkspaceTools.swift b/Sources/RepoPromptHeadless/MCP/Tools/HeadlessWorkspaceTools.swift new file mode 100644 index 000000000..e878d3637 --- /dev/null +++ b/Sources/RepoPromptHeadless/MCP/Tools/HeadlessWorkspaceTools.swift @@ -0,0 +1,109 @@ +import Foundation + +enum HeadlessWorkspaceTools { + static func bindContext(host: HeadlessHost, arguments: HeadlessJSONObject) async throws -> HeadlessJSONObject { + let op = HeadlessToolArguments.string(arguments, key: "op") ?? "get" + switch op { + case "list": + let listing = try await host.listWorkspaces() + return try workspaceListResponse(config: listing.config, workspaces: listing.workspaces, title: "## Headless Contexts ✅") + case "get", "status": + let snapshot = try await host.snapshot(requireWorkspace: false) + let text = if let workspace = snapshot.workspace { + "## Headless Context Binding ✅\n- **Active workspace**: \(workspace.name) (`\(workspace.id.uuidString)`)\n- **Roots**: \(snapshot.roots.map(\.name).joined(separator: ", "))\n- **State directory**: `\(snapshot.config.activeWorkspaceID == nil ? "unbound" : "bound")`" + } else { + "## Headless Context Binding ⚠️\nNo active headless workspace is bound. Configure roots and create/select a workspace first." + } + return try HeadlessToolResponse.success(text: text, structured: snapshotJSON(snapshot)) + case "bind": + let token = try workspaceToken(arguments) + let workspace = try await host.selectWorkspace(token: token) + let snapshot = try await host.snapshot(requireWorkspace: true) + let text = "## Headless Context Binding ✅\n- **Bound workspace**: \(workspace.name) (`\(workspace.id.uuidString)`)\n- **Roots**: \(snapshot.roots.map(\.name).joined(separator: ", "))" + return try HeadlessToolResponse.success(text: text, structured: snapshotJSON(snapshot)) + default: + throw HeadlessCommandError("Unsupported bind_context op '\(op)'. Supported ops: list, get, bind.", exitCode: 2) + } + } + + static func manageWorkspaces(host: HeadlessHost, arguments: HeadlessJSONObject) async throws -> HeadlessJSONObject { + let op = HeadlessToolArguments.string(arguments, key: "op") ?? HeadlessToolArguments.string(arguments, key: "action") ?? "list" + switch op { + case "list": + let listing = try await host.listWorkspaces() + return try workspaceListResponse(config: listing.config, workspaces: listing.workspaces, title: "## Headless Workspaces ✅") + case "get": + let snapshot = try await host.snapshot(requireWorkspace: false) + return try HeadlessToolResponse.success(text: workspaceSnapshotText(snapshot), structured: snapshotJSON(snapshot)) + case "create": + let name = try HeadlessToolArguments.requiredString(arguments, key: "name") + let roots = HeadlessToolArguments.stringArray(arguments, key: "roots") + ?? HeadlessToolArguments.stringArray(arguments, key: "root_ids") + ?? HeadlessToolArguments.stringArray(arguments, key: "root_names") + ?? [] + let workspace = try await host.createWorkspace(name: name, rootTokens: roots) + let text = "## Headless Workspace Created ✅\n- **Name**: \(workspace.name)\n- **ID**: `\(workspace.id.uuidString)`\n- **Root count**: \(workspace.rootIDs.count)" + return try HeadlessToolResponse.success(text: text, structured: HeadlessJSONValue.value(workspace)) + case "select", "switch": + let token = try workspaceToken(arguments) + let workspace = try await host.selectWorkspace(token: token) + let text = "## Headless Workspace Selected ✅\n- **Name**: \(workspace.name)\n- **ID**: `\(workspace.id.uuidString)`" + return try HeadlessToolResponse.success(text: text, structured: HeadlessJSONValue.value(workspace)) + case "rename": + let token = HeadlessToolArguments.string(arguments, key: "workspace") ?? HeadlessToolArguments.string(arguments, key: "id") + let newName = HeadlessToolArguments.string(arguments, key: "new_name") ?? HeadlessToolArguments.string(arguments, key: "name") + guard let newName else { + throw HeadlessCommandError("manage_workspaces rename requires new_name or name.", exitCode: 2) + } + let workspace = try await host.renameWorkspace(token: token, newName: newName) + let text = "## Headless Workspace Renamed ✅\n- **Name**: \(workspace.name)\n- **ID**: `\(workspace.id.uuidString)`" + return try HeadlessToolResponse.success(text: text, structured: HeadlessJSONValue.value(workspace)) + case "delete", "hide", "unhide", "add_folder", "remove_folder", "create_tab", "close_tab", "select_tab", "list_tabs": + throw HeadlessCommandError("manage_workspaces op '\(op)' is app/window or destructive and is not supported by the standalone safe profile.", exitCode: 2) + default: + throw HeadlessCommandError("Unsupported manage_workspaces op '\(op)'. Supported ops: list, get, create, select, rename.", exitCode: 2) + } + } + + private static func workspaceToken(_ arguments: HeadlessJSONObject) throws -> String { + if let token = HeadlessToolArguments.string(arguments, key: "workspace") ?? HeadlessToolArguments.string(arguments, key: "workspace_id") ?? HeadlessToolArguments.string(arguments, key: "id") ?? HeadlessToolArguments.string(arguments, key: "name") { + return token + } + throw HeadlessCommandError("Workspace id or name is required.", exitCode: 2) + } + + private static func workspaceListResponse(config: HeadlessConfigurationDocument, workspaces: [HeadlessWorkspaceDocument], title: String) throws -> HeadlessJSONObject { + var lines = [title, "- **Workspaces**: \(workspaces.count)"] + if let activeID = config.activeWorkspaceID { + lines.append("- **Active**: `\(activeID.uuidString)`") + } + for workspace in workspaces { + let marker = workspace.id == config.activeWorkspaceID ? "*" : "-" + lines.append("\(marker) \(workspace.name) (`\(workspace.id.uuidString)`) roots=\(workspace.rootIDs.count) selection=\(workspace.selection.count)") + } + return try HeadlessToolResponse.success(text: lines.joined(separator: "\n"), structured: [ + "active_workspace_id": config.activeWorkspaceID?.uuidString ?? NSNull(), + "workspaces": HeadlessJSONValue.value(workspaces) + ]) + } + + private static func workspaceSnapshotText(_ snapshot: HeadlessWorkspaceSnapshot) -> String { + guard let workspace = snapshot.workspace else { + return "## Headless Workspace ⚠️\nNo active workspace." + } + return "## Headless Workspace ✅\n- **Name**: \(workspace.name)\n- **ID**: `\(workspace.id.uuidString)`\n- **Roots**: \(snapshot.roots.map(\.name).joined(separator: ", "))\n- **Selection**: \(workspace.selection.count) entries" + } + + private static func snapshotJSON(_ snapshot: HeadlessWorkspaceSnapshot) throws -> HeadlessJSONObject { + var object: HeadlessJSONObject = try [ + "config": HeadlessJSONValue.value(snapshot.config), + "roots": HeadlessJSONValue.value(snapshot.roots) + ] + if let workspace = snapshot.workspace { + object["workspace"] = try HeadlessJSONValue.value(workspace) + } else { + object["workspace"] = NSNull() + } + return object + } +} diff --git a/Sources/RepoPromptHeadless/Runtime/HeadlessCodeStructureService.swift b/Sources/RepoPromptHeadless/Runtime/HeadlessCodeStructureService.swift new file mode 100644 index 000000000..ebd974c2a --- /dev/null +++ b/Sources/RepoPromptHeadless/Runtime/HeadlessCodeStructureService.swift @@ -0,0 +1,146 @@ +import Foundation + +struct HeadlessCodeStructureResult { + var text: String + var structured: [String: Any] +} + +final class HeadlessCodeStructureService { + private let maxReadableBytes = 2 * 1024 * 1024 + private let secureFileAccess: HeadlessSecureFileAccess + private let supportedExtensions: Set = [ + "swift", "py", "js", "jsx", "ts", "tsx", "go", "rs", "rb", "java", "c", "h", "cc", "cpp", "hpp", "cs", "php", "m", "mm" + ] + + init(secureFileAccess: HeadlessSecureFileAccess = HeadlessSecureFileAccess()) { + self.secureFileAccess = secureFileAccess + } + + func structure(paths: [HeadlessResolvedPath], maxResults: Int) throws -> HeadlessCodeStructureResult { + var fileBlocks: [[String: Any]] = [] + var textBlocks: [String] = [] + var skipped: [String] = [] + let limit = max(1, min(maxResults, 200)) + + for path in paths.prefix(limit) { + guard !path.isDirectory else { + skipped.append("\(path.displayPath) (directory)") + continue + } + let ext = path.url.pathExtension.lowercased() + guard supportedExtensions.contains(ext) else { + skipped.append("\(path.displayPath) (unsupported type)") + continue + } + let data: Data + do { + data = try secureFileAccess.readRegularFile( + root: path.root, + relativePath: path.relativePath, + maximumBytes: maxReadableBytes + ).data + } catch { + skipped.append("\(path.displayPath) (unreadable or oversized)") + continue + } + guard !data.contains(0), let text = String(data: data, encoding: .utf8) else { + skipped.append("\(path.displayPath) (binary or non-UTF-8)") + continue + } + let symbols = extractSymbols(from: text, extension: ext) + guard !symbols.isEmpty else { + skipped.append("\(path.displayPath) (no lightweight symbols found)") + continue + } + let symbolObjects = symbols.map { symbol in + ["line": symbol.line, "kind": symbol.kind, "signature": symbol.signature] as [String: Any] + } + fileBlocks.append([ + "path": path.displayPath, + "relative_path": path.relativePath, + "parser": "headless-lightweight", + "symbols": symbolObjects + ]) + var block = "File: \(path.displayPath)\nParser: headless-lightweight" + for symbol in symbols { + block += "\nL\(symbol.line): \(symbol.signature)" + } + textBlocks.append(block) + } + + let header = "## Code Structure ✅\n- **Files with codemap**: \(fileBlocks.count)\n- **Parser**: `headless-lightweight`" + let skippedText = skipped.isEmpty ? "" : "\n\nSkipped:\n" + skipped.map { "- \($0)" }.joined(separator: "\n") + let body = textBlocks.isEmpty ? "" : "\n\n```text\n\(textBlocks.joined(separator: "\n\n"))\n```" + return HeadlessCodeStructureResult( + text: header + skippedText + body, + structured: [ + "parser": "headless-lightweight", + "files": fileBlocks, + "skipped": skipped, + "files_with_codemap": fileBlocks.count + ] + ) + } + + private func extractSymbols(from text: String, extension ext: String) -> [Symbol] { + let patterns = patterns(for: ext) + let lines = text.components(separatedBy: .newlines) + var symbols: [Symbol] = [] + for (index, line) in lines.enumerated() { + let trimmed = line.trimmingCharacters(in: .whitespaces) + guard !trimmed.isEmpty, !trimmed.hasPrefix("//"), !trimmed.hasPrefix("#") else { + continue + } + for pattern in patterns { + if let match = trimmed.range(of: pattern.regex, options: .regularExpression) { + let signature = String(trimmed[match]).trimmingCharacters(in: .whitespaces) + symbols.append(Symbol(line: index + 1, kind: pattern.kind, signature: signature)) + break + } + } + } + return symbols + } + + private func patterns(for ext: String) -> [Pattern] { + switch ext { + case "swift": + [ + Pattern(kind: "type", regex: #"^(?:public|private|fileprivate|internal|open)?\s*(?:final\s+)?(?:class|struct|enum|protocol|actor|extension)\s+[^:{]+"#), + Pattern(kind: "function", regex: #"^(?:public|private|fileprivate|internal|open)?\s*(?:static\s+)?func\s+[^\{]+"#), + Pattern(kind: "initializer", regex: #"^(?:public|private|fileprivate|internal|open)?\s*init\s*\([^\{]*"#) + ] + case "py": + [Pattern(kind: "type", regex: #"^class\s+[^:]+"#), Pattern(kind: "function", regex: #"^(?:async\s+)?def\s+[^:]+"#)] + case "js", "jsx", "ts", "tsx": + [ + Pattern(kind: "type", regex: #"^(?:export\s+)?(?:default\s+)?class\s+[^\{]+"#), + Pattern(kind: "function", regex: #"^(?:export\s+)?(?:async\s+)?function\s+[^\{]+"#), + Pattern(kind: "function", regex: #"^(?:export\s+)?(?:const|let|var)\s+\w+\s*=\s*(?:async\s*)?\([^=]*\)\s*=>"#) + ] + case "go": + [Pattern(kind: "type", regex: #"^type\s+\w+\s+(?:struct|interface)"#), Pattern(kind: "function", regex: #"^func\s+[^\{]+"#)] + case "rs": + [Pattern(kind: "type", regex: #"^(?:pub\s+)?(?:struct|enum|trait|impl)\s+[^\{]+"#), Pattern(kind: "function", regex: #"^(?:pub\s+)?(?:async\s+)?fn\s+[^\{]+"#)] + case "rb": + [Pattern(kind: "type", regex: #"^class\s+.+"#), Pattern(kind: "type", regex: #"^module\s+.+"#), Pattern(kind: "function", regex: #"^def\s+.+"#)] + case "java", "cs": + [Pattern(kind: "type", regex: #"^(?:public|private|protected|internal|static|sealed|abstract|final|partial|\s)+\s*(?:class|interface|enum|record|struct)\s+[^\{]+"#), Pattern(kind: "function", regex: #"^(?:public|private|protected|internal|static|async|final|virtual|override|\s)+[\w<>\[\],?]+\s+\w+\s*\([^\)]*\)"#)] + case "php": + [Pattern(kind: "type", regex: #"^(?:final\s+|abstract\s+)?(?:class|interface|trait|enum)\s+[^\{]+"#), Pattern(kind: "function", regex: #"^(?:public|private|protected|static|\s)*function\s+[^\{]+"#)] + default: + [Pattern(kind: "type", regex: #"^(?:class|struct|enum|interface)\s+[^\{]+"#), Pattern(kind: "function", regex: #"^[\w\s\*:&<>]+\s+\w+\s*\([^\)]*\)"#)] + } + } + + private struct Symbol { + let line: Int + let kind: String + let signature: String + } + + private struct Pattern { + let kind: String + let regex: String + } +} diff --git a/Sources/RepoPromptHeadless/Runtime/HeadlessExportWriter.swift b/Sources/RepoPromptHeadless/Runtime/HeadlessExportWriter.swift new file mode 100644 index 000000000..34cbc28dc --- /dev/null +++ b/Sources/RepoPromptHeadless/Runtime/HeadlessExportWriter.swift @@ -0,0 +1,91 @@ +import Darwin +import Foundation + +struct HeadlessExportWriter { + private let paths: HeadlessStatePaths + private let fileManager: FileManager + + init(paths: HeadlessStatePaths, fileManager: FileManager = .default) { + self.paths = paths + self.fileManager = fileManager + } + + func write( + _ data: Data, + to requestedPath: String?, + defaultFileName: String, + permissions: HeadlessPermissions, + inStateParentDirectoryOpenedHook: HeadlessStateFileSecurity.ParentDirectoryOpenedHook? = nil, + externalParentDirectoryOpenedHook: HeadlessExternalExportFileSecurity.ParentDirectoryOpenedHook? = nil + ) throws -> URL { + try paths.ensureBaseDirectories(fileManager: fileManager) + let stateRoot = paths.rootDirectory.resolvingSymlinksInPath().standardizedFileURL + let exportsRoot = paths.exportsDirectory.resolvingSymlinksInPath().standardizedFileURL + let requestedAbsolutePath = requestedPath?.hasPrefix("/") ?? false + + let target: URL = if let requestedPath, !requestedPath.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + if requestedAbsolutePath { + URL(fileURLWithPath: requestedPath).standardizedFileURL + } else { + paths.exportsDirectory.appendingPathComponent(requestedPath, isDirectory: false).standardizedFileURL + } + } else { + paths.exportsDirectory.appendingPathComponent(defaultFileName, isDirectory: false).standardizedFileURL + } + + if try isSymbolicLink(at: target) { + throw HeadlessCommandError("Export target must not be an existing symbolic link: \(target.path)", exitCode: 2) + } + + let resolvedParent = target.deletingLastPathComponent().resolvingSymlinksInPath().standardizedFileURL + let resolvedTarget = resolvedParent + .appendingPathComponent(target.lastPathComponent, isDirectory: false) + .standardizedFileURL + let inState = HeadlessRootAccessPolicy.path(resolvedTarget.path, isContainedInOrEqualTo: stateRoot.path) + let parentInState = HeadlessRootAccessPolicy.path(resolvedParent.path, isContainedInOrEqualTo: stateRoot.path) + let inExports = HeadlessRootAccessPolicy.path(resolvedTarget.path, isContainedInOrEqualTo: exportsRoot.path) + let parentInExports = HeadlessRootAccessPolicy.path(resolvedParent.path, isContainedInOrEqualTo: exportsRoot.path) + + guard (inState && parentInState) || permissions.exportOutsideStateDirectory else { + throw HeadlessCommandError( + "Export path is outside the headless state directory and export_outside_state_directory is false: \(target.path)", + exitCode: 2 + ) + } + if !requestedAbsolutePath, !(inExports && parentInExports) { + throw HeadlessCommandError( + "Relative export path escapes the headless Exports directory: \(requestedPath ?? defaultFileName)", + exitCode: 2 + ) + } + + if inState { + try HeadlessStateFileSecurity.writePrivateFile( + data, + to: resolvedTarget, + stateRoot: stateRoot, + fileManager: fileManager, + parentDirectoryOpenedHook: inStateParentDirectoryOpenedHook + ) + } else { + try HeadlessExternalExportFileSecurity.writeFile( + data, + to: resolvedTarget, + parentDirectoryOpenedHook: externalParentDirectoryOpenedHook + ) + } + return resolvedTarget + } + + private func isSymbolicLink(at url: URL) throws -> Bool { + var status = stat() + if Darwin.lstat(url.path, &status) == 0 { + return status.st_mode & S_IFMT == S_IFLNK + } + if errno == ENOENT { + return false + } + let detail = String(cString: Darwin.strerror(errno)) + throw HeadlessCommandError("Unable to inspect export target '\(url.path)': \(detail)", exitCode: 2) + } +} diff --git a/Sources/RepoPromptHeadless/Runtime/HeadlessFileCatalog.swift b/Sources/RepoPromptHeadless/Runtime/HeadlessFileCatalog.swift new file mode 100644 index 000000000..925170589 --- /dev/null +++ b/Sources/RepoPromptHeadless/Runtime/HeadlessFileCatalog.swift @@ -0,0 +1,236 @@ +import Foundation + +struct HeadlessCatalogScanResult { + var entries: [HeadlessCatalogEntry] + var entryLimit: Int + var wasTruncated: Bool + var skippedEntryCount: Int +} + +final class HeadlessFileCatalog { + private let fileManager: FileManager + private let secureFileAccess: HeadlessSecureFileAccess + private let skippedDirectoryNames: Set = [".git", ".svn", ".hg", ".build", "node_modules", ".DS_Store"] + + init( + fileManager: FileManager = .default, + secureFileAccess: HeadlessSecureFileAccess = HeadlessSecureFileAccess() + ) { + self.fileManager = fileManager + self.secureFileAccess = secureFileAccess + } + + func scan( + roots: [HeadlessAllowedRoot], + under basePath: HeadlessResolvedPath? = nil, + maxEntries: Int = 20000, + shouldContinue: (() throws -> Bool)? = nil + ) throws -> HeadlessCatalogScanResult { + let entryLimit = max(0, maxEntries) + let scanRoots: [(root: HeadlessAllowedRoot, url: URL)] = if let basePath { + [(basePath.root, basePath.url)] + } else { + roots.map { ($0, URL(fileURLWithPath: $0.path, isDirectory: true).standardizedFileURL) } + } + + var entries: [HeadlessCatalogEntry] = [] + var wasTruncated = false + var skippedEntryCount = 0 + rootLoop: for item in scanRoots { + if try shouldContinue?() == false { + wasTruncated = true + break + } + let rootEntry = try catalogEntry(for: item.root, url: item.url) + guard entries.count < entryLimit else { + wasTruncated = true + break + } + entries.append(rootEntry) + if let basePath, !basePath.isDirectory { + continue + } + guard let enumerator = fileManager.enumerator( + at: item.url, + includingPropertiesForKeys: [.isDirectoryKey, .isRegularFileKey, .fileSizeKey, .isSymbolicLinkKey], + options: [.skipsHiddenFiles, .skipsPackageDescendants], + errorHandler: { _, _ in + skippedEntryCount += 1 + return true + } + ) else { + skippedEntryCount += 1 + continue + } + for case let url as URL in enumerator { + if try shouldContinue?() == false { + wasTruncated = true + break rootLoop + } + if skippedDirectoryNames.contains(url.lastPathComponent) { + enumerator.skipDescendants() + continue + } + let values = try? url.resourceValues(forKeys: [.isDirectoryKey, .isRegularFileKey, .isSymbolicLinkKey]) + if values?.isSymbolicLink == true { + if values?.isDirectory == true { + enumerator.skipDescendants() + } + continue + } + if values?.isDirectory == false, values?.isRegularFile == false { + continue + } + do { + let entry = try catalogEntry(for: item.root, url: url) + if entry.isDirectory || entry.byteCount != nil { + guard entries.count < entryLimit else { + wasTruncated = true + break rootLoop + } + entries.append(entry) + } + } catch { + skippedEntryCount += 1 + if values?.isDirectory == true { + enumerator.skipDescendants() + } + continue + } + } + } + let sortedEntries = entries.sorted { lhs, rhs in + if lhs.root.id == rhs.root.id { + if lhs.relativePath.isEmpty { return true } + if rhs.relativePath.isEmpty { return false } + return lhs.relativePath.localizedStandardCompare(rhs.relativePath) == .orderedAscending + } + return lhs.root.name.localizedStandardCompare(rhs.root.name) == .orderedAscending + } + return HeadlessCatalogScanResult( + entries: sortedEntries, + entryLimit: entryLimit, + wasTruncated: wasTruncated, + skippedEntryCount: skippedEntryCount + ) + } + + func catalogEntry(for root: HeadlessAllowedRoot, url: URL) throws -> HeadlessCatalogEntry { + let standardized = url.standardizedFileURL + let lexicalRootPath: String + if HeadlessRootAccessPolicy.path(standardized.path, isContainedInOrEqualTo: root.path) { + lexicalRootPath = root.path + } else if HeadlessRootAccessPolicy.path(standardized.path, isContainedInOrEqualTo: root.resolvedPath) { + lexicalRootPath = root.resolvedPath + } else { + throw HeadlessCommandError("Path is outside allowed root '\(root.name)': \(url.path)", exitCode: 2) + } + let relativePath = HeadlessPathResolver.relativePath(forResolvedPath: standardized.path, rootResolvedPath: lexicalRootPath) + let metadata = try secureFileAccess.inspect(root: root, relativePath: relativePath) + let resolvedURL = relativePath.isEmpty + ? URL(fileURLWithPath: root.resolvedPath, isDirectory: true) + : URL(fileURLWithPath: root.resolvedPath, isDirectory: true).appendingPathComponent(relativePath) + let displayPath = relativePath.isEmpty ? root.name : "\(root.name)/\(relativePath)" + return HeadlessCatalogEntry( + root: root, + url: standardized, + resolvedURL: resolvedURL, + relativePath: relativePath, + displayPath: displayPath, + isDirectory: metadata.kind == .directory, + byteCount: metadata.kind == .regularFile ? metadata.byteCount : nil + ) + } + + func filesUnder(_ path: HeadlessResolvedPath, maxFiles: Int = 1000) throws -> [HeadlessResolvedPath] { + guard path.isDirectory else { + return [path] + } + let scanResult = try scan(roots: [path.root], under: path, maxEntries: maxFiles + 1) + guard !scanResult.wasTruncated else { + throw HeadlessCommandError( + "Directory expansion exceeded the headless limit of \(maxFiles) files or entries at \(path.displayPath). Narrow the path and retry.", + exitCode: 2 + ) + } + return scanResult.entries + .filter { !$0.isDirectory && !$0.relativePath.isEmpty } + .prefix(maxFiles) + .map { entry in + HeadlessResolvedPath( + root: entry.root, + url: entry.url, + resolvedURL: entry.resolvedURL, + relativePath: entry.relativePath, + displayPath: entry.displayPath, + isDirectory: false, + isRegularFile: true + ) + } + } + + func tree(roots: [HeadlessAllowedRoot], basePath: HeadlessResolvedPath?, mode: String, maxDepth: Int?) throws -> (tree: String, rootsCount: Int, wasTruncated: Bool) { + if mode == "selected" { + return ("", roots.count, false) + } + let targets: [(HeadlessAllowedRoot, URL)] = if let basePath { + [(basePath.root, basePath.url)] + } else { + roots.map { ($0, URL(fileURLWithPath: $0.path, isDirectory: true).standardizedFileURL) } + } + var lines: [String] = [] + var truncated = false + for (index, target) in targets.enumerated() { + if index > 0 { lines.append("") } + let rootLabel = basePath == nil ? target.0.name : (basePath?.displayPath ?? target.0.name) + lines.append(rootLabel) + let result = try appendTreeLines(root: target.0, directory: target.1, prefix: "", depth: 0, maxDepth: maxDepth ?? 4, lines: &lines, maxLines: 1500) + truncated = truncated || result + } + return (lines.joined(separator: "\n"), targets.count, truncated) + } + + private func appendTreeLines(root: HeadlessAllowedRoot, directory: URL, prefix: String, depth: Int, maxDepth: Int, lines: inout [String], maxLines: Int) throws -> Bool { + guard depth < maxDepth else { + return false + } + guard lines.count < maxLines else { + return true + } + let children = try children(of: directory, root: root) + for (index, child) in children.enumerated() { + guard lines.count < maxLines else { + return true + } + let isLast = index == children.count - 1 + let branch = isLast ? "└── " : "├── " + lines.append("\(prefix)\(branch)\(child.url.lastPathComponent)\(child.isDirectory ? "/" : "")") + if child.isDirectory { + let childPrefix = prefix + (isLast ? " " : "│ ") + let truncated = try appendTreeLines(root: root, directory: child.url, prefix: childPrefix, depth: depth + 1, maxDepth: maxDepth, lines: &lines, maxLines: maxLines) + if truncated { return true } + } + } + return false + } + + private func children(of directory: URL, root: HeadlessAllowedRoot) throws -> [HeadlessCatalogEntry] { + let urls = try fileManager.contentsOfDirectory( + at: directory, + includingPropertiesForKeys: [.isDirectoryKey, .isRegularFileKey, .fileSizeKey], + options: [.skipsHiddenFiles] + ) + var entries: [HeadlessCatalogEntry] = [] + for url in urls where !skippedDirectoryNames.contains(url.lastPathComponent) { + if let entry = try? catalogEntry(for: root, url: url), entry.isDirectory || entry.byteCount != nil { + entries.append(entry) + } + } + return entries.sorted { lhs, rhs in + if lhs.isDirectory != rhs.isDirectory { + return lhs.isDirectory && !rhs.isDirectory + } + return lhs.url.lastPathComponent.localizedStandardCompare(rhs.url.lastPathComponent) == .orderedAscending + } + } +} diff --git a/Sources/RepoPromptHeadless/Runtime/HeadlessHost.swift b/Sources/RepoPromptHeadless/Runtime/HeadlessHost.swift new file mode 100644 index 000000000..3187705f8 --- /dev/null +++ b/Sources/RepoPromptHeadless/Runtime/HeadlessHost.swift @@ -0,0 +1,252 @@ +import Foundation + +actor HeadlessHost { + typealias CatalogMutationLoadedHook = @Sendable () throws -> Void + + let configurationStore: HeadlessConfigurationStore + let workspaceStore: HeadlessWorkspaceStore + let fileManager: FileManager + private let exportWriter: HeadlessExportWriter + private let catalogMutationLoadedHook: CatalogMutationLoadedHook? + + init( + configurationStore: HeadlessConfigurationStore, + fileManager: FileManager = .default, + exportWriter: HeadlessExportWriter? = nil, + catalogMutationLoadedHook: CatalogMutationLoadedHook? = nil + ) { + self.configurationStore = configurationStore + workspaceStore = HeadlessWorkspaceStore(paths: configurationStore.paths, fileManager: fileManager) + self.fileManager = fileManager + self.exportWriter = exportWriter ?? HeadlessExportWriter(paths: configurationStore.paths, fileManager: fileManager) + self.catalogMutationLoadedHook = catalogMutationLoadedHook + } + + func snapshot(requireWorkspace: Bool = false) throws -> HeadlessWorkspaceSnapshot { + let transaction = try configurationStore.withStateTransaction { config in + try workspaceState(config: &config, requireWorkspace: requireWorkspace) + } + return HeadlessWorkspaceSnapshot( + config: transaction.configuration, + workspace: transaction.value.workspace, + roots: transaction.value.roots + ) + } + + func listWorkspaces() throws -> (config: HeadlessConfigurationDocument, workspaces: [HeadlessWorkspaceDocument]) { + let transaction = try configurationStore.withStateTransaction { config in + try validateConfiguredRoots(config.allowedRoots) + var workspaces = try workspaceStore.loadWorkspaces() + if !config.allowedRoots.isEmpty { + let active = try ensureActiveWorkspace(config: &config, workspaces: workspaces) + if workspaces.isEmpty { + workspaces = [active] + } + } + return workspaces + } + return (transaction.configuration, transaction.value) + } + + func createWorkspace(name: String, rootTokens: [String]) throws -> HeadlessWorkspaceDocument { + let trimmedName = name.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmedName.isEmpty else { + throw HeadlessCommandError("Workspace name must not be empty.", exitCode: 2) + } + + return try configurationStore.withStateTransaction { config in + try catalogMutationLoadedHook?() + try validateConfiguredRoots(config.allowedRoots) + guard !config.allowedRoots.isEmpty else { + throw HeadlessCommandError("Cannot create a workspace without configured allowed roots.", exitCode: 2) + } + let existing = try workspaceStore.loadWorkspaces() + guard !existing.contains(where: { $0.name == trimmedName }) else { + throw HeadlessCommandError("Workspace name already exists: \(trimmedName)", exitCode: 2) + } + + let roots = try resolveConfiguredRoots(tokens: rootTokens, allowedRoots: config.allowedRoots) + let workspace = HeadlessWorkspaceDocument(name: trimmedName, rootIDs: roots.map(\.id)) + try workspaceStore.save(workspace) + config.activeWorkspaceID = workspace.id + return workspace + }.value + } + + func selectWorkspace(token: String) throws -> HeadlessWorkspaceDocument { + try configurationStore.withStateTransaction { config in + let workspaces = try workspaceStore.loadWorkspaces() + let workspace = try resolveWorkspace(token: token, workspaces: workspaces) + config.activeWorkspaceID = workspace.id + return workspace + }.value + } + + func renameWorkspace(token: String?, newName: String) throws -> HeadlessWorkspaceDocument { + let trimmedName = newName.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmedName.isEmpty else { + throw HeadlessCommandError("Workspace name must not be empty.", exitCode: 2) + } + + return try configurationStore.withStateTransaction { config in + let state = try workspaceState(config: &config, requireWorkspace: true) + guard let activeWorkspace = state.workspace else { + throw HeadlessCommandError("No active workspace is available.", exitCode: 2) + } + let workspace = try token.map { try resolveWorkspace(token: $0, workspaces: state.workspaces) } ?? activeWorkspace + guard !state.workspaces.contains(where: { $0.id != workspace.id && $0.name == trimmedName }) else { + throw HeadlessCommandError("Workspace name already exists: \(trimmedName)", exitCode: 2) + } + return try workspaceStore.update(id: workspace.id) { document in + document.name = trimmedName + } + }.value + } + + func updateActiveWorkspace(_ body: (inout HeadlessWorkspaceDocument) throws -> Void) throws -> HeadlessWorkspaceDocument { + let snapshot = try snapshot(requireWorkspace: true) + guard let workspace = snapshot.workspace else { + throw HeadlessCommandError("No active workspace is available.", exitCode: 2) + } + return try workspaceStore.update(id: workspace.id, body) + } + + func replaceSelection(_ selection: [HeadlessSelectionEntry]) throws -> HeadlessWorkspaceDocument { + try updateActiveWorkspace { workspace in + workspace.selection = HeadlessSelectionNormalizer.normalized(selection) + } + } + + func updateSelection( + workspaceID: UUID, + _ body: (inout [HeadlessSelectionEntry]) throws -> Void + ) throws -> HeadlessWorkspaceDocument { + try workspaceStore.update(id: workspaceID) { workspace in + try body(&workspace.selection) + workspace.selection = HeadlessSelectionNormalizer.normalized(workspace.selection) + } + } + + func setPrompt(_ text: String) throws -> HeadlessWorkspaceDocument { + try updateActiveWorkspace { workspace in + workspace.promptText = text + } + } + + func appendPrompt(_ text: String) throws -> HeadlessWorkspaceDocument { + try updateActiveWorkspace { workspace in + workspace.promptText += text + } + } + + func clearPrompt() throws -> HeadlessWorkspaceDocument { + try updateActiveWorkspace { workspace in + workspace.promptText = "" + } + } + + func export( + _ data: Data, + to requestedPath: String?, + defaultFileName: String, + permissions: HeadlessPermissions + ) throws -> URL { + try exportWriter.write( + data, + to: requestedPath, + defaultFileName: defaultFileName, + permissions: permissions + ) + } + + private func workspaceState( + config: inout HeadlessConfigurationDocument, + requireWorkspace: Bool + ) throws -> (workspace: HeadlessWorkspaceDocument?, roots: [HeadlessAllowedRoot], workspaces: [HeadlessWorkspaceDocument]) { + try validateConfiguredRoots(config.allowedRoots) + + if config.allowedRoots.isEmpty { + if requireWorkspace { + throw HeadlessCommandError("No headless allowed roots are configured. Add one with `\(HeadlessVersion.executableName) --state-dir \(configurationStore.paths.rootDirectory.path) config roots add /absolute/path --name NAME`.", exitCode: 2) + } + return (nil, [], []) + } + + let workspaces = try workspaceStore.loadWorkspaces() + let active = try ensureActiveWorkspace(config: &config, workspaces: workspaces) + let configuredRootIDs = Set(config.allowedRoots.map(\.id)) + let unknownRootIDs = Set(active.rootIDs) + .subtracting(configuredRootIDs) + .sorted { $0.uuidString < $1.uuidString } + guard unknownRootIDs.isEmpty else { + throw HeadlessCommandError( + "Active workspace '\(active.name)' references unknown configured root IDs: \(unknownRootIDs.map(\.uuidString).joined(separator: ", ")).", + exitCode: 2 + ) + } + let activeRootIDs = Set(active.rootIDs) + let roots = config.allowedRoots.filter { activeRootIDs.contains($0.id) } + if roots.isEmpty, requireWorkspace { + throw HeadlessCommandError("Active workspace '\(active.name)' has no configured roots. Create or select a workspace with configured root IDs/names.", exitCode: 2) + } + let catalog = workspaces.isEmpty ? [active] : workspaces + return (active, roots, catalog) + } + + private func ensureActiveWorkspace( + config: inout HeadlessConfigurationDocument, + workspaces: [HeadlessWorkspaceDocument] + ) throws -> HeadlessWorkspaceDocument { + if let activeID = config.activeWorkspaceID, + let active = workspaces.first(where: { $0.id == activeID }) + { + return active + } + if let first = workspaces.first { + config.activeWorkspaceID = first.id + return first + } + let created = try createDefaultWorkspace(for: config.allowedRoots) + config.activeWorkspaceID = created.id + return created + } + + private func createDefaultWorkspace(for roots: [HeadlessAllowedRoot]) throws -> HeadlessWorkspaceDocument { + let workspace = HeadlessWorkspaceDocument(name: "Default", rootIDs: roots.map(\.id)) + try workspaceStore.save(workspace) + return workspace + } + + private func validateConfiguredRoots(_ roots: [HeadlessAllowedRoot]) throws { + let failures = HeadlessRootAccessPolicy.validationFailures(for: roots, fileManager: fileManager) + guard failures.isEmpty else { + throw HeadlessCommandError("Headless root policy validation failed:\n- \(failures.joined(separator: "\n- "))", exitCode: 2) + } + } + + private func resolveConfiguredRoots(tokens: [String], allowedRoots: [HeadlessAllowedRoot]) throws -> [HeadlessAllowedRoot] { + guard !tokens.isEmpty else { + return allowedRoots + } + var resolved: [HeadlessAllowedRoot] = [] + for token in tokens { + guard let root = allowedRoots.first(where: { HeadlessRootAccessPolicy.rootMatches($0, token: token, fileManager: fileManager) }) else { + throw HeadlessCommandError("No configured allowed root matches '\(token)'.", exitCode: 2) + } + if !resolved.contains(where: { $0.id == root.id }) { + resolved.append(root) + } + } + return resolved + } + + private func resolveWorkspace(token: String, workspaces: [HeadlessWorkspaceDocument]) throws -> HeadlessWorkspaceDocument { + if let id = UUID(uuidString: token), let workspace = workspaces.first(where: { $0.id == id }) { + return workspace + } + if let workspace = workspaces.first(where: { $0.name == token }) { + return workspace + } + throw HeadlessCommandError("No headless workspace matches '\(token)'.", exitCode: 2) + } +} diff --git a/Sources/RepoPromptHeadless/Runtime/HeadlessPathResolver.swift b/Sources/RepoPromptHeadless/Runtime/HeadlessPathResolver.swift new file mode 100644 index 000000000..955217d13 --- /dev/null +++ b/Sources/RepoPromptHeadless/Runtime/HeadlessPathResolver.swift @@ -0,0 +1,147 @@ +import Foundation + +struct HeadlessPathResolver { + let roots: [HeadlessAllowedRoot] + let fileManager: FileManager + let secureFileAccess: HeadlessSecureFileAccess + + init( + roots: [HeadlessAllowedRoot], + fileManager: FileManager = .default, + secureFileAccess: HeadlessSecureFileAccess = HeadlessSecureFileAccess() + ) { + self.roots = roots + self.fileManager = fileManager + self.secureFileAccess = secureFileAccess + } + + func resolve(_ input: String, requireExists: Bool = true) throws -> HeadlessResolvedPath { + let trimmed = input.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { + throw HeadlessCommandError("Path must not be empty.", exitCode: 2) + } + guard !roots.isEmpty else { + throw HeadlessCommandError("No roots are bound to the active headless workspace.", exitCode: 2) + } + + if trimmed.hasPrefix("/") { + return try resolvedAbsolute(URL(fileURLWithPath: trimmed), requireExists: requireExists) + } + + if let prefixed = try resolveRootPrefixed(trimmed, requireExists: requireExists) { + return prefixed + } + + var matches: [HeadlessResolvedPath] = [] + for root in roots { + let candidate = URL(fileURLWithPath: root.path, isDirectory: true) + .appendingPathComponent(trimmed, isDirectory: false) + do { + let resolved = try resolvedCandidate(candidate, root: root, requireExists: requireExists) + matches.append(resolved) + } catch let error as HeadlessCommandError { + if requireExists, error.exitCode == 2 { + continue + } + throw error + } + } + + switch matches.count { + case 1: + return matches[0] + case 0: + throw HeadlessCommandError("Path is not available under the active workspace roots: \(trimmed)", exitCode: 2) + default: + let roots = matches.map(\.root.name).joined(separator: ", ") + throw HeadlessCommandError("Ambiguous relative path '\(trimmed)' matches multiple roots: \(roots). Prefix with RootName/ to disambiguate.", exitCode: 2) + } + } + + func resolveMany(_ inputs: [String], requireExists: Bool = true) throws -> [HeadlessResolvedPath] { + try inputs.map { try resolve($0, requireExists: requireExists) } + } + + private func resolveRootPrefixed(_ input: String, requireExists: Bool) throws -> HeadlessResolvedPath? { + let parts = input.split(separator: "/", omittingEmptySubsequences: false) + guard let first = parts.first else { + return nil + } + let token = String(first) + guard let root = roots.first(where: { root in + root.name == token || root.id.uuidString.caseInsensitiveCompare(token) == .orderedSame + }) else { + return nil + } + let rest = parts.dropFirst().map(String.init).joined(separator: "/") + let base = URL(fileURLWithPath: root.path, isDirectory: true) + let candidate = rest.isEmpty ? base : base.appendingPathComponent(rest, isDirectory: false) + return try resolvedCandidate(candidate, root: root, lexicalRootPath: root.path, requireExists: requireExists) + } + + private func resolvedAbsolute(_ url: URL, requireExists: Bool) throws -> HeadlessResolvedPath { + let standardized = url.standardizedFileURL + let containingRoots = roots.compactMap { root -> (root: HeadlessAllowedRoot, lexicalRootPath: String)? in + if HeadlessRootAccessPolicy.path(standardized.path, isContainedInOrEqualTo: root.path) { + return (root, root.path) + } + if HeadlessRootAccessPolicy.path(standardized.path, isContainedInOrEqualTo: root.resolvedPath) { + return (root, root.resolvedPath) + } + return nil + } + guard let match = containingRoots.sorted(by: { $0.lexicalRootPath.count > $1.lexicalRootPath.count }).first else { + throw HeadlessCommandError("Path is outside the active headless allowed roots: \(url.path)", exitCode: 2) + } + return try resolvedCandidate(standardized, root: match.root, lexicalRootPath: match.lexicalRootPath, requireExists: requireExists) + } + + private func resolvedCandidate( + _ candidate: URL, + root: HeadlessAllowedRoot, + lexicalRootPath: String? = nil, + requireExists: Bool + ) throws -> HeadlessResolvedPath { + let standardized = candidate.standardizedFileURL + let lexicalRootPath = lexicalRootPath ?? root.path + guard HeadlessRootAccessPolicy.path(standardized.path, isContainedInOrEqualTo: lexicalRootPath) else { + throw HeadlessCommandError("Path is outside allowed root '\(root.name)': \(candidate.path)", exitCode: 2) + } + let resolvedURL = standardized.resolvingSymlinksInPath().standardizedFileURL + guard HeadlessRootAccessPolicy.path(resolvedURL.path, isContainedInOrEqualTo: root.resolvedPath) else { + throw HeadlessCommandError("Path resolves outside allowed root '\(root.name)': \(candidate.path)", exitCode: 2) + } + let relativePath = Self.relativePath(forResolvedPath: standardized.path, rootResolvedPath: lexicalRootPath) + let metadata: HeadlessSecureFileMetadata? = if requireExists { + try secureFileAccess.inspect(root: root, relativePath: relativePath) + } else { + nil + } + let descriptorResolvedURL = relativePath.isEmpty + ? URL(fileURLWithPath: root.resolvedPath, isDirectory: true) + : URL(fileURLWithPath: root.resolvedPath, isDirectory: true).appendingPathComponent(relativePath) + let displayPath = relativePath.isEmpty ? root.name : "\(root.name)/\(relativePath)" + return HeadlessResolvedPath( + root: root, + url: standardized, + resolvedURL: descriptorResolvedURL.standardizedFileURL, + relativePath: relativePath, + displayPath: displayPath, + isDirectory: metadata?.kind == .directory, + isRegularFile: metadata?.kind == .regularFile + ) + } + + static func relativePath(forResolvedPath path: String, rootResolvedPath: String) -> String { + let root = URL(fileURLWithPath: rootResolvedPath).standardizedFileURL.path + let candidate = URL(fileURLWithPath: path).standardizedFileURL.path + guard candidate != root else { + return "" + } + let prefix = root.hasSuffix("/") ? root : "\(root)/" + guard candidate.hasPrefix(prefix) else { + return candidate + } + return String(candidate.dropFirst(prefix.count)) + } +} diff --git a/Sources/RepoPromptHeadless/Runtime/HeadlessReadFileSlicer.swift b/Sources/RepoPromptHeadless/Runtime/HeadlessReadFileSlicer.swift new file mode 100644 index 000000000..8476003ee --- /dev/null +++ b/Sources/RepoPromptHeadless/Runtime/HeadlessReadFileSlicer.swift @@ -0,0 +1,104 @@ +import Foundation + +struct HeadlessReadFileSlice: Equatable { + var content: String + var totalLines: Int + var firstLine: Int + var lastLine: Int + var message: String? +} + +enum HeadlessReadFileSlicer { + static func slice(text: String, startLine: Int?, limit: Int?) throws -> HeadlessReadFileSlice { + if let startLine { + if startLine < 0, limit != nil { + throw HeadlessCommandError("limit parameter is not allowed with negative start_line. Use start_line=-N to read the last N lines.", exitCode: 2) + } + if startLine == 0 { + throw HeadlessCommandError("start_line must be positive (1-based) or negative (tail-like behavior)", exitCode: 2) + } + } + + let lines = splitPreservingLineEndings(text) + let total = lines.count + let first: Int + let lastExclusive: Int + + if let startLine, startLine < 0 { + let linesToRead = startLine == Int.min ? Int.max : -startLine + first = max(0, total - linesToRead) + lastExclusive = total + } else { + let requestedStart = startLine ?? 1 + first = max(0, requestedStart - 1) + if let limit, limit >= 0 { + if first >= total || limit >= total - first { + lastExclusive = total + } else { + lastExclusive = first + limit + } + } else { + lastExclusive = total + } + } + + if !(first < total || total == 0) { + return HeadlessReadFileSlice( + content: "", + totalLines: total, + firstLine: max(1, first + 1), + lastLine: total, + message: "Requested start_line exceeds file length." + ) + } + + let content: String = if total == 0 || first >= lastExclusive { + "" + } else { + lines[first ..< lastExclusive].map { $0.line + $0.ending }.joined() + } + return HeadlessReadFileSlice( + content: content, + totalLines: total, + firstLine: total == 0 ? 0 : first + 1, + lastLine: total == 0 ? 0 : lastExclusive, + message: nil + ) + } + + private static func splitPreservingLineEndings(_ content: String) -> [(line: String, ending: String)] { + guard !content.isEmpty else { return [] } + + var result: [(String, String)] = [] + let scalars = content.unicodeScalars + var lineStart = scalars.startIndex + var index = scalars.startIndex + + while index < scalars.endIndex { + let scalar = scalars[index] + if scalar == "\r" { + let line = String(scalars[lineStart ..< index]) + let next = scalars.index(after: index) + if next < scalars.endIndex, scalars[next] == "\n" { + result.append((line, "\r\n")) + index = scalars.index(after: next) + } else { + result.append((line, "\r")) + index = next + } + lineStart = index + } else if scalar == "\n" { + result.append((String(scalars[lineStart ..< index]), "\n")) + index = scalars.index(after: index) + lineStart = index + } else { + index = scalars.index(after: index) + } + } + + if lineStart < scalars.endIndex { + result.append((String(scalars[lineStart ..< scalars.endIndex]), "")) + } + return result + } +} diff --git a/Sources/RepoPromptHeadless/Runtime/HeadlessSearchService.swift b/Sources/RepoPromptHeadless/Runtime/HeadlessSearchService.swift new file mode 100644 index 000000000..a8651e4e6 --- /dev/null +++ b/Sources/RepoPromptHeadless/Runtime/HeadlessSearchService.swift @@ -0,0 +1,488 @@ +import Dispatch +import Foundation +import RepoPromptCore + +struct HeadlessSearchResult { + var summary: String + var structured: [String: Any] +} + +struct HeadlessSearchLimits { + var maxCatalogEntries: Int = 20000 + var maxContentFiles: Int = 2048 + var maxContentBytes: Int = 64 * 1024 * 1024 + var maxElapsedNanoseconds: UInt64 = 3_000_000_000 + var maxMatcherWorkBytes: Int = 32 * 1024 * 1024 + var maxRegexSubjectBytes: Int = 64 * 1024 + var regexMatchLimits = PCRE2MatchLimits( + matchLimit: 250_000, + depthLimit: 10000, + heapLimitKiB: 8 * 1024 + ) +} + +final class HeadlessSearchService { + private let catalog: HeadlessFileCatalog + private let secureFileAccess: HeadlessSecureFileAccess + private let limits: HeadlessSearchLimits + private let monotonicNow: () -> UInt64 + private let maxReadableBytes = 2 * 1024 * 1024 + + init( + catalog: HeadlessFileCatalog = HeadlessFileCatalog(), + secureFileAccess: HeadlessSecureFileAccess = HeadlessSecureFileAccess(), + maxCatalogEntries: Int = 20000 + ) { + var limits = HeadlessSearchLimits() + limits.maxCatalogEntries = max(0, maxCatalogEntries) + self.catalog = catalog + self.secureFileAccess = secureFileAccess + self.limits = limits + monotonicNow = { DispatchTime.now().uptimeNanoseconds } + } + + init( + catalog: HeadlessFileCatalog = HeadlessFileCatalog(), + secureFileAccess: HeadlessSecureFileAccess = HeadlessSecureFileAccess(), + limits: HeadlessSearchLimits, + monotonicNow: @escaping () -> UInt64 = { DispatchTime.now().uptimeNanoseconds } + ) { + self.catalog = catalog + self.secureFileAccess = secureFileAccess + self.limits = HeadlessSearchLimits( + maxCatalogEntries: max(0, limits.maxCatalogEntries), + maxContentFiles: max(0, limits.maxContentFiles), + maxContentBytes: max(0, limits.maxContentBytes), + maxElapsedNanoseconds: limits.maxElapsedNanoseconds, + maxMatcherWorkBytes: max(0, limits.maxMatcherWorkBytes), + maxRegexSubjectBytes: max(0, limits.maxRegexSubjectBytes), + regexMatchLimits: limits.regexMatchLimits + ) + self.monotonicNow = monotonicNow + } + + func search(roots: [HeadlessAllowedRoot], resolver: HeadlessPathResolver, arguments: [String: Any]) throws -> HeadlessSearchResult { + let pattern = try HeadlessToolArguments.requiredString(arguments, key: "pattern") + let mode = HeadlessToolArguments.string(arguments, key: "mode") ?? "auto" + let countOnly = HeadlessToolArguments.bool(arguments, key: "count_only") ?? false + let maxResults = max(1, min(HeadlessToolArguments.int(arguments, key: "max_results") ?? 50, 1000)) + let contextLines = max(0, min(HeadlessToolArguments.int(arguments, key: "context_lines") ?? 0, 5)) + let wholeWord = HeadlessToolArguments.bool(arguments, key: "whole_word") ?? false + let regexFlag = HeadlessToolArguments.bool(arguments, key: "regex") + let useRegex = regexFlag ?? Self.looksLikeRegex(pattern) + let effectiveMode = mode == "auto" ? "both" : mode + guard ["path", "content", "both"].contains(effectiveMode) else { + throw HeadlessCommandError("Unsupported file_search mode '\(mode)'. Expected auto, path, content, or both.", exitCode: 2) + } + + let filter = arguments["filter"] as? [String: Any] ?? [:] + let extensions = Set((HeadlessToolArguments.stringArray(filter, key: "extensions") ?? []).map { ext in + ext.hasPrefix(".") ? ext.lowercased() : ".\(ext.lowercased())" + }) + let exclude = HeadlessToolArguments.stringArray(filter, key: "exclude") ?? [] + let filterPaths = (HeadlessToolArguments.stringArray(filter, key: "paths") ?? []) + (HeadlessToolArguments.string(arguments, key: "path").map { [$0] } ?? []) + let matcher = try Matcher(pattern: pattern, regex: useRegex, wholeWord: wholeWord, limits: limits.regexMatchLimits) + let budget = HeadlessSearchBudgetTracker(limits: limits, monotonicNow: monotonicNow) + + var searchEntries: [HeadlessCatalogEntry] = [] + var catalogScanCount = 0 + var catalogWasTruncated = false + var catalogSkippedEntries = 0 + let catalogCheckpoint = { try budget.checkpoint() } + if filterPaths.isEmpty { + let scanResult = try catalog.scan( + roots: roots, + maxEntries: limits.maxCatalogEntries, + shouldContinue: catalogCheckpoint + ) + searchEntries = scanResult.entries + catalogScanCount = 1 + catalogWasTruncated = scanResult.wasTruncated + catalogSkippedEntries = scanResult.skippedEntryCount + } else { + var seenEntries: Set = [] + for filterPath in filterPaths { + guard try budget.checkpoint(), searchEntries.count < limits.maxCatalogEntries else { + catalogWasTruncated = true + break + } + let resolved = try resolver.resolve(filterPath) + let scanResult = try catalog.scan( + roots: [resolved.root], + under: resolved, + maxEntries: limits.maxCatalogEntries, + shouldContinue: catalogCheckpoint + ) + catalogScanCount += 1 + catalogWasTruncated = catalogWasTruncated || scanResult.wasTruncated + catalogSkippedEntries += scanResult.skippedEntryCount + for entry in scanResult.entries { + guard try budget.checkpoint() else { + catalogWasTruncated = true + break + } + let key = "\(entry.root.id.uuidString):\(entry.relativePath)" + guard seenEntries.insert(key).inserted else { continue } + guard searchEntries.count < limits.maxCatalogEntries else { + catalogWasTruncated = true + break + } + searchEntries.append(entry) + } + if budget.isExhausted { + catalogWasTruncated = true + break + } + } + } + + var pathMatches: [[String: Any]] = [] + var contentMatches: [[String: Any]] = [] + var totalPathMatches = 0 + var totalContentMatches = 0 + var returnedMatches = 0 + var catalogEntriesProcessed = 0 + var contentFilesScanned = 0 + var contentFilesSkipped = 0 + var contentBytesScanned = 0 + + entryLoop: for entry in searchEntries where !entry.relativePath.isEmpty { + guard try budget.checkpoint() else { break } + catalogEntriesProcessed += 1 + if shouldSkip(entry: entry, extensions: extensions, exclude: exclude) { + continue + } + if effectiveMode == "path" || effectiveMode == "both" { + let displayMatched = try matcher.matches(entry.displayPath, budget: budget) + guard !budget.isExhausted else { break } + let relativeMatched = displayMatched ? false : try matcher.matches(entry.relativePath, budget: budget) + guard !budget.isExhausted else { break } + if displayMatched || relativeMatched { + totalPathMatches += 1 + if !countOnly, returnedMatches < maxResults { + pathMatches.append(["path": entry.displayPath, "relative_path": entry.relativePath, "root": entry.root.name]) + returnedMatches += 1 + } + } + } + guard !entry.isDirectory, effectiveMode == "content" || effectiveMode == "both" else { + continue + } + guard let byteCount = entry.byteCount, byteCount <= maxReadableBytes else { + contentFilesSkipped += 1 + continue + } + let contentByteCount = Int(byteCount) + guard try budget.reserveContentFile(byteCount: contentByteCount) else { break } + guard let snapshot = try? readTextFile(entry, maximumBytes: contentByteCount) else { + contentFilesSkipped += 1 + continue + } + guard try budget.checkpoint() else { break } + contentFilesScanned += 1 + contentBytesScanned += snapshot.byteCount + let lines = snapshot.text.components(separatedBy: .newlines) + for (index, line) in lines.enumerated() { + guard try budget.checkpoint() else { break entryLoop } + guard try matcher.matches(line, budget: budget) else { + if budget.isExhausted { break entryLoop } + continue + } + totalContentMatches += 1 + if !countOnly, returnedMatches < maxResults { + let start = max(0, index - contextLines) + let end = min(lines.count - 1, index + contextLines) + let context = (start ... end).map { lineIndex in + ["line": lineIndex + 1, "text": lines[lineIndex]] as [String: Any] + } + contentMatches.append([ + "path": entry.displayPath, + "relative_path": entry.relativePath, + "root": entry.root.name, + "line": index + 1, + "text": line, + "context": context + ]) + returnedMatches += 1 + } + } + } + + let totalMatches = totalPathMatches + totalContentMatches + let omitted = max(0, totalMatches - maxResults) + let includesPathSearch = effectiveMode == "path" || effectiveMode == "both" + let includesContentSearch = effectiveMode == "content" || effectiveMode == "both" + let catalogComplete = !catalogWasTruncated && catalogSkippedEntries == 0 + let pathTotalsComplete = !includesPathSearch || (catalogComplete && !budget.isExhausted) + let contentTotalsComplete = !includesContentSearch || (catalogComplete && contentFilesSkipped == 0 && !budget.isExhausted) + let totalsComplete = pathTotalsComplete && contentTotalsComplete + let totalDisplay = totalsComplete ? "\(totalMatches)" : "\(totalMatches) (lower bound)" + var lines: [String] = [ + "## Search Results ✅", + "- **Pattern**: `\(pattern)`", + "- **Mode**: `\(mode)`", + "- **Total matches**: \(totalDisplay)", + "- **Path matches**: \(totalPathMatches)", + "- **Content matches**: \(totalContentMatches)", + "- **Returned matches**: \(returnedMatches)", + "- **Omitted by max_results**: \(omitted)", + "- **Catalog entries scanned**: \(searchEntries.count)", + "- **Catalog entries processed**: \(catalogEntriesProcessed)", + "- **Catalog entry limit**: \(limits.maxCatalogEntries) across \(catalogScanCount) scan(s)", + "- **Content files read**: \(contentFilesScanned) / \(limits.maxContentFiles)", + "- **Content bytes read**: \(contentBytesScanned) / \(limits.maxContentBytes)", + "- **Matcher work bytes**: \(budget.matcherWorkBytes) / \(limits.maxMatcherWorkBytes)", + "- **Elapsed budget**: \(budget.elapsedMilliseconds) ms / \(limits.maxElapsedNanoseconds / 1_000_000) ms" + ] + if countOnly { + lines.append("- **Count only**: true") + } else { + if !pathMatches.isEmpty { + lines.append("\n### Path Matches") + for match in pathMatches { + lines.append("- `\(match["path"] as? String ?? "")`") + } + } + if !contentMatches.isEmpty { + lines.append("\n### Content Matches") + for match in contentMatches { + let path = match["path"] as? String ?? "" + let line = match["line"] as? Int ?? 0 + let text = match["text"] as? String ?? "" + lines.append("- `\(path):\(line)` \(text)") + } + } + if omitted > 0 { + lines.append("\n_Omitted \(omitted) match(es) after max_results=\(maxResults)._") + } + } + if catalogWasTruncated { + lines.append("\n⚠️ Catalog entry or search budget limit reached; eligible entries remain unscanned, so totals are lower bounds.") + } + if catalogSkippedEntries > 0 { + lines.append("\n⚠️ Skipped \(catalogSkippedEntries) catalog entry or traversal error(s); totals are lower bounds.") + } + if includesContentSearch, contentFilesSkipped > 0 { + lines.append("\n⚠️ Skipped \(contentFilesSkipped) unreadable, non-UTF-8, binary, or oversized content file(s); content totals are lower bounds.") + } + if let reason = budget.exhaustedReason { + lines.append("\n⚠️ Search stopped at the bounded \(reason.summary); totals are lower bounds. Narrow the path/filter or pattern and retry.") + } + + let budgetExhaustionReason: Any = budget.exhaustedReason?.rawValue as Any? ?? NSNull() + return HeadlessSearchResult(summary: lines.joined(separator: "\n"), structured: [ + "pattern": pattern, + "mode": mode, + "regex": useRegex, + "whole_word": wholeWord, + "total_matches": totalMatches, + "total_path_matches": totalPathMatches, + "total_content_matches": totalContentMatches, + "returned_matches": returnedMatches, + "count_only": countOnly, + "path_matches": pathMatches, + "content_matches": contentMatches, + "omitted": omitted, + "catalog_entries_scanned": searchEntries.count, + "catalog_entries_considered": searchEntries.count, + "catalog_entries_processed": catalogEntriesProcessed, + "catalog_entry_limit": limits.maxCatalogEntries, + "catalog_scan_count": catalogScanCount, + "catalog_truncated": catalogWasTruncated, + "catalog_skipped_entries": catalogSkippedEntries, + "content_files_scanned": contentFilesScanned, + "content_files_attempted": budget.contentFilesAttempted, + "content_file_limit": limits.maxContentFiles, + "content_files_skipped": contentFilesSkipped, + "content_bytes_scanned": contentBytesScanned, + "content_bytes_considered": budget.contentBytesConsidered, + "content_byte_limit": limits.maxContentBytes, + "matcher_work_bytes": budget.matcherWorkBytes, + "matcher_work_byte_limit": limits.maxMatcherWorkBytes, + "regex_subject_byte_limit": limits.maxRegexSubjectBytes, + "elapsed_milliseconds": budget.elapsedMilliseconds, + "elapsed_time_limit_milliseconds": limits.maxElapsedNanoseconds / 1_000_000, + "budget_exhausted": budget.isExhausted, + "budget_exhaustion_reason": budgetExhaustionReason, + "path_totals_complete": pathTotalsComplete, + "content_totals_complete": contentTotalsComplete, + "totals_complete": totalsComplete, + "totals_are_lower_bounds": !totalsComplete, + "total_matches_is_lower_bound": !totalsComplete, + "omitted_is_lower_bound": !totalsComplete + ]) + } + + private func shouldSkip(entry: HeadlessCatalogEntry, extensions: Set, exclude: [String]) -> Bool { + if !extensions.isEmpty, !entry.isDirectory { + let ext = ".\(entry.url.pathExtension.lowercased())" + if !extensions.contains(ext) { + return true + } + } + return exclude.contains { token in + entry.relativePath.localizedCaseInsensitiveContains(token) || entry.displayPath.localizedCaseInsensitiveContains(token) + } + } + + private func readTextFile(_ entry: HeadlessCatalogEntry, maximumBytes: Int) throws -> (text: String, byteCount: Int) { + let data = try secureFileAccess.readRegularFile( + root: entry.root, + relativePath: entry.relativePath, + maximumBytes: min(maxReadableBytes, maximumBytes) + ).data + guard !data.contains(0) else { + throw HeadlessCommandError("Binary file skipped: \(entry.displayPath)", exitCode: 2) + } + guard let text = String(data: data, encoding: .utf8) else { + throw HeadlessCommandError("File is not valid UTF-8: \(entry.displayPath)", exitCode: 2) + } + return (text, data.count) + } + + private static func looksLikeRegex(_ pattern: String) -> Bool { + pattern.range(of: #"[.\[\]()*+?{}|^$]"#, options: .regularExpression) != nil + } + + private struct Matcher { + let pattern: String + let regex: PCRE2Regex? + let matchLimits: PCRE2MatchLimits + + init(pattern: String, regex: Bool, wholeWord: Bool, limits: PCRE2MatchLimits) throws { + self.pattern = pattern + matchLimits = limits + guard regex || wholeWord else { + self.regex = nil + return + } + let source = regex ? pattern : PCRE2Literal.escapedPattern(for: pattern) + let wrapped = wholeWord ? "\\b(?:\(source))\\b" : source + do { + try RegexToolkit.validateComplexity(wrapped, isRegex: true) + self.regex = try PCRE2Regex(wrapped) + } catch { + throw HeadlessCommandError("Invalid or unsafe regular expression: \(error.localizedDescription)", exitCode: 2) + } + } + + func matches(_ text: String, budget: HeadlessSearchBudgetTracker) throws -> Bool { + let subjectBytes = text.utf8.count + guard try budget.reserveMatcherWork(subjectBytes: subjectBytes, isRegex: regex != nil) else { + return false + } + guard let regex else { + return text.localizedCaseInsensitiveContains(pattern) + } + do { + let matched = try regex.firstMatch(in: text, matchLimits: matchLimits) != nil + guard try budget.checkpoint() else { return false } + return matched + } catch let error as PCRE2Error { + switch error { + case .matchLimitExceeded: + budget.exhaust(.regexMatchLimit) + return false + default: + throw HeadlessCommandError("Regular expression matching failed: \(error.localizedDescription)", exitCode: 2) + } + } + } + } +} + +private final class HeadlessSearchBudgetTracker { + enum ExhaustionReason: String { + case timeLimit = "time_limit" + case contentFileLimit = "content_file_limit" + case contentByteLimit = "content_byte_limit" + case matcherWorkLimit = "matcher_work_limit" + case regexSubjectLimit = "regex_subject_limit" + case regexMatchLimit = "regex_match_limit" + + var summary: String { + switch self { + case .timeLimit: "elapsed-time budget" + case .contentFileLimit: "content-file budget" + case .contentByteLimit: "content-byte budget" + case .matcherWorkLimit: "matcher-work budget" + case .regexSubjectLimit: "per-subject regex budget" + case .regexMatchLimit: "per-match regex engine budget" + } + } + } + + let limits: HeadlessSearchLimits + let monotonicNow: () -> UInt64 + let startedAt: UInt64 + private(set) var lastObservedAt: UInt64 + private(set) var contentFilesAttempted = 0 + private(set) var contentBytesConsidered = 0 + private(set) var matcherWorkBytes = 0 + private(set) var exhaustedReason: ExhaustionReason? + + init(limits: HeadlessSearchLimits, monotonicNow: @escaping () -> UInt64) { + self.limits = limits + self.monotonicNow = monotonicNow + let now = monotonicNow() + startedAt = now + lastObservedAt = now + } + + var isExhausted: Bool { + exhaustedReason != nil + } + + var elapsedMilliseconds: UInt64 { + guard lastObservedAt >= startedAt else { return 0 } + return (lastObservedAt - startedAt) / 1_000_000 + } + + func checkpoint() throws -> Bool { + try Task.checkCancellation() + guard exhaustedReason == nil else { return false } + let now = monotonicNow() + lastObservedAt = max(lastObservedAt, now) + if now >= startedAt, now - startedAt >= limits.maxElapsedNanoseconds { + exhaust(.timeLimit) + return false + } + try Task.checkCancellation() + return true + } + + func reserveContentFile(byteCount: Int) throws -> Bool { + guard try checkpoint() else { return false } + guard contentFilesAttempted < limits.maxContentFiles else { + exhaust(.contentFileLimit) + return false + } + guard byteCount <= limits.maxContentBytes - min(contentBytesConsidered, limits.maxContentBytes) else { + exhaust(.contentByteLimit) + return false + } + contentFilesAttempted += 1 + contentBytesConsidered += byteCount + return true + } + + func reserveMatcherWork(subjectBytes: Int, isRegex: Bool) throws -> Bool { + guard try checkpoint() else { return false } + if isRegex, subjectBytes > limits.maxRegexSubjectBytes { + exhaust(.regexSubjectLimit) + return false + } + guard subjectBytes <= limits.maxMatcherWorkBytes - min(matcherWorkBytes, limits.maxMatcherWorkBytes) else { + exhaust(.matcherWorkLimit) + return false + } + matcherWorkBytes += subjectBytes + return true + } + + func exhaust(_ reason: ExhaustionReason) { + if exhaustedReason == nil { + exhaustedReason = reason + } + } +} diff --git a/Sources/RepoPromptHeadless/Runtime/HeadlessSecureFileAccess.swift b/Sources/RepoPromptHeadless/Runtime/HeadlessSecureFileAccess.swift new file mode 100644 index 000000000..7dc40d5ea --- /dev/null +++ b/Sources/RepoPromptHeadless/Runtime/HeadlessSecureFileAccess.swift @@ -0,0 +1,166 @@ +import Darwin +import Foundation + +struct HeadlessSecureFileMetadata: Equatable { + enum Kind: Equatable { + case directory + case regularFile + } + + var kind: Kind + var byteCount: Int64 +} + +struct HeadlessSecureFileSnapshot { + var data: Data + var byteCount: Int64 +} + +final class HeadlessSecureFileAccess { + typealias ComponentOpenHook = (_ relativePath: String, _ descriptor: Int32) -> Void + + private let componentOpenHook: ComponentOpenHook? + + init(componentOpenHook: ComponentOpenHook? = nil) { + self.componentOpenHook = componentOpenHook + } + + func inspect(root: HeadlessAllowedRoot, relativePath: String) throws -> HeadlessSecureFileMetadata { + let opened = try openNode(root: root, relativePath: relativePath) + defer { Darwin.close(opened.descriptor) } + return try metadata(from: opened.status, displayPath: displayPath(root: root, relativePath: relativePath)) + } + + func readRegularFile(root: HeadlessAllowedRoot, relativePath: String, maximumBytes: Int) throws -> HeadlessSecureFileSnapshot { + guard maximumBytes >= 0 else { + throw HeadlessCommandError("Maximum readable byte count must not be negative.", exitCode: 2) + } + let opened = try openNode(root: root, relativePath: relativePath) + defer { Darwin.close(opened.descriptor) } + + let displayPath = displayPath(root: root, relativePath: relativePath) + let metadata = try metadata(from: opened.status, displayPath: displayPath) + guard metadata.kind == .regularFile else { + throw HeadlessCommandError("Path is not a regular file: \(displayPath)", exitCode: 2) + } + guard metadata.byteCount <= Int64(maximumBytes) else { + throw HeadlessCommandError("File is too large to read in headless v1 (\(metadata.byteCount) bytes > \(maximumBytes)): \(displayPath)", exitCode: 2) + } + + var data = Data() + data.reserveCapacity(min(maximumBytes, max(0, Int(metadata.byteCount)))) + var buffer = [UInt8](repeating: 0, count: 64 * 1024) + while true { + let count = buffer.withUnsafeMutableBytes { rawBuffer -> Int in + guard let baseAddress = rawBuffer.baseAddress else { return 0 } + return Darwin.read(opened.descriptor, baseAddress, rawBuffer.count) + } + if count == 0 { + break + } + if count < 0 { + if errno == EINTR { + continue + } + throw posixError(operation: "read", path: displayPath, errorNumber: errno) + } + guard data.count <= maximumBytes - count else { + throw HeadlessCommandError("File grew beyond the headless read limit (\(maximumBytes) bytes): \(displayPath)", exitCode: 2) + } + data.append(contentsOf: buffer[0 ..< count]) + } + return HeadlessSecureFileSnapshot(data: data, byteCount: metadata.byteCount) + } + + private func openNode(root: HeadlessAllowedRoot, relativePath: String) throws -> (descriptor: Int32, status: stat) { + let components = try validatedComponents(relativePath) + let rootDescriptor = Darwin.open(root.resolvedPath, O_RDONLY | O_DIRECTORY | O_CLOEXEC | O_NOFOLLOW) + guard rootDescriptor >= 0 else { + throw posixError(operation: "open allowed root", path: root.resolvedPath, errorNumber: errno) + } + + var currentDescriptor = rootDescriptor + var traversed: [String] = [] + if components.isEmpty { + var status = stat() + guard Darwin.fstat(currentDescriptor, &status) == 0 else { + let savedErrno = errno + Darwin.close(currentDescriptor) + throw posixError(operation: "inspect allowed root", path: root.resolvedPath, errorNumber: savedErrno) + } + componentOpenHook?("", currentDescriptor) + return (currentDescriptor, status) + } + + for (index, component) in components.enumerated() { + let isLeaf = index == components.count - 1 + let flags = O_RDONLY | O_CLOEXEC | O_NOFOLLOW | (isLeaf ? O_NONBLOCK : O_DIRECTORY) + let nextDescriptor = component.withCString { pointer in + Darwin.openat(currentDescriptor, pointer, flags) + } + guard nextDescriptor >= 0 else { + let savedErrno = errno + Darwin.close(currentDescriptor) + traversed.append(component) + throw posixError(operation: "open", path: displayPath(root: root, relativePath: traversed.joined(separator: "/")), errorNumber: savedErrno) + } + + Darwin.close(currentDescriptor) + currentDescriptor = nextDescriptor + traversed.append(component) + + var status = stat() + guard Darwin.fstat(currentDescriptor, &status) == 0 else { + let savedErrno = errno + Darwin.close(currentDescriptor) + throw posixError(operation: "inspect", path: displayPath(root: root, relativePath: traversed.joined(separator: "/")), errorNumber: savedErrno) + } + if !isLeaf, (status.st_mode & S_IFMT) != S_IFDIR { + Darwin.close(currentDescriptor) + throw HeadlessCommandError("Path component is not a directory: \(displayPath(root: root, relativePath: traversed.joined(separator: "/")))", exitCode: 2) + } + componentOpenHook?(traversed.joined(separator: "/"), currentDescriptor) + if isLeaf { + return (currentDescriptor, status) + } + } + + Darwin.close(currentDescriptor) + throw HeadlessCommandError("Unable to open path: \(displayPath(root: root, relativePath: relativePath))", exitCode: 2) + } + + private func validatedComponents(_ relativePath: String) throws -> [String] { + guard !relativePath.hasPrefix("/") else { + throw HeadlessCommandError("Resolved path must remain relative to its allowed root.", exitCode: 2) + } + guard !relativePath.utf8.contains(0) else { + throw HeadlessCommandError("Path must not contain NUL bytes.", exitCode: 2) + } + guard !relativePath.isEmpty else { return [] } + let components = relativePath.split(separator: "/", omittingEmptySubsequences: false).map(String.init) + guard components.allSatisfy({ !$0.isEmpty && $0 != "." && $0 != ".." }) else { + throw HeadlessCommandError("Path contains an invalid component: \(relativePath)", exitCode: 2) + } + return components + } + + private func metadata(from status: stat, displayPath: String) throws -> HeadlessSecureFileMetadata { + switch status.st_mode & S_IFMT { + case S_IFDIR: + return HeadlessSecureFileMetadata(kind: .directory, byteCount: Int64(status.st_size)) + case S_IFREG: + return HeadlessSecureFileMetadata(kind: .regularFile, byteCount: Int64(status.st_size)) + default: + throw HeadlessCommandError("Path is not a regular file or directory: \(displayPath)", exitCode: 2) + } + } + + private func displayPath(root: HeadlessAllowedRoot, relativePath: String) -> String { + relativePath.isEmpty ? root.name : "\(root.name)/\(relativePath)" + } + + private func posixError(operation: String, path: String, errorNumber: Int32) -> HeadlessCommandError { + let detail = String(cString: Darwin.strerror(errorNumber)) + return HeadlessCommandError("Unable to \(operation) '\(path)': \(detail)", exitCode: 2) + } +} diff --git a/Sources/RepoPromptHeadless/Runtime/HeadlessWorkspaceModels.swift b/Sources/RepoPromptHeadless/Runtime/HeadlessWorkspaceModels.swift new file mode 100644 index 000000000..640ed76a4 --- /dev/null +++ b/Sources/RepoPromptHeadless/Runtime/HeadlessWorkspaceModels.swift @@ -0,0 +1,184 @@ +import Foundation + +struct HeadlessWorkspaceDocument: Codable, Equatable, Identifiable { + static let currentSchemaVersion = 1 + + var schemaVersion: Int + var id: UUID + var name: String + var rootIDs: [UUID] + var promptText: String + var selection: [HeadlessSelectionEntry] + var createdAt: Date + var updatedAt: Date + + init(id: UUID = UUID(), name: String, rootIDs: [UUID], now: Date = Date()) { + schemaVersion = Self.currentSchemaVersion + self.id = id + self.name = name + self.rootIDs = rootIDs + promptText = "" + selection = [] + createdAt = now + updatedAt = now + } + + mutating func touch(now: Date = Date()) { + updatedAt = now + } + + enum CodingKeys: String, CodingKey { + case schemaVersion = "schema_version" + case id + case name + case rootIDs = "root_ids" + case promptText = "prompt_text" + case selection + case createdAt = "created_at" + case updatedAt = "updated_at" + } +} + +enum HeadlessSelectionMode: String, Codable, CaseIterable { + case full + case slices + case codemapOnly = "codemap_only" +} + +struct HeadlessLineRange: Codable, Equatable { + var startLine: Int + var endLine: Int + var description: String? + + init(startLine: Int, endLine: Int, description: String? = nil) { + self.startLine = startLine + self.endLine = endLine + self.description = description + } + + enum CodingKeys: String, CodingKey { + case startLine = "start_line" + case endLine = "end_line" + case description + } +} + +struct HeadlessSelectionEntry: Codable, Equatable { + var rootID: UUID + var relativePath: String + var mode: HeadlessSelectionMode + var ranges: [HeadlessLineRange] + + init(rootID: UUID, relativePath: String, mode: HeadlessSelectionMode, ranges: [HeadlessLineRange] = []) { + self.rootID = rootID + self.relativePath = relativePath + self.mode = mode + self.ranges = ranges + } + + enum CodingKeys: String, CodingKey { + case rootID = "root_id" + case relativePath = "relative_path" + case mode + case ranges + } +} + +enum HeadlessSelectionNormalizer { + static func normalized(_ selection: [HeadlessSelectionEntry]) -> [HeadlessSelectionEntry] { + var result: [HeadlessSelectionEntry] = [] + for entry in selection { + var sanitized = entry + switch sanitized.mode { + case .slices: + sanitized.ranges = normalizedRanges(sanitized.ranges) + guard !sanitized.ranges.isEmpty else { continue } + case .full, .codemapOnly: + sanitized.ranges = [] + } + + if let index = result.firstIndex(where: { + $0.rootID == sanitized.rootID && $0.relativePath == sanitized.relativePath + }) { + result[index] = sanitized + } else { + result.append(sanitized) + } + } + return result.sorted { lhs, rhs in + if lhs.rootID == rhs.rootID { + return lhs.relativePath.localizedStandardCompare(rhs.relativePath) == .orderedAscending + } + return lhs.rootID.uuidString < rhs.rootID.uuidString + } + } + + static func subtracting( + _ removals: [HeadlessLineRange], + from ranges: [HeadlessLineRange] + ) -> [HeadlessLineRange] { + var remaining = normalizedRanges(ranges) + for removal in normalizedRanges(removals) { + remaining = remaining.flatMap { range in + guard removal.endLine >= range.startLine, removal.startLine <= range.endLine else { + return [range] + } + + var residuals: [HeadlessLineRange] = [] + if removal.startLine > range.startLine { + residuals.append(HeadlessLineRange( + startLine: range.startLine, + endLine: removal.startLine - 1, + description: range.description + )) + } + if removal.endLine < range.endLine { + residuals.append(HeadlessLineRange( + startLine: removal.endLine + 1, + endLine: range.endLine, + description: range.description + )) + } + return residuals + } + } + return normalizedRanges(remaining) + } + + static func normalizedRanges(_ ranges: [HeadlessLineRange]) -> [HeadlessLineRange] { + ranges + .filter { $0.startLine > 0 && $0.endLine >= $0.startLine } + .sorted { lhs, rhs in + if lhs.startLine != rhs.startLine { + return lhs.startLine < rhs.startLine + } + return lhs.endLine < rhs.endLine + } + } +} + +struct HeadlessWorkspaceSnapshot { + var config: HeadlessConfigurationDocument + var workspace: HeadlessWorkspaceDocument? + var roots: [HeadlessAllowedRoot] +} + +struct HeadlessResolvedPath { + var root: HeadlessAllowedRoot + var url: URL + var resolvedURL: URL + var relativePath: String + var displayPath: String + var isDirectory: Bool + var isRegularFile: Bool +} + +struct HeadlessCatalogEntry { + var root: HeadlessAllowedRoot + var url: URL + var resolvedURL: URL + var relativePath: String + var displayPath: String + var isDirectory: Bool + var byteCount: Int64? +} diff --git a/Sources/RepoPromptHeadless/Runtime/HeadlessWorkspaceStore.swift b/Sources/RepoPromptHeadless/Runtime/HeadlessWorkspaceStore.swift new file mode 100644 index 000000000..ae6f993ba --- /dev/null +++ b/Sources/RepoPromptHeadless/Runtime/HeadlessWorkspaceStore.swift @@ -0,0 +1,129 @@ +import Foundation + +final class HeadlessWorkspaceStore: @unchecked Sendable { + private let paths: HeadlessStatePaths + private let fileManager: FileManager + + init(paths: HeadlessStatePaths, fileManager: FileManager = .default) { + self.paths = paths + self.fileManager = fileManager + } + + func loadWorkspaces() throws -> [HeadlessWorkspaceDocument] { + try paths.ensureBaseDirectories(fileManager: fileManager) + guard fileManager.fileExists(atPath: paths.workspacesDirectory.path) else { + return [] + } + + let files = try fileManager.contentsOfDirectory( + at: paths.workspacesDirectory, + includingPropertiesForKeys: nil, + options: [.skipsHiddenFiles] + ) + var documents: [HeadlessWorkspaceDocument] = [] + for file in files where file.pathExtension == "json" { + guard let workspaceID = UUID(uuidString: file.deletingPathExtension().lastPathComponent) else { + throw HeadlessCommandError( + "Headless workspace filename must be a UUID: \(file.lastPathComponent)", + exitCode: 2 + ) + } + let lockFile = paths.workspaceLockFile(for: workspaceID) + let document = try HeadlessFileLock.withExclusiveLock( + path: lockFile, + stateRoot: paths.rootDirectory + ) { + try loadWorkspaceUnlocked(file: file, expectedID: workspaceID) + } + if let document { + documents.append(document) + } + } + documents.sort { lhs, rhs in + lhs.name.localizedStandardCompare(rhs.name) == .orderedAscending + } + return documents + } + + func loadWorkspace(id: UUID) throws -> HeadlessWorkspaceDocument? { + try HeadlessFileLock.withExclusiveLock( + path: paths.workspaceLockFile(for: id), + stateRoot: paths.rootDirectory + ) { + try loadWorkspaceUnlocked(file: workspaceFile(for: id), expectedID: id) + } + } + + @discardableResult + func save(_ workspace: HeadlessWorkspaceDocument) throws -> HeadlessWorkspaceDocument { + try HeadlessFileLock.withExclusiveLock( + path: paths.workspaceLockFile(for: workspace.id), + stateRoot: paths.rootDirectory + ) { + try saveUnlocked(workspace) + } + } + + func update(id: UUID, _ body: (inout HeadlessWorkspaceDocument) throws -> Void) throws -> HeadlessWorkspaceDocument { + try HeadlessFileLock.withExclusiveLock( + path: paths.workspaceLockFile(for: id), + stateRoot: paths.rootDirectory + ) { + guard var workspace = try loadWorkspaceUnlocked(file: workspaceFile(for: id), expectedID: id) else { + throw HeadlessCommandError("No headless workspace found for id \(id.uuidString).", exitCode: 2) + } + try body(&workspace) + workspace.touch() + return try saveUnlocked(workspace) + } + } + + private func loadWorkspaceUnlocked(file: URL, expectedID: UUID) throws -> HeadlessWorkspaceDocument? { + try paths.ensureBaseDirectories(fileManager: fileManager) + guard let data = try HeadlessStateFileSecurity.readPrivateFileIfPresent(at: file, stateRoot: paths.rootDirectory) else { + return nil + } + var document = try HeadlessJSONFormatting.decoder().decode(HeadlessWorkspaceDocument.self, from: data) + guard document.schemaVersion == HeadlessWorkspaceDocument.currentSchemaVersion else { + throw HeadlessCommandError( + "Unsupported headless workspace schema_version \(document.schemaVersion) in \(file.lastPathComponent); expected \(HeadlessWorkspaceDocument.currentSchemaVersion).", + exitCode: 2 + ) + } + guard document.id == expectedID else { + throw HeadlessCommandError( + "Headless workspace id \(document.id.uuidString) does not match filename \(expectedID.uuidString).", + exitCode: 2 + ) + } + + let normalizedSelection = HeadlessSelectionNormalizer.normalized(document.selection) + if normalizedSelection != document.selection { + document.selection = normalizedSelection + try saveUnlocked(document, file: file) + } + return document + } + + @discardableResult + private func saveUnlocked( + _ workspace: HeadlessWorkspaceDocument, + file: URL? = nil + ) throws -> HeadlessWorkspaceDocument { + try paths.ensureBaseDirectories(fileManager: fileManager) + var normalizedWorkspace = workspace + normalizedWorkspace.selection = HeadlessSelectionNormalizer.normalized(workspace.selection) + let data = try HeadlessJSONFormatting.encoder(prettyPrinted: true).encode(normalizedWorkspace) + try HeadlessStateFileSecurity.writePrivateFile( + data, + to: file ?? workspaceFile(for: workspace.id), + stateRoot: paths.rootDirectory, + fileManager: fileManager + ) + return normalizedWorkspace + } + + private func workspaceFile(for id: UUID) -> URL { + paths.workspacesDirectory.appendingPathComponent("\(id.uuidString).json", isDirectory: false) + } +} diff --git a/Sources/RepoPromptHeadless/Security/HeadlessExternalExportFileSecurity.swift b/Sources/RepoPromptHeadless/Security/HeadlessExternalExportFileSecurity.swift new file mode 100644 index 000000000..910eead6d --- /dev/null +++ b/Sources/RepoPromptHeadless/Security/HeadlessExternalExportFileSecurity.swift @@ -0,0 +1,190 @@ +import Darwin +import Foundation + +/// Descriptor-relative writes for explicitly authorized exports outside private headless state. +/// +/// Unlike `HeadlessStateFileSecurity`, this writer does not impose state ownership or +/// directory-mode policy on existing external paths. It pins the destination parent +/// descriptor, rejects a symlink/non-regular leaf, and performs a same-directory +/// temporary-file rename so parent replacement cannot redirect the write. +enum HeadlessExternalExportFileSecurity { + typealias ParentDirectoryOpenedHook = (Int32) throws -> Void + + private static let createdDirectoryMode: mode_t = S_IRWXU + private static let fileMode: mode_t = S_IRUSR | S_IWUSR + + static func writeFile( + _ data: Data, + to url: URL, + parentDirectoryOpenedHook: ParentDirectoryOpenedHook? = nil + ) throws { + let target = url.standardizedFileURL + guard target.path.hasPrefix("/"), !target.lastPathComponent.isEmpty else { + throw HeadlessCommandError("External export path must be an absolute non-root file path: \(url.path)", exitCode: 2) + } + + let parentDescriptor = try openDirectoryCreatingMissing(at: target.deletingLastPathComponent()) + defer { Darwin.close(parentDescriptor) } + try parentDirectoryOpenedHook?(parentDescriptor) + try validateExistingRegularFileIfPresent( + named: target.lastPathComponent, + relativeTo: parentDescriptor, + path: target.path + ) + + let temporaryName = ".\(target.lastPathComponent).\(UUID().uuidString).tmp" + let descriptor = temporaryName.withCString { pointer in + Darwin.openat( + parentDescriptor, + pointer, + O_CREAT | O_EXCL | O_WRONLY | O_CLOEXEC | O_NOFOLLOW, + fileMode + ) + } + guard descriptor >= 0 else { + throw posixError(operation: "create external export file", path: target.path, errorNumber: errno) + } + + var shouldRemoveTemporaryFile = true + defer { + Darwin.close(descriptor) + if shouldRemoveTemporaryFile { + temporaryName.withCString { pointer in _ = Darwin.unlinkat(parentDescriptor, pointer, 0) } + } + } + + try validateOpenedFile(descriptor, path: target.path) + try writeAll(data, to: descriptor, path: target.path) + guard Darwin.fsync(descriptor) == 0 else { + throw posixError(operation: "sync external export file", path: target.path, errorNumber: errno) + } + let renameResult = temporaryName.withCString { temporaryPointer in + target.lastPathComponent.withCString { targetPointer in + Darwin.renameat(parentDescriptor, temporaryPointer, parentDescriptor, targetPointer) + } + } + guard renameResult == 0 else { + throw posixError(operation: "replace external export file", path: target.path, errorNumber: errno) + } + shouldRemoveTemporaryFile = false + try validateExistingRegularFileIfPresent( + named: target.lastPathComponent, + relativeTo: parentDescriptor, + path: target.path, + requirePresent: true + ) + } + + private static func openDirectoryCreatingMissing(at url: URL) throws -> Int32 { + var ancestor = url.standardizedFileURL + var missingComponents: [String] = [] + while !pathExists(ancestor.path) { + let component = ancestor.lastPathComponent + guard !component.isEmpty else { + throw HeadlessCommandError("Unable to find an existing ancestor for external export path: \(url.path)", exitCode: 2) + } + missingComponents.insert(component, at: 0) + ancestor.deleteLastPathComponent() + } + + var descriptor = Darwin.open(ancestor.path, O_RDONLY | O_DIRECTORY | O_CLOEXEC) + guard descriptor >= 0 else { + throw posixError(operation: "open external export directory", path: ancestor.path, errorNumber: errno) + } + do { + try validateOpenedDirectory(descriptor, path: ancestor.path) + for component in missingComponents { + try validateComponent(component, path: url.path) + let mkdirResult = component.withCString { pointer in + Darwin.mkdirat(descriptor, pointer, createdDirectoryMode) + } + if mkdirResult != 0, errno != EEXIST { + throw posixError(operation: "create external export directory", path: url.path, errorNumber: errno) + } + let nextDescriptor = component.withCString { pointer in + Darwin.openat(descriptor, pointer, O_RDONLY | O_DIRECTORY | O_CLOEXEC | O_NOFOLLOW) + } + guard nextDescriptor >= 0 else { + throw posixError(operation: "open external export directory", path: url.path, errorNumber: errno) + } + try validateOpenedDirectory(nextDescriptor, path: url.path) + Darwin.close(descriptor) + descriptor = nextDescriptor + } + return descriptor + } catch { + Darwin.close(descriptor) + throw error + } + } + + private static func validateExistingRegularFileIfPresent( + named name: String, + relativeTo parentDescriptor: Int32, + path: String, + requirePresent: Bool = false + ) throws { + try validateComponent(name, path: path) + let descriptor = name.withCString { pointer in + Darwin.openat(parentDescriptor, pointer, O_RDONLY | O_NONBLOCK | O_CLOEXEC | O_NOFOLLOW) + } + if descriptor < 0, !requirePresent, errno == ENOENT { + return + } + guard descriptor >= 0 else { + throw posixError(operation: "open external export target", path: path, errorNumber: errno) + } + defer { Darwin.close(descriptor) } + try validateOpenedFile(descriptor, path: path) + } + + private static func validateOpenedDirectory(_ descriptor: Int32, path: String) throws { + var status = stat() + guard Darwin.fstat(descriptor, &status) == 0 else { + throw posixError(operation: "inspect external export directory", path: path, errorNumber: errno) + } + guard status.st_mode & S_IFMT == S_IFDIR else { + throw HeadlessCommandError("External export parent is not a directory: \(path)", exitCode: 2) + } + } + + private static func validateOpenedFile(_ descriptor: Int32, path: String) throws { + var status = stat() + guard Darwin.fstat(descriptor, &status) == 0 else { + throw posixError(operation: "inspect external export file", path: path, errorNumber: errno) + } + guard status.st_mode & S_IFMT == S_IFREG else { + throw HeadlessCommandError("External export target has an unsafe file type: \(path)", exitCode: 2) + } + } + + private static func validateComponent(_ component: String, path: String) throws { + guard !component.isEmpty, component != ".", component != "..", !component.utf8.contains(0) else { + throw HeadlessCommandError("External export path contains an invalid component: \(path)", exitCode: 2) + } + } + + private static func pathExists(_ path: String) -> Bool { + FileManager.default.fileExists(atPath: path) + } + + private static func writeAll(_ data: Data, to descriptor: Int32, path: String) throws { + try data.withUnsafeBytes { rawBuffer in + guard let baseAddress = rawBuffer.baseAddress else { return } + var offset = 0 + while offset < rawBuffer.count { + let count = Darwin.write(descriptor, baseAddress.advanced(by: offset), rawBuffer.count - offset) + if count < 0 { + if errno == EINTR { continue } + throw posixError(operation: "write external export file", path: path, errorNumber: errno) + } + offset += count + } + } + } + + private static func posixError(operation: String, path: String, errorNumber: Int32) -> HeadlessCommandError { + let detail = String(cString: Darwin.strerror(errorNumber)) + return HeadlessCommandError("Unable to \(operation) '\(path)': \(detail)", exitCode: 2) + } +} diff --git a/Sources/RepoPromptHeadless/Security/HeadlessSecureStorage.swift b/Sources/RepoPromptHeadless/Security/HeadlessSecureStorage.swift new file mode 100644 index 000000000..c37e58e06 --- /dev/null +++ b/Sources/RepoPromptHeadless/Security/HeadlessSecureStorage.swift @@ -0,0 +1,10 @@ +import RepoPromptCore +import RepoPromptCoreMacOS + +enum HeadlessSecureStorage { + static let namespace = HeadlessVersion.secureStorageNamespace + + static func makeService() -> SecureKeysService { + SecureKeysService(secureStorage: KeychainService(serviceName: namespace)) + } +} diff --git a/Sources/RepoPromptHeadless/Support/HeadlessOutput.swift b/Sources/RepoPromptHeadless/Support/HeadlessOutput.swift new file mode 100644 index 000000000..dcd830a65 --- /dev/null +++ b/Sources/RepoPromptHeadless/Support/HeadlessOutput.swift @@ -0,0 +1,42 @@ +import Foundation + +enum HeadlessOutput { + static func stdout(_ message: String = "") { + write(message, to: .standardOutput) + } + + static func stderr(_ message: String = "") { + write(message, to: .standardError) + } + + private static func write(_ message: String, to handle: FileHandle) { + let line = message.hasSuffix("\n") ? message : "\(message)\n" + if let data = line.data(using: .utf8) { + handle.write(data) + } + } +} + +enum HeadlessJSONFormatting { + static func encoder(prettyPrinted: Bool = true) -> JSONEncoder { + let encoder = JSONEncoder() + encoder.dateEncodingStrategy = .iso8601 + if prettyPrinted { + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + } else { + encoder.outputFormatting = [.sortedKeys] + } + return encoder + } + + static func decoder() -> JSONDecoder { + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + return decoder + } + + static func string(_ value: some Encodable, prettyPrinted: Bool = true) throws -> String { + let data = try encoder(prettyPrinted: prettyPrinted).encode(value) + return String(decoding: data, as: UTF8.self) + } +} diff --git a/Sources/RepoPromptHeadless/main.swift b/Sources/RepoPromptHeadless/main.swift new file mode 100644 index 000000000..ba2844ae0 --- /dev/null +++ b/Sources/RepoPromptHeadless/main.swift @@ -0,0 +1,11 @@ +import Darwin +import Foundation + +let cli = HeadlessCLI() +let exitCode = await cli.run( + arguments: Array(CommandLine.arguments.dropFirst()), + environment: ProcessInfo.processInfo.environment +) +if exitCode != 0 { + Darwin.exit(Int32(exitCode)) +} diff --git a/Sources/RepoPromptMCP/Interactive/InteractiveMCPClientSession.swift b/Sources/RepoPromptMCP/Interactive/InteractiveMCPClientSession.swift index 1cc441a16..2ea5d1dd1 100644 --- a/Sources/RepoPromptMCP/Interactive/InteractiveMCPClientSession.swift +++ b/Sources/RepoPromptMCP/Interactive/InteractiveMCPClientSession.swift @@ -9,6 +9,7 @@ import Foundation import Logging import MCP +import RepoPromptPOSIXSupport import RepoPromptShared // MARK: - Progress Notification (CLI-side) diff --git a/Sources/RepoPromptMCP/Shared/MCPBootstrapMessages.swift b/Sources/RepoPromptMCP/Shared/MCPBootstrapMessages.swift deleted file mode 100644 index aec754aac..000000000 --- a/Sources/RepoPromptMCP/Shared/MCPBootstrapMessages.swift +++ /dev/null @@ -1,113 +0,0 @@ -// -// MCPBootstrapMessages.swift -// RepoPrompt -// -// Shared bootstrap socket handshake message types. -// Used by both the app (BootstrapSocketServer) and CLI (repoprompt-mcp). -// -// IMPORTANT: This file must be included in both targets: -// - RepoPrompt (app) -// - repoprompt-mcp (CLI) -// - -import Foundation - -// MARK: - Protocol Version - -/// Bootstrap socket protocol versioning. -public enum MCPBootstrapProtocol { - /// Current protocol version. - /// - v1: Initial bootstrap socket implementation - /// - v2: Added client identity caching for reconnects, improved retry logic - public static let currentVersion = 2 -} - -// MARK: - Timing - -/// Shared timing budgets for bootstrap socket startup. -/// Keep proxy startup under common outer host timeouts so retries can engage. -public enum MCPBootstrapTiming { - public static let initialResponseTimeout: TimeInterval = 5 -} - -// MARK: - Handshake Request - -/// Request sent by CLI when connecting to bootstrap socket. -public struct MCPBootstrapRequest: Codable, Sendable { - /// Message type identifier (always "connect") - public let type: String - - /// Unique session token for this CLI instance. - /// Used by the app to identify this CLI across reconnections. - public let sessionToken: String - - /// CLI process ID for debugging and diagnostics. - public let clientPid: Int - - /// Client executable name (e.g., "cursor", "claude", "repoprompt-mcp"). - /// May be nil if detection fails. - public let clientName: String? - - /// Protocol version for compatibility checking. - public let protocolVersion: Int - - public init( - sessionToken: String, - clientPid: Int, - clientName: String?, - protocolVersion: Int = MCPBootstrapProtocol.currentVersion - ) { - type = "connect" - self.sessionToken = sessionToken - self.clientPid = clientPid - self.clientName = clientName - self.protocolVersion = protocolVersion - } -} - -// MARK: - Handshake Response - -/// Response sent by app after receiving connect request. -/// Bootstrap only establishes the transport; user-facing approval happens later during MCP initialize. -public struct MCPBootstrapResponse: Codable, Sendable { - /// Response type: "accepted" or "rejected" - public let type: String - - /// Reason for rejection (if type == "rejected") - public let reason: String? - - /// Error code for programmatic handling - public let errorCode: String? - - public init(type: String, reason: String? = nil, errorCode: String? = nil) { - self.type = type - self.reason = reason - self.errorCode = errorCode - } - - // MARK: Factory Methods - - /// Creates an accepted response. - public static func accepted() -> MCPBootstrapResponse { - MCPBootstrapResponse(type: "accepted", reason: nil, errorCode: nil) - } - - /// Creates a rejected response with reason. - public static func rejected(reason: String, errorCode: String? = nil) -> MCPBootstrapResponse { - MCPBootstrapResponse(type: "rejected", reason: reason, errorCode: errorCode) - } -} - -// MARK: - Error Codes - -/// Known error codes for programmatic error handling. -public enum MCPBootstrapErrorCode: String { - case approvalDenied = "approval_denied" - case protocolVersionMismatch = "protocol_version_mismatch" - case serverNotReady = "server_not_ready" - case serverUnavailable = "server_unavailable" - case connectionLimitReached = "connection_limit_reached" - case capacityExceeded = "capacity_exceeded" - case sessionBlocked = "session_blocked" - case clientCooldown = "client_cooldown" -} diff --git a/Sources/RepoPromptMCP/Shared/MCPFilesystemConstants.swift b/Sources/RepoPromptMCP/Shared/MCPFilesystemConstants.swift index a1426efb5..53a158b20 100644 --- a/Sources/RepoPromptMCP/Shared/MCPFilesystemConstants.swift +++ b/Sources/RepoPromptMCP/Shared/MCPFilesystemConstants.swift @@ -1,3 +1,4 @@ +import Darwin import Foundation import RepoPromptShared @@ -60,6 +61,8 @@ func mcpRoutingDebugLog(_ message: @autoclosure () -> String) { } enum MCPFilesystemConstants { + private static let userID = UInt32(getuid()) + #if DEBUG static let identity = MCPFilesystemIdentity.repoPromptCE(.debug) #else @@ -79,7 +82,7 @@ enum MCPFilesystemConstants { } static func socketDirectoryURL() -> URL { - identity.socketDirectoryURL() + identity.socketDirectoryURL(userID: userID) } @discardableResult @@ -105,7 +108,7 @@ enum MCPFilesystemConstants { } static func bootstrapSocketURL() -> URL { - identity.bootstrapSocketURL() + identity.bootstrapSocketURL(userID: userID) } static func eventsDirectoryURL() -> URL { diff --git a/Sources/RepoPromptMCP/Transports/BootstrapSocketMCPTransport.swift b/Sources/RepoPromptMCP/Transports/BootstrapSocketMCPTransport.swift index 034444fef..8a1944672 100644 --- a/Sources/RepoPromptMCP/Transports/BootstrapSocketMCPTransport.swift +++ b/Sources/RepoPromptMCP/Transports/BootstrapSocketMCPTransport.swift @@ -10,6 +10,7 @@ import Dispatch import Foundation import Logging import MCP +import RepoPromptPOSIXSupport import RepoPromptShared import SystemPackage diff --git a/Sources/RepoPromptMCP/main.swift b/Sources/RepoPromptMCP/main.swift index cfb9da03a..a4654d46c 100644 --- a/Sources/RepoPromptMCP/main.swift +++ b/Sources/RepoPromptMCP/main.swift @@ -2,6 +2,7 @@ import Dispatch import Foundation import Logging import MCP +import RepoPromptPOSIXSupport import RepoPromptShared import ServiceLifecycle import SystemPackage diff --git a/Sources/RepoPromptPOSIXSupport/Descriptors/POSIXDescriptorSupport.swift b/Sources/RepoPromptPOSIXSupport/Descriptors/POSIXDescriptorSupport.swift new file mode 100644 index 000000000..7ab6db673 --- /dev/null +++ b/Sources/RepoPromptPOSIXSupport/Descriptors/POSIXDescriptorSupport.swift @@ -0,0 +1,73 @@ +import Darwin +import Darwin.POSIX.fcntl +import RepoPromptC + +package enum POSIXDescriptorConfigurationError: Error, Equatable { + case invalidFileDescriptor(fd: Int32) + case getDescriptorFlagsFailed(fd: Int32, errno: Int32) + case setDescriptorFlagsFailed(fd: Int32, errno: Int32) + + package var errnoValue: Int32 { + switch self { + case .invalidFileDescriptor: + EBADF + case let .getDescriptorFlagsFailed(_, errno), let .setDescriptorFlagsFailed(_, errno): + errno + } + } +} + +package enum POSIXDescriptorPathError: Error, Equatable { + case invalidFileDescriptor(fd: Int32) + case getPathFailed(fd: Int32, errno: Int32) + + package var errnoValue: Int32 { + switch self { + case .invalidFileDescriptor: + EBADF + case let .getPathFailed(_, errno): + errno + } + } +} + +package enum POSIXDescriptorSupport { + package static func path(for fd: Int32) throws -> String { + guard fd >= 0 else { + throw POSIXDescriptorPathError.invalidFileDescriptor(fd: fd) + } + + var buffer = [CChar](repeating: 0, count: Int(MAXPATHLEN)) + let result = buffer.withUnsafeMutableBufferPointer { pointer -> Int32 in + guard let baseAddress = pointer.baseAddress else { + errno = EINVAL + return -1 + } + return RepoPromptC.repo_prompt_descriptor_get_path(fd, baseAddress) + } + guard result == 0 else { + throw POSIXDescriptorPathError.getPathFailed(fd: fd, errno: errno) + } + return String(cString: buffer) + } + + package static func setCloseOnExec(_ fd: Int32) throws { + guard fd >= 0 else { + throw POSIXDescriptorConfigurationError.invalidFileDescriptor(fd: fd) + } + + let flags = fcntl(fd, F_GETFD) + guard flags != -1 else { + throw POSIXDescriptorConfigurationError.getDescriptorFlagsFailed(fd: fd, errno: errno) + } + + guard fcntl(fd, F_SETFD, flags | FD_CLOEXEC) != -1 else { + throw POSIXDescriptorConfigurationError.setDescriptorFlagsFailed(fd: fd, errno: errno) + } + } + + package static func shutdownSocketReadWrite(_ fd: Int32) { + guard fd >= 0 else { return } + _ = shutdown(fd, SHUT_RDWR) + } +} diff --git a/Sources/RepoPromptPOSIXSupport/Descriptors/POSIXFileContentSnapshotSupport.swift b/Sources/RepoPromptPOSIXSupport/Descriptors/POSIXFileContentSnapshotSupport.swift new file mode 100644 index 000000000..cbba5e2ab --- /dev/null +++ b/Sources/RepoPromptPOSIXSupport/Descriptors/POSIXFileContentSnapshotSupport.swift @@ -0,0 +1,82 @@ +import Darwin +import Foundation + +package struct POSIXFileContentMetadata: Equatable { + package let deviceID: UInt64 + package let fileNumber: UInt64 + package let byteSize: Int64 + package let modificationSeconds: Int64 + package let modificationNanoseconds: Int64 + package let statusChangeSeconds: Int64 + package let statusChangeNanoseconds: Int64 + + package init( + deviceID: UInt64, + fileNumber: UInt64, + byteSize: Int64, + modificationSeconds: Int64, + modificationNanoseconds: Int64, + statusChangeSeconds: Int64, + statusChangeNanoseconds: Int64 + ) { + self.deviceID = deviceID + self.fileNumber = fileNumber + self.byteSize = byteSize + self.modificationSeconds = modificationSeconds + self.modificationNanoseconds = modificationNanoseconds + self.statusChangeSeconds = statusChangeSeconds + self.statusChangeNanoseconds = statusChangeNanoseconds + } +} + +package enum POSIXFileContentSnapshotError: Error, Equatable { + case operationFailed(errno: Int32) + case notRegularFile +} + +package enum POSIXFileContentSnapshotSupport { + package static func metadata(atPath path: String) throws -> POSIXFileContentMetadata { + var info = stat() + let result = path.withCString { pointer in + lstat(pointer, &info) + } + guard result == 0 else { + throw POSIXFileContentSnapshotError.operationFailed(errno: errno) + } + return try metadata(from: info) + } + + package static func metadata(fileDescriptor: Int32) throws -> POSIXFileContentMetadata { + var info = stat() + guard fstat(fileDescriptor, &info) == 0 else { + throw POSIXFileContentSnapshotError.operationFailed(errno: errno) + } + return try metadata(from: info) + } + + package static func openReadOnlyFileDescriptor(atPath path: String) throws -> Int32 { + let descriptor = path.withCString { pointer in + open(pointer, O_RDONLY | O_CLOEXEC | O_NOFOLLOW) + } + guard descriptor >= 0 else { + throw POSIXFileContentSnapshotError.operationFailed(errno: errno) + } + return descriptor + } + + private static func metadata(from info: stat) throws -> POSIXFileContentMetadata { + guard (info.st_mode & mode_t(S_IFMT)) == mode_t(S_IFREG) else { + throw POSIXFileContentSnapshotError.notRegularFile + } + + return POSIXFileContentMetadata( + deviceID: UInt64(info.st_dev), + fileNumber: UInt64(info.st_ino), + byteSize: Int64(info.st_size), + modificationSeconds: Int64(info.st_mtimespec.tv_sec), + modificationNanoseconds: Int64(info.st_mtimespec.tv_nsec), + statusChangeSeconds: Int64(info.st_ctimespec.tv_sec), + statusChangeNanoseconds: Int64(info.st_ctimespec.tv_nsec) + ) + } +} diff --git a/Sources/RepoPrompt/Infrastructure/MCP/AppShared/MCPBootstrapMessages.swift b/Sources/RepoPromptShared/MCP/MCPBootstrapMessages.swift similarity index 96% rename from Sources/RepoPrompt/Infrastructure/MCP/AppShared/MCPBootstrapMessages.swift rename to Sources/RepoPromptShared/MCP/MCPBootstrapMessages.swift index aec754aac..b308a282b 100644 --- a/Sources/RepoPrompt/Infrastructure/MCP/AppShared/MCPBootstrapMessages.swift +++ b/Sources/RepoPromptShared/MCP/MCPBootstrapMessages.swift @@ -1,14 +1,10 @@ // // MCPBootstrapMessages.swift -// RepoPrompt +// RepoPromptShared // // Shared bootstrap socket handshake message types. // Used by both the app (BootstrapSocketServer) and CLI (repoprompt-mcp). // -// IMPORTANT: This file must be included in both targets: -// - RepoPrompt (app) -// - repoprompt-mcp (CLI) -// import Foundation diff --git a/Sources/RepoPromptShared/MCP/MCPFilesystemIdentity.swift b/Sources/RepoPromptShared/MCP/MCPFilesystemIdentity.swift index 1ea17312a..71618162d 100644 --- a/Sources/RepoPromptShared/MCP/MCPFilesystemIdentity.swift +++ b/Sources/RepoPromptShared/MCP/MCPFilesystemIdentity.swift @@ -1,4 +1,3 @@ -import Darwin import Foundation /// Shared filesystem and stable-name authority for RepoPrompt MCP products. @@ -130,11 +129,11 @@ public struct MCPFilesystemIdentity: Equatable, Sendable { } } - public func socketDirectoryURL(userID: uid_t = getuid()) -> URL { + public func socketDirectoryURL(userID: UInt32) -> URL { URL(fileURLWithPath: "/tmp/\(socketDirectoryName)-\(userID)", isDirectory: true) } - public func bootstrapSocketURL(userID: uid_t = getuid()) -> URL { + public func bootstrapSocketURL(userID: UInt32) -> URL { socketDirectoryURL(userID: userID).appendingPathComponent(bootstrapSocketName, isDirectory: false) } diff --git a/Sources/RepoPromptShared/MCP/POSIXDescriptorSupport.swift b/Sources/RepoPromptShared/MCP/POSIXDescriptorSupport.swift deleted file mode 100644 index f0b7c1766..000000000 --- a/Sources/RepoPromptShared/MCP/POSIXDescriptorSupport.swift +++ /dev/null @@ -1,39 +0,0 @@ -import Darwin -import Darwin.POSIX.fcntl - -public enum POSIXDescriptorConfigurationError: Error, Equatable, Sendable { - case invalidFileDescriptor(fd: Int32) - case getDescriptorFlagsFailed(fd: Int32, errno: Int32) - case setDescriptorFlagsFailed(fd: Int32, errno: Int32) - - public var errnoValue: Int32 { - switch self { - case .invalidFileDescriptor: - EBADF - case let .getDescriptorFlagsFailed(_, errno), let .setDescriptorFlagsFailed(_, errno): - errno - } - } -} - -public enum POSIXDescriptorSupport { - public static func setCloseOnExec(_ fd: Int32) throws { - guard fd >= 0 else { - throw POSIXDescriptorConfigurationError.invalidFileDescriptor(fd: fd) - } - - let flags = fcntl(fd, F_GETFD) - guard flags != -1 else { - throw POSIXDescriptorConfigurationError.getDescriptorFlagsFailed(fd: fd, errno: errno) - } - - guard fcntl(fd, F_SETFD, flags | FD_CLOEXEC) != -1 else { - throw POSIXDescriptorConfigurationError.setDescriptorFlagsFailed(fd: fd, errno: errno) - } - } - - public static func shutdownSocketReadWrite(_ fd: Int32) { - guard fd >= 0 else { return } - _ = shutdown(fd, SHUT_RDWR) - } -} diff --git a/Sources/RepoPromptSyntaxCBridge/RepoPromptSyntaxCBridge.c b/Sources/RepoPromptSyntaxCBridge/RepoPromptSyntaxCBridge.c new file mode 100644 index 000000000..ce1511725 --- /dev/null +++ b/Sources/RepoPromptSyntaxCBridge/RepoPromptSyntaxCBridge.c @@ -0,0 +1 @@ +#include "RepoPromptSyntaxCBridge.h" diff --git a/Sources/RepoPromptSyntaxCBridge/include/RepoPromptSyntaxCBridge.h b/Sources/RepoPromptSyntaxCBridge/include/RepoPromptSyntaxCBridge.h new file mode 100644 index 000000000..b8d1138c9 --- /dev/null +++ b/Sources/RepoPromptSyntaxCBridge/include/RepoPromptSyntaxCBridge.h @@ -0,0 +1,21 @@ +#ifndef RepoPromptSyntaxCBridge_h +#define RepoPromptSyntaxCBridge_h + +typedef struct TSLanguage TSLanguage; + +const TSLanguage * tree_sitter_javascript(void); +const TSLanguage * tree_sitter_python(void); +const TSLanguage * tree_sitter_c_sharp(void); +const TSLanguage * tree_sitter_swift(void); +const TSLanguage * tree_sitter_c(void); +const TSLanguage * tree_sitter_cpp(void); +const TSLanguage * tree_sitter_rust(void); +const TSLanguage * tree_sitter_go(void); +const TSLanguage * tree_sitter_java(void); +const TSLanguage * tree_sitter_dart(void); +const TSLanguage * tree_sitter_php(void); +const TSLanguage * tree_sitter_ruby(void); +const TSLanguage * tree_sitter_typescript(void); +const TSLanguage * tree_sitter_tsx(void); + +#endif /* RepoPromptSyntaxCBridge_h */ diff --git a/Tests/RepoPromptCoreMacOSTests/FileSystem/MacOSFSEventsWatcherTests.swift b/Tests/RepoPromptCoreMacOSTests/FileSystem/MacOSFSEventsWatcherTests.swift new file mode 100644 index 000000000..f03a18941 --- /dev/null +++ b/Tests/RepoPromptCoreMacOSTests/FileSystem/MacOSFSEventsWatcherTests.swift @@ -0,0 +1,636 @@ +import CoreFoundation +import CoreServices +import Dispatch +import Foundation +@testable import RepoPromptCore +@testable import RepoPromptCoreMacOS +import XCTest + +final class MacOSFSEventsWatcherTests: XCTestCase { + func testSemanticFlagsMapsNativeMutationAndTypeBits() { + let rawFlags = FSEventStreamEventFlags( + kFSEventStreamEventFlagItemCreated + | kFSEventStreamEventFlagItemModified + | kFSEventStreamEventFlagItemIsFile + ) + + XCTAssertEqual( + MacOSFSEventsWatcher.semanticFlags(for: rawFlags), + [.itemCreated, .contentChanged, .itemIsFile] + ) + } + + func testSemanticFlagsCollapsesNativeReliabilityBits() { + let rawFlags = FSEventStreamEventFlags( + kFSEventStreamEventFlagMustScanSubDirs + | kFSEventStreamEventFlagUserDropped + | kFSEventStreamEventFlagKernelDropped + | kFSEventStreamEventFlagRootChanged + ) + + XCTAssertEqual( + MacOSFSEventsWatcher.semanticFlags(for: rawFlags), + [.mustScanSubdirectories, .droppedEvents, .rootChanged] + ) + } + + func testBuildOwnedPayloadDeepCopiesMutablePathStorage() throws { + let mutablePath = NSMutableString(string: "/tmp/original.swift") + let payload = try XCTUnwrap(ownedPayload( + paths: [mutablePath] as CFArray, + flags: [FSEventStreamEventFlags(kFSEventStreamEventFlagItemModified)], + eventIDs: [7] + )) + + mutablePath.setString("/tmp/mutated.swift") + + XCTAssertEqual(payload.entries, [ + FileSystemWatchEvent(path: "/tmp/original.swift", flags: [.contentChanged], id: 7) + ]) + } + + func testBuildOwnedPayloadRetainsTemporaryPathAfterCallbackStorageLifetime() throws { + let payload = try XCTUnwrap(autoreleasepool { () -> FileSystemWatchEventPayload? in + let temporaryPath = NSMutableString(string: "/tmp/temporary.swift") + return ownedPayload( + paths: [temporaryPath] as CFArray, + flags: [FSEventStreamEventFlags(kFSEventStreamEventFlagItemCreated)], + eventIDs: [8] + ) + }) + + XCTAssertEqual(payload.entries, [ + FileSystemWatchEvent(path: "/tmp/temporary.swift", flags: [.itemCreated], id: 8) + ]) + } + + func testSemanticFlagsCollapsesNativeMetadataBits() { + let rawFlags = FSEventStreamEventFlags( + kFSEventStreamEventFlagItemInodeMetaMod + | kFSEventStreamEventFlagItemFinderInfoMod + | kFSEventStreamEventFlagItemChangeOwner + ) + + XCTAssertEqual(MacOSFSEventsWatcher.semanticFlags(for: rawFlags), [.metadataChanged]) + } + + func testStopDuringStartInProgressCancelsAttemptAndRejectsStaleCallback() throws { + let backend = FakeFSEventStreamBackend() + let startGate = SemaphoreGate() + backend.enqueueStartBehavior(.wait(startGate, result: true)) + let watcher = MacOSFSEventsWatcher(path: "/tmp/watcher-start-cancel", streamBackend: backend) + let payloads = PayloadRecorder() + let startResult = LockedValue(nil) + let startFinished = DispatchSemaphore(value: 0) + + DispatchQueue.global(qos: .utility).async { + let result = watcher.start { payloads.append($0) } + startResult.set(result) + startFinished.signal() + } + + XCTAssertTrue(startGate.waitUntilEntered(), "Timed out waiting for fake startStream to block") + let oldStream = try XCTUnwrap(backend.stream(at: 0)) + + watcher.stop() + + XCTAssertFalse(watcher.isWatching) + backend.emit(from: oldStream, path: "/tmp/canceled.swift", eventID: 100) + XCTAssertEqual(payloads.snapshot(), []) + + startGate.release() + XCTAssertEqual(startFinished.wait(timeout: .now() + 2), .success) + XCTAssertEqual(startResult.value(), false) + XCTAssertFalse(watcher.isWatching) + XCTAssertEqual(backend.disposeCount(for: oldStream), 1) + XCTAssertEqual(backend.disposeRecords(for: oldStream).map(\.wasStarted), [true]) + } + + func testConcurrentStartWaitsForInProgressStartAndKeepsFirstHandler() throws { + let backend = FakeFSEventStreamBackend() + let startGate = SemaphoreGate() + backend.enqueueStartBehavior(.wait(startGate, result: true)) + let watcher = MacOSFSEventsWatcher(path: "/tmp/watcher-concurrent-start", streamBackend: backend) + let firstPayloads = PayloadRecorder() + let secondPayloads = PayloadRecorder() + let firstStartResult = LockedValue(nil) + let secondStartResult = LockedValue(nil) + let firstStartFinished = DispatchSemaphore(value: 0) + let secondStartFinished = DispatchSemaphore(value: 0) + + DispatchQueue.global(qos: .utility).async { + let result = watcher.start { firstPayloads.append($0) } + firstStartResult.set(result) + firstStartFinished.signal() + } + XCTAssertTrue(startGate.waitUntilEntered(), "Timed out waiting for first start to block") + + DispatchQueue.global(qos: .utility).async { + let result = watcher.start { secondPayloads.append($0) } + secondStartResult.set(result) + secondStartFinished.signal() + } + Thread.sleep(forTimeInterval: 0.05) + XCTAssertNil(secondStartResult.value()) + + startGate.release() + XCTAssertEqual(firstStartFinished.wait(timeout: .now() + 2), .success) + XCTAssertEqual(secondStartFinished.wait(timeout: .now() + 2), .success) + XCTAssertEqual(firstStartResult.value(), true) + XCTAssertEqual(secondStartResult.value(), true) + XCTAssertEqual(backend.createdStreamCount, 1) + + let stream = try XCTUnwrap(backend.stream(at: 0)) + backend.emit(from: stream, path: "/tmp/first-handler.swift", eventID: 101) + XCTAssertEqual(firstPayloads.paths(), ["/tmp/first-handler.swift"]) + XCTAssertEqual(secondPayloads.snapshot(), []) + + watcher.stop() + } + + func testOldStreamCallbackCannotReachNewHandlerAfterRestart() throws { + let backend = FakeFSEventStreamBackend() + let watcher = MacOSFSEventsWatcher(path: "/tmp/watcher-restart", streamBackend: backend) + let oldPayloads = PayloadRecorder() + let newPayloads = PayloadRecorder() + + XCTAssertTrue(watcher.start { oldPayloads.append($0) }) + let oldStream = try XCTUnwrap(backend.stream(at: 0)) + + let disposeGate = SemaphoreGate() + backend.enqueueDisposeBehavior(.wait(disposeGate)) + let stopFinished = DispatchSemaphore(value: 0) + DispatchQueue.global(qos: .utility).async { + watcher.stop() + stopFinished.signal() + } + XCTAssertTrue(disposeGate.waitUntilEntered(), "Timed out waiting for old stream disposal to block") + XCTAssertFalse(watcher.isWatching) + + XCTAssertTrue(watcher.start { newPayloads.append($0) }) + let newStream = try XCTUnwrap(backend.stream(at: 1)) + + backend.emit(from: oldStream, path: "/tmp/old-stream.swift", eventID: 102) + XCTAssertEqual(oldPayloads.snapshot(), []) + XCTAssertEqual(newPayloads.snapshot(), []) + + backend.emit(from: newStream, path: "/tmp/new-stream.swift", eventID: 103) + XCTAssertEqual(newPayloads.paths(), ["/tmp/new-stream.swift"]) + + disposeGate.release() + XCTAssertEqual(stopFinished.wait(timeout: .now() + 2), .success) + watcher.stop() + } + + func testRepeatedStopIsIdempotent() throws { + let backend = FakeFSEventStreamBackend() + let watcher = MacOSFSEventsWatcher(path: "/tmp/watcher-stop-idempotent", streamBackend: backend) + + XCTAssertTrue(watcher.start { _ in }) + let stream = try XCTUnwrap(backend.stream(at: 0)) + + watcher.stop() + watcher.stop() + watcher.stop() + + XCTAssertFalse(watcher.isWatching) + XCTAssertEqual(backend.disposeCount(for: stream), 1) + XCTAssertEqual(backend.totalDisposeCount, 1) + } + + func testCreateFailureLeavesWatcherStoppedAndAllowsRetry() { + let backend = FakeFSEventStreamBackend() + backend.enqueueCreateBehavior(.fail) + let watcher = MacOSFSEventsWatcher(path: "/tmp/watcher-create-failure", streamBackend: backend) + + XCTAssertFalse(watcher.start { _ in }) + XCTAssertFalse(watcher.isWatching) + XCTAssertEqual(backend.createdStreamCount, 0) + XCTAssertEqual(backend.totalDisposeCount, 0) + + XCTAssertTrue(watcher.start { _ in }) + XCTAssertTrue(watcher.isWatching) + XCTAssertEqual(backend.createdStreamCount, 1) + + watcher.stop() + } + + func testStopDuringCreateDisposesUnstartedStreamWithoutCallingStart() throws { + let backend = FakeFSEventStreamBackend() + let createGate = SemaphoreGate() + backend.enqueueCreateBehavior(.wait(createGate, result: true)) + let watcher = MacOSFSEventsWatcher(path: "/tmp/watcher-create-cancel", streamBackend: backend) + let startResult = LockedValue(nil) + let startFinished = DispatchSemaphore(value: 0) + + DispatchQueue.global(qos: .utility).async { + let result = watcher.start { _ in } + startResult.set(result) + startFinished.signal() + } + + XCTAssertTrue(createGate.waitUntilEntered(), "Timed out waiting for fake createStream to block") + watcher.stop() + XCTAssertFalse(watcher.isWatching) + + createGate.release() + XCTAssertEqual(startFinished.wait(timeout: .now() + 2), .success) + XCTAssertEqual(startResult.value(), false) + let canceledStream = try XCTUnwrap(backend.stream(at: 0)) + XCTAssertEqual(backend.startCount(for: canceledStream), 0) + XCTAssertEqual(backend.disposeRecords(for: canceledStream).map(\.wasStarted), [false]) + XCTAssertFalse(watcher.isWatching) + } + + func testStartFailureDisposesUnstartedStreamAndAllowsRetry() throws { + let backend = FakeFSEventStreamBackend() + backend.enqueueStartBehavior(.fail) + let watcher = MacOSFSEventsWatcher(path: "/tmp/watcher-start-failure", streamBackend: backend) + + XCTAssertFalse(watcher.start { _ in }) + XCTAssertFalse(watcher.isWatching) + let failedStream = try XCTUnwrap(backend.stream(at: 0)) + XCTAssertEqual(backend.disposeRecords(for: failedStream).map(\.wasStarted), [false]) + + XCTAssertTrue(watcher.start { _ in }) + XCTAssertTrue(watcher.isWatching) + XCTAssertEqual(backend.createdStreamCount, 2) + + watcher.stop() + } + + func testStopFromCallbackQueueMarksStoppedAndDefersNativeDisposal() throws { + let backend = FakeFSEventStreamBackend() + let disposeGate = SemaphoreGate() + backend.enqueueDisposeBehavior(.wait(disposeGate)) + let watcher = MacOSFSEventsWatcher(path: "/tmp/watcher-reentrant-stop", streamBackend: backend) + let payloads = PayloadRecorder() + let isWatchingAfterStop = LockedValue(nil) + + XCTAssertTrue(watcher.start { payload in + payloads.append(payload) + watcher.stop() + isWatchingAfterStop.set(watcher.isWatching) + }) + let stream = try XCTUnwrap(backend.stream(at: 0)) + + backend.emit(from: stream, path: "/tmp/reentrant-stop.swift", eventID: 104, onCallbackQueue: true) + XCTAssertEqual(payloads.paths(), ["/tmp/reentrant-stop.swift"]) + XCTAssertEqual(isWatchingAfterStop.value(), false) + XCTAssertFalse(watcher.isWatching) + XCTAssertTrue(disposeGate.waitUntilEntered(), "Timed out waiting for deferred disposal") + + backend.emit(from: stream, path: "/tmp/stale-reentrant.swift", eventID: 105) + XCTAssertEqual(payloads.paths(), ["/tmp/reentrant-stop.swift"]) + + disposeGate.release() + XCTAssertTrue(backend.waitForTotalDisposeCount(1)) + } + + func testActiveWatcherDoesNotSelfRetainAndStopsOnDeinit() throws { + let backend = FakeFSEventStreamBackend() + weak var weakWatcher: MacOSFSEventsWatcher? + var activeStream: FakeFSEventStreamToken? + + autoreleasepool { + var watcher: MacOSFSEventsWatcher? = MacOSFSEventsWatcher( + path: "/tmp/watcher-no-self-retain", + streamBackend: backend + ) + weakWatcher = watcher + XCTAssertTrue(watcher?.start { _ in } == true) + activeStream = backend.stream(at: 0) + watcher = nil + } + + XCTAssertNil(weakWatcher) + let stream = try XCTUnwrap(activeStream) + XCTAssertEqual(backend.disposeRecords(for: stream).map(\.wasStarted), [true]) + } + + private func ownedPayload( + paths: CFArray, + flags: [FSEventStreamEventFlags], + eventIDs: [FSEventStreamEventId] + ) -> FileSystemWatchEventPayload? { + flags.withUnsafeBufferPointer { flagsBuffer in + eventIDs.withUnsafeBufferPointer { eventIDsBuffer in + guard let flagsBaseAddress = flagsBuffer.baseAddress, + let eventIDsBaseAddress = eventIDsBuffer.baseAddress + else { return nil } + return MacOSFSEventsWatcher.buildOwnedPayload( + numEvents: flags.count, + eventPaths: Unmanaged.passUnretained(paths).toOpaque(), + eventFlags: flagsBaseAddress, + eventIDs: eventIDsBaseAddress + ) + } + } + } +} + +private final class FakeFSEventStreamBackend: MacOSFSEventStreamBackend, @unchecked Sendable { + enum CreateBehavior { + case succeed + case fail + case wait(SemaphoreGate, result: Bool) + } + + enum StartBehavior { + case succeed + case fail + case wait(SemaphoreGate, result: Bool) + } + + enum DisposeBehavior { + case immediate + case wait(SemaphoreGate) + } + + struct DisposeRecord: Equatable { + let streamID: Int + let wasStarted: Bool + } + + private let condition = NSCondition() + private var nextStreamID = 0 + private var createBehaviors: [CreateBehavior] = [] + private var startBehaviors: [StartBehavior] = [] + private var disposeBehaviors: [DisposeBehavior] = [] + private var streams: [FakeFSEventStreamToken] = [] + private var startRecords: [Int] = [] + private var disposeRecords: [DisposeRecord] = [] + + var createdStreamCount: Int { + condition.lock() + defer { condition.unlock() } + return streams.count + } + + var totalDisposeCount: Int { + condition.lock() + defer { condition.unlock() } + return disposeRecords.count + } + + func enqueueCreateBehavior(_ behavior: CreateBehavior) { + condition.lock() + createBehaviors.append(behavior) + condition.unlock() + } + + func enqueueStartBehavior(_ behavior: StartBehavior) { + condition.lock() + startBehaviors.append(behavior) + condition.unlock() + } + + func enqueueDisposeBehavior(_ behavior: DisposeBehavior) { + condition.lock() + disposeBehaviors.append(behavior) + condition.unlock() + } + + func createStream( + path _: String, + callback: FSEventStreamCallback, + contextInfo: UnsafeMutableRawPointer, + callbackQueue: DispatchQueue + ) -> (any MacOSFSEventStreamToken)? { + condition.lock() + let behavior = createBehaviors.isEmpty ? CreateBehavior.succeed : createBehaviors.removeFirst() + condition.unlock() + + switch behavior { + case .succeed: + break + case .fail: + return nil + case let .wait(gate, result): + gate.markEntered() + gate.waitForRelease() + guard result else { return nil } + } + + condition.lock() + let stream = FakeFSEventStreamToken( + id: nextStreamID, + callback: callback, + contextInfo: contextInfo, + callbackQueue: callbackQueue + ) + nextStreamID += 1 + streams.append(stream) + condition.broadcast() + condition.unlock() + return stream + } + + func startStream(_ stream: any MacOSFSEventStreamToken) -> Bool { + let behavior: StartBehavior + condition.lock() + let fakeStream = requireFakeStream(stream) + startRecords.append(fakeStream.id) + behavior = startBehaviors.isEmpty ? .succeed : startBehaviors.removeFirst() + condition.unlock() + + switch behavior { + case .succeed: + return true + case .fail: + return false + case let .wait(gate, result): + gate.markEntered() + gate.waitForRelease() + return result + } + } + + func disposeStream(_ stream: any MacOSFSEventStreamToken, wasStarted: Bool) { + let behavior: DisposeBehavior + condition.lock() + let fakeStream = requireFakeStream(stream) + disposeRecords.append(DisposeRecord(streamID: fakeStream.id, wasStarted: wasStarted)) + behavior = disposeBehaviors.isEmpty ? .immediate : disposeBehaviors.removeFirst() + condition.broadcast() + condition.unlock() + + switch behavior { + case .immediate: + return + case let .wait(gate): + gate.markEntered() + gate.waitForRelease() + } + } + + func stream(at index: Int) -> FakeFSEventStreamToken? { + condition.lock() + defer { condition.unlock() } + guard streams.indices.contains(index) else { return nil } + return streams[index] + } + + func disposeCount(for stream: FakeFSEventStreamToken) -> Int { + disposeRecords(for: stream).count + } + + func startCount(for stream: FakeFSEventStreamToken) -> Int { + condition.lock() + defer { condition.unlock() } + return startRecords.count(where: { $0 == stream.id }) + } + + func disposeRecords(for stream: FakeFSEventStreamToken) -> [DisposeRecord] { + condition.lock() + defer { condition.unlock() } + return disposeRecords.filter { $0.streamID == stream.id } + } + + func waitForTotalDisposeCount(_ expectedCount: Int, timeout: TimeInterval = 2) -> Bool { + let deadline = Date().addingTimeInterval(timeout) + condition.lock() + defer { condition.unlock() } + while disposeRecords.count < expectedCount { + let remaining = deadline.timeIntervalSinceNow + if remaining <= 0 { + return false + } + condition.wait(until: Date().addingTimeInterval(remaining)) + } + return true + } + + func emit( + from stream: FakeFSEventStreamToken, + path: String, + flags: FSEventStreamEventFlags = FSEventStreamEventFlags(kFSEventStreamEventFlagItemModified), + eventID: FSEventStreamEventId, + onCallbackQueue: Bool = false + ) { + let deliver = { + let paths = [path as NSString] as CFArray + var eventFlags = [flags] + var eventIDs = [eventID] + eventFlags.withUnsafeBufferPointer { flagsBuffer in + eventIDs.withUnsafeBufferPointer { idsBuffer in + guard let flagsBaseAddress = flagsBuffer.baseAddress, + let idsBaseAddress = idsBuffer.baseAddress + else { return } + stream.callback( + OpaquePointer(bitPattern: stream.id + 1)!, + stream.contextInfo, + 1, + Unmanaged.passUnretained(paths).toOpaque(), + flagsBaseAddress, + idsBaseAddress + ) + } + } + withExtendedLifetime(paths) {} + } + + if onCallbackQueue { + stream.callbackQueue.sync(execute: deliver) + } else { + deliver() + } + } + + private func requireFakeStream(_ stream: any MacOSFSEventStreamToken) -> FakeFSEventStreamToken { + guard let fakeStream = stream as? FakeFSEventStreamToken else { + XCTFail("Unexpected fake FSEvents stream token type") + return FakeFSEventStreamToken( + id: -1, + callback: { _, _, _, _, _, _ in }, + contextInfo: UnsafeMutableRawPointer(bitPattern: 0x1)!, + callbackQueue: .global(qos: .utility) + ) + } + return fakeStream + } +} + +private final class FakeFSEventStreamToken: MacOSFSEventStreamToken, @unchecked Sendable { + let id: Int + let callback: FSEventStreamCallback + let contextInfo: UnsafeMutableRawPointer + let callbackQueue: DispatchQueue + + init( + id: Int, + callback: FSEventStreamCallback, + contextInfo: UnsafeMutableRawPointer, + callbackQueue: DispatchQueue + ) { + self.id = id + self.callback = callback + self.contextInfo = contextInfo + self.callbackQueue = callbackQueue + } +} + +private final class SemaphoreGate: @unchecked Sendable { + private let entered = DispatchSemaphore(value: 0) + private let releaseSemaphore = DispatchSemaphore(value: 0) + + func markEntered() { + entered.signal() + } + + func waitUntilEntered(timeout: DispatchTime = .now() + 2) -> Bool { + entered.wait(timeout: timeout) == .success + } + + func waitForRelease() { + releaseSemaphore.wait() + } + + func release() { + releaseSemaphore.signal() + } +} + +private final class PayloadRecorder: @unchecked Sendable { + private let lock = NSLock() + private var payloads: [FileSystemWatchEventPayload] = [] + + func append(_ payload: FileSystemWatchEventPayload) { + lock.lock() + payloads.append(payload) + lock.unlock() + } + + func snapshot() -> [FileSystemWatchEventPayload] { + lock.lock() + defer { lock.unlock() } + return payloads + } + + func paths() -> [String] { + snapshot().flatMap { payload in + payload.entries.map(\.path) + } + } +} + +private final class LockedValue: @unchecked Sendable { + private let lock = NSLock() + private var storedValue: Value + + init(_ value: Value) { + storedValue = value + } + + func set(_ value: Value) { + lock.lock() + storedValue = value + lock.unlock() + } + + func value() -> Value { + lock.lock() + defer { lock.unlock() } + return storedValue + } +} diff --git a/Tests/RepoPromptCoreMacOSTests/FileSystem/MacOSFileContentSnapshotReaderTests.swift b/Tests/RepoPromptCoreMacOSTests/FileSystem/MacOSFileContentSnapshotReaderTests.swift new file mode 100644 index 000000000..3cf6db5fa --- /dev/null +++ b/Tests/RepoPromptCoreMacOSTests/FileSystem/MacOSFileContentSnapshotReaderTests.swift @@ -0,0 +1,66 @@ +import Darwin +import Foundation +@testable import RepoPromptCore +@testable import RepoPromptCoreMacOS +import XCTest + +final class MacOSFileContentSnapshotReaderTests: XCTestCase { + func testOpenedDescriptorRetainsStableIdentityAfterPathReplacement() throws { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("MacOSFileContentSnapshotReaderTests-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + + let file = directory.appendingPathComponent("content.txt") + try Data("first".utf8).write(to: file) + + let reader = MacOSFileContentSnapshotReader() + let initialPathFingerprint = try reader.fingerprint(atPath: file.path) + let handle = try reader.openReadOnlyFileHandle(atPath: file.path) + defer { try? handle.close() } + + XCTAssertEqual(try reader.fingerprint(fileDescriptor: handle.fileDescriptor), initialPathFingerprint) + XCTAssertNotEqual(Darwin.fcntl(handle.fileDescriptor, F_GETFD) & FD_CLOEXEC, 0) + + try FileManager.default.removeItem(at: file) + try Data("replacement".utf8).write(to: file) + + let retainedDescriptorFingerprint = try reader.fingerprint(fileDescriptor: handle.fileDescriptor) + XCTAssertEqual(retainedDescriptorFingerprint.deviceID, initialPathFingerprint.deviceID) + XCTAssertEqual(retainedDescriptorFingerprint.fileNumber, initialPathFingerprint.fileNumber) + XCTAssertEqual(retainedDescriptorFingerprint.byteSize, initialPathFingerprint.byteSize) + XCTAssertEqual(retainedDescriptorFingerprint.modificationSeconds, initialPathFingerprint.modificationSeconds) + XCTAssertEqual(retainedDescriptorFingerprint.modificationNanoseconds, initialPathFingerprint.modificationNanoseconds) + XCTAssertNotEqual(try reader.fingerprint(atPath: file.path), initialPathFingerprint) + } + + func testRejectsSymbolicLinkWithoutFollowingIt() throws { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("MacOSFileContentSnapshotReaderTests-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + + let target = directory.appendingPathComponent("target.txt") + let link = directory.appendingPathComponent("link.txt") + try Data("target".utf8).write(to: target) + try FileManager.default.createSymbolicLink(atPath: link.path, withDestinationPath: target.path) + + let reader = MacOSFileContentSnapshotReader() + assertInvalidRelativePath { try reader.fingerprint(atPath: link.path) } + assertInvalidRelativePath { try reader.openReadOnlyFileHandle(atPath: link.path) } + } + + private func assertInvalidRelativePath( + _ operation: () throws -> some Any, + file: StaticString = #filePath, + line: UInt = #line + ) { + XCTAssertThrowsError(try operation(), file: file, line: line) { error in + guard let fileSystemError = error as? FileSystemError, + case .invalidRelativePath = fileSystemError + else { + return XCTFail("Expected invalidRelativePath, got \(error)", file: file, line: line) + } + } + } +} diff --git a/Tests/RepoPromptCoreMacOSTests/FileSystem/MacOSWorkspaceExternalFileReaderTests.swift b/Tests/RepoPromptCoreMacOSTests/FileSystem/MacOSWorkspaceExternalFileReaderTests.swift new file mode 100644 index 000000000..be2ca3252 --- /dev/null +++ b/Tests/RepoPromptCoreMacOSTests/FileSystem/MacOSWorkspaceExternalFileReaderTests.swift @@ -0,0 +1,181 @@ +import Darwin +import Foundation +@testable import RepoPromptCore +@testable import RepoPromptCoreMacOS +import XCTest + +final class MacOSWorkspaceExternalFileReaderTests: XCTestCase { + private var temporaryDirectories: [URL] = [] + + override func tearDownWithError() throws { + for directory in temporaryDirectories { + try? FileManager.default.removeItem(at: directory) + } + temporaryDirectories.removeAll() + } + + func testReadsOrdinaryFileAndResolvesDirectoryWithCloseOnExec() throws { + let fixture = try makeFixture() + let folder = fixture.skillsRoot.appendingPathComponent("example", isDirectory: true) + try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true) + let file = folder.appendingPathComponent("SKILL.md") + try Data("skill body".utf8).write(to: file) + let descriptorFlags = LockedValue(0) + let reader = MacOSWorkspaceExternalFileReader { _, descriptor in + descriptorFlags.value = Darwin.fcntl(descriptor, F_GETFD) + } + + XCTAssertEqual( + try reader.resolveDirectory(atAbsolutePath: folder.path, allowedDirectories: fixture.allowedDirectories), + folder.path + ) + XCTAssertEqual( + try reader.resolveRegularFile(atAbsolutePath: file.path, allowedDirectories: fixture.allowedDirectories), + file.path + ) + XCTAssertEqual( + String(data: try reader.readRegularFile(atAbsolutePath: file.path, allowedDirectories: fixture.allowedDirectories), encoding: .utf8), + "skill body" + ) + XCTAssertNotEqual(descriptorFlags.value & FD_CLOEXEC, 0) + } + + func testAllowsInRootSymlinkAndRejectsEscapeSymlink() throws { + let fixture = try makeFixture() + let real = fixture.skillsRoot.appendingPathComponent("real.md") + try Data("inside".utf8).write(to: real) + let internalLink = fixture.skillsRoot.appendingPathComponent("internal.md") + try FileManager.default.createSymbolicLink(atPath: internalLink.path, withDestinationPath: real.path) + + let outside = try makeTemporaryDirectory().appendingPathComponent("outside.md") + try Data("outside".utf8).write(to: outside) + let escapeLink = fixture.skillsRoot.appendingPathComponent("escape.md") + try FileManager.default.createSymbolicLink(atPath: escapeLink.path, withDestinationPath: outside.path) + + let reader = MacOSWorkspaceExternalFileReader() + XCTAssertEqual( + try reader.resolveRegularFile(atAbsolutePath: internalLink.path, allowedDirectories: fixture.allowedDirectories), + real.path + ) + XCTAssertEqual( + String(data: try reader.readRegularFile(atAbsolutePath: internalLink.path, allowedDirectories: fixture.allowedDirectories), encoding: .utf8), + "inside" + ) + XCTAssertNil(try reader.resolveRegularFile(atAbsolutePath: escapeLink.path, allowedDirectories: fixture.allowedDirectories)) + XCTAssertThrowsError(try reader.readRegularFile(atAbsolutePath: escapeLink.path, allowedDirectories: fixture.allowedDirectories)) + } + + func testSupportsSymlinkedAllowedRoot() throws { + let home = try makeTemporaryDirectory() + let actualRoot = home.appendingPathComponent("actual-skills", isDirectory: true) + try FileManager.default.createDirectory(at: actualRoot, withIntermediateDirectories: true) + let actualFile = actualRoot.appendingPathComponent("SKILL.md") + try Data("linked root".utf8).write(to: actualFile) + + let linkedRoot = home.appendingPathComponent(".agents/skills", isDirectory: true) + try FileManager.default.createDirectory(at: linkedRoot.deletingLastPathComponent(), withIntermediateDirectories: true) + try FileManager.default.createSymbolicLink(atPath: linkedRoot.path, withDestinationPath: actualRoot.path) + let linkedFile = linkedRoot.appendingPathComponent("SKILL.md") + let reader = MacOSWorkspaceExternalFileReader() + let allowedDirectories = AgentSupportDirectoryCatalog.builtInAlwaysReadableDirectories(homeDirectoryURL: home) + + XCTAssertEqual( + try reader.resolveRegularFile(atAbsolutePath: linkedFile.path, allowedDirectories: allowedDirectories), + linkedFile.path + ) + XCTAssertEqual( + String(data: try reader.readRegularFile(atAbsolutePath: linkedFile.path, allowedDirectories: allowedDirectories), encoding: .utf8), + "linked root" + ) + } + + func testRejectsDirectoryMasqueradingAsFile() throws { + let fixture = try makeFixture() + let directory = fixture.skillsRoot.appendingPathComponent("not-a-file", isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + let reader = MacOSWorkspaceExternalFileReader() + + XCTAssertNil(try reader.resolveRegularFile(atAbsolutePath: directory.path, allowedDirectories: fixture.allowedDirectories)) + XCTAssertThrowsError(try reader.readRegularFile(atAbsolutePath: directory.path, allowedDirectories: fixture.allowedDirectories)) + } + + func testLeafReplacementAfterValidationReadsOpenedDescriptor() throws { + let fixture = try makeFixture() + let target = fixture.skillsRoot.appendingPathComponent("target.md") + try Data("original".utf8).write(to: target) + let swapped = LockedValue(false) + let reader = MacOSWorkspaceExternalFileReader { path, _ in + guard path == target.path, !swapped.value else { return } + swapped.value = true + try? FileManager.default.removeItem(at: target) + try? Data("replacement".utf8).write(to: target) + } + + let data = try reader.readRegularFile(atAbsolutePath: target.path, allowedDirectories: fixture.allowedDirectories) + XCTAssertEqual(String(data: data, encoding: .utf8), "original") + XCTAssertEqual(try String(contentsOf: target, encoding: .utf8), "replacement") + } + + func testIntermediateReplacementAfterValidationReadsOpenedDescriptor() throws { + let fixture = try makeFixture() + let directory = fixture.skillsRoot.appendingPathComponent("dir", isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + let target = directory.appendingPathComponent("target.md") + try Data("inside".utf8).write(to: target) + + let outsideDirectory = try makeTemporaryDirectory() + try Data("outside".utf8).write(to: outsideDirectory.appendingPathComponent("target.md")) + let movedDirectory = fixture.skillsRoot.appendingPathComponent("dir-opened", isDirectory: true) + let swapped = LockedValue(false) + let reader = MacOSWorkspaceExternalFileReader { path, _ in + guard path == target.path, !swapped.value else { return } + swapped.value = true + try? FileManager.default.moveItem(at: directory, to: movedDirectory) + try? FileManager.default.createSymbolicLink(atPath: directory.path, withDestinationPath: outsideDirectory.path) + } + + let data = try reader.readRegularFile(atAbsolutePath: target.path, allowedDirectories: fixture.allowedDirectories) + XCTAssertEqual(String(data: data, encoding: .utf8), "inside") + } + + private func makeFixture() throws -> (home: URL, skillsRoot: URL, allowedDirectories: [AlwaysReadableDirectory]) { + let home = try makeTemporaryDirectory() + let skillsRoot = home.appendingPathComponent(".agents/skills", isDirectory: true) + try FileManager.default.createDirectory(at: skillsRoot, withIntermediateDirectories: true) + return ( + home, + skillsRoot, + AgentSupportDirectoryCatalog.builtInAlwaysReadableDirectories(homeDirectoryURL: home) + ) + } + + private func makeTemporaryDirectory() throws -> URL { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("RepoPromptExternalReader-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + temporaryDirectories.append(directory) + return directory + } +} + +private final class LockedValue: @unchecked Sendable { + private let lock = NSLock() + private var storage: Value + + init(_ value: Value) { + storage = value + } + + var value: Value { + get { + lock.lock() + defer { lock.unlock() } + return storage + } + set { + lock.lock() + storage = newValue + lock.unlock() + } + } +} diff --git a/Tests/RepoPromptCoreMacOSTests/RepoPromptCoreMacOSTests.swift b/Tests/RepoPromptCoreMacOSTests/RepoPromptCoreMacOSTests.swift new file mode 100644 index 000000000..dd2f4af17 --- /dev/null +++ b/Tests/RepoPromptCoreMacOSTests/RepoPromptCoreMacOSTests.swift @@ -0,0 +1,5 @@ +import Testing + +@Test func repoPromptCoreMacOSTestTargetLoads() { + #expect(true) +} diff --git a/Tests/RepoPromptTests/CodeMap/CodeMapGoldenTests.swift b/Tests/RepoPromptCoreTests/CodeMap/CodeMapGoldenTests.swift similarity index 99% rename from Tests/RepoPromptTests/CodeMap/CodeMapGoldenTests.swift rename to Tests/RepoPromptCoreTests/CodeMap/CodeMapGoldenTests.swift index 682b8b13c..85e051d61 100644 --- a/Tests/RepoPromptTests/CodeMap/CodeMapGoldenTests.swift +++ b/Tests/RepoPromptCoreTests/CodeMap/CodeMapGoldenTests.swift @@ -1,5 +1,5 @@ import Foundation -@testable import RepoPrompt +@testable import RepoPromptCore import XCTest final class CodeMapGoldenTests: XCTestCase { diff --git a/Tests/RepoPromptTests/CodeMap/Fixtures/c/smoke.c b/Tests/RepoPromptCoreTests/CodeMap/Fixtures/c/smoke.c similarity index 100% rename from Tests/RepoPromptTests/CodeMap/Fixtures/c/smoke.c rename to Tests/RepoPromptCoreTests/CodeMap/Fixtures/c/smoke.c diff --git a/Tests/RepoPromptTests/CodeMap/Fixtures/cpp/edge_methods.cpp b/Tests/RepoPromptCoreTests/CodeMap/Fixtures/cpp/edge_methods.cpp similarity index 100% rename from Tests/RepoPromptTests/CodeMap/Fixtures/cpp/edge_methods.cpp rename to Tests/RepoPromptCoreTests/CodeMap/Fixtures/cpp/edge_methods.cpp diff --git a/Tests/RepoPromptTests/CodeMap/Fixtures/dart/smoke.dart b/Tests/RepoPromptCoreTests/CodeMap/Fixtures/dart/smoke.dart similarity index 100% rename from Tests/RepoPromptTests/CodeMap/Fixtures/dart/smoke.dart rename to Tests/RepoPromptCoreTests/CodeMap/Fixtures/dart/smoke.dart diff --git a/Tests/RepoPromptTests/CodeMap/Fixtures/go/smoke.go b/Tests/RepoPromptCoreTests/CodeMap/Fixtures/go/smoke.go similarity index 100% rename from Tests/RepoPromptTests/CodeMap/Fixtures/go/smoke.go rename to Tests/RepoPromptCoreTests/CodeMap/Fixtures/go/smoke.go diff --git a/Tests/RepoPromptTests/CodeMap/Fixtures/java/smoke.java b/Tests/RepoPromptCoreTests/CodeMap/Fixtures/java/smoke.java similarity index 100% rename from Tests/RepoPromptTests/CodeMap/Fixtures/java/smoke.java rename to Tests/RepoPromptCoreTests/CodeMap/Fixtures/java/smoke.java diff --git a/Tests/RepoPromptTests/CodeMap/Fixtures/js/smoke.js b/Tests/RepoPromptCoreTests/CodeMap/Fixtures/js/smoke.js similarity index 100% rename from Tests/RepoPromptTests/CodeMap/Fixtures/js/smoke.js rename to Tests/RepoPromptCoreTests/CodeMap/Fixtures/js/smoke.js diff --git a/Tests/RepoPromptTests/CodeMap/Fixtures/php/edge_namespaces.php b/Tests/RepoPromptCoreTests/CodeMap/Fixtures/php/edge_namespaces.php similarity index 100% rename from Tests/RepoPromptTests/CodeMap/Fixtures/php/edge_namespaces.php rename to Tests/RepoPromptCoreTests/CodeMap/Fixtures/php/edge_namespaces.php diff --git a/Tests/RepoPromptTests/CodeMap/Fixtures/py/smoke.py b/Tests/RepoPromptCoreTests/CodeMap/Fixtures/py/smoke.py similarity index 100% rename from Tests/RepoPromptTests/CodeMap/Fixtures/py/smoke.py rename to Tests/RepoPromptCoreTests/CodeMap/Fixtures/py/smoke.py diff --git a/Tests/RepoPromptTests/CodeMap/Fixtures/rb/smoke.rb b/Tests/RepoPromptCoreTests/CodeMap/Fixtures/rb/smoke.rb similarity index 100% rename from Tests/RepoPromptTests/CodeMap/Fixtures/rb/smoke.rb rename to Tests/RepoPromptCoreTests/CodeMap/Fixtures/rb/smoke.rb diff --git a/Tests/RepoPromptTests/CodeMap/Fixtures/rs/smoke.rs b/Tests/RepoPromptCoreTests/CodeMap/Fixtures/rs/smoke.rs similarity index 100% rename from Tests/RepoPromptTests/CodeMap/Fixtures/rs/smoke.rs rename to Tests/RepoPromptCoreTests/CodeMap/Fixtures/rs/smoke.rs diff --git a/Tests/RepoPromptTests/CodeMap/Fixtures/swift/smoke.swift b/Tests/RepoPromptCoreTests/CodeMap/Fixtures/swift/smoke.swift similarity index 100% rename from Tests/RepoPromptTests/CodeMap/Fixtures/swift/smoke.swift rename to Tests/RepoPromptCoreTests/CodeMap/Fixtures/swift/smoke.swift diff --git a/Tests/RepoPromptTests/CodeMap/Fixtures/ts/smoke.ts b/Tests/RepoPromptCoreTests/CodeMap/Fixtures/ts/smoke.ts similarity index 100% rename from Tests/RepoPromptTests/CodeMap/Fixtures/ts/smoke.ts rename to Tests/RepoPromptCoreTests/CodeMap/Fixtures/ts/smoke.ts diff --git a/Tests/RepoPromptTests/CodeMap/Fixtures/tsx/component.tsx b/Tests/RepoPromptCoreTests/CodeMap/Fixtures/tsx/component.tsx similarity index 100% rename from Tests/RepoPromptTests/CodeMap/Fixtures/tsx/component.tsx rename to Tests/RepoPromptCoreTests/CodeMap/Fixtures/tsx/component.tsx diff --git a/Tests/RepoPromptTests/CodeMap/Goldens/c_smoke.codemap.txt b/Tests/RepoPromptCoreTests/CodeMap/Goldens/c_smoke.codemap.txt similarity index 100% rename from Tests/RepoPromptTests/CodeMap/Goldens/c_smoke.codemap.txt rename to Tests/RepoPromptCoreTests/CodeMap/Goldens/c_smoke.codemap.txt diff --git a/Tests/RepoPromptTests/CodeMap/Goldens/cpp_edge_methods.codemap.txt b/Tests/RepoPromptCoreTests/CodeMap/Goldens/cpp_edge_methods.codemap.txt similarity index 100% rename from Tests/RepoPromptTests/CodeMap/Goldens/cpp_edge_methods.codemap.txt rename to Tests/RepoPromptCoreTests/CodeMap/Goldens/cpp_edge_methods.codemap.txt diff --git a/Tests/RepoPromptTests/CodeMap/Goldens/dart_smoke.codemap.txt b/Tests/RepoPromptCoreTests/CodeMap/Goldens/dart_smoke.codemap.txt similarity index 100% rename from Tests/RepoPromptTests/CodeMap/Goldens/dart_smoke.codemap.txt rename to Tests/RepoPromptCoreTests/CodeMap/Goldens/dart_smoke.codemap.txt diff --git a/Tests/RepoPromptTests/CodeMap/Goldens/fixture-tree.txt b/Tests/RepoPromptCoreTests/CodeMap/Goldens/fixture-tree.txt similarity index 100% rename from Tests/RepoPromptTests/CodeMap/Goldens/fixture-tree.txt rename to Tests/RepoPromptCoreTests/CodeMap/Goldens/fixture-tree.txt diff --git a/Tests/RepoPromptTests/CodeMap/Goldens/go_smoke.codemap.txt b/Tests/RepoPromptCoreTests/CodeMap/Goldens/go_smoke.codemap.txt similarity index 100% rename from Tests/RepoPromptTests/CodeMap/Goldens/go_smoke.codemap.txt rename to Tests/RepoPromptCoreTests/CodeMap/Goldens/go_smoke.codemap.txt diff --git a/Tests/RepoPromptTests/CodeMap/Goldens/java_smoke.codemap.txt b/Tests/RepoPromptCoreTests/CodeMap/Goldens/java_smoke.codemap.txt similarity index 100% rename from Tests/RepoPromptTests/CodeMap/Goldens/java_smoke.codemap.txt rename to Tests/RepoPromptCoreTests/CodeMap/Goldens/java_smoke.codemap.txt diff --git a/Tests/RepoPromptTests/CodeMap/Goldens/js_smoke.codemap.txt b/Tests/RepoPromptCoreTests/CodeMap/Goldens/js_smoke.codemap.txt similarity index 100% rename from Tests/RepoPromptTests/CodeMap/Goldens/js_smoke.codemap.txt rename to Tests/RepoPromptCoreTests/CodeMap/Goldens/js_smoke.codemap.txt diff --git a/Tests/RepoPromptTests/CodeMap/Goldens/php_edge_namespaces.codemap.txt b/Tests/RepoPromptCoreTests/CodeMap/Goldens/php_edge_namespaces.codemap.txt similarity index 100% rename from Tests/RepoPromptTests/CodeMap/Goldens/php_edge_namespaces.codemap.txt rename to Tests/RepoPromptCoreTests/CodeMap/Goldens/php_edge_namespaces.codemap.txt diff --git a/Tests/RepoPromptTests/CodeMap/Goldens/py_smoke.codemap.txt b/Tests/RepoPromptCoreTests/CodeMap/Goldens/py_smoke.codemap.txt similarity index 100% rename from Tests/RepoPromptTests/CodeMap/Goldens/py_smoke.codemap.txt rename to Tests/RepoPromptCoreTests/CodeMap/Goldens/py_smoke.codemap.txt diff --git a/Tests/RepoPromptTests/CodeMap/Goldens/rb_smoke.codemap.txt b/Tests/RepoPromptCoreTests/CodeMap/Goldens/rb_smoke.codemap.txt similarity index 100% rename from Tests/RepoPromptTests/CodeMap/Goldens/rb_smoke.codemap.txt rename to Tests/RepoPromptCoreTests/CodeMap/Goldens/rb_smoke.codemap.txt diff --git a/Tests/RepoPromptTests/CodeMap/Goldens/rs_smoke.codemap.txt b/Tests/RepoPromptCoreTests/CodeMap/Goldens/rs_smoke.codemap.txt similarity index 100% rename from Tests/RepoPromptTests/CodeMap/Goldens/rs_smoke.codemap.txt rename to Tests/RepoPromptCoreTests/CodeMap/Goldens/rs_smoke.codemap.txt diff --git a/Tests/RepoPromptTests/CodeMap/Goldens/swift_smoke.codemap.txt b/Tests/RepoPromptCoreTests/CodeMap/Goldens/swift_smoke.codemap.txt similarity index 100% rename from Tests/RepoPromptTests/CodeMap/Goldens/swift_smoke.codemap.txt rename to Tests/RepoPromptCoreTests/CodeMap/Goldens/swift_smoke.codemap.txt diff --git a/Tests/RepoPromptTests/CodeMap/Goldens/ts_smoke.codemap.txt b/Tests/RepoPromptCoreTests/CodeMap/Goldens/ts_smoke.codemap.txt similarity index 100% rename from Tests/RepoPromptTests/CodeMap/Goldens/ts_smoke.codemap.txt rename to Tests/RepoPromptCoreTests/CodeMap/Goldens/ts_smoke.codemap.txt diff --git a/Tests/RepoPromptTests/CodeMap/Goldens/tsx_component.codemap.txt b/Tests/RepoPromptCoreTests/CodeMap/Goldens/tsx_component.codemap.txt similarity index 100% rename from Tests/RepoPromptTests/CodeMap/Goldens/tsx_component.codemap.txt rename to Tests/RepoPromptCoreTests/CodeMap/Goldens/tsx_component.codemap.txt diff --git a/Tests/RepoPromptTests/CodeMap/Helpers/CodeMapFixtureRunner.swift b/Tests/RepoPromptCoreTests/CodeMap/Helpers/CodeMapFixtureRunner.swift similarity index 98% rename from Tests/RepoPromptTests/CodeMap/Helpers/CodeMapFixtureRunner.swift rename to Tests/RepoPromptCoreTests/CodeMap/Helpers/CodeMapFixtureRunner.swift index df408d399..31d716e58 100644 --- a/Tests/RepoPromptTests/CodeMap/Helpers/CodeMapFixtureRunner.swift +++ b/Tests/RepoPromptCoreTests/CodeMap/Helpers/CodeMapFixtureRunner.swift @@ -1,5 +1,5 @@ import Foundation -@testable import RepoPrompt +@testable import RepoPromptCore struct CodeMapFixture { let relativePath: String @@ -157,7 +157,7 @@ enum CodeMapFixtureRunner { includeLegend: true, showCodeMapMarkers: true ) - let rendered = CodeMapExtractor.generateFileTree(using: snapshot) + let rendered = FileTreeSnapshotRenderer.generateFileTree(using: snapshot) return rendered.isEmpty ? "" : normalize(rendered, tempRoot: tempRoot) } diff --git a/Tests/RepoPromptTests/Services/FileSystem/FileSystemAcceptedIngressBarrierTests.swift b/Tests/RepoPromptCoreTests/FileSystem/FileSystemAcceptedIngressBarrierTests.swift similarity index 71% rename from Tests/RepoPromptTests/Services/FileSystem/FileSystemAcceptedIngressBarrierTests.swift rename to Tests/RepoPromptCoreTests/FileSystem/FileSystemAcceptedIngressBarrierTests.swift index f0b03c276..30ae5685b 100644 --- a/Tests/RepoPromptTests/Services/FileSystem/FileSystemAcceptedIngressBarrierTests.swift +++ b/Tests/RepoPromptCoreTests/FileSystem/FileSystemAcceptedIngressBarrierTests.swift @@ -1,6 +1,4 @@ -import Combine -import CoreServices -@testable import RepoPrompt +@testable import RepoPromptCore import XCTest final class FileSystemAcceptedIngressBarrierTests: XCTestCase { @@ -15,8 +13,10 @@ final class FileSystemAcceptedIngressBarrierTests: XCTestCase { let root = try temporaryRoots.makeRoot(suiteName: "FileSystemAcceptedIngressBarrier") let service = try await makeService(root: root) let publications = LockedPublications() - let publisher = await service.publisherForChanges() - let cancellable = publisher.sink { publications.append($0) } + let subscription = service.subscribeToChanges { publication in + publications.append(publication) + return true + } let acceptedBefore = await service.acceptWatcherPayloadForTesting([ (absolutePath: "/outside/before.swift", flags: createdFileFlags, eventId: 1) @@ -48,15 +48,17 @@ final class FileSystemAcceptedIngressBarrierTests: XCTestCase { XCTAssertTrue(snapshot[1].deltas.isEmpty) let queuedAfterSecondCut = await service.watcherIngressMailboxSnapshotForTesting() XCTAssertEqual(queuedAfterSecondCut.queuedRawEntryCount, 0) - withExtendedLifetime(cancellable) {} + withExtendedLifetime(subscription) {} } func testNoDeltaAcceptedPayloadAdvancesPublishedWatcherWatermark() async throws { let root = try temporaryRoots.makeRoot(suiteName: "FileSystemAcceptedIngressNoop") let service = try await makeService(root: root) let publications = LockedPublications() - let publisher = await service.publisherForChanges() - let cancellable = publisher.sink { publications.append($0) } + let subscription = service.subscribeToChanges { publication in + publications.append(publication) + return true + } let acceptedPayload = await service.acceptWatcherPayloadForTesting([ (absolutePath: "/outside/no-delta.swift", flags: createdFileFlags, eventId: 10) @@ -71,20 +73,22 @@ final class FileSystemAcceptedIngressBarrierTests: XCTestCase { XCTAssertEqual(publication.watcherAcceptedWatermark, accepted) XCTAssertTrue(publication.deltas.isEmpty) XCTAssertEqual(serviceState.lastPublishedWatcherAcceptedWatermark, accepted) - withExtendedLifetime(cancellable) {} + withExtendedLifetime(subscription) {} } func testMailboxOverflowRootRescanPreservesHighestAcceptedWatermark() async throws { let root = try temporaryRoots.makeRoot(suiteName: "FileSystemAcceptedIngressOverflow") let service = try await makeService(root: root, maxPendingWatcherIngressEntries: 2) let publications = LockedPublications() - let publisher = await service.publisherForChanges() - let cancellable = publisher.sink { publications.append($0) } + let subscription = service.subscribeToChanges { publication in + publications.append(publication) + return true + } var highest = FileSystemWatcherIngressMailbox.Watermark.zero for eventID in 1 ... 3 { let acceptedPayload = await service.acceptWatcherPayloadForTesting([ - (absolutePath: "/outside/overflow-\(eventID).swift", flags: createdFileFlags, eventId: FSEventStreamEventId(eventID)) + (absolutePath: "/outside/overflow-\(eventID).swift", flags: createdFileFlags, eventId: FileSystemWatchEventID(eventID)) ], scheduleDrain: false) highest = try XCTUnwrap(acceptedPayload) } @@ -100,15 +104,17 @@ final class FileSystemAcceptedIngressBarrierTests: XCTestCase { XCTAssertEqual(publication.watcherAcceptedWatermark, highest) let serviceState = await service.publicationStateForTesting() XCTAssertEqual(serviceState.lastPublishedWatcherAcceptedWatermark, highest) - withExtendedLifetime(cancellable) {} + withExtendedLifetime(subscription) {} } func testFlushWaitsForInFlightWatcherBatchBeforePublishingAcceptedCut() async throws { let root = try temporaryRoots.makeRoot(suiteName: "FileSystemAcceptedIngressInFlight") let service = try await makeService(root: root) let publications = LockedPublications() - let publisher = await service.publisherForChanges() - let cancellable = publisher.sink { publications.append($0) } + let subscription = service.subscribeToChanges { publication in + publications.append(publication) + return true + } let processingGate = AsyncGate() let flushCompleted = AsyncSignal() @@ -137,7 +143,7 @@ final class FileSystemAcceptedIngressBarrierTests: XCTestCase { XCTAssertGreaterThan(sequence, 0) XCTAssertEqual(publication.watcherAcceptedWatermark, accepted) await service.setWatcherBatchWillProcessHandlerForTesting(nil) - withExtendedLifetime(cancellable) {} + withExtendedLifetime(subscription) {} } func testMailboxOverflowPreservesIgnoreChangeAlreadyBufferedOnActor() async throws { @@ -152,7 +158,7 @@ final class FileSystemAcceptedIngressBarrierTests: XCTestCase { var highest = FileSystemWatcherIngressMailbox.Watermark.zero for eventID in 31 ... 33 { let acceptedPayload = await service.acceptWatcherPayloadForTesting([ - (absolutePath: "/outside/overflow-ignore-\(eventID).swift", flags: createdFileFlags, eventId: FSEventStreamEventId(eventID)) + (absolutePath: "/outside/overflow-ignore-\(eventID).swift", flags: createdFileFlags, eventId: FileSystemWatchEventID(eventID)) ], scheduleDrain: false) highest = try XCTUnwrap(acceptedPayload) } @@ -171,7 +177,8 @@ final class FileSystemAcceptedIngressBarrierTests: XCTestCase { let firstPayload = callbackPayload(path: "/outside/old.swift", eventID: 40) let secondPayload = callbackPayload(path: "/outside/new.swift", eventID: 41) - _ = mailbox.accept(firstPayload, lifecycleCorrelation: nil) { + let oldAcceptanceGeneration = mailbox.startAccepting() + _ = mailbox.accept(firstPayload, acceptanceGeneration: oldAcceptanceGeneration, diagnosticContext: nil) { let invocation = await oldDrainCount.incrementAndValue() if invocation == 1 { await oldDrainGate.markStartedAndWaitForRelease() @@ -180,10 +187,11 @@ final class FileSystemAcceptedIngressBarrierTests: XCTestCase { } } await oldDrainGate.waitUntilStarted() - mailbox.stopAcceptingAndDiscardPending() - mailbox.startAccepting() + _ = mailbox.stopAccepting() + mailbox.discardPendingAndCancelDrain() + let newAcceptanceGeneration = mailbox.startAccepting() - _ = mailbox.accept(secondPayload, lifecycleCorrelation: nil) { + _ = mailbox.accept(secondPayload, acceptanceGeneration: newAcceptanceGeneration, diagnosticContext: nil) { _ = await newDrainCount.incrementAndValue() await newDrainGate.markStartedAndWaitForRelease() while mailbox.takeNextAcceptedPayload() != nil {} @@ -200,12 +208,80 @@ final class FileSystemAcceptedIngressBarrierTests: XCTestCase { await newDrainGate.release() } + func testMailboxRejectsOldAcceptanceGenerationAfterRestart() { + let mailbox = FileSystemWatcherIngressMailbox(maxQueuedRawEntries: 10) + let oldAcceptanceGeneration = mailbox.startAccepting() + let oldPayload = callbackPayload(path: "/outside/old.swift", eventID: 42) + let newPayload = callbackPayload(path: "/outside/new.swift", eventID: 43) + + XCTAssertNotNil(mailbox.accept( + oldPayload, + acceptanceGeneration: oldAcceptanceGeneration, + diagnosticContext: nil, + scheduleDrain: nil + )) + _ = mailbox.stopAccepting() + mailbox.discardPendingAndCancelDrain() + let newAcceptanceGeneration = mailbox.startAccepting() + + XCTAssertNil(mailbox.accept( + oldPayload, + acceptanceGeneration: oldAcceptanceGeneration, + diagnosticContext: nil, + scheduleDrain: nil + )) + XCTAssertNotNil(mailbox.accept( + newPayload, + acceptanceGeneration: newAcceptanceGeneration, + diagnosticContext: nil, + scheduleDrain: nil + )) + } + + func testStaleDetachedStopCannotResetLaterRestartedAndStoppedLifecycle() async throws { + let root = try temporaryRoots.makeRoot(suiteName: "FileSystemAcceptedIngressLifecycleEpoch") + let service = try await makeService(root: root) + let publications = LockedPublications() + let subscription = service.subscribeToChanges { publication in + publications.append(publication) + return true + } + + await service.startWatchingForChanges() + let staleStop = await service.detachWatcherAndCaptureAcceptedWatermark() + + await service.startWatchingForChanges() + let acceptedPayload = await service.acceptWatcherPayloadForTesting([ + (absolutePath: "/outside/restarted.swift", flags: createdFileFlags, eventId: 44) + ], scheduleDrain: false) + let accepted = try XCTUnwrap(acceptedPayload) + let currentStop = await service.detachWatcherAndCaptureAcceptedWatermark() + + await service.finishDetachedWatcherStop(staleStop) + let stateAfterStaleFinish = await service.watcherIngressMailboxSnapshotForTesting() + XCTAssertEqual(stateAfterStaleFinish.queuedRawEntryCount, 1) + XCTAssertEqual(stateAfterStaleFinish.acceptedHighWatermark, accepted) + + _ = await service.flushPendingEventsNow( + throughAcceptedWatcherWatermark: currentStop.acceptedWatermark + ) + await service.finishDetachedWatcherStop(currentStop) + + let publication = try XCTUnwrap(publications.snapshot().last) + XCTAssertEqual(publication.watcherAcceptedWatermark, accepted) + let finalState = await service.watcherIngressMailboxSnapshotForTesting() + XCTAssertEqual(finalState.queuedRawEntryCount, 0) + withExtendedLifetime(subscription) {} + } + func testSyntheticPublicationDoesNotAdvanceWatcherAcceptedWatermark() async throws { let root = try temporaryRoots.makeRoot(suiteName: "FileSystemAcceptedIngressSynthetic") let service = try await makeService(root: root) let publications = LockedPublications() - let publisher = await service.publisherForChanges() - let cancellable = publisher.sink { publications.append($0) } + let subscription = service.subscribeToChanges { publication in + publications.append(publication) + return true + } let before = service.captureAcceptedWatcherWatermark() let sequence = await service.publishFileSystemDeltas([.fileAdded("Synthetic.swift")], source: .syntheticMutation) @@ -218,20 +294,20 @@ final class FileSystemAcceptedIngressBarrierTests: XCTestCase { XCTAssertEqual(publication.source, .syntheticMutation) XCTAssertNil(publication.watcherAcceptedWatermark) XCTAssertEqual(publication.servicePublicationSequence, sequence) - withExtendedLifetime(cancellable) {} + withExtendedLifetime(subscription) {} } - private var createdFileFlags: FSEventStreamEventFlags { - FSEventStreamEventFlags(kFSEventStreamEventFlagItemCreated | kFSEventStreamEventFlagItemIsFile) + private var createdFileFlags: FileSystemWatchEventFlags { + [.itemCreated, .itemIsFile] } - private var modifiedFileFlags: FSEventStreamEventFlags { - FSEventStreamEventFlags(kFSEventStreamEventFlagItemModified | kFSEventStreamEventFlagItemIsFile) + private var modifiedFileFlags: FileSystemWatchEventFlags { + [.contentChanged, .itemIsFile] } - private func callbackPayload(path: String, eventID: FSEventStreamEventId) -> FSEventCallbackPayload { - FSEventCallbackPayload(entries: [ - FSEventCallbackEntry(path: path, flags: createdFileFlags, id: eventID) + private func callbackPayload(path: String, eventID: FileSystemWatchEventID) -> FileSystemWatchEventPayload { + FileSystemWatchEventPayload(entries: [ + FileSystemWatchEvent(path: path, flags: createdFileFlags, id: eventID) ]) } diff --git a/Tests/RepoPromptTests/Services/FileSystem/FileSystemServiceEventPathMappingTests.swift b/Tests/RepoPromptCoreTests/FileSystem/FileSystemServiceEventPathMappingTests.swift similarity index 98% rename from Tests/RepoPromptTests/Services/FileSystem/FileSystemServiceEventPathMappingTests.swift rename to Tests/RepoPromptCoreTests/FileSystem/FileSystemServiceEventPathMappingTests.swift index 9195702d2..181d59921 100644 --- a/Tests/RepoPromptTests/Services/FileSystem/FileSystemServiceEventPathMappingTests.swift +++ b/Tests/RepoPromptCoreTests/FileSystem/FileSystemServiceEventPathMappingTests.swift @@ -1,4 +1,4 @@ -@testable import RepoPrompt +@testable import RepoPromptCore import XCTest final class FileSystemServiceEventPathMappingTests: XCTestCase { diff --git a/Tests/RepoPromptTests/Services/FileSystem/FileSystemServiceIgnoreRecoveryTests.swift b/Tests/RepoPromptCoreTests/FileSystem/FileSystemServiceIgnoreRecoveryTests.swift similarity index 98% rename from Tests/RepoPromptTests/Services/FileSystem/FileSystemServiceIgnoreRecoveryTests.swift rename to Tests/RepoPromptCoreTests/FileSystem/FileSystemServiceIgnoreRecoveryTests.swift index 3f06a0c91..cbd32c8e1 100644 --- a/Tests/RepoPromptTests/Services/FileSystem/FileSystemServiceIgnoreRecoveryTests.swift +++ b/Tests/RepoPromptCoreTests/FileSystem/FileSystemServiceIgnoreRecoveryTests.swift @@ -1,4 +1,4 @@ -@testable import RepoPrompt +@testable import RepoPromptCore import XCTest final class FileSystemServiceIgnoreRecoveryTests: XCTestCase { diff --git a/Tests/RepoPromptTests/Services/FileSystem/FileSystemServiceRecoveryTests.swift b/Tests/RepoPromptCoreTests/FileSystem/FileSystemServiceRecoveryTests.swift similarity index 97% rename from Tests/RepoPromptTests/Services/FileSystem/FileSystemServiceRecoveryTests.swift rename to Tests/RepoPromptCoreTests/FileSystem/FileSystemServiceRecoveryTests.swift index 80f2190b2..fbc9beb73 100644 --- a/Tests/RepoPromptTests/Services/FileSystem/FileSystemServiceRecoveryTests.swift +++ b/Tests/RepoPromptCoreTests/FileSystem/FileSystemServiceRecoveryTests.swift @@ -1,4 +1,4 @@ -@testable import RepoPrompt +@testable import RepoPromptCore import XCTest final class FileSystemServiceRecoveryTests: XCTestCase { diff --git a/Tests/RepoPromptTests/Services/FileSystem/IgnoreRulesRecoveryTests.swift b/Tests/RepoPromptCoreTests/FileSystem/IgnoreRulesRecoveryTests.swift similarity index 96% rename from Tests/RepoPromptTests/Services/FileSystem/IgnoreRulesRecoveryTests.swift rename to Tests/RepoPromptCoreTests/FileSystem/IgnoreRulesRecoveryTests.swift index 857a50317..5aeebf2c8 100644 --- a/Tests/RepoPromptTests/Services/FileSystem/IgnoreRulesRecoveryTests.swift +++ b/Tests/RepoPromptCoreTests/FileSystem/IgnoreRulesRecoveryTests.swift @@ -1,4 +1,4 @@ -@testable import RepoPrompt +@testable import RepoPromptCore import XCTest final class IgnoreRulesRecoveryTests: XCTestCase { diff --git a/Tests/RepoPromptCoreTests/Phase1CoreBoundaryContractTests.swift b/Tests/RepoPromptCoreTests/Phase1CoreBoundaryContractTests.swift new file mode 100644 index 000000000..1d68cda08 --- /dev/null +++ b/Tests/RepoPromptCoreTests/Phase1CoreBoundaryContractTests.swift @@ -0,0 +1,83 @@ +import Foundation +@testable import RepoPromptCore +import XCTest + +final class Phase1CoreBoundaryContractTests: XCTestCase { + private struct TestDocument: Identifiable, Equatable, Sendable { + let id: UUID + let name: String + } + + private struct TestCodec: WorkspaceDocumentCodec { + func decode(_ data: Data) throws -> WorkspaceDocumentDecodeResult { + WorkspaceDocumentDecodeResult( + document: TestDocument(id: UUID(uuidString: String(decoding: data, as: UTF8.self))!, name: "decoded"), + sourceVersion: WorkspaceDocumentFormatVersion(family: "embedded", version: 1), + warnings: [WorkspaceCodecWarning(code: "legacy_default", message: "Applied a permissive default")], + requiresRewrite: true + ) + } + + func encode(_ document: TestDocument) throws -> WorkspaceDocumentEncodeResult { + WorkspaceDocumentEncodeResult( + data: Data(document.id.uuidString.utf8), + schemaVersion: WorkspaceDocumentFormatVersion(family: "canonical", version: 2) + ) + } + } + + func testWorkspaceCodecCarriesVersionWarningsAndRewriteMetadataWithoutConcreteAppModel() throws { + let document = TestDocument(id: UUID(), name: "fixture") + let encoded = try TestCodec().encode(document) + XCTAssertEqual(encoded.schemaVersion, WorkspaceDocumentFormatVersion(family: "canonical", version: 2)) + + let decoded = try TestCodec().decode(encoded.data) + XCTAssertEqual(decoded.document.id, document.id) + XCTAssertEqual(decoded.sourceVersion, WorkspaceDocumentFormatVersion(family: "embedded", version: 1)) + XCTAssertEqual(decoded.warnings.map(\.code), ["legacy_default"]) + XCTAssertTrue(decoded.requiresRewrite) + } + + func testToolCapabilityPolicyIsImmutableAndRequiresEveryCapability() { + let policy = ToolCapabilityPolicy(grantedCapabilities: [.workspaceRead, .fileRead]) + + XCTAssertTrue(policy.allows(.workspaceRead)) + XCTAssertFalse(policy.allows(.fileWrite)) + XCTAssertTrue(policy.allowsAll([.workspaceRead, .fileRead])) + XCTAssertFalse(policy.allowsAll([.workspaceRead, .fileWrite])) + } + + func testSessionToolVocabularyPreservesPhase0Names() { + XCTAssertEqual(MCPSessionToolName.bindContext, "bind_context") + XCTAssertEqual(MCPSessionToolName.manageWorkspaces, "manage_workspaces") + XCTAssertEqual(MCPSessionToolName.manageSelection, "manage_selection") + XCTAssertEqual(MCPSessionToolName.workspaceContext, "workspace_context") + XCTAssertEqual(MCPSessionToolName.getFileTree, "get_file_tree") + XCTAssertEqual(MCPSessionToolName.getCodeStructure, "get_code_structure") + XCTAssertEqual(MCPSessionToolName.readFile, "read_file") + XCTAssertEqual(MCPSessionToolName.search, "file_search") + XCTAssertEqual(MCPSessionToolName.prompt, "prompt") + } + + func testProcessDescriptorFailureContainsOnlyNeutralFields() { + let error = ProcessLauncherError.descriptorConfigurationFailed( + operation: "setCloseOnExec", + label: "stdout", + fd: 9, + errno: EBADF + ) + + guard case let .descriptorConfigurationFailed(operation, label, fd, errno) = error else { + return XCTFail("Expected descriptor configuration failure") + } + XCTAssertEqual(operation, "setCloseOnExec") + XCTAssertEqual(label, "stdout") + XCTAssertEqual(fd, 9) + XCTAssertEqual(errno, EBADF) + } + + func testMigrationContractsDescribeAssessmentAndResultWithoutExecutingMigration() { + XCTAssertEqual(WorkspaceLegacyMigrationAssessment.ready(documentCount: 2), .ready(documentCount: 2)) + XCTAssertEqual(WorkspaceLegacyMigrationResult.notRequired, .notRequired) + } +} diff --git a/Tests/RepoPromptCoreTests/Prompt/PromptAssemblyBuilderTests.swift b/Tests/RepoPromptCoreTests/Prompt/PromptAssemblyBuilderTests.swift new file mode 100644 index 000000000..b3fe693f4 --- /dev/null +++ b/Tests/RepoPromptCoreTests/Prompt/PromptAssemblyBuilderTests.swift @@ -0,0 +1,89 @@ +import RepoPromptCore +import XCTest + +final class PromptAssemblyBuilderTests: XCTestCase { + func testSectionIdentityAndDefaultOrderRemainStable() { + XCTAssertEqual(PromptSection.allCases.map(\.rawValue), [ + "fileMap", + "fileContents", + "metaPrompts", + "userInstructions", + "gitDiff" + ]) + XCTAssertEqual(PromptAssemblyBuilder.defaultSectionOrder, [ + .fileMap, + .fileContents, + .gitDiff, + .metaPrompts, + .userInstructions + ]) + } + + func testAssemblyPreservesDisabledSectionsDuplicationAndTrailingNewlines() { + let policy = PromptRenderPolicy( + sectionOrder: [.metaPrompts, .fileContents, .userInstructions, .fileMap, .gitDiff], + disabledSections: [.fileContents, .userInstructions], + duplicateUserInstructionsAtTop: true + ) + let snippets: [PromptSection: String] = [ + .fileMap: "MAP", + .fileContents: "FILES\n\n", + .metaPrompts: "META\n", + .userInstructions: "USER\n\n", + .gitDiff: "" + ] + + XCTAssertEqual( + PromptAssemblyBuilder(policy: policy, snippets: snippets).build(), + "USER\n\nMETA\nMAP\n" + ) + XCTAssertEqual( + PromptAssemblyBuilder(policy: policy, snippets: snippets).build(layout: .lineTerminatedFragments), + "USER\n\nMETA\nMAP\n" + ) + XCTAssertEqual( + PromptAssemblyBuilder.build( + order: policy.sectionOrder, + disabled: [.fileContents], + duplicateUserInstructionsAtTop: true, + snippets: snippets + ), + "USER\n\nMETA\nUSER\n\nMAP\n" + ) + } + + func testBlankLineSeparatedLayoutNormalizesFragmentsWithoutChangingDefaultBuild() { + let policy = PromptRenderPolicy( + sectionOrder: [.fileMap, .fileContents, .gitDiff, .metaPrompts, .userInstructions], + disabledSections: [.gitDiff], + duplicateUserInstructionsAtTop: true + ) + let snippets: [PromptSection: String] = [ + .fileMap: "\nTREE\n\n", + .fileContents: "\nFILES\n\n\n", + .gitDiff: "\nDIFF\n\n", + .metaPrompts: "\nMETA\n", + .userInstructions: "\nUSER\n\n\n" + ] + let builder = PromptAssemblyBuilder(policy: policy, snippets: snippets) + + XCTAssertEqual( + builder.build(), + "\nUSER\n\n\n\nTREE\n\n\nFILES\n\n\n\nMETA\n\n\nUSER\n\n\n" + ) + XCTAssertEqual( + builder.build(layout: .blankLineSeparatedFragments), + "\nUSER\n\n\n\nTREE\n\n\n\nFILES\n\n\n\nMETA\n\n\n\nUSER\n" + ) + XCTAssertEqual( + PromptAssemblyBuilder.build( + order: policy.sectionOrder, + disabled: policy.disabledSections, + duplicateUserInstructionsAtTop: policy.duplicateUserInstructionsAtTop, + snippets: snippets, + layout: .blankLineSeparatedFragments + ), + builder.build(layout: .blankLineSeparatedFragments) + ) + } +} diff --git a/Tests/RepoPromptCoreTests/Prompt/PromptContextAccountingServiceTests.swift b/Tests/RepoPromptCoreTests/Prompt/PromptContextAccountingServiceTests.swift new file mode 100644 index 000000000..95ba8e4b1 --- /dev/null +++ b/Tests/RepoPromptCoreTests/Prompt/PromptContextAccountingServiceTests.swift @@ -0,0 +1,562 @@ +import Foundation +@testable import RepoPromptCore +import XCTest + +final class PromptContextAccountingServiceTests: XCTestCase { + private var temporaryRoots = FileSystemTemporaryRoots() + + override func tearDownWithError() throws { + temporaryRoots.removeAll() + try super.tearDownWithError() + } + + func testExactSelectedFilesPreserveStoredSelectionOrderAfterConcurrentReads() async throws { + let root = try temporaryRoots.makeRoot(suiteName: "AccountingOrder") + let fileA = root.appendingPathComponent("A.swift") + let fileB = root.appendingPathComponent("B.swift") + let fileC = root.appendingPathComponent("C.swift") + try FileSystemTestSupport.write("alpha", to: fileA) + try FileSystemTestSupport.write("beta", to: fileB) + try FileSystemTestSupport.write("gamma", to: fileC) + + let store = WorkspaceFileContextStore() + _ = try await store.loadRoot(path: root.path) + let selection = StoredSelection( + selectedPaths: [fileC.path, fileA.path, fileB.path], + codemapAutoEnabled: false + ) + + let resolution = try await PromptContextAccountingService().resolveEntries( + selection: selection, + store: store, + codeMapUsage: .none + ) + + XCTAssertEqual(resolution.entries.map(\.file.standardizedRelativePath), ["C.swift", "A.swift", "B.swift"]) + XCTAssertEqual(resolution.entries.map(\.loadedContent), ["gamma", "alpha", "beta"]) + XCTAssertEqual(resolution.missingPaths, []) + XCTAssertEqual(resolution.invalidPaths, []) + } + + func testCaptureBackedAccountingUsesCoreProjectionPlanAndPreservesProvenance() async throws { + let root = try temporaryRoots.makeRoot(suiteName: "CaptureBackedAccounting") + let selected = root.appendingPathComponent("Selected.swift") + let auto = root.appendingPathComponent("Auto.swift") + try FileSystemTestSupport.write("struct Selected {}", to: selected) + try FileSystemTestSupport.write("struct Auto {}", to: auto) + + let store = WorkspaceFileContextStore() + _ = try await store.loadRoot(path: root.path) + await store.applyObservedCodemapResults([ + WorkspaceObservedCodemapResult( + fullPath: auto.path, + modificationDate: Date(timeIntervalSince1970: 0), + fileAPI: makeFileAPI(path: auto.path, symbol: "autoSymbol") + ) + ]) + let selection = StoredSelection( + selectedPaths: [selected.path], + autoCodemapPaths: [auto.path], + codemapAutoEnabled: true + ) + let capture = try await store.captureWorkspaceFileContext( + selection: selection, + fileTreeRequest: WorkspaceFileTreeSnapshotRequest( + mode: .none, + filePathDisplay: .relative, + onlyIncludeRootsWithSelectedFiles: false, + includeLegend: false, + showCodeMapMarkers: false, + rootScope: .allLoaded + ), + coverage: .projection(codemapCoverage: .referenced) + ) + let request = PromptContextAccountingRequest( + selection: selection, + codeMapUsage: .auto, + filePathDisplay: .relative + ) + + let result = try await PromptContextAccountingService().calculatePromptStats( + request: request, + store: store, + capture: capture + ) + + XCTAssertEqual(result.captureProvenance, capture.provenance) + XCTAssertEqual(result.resolvedEntries.map(\.file.standardizedRelativePath), [ + "Selected.swift", + "Auto.swift" + ]) + XCTAssertEqual(result.resolvedEntries.map(\.mode), [.fullFile, .codemap]) + XCTAssertEqual(result.resolvedEntries.map(\.loadedContent), ["struct Selected {}", nil]) + XCTAssertGreaterThan(result.tokenResult.totalTokenCount, 0) + XCTAssertTrue(capture.fileTree.roots.isEmpty) + } + + func testCaptureBackedAccountingRejectsDifferentSelection() async throws { + let root = try temporaryRoots.makeRoot(suiteName: "CaptureSelectionMismatch") + let file = root.appendingPathComponent("A.swift") + try FileSystemTestSupport.write("struct A {}", to: file) + + let store = WorkspaceFileContextStore() + _ = try await store.loadRoot(path: root.path) + let captureSelection = StoredSelection(selectedPaths: [file.path]) + let capture = try await store.captureWorkspaceFileContext( + selection: captureSelection, + fileTreeRequest: WorkspaceFileTreeSnapshotRequest( + mode: .none, + filePathDisplay: .relative, + onlyIncludeRootsWithSelectedFiles: false, + includeLegend: false, + showCodeMapMarkers: false, + rootScope: .allLoaded + ) + ) + + do { + _ = try await PromptContextAccountingService().calculatePromptStats( + request: PromptContextAccountingRequest(selection: StoredSelection()), + store: store, + capture: capture + ) + XCTFail("Expected capture selection mismatch") + } catch let error as PromptContextAccountingError { + XCTAssertEqual(error, .captureSelectionMismatch) + } + } + + func testDirectSelectedFolderUsesRelativePathOrderAndBoundedReads() async throws { + let root = try temporaryRoots.makeRoot(suiteName: "AccountingFolder") + let paths = [ + "Sources/Z.swift", + "Sources/A.swift", + "Sources/Nested/C.swift", + "Sources/Nested/B.swift", + "Sources/notes.txt", + "Sources/Nested/D.swift" + ] + for path in paths { + try FileSystemTestSupport.write(path, to: root.appendingPathComponent(path)) + } + try FileSystemTestSupport.write("outside", to: root.appendingPathComponent("Outside.swift")) + + let store = WorkspaceFileContextStore() + let rootRecord = try await store.loadRoot(path: root.path) + let gate = AccountingReadGate() + let tracker = AccountingReadConcurrencyTracker() + try await store.setContentReadChunkHandlerForTesting(rootID: rootRecord.id) { _ in + await tracker.enter() + await gate.enterAndWaitForRelease() + await tracker.leave() + } + + let task = Task { + try await PromptContextAccountingService().resolveEntries( + selection: StoredSelection( + selectedPaths: [root.appendingPathComponent("Sources").path], + codemapAutoEnabled: false + ), + store: store, + codeMapUsage: .none + ) + } + let effectiveLimit = min( + PromptContextAccountingService.selectedFileReadConcurrencyLimit, + FileSystemService.contentReadWorkerLimitForTesting + ) + let reachedReadLimit = await gate.waitUntilStarted(atLeast: effectiveLimit) + XCTAssertTrue(reachedReadLimit) + let beforeRelease = await tracker.snapshot() + XCTAssertEqual(beforeRelease.active, effectiveLimit) + XCTAssertEqual(beforeRelease.maximum, effectiveLimit) + + await gate.release() + let resolution = try await task.value + try await store.setContentReadChunkHandlerForTesting(rootID: rootRecord.id, nil) + + XCTAssertEqual(resolution.entries.map(\.file.standardizedRelativePath), [ + "Sources/A.swift", + "Sources/Nested/B.swift", + "Sources/Nested/C.swift", + "Sources/Nested/D.swift", + "Sources/Z.swift", + "Sources/notes.txt" + ]) + XCTAssertEqual( + resolution.entries.map(\.loadedContent), + resolution.entries.map { Optional($0.file.standardizedRelativePath) } + ) + let finalConcurrency = await tracker.snapshot() + XCTAssertEqual(finalConcurrency.maximum, effectiveLimit) + } + + func testOverlappingFileFolderAndDuplicatePathsKeepFirstEncounterOrder() async throws { + let root = try temporaryRoots.makeRoot(suiteName: "AccountingDedup") + let fileA = root.appendingPathComponent("Sources/A.swift") + let fileB = root.appendingPathComponent("Sources/B.swift") + try FileSystemTestSupport.write("alpha", to: fileA) + try FileSystemTestSupport.write("beta", to: fileB) + + let store = WorkspaceFileContextStore() + _ = try await store.loadRoot(path: root.path) + let selection = StoredSelection( + selectedPaths: [fileB.path, root.appendingPathComponent("Sources").path, fileB.path], + codemapAutoEnabled: false + ) + + let resolution = try await PromptContextAccountingService().resolveEntries( + selection: selection, + store: store, + codeMapUsage: .none + ) + + XCTAssertEqual(resolution.entries.map(\.file.standardizedRelativePath), ["Sources/B.swift", "Sources/A.swift"]) + XCTAssertEqual(resolution.entries.map(\.loadedContent), ["beta", "alpha"]) + } + + func testSelectedFileSliceResolvesRelativeAliasWithoutDuplicatingStandaloneSlice() async throws { + let root = try temporaryRoots.makeRoot(suiteName: "AccountingSlices") + let file = root.appendingPathComponent("Sources/A.swift") + try FileSystemTestSupport.write("one\ntwo\nthree", to: file) + let ranges = [LineRange(start: 2, end: 2, description: "middle")] + + let store = WorkspaceFileContextStore() + _ = try await store.loadRoot(path: root.path) + let selection = StoredSelection( + selectedPaths: [file.path], + slices: ["Sources/A.swift": ranges], + codemapAutoEnabled: false + ) + + let resolution = try await PromptContextAccountingService().resolveEntries( + selection: selection, + store: store, + codeMapUsage: .none + ) + + let entry = try XCTUnwrap(resolution.entries.first) + XCTAssertEqual(resolution.entries.count, 1) + XCTAssertEqual(entry.mode, .sliced) + XCTAssertEqual(entry.lineRanges, ranges) + XCTAssertEqual(entry.loadedContent, "one\ntwo\nthree") + } + + func testAmbiguousExactPathIsInvalidWhileOrdinaryUnresolvedPathIsMissing() async throws { + let parentA = try temporaryRoots.makeRoot(suiteName: "AccountingAmbiguousA") + let parentB = try temporaryRoots.makeRoot(suiteName: "AccountingAmbiguousB") + let rootA = parentA.appendingPathComponent("SharedRoot", isDirectory: true) + let rootB = parentB.appendingPathComponent("SharedRoot", isDirectory: true) + try FileSystemTestSupport.write("a", to: rootA.appendingPathComponent("Sources/A.swift")) + try FileSystemTestSupport.write("b", to: rootB.appendingPathComponent("Sources/A.swift")) + + let store = WorkspaceFileContextStore() + _ = try await store.loadRoot(path: rootA.path) + _ = try await store.loadRoot(path: rootB.path) + let missing = "DefinitelyMissing.swift" + let selection = StoredSelection( + selectedPaths: ["Sources/A.swift", missing], + codemapAutoEnabled: false + ) + + let resolution = try await PromptContextAccountingService().resolveEntries( + selection: selection, + store: store, + rootScope: .allLoaded, + codeMapUsage: .none + ) + + XCTAssertEqual(resolution.entries, []) + XCTAssertEqual(resolution.invalidPaths, ["Sources/A.swift"]) + XCTAssertEqual(resolution.missingPaths, [missing]) + } + + func testSelectedCodemapSkipsFullContentReadWhenAPIExists() async throws { + let root = try temporaryRoots.makeRoot(suiteName: "AccountingSelectedCodemap") + let file = root.appendingPathComponent("A.swift") + try FileSystemTestSupport.write("struct A { func fullContent() {} }", to: file) + + let store = WorkspaceFileContextStore() + let rootRecord = try await store.loadRoot(path: root.path) + await store.applyObservedCodemapResults([ + WorkspaceObservedCodemapResult( + fullPath: file.path, + modificationDate: Date(), + fileAPI: makeFileAPI(path: file.path, symbol: "codemapOnlySymbol") + ) + ]) + let reads = AccountingReadCounter() + try await store.setContentReadChunkHandlerForTesting(rootID: rootRecord.id) { _ in + await reads.increment() + } + + let resolution = try await PromptContextAccountingService().resolveEntries( + selection: StoredSelection(selectedPaths: [file.path], codemapAutoEnabled: false), + store: store, + codeMapUsage: .selected + ) + try await store.setContentReadChunkHandlerForTesting(rootID: rootRecord.id, nil) + + let entry = try XCTUnwrap(resolution.entries.first) + XCTAssertEqual(resolution.entries.count, 1) + XCTAssertTrue(entry.isCodemap) + XCTAssertEqual(entry.mode, .codemap) + XCTAssertNil(entry.lineRanges) + XCTAssertNil(entry.loadedContent) + let readCount = await reads.value() + XCTAssertEqual(readCount, 0) + } + + func testSelectedCodemapFallsBackToFullContentWhenAPIIsUnavailable() async throws { + let root = try temporaryRoots.makeRoot(suiteName: "AccountingSelectedCodemapFallback") + let file = root.appendingPathComponent("A.swift") + try FileSystemTestSupport.write("struct A {}", to: file) + + let store = WorkspaceFileContextStore() + let rootRecord = try await store.loadRoot(path: root.path) + let reads = AccountingReadCounter() + try await store.setContentReadChunkHandlerForTesting(rootID: rootRecord.id) { _ in + await reads.increment() + } + + let resolution = try await PromptContextAccountingService().resolveEntries( + selection: StoredSelection(selectedPaths: [file.path], codemapAutoEnabled: false), + store: store, + codeMapUsage: .selected + ) + try await store.setContentReadChunkHandlerForTesting(rootID: rootRecord.id, nil) + + let entry = try XCTUnwrap(resolution.entries.first) + XCTAssertFalse(entry.isCodemap) + XCTAssertEqual(entry.mode, .fullFile) + XCTAssertEqual(entry.loadedContent, "struct A {}") + let readCount = await reads.value() + XCTAssertGreaterThan(readCount, 0) + } + + func testAutoAndCompleteCodemapModesIncludeOnlyEligibleUnselectedFiles() async throws { + let root = try temporaryRoots.makeRoot(suiteName: "AccountingCodemapModes") + let selected = root.appendingPathComponent("Selected.swift") + let auto = root.appendingPathComponent("Auto.swift") + let complete = root.appendingPathComponent("Complete.swift") + try FileSystemTestSupport.write("selected", to: selected) + try FileSystemTestSupport.write("auto", to: auto) + try FileSystemTestSupport.write("complete", to: complete) + + let store = WorkspaceFileContextStore() + _ = try await store.loadRoot(path: root.path) + await store.applyObservedCodemapResults([ + WorkspaceObservedCodemapResult(fullPath: selected.path, modificationDate: Date(), fileAPI: makeFileAPI(path: selected.path, symbol: "selectedAPI")), + WorkspaceObservedCodemapResult(fullPath: auto.path, modificationDate: Date(), fileAPI: makeFileAPI(path: auto.path, symbol: "autoAPI")), + WorkspaceObservedCodemapResult(fullPath: complete.path, modificationDate: Date(), fileAPI: makeFileAPI(path: complete.path, symbol: "completeAPI")) + ]) + let selection = StoredSelection( + selectedPaths: [selected.path], + autoCodemapPaths: [auto.path], + codemapAutoEnabled: true + ) + let service = PromptContextAccountingService() + + let autoResolution = try await service.resolveEntries( + selection: selection, + store: store, + codeMapUsage: .auto + ) + XCTAssertEqual(autoResolution.entries.map(\.file.standardizedRelativePath), ["Selected.swift", "Auto.swift"]) + XCTAssertEqual(autoResolution.entries.map(\.isCodemap), [false, true]) + XCTAssertNil(autoResolution.entries[1].loadedContent) + + let completeResolution = try await service.resolveEntries( + selection: selection, + store: store, + codeMapUsage: .complete + ) + XCTAssertEqual(completeResolution.entries.first?.file.standardizedRelativePath, "Selected.swift") + XCTAssertEqual( + Set(completeResolution.entries.dropFirst().map(\.file.standardizedRelativePath)), + Set(["Auto.swift", "Complete.swift"]) + ) + XCTAssertTrue(completeResolution.entries.dropFirst().allSatisfy(\.isCodemap)) + XCTAssertTrue(completeResolution.entries.dropFirst().allSatisfy { $0.loadedContent == nil }) + } + + func testOrdinaryReadFailureKeepsEntryWithNilContent() async throws { + let root = try temporaryRoots.makeRoot(suiteName: "AccountingReadFailure") + let file = root.appendingPathComponent("A.swift") + try FileSystemTestSupport.write("alpha", to: file) + + let store = WorkspaceFileContextStore() + _ = try await store.loadRoot(path: root.path) + try FileManager.default.removeItem(at: file) + + let resolution = try await PromptContextAccountingService().resolveEntries( + selection: StoredSelection(selectedPaths: [file.path], codemapAutoEnabled: false), + store: store, + codeMapUsage: .none + ) + + XCTAssertEqual(resolution.entries.count, 1) + XCTAssertNil(resolution.entries[0].loadedContent) + XCTAssertEqual(resolution.missingPaths, []) + XCTAssertEqual(resolution.invalidPaths, []) + } + + func testCancellationThrowsWithoutPartialResultAndReleasesReadCapacity() async throws { + let root = try temporaryRoots.makeRoot(suiteName: "AccountingCancellation") + let file = root.appendingPathComponent("A.swift") + try FileSystemTestSupport.write(String(repeating: "a", count: 1_000_000), to: file) + + let store = WorkspaceFileContextStore() + let rootRecord = try await store.loadRoot(path: root.path) + let gate = AccountingReadGate() + try await store.setContentReadChunkHandlerForTesting(rootID: rootRecord.id) { _ in + await gate.enterAndWaitForRelease() + } + let selection = StoredSelection(selectedPaths: [file.path], codemapAutoEnabled: false) + let service = PromptContextAccountingService() + let task = Task { + try await service.resolveEntries( + selection: selection, + store: store, + codeMapUsage: .none + ) + } + + let readStarted = await gate.waitUntilStarted(atLeast: 1) + XCTAssertTrue(readStarted) + task.cancel() + await gate.release() + do { + _ = try await task.value + XCTFail("Expected cancellation") + } catch is CancellationError { + // Expected. + } + + try await store.setContentReadChunkHandlerForTesting(rootID: rootRecord.id, nil) + let later = try await service.resolveEntries( + selection: selection, + store: store, + codeMapUsage: .none + ) + XCTAssertEqual(later.entries.first?.loadedContent?.count, 1_000_000) + let limiter = await FileSystemService.contentReadWorkerLimiterSnapshotForTesting() + XCTAssertEqual(limiter.queueDepth, 0) + XCTAssertEqual(limiter.waiterCount, 0) + XCTAssertEqual(limiter.pendingWaiterCount, 0) + } + + func testConcurrentCalculationsOnOneServiceDoNotCancelEachOther() async throws { + let root = try temporaryRoots.makeRoot(suiteName: "AccountingIndependentCalculations") + let fileA = root.appendingPathComponent("A.swift") + let fileB = root.appendingPathComponent("B.swift") + try FileSystemTestSupport.write(String(repeating: "a", count: 200_000), to: fileA) + try FileSystemTestSupport.write(String(repeating: "b", count: 200_000), to: fileB) + + let store = WorkspaceFileContextStore() + _ = try await store.loadRoot(path: root.path) + let service = PromptContextAccountingService() + async let resultA = service.calculatePromptStats( + request: PromptContextAccountingRequest( + selection: StoredSelection(selectedPaths: [fileA.path]), + codeMapUsage: .none + ), + store: store + ) + async let resultB = service.calculatePromptStats( + request: PromptContextAccountingRequest( + selection: StoredSelection(selectedPaths: [fileB.path]), + codeMapUsage: .none + ), + store: store + ) + let (accountingA, accountingB) = try await (resultA, resultB) + + XCTAssertGreaterThan(accountingA.tokenResult.totalTokenCountFilesOnly, 0) + XCTAssertGreaterThan(accountingB.tokenResult.totalTokenCountFilesOnly, 0) + XCTAssertEqual(accountingA.resolvedEntries.map(\.file.standardizedRelativePath), ["A.swift"]) + XCTAssertEqual(accountingB.resolvedEntries.map(\.file.standardizedRelativePath), ["B.swift"]) + } + + private func makeFileAPI(path: String, symbol: String) -> FileAPI { + FileAPI( + filePath: path, + imports: [], + classes: [], + functions: [ + FunctionInfo( + name: symbol, + parameters: [], + returnType: nil, + definitionLine: "func \(symbol)()", + lineNumber: 1 + ) + ], + enums: [], + globalVars: [], + macros: [], + referencedTypes: [] + ) + } +} + +private actor AccountingReadGate { + private var startedCount = 0 + private var released = false + private var continuations: [CheckedContinuation] = [] + + func enterAndWaitForRelease() async { + startedCount += 1 + guard !released else { return } + await withCheckedContinuation { continuation in + continuations.append(continuation) + } + } + + func waitUntilStarted(atLeast target: Int) async -> Bool { + for _ in 0 ..< 500 { + if startedCount >= target { return true } + try? await Task.sleep(nanoseconds: 10_000_000) + } + return startedCount >= target + } + + func release() { + released = true + let pending = continuations + continuations.removeAll() + for continuation in pending { + continuation.resume() + } + } +} + +private actor AccountingReadConcurrencyTracker { + private var active = 0 + private var maximum = 0 + + func enter() { + active += 1 + maximum = max(maximum, active) + } + + func leave() { + active -= 1 + } + + func snapshot() -> (active: Int, maximum: Int) { + (active, maximum) + } +} + +private actor AccountingReadCounter { + private var count = 0 + + func increment() { + count += 1 + } + + func value() -> Int { + count + } +} diff --git a/Tests/RepoPromptCoreTests/Prompt/PromptRenderingServiceTests.swift b/Tests/RepoPromptCoreTests/Prompt/PromptRenderingServiceTests.swift new file mode 100644 index 000000000..e41b5f46b --- /dev/null +++ b/Tests/RepoPromptCoreTests/Prompt/PromptRenderingServiceTests.swift @@ -0,0 +1,204 @@ +@testable import RepoPromptCore +import XCTest + +final class PromptRenderingServiceTests: XCTestCase { + func testFullFileRenderingPreservesFencesEmptyContentOmissionIndicesAndTrailingWhitespace() { + let blocks = PromptRenderingService.renderFileBlocks([ + PromptRenderingFileValue( + displayPath: "Sources/A.swift", + fileName: "A.swift", + content: "struct A {}\n" + ), + PromptRenderingFileValue( + displayPath: "Sources/Missing.swift", + fileName: "Missing.swift", + content: nil + ), + PromptRenderingFileValue( + displayPath: "README", + fileName: "README", + content: "" + ) + ]) + + XCTAssertEqual(blocks.map(\.inputIndex), [0, 2]) + XCTAssertEqual(blocks.map(\.kind), [.content, .content]) + XCTAssertEqual(blocks.map(\.text), [ + "File: Sources/A.swift\n```swift\nstruct A {}\n\n```", + "File: README\n```\n\n```" + ]) + XCTAssertEqual(PromptRenderingService.codeFenceStart(for: "A.swift"), "```swift") + XCTAssertEqual(PromptRenderingService.codeFenceStart(for: "README"), "```") + } + + func testSliceRenderingUsesNormalizedRangeOrderLabelsDescriptionsAndSeparators() { + let blocks = PromptRenderingService.renderFileBlocks([ + PromptRenderingFileValue( + displayPath: "Sources/Sliced.swift", + fileName: "Sliced.swift", + content: "one\ntwo\nthree\nfour\n", + ranges: [ + LineRange(start: 3, end: 3, description: "third"), + LineRange(start: 1, end: 1), + LineRange(start: 9, end: 12, description: "ignored") + ] + ), + PromptRenderingFileValue( + displayPath: "Sources/Merged.swift", + fileName: "Merged.swift", + content: "one\ntwo\nthree\n", + ranges: [ + LineRange(start: 2, end: 2, description: "second"), + LineRange(start: 1, end: 1, description: "first") + ] + ) + ]) + + XCTAssertEqual( + blocks[0].text, + "File: Sources/Sliced.swift\n(lines 1)\n```swift\none\n\n```\n\n(lines 3: third)\n```swift\nthree\n\n```" + ) + XCTAssertEqual( + blocks[1].text, + "File: Sources/Merged.swift\n(lines 1-2: first; second)\n```swift\none\ntwo\n\n```" + ) + } + + func testCodemapPartitionAndMissingCodemapFallbackPreserveOrderingWithoutDuplication() { + let values = [ + PromptRenderingFileValue( + displayPath: "A.swift", + fileName: "A.swift", + content: "A\n" + ), + PromptRenderingFileValue( + displayPath: "B.swift", + fileName: "B.swift", + content: "B full content must not render", + codemapText: "B CODEMAP" + ), + PromptRenderingFileValue( + displayPath: "Omitted.swift", + fileName: "Omitted.swift", + content: nil + ), + PromptRenderingFileValue( + displayPath: "Fallback.swift", + fileName: "Fallback.swift", + content: "FALLBACK\n", + codemapText: nil + ), + PromptRenderingFileValue( + displayPath: "EmptyCodemap.swift", + fileName: "EmptyCodemap.swift", + content: "must not render", + codemapText: "" + ) + ] + + let detailed = PromptRenderingService.renderFileBlocks(values) + XCTAssertEqual(detailed.map(\.inputIndex), [0, 1, 3, 4]) + XCTAssertEqual(detailed.map(\.kind), [.content, .codemap, .content, .codemap]) + + let partitioned = PromptRenderingService.renderPartitionedFileBlocks(values) + XCTAssertEqual(partitioned.codemapBlocks, ["B CODEMAP"]) + XCTAssertEqual(partitioned.contentBlocks, [ + "File: A.swift\n```swift\nA\n\n```", + "File: Fallback.swift\n```swift\nFALLBACK\n\n```" + ]) + XCTAssertFalse(partitioned.contentBlocks.joined().contains("B full content")) + XCTAssertEqual(partitioned.contentBlocks.joined().components(separatedBy: "FALLBACK").count - 1, 1) + } + + func testSelectedDiffRenderingPreservesSliceJoiningOrderOmissionAndTwoNewlinePartitioning() { + let values = [ + PromptRenderingDiffValue( + content: "a\nb\nc\n", + ranges: [LineRange(start: 1, end: 1), LineRange(start: 3, end: 3)] + ), + PromptRenderingDiffValue(content: nil), + PromptRenderingDiffValue(content: ""), + PromptRenderingDiffValue(content: "PATCH") + ] + + XCTAssertEqual(PromptRenderingService.renderDiffParts(values), ["a\n\nc\n", "PATCH"]) + XCTAssertEqual(PromptRenderingService.renderSelectedDiffText(values), "a\n\nc\n\n\nPATCH") + XCTAssertNil(PromptRenderingService.renderSelectedDiffText([ + PromptRenderingDiffValue(content: nil), + PromptRenderingDiffValue(content: "") + ])) + } + + func testFactualSnippetRenderingPreservesWrappersOrderingOmissionAndTrailingNewlines() { + let snippets = PromptRenderingService.renderFactualSnippets( + fileTreeContent: "TREE", + codemapBlocks: ["MAP-ONE", "MAP-TWO"], + contentBlocks: ["FILE-ONE", "FILE-TWO"], + gitDiff: "DIFF" + ) + + XCTAssertEqual(PromptFactualEnvelopePolicy.canonical.fileMapTag, "file_map") + XCTAssertEqual(PromptFactualEnvelopePolicy.canonical.fileContentsTag, "file_contents") + XCTAssertEqual(PromptFactualEnvelopePolicy.canonical.gitDiffTag, "git_diff") + XCTAssertEqual(snippets.fileMap, "\nTREE\n\nMAP-ONE\n\nMAP-TWO\n\n") + XCTAssertEqual(snippets.fileContents, "\nFILE-ONE\n\nFILE-TWO\n\n") + XCTAssertEqual(snippets.gitDiff, "\nDIFF\n\n") + + XCTAssertEqual( + PromptRenderingService.renderFactualSnippets( + fileTreeContent: "", + codemapBlocks: [], + contentBlocks: [], + gitDiff: "" + ), + PromptRenderedFactualSnippets(fileMap: nil, fileContents: nil, gitDiff: nil) + ) + XCTAssertEqual( + PromptRenderingService.renderFactualSnippets( + fileTreeContent: nil, + codemapBlocks: [], + contentBlocks: [""], + gitDiff: nil + ).fileContents, + "\n\n\n" + ) + } + + func testFactualSnippetRenderingSupportsChatStyleTreeEnvelopePolicy() { + let snippets = PromptRenderingService.renderFactualSnippets( + fileTreeContent: "TREE", + codemapBlocks: [], + contentBlocks: ["FILE"], + gitDiff: "DIFF", + envelopePolicy: .chatStyleTree + ) + + XCTAssertEqual(PromptFactualEnvelopePolicy.chatStyleTree.fileMapTag, "file_tree") + XCTAssertEqual(snippets.fileMap, "\nTREE\n") + XCTAssertEqual(snippets.fileContents, "\nFILE\n\n") + XCTAssertEqual(snippets.gitDiff, "\nDIFF\n") + } + + func testFactualSnippetRenderingKeepsCoreOwnedCloseSpacingAndOmissionRules() { + let treeOnly = PromptRenderingService.renderFactualSnippets( + fileTreeContent: "TREE\n", + codemapBlocks: [], + contentBlocks: [], + gitDiff: nil + ) + XCTAssertEqual(treeOnly.fileMap, "\nTREE\n\n\n") + XCTAssertNil(treeOnly.fileContents) + XCTAssertNil(treeOnly.gitDiff) + + let codemapOnly = PromptRenderingService.renderFactualSnippets( + fileTreeContent: nil, + codemapBlocks: ["CODEMAP"], + contentBlocks: [], + gitDiff: nil, + envelopePolicy: .chatStyleTree + ) + XCTAssertEqual(codemapOnly.fileMap, "\nCODEMAP\n") + XCTAssertNil(codemapOnly.fileContents) + XCTAssertNil(codemapOnly.gitDiff) + } +} diff --git a/Tests/RepoPromptCoreTests/Support/FileSystemTestSupport.swift b/Tests/RepoPromptCoreTests/Support/FileSystemTestSupport.swift new file mode 100644 index 000000000..881ab3f90 --- /dev/null +++ b/Tests/RepoPromptCoreTests/Support/FileSystemTestSupport.swift @@ -0,0 +1,54 @@ +@testable import RepoPromptCore +import XCTest + +struct FileSystemTemporaryRoots { + private var roots: [URL] = [] + + mutating func makeRoot(suiteName: String) throws -> URL { + let url = FileManager.default.temporaryDirectory + .appendingPathComponent("RepoPromptTests", isDirectory: true) + .appendingPathComponent("\(suiteName)-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true) + roots.append(url) + return url + } + + mutating func removeAll() { + for url in roots { + try? FileManager.default.removeItem(at: url) + } + roots.removeAll() + } +} + +enum FileSystemTestSupport { + static func write(_ content: String, to url: URL) throws { + let parent = url.deletingLastPathComponent() + try FileManager.default.createDirectory(at: parent, withIntermediateDirectories: true) + try content.write(to: url, atomically: true, encoding: .utf8) + } + + static func createDirectorySymlinkOrSkip(at link: URL, destination: URL) throws { + do { + try FileManager.default.createSymbolicLink(atPath: link.path, withDestinationPath: destination.path) + } catch { + throw XCTSkip("Directory symlink creation unavailable in this environment: \(error)") + } + } + + static func collectRelativePaths(from service: FileSystemService, root: URL) async throws -> Set { + var paths = Set() + for try await event in await service.loadContentsInChunks(of: root, chunkSize: 2) { + switch event { + case let .preparedItems(chunk): + paths.formUnion(chunk.folders.map(\.relativePath)) + paths.formUnion(chunk.files.map(\.relativePath)) + case let .items(legacyItems): + paths.formUnion(legacyItems.map { item, _ in item.relativePath(rootPath: root.path) }) + case .totalFileCount: + continue + } + } + return paths + } +} diff --git a/Tests/RepoPromptCoreTests/Support/RepoRoot.swift b/Tests/RepoPromptCoreTests/Support/RepoRoot.swift new file mode 100644 index 000000000..a059ca33f --- /dev/null +++ b/Tests/RepoPromptCoreTests/Support/RepoRoot.swift @@ -0,0 +1,53 @@ +import Foundation + +enum RepoRoot { + static func url( + filePath: StaticString = #filePath, + fileManager: FileManager = .default + ) throws -> URL { + var current = URL(fileURLWithPath: "\(filePath)") + .deletingLastPathComponent() + .standardizedFileURL + + while true { + let packageManifest = current.appendingPathComponent("Package.swift") + let sourcesRoot = current.appendingPathComponent("Sources/RepoPromptCore", isDirectory: true) + var packageIsDirectory: ObjCBool = false + var sourcesIsDirectory: ObjCBool = false + + if fileManager.fileExists(atPath: packageManifest.path, isDirectory: &packageIsDirectory), + !packageIsDirectory.boolValue, + fileManager.fileExists(atPath: sourcesRoot.path, isDirectory: &sourcesIsDirectory), + sourcesIsDirectory.boolValue + { + return current + } + + let parent = current.deletingLastPathComponent().standardizedFileURL + if parent.path == current.path { + throw RepoRootError.notFound(startingAt: "\(filePath)") + } + current = parent + } + } + + static func relativePath(for fileURL: URL, relativeTo rootURL: URL) -> String { + let rootPath = rootURL.standardizedFileURL.path + let filePath = fileURL.standardizedFileURL.path + let prefix = rootPath.hasSuffix("/") ? rootPath : rootPath + "/" + + guard filePath.hasPrefix(prefix) else { return filePath } + return String(filePath.dropFirst(prefix.count)) + } +} + +enum RepoRootError: Error, CustomStringConvertible { + case notFound(startingAt: String) + + var description: String { + switch self { + case let .notFound(startingAt): + "Could not find repository root containing Package.swift and Sources/RepoPromptCore when walking upward from \(startingAt)" + } + } +} diff --git a/Tests/RepoPromptCoreTests/Support/TestWorkspaceRuntime.swift b/Tests/RepoPromptCoreTests/Support/TestWorkspaceRuntime.swift new file mode 100644 index 000000000..8f5684d09 --- /dev/null +++ b/Tests/RepoPromptCoreTests/Support/TestWorkspaceRuntime.swift @@ -0,0 +1,270 @@ +import Darwin +import Foundation +@testable import RepoPromptCore + +private struct TestFileContentSnapshotReader: FileContentSnapshotReading { + func fingerprint(atPath path: String) throws -> FileContentFingerprint { + var info = stat() + guard path.withCString({ lstat($0, &info) }) == 0 else { + throw fileSystemError(for: errno) + } + return try fingerprint(from: info) + } + + func fingerprint(fileDescriptor: Int32) throws -> FileContentFingerprint { + var info = stat() + guard fstat(fileDescriptor, &info) == 0 else { + throw fileSystemError(for: errno) + } + return try fingerprint(from: info) + } + + func openReadOnlyFileHandle(atPath path: String) throws -> FileHandle { + let descriptor = path.withCString { open($0, O_RDONLY | O_CLOEXEC | O_NOFOLLOW) } + guard descriptor >= 0 else { + throw fileSystemError(for: errno) + } + return FileHandle(fileDescriptor: descriptor, closeOnDealloc: true) + } + + private func fingerprint(from info: stat) throws -> FileContentFingerprint { + guard (info.st_mode & mode_t(S_IFMT)) == mode_t(S_IFREG) else { + throw FileSystemError.invalidRelativePath + } + return FileContentFingerprint( + deviceID: UInt64(info.st_dev), + fileNumber: UInt64(info.st_ino), + byteSize: Int64(info.st_size), + modificationSeconds: Int64(info.st_mtimespec.tv_sec), + modificationNanoseconds: Int64(info.st_mtimespec.tv_nsec), + statusChangeSeconds: Int64(info.st_ctimespec.tv_sec), + statusChangeNanoseconds: Int64(info.st_ctimespec.tv_nsec) + ) + } + + private func fileSystemError(for errorNumber: Int32) -> FileSystemError { + switch errorNumber { + case ENOENT, ENOTDIR: + .fileNotFound + case ELOOP: + .invalidRelativePath + default: + .failedToReadFile + } + } +} + +private final class TestFileSystemWatcher: FileSystemWatching, @unchecked Sendable { + private(set) var isWatching = false + + func start(eventHandler _: @escaping @Sendable (FileSystemWatchEventPayload) -> Void) -> Bool { + isWatching = true + return true + } + + func stop() { + isWatching = false + } +} + +private struct TestFileSystemWatcherFactory: FileSystemWatcherCreating { + func makeWatcher(path _: String) -> any FileSystemWatching { + TestFileSystemWatcher() + } +} + +private struct TestWorkspaceFileMutationBackend: WorkspaceFileMutationBackend { + func createDirectory(at url: URL) throws { + try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true) + } + + func createFile(at url: URL, contents: Data?) throws { + guard FileManager.default.createFile(atPath: url.path, contents: contents) else { + throw CocoaError(.fileWriteUnknown) + } + } + + func write(_ data: Data, to url: URL, atomically: Bool) throws { + try data.write(to: url, options: atomically ? .atomic : []) + } + + func moveItem(at sourceURL: URL, to destinationURL: URL) throws { + try FileManager.default.moveItem(at: sourceURL, to: destinationURL) + } + + func removeItem(at url: URL) throws { + try FileManager.default.removeItem(at: url) + } + + func trashItem(at url: URL) throws { + try FileManager.default.removeItem(at: url) + } + + func fileExists(atPath path: String, isDirectory: inout Bool) -> Bool { + var directoryFlag = ObjCBool(false) + let exists = FileManager.default.fileExists(atPath: path, isDirectory: &directoryFlag) + isDirectory = directoryFlag.boolValue + return exists + } + + func modificationDate(at url: URL) throws -> Date { + let attributes = try FileManager.default.attributesOfItem(atPath: url.path) + guard let date = attributes[.modificationDate] as? Date else { + throw CocoaError(.fileReadUnknown) + } + return date + } +} + +private struct TestWorkspaceDirectoryListingBackend: WorkspaceDirectoryListingBackend { + func listDirectoryWithIgnoreDetection(at path: String) throws -> WorkspaceDirectoryScanResult { + let urls = try FileManager.default.contentsOfDirectory( + at: URL(fileURLWithPath: path, isDirectory: true), + includingPropertiesForKeys: [.isDirectoryKey, .isSymbolicLinkKey], + options: [] + ) + var hasGitignore = false + var hasRepoIgnore = false + var hasCursorignore = false + var entries: [WorkspaceDirectoryEntry] = [] + entries.reserveCapacity(urls.count) + for url in urls { + var isDirectory = ObjCBool(false) + _ = FileManager.default.fileExists(atPath: url.path, isDirectory: &isDirectory) + let attributes = try FileManager.default.attributesOfItem(atPath: url.path) + let name = url.lastPathComponent + hasGitignore = hasGitignore || name == ".gitignore" + hasRepoIgnore = hasRepoIgnore || name == ".repoignore" + hasCursorignore = hasCursorignore || name == ".cursorignore" + entries.append(WorkspaceDirectoryEntry( + name: name, + isDirectory: isDirectory.boolValue, + isSymbolicLink: attributes[.type] as? FileAttributeType == .typeSymbolicLink + )) + } + entries.sort { $0.name < $1.name } + return WorkspaceDirectoryScanResult( + entries: entries, + hasGitignore: hasGitignore, + hasRepoIgnore: hasRepoIgnore, + hasCursorignore: hasCursorignore + ) + } + + func directoryIdentity(followingSymlinksAt path: String) -> WorkspaceDirectoryIdentity? { + let canonical = URL(fileURLWithPath: path).resolvingSymlinksInPath().standardizedFileURL.path + guard FileManager.default.fileExists(atPath: canonical) else { return nil } + return WorkspaceDirectoryIdentity(device: 0, inode: canonical.testFNV1a64) + } + + func canonicalPath(for path: String) -> String? { + let canonical = URL(fileURLWithPath: path).resolvingSymlinksInPath().standardizedFileURL.path + return FileManager.default.fileExists(atPath: canonical) ? canonical : nil + } +} + +private extension String { + var testFNV1a64: UInt64 { + var hash: UInt64 = 14_695_981_039_346_656_037 + for byte in utf8 { + hash ^= UInt64(byte) + hash &*= 1_099_511_628_211 + } + return hash + } +} + +func makeTestWorkspaceRuntimeDependencies( + maxPendingWatcherEntries: Int = 50000, + maxParallelScans: Int? = nil, + maxFoldersPerBatch: Int = 256, + diagnostics: any WorkspaceRuntimeDiagnosticsSink = NoopWorkspaceRuntimeDiagnosticsSink() +) -> WorkspaceRuntimeDependencies { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("RepoPromptCoreTests-Runtime", isDirectory: true) + .appendingPathComponent(UUID().uuidString, isDirectory: true) + return WorkspaceRuntimeDependencies( + watcherFactory: TestFileSystemWatcherFactory(), + directoryListingBackend: TestWorkspaceDirectoryListingBackend(), + fileContentSnapshotReader: TestFileContentSnapshotReader(), + mutationBackend: TestWorkspaceFileMutationBackend(), + partitionRoot: root.appendingPathComponent("Partitions", isDirectory: true), + codeMapCacheRoot: root.appendingPathComponent("CodeMapCaches", isDirectory: true), + configuration: WorkspaceRuntimeConfiguration( + maxPendingWatcherEntries: maxPendingWatcherEntries, + maxParallelScans: maxParallelScans, + maxFoldersPerBatch: maxFoldersPerBatch, + agentSupportRoot: root.appendingPathComponent("Agents", isDirectory: true), + globalIgnoreDefaults: "" + ), + diagnostics: diagnostics + ) +} + +extension WorkspaceFileContextStore { + init() { + self.init(runtimeDependencies: makeTestWorkspaceRuntimeDependencies()) + } +} + +extension FileSystemService { + init( + path: String, + respectGitignore: Bool = true, + respectRepoIgnore: Bool = true, + respectCursorignore: Bool = true, + skipSymlinks: Bool = true, + enableHierarchicalIgnores: Bool = true + ) async throws { + try await self.init( + path: path, + respectGitignore: respectGitignore, + respectRepoIgnore: respectRepoIgnore, + respectCursorignore: respectCursorignore, + skipSymlinks: skipSymlinks, + enableHierarchicalIgnores: enableHierarchicalIgnores, + dependencies: makeTestWorkspaceRuntimeDependencies() + ) + } + + #if DEBUG + init( + path: String, + respectGitignore: Bool = true, + respectRepoIgnore: Bool = true, + respectCursorignore: Bool = true, + skipSymlinks: Bool = true, + enableHierarchicalIgnores: Bool = true, + testVisitedPaths: Set? = nil, + testVisitedItems: [String: Bool]? = nil, + testIgnoreRules: IgnoreRules? = nil, + isTestMode: Bool = false, + fileManagerOverride: (any FileSystemProviding)? = nil, + maxParallelScansOverride: Int? = nil, + maxFoldersPerBatchOverride: Int? = nil, + maxPendingWatcherIngressEntriesOverride: Int? = nil + ) async throws { + try await self.init( + path: path, + respectGitignore: respectGitignore, + respectRepoIgnore: respectRepoIgnore, + respectCursorignore: respectCursorignore, + skipSymlinks: skipSymlinks, + enableHierarchicalIgnores: enableHierarchicalIgnores, + testVisitedPaths: testVisitedPaths, + testVisitedItems: testVisitedItems, + testIgnoreRules: testIgnoreRules, + isTestMode: isTestMode, + fileManagerOverride: fileManagerOverride, + maxParallelScansOverride: maxParallelScansOverride, + maxFoldersPerBatchOverride: maxFoldersPerBatchOverride, + maxPendingWatcherIngressEntriesOverride: maxPendingWatcherIngressEntriesOverride, + dependencies: makeTestWorkspaceRuntimeDependencies( + maxPendingWatcherEntries: maxPendingWatcherIngressEntriesOverride ?? 50000, + maxParallelScans: maxParallelScansOverride, + maxFoldersPerBatch: maxFoldersPerBatchOverride ?? 256 + ) + ) + } + #endif +} diff --git a/Tests/RepoPromptCoreTests/WorkspaceContext/AgentSupportDirectoryCatalogTests.swift b/Tests/RepoPromptCoreTests/WorkspaceContext/AgentSupportDirectoryCatalogTests.swift new file mode 100644 index 000000000..d67ae27fd --- /dev/null +++ b/Tests/RepoPromptCoreTests/WorkspaceContext/AgentSupportDirectoryCatalogTests.swift @@ -0,0 +1,42 @@ +import Foundation +@testable import RepoPromptCore +import XCTest + +final class AgentSupportDirectoryCatalogTests: XCTestCase { + func testBuiltInAlwaysReadableDirectoriesPreserveEstablishedFourRootPolicy() { + let home = URL(fileURLWithPath: "/tmp/repoprompt-agent-support-home", isDirectory: true) + + let directories = AgentSupportDirectoryCatalog.builtInAlwaysReadableDirectories( + homeDirectoryURL: home + ) + + XCTAssertEqual( + directories.map(\.source), + [.globalAgentsSkills, .globalAgentsSlash, .globalClaudeSkills, .globalClaudeCommands] + ) + XCTAssertEqual( + directories.map(\.standardizedPath), + [ + "/tmp/repoprompt-agent-support-home/.agents/skills", + "/tmp/repoprompt-agent-support-home/.agents/slash", + "/tmp/repoprompt-agent-support-home/.claude/skills", + "/tmp/repoprompt-agent-support-home/.claude/commands", + ] + ) + } + + func testCodexPromptInstallLocationIsNotImplicitReadAuthorization() { + let home = URL(fileURLWithPath: "/tmp/repoprompt-agent-support-home", isDirectory: true) + let roots = AgentSupportDirectoryCatalog.globalRootURLs(homeDirectoryURL: home) + let readablePaths = Set( + AgentSupportDirectoryCatalog.builtInAlwaysReadableDirectories(homeDirectoryURL: home) + .map(\.standardizedPath) + ) + + XCTAssertEqual( + AgentSupportDirectoryCatalog.normalizedPath(for: roots.codexPrompts.path), + "/tmp/repoprompt-agent-support-home/.codex/prompts" + ) + XCTAssertFalse(readablePaths.contains("/tmp/repoprompt-agent-support-home/.codex/prompts")) + } +} diff --git a/Tests/RepoPromptCoreTests/WorkspaceContext/AgentSupportDirectoryContainmentSecurityTests.swift b/Tests/RepoPromptCoreTests/WorkspaceContext/AgentSupportDirectoryContainmentSecurityTests.swift new file mode 100644 index 000000000..e0fa48ee2 --- /dev/null +++ b/Tests/RepoPromptCoreTests/WorkspaceContext/AgentSupportDirectoryContainmentSecurityTests.swift @@ -0,0 +1,58 @@ +import Foundation +@testable import RepoPromptCore +import XCTest + +final class AgentSupportDirectoryContainmentSecurityTests: XCTestCase { + private var temporaryDirectories: [URL] = [] + + override func tearDownWithError() throws { + for directory in temporaryDirectories { + try? FileManager.default.removeItem(at: directory) + } + temporaryDirectories.removeAll() + } + + func testExistingSymlinksAreComparedCanonically() throws { + let home = try makeTemporaryDirectory() + let skillsRoot = home.appendingPathComponent(".agents/skills", isDirectory: true) + try FileManager.default.createDirectory(at: skillsRoot, withIntermediateDirectories: true) + let directory = AlwaysReadableDirectory(url: skillsRoot, source: .globalAgentsSkills) + + let inRootFile = skillsRoot.appendingPathComponent("real.md") + try Data("inside".utf8).write(to: inRootFile) + let internalLink = skillsRoot.appendingPathComponent("internal.md") + try FileManager.default.createSymbolicLink(atPath: internalLink.path, withDestinationPath: inRootFile.path) + + let outsideFile = home.appendingPathComponent("outside.md") + try Data("outside".utf8).write(to: outsideFile) + let escapeLink = skillsRoot.appendingPathComponent("escape.md") + try FileManager.default.createSymbolicLink(atPath: escapeLink.path, withDestinationPath: outsideFile.path) + + XCTAssertTrue(AgentSupportDirectoryCatalog.contains(absolutePath: internalLink.path, in: directory)) + XCTAssertFalse(AgentSupportDirectoryCatalog.contains(absolutePath: escapeLink.path, in: directory)) + } + + func testSymlinkedAllowedRootRemainsSupported() throws { + let home = try makeTemporaryDirectory() + let actualRoot = home.appendingPathComponent("actual-skills", isDirectory: true) + try FileManager.default.createDirectory(at: actualRoot, withIntermediateDirectories: true) + let file = actualRoot.appendingPathComponent("SKILL.md") + try Data("skill".utf8).write(to: file) + + let linkedRoot = home.appendingPathComponent(".agents/skills", isDirectory: true) + try FileManager.default.createDirectory(at: linkedRoot.deletingLastPathComponent(), withIntermediateDirectories: true) + try FileManager.default.createSymbolicLink(atPath: linkedRoot.path, withDestinationPath: actualRoot.path) + let linkedFile = linkedRoot.appendingPathComponent("SKILL.md") + let directory = AlwaysReadableDirectory(url: linkedRoot, source: .globalAgentsSkills) + + XCTAssertTrue(AgentSupportDirectoryCatalog.contains(absolutePath: linkedFile.path, in: directory)) + } + + private func makeTemporaryDirectory() throws -> URL { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("RepoPromptAgentSupportContainment-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + temporaryDirectories.append(directory) + return directory + } +} diff --git a/Tests/RepoPromptTests/WorkspaceContext/PathMatching/PathMatchingRecoveryTests.swift b/Tests/RepoPromptCoreTests/WorkspaceContext/PathMatching/PathMatchingRecoveryTests.swift similarity index 99% rename from Tests/RepoPromptTests/WorkspaceContext/PathMatching/PathMatchingRecoveryTests.swift rename to Tests/RepoPromptCoreTests/WorkspaceContext/PathMatching/PathMatchingRecoveryTests.swift index f4aab064b..9a7bb15f9 100644 --- a/Tests/RepoPromptTests/WorkspaceContext/PathMatching/PathMatchingRecoveryTests.swift +++ b/Tests/RepoPromptCoreTests/WorkspaceContext/PathMatching/PathMatchingRecoveryTests.swift @@ -1,4 +1,4 @@ -@testable import RepoPrompt +@testable import RepoPromptCore import XCTest final class PathMatchingRecoveryTests: XCTestCase { diff --git a/Tests/RepoPromptCoreTests/WorkspaceContext/Projection/CodeStructureProjectionTests.swift b/Tests/RepoPromptCoreTests/WorkspaceContext/Projection/CodeStructureProjectionTests.swift new file mode 100644 index 000000000..c3f832638 --- /dev/null +++ b/Tests/RepoPromptCoreTests/WorkspaceContext/Projection/CodeStructureProjectionTests.swift @@ -0,0 +1,259 @@ +import Foundation +@testable import RepoPromptCore +import XCTest + +final class CodeStructureProjectionTests: XCTestCase { + func testBudgetSelectionFreezesLimitsSeparatorCostAndOversizedFirstBehavior() { + struct Case { + let costs: [Int] + let resultLimit: Int + let included: [String] + let omittedByResultLimit: Int + let omittedByTokenBudget: Int + } + + XCTAssertEqual(CodeStructureProjectionService.defaultTokenBudget, 6000) + XCTAssertEqual(CodeStructureProjectionService.outputSeparator, "\n\n") + XCTAssertEqual( + CodeStructureProjectionService.defaultSeparatorTokenCost, + TokenCalculationService.estimateTokens(for: "\n\n") + ) + + let cases = [ + Case(costs: [1000, 2000], resultLimit: 10, included: ["0", "1"], omittedByResultLimit: 0, omittedByTokenBudget: 0), + Case(costs: [3000, 3000], resultLimit: 10, included: ["0", "1"], omittedByResultLimit: 0, omittedByTokenBudget: 0), + Case(costs: [3000, 3001], resultLimit: 10, included: ["0"], omittedByResultLimit: 0, omittedByTokenBudget: 1), + Case(costs: [6001, 1], resultLimit: 10, included: ["0"], omittedByResultLimit: 0, omittedByTokenBudget: 1), + Case(costs: [100, 6000, 1], resultLimit: 10, included: ["0"], omittedByResultLimit: 0, omittedByTokenBudget: 2), + Case(costs: [100, 100, 100], resultLimit: 2, included: ["0", "1"], omittedByResultLimit: 1, omittedByTokenBudget: 0), + Case(costs: [100, 100], resultLimit: 0, included: [], omittedByResultLimit: 2, omittedByTokenBudget: 0), + Case(costs: [100, 100], resultLimit: -1, included: [], omittedByResultLimit: 2, omittedByTokenBudget: 0) + ] + + for testCase in cases { + let result = CodeStructureProjectionService.selectBudgetedCandidates( + testCase.costs.enumerated().map { + .init(key: String($0.offset), estimatedTokens: $0.element) + }, + resultLimit: testCase.resultLimit + ) + + XCTAssertEqual(result.includedKeys, testCase.included) + XCTAssertEqual(result.omissions.resultLimit, testCase.omittedByResultLimit) + XCTAssertEqual(result.omissions.tokenBudget, testCase.omittedByTokenBudget) + XCTAssertEqual(result.omissions.total, testCase.omittedByResultLimit + testCase.omittedByTokenBudget) + } + + let separatorLimited = CodeStructureProjectionService.selectBudgetedCandidates( + [ + .init(key: "first", estimatedTokens: 3), + .init(key: "second", estimatedTokens: 3) + ], + resultLimit: 2, + tokenBudget: 6, + separatorTokenCost: 1 + ) + XCTAssertEqual(separatorLimited.includedKeys, ["first"]) + XCTAssertEqual(separatorLimited.omissions, .init(resultLimit: 0, tokenBudget: 1)) + + let negativeSeparatorCost = CodeStructureProjectionService.selectBudgetedCandidates( + [ + .init(key: "first", estimatedTokens: 3), + .init(key: "second", estimatedTokens: 3) + ], + resultLimit: 2, + tokenBudget: 6, + separatorTokenCost: -10 + ) + XCTAssertEqual(negativeSeparatorCost.includedKeys, ["first", "second"]) + } + + func testProjectionFreezesPhysicalPathDedupDisplayOrderingRenderingAndOmissions() { + let alpha = makeFileAPI(path: "/repo/Alpha/A.swift", typeName: "AlphaSymbol") + let zeta = makeFileAPI(path: "/repo/Zeta/B.swift", typeName: "ZetaSymbol") + let duplicateAlpha = makeFileAPI(path: "/repo/Alpha/A.swift", typeName: "DuplicateAlphaSymbol") + let entries: [CodeStructureProjectionRequest.Entry] = [ + .init(physicalPath: zeta.filePath, displayPath: "Zeta/B.swift", fileAPI: zeta), + .init(physicalPath: "/repo/Omega/Missing.swift", displayPath: "Omega/Missing.swift", fileAPI: nil), + .init(physicalPath: alpha.filePath, displayPath: "Alpha/A.swift", fileAPI: alpha), + .init(physicalPath: "/repo/Alpha/../Alpha/A.swift", displayPath: "Duplicate/A.swift", fileAPI: duplicateAlpha), + .init(physicalPath: "/repo/Beta/Missing.swift", displayPath: "Beta/Missing.swift", fileAPI: nil) + ] + + let full = CodeStructureProjectionService.project(.init( + entries: entries, + budget: .init(resultLimit: 10, tokenBudget: 100_000), + includeUnmappedPaths: true + )) + + XCTAssertEqual(full.renderedPaths, ["Alpha/A.swift", "Zeta/B.swift"]) + XCTAssertEqual(full.fileCount, 2) + XCTAssertEqual(full.content, [ + alpha.getFullAPIDescription(displayPath: "Alpha/A.swift"), + zeta.getFullAPIDescription(displayPath: "Zeta/B.swift") + ].joined(separator: "\n\n")) + XCTAssertFalse(full.content.contains("DuplicateAlphaSymbol")) + XCTAssertEqual(full.unmappedPaths, ["Beta/Missing.swift", "Omega/Missing.swift"]) + XCTAssertEqual(full.omissions, .init(resultLimit: 0, tokenBudget: 0)) + XCTAssertFalse(full.tokenBudgetHit) + + let limited = CodeStructureProjectionService.project(.init( + entries: entries, + budget: .init(resultLimit: 1, tokenBudget: 100_000), + includeUnmappedPaths: true + )) + XCTAssertEqual(limited.renderedPaths, ["Alpha/A.swift"]) + XCTAssertEqual(limited.omissions, .init(resultLimit: 1, tokenBudget: 0)) + + let alphaCost = alpha.estimatedFullAPIDescriptionTokens(displayPath: "Alpha/A.swift") + let zetaCost = zeta.estimatedFullAPIDescriptionTokens(displayPath: "Zeta/B.swift") + let budgetLimited = CodeStructureProjectionService.project(.init( + entries: entries, + budget: .init( + resultLimit: 10, + tokenBudget: alphaCost + CodeStructureProjectionService.defaultSeparatorTokenCost + zetaCost - 1 + ), + includeUnmappedPaths: false + )) + XCTAssertEqual(budgetLimited.renderedPaths, ["Alpha/A.swift"]) + XCTAssertEqual(budgetLimited.unmappedPaths, []) + XCTAssertEqual(budgetLimited.omissions, .init(resultLimit: 0, tokenBudget: 1)) + XCTAssertTrue(budgetLimited.tokenBudgetHit) + } + + func testProjectionUsesPhysicalPathAsDisplayPathTieBreakerWithoutChangingInputPathText() throws { + let alpha = makeFileAPI(path: "/repo/A.swift", typeName: "AlphaSymbol") + let zeta = makeFileAPI(path: "/repo/Z.swift", typeName: "ZetaSymbol") + let result = CodeStructureProjectionService.project(.init( + entries: [ + .init(physicalPath: zeta.filePath, displayPath: "Same.swift", fileAPI: zeta), + .init(physicalPath: alpha.filePath, displayPath: "Same.swift", fileAPI: alpha) + ], + budget: .init(resultLimit: 10), + includeUnmappedPaths: false + )) + + XCTAssertEqual(result.renderedPaths, ["Same.swift", "Same.swift"]) + XCTAssertLessThan( + try XCTUnwrap(result.content.range(of: "AlphaSymbol")?.lowerBound), + try XCTUnwrap(result.content.range(of: "ZetaSymbol")?.lowerBound) + ) + XCTAssertEqual(result.content.components(separatedBy: "File: Same.swift").count - 1, 2) + } + + func testCompleteLocalDefinitionsPreserveFilteredInputOrderFirstPathWinsAndExactWrapper() throws { + let selected = makeFileAPI(path: "/repo/Selected.swift", typeName: "SelectedSymbol") + let zeta = makeFileAPI(path: "/repo/Zeta.swift", typeName: "ZetaFirst") + let duplicateZeta = makeFileAPI(path: "/repo/Zeta.swift", typeName: "ZetaDuplicate") + let alpha = makeFileAPI(path: "/repo/Alpha.swift", typeName: "AlphaSymbol") + let outside = makeFileAPI(path: "/outside/Outside.swift", typeName: "OutsideSymbol") + let roots = [ + LocalDefinitionProjectionRequest.Root(standardizedPath: "/repo", displayName: "Repo"), + LocalDefinitionProjectionRequest.Root(standardizedPath: "/library", displayName: "Library") + ] + + let result = CodeStructureProjectionService.projectLocalDefinitions(.init( + codeMapUsage: .complete, + selectedFiles: [makeFileRecord(path: selected.filePath)], + availableFileAPIs: [zeta, duplicateZeta, alpha, selected, outside], + pathDisplay: .relative, + roots: roots + )) + let expected = "\n\n" + + zeta.getFullAPIDescription(displayPath: "Repo/Zeta.swift") + + "\n\n" + + alpha.getFullAPIDescription(displayPath: "Repo/Alpha.swift") + + "\n" + + XCTAssertEqual(result, .init(text: expected, fileCount: 2)) + XCTAssertFalse(result.text.contains("ZetaDuplicate")) + XCTAssertFalse(result.text.contains("SelectedSymbol")) + XCTAssertFalse(result.text.contains("OutsideSymbol")) + XCTAssertLessThan( + try XCTUnwrap(result.text.range(of: "ZetaFirst")?.lowerBound), + try XCTUnwrap(result.text.range(of: "AlphaSymbol")?.lowerBound) + ) + } + + func testAutoLocalDefinitionsSortReferencedPathsAndRenderThroughFileAPIPrimitive() { + let selected = makeFileAPI( + path: "/repo/Selected.swift", + typeName: "SelectedSymbol", + referencedTypes: ["ZetaType", "AlphaType", "ZetaType"] + ) + let zeta = makeFileAPI(path: "/repo/Zeta.swift", typeName: "ZetaType") + let alpha = makeFileAPI(path: "/repo/Alpha.swift", typeName: "AlphaType") + let unused = makeFileAPI(path: "/repo/Unused.swift", typeName: "UnusedType") + let roots = [LocalDefinitionProjectionRequest.Root(standardizedPath: "/repo", displayName: "Repo")] + + let result = CodeStructureProjectionService.projectLocalDefinitions(.init( + codeMapUsage: .auto, + selectedFiles: [makeFileRecord(path: selected.filePath)], + availableFileAPIs: [selected, zeta, unused, alpha], + pathDisplay: .relative, + roots: roots + )) + let expected = "\n\n" + + alpha.getFullAPIDescription(displayPath: "Alpha.swift") + + "\n\n" + + zeta.getFullAPIDescription(displayPath: "Zeta.swift") + + "\n" + + XCTAssertEqual(result, .init(text: expected, fileCount: 2)) + XCTAssertFalse(result.text.contains("UnusedType")) + } + + func testLocalDefinitionsKeepNoneSelectedAndEmptyAutoEmpty() { + let api = makeFileAPI(path: "/repo/Only.swift", typeName: "OnlySymbol") + let base = LocalDefinitionProjectionRequest( + codeMapUsage: .none, + selectedFiles: [makeFileRecord(path: api.filePath)], + availableFileAPIs: [api], + pathDisplay: .full, + roots: [.init(standardizedPath: "/repo", displayName: "Repo")] + ) + + XCTAssertEqual(CodeStructureProjectionService.projectLocalDefinitions(base), .empty) + XCTAssertEqual(CodeStructureProjectionService.projectLocalDefinitions(.init( + codeMapUsage: .selected, + selectedFiles: base.selectedFiles, + availableFileAPIs: base.availableFileAPIs, + pathDisplay: base.pathDisplay, + roots: base.roots + )), .empty) + XCTAssertEqual(CodeStructureProjectionService.projectLocalDefinitions(.init( + codeMapUsage: .auto, + selectedFiles: base.selectedFiles, + availableFileAPIs: base.availableFileAPIs, + pathDisplay: base.pathDisplay, + roots: base.roots + )), .empty) + } + + private func makeFileRecord(path: String) -> WorkspaceFileRecord { + WorkspaceFileRecord( + rootID: UUID(), + name: (path as NSString).lastPathComponent, + relativePath: String(path.dropFirst("/repo/".count)), + fullPath: path, + parentFolderID: nil + ) + } + + private func makeFileAPI( + path: String, + typeName: String, + referencedTypes: [String] = [] + ) -> FileAPI { + FileAPI( + filePath: path, + imports: [], + classes: [.init(name: typeName, methods: [], properties: [])], + functions: [], + enums: [], + globalVars: [], + macros: [], + referencedTypes: referencedTypes + ) + } +} diff --git a/Tests/RepoPromptCoreTests/WorkspaceContext/Projection/TokenProjectionTests.swift b/Tests/RepoPromptCoreTests/WorkspaceContext/Projection/TokenProjectionTests.swift new file mode 100644 index 000000000..0d28c56e8 --- /dev/null +++ b/Tests/RepoPromptCoreTests/WorkspaceContext/Projection/TokenProjectionTests.swift @@ -0,0 +1,441 @@ +@testable import RepoPromptCore +import XCTest + +final class TokenProjectionTests: XCTestCase { + func testProvenanceAxesRemainIndependent() { + let normalized = TokenProjection.Provenance( + view: .normalized, + scope: .selection, + source: .activeLive, + basis: .componentEstimate + ) + let configured = TokenProjection.Provenance( + view: .userConfigured, + scope: .workspace, + source: .virtualRecomputed, + basis: .exactRenderedPayload + ) + let snapshotExport = TokenProjection.Provenance( + view: .normalized, + scope: .export, + source: .immutableSnapshot, + basis: .exactRenderedPayload + ) + let preflightExport = TokenProjection.Provenance( + view: .userConfigured, + scope: .export, + source: .activeLive, + basis: .renderedPayloadEstimate + ) + + XCTAssertNotEqual(normalized, configured) + XCTAssertEqual(normalized.view, .normalized) + XCTAssertEqual(configured.view, .userConfigured) + XCTAssertEqual(normalized.scope, .selection) + XCTAssertEqual(configured.scope, .workspace) + XCTAssertEqual(snapshotExport.scope, .export) + XCTAssertEqual(normalized.source, .activeLive) + XCTAssertEqual(configured.source, .virtualRecomputed) + XCTAssertEqual(snapshotExport.source, .immutableSnapshot) + XCTAssertEqual(normalized.basis, .componentEstimate) + XCTAssertEqual(configured.basis, .exactRenderedPayload) + XCTAssertEqual(preflightExport.basis, .renderedPayloadEstimate) + XCTAssertNotEqual(preflightExport, snapshotExport) + } + + func testComponentEstimatePreservesDuplicatePromptArithmetic() { + let duplicated = TokenCalculationService.calculateComponentBreakdown( + promptText: "12345678", + selectedInstructionsText: "1234", + fileTreeText: "12345678", + gitDiffText: "1234", + metadataText: "1234", + duplicateUserInstructionsAtTop: true + ) + let withoutDuplicate = TokenCalculationService.calculateComponentBreakdown( + promptText: "12345678", + selectedInstructionsText: "1234", + fileTreeText: "12345678", + gitDiffText: "1234", + metadataText: "1234", + duplicateUserInstructionsAtTop: false + ) + let selection = makeSelection() + + let duplicatedProjection = TokenProjectionService.workspaceComponentEstimates( + from: selection, + source: .immutableSnapshot, + nonFile: .init(breakdown: duplicated) + ).normalized + let singleProjection = TokenProjectionService.workspaceComponentEstimates( + from: selection, + source: .immutableSnapshot, + nonFile: .init(breakdown: withoutDuplicate) + ).normalized + + XCTAssertEqual(duplicated.promptDisplay, 4) + XCTAssertEqual(duplicated.totalNonFile, 9) + XCTAssertEqual(duplicatedProjection.components.prompt, 4) + XCTAssertEqual(duplicatedProjection.total, 141) + XCTAssertEqual(withoutDuplicate.promptDisplay, 2) + XCTAssertEqual(withoutDuplicate.totalNonFile, 7) + XCTAssertEqual(singleProjection.total, 139) + } + + func testComponentEstimateDistinguishesAbsentFromKnownZero() { + let absent = TokenProjectionService.componentEstimate( + view: .normalized, + scope: .workspace, + source: .immutableSnapshot, + components: .init() + ) + let knownZero = TokenProjectionService.componentEstimate( + view: .normalized, + scope: .workspace, + source: .immutableSnapshot, + components: .init( + files: 0, + prompt: 0, + fileTree: 0, + meta: 0, + git: 0, + other: 0, + filesContent: 0, + codemaps: 0 + ) + ) + + XCTAssertEqual(absent.total, 0) + XCTAssertEqual(knownZero.total, 0) + XCTAssertNotEqual(absent.components, knownZero.components) + XCTAssertNil(absent.components.files) + XCTAssertEqual(knownZero.components.files, 0) + XCTAssertNil(absent.components.codemaps) + XCTAssertEqual(knownZero.components.codemaps, 0) + } + + func testSelectionCompositionPreservesNormalizedConfiguredAndHiddenTotals() throws { + let normalized = try XCTUnwrap(TokenProjectionService.selectionProjection( + from: makeSelection(), + view: .normalized, + source: .immutableSnapshot + )) + XCTAssertEqual(normalized.total, 132) + XCTAssertEqual(normalized.components.filesContent, 120) + XCTAssertEqual(normalized.components.codemaps, 12) + + let selected = try XCTUnwrap(TokenProjectionService.selectionProjection( + from: makeSelection(alternate: .init( + codeMapUsage: .selected, + includesFiles: true, + contentTokens: 0, + codemapTokens: 21, + totalTokens: 21, + includedTotalTokens: 21 + )), + view: .userConfigured, + source: .immutableSnapshot + )) + XCTAssertEqual(selected.total, 21) + XCTAssertEqual(selected.components.filesContent, 0) + XCTAssertEqual(selected.components.codemaps, 21) + + let complete = try XCTUnwrap(TokenProjectionService.selectionProjection( + from: makeSelection(alternate: .init( + codeMapUsage: .complete, + includesFiles: true, + contentTokens: 0, + codemapTokens: 33, + totalTokens: 33, + includedTotalTokens: 33 + )), + view: .userConfigured, + source: .immutableSnapshot + )) + XCTAssertEqual(complete.total, 33) + XCTAssertEqual(complete.components.filesContent, 0) + XCTAssertEqual(complete.components.codemaps, 33) + + let none = try XCTUnwrap(TokenProjectionService.selectionProjection( + from: makeSelection(alternate: .init( + codeMapUsage: .none, + includesFiles: true, + contentTokens: 120, + codemapTokens: 0, + totalTokens: 120, + includedTotalTokens: 120 + )), + view: .userConfigured, + source: .immutableSnapshot + )) + XCTAssertEqual(none.total, 120) + XCTAssertEqual(none.components.filesContent, 120) + XCTAssertEqual(none.components.codemaps, 0) + + let selectedWithoutFiles = try XCTUnwrap(TokenProjectionService.selectionProjection( + from: makeSelection(alternate: .init( + codeMapUsage: .selected, + includesFiles: false, + contentTokens: 0, + codemapTokens: 21, + totalTokens: 21, + includedTotalTokens: 12 + )), + view: .userConfigured, + source: .immutableSnapshot + )) + XCTAssertEqual(selectedWithoutFiles.total, 12) + XCTAssertEqual(selectedWithoutFiles.components.filesContent, 0) + XCTAssertEqual(selectedWithoutFiles.components.codemaps, 12) + + let noneWithoutFiles = try XCTUnwrap(TokenProjectionService.selectionProjection( + from: makeSelection(alternate: .init( + codeMapUsage: .none, + includesFiles: false, + contentTokens: 120, + codemapTokens: 0, + totalTokens: 120, + includedTotalTokens: 0 + )), + view: .userConfigured, + source: .immutableSnapshot + )) + XCTAssertEqual(noneWithoutFiles.total, 0) + XCTAssertEqual(noneWithoutFiles.components.filesContent, 0) + XCTAssertEqual(noneWithoutFiles.components.codemaps, 0) + } + + func testVirtualWorkspaceReplacesOnlyFileProjectionAndOmitsZeroOptionals() throws { + let selection = makeSelection(alternate: .init( + codeMapUsage: .selected, + includesFiles: true, + contentTokens: 0, + codemapTokens: 21, + totalTokens: 21, + includedTotalTokens: 21 + )) + let projections = TokenProjectionService.workspaceComponentEstimates( + from: selection, + source: .virtualRecomputed, + nonFile: .init(prompt: 4, fileTree: 2, meta: 1, git: 1, other: 0) + ) + let configured = try XCTUnwrap(projections.userConfigured) + + XCTAssertEqual(projections.normalized.total, 140) + XCTAssertEqual(configured.total, 29) + XCTAssertEqual(projections.normalized.components.prompt, configured.components.prompt) + XCTAssertEqual(projections.normalized.components.fileTree, configured.components.fileTree) + XCTAssertEqual(projections.normalized.components.meta, configured.components.meta) + XCTAssertEqual(projections.normalized.components.git, configured.components.git) + XCTAssertNil(projections.normalized.components.other) + XCTAssertNil(configured.components.other) + XCTAssertEqual(projections.normalized.components.files, 132) + XCTAssertEqual(configured.components.files, 21) + XCTAssertEqual(projections.normalized.components.filesContent, 120) + XCTAssertNil(configured.components.filesContent) + XCTAssertEqual(projections.normalized.components.codemaps, 12) + XCTAssertEqual(configured.components.codemaps, 21) + + let empty = TokenProjectionService.workspaceComponentEstimates( + from: makeEmptySelection(), + source: .virtualRecomputed, + nonFile: .init(prompt: 0, fileTree: 0, meta: 0, git: 0) + ).normalized + XCTAssertEqual(empty.components.files, 0) + XCTAssertNil(empty.components.prompt) + XCTAssertNil(empty.components.fileTree) + XCTAssertNil(empty.components.meta) + XCTAssertNil(empty.components.git) + XCTAssertNil(empty.components.other) + XCTAssertNil(empty.components.filesContent) + XCTAssertNil(empty.components.codemaps) + } + + func testActiveLiveRepairsTotalsAndPreservesKnownZeroComponents() { + let selection = makeSelection() + let zeroReported = TokenProjectionService.activeLiveWorkspaceEstimates( + from: selection, + input: .init(reportedTotal: 0, prompt: 4, fileTree: 2, meta: 1, git: 1) + ).normalized + let belowComponents = TokenProjectionService.activeLiveWorkspaceEstimates( + from: selection, + input: .init(reportedTotal: 139, prompt: 4, fileTree: 2, meta: 1, git: 1) + ).normalized + let residual = TokenProjectionService.activeLiveWorkspaceEstimates( + from: selection, + input: .init(reportedTotal: 150, prompt: 4, fileTree: 2, meta: 1, git: 1) + ).normalized + + XCTAssertEqual(zeroReported.total, 140) + XCTAssertEqual(zeroReported.components.other, 0) + XCTAssertEqual(belowComponents.total, 140) + XCTAssertEqual(belowComponents.components.other, 0) + XCTAssertEqual(residual.total, 150) + XCTAssertEqual(residual.components.other, 10) + + let empty = TokenProjectionService.activeLiveWorkspaceEstimates( + from: makeEmptySelection(), + input: .init(reportedTotal: 0, prompt: 0, fileTree: 0, meta: 0, git: 0) + ).normalized + XCTAssertEqual(empty.components.files, 0) + XCTAssertEqual(empty.components.prompt, 0) + XCTAssertEqual(empty.components.fileTree, 0) + XCTAssertEqual(empty.components.meta, 0) + XCTAssertEqual(empty.components.git, 0) + XCTAssertEqual(empty.components.other, 0) + XCTAssertNil(empty.components.filesContent) + XCTAssertNil(empty.components.codemaps) + } + + func testActiveLiveTreeFallbackOnlyReplacesZeroLiveTree() { + let selection = makeSelection() + let fallback = TokenProjectionService.activeLiveWorkspaceEstimates( + from: selection, + input: .init( + reportedTotal: 0, + prompt: 0, + fileTree: 0, + meta: 0, + git: 0, + requestedFileTreeEstimate: 3 + ) + ).normalized + let retained = TokenProjectionService.activeLiveWorkspaceEstimates( + from: selection, + input: .init( + reportedTotal: 0, + prompt: 0, + fileTree: 2, + meta: 0, + git: 0, + requestedFileTreeEstimate: 3 + ) + ).normalized + + XCTAssertEqual(fallback.components.fileTree, 3) + XCTAssertEqual(fallback.total, 135) + XCTAssertEqual(retained.components.fileTree, 2) + XCTAssertEqual(retained.total, 134) + } + + func testActiveLiveConfiguredReplacementPreservesResidualAndComponentFloor() throws { + let selection = makeSelection(alternate: .init( + codeMapUsage: .selected, + includesFiles: true, + contentTokens: 0, + codemapTokens: 21, + totalTokens: 21, + includedTotalTokens: 21 + )) + let residual = TokenProjectionService.activeLiveWorkspaceEstimates( + from: selection, + input: .init(reportedTotal: 150, prompt: 4, fileTree: 2, meta: 1, git: 1) + ) + let repaired = TokenProjectionService.activeLiveWorkspaceEstimates( + from: selection, + input: .init(reportedTotal: 0, prompt: 4, fileTree: 2, meta: 1, git: 1) + ) + let residualConfigured = try XCTUnwrap(residual.userConfigured) + let repairedConfigured = try XCTUnwrap(repaired.userConfigured) + + XCTAssertEqual(residual.normalized.components.other, 10) + XCTAssertEqual(residualConfigured.total, 39) + XCTAssertEqual(residualConfigured.components.other, 10) + XCTAssertEqual(repairedConfigured.total, 29) + XCTAssertEqual(repairedConfigured.components.other, 0) + } + + func testWorkspaceComponentEstimateDoesNotDoubleCountCodemapsAndDefaultsOtherToZero() { + let selection = makeSelection() + let withoutOther = TokenProjectionService.workspaceComponentEstimates( + from: selection, + source: .virtualRecomputed, + nonFile: .init(prompt: 4, fileTree: 0, meta: 0, git: 0) + ).normalized + let withOther = TokenProjectionService.workspaceComponentEstimates( + from: selection, + source: .virtualRecomputed, + nonFile: .init(prompt: 4, fileTree: 0, meta: 0, git: 0, other: 5) + ).normalized + + XCTAssertEqual(withoutOther.total, 136) + XCTAssertEqual(withoutOther.components.codemaps, 12) + XCTAssertNil(withoutOther.components.other) + XCTAssertEqual(withOther.total, 141) + XCTAssertEqual(withOther.components.other, 5) + } + + func testRenderedPayloadEstimateUsesCompleteStringWithoutClaimingExactBytes() { + let projection = TokenProjectionService.renderedPayloadEstimate( + "12345678", + view: .userConfigured, + source: .activeLive + ) + + XCTAssertEqual(projection.total, TokenCalculationService.estimateTokens(for: "12345678")) + XCTAssertEqual(projection.provenance, .init( + view: .userConfigured, + scope: .export, + source: .activeLive, + basis: .renderedPayloadEstimate + )) + XCTAssertEqual(projection.components, .init()) + } + + func testExactRenderedPayloadUsesCompleteStringEstimateWithoutInventingComponents() { + let projection = TokenProjectionService.exactRenderedPayload( + "12345678", + view: .userConfigured, + source: .immutableSnapshot + ) + let empty = TokenProjectionService.exactRenderedPayload( + "", + view: .normalized, + source: .immutableSnapshot + ) + + XCTAssertEqual(projection.total, TokenCalculationService.estimateTokens(for: "12345678")) + XCTAssertEqual(projection.provenance, .init( + view: .userConfigured, + scope: .export, + source: .immutableSnapshot, + basis: .exactRenderedPayload + )) + XCTAssertEqual(projection.components, .init()) + XCTAssertEqual(empty.total, 0) + XCTAssertEqual(empty.components, .init()) + } + + private func makeSelection( + alternate: WorkspaceSelectionProjection.Alternate? = nil + ) -> WorkspaceSelectionProjection { + WorkspaceSelectionProjection( + files: [], + slices: [], + summary: .init( + fullCount: 1, + sliceCount: 1, + codemapCount: 1, + fullTokens: 100, + sliceTokens: 20, + codemapTokens: 12 + ), + invalidPaths: [], + codeMapUsage: .auto, + codemapAutoEnabled: true, + alternate: alternate + ) + } + + private func makeEmptySelection() -> WorkspaceSelectionProjection { + WorkspaceSelectionProjection( + files: [], + slices: [], + summary: .empty, + invalidPaths: [], + codeMapUsage: .auto, + codemapAutoEnabled: true, + alternate: nil + ) + } +} diff --git a/Tests/RepoPromptCoreTests/WorkspaceContext/Projection/WorkspaceContextProjectionServiceTests.swift b/Tests/RepoPromptCoreTests/WorkspaceContext/Projection/WorkspaceContextProjectionServiceTests.swift new file mode 100644 index 000000000..6d82a28fd --- /dev/null +++ b/Tests/RepoPromptCoreTests/WorkspaceContext/Projection/WorkspaceContextProjectionServiceTests.swift @@ -0,0 +1,883 @@ +import Foundation +@testable import RepoPromptCore +import XCTest + +final class WorkspaceContextProjectionServiceTests: XCTestCase { + func testProjectionAPISurfaceIsSendable() { + func requireSendable(_: (some Sendable).Type) {} + + requireSendable(WorkspaceContextProjectionService.CaptureOperation.self) + requireSendable(WorkspaceContextProjectionService.Materializer.self) + requireSendable(WorkspaceContextProjectionRequest.self) + requireSendable(WorkspaceContextProjectionRequest.Sections.self) + requireSendable(WorkspaceContextProjectionMaterializationRequest.self) + requireSendable(WorkspaceContextProjectionMaterializationRequest.OccurrenceID.self) + requireSendable(WorkspaceContextProjectionMaterializationRequest.Codemap.self) + requireSendable(WorkspaceContextProjectionMaterializationRequest.Occurrence.self) + requireSendable(WorkspaceContextProjectionMaterialization.self) + requireSendable(WorkspaceContextProjectionMaterialization.TokenFacts.self) + requireSendable(WorkspaceContextProjectionMaterialization.Occurrence.self) + requireSendable(WorkspaceContextProjection.self) + requireSendable(WorkspaceContextProjection.Section.self) + requireSendable(WorkspaceContextProjection.Section.self) + requireSendable(WorkspaceContextProjection.Section<[String]>.self) + requireSendable(WorkspaceContextProjection.Section.self) + requireSendable(WorkspaceContextProjection.Section.self) + requireSendable(WorkspaceContextProjection.Section.self) + requireSendable(WorkspaceContextProjection.FileTree.self) + requireSendable(WorkspaceContextProjection.TokenViews.self) + requireSendable(WorkspaceSelectionProjection.self) + requireSendable(CodeStructureProjection.self) + requireSendable(TokenProjection.self) + } + + func testOmittedSectionsAreNilAndDoNotMaterialize() async throws { + let capture = makeEmptyCapture() + let service = WorkspaceContextProjectionService( + capture: { capture }, + materializer: { request in + XCTFail("Empty-section projection must not materialize") + return .init(provenance: request.provenance, occurrences: []) + } + ) + + let projection = try await service.project(.init(sections: [])) + + XCTAssertNil(projection.prompt) + XCTAssertNil(projection.selection) + XCTAssertNil(projection.fileBlocks) + XCTAssertNil(projection.codeStructure) + XCTAssertNil(projection.fileTree) + XCTAssertNil(projection.tokens) + } + + func testRequestedEmptySectionsRemainPresentWithCaptureProvenance() async throws { + let capture = makeEmptyCapture() + let service = WorkspaceContextProjectionService( + capture: { capture }, + materializer: { request in + XCTAssertEqual(request.provenance, capture.provenance) + XCTAssertEqual(request.occurrences, []) + XCTAssertTrue(request.requiresContent) + XCTAssertTrue(request.requiresTokenFacts) + return .init(provenance: request.provenance, occurrences: []) + } + ) + + let projection = try await service.project(.init()) + + XCTAssertEqual(projection.prompt?.value, "") + XCTAssertEqual(projection.selection?.value.files, []) + XCTAssertEqual(projection.selection?.value.slices, []) + XCTAssertEqual(projection.selection?.value.summary, .empty) + XCTAssertEqual(projection.fileBlocks?.value, []) + XCTAssertEqual(projection.codeStructure?.value.content, "") + XCTAssertEqual(projection.codeStructure?.value.renderedPaths, []) + XCTAssertEqual(projection.codeStructure?.value.unmappedPaths, []) + XCTAssertEqual(projection.fileTree?.value.content, "") + XCTAssertEqual(projection.fileTree?.value.rootCount, 0) + XCTAssertEqual(projection.tokens?.value.normalized.total, 0) + XCTAssertEqual(projection.tokens?.value.normalized.components.files, 0) + XCTAssertNil(projection.tokens?.value.normalized.components.prompt) + XCTAssertNil(projection.tokens?.value.normalized.components.filesContent) + XCTAssertNil(projection.tokens?.value.normalized.components.codemaps) + XCTAssertNil(projection.tokens?.value.userConfigured) + assertAllPresentSections(projection, have: capture.provenance) + } + + func testAllSectionsComposeFullSliceCodemapTreeCodeAndTokenViews() async throws { + let fixture = makeFullSliceCodemapCapture() + let codemapTokens = try XCTUnwrap(fixture.codemap.fileAPI?.apiTokenCount) + let ranges = [LineRange(start: 2, end: 2, description: "middle")] + let service = WorkspaceContextProjectionService( + capture: { fixture.capture }, + materializer: { request in + XCTAssertEqual(request.occurrences.map(\.file.standardizedRelativePath), [ + "Full.swift", + "Slice.swift", + "Code.swift" + ]) + XCTAssertEqual(request.occurrences.map(\.mode), [.full, .slice, .codemap]) + XCTAssertEqual(request.occurrences.map(\.ranges), [[], ranges, []]) + XCTAssertNil(request.occurrences[0].codemap) + XCTAssertNil(request.occurrences[1].codemap) + XCTAssertEqual(request.occurrences[2].codemap?.tokens, codemapTokens) + XCTAssertTrue(request.requiresContent) + XCTAssertTrue(request.requiresTokenFacts) + return .init( + provenance: request.provenance, + occurrences: request.occurrences.reversed().map { occurrence in + let content: String? = switch occurrence.mode { + case .full: "full body" + case .slice: "one\ntwo\nthree" + case .codemap: "ignored live content" + } + let tokenFacts: WorkspaceContextProjectionMaterialization.TokenFacts = switch occurrence.mode { + case .full: .init(displayTokens: 12, fullTokens: 12) + case .slice: .init(displayTokens: 3, fullTokens: 30) + case .codemap: .init(displayTokens: codemapTokens, fullTokens: 20) + } + return .init(id: occurrence.id, content: content, tokenFacts: tokenFacts) + } + ) + } + ) + + let projection = try await service.project(.init( + promptText: "user prompt", + alternatePolicy: .init(includeFiles: true, codeMapUsage: .none), + tokenProjectionInput: .componentEstimate( + source: .virtualRecomputed, + nonFile: .init(prompt: 2, fileTree: 1, meta: 0, git: 0) + ) + )) + + XCTAssertEqual(projection.prompt?.value, "user prompt") + XCTAssertEqual(projection.selection?.value.files.map(\.mode), [.full, .slice, .codemap]) + XCTAssertEqual(projection.selection?.value.files.map(\.tokens), [12, 3, codemapTokens]) + XCTAssertEqual(projection.selection?.value.slices.map(\.ranges), [ranges]) + XCTAssertEqual(projection.selection?.value.summary, .init( + fullCount: 1, + sliceCount: 1, + codemapCount: 1, + fullTokens: 12, + sliceTokens: 3, + codemapTokens: codemapTokens + )) + + let blocks = try XCTUnwrap(projection.fileBlocks?.value) + XCTAssertEqual(blocks.count, 3) + XCTAssertTrue(blocks[0].contains("File: Full.swift")) + XCTAssertTrue(blocks[0].contains("full body")) + XCTAssertTrue(blocks[1].contains("(lines 2: middle)")) + XCTAssertTrue(blocks[1].contains("two")) + XCTAssertFalse(blocks[1].contains("one")) + XCTAssertTrue(blocks[2].contains("CodeSymbol")) + + let code = try XCTUnwrap(projection.codeStructure?.value) + XCTAssertEqual(code.renderedPaths, ["Code.swift"]) + XCTAssertEqual(code.unmappedPaths, ["Full.swift", "Slice.swift"]) + XCTAssertTrue(code.content.contains("CodeSymbol")) + + let tree = try XCTUnwrap(projection.fileTree?.value) + XCTAssertEqual(tree.rootCount, 1) + XCTAssertTrue(tree.usesLegend) + XCTAssertTrue(tree.content.contains("Full.swift")) + + let tokens = try XCTUnwrap(projection.tokens?.value) + XCTAssertEqual(tokens.normalized.total, 12 + 3 + codemapTokens + 3) + XCTAssertEqual(tokens.normalized.components.files, 12 + 3 + codemapTokens) + XCTAssertEqual(tokens.normalized.components.filesContent, 15) + XCTAssertEqual(tokens.normalized.components.codemaps, codemapTokens) + XCTAssertEqual(tokens.normalized.components.prompt, 2) + XCTAssertEqual(tokens.normalized.components.fileTree, 1) + XCTAssertNil(tokens.normalized.components.meta) + XCTAssertEqual(tokens.userConfigured?.total, 18) + XCTAssertEqual(tokens.userConfigured?.components.files, 15) + XCTAssertEqual(tokens.userConfigured?.components.codemaps, nil) + assertAllPresentSections(projection, have: fixture.capture.provenance) + } + + func testCompleteAlternateIncludesCompleteOnlyCodemapsWithoutChangingNormalizedOccurrences() async throws { + let root = makeRoot() + let selected = makeFile(root: root, path: "Selected.swift") + let auto = makeFile(root: root, path: "Auto.swift") + let completeOnly = makeFile(root: root, path: "CompleteOnly.swift") + let selectedCodemap = makeCodemap(file: selected, root: root, symbol: "SelectedSymbol") + let autoCodemap = makeCodemap(file: auto, root: root, symbol: "AutoSymbol") + let completeOnlyCodemap = makeCodemap(file: completeOnly, root: root, symbol: "CompleteOnlySymbol") + let selectedCodemapTokens = try XCTUnwrap(selectedCodemap.fileAPI?.apiTokenCount) + let autoCodemapTokens = try XCTUnwrap(autoCodemap.fileAPI?.apiTokenCount) + let completeOnlyCodemapTokens = try XCTUnwrap(completeOnlyCodemap.fileAPI?.apiTokenCount) + let capture = makeCapture( + root: root, + files: [selected, auto, completeOnly], + selection: StoredSelection( + selectedPaths: [selected.fullPath], + autoCodemapPaths: [auto.fullPath], + codemapAutoEnabled: true + ), + selectedPaths: [.init(input: selected.fullPath, resolution: .file(selected))], + autoCodemapPaths: [.init(input: auto.fullPath, resolution: .file(auto))], + codemapSnapshots: [selectedCodemap, autoCodemap, completeOnlyCodemap] + ) + let service = WorkspaceContextProjectionService( + capture: { capture }, + materializer: { request in + XCTAssertEqual(request.occurrences.map(\.file.standardizedRelativePath), [ + "Selected.swift", + "Auto.swift" + ]) + XCTAssertEqual(request.occurrences.map(\.mode), [.full, .codemap]) + return .init( + provenance: request.provenance, + occurrences: request.occurrences.map { occurrence in + let displayTokens = occurrence.mode == .codemap ? autoCodemapTokens : 100 + return .init( + id: occurrence.id, + content: nil, + tokenFacts: .init(displayTokens: displayTokens, fullTokens: 100) + ) + } + ) + } + ) + + let projection = try await service.project(.init( + sections: [.selection, .tokens], + codeMapUsage: .auto, + alternatePolicy: .init(includeFiles: true, codeMapUsage: .complete) + )) + + XCTAssertEqual(projection.selection?.value.files.map(\.file.standardizedRelativePath), [ + "Selected.swift", + "Auto.swift" + ]) + XCTAssertEqual(projection.selection?.value.summary, .init( + fullCount: 1, + sliceCount: 0, + codemapCount: 1, + fullTokens: 100, + sliceTokens: 0, + codemapTokens: autoCodemapTokens + )) + let expectedCompleteTokens = selectedCodemapTokens + autoCodemapTokens + completeOnlyCodemapTokens + XCTAssertEqual(projection.selection?.value.alternate?.codemapTokens, expectedCompleteTokens) + XCTAssertEqual(projection.selection?.value.alternate?.totalTokens, expectedCompleteTokens) + XCTAssertEqual(projection.tokens?.value.normalized.components.files, 100 + autoCodemapTokens) + XCTAssertEqual(projection.tokens?.value.userConfigured?.components.files, expectedCompleteTokens) + XCTAssertEqual(projection.tokens?.value.userConfigured?.components.codemaps, expectedCompleteTokens) + } + + func testNilContentOmitsBlockWhileEmptyContentEmitsEmptyFence() async throws { + let root = makeRoot() + let nilFile = makeFile(root: root, path: "Nil.swift") + let emptyFile = makeFile(root: root, path: "Empty.swift") + let capture = makeCapture( + root: root, + files: [nilFile, emptyFile], + selection: StoredSelection(selectedPaths: [nilFile.fullPath, emptyFile.fullPath]), + selectedPaths: [ + .init(input: nilFile.fullPath, resolution: .file(nilFile)), + .init(input: emptyFile.fullPath, resolution: .file(emptyFile)) + ] + ) + let service = WorkspaceContextProjectionService( + capture: { capture }, + materializer: { request in + .init( + provenance: request.provenance, + occurrences: request.occurrences.map { occurrence in + .init( + id: occurrence.id, + content: occurrence.file.id == nilFile.id ? nil : "", + tokenFacts: nil + ) + } + ) + } + ) + + let projection = try await service.project(.init(sections: [.files])) + let blocks = try XCTUnwrap(projection.fileBlocks?.value) + + XCTAssertEqual(blocks.count, 1) + XCTAssertTrue(blocks[0].contains("File: Empty.swift")) + XCTAssertTrue(blocks[0].contains("```swift\n\n```")) + XCTAssertFalse(blocks[0].contains("Nil.swift")) + } + + func testProvenanceMismatchIsRejectedAndEveryPresentSectionUsesCaptureProvenance() async throws { + let capture = makeSingleFileCapture() + let mismatched = WorkspaceFileContextCapture.Provenance( + captureGeneration: capture.provenance.captureGeneration + 1, + catalogGeneration: capture.provenance.catalogGeneration, + catalogValidationToken: capture.provenance.catalogValidationToken, + rootScope: capture.provenance.rootScope, + ingressSamples: capture.provenance.ingressSamples + ) + let mismatchService = WorkspaceContextProjectionService( + capture: { capture }, + materializer: { request in + .init( + provenance: mismatched, + occurrences: request.occurrences.map { + .init(id: $0.id, content: "body", tokenFacts: nil) + } + ) + } + ) + + do { + _ = try await mismatchService.project(.init(sections: [.files])) + XCTFail("Expected materialization provenance mismatch") + } catch let error as WorkspaceContextProjectionError { + XCTAssertEqual(error, .materializationProvenanceMismatch) + } + + let invalidCapture = copyCapture( + capture, + provenance: .init( + captureGeneration: capture.provenance.captureGeneration, + catalogGeneration: capture.provenance.catalogGeneration + 1, + catalogValidationToken: capture.provenance.catalogValidationToken, + rootScope: capture.provenance.rootScope, + ingressSamples: capture.provenance.ingressSamples + ) + ) + let invalidCaptureService = WorkspaceContextProjectionService( + capture: { invalidCapture }, + materializer: { request in .init(provenance: request.provenance, occurrences: []) } + ) + do { + _ = try await invalidCaptureService.project(.init(sections: [])) + XCTFail("Expected capture provenance mismatch") + } catch let error as WorkspaceContextProjectionError { + XCTAssertEqual(error, .captureProvenanceMismatch) + } + } + + func testMissingUnexpectedAndDuplicateMaterializationOccurrenceIDsAreRejected() async throws { + let capture = makeSingleFileCapture() + let expectedID = WorkspaceContextProjectionMaterializationRequest.OccurrenceID(rawValue: 0) + let unexpectedID = WorkspaceContextProjectionMaterializationRequest.OccurrenceID(rawValue: 99) + + let missingService = WorkspaceContextProjectionService( + capture: { capture }, + materializer: { request in .init(provenance: request.provenance, occurrences: []) } + ) + do { + _ = try await missingService.project(.init(sections: [.files])) + XCTFail("Expected missing occurrence ID") + } catch let error as WorkspaceContextProjectionError { + XCTAssertEqual(error, .missingOccurrenceIDs([expectedID])) + } + + let unexpectedService = WorkspaceContextProjectionService( + capture: { capture }, + materializer: { request in + .init(provenance: request.provenance, occurrences: [ + .init(id: expectedID, content: "body", tokenFacts: nil), + .init(id: unexpectedID, content: "extra", tokenFacts: nil) + ]) + } + ) + do { + _ = try await unexpectedService.project(.init(sections: [.files])) + XCTFail("Expected unexpected occurrence ID") + } catch let error as WorkspaceContextProjectionError { + XCTAssertEqual(error, .unexpectedOccurrenceIDs([unexpectedID])) + } + + let duplicateService = WorkspaceContextProjectionService( + capture: { capture }, + materializer: { request in + .init(provenance: request.provenance, occurrences: [ + .init(id: expectedID, content: "first", tokenFacts: nil), + .init(id: expectedID, content: "second", tokenFacts: nil) + ]) + } + ) + do { + _ = try await duplicateService.project(.init(sections: [.files])) + XCTFail("Expected duplicate occurrence ID") + } catch let error as WorkspaceContextProjectionError { + XCTAssertEqual(error, .duplicateOccurrenceID(expectedID)) + } + } + + func testRequiredAndModeBoundTokenFactsAreValidatedWithZeroSemantics() async throws { + let capture = makeSingleFileCapture() + let missingService = WorkspaceContextProjectionService( + capture: { capture }, + materializer: { request in + .init( + provenance: request.provenance, + occurrences: request.occurrences.map { + .init(id: $0.id, content: "body", tokenFacts: nil) + } + ) + } + ) + do { + _ = try await missingService.project(.init(sections: [.selection])) + XCTFail("Expected required token facts") + } catch let error as WorkspaceContextProjectionError { + XCTAssertEqual(error, .missingTokenFacts(.init(rawValue: 0))) + } + + let invalidService = WorkspaceContextProjectionService( + capture: { capture }, + materializer: { request in + .init( + provenance: request.provenance, + occurrences: request.occurrences.map { + .init( + id: $0.id, + content: "body", + tokenFacts: .init(displayTokens: 1, fullTokens: 2) + ) + } + ) + } + ) + do { + _ = try await invalidService.project(.init(sections: [.selection])) + XCTFail("Expected mode-bound token validation") + } catch let error as WorkspaceContextProjectionError { + XCTAssertEqual(error, .invalidTokenFacts(.init(rawValue: 0))) + } + + let zeroService = WorkspaceContextProjectionService( + capture: { capture }, + materializer: { request in + .init( + provenance: request.provenance, + occurrences: request.occurrences.map { + .init( + id: $0.id, + content: "", + tokenFacts: .init(displayTokens: 0, fullTokens: 0) + ) + } + ) + } + ) + let zero = try await zeroService.project(.init(sections: [.tokens])) + XCTAssertEqual(zero.tokens?.value.normalized.total, 0) + XCTAssertEqual(zero.tokens?.value.normalized.components.files, 0) + XCTAssertNil(zero.tokens?.value.normalized.components.filesContent) + XCTAssertNil(zero.tokens?.value.normalized.components.codemaps) + XCTAssertNil(zero.tokens?.value.userConfigured) + } + + func testRootAndCodemapAssociationsAreValidatedBeforeMaterialization() async throws { + let base = makeSingleFileCapture(includeCodemap: true) + let file = try XCTUnwrap(base.materializedFiles.first) + let wrongRootFile = WorkspaceFileRecord( + id: file.id, + rootID: UUID(), + name: file.name, + relativePath: file.relativePath, + fullPath: file.fullPath, + parentFolderID: file.parentFolderID + ) + let wrongRoot = copyCapture( + base, + selectedPaths: [.init(input: wrongRootFile.fullPath, resolution: .file(wrongRootFile))], + materializedFiles: [wrongRootFile], + codemapSnapshots: [] + ) + let wrongRootService = WorkspaceContextProjectionService( + capture: { wrongRoot }, + materializer: { request in .init(provenance: request.provenance, occurrences: []) } + ) + do { + _ = try await wrongRootService.project(.init(sections: [])) + XCTFail("Expected root association failure") + } catch let error as WorkspaceContextProjectionError { + XCTAssertEqual(error, .rootAssociationMismatch(recordID: file.id, rootID: wrongRootFile.rootID)) + } + + let codemap = try XCTUnwrap(base.codemapSnapshots.first) + let wrongCodemap = WorkspaceCodemapSnapshot( + fileID: codemap.fileID, + rootID: codemap.rootID, + rootPath: "/wrong/root", + relativePath: codemap.relativePath, + fullPath: codemap.fullPath, + modificationDate: codemap.modificationDate, + fileAPI: codemap.fileAPI + ) + let wrongCodemapCapture = copyCapture(base, codemapSnapshots: [wrongCodemap]) + let wrongCodemapService = WorkspaceContextProjectionService( + capture: { wrongCodemapCapture }, + materializer: { request in .init(provenance: request.provenance, occurrences: []) } + ) + do { + _ = try await wrongCodemapService.project(.init(sections: [])) + XCTFail("Expected codemap association failure") + } catch let error as WorkspaceContextProjectionError { + XCTAssertEqual(error, .codemapAssociationMismatch(file.id)) + } + } + + func testCaptureAndMaterializerErrorsPropagateUnchanged() async throws { + enum Sentinel: Error, Equatable { + case capture + case materializer + } + + let captureErrorService = WorkspaceContextProjectionService( + capture: { throw Sentinel.capture }, + materializer: { request in .init(provenance: request.provenance, occurrences: []) } + ) + do { + _ = try await captureErrorService.project(.init()) + XCTFail("Expected capture error") + } catch let error as Sentinel { + XCTAssertEqual(error, .capture) + } + + let capture = makeSingleFileCapture() + let materializerErrorService = WorkspaceContextProjectionService( + capture: { capture }, + materializer: { _ in throw Sentinel.materializer } + ) + do { + _ = try await materializerErrorService.project(.init(sections: [.files])) + XCTFail("Expected materializer error") + } catch let error as Sentinel { + XCTAssertEqual(error, .materializer) + } + } + + func testCancellationIsCheckedAfterCaptureAndMaterialization() async throws { + let capture = makeSingleFileCapture() + let captureCancelledService = WorkspaceContextProjectionService( + capture: { + withUnsafeCurrentTask { $0?.cancel() } + return capture + }, + materializer: { request in + XCTFail("Cancellation after capture must prevent materialization") + return .init(provenance: request.provenance, occurrences: []) + } + ) + let captureTask = Task { + try await captureCancelledService.project(.init(sections: [.files])) + } + do { + _ = try await captureTask.value + XCTFail("Expected cancellation after capture") + } catch is CancellationError {} + + let materializerCancelledService = WorkspaceContextProjectionService( + capture: { capture }, + materializer: { request in + withUnsafeCurrentTask { $0?.cancel() } + return .init( + provenance: request.provenance, + occurrences: request.occurrences.map { + .init(id: $0.id, content: "body", tokenFacts: nil) + } + ) + } + ) + let materializerTask = Task { + try await materializerCancelledService.project(.init(sections: [.files])) + } + do { + _ = try await materializerTask.value + XCTFail("Expected cancellation after materialization") + } catch is CancellationError {} + } + + func testEmptySectionCancellationIsObservedWithoutMaterialization() async throws { + let capture = makeEmptyCapture() + let service = WorkspaceContextProjectionService( + capture: { capture }, + materializer: { request in + XCTFail("Empty-section cancellation path must not materialize") + return .init(provenance: request.provenance, occurrences: []) + }, + willReturnForTesting: { + withUnsafeCurrentTask { $0?.cancel() } + } + ) + let task = Task { + try await service.project(.init(sections: [])) + } + + do { + _ = try await task.value + XCTFail("Expected cancellation on the empty-section path") + } catch is CancellationError {} + } + + func testDuplicateCaptureSelectionsUseFirstIdenticalOccurrenceButPreserveDistinctModes() async throws { + let root = makeRoot() + let rootFolder = makeRootFolder(root) + let fileA = makeFile(root: root, path: "A.swift", parentFolderID: rootFolder.id) + let fileB = makeFile(root: root, path: "B.swift", parentFolderID: rootFolder.id) + let ranges = [LineRange(start: 2, end: 2)] + let folderPath = WorkspaceFileContextCapture.SelectionPath( + input: root.fullPath, + resolution: .folder(rootFolder, descendantFiles: [fileA, fileB]) + ) + let capture = makeCapture( + root: root, + files: [fileA, fileB], + folders: [rootFolder], + selection: StoredSelection( + selectedPaths: [fileB.fullPath, root.fullPath, root.fullPath], + slices: [fileB.fullPath: ranges] + ), + selectedPaths: [ + .init(input: fileB.fullPath, resolution: .file(fileB)), + folderPath, + folderPath + ], + slices: [.init(path: fileB.fullPath, ranges: ranges, file: fileB, issue: nil)] + ) + let service = WorkspaceContextProjectionService( + capture: { capture }, + materializer: { request in + XCTAssertEqual(request.occurrences.map(\.file.standardizedRelativePath), [ + "B.swift", + "A.swift", + "B.swift" + ]) + XCTAssertEqual(request.occurrences.map(\.mode), [.slice, .full, .full]) + return .init( + provenance: request.provenance, + occurrences: request.occurrences.map { occurrence in + let display = occurrence.mode == .slice ? 1 : 2 + return .init( + id: occurrence.id, + content: "one\ntwo", + tokenFacts: .init(displayTokens: display, fullTokens: 2) + ) + } + ) + } + ) + + let projection = try await service.project(.init(sections: [.selection])) + + XCTAssertEqual(projection.selection?.value.files.map(\.file.standardizedRelativePath), [ + "B.swift", + "A.swift", + "B.swift" + ]) + XCTAssertEqual(projection.selection?.value.files.map(\.mode), [.slice, .full, .full]) + XCTAssertEqual(projection.selection?.value.summary.sliceCount, 1) + XCTAssertEqual(projection.selection?.value.summary.fullCount, 2) + } + + private struct FullSliceCodemapFixture { + let capture: WorkspaceFileContextCapture + let codemap: WorkspaceCodemapSnapshot + } + + private func makeFullSliceCodemapCapture() -> FullSliceCodemapFixture { + let root = makeRoot() + let rootFolder = makeRootFolder(root) + let full = makeFile(root: root, path: "Full.swift", parentFolderID: rootFolder.id) + let slice = makeFile(root: root, path: "Slice.swift", parentFolderID: rootFolder.id) + let code = makeFile(root: root, path: "Code.swift", parentFolderID: rootFolder.id) + let ranges = [LineRange(start: 2, end: 2, description: "middle")] + let codemap = makeCodemap(file: code, root: root, symbol: "CodeSymbol") + let tree = FileTreeSelectionSnapshot( + roots: [.init( + id: rootFolder.id, + name: root.name, + fullPath: root.fullPath, + standardizedFullPath: root.standardizedFullPath, + standardizedRootPath: root.standardizedFullPath, + children: [full, slice, code].map { + .file(.init( + id: $0.id, + name: $0.name, + fileExtension: "swift", + hasCodeMap: $0.id == code.id + )) + } + )], + selectedFileIDs: [full.id, slice.id, code.id], + mode: "selected", + showFullPaths: false, + onlyIncludeRootsWithSelectedFiles: false, + includeLegend: true + ) + let capture = makeCapture( + root: root, + files: [full, slice, code], + folders: [rootFolder], + selection: StoredSelection( + selectedPaths: [full.fullPath, slice.fullPath], + autoCodemapPaths: [code.fullPath], + slices: [slice.fullPath: ranges] + ), + selectedPaths: [ + .init(input: full.fullPath, resolution: .file(full)), + .init(input: slice.fullPath, resolution: .file(slice)) + ], + autoCodemapPaths: [.init(input: code.fullPath, resolution: .file(code))], + slices: [.init(path: slice.fullPath, ranges: ranges, file: slice, issue: nil)], + codemapSnapshots: [codemap], + fileTree: tree + ) + return FullSliceCodemapFixture(capture: capture, codemap: codemap) + } + + private func makeSingleFileCapture(includeCodemap: Bool = false) -> WorkspaceFileContextCapture { + let root = makeRoot() + let file = makeFile(root: root, path: "Only.swift") + let codemaps = includeCodemap ? [makeCodemap(file: file, root: root, symbol: "OnlySymbol")] : [] + return makeCapture( + root: root, + files: [file], + selection: StoredSelection(selectedPaths: [file.fullPath]), + selectedPaths: [.init(input: file.fullPath, resolution: .file(file))], + codemapSnapshots: codemaps + ) + } + + private func makeEmptyCapture() -> WorkspaceFileContextCapture { + makeCapture( + root: makeRoot(), + files: [], + selection: StoredSelection(), + selectedPaths: [] + ) + } + + private func makeCapture( + root: WorkspaceRootRecord, + files: [WorkspaceFileRecord], + folders: [WorkspaceFolderRecord] = [], + selection: StoredSelection, + selectedPaths: [WorkspaceFileContextCapture.SelectionPath], + autoCodemapPaths: [WorkspaceFileContextCapture.SelectionPath] = [], + slices: [WorkspaceFileContextCapture.Slice] = [], + codemapSnapshots: [WorkspaceCodemapSnapshot] = [], + fileTree: FileTreeSelectionSnapshot? = nil + ) -> WorkspaceFileContextCapture { + let generation: UInt64 = 7 + let provenance = WorkspaceFileContextCapture.Provenance( + captureGeneration: 11, + catalogGeneration: generation, + catalogValidationToken: 13, + rootScope: .visibleWorkspace, + ingressSamples: [] + ) + let diagnostics = WorkspaceCatalogDiagnostics( + generation: generation, + rootScope: .visibleWorkspace, + rootCount: 1, + folderCount: folders.count, + fileCount: files.count + ) + let catalog = WorkspaceSearchCatalogSnapshot( + generation: generation, + rootScope: .visibleWorkspace, + roots: [root], + files: files, + entries: files.map { WorkspaceSearchCatalogEntry(file: $0, root: root) }, + diagnostics: diagnostics + ) + return WorkspaceFileContextCapture( + provenance: provenance, + storedSelection: selection, + selectedPaths: selectedPaths, + autoCodemapPaths: autoCodemapPaths, + slices: slices, + catalog: catalog, + materializedFolders: folders, + materializedFiles: files, + codemapSnapshots: codemapSnapshots, + fileTree: fileTree ?? .init( + roots: [], + selectedFileIDs: Set(files.map(\.id)), + mode: "none", + showFullPaths: false, + onlyIncludeRootsWithSelectedFiles: false, + includeLegend: false + ) + ) + } + + private func copyCapture( + _ capture: WorkspaceFileContextCapture, + provenance: WorkspaceFileContextCapture.Provenance? = nil, + selectedPaths: [WorkspaceFileContextCapture.SelectionPath]? = nil, + materializedFiles: [WorkspaceFileRecord]? = nil, + codemapSnapshots: [WorkspaceCodemapSnapshot]? = nil + ) -> WorkspaceFileContextCapture { + WorkspaceFileContextCapture( + provenance: provenance ?? capture.provenance, + storedSelection: capture.storedSelection, + selectedPaths: selectedPaths ?? capture.selectedPaths, + autoCodemapPaths: capture.autoCodemapPaths, + slices: capture.slices, + catalog: capture.catalog, + materializedFolders: capture.materializedFolders, + materializedFiles: materializedFiles ?? capture.materializedFiles, + codemapSnapshots: codemapSnapshots ?? capture.codemapSnapshots, + fileTree: capture.fileTree + ) + } + + private func makeRoot() -> WorkspaceRootRecord { + WorkspaceRootRecord( + id: UUID(uuidString: "AAAAAAAA-AAAA-AAAA-AAAA-AAAAAAAAAAAA")!, + name: "Repo", + fullPath: "/repo" + ) + } + + private func makeRootFolder(_ root: WorkspaceRootRecord) -> WorkspaceFolderRecord { + WorkspaceFolderRecord( + id: UUID(uuidString: "BBBBBBBB-BBBB-BBBB-BBBB-BBBBBBBBBBBB")!, + rootID: root.id, + name: root.name, + relativePath: "", + fullPath: root.fullPath, + parentFolderID: nil + ) + } + + private func makeFile( + root: WorkspaceRootRecord, + path: String, + parentFolderID: UUID? = nil + ) -> WorkspaceFileRecord { + WorkspaceFileRecord( + rootID: root.id, + name: (path as NSString).lastPathComponent, + relativePath: path, + fullPath: root.fullPath + "/" + path, + parentFolderID: parentFolderID + ) + } + + private func makeCodemap( + file: WorkspaceFileRecord, + root: WorkspaceRootRecord, + symbol: String + ) -> WorkspaceCodemapSnapshot { + WorkspaceCodemapSnapshot( + fileID: file.id, + rootID: root.id, + rootPath: root.fullPath, + relativePath: file.relativePath, + fullPath: file.fullPath, + modificationDate: Date(timeIntervalSince1970: 0), + fileAPI: FileAPI( + filePath: file.fullPath, + imports: [], + classes: [.init(name: symbol, methods: [], properties: [])], + functions: [], + enums: [], + globalVars: [], + macros: [], + referencedTypes: [] + ) + ) + } + + private func assertAllPresentSections( + _ projection: WorkspaceContextProjection, + have provenance: WorkspaceFileContextCapture.Provenance, + file: StaticString = #filePath, + line: UInt = #line + ) { + XCTAssertEqual(projection.prompt?.provenance, provenance, file: file, line: line) + XCTAssertEqual(projection.selection?.provenance, provenance, file: file, line: line) + XCTAssertEqual(projection.fileBlocks?.provenance, provenance, file: file, line: line) + XCTAssertEqual(projection.codeStructure?.provenance, provenance, file: file, line: line) + XCTAssertEqual(projection.fileTree?.provenance, provenance, file: file, line: line) + XCTAssertEqual(projection.tokens?.provenance, provenance, file: file, line: line) + } +} diff --git a/Tests/RepoPromptCoreTests/WorkspaceContext/Projection/WorkspaceSelectionProjectionServiceTests.swift b/Tests/RepoPromptCoreTests/WorkspaceContext/Projection/WorkspaceSelectionProjectionServiceTests.swift new file mode 100644 index 000000000..56862f0e7 --- /dev/null +++ b/Tests/RepoPromptCoreTests/WorkspaceContext/Projection/WorkspaceSelectionProjectionServiceTests.swift @@ -0,0 +1,415 @@ +import Foundation +@testable import RepoPromptCore +import XCTest + +final class WorkspaceSelectionProjectionServiceTests: XCTestCase { + private var temporaryRoots = FileSystemTemporaryRoots() + + override func tearDownWithError() throws { + temporaryRoots.removeAll() + try super.tearDownWithError() + } + + func testBaseProjectionAndSelectedAlternateMatchFrozenTokenSemantics() { + let ranges = [LineRange(start: 2, end: 2, description: "middle")] + let entries = [ + makeEntry(path: "Sources/Full.swift", mode: .full, displayTokens: 100, fullTokens: 100, codemapTokens: 10), + makeEntry( + path: "Sources/Sliced.swift", + mode: .slice, + ranges: ranges, + displayTokens: 20, + fullTokens: 200, + codemapTokens: 11 + ), + makeEntry(path: "Sources/Auto.swift", mode: .codemap, displayTokens: 12, fullTokens: 300, codemapTokens: 12) + ] + + let projection = project(entries, alternateUsage: .selected) + + XCTAssertEqual(projection.files.map(\.file.standardizedRelativePath), [ + "Sources/Full.swift", + "Sources/Sliced.swift", + "Sources/Auto.swift" + ]) + XCTAssertEqual(projection.files.map(\.mode), [.full, .slice, .codemap]) + XCTAssertEqual(projection.files.map(\.tokens), [100, 20, 12]) + XCTAssertEqual(projection.files.map(\.isAuto), [false, false, true]) + XCTAssertEqual(projection.totalTokens, 132) + XCTAssertEqual(projection.summary, .init( + fullCount: 1, + sliceCount: 1, + codemapCount: 1, + fullTokens: 100, + sliceTokens: 20, + codemapTokens: 12 + )) + XCTAssertEqual(projection.slices.map(\.file.standardizedRelativePath), ["Sources/Sliced.swift"]) + XCTAssertEqual(projection.slices.map(\.ranges), [ranges]) + XCTAssertEqual(projection.files.map(\.alternate?.mode), [.codemap, .codemap, nil]) + XCTAssertEqual(projection.files.map(\.alternate?.tokens), [10, 11, nil]) + XCTAssertEqual(projection.files.map(\.alternate?.codemapOrigin), [.selectedMode, .selectedMode, nil]) + XCTAssertEqual(projection.alternate?.contentTokens, 0) + XCTAssertEqual(projection.alternate?.codemapTokens, 33) + XCTAssertEqual(projection.alternate?.totalTokens, 33) + XCTAssertEqual(projection.alternate?.includedTotalTokens, 33) + XCTAssertEqual(projection.alternate?.includedFiles.map(\.file.standardizedRelativePath), [ + "Sources/Full.swift", + "Sources/Sliced.swift", + "Sources/Auto.swift" + ]) + XCTAssertEqual(projection.alternate?.includedFiles.map(\.mode), [.codemap, .codemap, .codemap]) + } + + func testCompleteNoneAndAutoAlternatesPreserveBaseAndAggregateSemantics() { + let entries = [ + makeEntry(path: "Full.swift", mode: .full, displayTokens: 100, fullTokens: 100, codemapTokens: 10), + makeEntry(path: "Sliced.swift", mode: .slice, displayTokens: 20, fullTokens: 200, codemapTokens: 11), + makeEntry(path: "Auto.swift", mode: .codemap, displayTokens: 12, fullTokens: 300, codemapTokens: 12) + ] + + let complete = project(entries, alternateUsage: .complete) + XCTAssertEqual(complete.files.map(\.alternate?.tokens), [10, 11, nil]) + XCTAssertEqual(complete.files.map(\.alternate?.codemapOrigin), [.completeMode, .completeMode, nil]) + XCTAssertEqual(complete.alternate?.contentTokens, 0) + XCTAssertEqual(complete.alternate?.codemapTokens, 33) + XCTAssertEqual(complete.alternate?.totalTokens, 33) + XCTAssertEqual(complete.alternate?.includedTotalTokens, 33) + + let none = project(entries, alternateUsage: .none) + XCTAssertEqual(none.files.map(\.alternate?.mode), [nil, nil, .hidden]) + XCTAssertEqual(none.files.map(\.tokens), [100, 20, 12]) + XCTAssertEqual(none.summary.codemapTokens, 12) + XCTAssertEqual(none.alternate?.contentTokens, 120) + XCTAssertEqual(none.alternate?.codemapTokens, 0) + XCTAssertEqual(none.alternate?.totalTokens, 120) + XCTAssertEqual(none.alternate?.includedTotalTokens, 120) + + let auto = project(entries, alternateUsage: .auto) + XCTAssertTrue(auto.files.allSatisfy { $0.alternate == nil }) + XCTAssertEqual(auto.alternate?.contentTokens, 120) + XCTAssertEqual(auto.alternate?.codemapTokens, 12) + XCTAssertEqual(auto.alternate?.totalTokens, 132) + XCTAssertEqual(auto.alternate?.includedTotalTokens, 132) + } + + func testCompleteAlternateEntriesAffectOnlyAlternateTotals() { + let selected = makeEntry( + path: "Selected.swift", + mode: .full, + displayTokens: 100, + fullTokens: 100, + codemapTokens: 10 + ) + let auto = makeEntry( + path: "Auto.swift", + mode: .codemap, + displayTokens: 12, + fullTokens: 300, + codemapTokens: 12 + ) + let completeOnly = makeEntry( + path: "CompleteOnly.swift", + mode: .codemap, + displayTokens: 14, + fullTokens: 0, + codemapTokens: 14 + ) + + let projection = WorkspaceSelectionProjectionService.project(.init( + entries: [selected, auto], + completeAlternateEntries: [completeOnly], + codeMapUsage: .auto, + codemapAutoEnabled: true, + alternatePolicy: .init(includeFiles: true, codeMapUsage: .complete) + )) + + XCTAssertEqual(projection.files.map(\.file.standardizedRelativePath), ["Selected.swift", "Auto.swift"]) + XCTAssertEqual(projection.summary, .init( + fullCount: 1, + sliceCount: 0, + codemapCount: 1, + fullTokens: 100, + sliceTokens: 0, + codemapTokens: 12 + )) + XCTAssertEqual(projection.files.map(\.alternate?.tokens), [10, nil]) + XCTAssertEqual(projection.alternate?.contentTokens, 0) + XCTAssertEqual(projection.alternate?.codemapTokens, 36) + XCTAssertEqual(projection.alternate?.totalTokens, 36) + XCTAssertEqual(projection.alternate?.includedTotalTokens, 36) + XCTAssertEqual(projection.alternate?.includedFiles.map(\.file.standardizedRelativePath), [ + "Selected.swift", + "Auto.swift", + "CompleteOnly.swift" + ]) + } + + func testAlternateIncludeFilesFalseUsesFrozenBaseCodemapTotalRule() { + let entries = [ + makeEntry(path: "Full.swift", mode: .full, displayTokens: 100, fullTokens: 100, codemapTokens: 10), + makeEntry(path: "Auto.swift", mode: .codemap, displayTokens: 12, fullTokens: 300, codemapTokens: 12) + ] + + let selected = project(entries, alternateUsage: .selected, includeFiles: false) + XCTAssertEqual(selected.alternate?.totalTokens, 22) + XCTAssertEqual(selected.alternate?.includedTotalTokens, 12) + XCTAssertEqual(selected.alternate?.includedFiles.map(\.file.standardizedRelativePath), ["Auto.swift"]) + + let complete = project(entries, alternateUsage: .complete, includeFiles: false) + XCTAssertEqual(complete.alternate?.totalTokens, 22) + XCTAssertEqual(complete.alternate?.includedTotalTokens, 12) + + let auto = project(entries, alternateUsage: .auto, includeFiles: false) + XCTAssertEqual(auto.alternate?.totalTokens, 112) + XCTAssertEqual(auto.alternate?.includedTotalTokens, 12) + + let none = project(entries, alternateUsage: .none, includeFiles: false) + XCTAssertEqual(none.alternate?.totalTokens, 100) + XCTAssertEqual(none.alternate?.includedTotalTokens, 0) + } + + func testBaseCodemapOriginsPreserveNormalizedAutoAndConfiguredUsage() throws { + let entry = makeEntry(path: "Only.swift", mode: .codemap, displayTokens: 7, fullTokens: 70, codemapTokens: 7) + let cases: [(CodeMapUsage, Bool, WorkspaceSelectionProjection.CodemapOrigin, Bool)] = [ + (.auto, true, .auto, true), + (.auto, false, .manual, false), + (.selected, false, .selectedMode, false), + (.complete, false, .auto, true), + (.none, false, .manual, false) + ] + + for (usage, autoEnabled, expectedOrigin, expectedIsAuto) in cases { + let projection = WorkspaceSelectionProjectionService.project(.init( + entries: [entry], + codeMapUsage: usage, + codemapAutoEnabled: autoEnabled + )) + let file = try XCTUnwrap(projection.files.first) + XCTAssertEqual(file.codemapOrigin, expectedOrigin) + XCTAssertEqual(file.isAuto, expectedIsAuto) + } + } + + func testExplicitCodemapAvailabilityDoesNotDependOnTokenCount() throws { + let zeroTokenAvailable = makeEntry( + path: "Zero.swift", + mode: .full, + displayTokens: 50, + fullTokens: 50, + codemapTokens: 0, + codemapAvailable: true + ) + let nonzeroUnavailable = makeEntry( + path: "Unavailable.swift", + mode: .slice, + displayTokens: 25, + fullTokens: 75, + codemapTokens: 99, + codemapAvailable: false + ) + + let selected = project([zeroTokenAvailable, nonzeroUnavailable], alternateUsage: .selected) + let zero = try XCTUnwrap(selected.files.first) + XCTAssertEqual(zero.alternate, .init(mode: .codemap, tokens: 0, codemapOrigin: .selectedMode)) + XCTAssertNil(selected.files.last?.alternate) + XCTAssertEqual(selected.alternate?.codemapTokens, 0) + XCTAssertEqual(selected.alternate?.contentTokens, 25) + + let complete = project([zeroTokenAvailable, nonzeroUnavailable], alternateUsage: .complete) + XCTAssertEqual(complete.files.first?.alternate, .init(mode: .codemap, tokens: 0, codemapOrigin: .completeMode)) + XCTAssertNil(complete.files.last?.alternate) + XCTAssertEqual(complete.alternate?.codemapTokens, 0) + XCTAssertEqual(complete.alternate?.contentTokens, 25) + } + + func testProjectionPreservesSelectedFolderAndSliceOrder() async throws { + let root = try temporaryRoots.makeRoot(suiteName: "SelectionProjectionOrder") + let fileA = root.appendingPathComponent("Sources/A.swift") + let fileB = root.appendingPathComponent("Sources/B.swift") + try FileSystemTestSupport.write("alpha", to: fileA) + try FileSystemTestSupport.write("one\ntwo\nthree", to: fileB) + let ranges = [ + LineRange(start: 3, end: 3, description: "third"), + LineRange(start: 1, end: 1, description: "first") + ] + + let store = WorkspaceFileContextStore() + _ = try await store.loadRoot(path: root.path) + let resolution = try await PromptContextAccountingService().resolveEntries( + selection: StoredSelection( + selectedPaths: [fileB.path, root.appendingPathComponent("Sources").path], + slices: [fileB.path: ranges], + codemapAutoEnabled: false + ), + store: store, + codeMapUsage: .none + ) + let entries = try resolution.entries.enumerated().map { index, resolved in + try makeEntry( + file: resolved.file, + displayPath: "LogicalRepo/\(resolved.file.standardizedRelativePath)", + rootPath: XCTUnwrap(resolved.rootFolderPath), + mode: resolved.mode == .sliced ? .slice : .full, + ranges: resolved.lineRanges ?? [], + displayTokens: index + 1, + fullTokens: index + 1, + codemapTokens: 0, + codemapAvailable: false + ) + } + + let projection = WorkspaceSelectionProjectionService.project(.init( + entries: entries, + codeMapUsage: .none, + codemapAutoEnabled: false + )) + + XCTAssertEqual(projection.files.map(\.file.standardizedRelativePath), [ + "Sources/B.swift", + "Sources/A.swift", + "Sources/B.swift" + ]) + XCTAssertEqual(projection.files.map(\.mode), [.slice, .full, .full]) + XCTAssertEqual(projection.files.map(\.metadata.displayPath), [ + "LogicalRepo/Sources/B.swift", + "LogicalRepo/Sources/A.swift", + "LogicalRepo/Sources/B.swift" + ]) + XCTAssertEqual(projection.slices.map(\.file.standardizedRelativePath), ["Sources/B.swift"]) + XCTAssertEqual(projection.slices.first?.ranges, ranges) + } + + func testInjectedDisplayMetadataDoesNotReplacePhysicalProvenanceAndInvalidOrderIsStable() throws { + let physicalPath = "/tmp/worktrees/session/Sources/A.swift" + let file = WorkspaceFileRecord( + rootID: UUID(), + name: "A.swift", + relativePath: "Sources/A.swift", + fullPath: physicalPath, + parentFolderID: nil + ) + let entry = makeEntry( + file: file, + displayPath: "LogicalRepo/Sources/A.swift", + rootPath: "/tmp/worktrees/session", + mode: .full, + displayTokens: 5, + fullTokens: 5, + codemapTokens: 0, + codemapAvailable: false + ) + + let projection = WorkspaceSelectionProjectionService.project(.init( + entries: [entry], + codeMapUsage: .none, + codemapAutoEnabled: false, + missingPaths: ["missing-b", "missing-a"], + invalidPaths: ["invalid-b", "invalid-a"] + )) + let projected = try XCTUnwrap(projection.files.first) + + XCTAssertEqual(projected.file.fullPath, physicalPath) + XCTAssertEqual(projected.file.standardizedFullPath, physicalPath) + XCTAssertEqual(projected.metadata.displayPath, "LogicalRepo/Sources/A.swift") + XCTAssertEqual(projected.metadata.rootPath, "/tmp/worktrees/session") + XCTAssertEqual(projected.metadata.pathWithinRoot, "Sources/A.swift") + XCTAssertEqual(projection.invalidPaths, ["missing-b", "missing-a", "invalid-b", "invalid-a"]) + } + + func testEmptyProjectionHasZeroTotalsAndRetainsInvalidPaths() { + let projection = WorkspaceSelectionProjectionService.project(.init( + entries: [], + codeMapUsage: .auto, + codemapAutoEnabled: true, + missingPaths: ["missing"], + invalidPaths: ["invalid"], + alternatePolicy: .init(includeFiles: true, codeMapUsage: .selected) + )) + + XCTAssertEqual(projection.files, []) + XCTAssertEqual(projection.slices, []) + XCTAssertEqual(projection.summary, .empty) + XCTAssertEqual(projection.totalTokens, 0) + XCTAssertEqual(projection.invalidPaths, ["missing", "invalid"]) + XCTAssertEqual(projection.alternate, .init( + codeMapUsage: .selected, + includesFiles: true, + contentTokens: 0, + codemapTokens: 0, + totalTokens: 0, + includedTotalTokens: 0 + )) + } + + private func project( + _ entries: [WorkspaceSelectionProjectionRequest.Entry], + alternateUsage: CodeMapUsage, + includeFiles: Bool = true + ) -> WorkspaceSelectionProjection { + WorkspaceSelectionProjectionService.project(.init( + entries: entries, + codeMapUsage: .auto, + codemapAutoEnabled: true, + alternatePolicy: .init(includeFiles: includeFiles, codeMapUsage: alternateUsage) + )) + } + + private func makeEntry( + path: String, + mode: WorkspaceSelectionProjection.BaseMode, + ranges: [LineRange] = [], + displayTokens: Int, + fullTokens: Int, + codemapTokens: Int, + codemapAvailable: Bool = true + ) -> WorkspaceSelectionProjectionRequest.Entry { + let file = WorkspaceFileRecord( + rootID: UUID(), + name: (path as NSString).lastPathComponent, + relativePath: path, + fullPath: "/physical/root/\(path)", + parentFolderID: nil + ) + return makeEntry( + file: file, + displayPath: path, + rootPath: "/physical/root", + mode: mode, + ranges: ranges, + displayTokens: displayTokens, + fullTokens: fullTokens, + codemapTokens: codemapTokens, + codemapAvailable: codemapAvailable + ) + } + + private func makeEntry( + file: WorkspaceFileRecord, + displayPath: String, + rootPath: String, + mode: WorkspaceSelectionProjection.BaseMode, + ranges: [LineRange] = [], + displayTokens: Int, + fullTokens: Int, + codemapTokens: Int, + codemapAvailable: Bool + ) -> WorkspaceSelectionProjectionRequest.Entry { + WorkspaceSelectionProjectionRequest.Entry( + file: file, + metadata: .init( + displayPath: displayPath, + rootPath: rootPath, + pathWithinRoot: file.standardizedRelativePath + ), + mode: mode, + ranges: ranges, + tokens: .init( + displayTokens: displayTokens, + fullTokens: fullTokens, + codemapTokens: codemapTokens + ), + codemapAvailable: codemapAvailable + ) + } +} diff --git a/Tests/RepoPromptTests/WorkspaceContext/Search/PathSearchIndexRecoveryTests.swift b/Tests/RepoPromptCoreTests/WorkspaceContext/Search/PathSearchIndexRecoveryTests.swift similarity index 98% rename from Tests/RepoPromptTests/WorkspaceContext/Search/PathSearchIndexRecoveryTests.swift rename to Tests/RepoPromptCoreTests/WorkspaceContext/Search/PathSearchIndexRecoveryTests.swift index 0b59b6c4a..d164bf66d 100644 --- a/Tests/RepoPromptTests/WorkspaceContext/Search/PathSearchIndexRecoveryTests.swift +++ b/Tests/RepoPromptCoreTests/WorkspaceContext/Search/PathSearchIndexRecoveryTests.swift @@ -1,4 +1,4 @@ -@testable import RepoPrompt +@testable import RepoPromptCore import XCTest final class PathSearchIndexRecoveryTests: XCTestCase { diff --git a/Tests/RepoPromptTests/WorkspaceContext/Search/RepoSearchQueryRecoveryTests.swift b/Tests/RepoPromptCoreTests/WorkspaceContext/Search/RepoSearchQueryRecoveryTests.swift similarity index 97% rename from Tests/RepoPromptTests/WorkspaceContext/Search/RepoSearchQueryRecoveryTests.swift rename to Tests/RepoPromptCoreTests/WorkspaceContext/Search/RepoSearchQueryRecoveryTests.swift index ac31eb90f..f7343a366 100644 --- a/Tests/RepoPromptTests/WorkspaceContext/Search/RepoSearchQueryRecoveryTests.swift +++ b/Tests/RepoPromptCoreTests/WorkspaceContext/Search/RepoSearchQueryRecoveryTests.swift @@ -1,4 +1,4 @@ -@testable import RepoPrompt +@testable import RepoPromptCore import XCTest final class RepoSearchQueryRecoveryTests: XCTestCase { diff --git a/Tests/RepoPromptTests/WorkspaceContext/Search/SearchPathFilteringTests.swift b/Tests/RepoPromptCoreTests/WorkspaceContext/Search/SearchPathFilteringTests.swift similarity index 99% rename from Tests/RepoPromptTests/WorkspaceContext/Search/SearchPathFilteringTests.swift rename to Tests/RepoPromptCoreTests/WorkspaceContext/Search/SearchPathFilteringTests.swift index 7c340c645..06ac9f7fc 100644 --- a/Tests/RepoPromptTests/WorkspaceContext/Search/SearchPathFilteringTests.swift +++ b/Tests/RepoPromptCoreTests/WorkspaceContext/Search/SearchPathFilteringTests.swift @@ -1,4 +1,4 @@ -@testable import RepoPrompt +@testable import RepoPromptCore import XCTest final class SearchPathFilteringTests: XCTestCase { diff --git a/Tests/RepoPromptTests/WorkspaceContext/Search/StoreBackedWorkspaceSearchConcurrencyMatrixTests.swift b/Tests/RepoPromptCoreTests/WorkspaceContext/Search/StoreBackedWorkspaceSearchConcurrencyMatrixTests.swift similarity index 99% rename from Tests/RepoPromptTests/WorkspaceContext/Search/StoreBackedWorkspaceSearchConcurrencyMatrixTests.swift rename to Tests/RepoPromptCoreTests/WorkspaceContext/Search/StoreBackedWorkspaceSearchConcurrencyMatrixTests.swift index fed763c97..e434e341a 100644 --- a/Tests/RepoPromptTests/WorkspaceContext/Search/StoreBackedWorkspaceSearchConcurrencyMatrixTests.swift +++ b/Tests/RepoPromptCoreTests/WorkspaceContext/Search/StoreBackedWorkspaceSearchConcurrencyMatrixTests.swift @@ -1,5 +1,5 @@ import Foundation -@testable import RepoPrompt +@testable import RepoPromptCore import XCTest #if DEBUG @@ -116,7 +116,7 @@ import XCTest fixtures.reserveCapacity(storeCount) for storeIndex in 0 ..< storeCount { let root = FileManager.default.temporaryDirectory - .appendingPathComponent("RepoPromptTests", isDirectory: true) + .appendingPathComponent("RepoPromptCoreTests", isDirectory: true) .appendingPathComponent("SearchMatrix-\(label)-s\(storeIndex)-\(UUID().uuidString)", isDirectory: true) try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) temporaryRoots.append(root) @@ -256,7 +256,7 @@ import XCTest countOnly: countOnly, rootScope: .visibleWorkspace, store: fixture.store, - workspaceManager: nil + readinessSource: nil ) } diff --git a/Tests/RepoPromptTests/WorkspaceContext/Search/StoreBackedWorkspaceSearchLaneTests.swift b/Tests/RepoPromptCoreTests/WorkspaceContext/Search/StoreBackedWorkspaceSearchLaneTests.swift similarity index 99% rename from Tests/RepoPromptTests/WorkspaceContext/Search/StoreBackedWorkspaceSearchLaneTests.swift rename to Tests/RepoPromptCoreTests/WorkspaceContext/Search/StoreBackedWorkspaceSearchLaneTests.swift index a31039b7b..561c868d5 100644 --- a/Tests/RepoPromptTests/WorkspaceContext/Search/StoreBackedWorkspaceSearchLaneTests.swift +++ b/Tests/RepoPromptCoreTests/WorkspaceContext/Search/StoreBackedWorkspaceSearchLaneTests.swift @@ -1,4 +1,4 @@ -@testable import RepoPrompt +@testable import RepoPromptCore import XCTest #if DEBUG diff --git a/Tests/RepoPromptTests/WorkspaceContext/Search/StoreBackedWorkspaceSearchTests.swift b/Tests/RepoPromptCoreTests/WorkspaceContext/Search/StoreBackedWorkspaceSearchTests.swift similarity index 89% rename from Tests/RepoPromptTests/WorkspaceContext/Search/StoreBackedWorkspaceSearchTests.swift rename to Tests/RepoPromptCoreTests/WorkspaceContext/Search/StoreBackedWorkspaceSearchTests.swift index b0129b4df..3c02e7682 100644 --- a/Tests/RepoPromptTests/WorkspaceContext/Search/StoreBackedWorkspaceSearchTests.swift +++ b/Tests/RepoPromptCoreTests/WorkspaceContext/Search/StoreBackedWorkspaceSearchTests.swift @@ -1,13 +1,11 @@ -@testable import RepoPrompt +import Foundation +@testable import RepoPromptCore import XCTest final class StoreBackedWorkspaceSearchTests: XCTestCase { private var temporaryRoots: [URL] = [] override func tearDownWithError() throws { - #if DEBUG - EditFlowPerf.resetDebugCaptureForTesting() - #endif for url in temporaryRoots { try? FileManager.default.removeItem(at: url) } @@ -365,14 +363,16 @@ final class StoreBackedWorkspaceSearchTests: XCTestCase { } let store = WorkspaceFileContextStore() _ = try await store.loadRoot(path: root.path) - _ = startedCapture(label: "adaptive-content-batch-window", maxSamples: 10000) + let diagnostics = RecordingWorkspaceRuntimeDiagnosticsSink() - let result = try await searchContent( - pattern: "needle", - maxMatches: 1, - store: store - ) - let capture = EditFlowPerf.debugCaptureSnapshot(finish: true) + let result = try await WorkspaceRuntimePerf.withSink(diagnostics) { + try await searchContent( + pattern: "needle", + maxMatches: 1, + store: store + ) + } + let capture = diagnostics.snapshot() let resolvedBatchSize = FileSearchActor.contentScanBatchSize( fileCount: fileCount, workerCount: workerCount @@ -386,30 +386,31 @@ final class StoreBackedWorkspaceSearchTests: XCTestCase { ) XCTAssertEqual(result.matches?.map(\.lineNumber), [0]) - let totalRows = capture.stages.filter { - $0.stageName == String(describing: EditFlowPerf.Stage.Search.contentScanTotal) - && $0.sanitizedDimensions.contains("outcome=capped") + let totalRows = capture.filter { + $0.kind == .intervalEnded + && $0.name == String(describing: WorkspaceRuntimePerf.Stage.Search.contentScanTotal) + && ($0.fields["dimensions"] ?? "").contains("outcome=capped") } XCTAssertEqual(totalRows.count, 1) - let totalRow = try XCTUnwrap(totalRows.first) - XCTAssertEqual(dimensionInt("batchSize", in: totalRow.sanitizedDimensions), resolvedBatchSize) - XCTAssertEqual(dimensionInt("workerCount", in: totalRow.sanitizedDimensions), workerCount) + let totalDimensions = try XCTUnwrap(totalRows.first?.fields["dimensions"]) + XCTAssertEqual(dimensionInt("batchSize", in: totalDimensions), resolvedBatchSize) + XCTAssertEqual(dimensionInt("workerCount", in: totalDimensions), workerCount) - let batchRows = capture.stages.filter { - $0.stageName == String(describing: EditFlowPerf.Stage.Search.contentBatch) + let batchRows = capture.filter { + $0.kind == .intervalEnded + && $0.name == String(describing: WorkspaceRuntimePerf.Stage.Search.contentBatch) } - let enqueuedBatchCount = batchRows.reduce(0) { $0 + $1.sampleCount } + let enqueuedBatchCount = batchRows.count let scannedFileCount = try batchRows.reduce(into: 0) { count, row in - let batchScannedFileCount = try XCTUnwrap( - dimensionInt("scannedFileCount", in: row.sanitizedDimensions) - ) - count += batchScannedFileCount * row.sampleCount + let dimensions = try XCTUnwrap(row.fields["dimensions"]) + count += try XCTUnwrap(dimensionInt("scannedFileCount", in: dimensions)) } XCTAssertLessThanOrEqual(enqueuedBatchCount, workerCount) XCTAssertLessThanOrEqual(scannedFileCount, adaptiveScannedWindow) XCTAssertTrue(batchRows.allSatisfy { - dimensionInt("batchSize", in: $0.sanitizedDimensions) == resolvedBatchSize - && dimensionInt("workerCount", in: $0.sanitizedDimensions) == workerCount + let dimensions = $0.fields["dimensions"] ?? "" + return dimensionInt("batchSize", in: dimensions) == resolvedBatchSize + && dimensionInt("workerCount", in: dimensions) == workerCount }) } #endif @@ -432,7 +433,7 @@ final class StoreBackedWorkspaceSearchTests: XCTestCase { maxMatches: 25, rootScope: .visibleWorkspace, store: store, - workspaceManager: nil + readinessSource: nil ) let expected = (0 ..< 25).map { root.appendingPathComponent(String(format: "File-%03d.swift", $0)).path @@ -574,19 +575,22 @@ final class StoreBackedWorkspaceSearchTests: XCTestCase { } let saturated = await enteredCount.waitUntilValue(atLeast: workerLimit) XCTAssertTrue(saturated) - _ = startedCapture(label: "store-backed-search-worker-correlation", maxSamples: 100) - let correlation = try XCTUnwrap(EditFlowPerf.makeLifecycleCorrelationIfActive()) + let diagnostics = RecordingWorkspaceRuntimeDiagnosticsSink() + let correlationID = UUID() let searchTask = Task { - try await EditFlowPerf.$currentLifecycleCorrelation.withValue(correlation) { - try await self.searchContent( - pattern: "inheritedCorrelationNeedle", - store: store - ) + try await WorkspaceRuntimePerf.withSink(diagnostics) { + try await WorkspaceRuntimePerf.withLifecycleCorrelation(id: correlationID) { + try await self.searchContent( + pattern: "inheritedCorrelationNeedle", + store: store + ) + } } } let waitBegan = await waitForLifecycleEvent( "FileSystem.ContentReadWorkerPermitWaitBegan", - correlationID: correlation.id + correlationID: correlationID, + diagnostics: diagnostics ) XCTAssertTrue(waitBegan) @@ -596,17 +600,19 @@ final class StoreBackedWorkspaceSearchTests: XCTestCase { } let results = try await searchTask.value XCTAssertEqual(results.matches?.count, 1) - let snapshot = EditFlowPerf.debugCaptureSnapshot(finish: true) - let workerEvents = snapshot.lifecycleEvents.filter { - $0.correlationID == correlation.id.uuidString && - $0.eventName.hasPrefix("FileSystem.ContentReadWorker") + let workerEvents = diagnostics.snapshot().filter { + $0.kind == .lifecycle + && $0.correlationID == correlationID + && $0.name.hasPrefix("FileSystem.ContentReadWorker") } - XCTAssertEqual(workerEvents.map(\.eventName), [ + XCTAssertEqual(workerEvents.map(\.name), [ "FileSystem.ContentReadWorkerPermitWaitBegan", "FileSystem.ContentReadWorkerPermitAcquired", "FileSystem.ContentReadWorkerReturned" ]) - XCTAssertTrue(workerEvents.allSatisfy { $0.sanitizedDimensions.contains("workloadClass=contentSearch") }) + XCTAssertTrue(workerEvents.allSatisfy { + ($0.fields["dimensions"] ?? "").contains("workloadClass=contentSearch") + }) await holdingService.setContentReadChunkHandlerForTesting(nil) } @@ -615,37 +621,47 @@ final class StoreBackedWorkspaceSearchTests: XCTestCase { try write("let revisionIdentityToken = true\n", to: root.appendingPathComponent("A.swift")) let store = WorkspaceFileContextStore() _ = try await store.loadRoot(path: root.path) - _ = startedCapture(label: "store-backed-revision-identity", maxSamples: 200) - let cold = try await searchContent( - pattern: "revisionIdentityToken", - store: store - ) - let warm = try await searchContent( - pattern: "revisionIdentityToken", - store: store - ) - let capture = EditFlowPerf.debugCaptureSnapshot(finish: true) - let lineIndexRows = capture.stages.filter { - $0.stageName == String(describing: EditFlowPerf.Stage.Search.lineIndexLookup) + let diagnostics = RecordingWorkspaceRuntimeDiagnosticsSink() + let (cold, warm) = try await WorkspaceRuntimePerf.withSink(diagnostics) { + let cold = try await searchContent( + pattern: "revisionIdentityToken", + store: store + ) + let warm = try await searchContent( + pattern: "revisionIdentityToken", + store: store + ) + return (cold, warm) + } + let capture = diagnostics.snapshot() + let lineIndexRows = capture.filter { + $0.kind == .intervalEnded + && $0.name == String(describing: WorkspaceRuntimePerf.Stage.Search.lineIndexLookup) } let cache = await store.searchDecodedContentCacheSnapshotForTesting() XCTAssertEqual(cold.matches?.count, 1) XCTAssertEqual(warm.matches, cold.matches) XCTAssertFalse(lineIndexRows.isEmpty) - XCTAssertTrue(lineIndexRows.allSatisfy { $0.sanitizedDimensions.contains("scanKind=revision") }) - XCTAssertTrue(lineIndexRows.allSatisfy { !$0.sanitizedDimensions.contains("hash-fallback") }) + XCTAssertTrue(lineIndexRows.allSatisfy { + ($0.fields["dimensions"] ?? "").contains("scanKind=revision") + }) + XCTAssertTrue(lineIndexRows.allSatisfy { + !($0.fields["dimensions"] ?? "").contains("hash-fallback") + }) XCTAssertEqual( - capture.stages - .filter { $0.stageName == String(describing: EditFlowPerf.Stage.FileSystem.contentReadWorkerPermitWait) } - .reduce(0) { $0 + $1.sampleCount }, + capture.count(where: { + $0.kind == .intervalEnded + && $0.name == String(describing: WorkspaceRuntimePerf.Stage.FileSystem.contentReadWorkerPermitWait) + }), 1, "The warm decoded-content hit must not enter filesystem read admission" ) XCTAssertEqual( - capture.stages - .filter { $0.stageName == String(describing: EditFlowPerf.Stage.FileSystem.contentReadWorkerBody) } - .reduce(0) { $0 + $1.sampleCount }, + capture.count(where: { + $0.kind == .intervalEnded + && $0.name == String(describing: WorkspaceRuntimePerf.Stage.FileSystem.contentReadWorkerBody) + }), 1, "Only the cold miss should execute a disk-read worker body" ) @@ -715,7 +731,7 @@ final class StoreBackedWorkspaceSearchTests: XCTestCase { mode: .content, rootScope: scope, store: store, - workspaceManager: nil + readinessSource: nil ) XCTFail("Expected unavailable session worktree error") } catch let error as StoreBackedWorkspaceSearchError { @@ -756,7 +772,7 @@ final class StoreBackedWorkspaceSearchTests: XCTestCase { mode: .content, rootScope: scope, store: store, - workspaceManager: nil + readinessSource: nil ) } await assertAsyncTrue(waitForAdmissionWaiterCount(1, store: store)) @@ -782,7 +798,7 @@ final class StoreBackedWorkspaceSearchTests: XCTestCase { func testSearchScopeParserKeepsRequiredResolutionOrder() throws { let source = try String( - contentsOf: RepoRoot.url().appendingPathComponent("Sources/RepoPrompt/Features/Search/StoreBackedWorkspaceSearch.swift"), + contentsOf: RepoRoot.url().appendingPathComponent("Sources/RepoPromptCore/WorkspaceContext/Search/StoreBackedWorkspaceSearch.swift"), encoding: .utf8 ) try assertOrdered([ @@ -805,7 +821,7 @@ final class StoreBackedWorkspaceSearchTests: XCTestCase { paths: paths, rootScope: .visibleWorkspace, store: store, - workspaceManager: nil + readinessSource: nil ) } @@ -821,7 +837,7 @@ final class StoreBackedWorkspaceSearchTests: XCTestCase { maxPaths: 100, rootScope: .visibleWorkspace, store: store, - workspaceManager: nil + readinessSource: nil ) } @@ -843,7 +859,7 @@ final class StoreBackedWorkspaceSearchTests: XCTestCase { countOnly: countOnly, rootScope: .visibleWorkspace, store: store, - workspaceManager: nil + readinessSource: nil ) } @@ -891,14 +907,14 @@ final class StoreBackedWorkspaceSearchTests: XCTestCase { private func waitForLifecycleEvent( _ eventName: String, correlationID: UUID, + diagnostics: RecordingWorkspaceRuntimeDiagnosticsSink, timeoutNanoseconds: UInt64 = 1_000_000_000 ) async -> Bool { let interval: UInt64 = 10_000_000 var waited: UInt64 = 0 while waited < timeoutNanoseconds { - let snapshot = EditFlowPerf.debugCaptureSnapshot(finish: false) - if snapshot.lifecycleEvents.contains(where: { - $0.eventName == eventName && $0.correlationID == correlationID.uuidString + if diagnostics.snapshot().contains(where: { + $0.kind == .lifecycle && $0.name == eventName && $0.correlationID == correlationID }) { return true } @@ -916,15 +932,6 @@ final class StoreBackedWorkspaceSearchTests: XCTestCase { return Int(component.dropFirst(prefix.count)) } - private func startedCapture(label: String, maxSamples: Int) -> EditFlowPerf.DebugCaptureSnapshot { - switch EditFlowPerf.beginDebugCapture(label: label, maxSamples: maxSamples) { - case let .started(snapshot): - return snapshot - case .busy: - XCTFail("Capture should start.") - fatalError("Capture should start.") - } - } #endif private func assertOrdered(_ needles: [String], in source: String) throws { @@ -1026,7 +1033,7 @@ final class StoreBackedWorkspaceSearchTests: XCTestCase { private func makeTemporaryRoot(name: String) throws -> URL { let url = FileManager.default.temporaryDirectory - .appendingPathComponent("RepoPromptTests", isDirectory: true) + .appendingPathComponent("RepoPromptCoreTests", isDirectory: true) .appendingPathComponent("\(name)-\(UUID().uuidString)", isDirectory: true) try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true) temporaryRoots.append(url) @@ -1035,7 +1042,7 @@ final class StoreBackedWorkspaceSearchTests: XCTestCase { private func makeHomeTemporaryRoot(name: String) throws -> URL { let url = FileManager.default.homeDirectoryForCurrentUser - .appendingPathComponent(".RepoPromptTests-\(name)-\(UUID().uuidString)", isDirectory: true) + .appendingPathComponent(".RepoPromptCoreTests-\(name)-\(UUID().uuidString)", isDirectory: true) try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true) temporaryRoots.append(url) return url @@ -1046,3 +1053,20 @@ final class StoreBackedWorkspaceSearchTests: XCTestCase { try content.write(to: url, atomically: true, encoding: .utf8) } } + +private final class RecordingWorkspaceRuntimeDiagnosticsSink: WorkspaceRuntimeDiagnosticsSink, @unchecked Sendable { + private let lock = NSLock() + private var events: [WorkspaceRuntimeDiagnosticEvent] = [] + + func record(_ event: WorkspaceRuntimeDiagnosticEvent) { + lock.lock() + events.append(event) + lock.unlock() + } + + func snapshot() -> [WorkspaceRuntimeDiagnosticEvent] { + lock.lock() + defer { lock.unlock() } + return events + } +} diff --git a/Tests/RepoPromptTests/WorkspaceContext/Search/WorkspaceSearchServiceTests.swift b/Tests/RepoPromptCoreTests/WorkspaceContext/Search/WorkspaceSearchServiceTests.swift similarity index 99% rename from Tests/RepoPromptTests/WorkspaceContext/Search/WorkspaceSearchServiceTests.swift rename to Tests/RepoPromptCoreTests/WorkspaceContext/Search/WorkspaceSearchServiceTests.swift index ac2d087bc..13a31815b 100644 --- a/Tests/RepoPromptTests/WorkspaceContext/Search/WorkspaceSearchServiceTests.swift +++ b/Tests/RepoPromptCoreTests/WorkspaceContext/Search/WorkspaceSearchServiceTests.swift @@ -1,4 +1,4 @@ -@testable import RepoPrompt +@testable import RepoPromptCore import XCTest final class WorkspaceSearchServiceTests: XCTestCase { diff --git a/Tests/RepoPromptCoreTests/WorkspaceContext/Selection/WorkspaceSelectionControllerTests.swift b/Tests/RepoPromptCoreTests/WorkspaceContext/Selection/WorkspaceSelectionControllerTests.swift new file mode 100644 index 000000000..a374683e9 --- /dev/null +++ b/Tests/RepoPromptCoreTests/WorkspaceContext/Selection/WorkspaceSelectionControllerTests.swift @@ -0,0 +1,146 @@ +import Foundation +@testable import RepoPromptCore +import XCTest + +@MainActor +final class WorkspaceSelectionControllerTests: XCTestCase { + func testPersistActiveSelectionUsesSessionAuthorityAndAllocatesRevision() throws { + let session = makeSessionController() + let workspace = makeSlice1Workspace(selection: StoredSelection(selectedPaths: ["/tmp/root/Before.swift"])) + let tabID = try XCTUnwrap(workspace.activeComposeTabID) + session.replaceAll([workspace], activeWorkspaceID: workspace.id) + let controller = makeSelectionController(session: session) + var changes: [WorkspaceSelectionController.Change] = [] + let token = controller.observe { changes.append($0) } + let next = StoredSelection(selectedPaths: ["/tmp/root/After.swift"], codemapAutoEnabled: false) + + controller.persistActiveSelection(next) + + XCTAssertEqual(session.workspace(id: workspace.id)?.composeTabs.first?.selection, next) + XCTAssertGreaterThan(session.selectionRevision(workspaceID: workspace.id, tabID: tabID), 0) + XCTAssertEqual(changes, [.init(tabID: tabID, selection: next, source: .runtimeMutation)]) + token.cancel() + } + + func testDirectSessionSelectionMutationPublishesMirrorChange() throws { + let session = makeSessionController() + let workspace = makeSlice1Workspace() + let tabID = try XCTUnwrap(workspace.activeComposeTabID) + session.replaceAll([workspace], activeWorkspaceID: workspace.id) + let controller = makeSelectionController(session: session) + var changes: [WorkspaceSelectionController.Change] = [] + let token = controller.observe { changes.append($0) } + let next = StoredSelection(selectedPaths: ["/tmp/root/Direct.swift"]) + + session.mutateComposeTab(workspaceID: workspace.id, tabID: tabID) { $0.selection = next } + + XCTAssertEqual(changes, [.init(tabID: tabID, selection: next, source: .mirror)]) + token.cancel() + } + + func testExternalUICommitPublishesUIFlushAndAllocatesRevision() throws { + let session = makeSessionController() + let workspace = makeSlice1Workspace() + let tabID = try XCTUnwrap(workspace.activeComposeTabID) + session.replaceAll([workspace], activeWorkspaceID: workspace.id) + let controller = makeSelectionController(session: session) + var changes: [WorkspaceSelectionController.Change] = [] + let token = controller.observe { changes.append($0) } + let pending = try XCTUnwrap(controller.beginExternallyCommittedSelection(source: .uiFlush)) + let next = StoredSelection(selectedPaths: ["/tmp/root/UI.swift"]) + + session.mutateComposeTab( + workspaceID: workspace.id, + tabID: tabID, + options: .hydration + ) { $0.selection = next } + controller.finishExternallyCommittedSelection(target: pending.target, previous: pending.previous) + + XCTAssertEqual(changes, [.init(tabID: tabID, selection: next, source: .uiFlush)]) + XCTAssertGreaterThan(session.selectionRevision(workspaceID: workspace.id, tabID: tabID), 0) + token.cancel() + } + + func testActiveSelectionIsScopedByWorkspaceWhenTabIDsCollide() throws { + let sharedTabID = UUID() + let firstTab = ComposeTabState(id: sharedTabID, name: "First", selection: StoredSelection(selectedPaths: ["/tmp/first.swift"])) + let secondTab = ComposeTabState(id: sharedTabID, name: "Second", selection: StoredSelection(selectedPaths: ["/tmp/second.swift"])) + let first = WorkspaceModel(name: "First", repoPaths: ["/tmp/first"], composeTabs: [firstTab], activeComposeTabID: sharedTabID) + let second = WorkspaceModel(name: "Second", repoPaths: ["/tmp/second"], composeTabs: [secondTab], activeComposeTabID: sharedTabID) + let session = makeSessionController() + session.replaceAll([first, second], activeWorkspaceID: second.id) + let controller = makeSelectionController(session: session) + + let snapshot = controller.activeSelectionSnapshot() + + XCTAssertEqual(snapshot.tabID, sharedTabID) + XCTAssertEqual(snapshot.selection, secondTab.selection) + XCTAssertEqual(controller.target(forTabID: sharedTabID), .init(workspaceID: second.id, tabID: sharedTabID)) + } + + func testAmbiguousInactiveVirtualTabIDIsRejected() { + let duplicateID = UUID() + let firstActive = ComposeTabState(name: "First Active") + let secondActive = ComposeTabState(name: "Second Active") + let first = WorkspaceModel( + name: "First", + repoPaths: ["/tmp/first"], + composeTabs: [firstActive, ComposeTabState(id: duplicateID, name: "Duplicate")], + activeComposeTabID: firstActive.id + ) + let second = WorkspaceModel( + name: "Second", + repoPaths: ["/tmp/second"], + composeTabs: [secondActive, ComposeTabState(id: duplicateID, name: "Duplicate")], + activeComposeTabID: secondActive.id + ) + let session = makeSessionController() + session.replaceAll([first, second], activeWorkspaceID: second.id) + let controller = makeSelectionController(session: session) + let attempted = StoredSelection(selectedPaths: ["/tmp/ambiguous.swift"]) + + XCTAssertNil(controller.target(forTabID: duplicateID)) + controller.persistVirtualSelection(attempted, for: duplicateID) + XCTAssertNotEqual(session.workspace(id: first.id)?.composeTabs.last?.selection, attempted) + XCTAssertNotEqual(session.workspace(id: second.id)?.composeTabs.last?.selection, attempted) + } + + func testObserverCanCancelDuringPublication() throws { + let session = makeSessionController() + let workspace = makeSlice1Workspace() + let tabID = try XCTUnwrap(workspace.activeComposeTabID) + session.replaceAll([workspace], activeWorkspaceID: workspace.id) + let controller = makeSelectionController(session: session) + var calls = 0 + var token: WorkspaceSelectionObservationToken? + token = controller.observe { _ in + calls += 1 + token?.cancel() + } + + session.mutateComposeTab(workspaceID: workspace.id, tabID: tabID) { + $0.selection = StoredSelection(selectedPaths: ["/tmp/one.swift"]) + } + session.mutateComposeTab(workspaceID: workspace.id, tabID: tabID) { + $0.selection = StoredSelection(selectedPaths: ["/tmp/two.swift"]) + } + + XCTAssertEqual(calls, 1) + } + + private func makeSelectionController(session: WorkspaceSessionController) -> WorkspaceSelectionController { + WorkspaceSelectionController( + sessionController: session, + mutationService: WorkspaceSelectionMutationService(store: WorkspaceFileContextStore()) + ) + } + + private func makeSessionController() -> WorkspaceSessionController { + let graph = Slice1TestWorkspaceGraph(root: FileManager.default.temporaryDirectory) + return WorkspaceSessionController( + repository: graph.repository, + persistenceWriter: graph.writer, + accessPolicy: UnrestrictedWorkspaceAccessPolicy() + ) + } +} diff --git a/Tests/RepoPromptTests/WorkspaceContext/Slices/SelectionSlicePersistenceAndRebaseTests.swift b/Tests/RepoPromptCoreTests/WorkspaceContext/Slices/SelectionSlicePersistenceAndRebaseTests.swift similarity index 99% rename from Tests/RepoPromptTests/WorkspaceContext/Slices/SelectionSlicePersistenceAndRebaseTests.swift rename to Tests/RepoPromptCoreTests/WorkspaceContext/Slices/SelectionSlicePersistenceAndRebaseTests.swift index 44545a87f..10de4d09e 100644 --- a/Tests/RepoPromptTests/WorkspaceContext/Slices/SelectionSlicePersistenceAndRebaseTests.swift +++ b/Tests/RepoPromptCoreTests/WorkspaceContext/Slices/SelectionSlicePersistenceAndRebaseTests.swift @@ -1,4 +1,4 @@ -@testable import RepoPrompt +@testable import RepoPromptCore import XCTest final class SelectionSlicePersistenceAndRebaseTests: XCTestCase { diff --git a/Tests/RepoPromptCoreTests/WorkspaceContext/TokenAccounting/TokenCalculationServiceTests.swift b/Tests/RepoPromptCoreTests/WorkspaceContext/TokenAccounting/TokenCalculationServiceTests.swift new file mode 100644 index 000000000..bad8ecca5 --- /dev/null +++ b/Tests/RepoPromptCoreTests/WorkspaceContext/TokenAccounting/TokenCalculationServiceTests.swift @@ -0,0 +1,579 @@ +import Foundation +@testable import RepoPromptCore +import XCTest + +final class TokenCalculationServiceTests: XCTestCase { + func testEstimateTokensUsesUTF8BytesAndSafetyMultiplier() { + XCTAssertEqual(TokenCalculationService.estimateTokens(for: ""), 0) + XCTAssertEqual(TokenCalculationService.estimateTokens(for: "1234"), 1) + XCTAssertEqual(TokenCalculationService.estimateTokens(for: "éé"), 1) + XCTAssertEqual(TokenCalculationService.estimateTokens(for: String(repeating: "a", count: 40)), 10) + } + + func testMiddleTruncateIsDeterministicIdempotentAndUnicodeSafe() { + let text = String(repeating: "🙂abcdef", count: 100) + let truncated = TokenCalculationService.middleTruncate(text: text, maxTokens: 30) + + XCTAssertTrue(truncated.contains("[content truncated]")) + XCTAssertLessThan(truncated.utf8.count, text.utf8.count) + XCTAssertEqual(TokenCalculationService.middleTruncate(text: truncated, maxTokens: 30), truncated) + XCTAssertNotNil(truncated.data(using: .utf8)) + } + + func testComponentBreakdownPreservesDuplicatePromptAndNonFileTotals() { + let breakdown = TokenCalculationService.calculateComponentBreakdown( + promptText: "12345678", + selectedInstructionsText: "1234", + fileTreeText: "12345678", + gitDiffText: "1234", + metadataText: "1234", + duplicateUserInstructionsAtTop: true + ) + + XCTAssertEqual(breakdown.prompt, 2) + XCTAssertEqual(breakdown.duplicatePrompt, 2) + XCTAssertEqual(breakdown.instructions, 1) + XCTAssertEqual(breakdown.fileTree, 2) + XCTAssertEqual(breakdown.gitDiff, 1) + XCTAssertEqual(breakdown.metadata, 1) + XCTAssertEqual(breakdown.promptDisplay, 4) + XCTAssertEqual(breakdown.totalNonFile, 9) + } + + func testPromptEntryEvaluationDistinguishesFullSliceAndCodemapModes() async { + let service = TokenCalculationService() + let fullID = UUID() + let sliceID = UUID() + let codemapID = UUID() + let entries = [ + PromptFileEntrySnapshot( + fileID: fullID, + relativePath: "Full.swift", + isCodemapRequested: false, + ranges: nil, + cachedFullTokenCount: nil, + loadedContent: "one\ntwo\nthree\n", + codeMapContent: nil, + availableCodeMapTokenCount: 0 + ), + PromptFileEntrySnapshot( + fileID: sliceID, + relativePath: "Slice.swift", + isCodemapRequested: false, + ranges: [LineRange(start: 2, end: 2)], + cachedFullTokenCount: nil, + loadedContent: "one\ntwo\nthree\n", + codeMapContent: nil, + availableCodeMapTokenCount: 0 + ), + PromptFileEntrySnapshot( + fileID: codemapID, + relativePath: "Map.swift", + isCodemapRequested: true, + ranges: nil, + cachedFullTokenCount: 20, + loadedContent: nil, + codeMapContent: "struct Map {}", + availableCodeMapTokenCount: 4 + ) + ] + + let result = await service.evaluatePromptEntries(entries) + + XCTAssertEqual(result.entryResultsByFileID[fullID]?.renderMode, .full) + XCTAssertEqual(result.entryResultsByFileID[sliceID]?.renderMode, .slice) + XCTAssertEqual(result.entryResultsByFileID[codemapID]?.renderMode, .codemap) + XCTAssertEqual(result.fullCount, 1) + XCTAssertEqual(result.sliceCount, 1) + XCTAssertEqual(result.codemapCount, 1) + XCTAssertEqual(result.codeMapFileCount, 1) + XCTAssertTrue(result.codeMapContent.contains("struct Map")) + } + + func testOverlappingScopedCalculationsCompleteIndependently() async throws { + let gate = TokenCalculationOperationGate() + let service = TokenCalculationService { snapshot in + try await gate.execute(snapshot: snapshot) + } + let firstSnapshot = makeSnapshot(promptText: "first") + let secondSnapshot = makeSnapshot(promptText: "second") + let firstExpected = makeResult(total: 11) + let secondExpected = makeResult(total: 22) + + let firstTask = Task { + try await service.calculatePromptStatsScoped(snapshot: firstSnapshot) + } + await gate.waitUntilStarted("first") + let secondTask = Task { + try await service.calculatePromptStatsScoped(snapshot: secondSnapshot) + } + await gate.waitUntilStarted("second") + + await gate.succeed("second", with: secondExpected) + await gate.succeed("first", with: firstExpected) + + let firstResult = try await firstTask.value + let secondResult = try await secondTask.value + XCTAssertEqual(firstResult.totalTokenCount, 11) + XCTAssertEqual(secondResult.totalTokenCount, 22) + } + + func testScopedCancellationCancelsOnlyTheCancelledCall() async throws { + let gate = TokenCalculationOperationGate() + let service = TokenCalculationService { snapshot in + try await gate.execute(snapshot: snapshot) + } + let cancelledSnapshot = makeSnapshot(promptText: "cancelled") + let survivingSnapshot = makeSnapshot(promptText: "surviving") + let survivingExpected = makeResult(total: 33) + + let cancelledTask = Task { + try await service.calculatePromptStatsScoped(snapshot: cancelledSnapshot) + } + let survivingTask = Task { + try await service.calculatePromptStatsScoped(snapshot: survivingSnapshot) + } + await gate.waitUntilStarted("cancelled") + await gate.waitUntilStarted("surviving") + + cancelledTask.cancel() + await gate.waitUntilCancelled("cancelled") + await gate.succeed("surviving", with: survivingExpected) + + do { + _ = try await cancelledTask.value + XCTFail("Expected scoped cancellation to propagate") + } catch is CancellationError { + // Expected. + } catch { + XCTFail("Expected CancellationError, got \(error)") + } + let survivingResult = try await survivingTask.value + XCTAssertEqual(survivingResult.totalTokenCount, 33) + } + + func testScopedCancellationThrowsWhenOperationReturnsAfterCancellation() async { + let expectedResult = makeResult(total: 66) + let gate = TokenCalculationCancellationIgnoringGate(result: expectedResult) + let service = TokenCalculationService { _ in + await gate.execute() + } + let task = Task { + try await service.calculatePromptStatsScoped(snapshot: makeSnapshot(promptText: "ignores-cancellation")) + } + await gate.waitUntilStarted() + + task.cancel() + await gate.waitUntilCancellationObserved() + + do { + _ = try await task.value + XCTFail("Expected caller cancellation to win over a successful operation result") + } catch is CancellationError { + // Expected. + } catch { + XCTFail("Expected CancellationError, got \(error)") + } + } + + func testAlreadyCancelledScopedCallerCancelsDetachedOperation() async { + let entryGate = TokenCalculationEntryGate() + let operationGate = TokenCalculationOperationGate() + let service = TokenCalculationService { snapshot in + try await operationGate.execute(snapshot: snapshot) + } + let snapshot = makeSnapshot(promptText: "already-cancelled") + let task = Task { + await entryGate.wait() + return try await service.calculatePromptStatsScoped(snapshot: snapshot) + } + + await entryGate.waitUntilWaiting() + task.cancel() + await entryGate.release() + await operationGate.waitUntilStarted("already-cancelled") + await operationGate.waitUntilCancelled("already-cancelled") + + do { + _ = try await task.value + XCTFail("Expected already-cancelled caller to propagate cancellation") + } catch is CancellationError { + // Expected. + } catch { + XCTFail("Expected CancellationError, got \(error)") + } + } + + func testScopedCalculationPropagatesExactOperationError() async { + let gate = TokenCalculationOperationGate() + let service = TokenCalculationService { snapshot in + try await gate.execute(snapshot: snapshot) + } + let snapshot = makeSnapshot(promptText: "failure") + let task = Task { + try await service.calculatePromptStatsScoped(snapshot: snapshot) + } + await gate.waitUntilStarted("failure") + await gate.fail("failure", with: TokenCalculationTestError.expected(47)) + + do { + _ = try await task.value + XCTFail("Expected operation error to propagate") + } catch let error as TokenCalculationTestError { + XCTAssertEqual(error, .expected(47)) + } catch { + XCTFail("Expected TokenCalculationTestError, got \(error)") + } + } + + func testLegacyCalculationRemainsLatestCallWinsAndNonthrowing() async { + let gate = TokenCalculationOperationGate() + let service = TokenCalculationService { snapshot in + try await gate.execute(snapshot: snapshot) + } + let firstSnapshot = makeSnapshot(promptText: "legacy-first") + let secondSnapshot = makeSnapshot(promptText: "legacy-second") + let secondExpected = makeResult(total: 44) + + let firstTask = Task { + await service.calculatePromptStats(snapshot: firstSnapshot) + } + await gate.waitUntilStarted("legacy-first") + let secondTask = Task { + await service.calculatePromptStats(snapshot: secondSnapshot) + } + await gate.waitUntilCancelled("legacy-first") + await gate.waitUntilStarted("legacy-second") + await gate.succeed("legacy-second", with: secondExpected) + + let firstResult = await firstTask.value + let secondResult = await secondTask.value + assertZeroResult(firstResult) + XCTAssertEqual(secondResult.totalTokenCount, 44) + } + + func testLegacyStaleCompletionCannotHideNewerTaskFromShutdown() async { + let gate = TokenCalculationOperationGate() + let service = TokenCalculationService { snapshot in + try await gate.execute(snapshot: snapshot) + } + let firstSnapshot = makeSnapshot(promptText: "stale-first") + let secondSnapshot = makeSnapshot(promptText: "stale-second") + let secondExpected = makeResult(total: 55) + + let firstTask = Task { + await service.calculatePromptStats(snapshot: firstSnapshot) + } + await gate.waitUntilStarted("stale-first") + let secondTask = Task { + await service.calculatePromptStats(snapshot: secondSnapshot) + } + await gate.waitUntilCancelled("stale-first") + await gate.waitUntilStarted("stale-second") + + let firstResult = await firstTask.value + assertZeroResult(firstResult) + await service.shutdown() + + let secondWasCancelled = await gate.wasCancelled("stale-second") + if !secondWasCancelled { + await gate.succeed("stale-second", with: secondExpected) + } + let secondResult = await secondTask.value + XCTAssertTrue(secondWasCancelled) + assertZeroResult(secondResult) + } + + func testScopedAndLegacyProductionCalculationsHaveFieldForFieldParityExcludingUUIDs() async throws { + let service = TokenCalculationService() + let fullID = UUID() + let sliceID = UUID() + let codemapID = UUID() + let snapshot = TokenCalculationSnapshot( + promptText: "User prompt", + selectedInstructionsText: "Selected instructions", + duplicateUserInstructionsAtTop: true, + promptEntries: [ + PromptFileEntrySnapshot( + fileID: fullID, + relativePath: "Sources/Full.swift", + isCodemapRequested: false, + ranges: nil, + cachedFullTokenCount: nil, + loadedContent: "one\ntwo\nthree\n", + codeMapContent: nil, + availableCodeMapTokenCount: 0 + ), + PromptFileEntrySnapshot( + fileID: sliceID, + relativePath: "Sources/Slice.swift", + isCodemapRequested: false, + ranges: [LineRange(start: 2, end: 2)], + cachedFullTokenCount: nil, + loadedContent: "alpha\nbeta\ngamma\n", + codeMapContent: nil, + availableCodeMapTokenCount: 0 + ), + PromptFileEntrySnapshot( + fileID: codemapID, + relativePath: "Sources/Map.swift", + isCodemapRequested: true, + ranges: nil, + cachedFullTokenCount: 20, + loadedContent: nil, + codeMapContent: "struct Map {}", + availableCodeMapTokenCount: 4 + ) + ], + fileTree: .rendered("Sources\n├── Full.swift\n├── Map.swift\n└── Slice.swift") + ) + + let legacy = await service.calculatePromptStats(snapshot: snapshot) + let scoped = try await service.calculatePromptStatsScoped(snapshot: snapshot) + + assertEquivalentResults(legacy, scoped) + } + + private func makeSnapshot(promptText: String) -> TokenCalculationSnapshot { + TokenCalculationSnapshot( + promptText: promptText, + selectedInstructionsText: "", + duplicateUserInstructionsAtTop: false, + promptEntries: [], + fileTree: .none + ) + } + + private func makeResult(total: Int) -> TokenCalculationResult { + TokenCalculationResult( + totalTokenCount: total, + totalTokenCountFilesOnly: total, + fileTokenInfo: [:], + folderTokenInfo: [:], + tokenCountString: "\(total)", + tokenCountFilesOnlyString: "\(total)", + charCount: total, + fileTreeContent: "tree-\(total)", + fileTreeTokenCount: Double(total), + fileTreeTokenCountRaw: total, + codeMapContent: "map-\(total)", + codeMapFileCount: total, + codeMapTokenCount: total + ) + } + + private func assertZeroResult( + _ result: TokenCalculationResult, + file: StaticString = #filePath, + line: UInt = #line + ) { + XCTAssertEqual(result.totalTokenCount, 0, file: file, line: line) + XCTAssertEqual(result.totalTokenCountFilesOnly, 0, file: file, line: line) + XCTAssertTrue(result.fileTokenInfo.isEmpty, file: file, line: line) + XCTAssertTrue(result.folderTokenInfo.isEmpty, file: file, line: line) + XCTAssertEqual(result.tokenCountString, "0.00k", file: file, line: line) + XCTAssertEqual(result.tokenCountFilesOnlyString, "0.00k", file: file, line: line) + XCTAssertEqual(result.charCount, 0, file: file, line: line) + XCTAssertEqual(result.fileTreeContent, "", file: file, line: line) + XCTAssertEqual(result.fileTreeTokenCount, 0, file: file, line: line) + XCTAssertEqual(result.fileTreeTokenCountRaw, 0, file: file, line: line) + XCTAssertEqual(result.codeMapContent, "", file: file, line: line) + XCTAssertEqual(result.codeMapFileCount, 0, file: file, line: line) + XCTAssertEqual(result.codeMapTokenCount, 0, file: file, line: line) + } + + private func assertEquivalentResults( + _ lhs: TokenCalculationResult, + _ rhs: TokenCalculationResult, + file: StaticString = #filePath, + line: UInt = #line + ) { + XCTAssertEqual(lhs.totalTokenCount, rhs.totalTokenCount, file: file, line: line) + XCTAssertEqual(lhs.totalTokenCountFilesOnly, rhs.totalTokenCountFilesOnly, file: file, line: line) + XCTAssertEqual(lhs.tokenCountString, rhs.tokenCountString, file: file, line: line) + XCTAssertEqual(lhs.tokenCountFilesOnlyString, rhs.tokenCountFilesOnlyString, file: file, line: line) + XCTAssertEqual(lhs.charCount, rhs.charCount, file: file, line: line) + XCTAssertEqual(lhs.fileTreeContent, rhs.fileTreeContent, file: file, line: line) + XCTAssertEqual(lhs.fileTreeTokenCount, rhs.fileTreeTokenCount, file: file, line: line) + XCTAssertEqual(lhs.fileTreeTokenCountRaw, rhs.fileTreeTokenCountRaw, file: file, line: line) + XCTAssertEqual(lhs.codeMapContent, rhs.codeMapContent, file: file, line: line) + XCTAssertEqual(lhs.codeMapFileCount, rhs.codeMapFileCount, file: file, line: line) + XCTAssertEqual(lhs.codeMapTokenCount, rhs.codeMapTokenCount, file: file, line: line) + XCTAssertEqual(Set(lhs.fileTokenInfo.keys), Set(rhs.fileTokenInfo.keys), file: file, line: line) + XCTAssertEqual(Set(lhs.folderTokenInfo.keys), Set(rhs.folderTokenInfo.keys), file: file, line: line) + + for key in lhs.fileTokenInfo.keys { + assertEquivalentTokenInfo(lhs.fileTokenInfo[key], rhs.fileTokenInfo[key], file: file, line: line) + } + for key in lhs.folderTokenInfo.keys { + assertEquivalentTokenInfo(lhs.folderTokenInfo[key], rhs.folderTokenInfo[key], file: file, line: line) + } + } + + private func assertEquivalentTokenInfo( + _ lhs: TokenInfo?, + _ rhs: TokenInfo?, + file: StaticString, + line: UInt + ) { + XCTAssertEqual(lhs?.count, rhs?.count, file: file, line: line) + XCTAssertEqual(lhs?.fullCount, rhs?.fullCount, file: file, line: line) + XCTAssertEqual(lhs?.codemapCount, rhs?.codemapCount, file: file, line: line) + XCTAssertEqual(lhs?.formatted, rhs?.formatted, file: file, line: line) + XCTAssertEqual(lhs?.percentage, rhs?.percentage, file: file, line: line) + } +} + +private enum TokenCalculationTestError: Error, Equatable { + case expected(Int) +} + +private actor TokenCalculationCancellationIgnoringGate { + private let result: TokenCalculationResult + private var operationContinuation: CheckedContinuation? + private var isStarted = false + private var didObserveCancellation = false + private var startContinuations: [CheckedContinuation] = [] + private var cancellationContinuations: [CheckedContinuation] = [] + + init(result: TokenCalculationResult) { + self.result = result + } + + func execute() async -> TokenCalculationResult { + await withTaskCancellationHandler { + await withCheckedContinuation { continuation in + isStarted = true + let continuations = startContinuations + startContinuations.removeAll() + continuations.forEach { $0.resume() } + if didObserveCancellation { + continuation.resume() + } else { + operationContinuation = continuation + } + } + } onCancel: { + Task { + await self.observeCancellation() + } + } + return result + } + + func waitUntilStarted() async { + guard !isStarted else { return } + await withCheckedContinuation { continuation in + startContinuations.append(continuation) + } + } + + func waitUntilCancellationObserved() async { + guard !didObserveCancellation else { return } + await withCheckedContinuation { continuation in + cancellationContinuations.append(continuation) + } + } + + private func observeCancellation() { + didObserveCancellation = true + operationContinuation?.resume() + operationContinuation = nil + let continuations = cancellationContinuations + cancellationContinuations.removeAll() + continuations.forEach { $0.resume() } + } +} + +private actor TokenCalculationEntryGate { + private var releaseContinuation: CheckedContinuation? + private var isWaiting = false + private var waitingContinuations: [CheckedContinuation] = [] + + func wait() async { + isWaiting = true + let continuations = waitingContinuations + waitingContinuations.removeAll() + continuations.forEach { $0.resume() } + await withCheckedContinuation { continuation in + releaseContinuation = continuation + } + } + + func waitUntilWaiting() async { + guard !isWaiting else { return } + await withCheckedContinuation { continuation in + waitingContinuations.append(continuation) + } + } + + func release() { + releaseContinuation?.resume() + releaseContinuation = nil + } +} + +private actor TokenCalculationOperationGate { + private var continuations: [String: CheckedContinuation] = [:] + private var startedKeys: Set = [] + private var cancelledKeys: Set = [] + private var startWaiters: [String: [CheckedContinuation]] = [:] + private var cancellationWaiters: [String: [CheckedContinuation]] = [:] + + func execute(snapshot: TokenCalculationSnapshot) async throws -> TokenCalculationResult { + let key = snapshot.promptText + return try await withTaskCancellationHandler { + try await withCheckedThrowingContinuation { continuation in + startedKeys.insert(key) + if cancelledKeys.contains(key) { + continuation.resume(throwing: CancellationError()) + } else { + continuations[key] = continuation + } + resumeStartWaiters(for: key) + } + } onCancel: { + Task { + await self.cancel(key) + } + } + } + + func waitUntilStarted(_ key: String) async { + guard !startedKeys.contains(key) else { return } + await withCheckedContinuation { continuation in + startWaiters[key, default: []].append(continuation) + } + } + + func waitUntilCancelled(_ key: String) async { + guard !cancelledKeys.contains(key) else { return } + await withCheckedContinuation { continuation in + cancellationWaiters[key, default: []].append(continuation) + } + } + + func wasCancelled(_ key: String) -> Bool { + cancelledKeys.contains(key) + } + + func succeed(_ key: String, with result: TokenCalculationResult) { + continuations.removeValue(forKey: key)?.resume(returning: result) + } + + func fail(_ key: String, with error: Error) { + continuations.removeValue(forKey: key)?.resume(throwing: error) + } + + private func cancel(_ key: String) { + cancelledKeys.insert(key) + continuations.removeValue(forKey: key)?.resume(throwing: CancellationError()) + let waiters = cancellationWaiters.removeValue(forKey: key) ?? [] + waiters.forEach { $0.resume() } + } + + private func resumeStartWaiters(for key: String) { + let waiters = startWaiters.removeValue(forKey: key) ?? [] + waiters.forEach { $0.resume() } + } +} diff --git a/Tests/RepoPromptCoreTests/Workspaces/EmbeddedWorkspaceCodecV1Tests.swift b/Tests/RepoPromptCoreTests/Workspaces/EmbeddedWorkspaceCodecV1Tests.swift new file mode 100644 index 000000000..8ac45370f --- /dev/null +++ b/Tests/RepoPromptCoreTests/Workspaces/EmbeddedWorkspaceCodecV1Tests.swift @@ -0,0 +1,99 @@ +import Foundation +@testable import RepoPromptCore +import XCTest + +final class EmbeddedWorkspaceCodecV1Tests: XCTestCase { + func testRoundTripPreservesAppV1CodingKeysAndValues() throws { + let firstContextBuilderPromptID = try XCTUnwrap(UUID(uuidString: "11111111-1111-1111-1111-111111111111")) + let secondContextBuilderPromptID = try XCTUnwrap(UUID(uuidString: "22222222-2222-2222-2222-222222222222")) + let selectedContextBuilderPromptIDs = [secondContextBuilderPromptID, firstContextBuilderPromptID] + let tab = ComposeTabState( + name: "T1", + selection: StoredSelection( + selectedPaths: ["/tmp/root/Full.swift"], + autoCodemapPaths: ["/tmp/root/Structure.swift"], + slices: ["/tmp/root/Sliced.swift": [LineRange(start: 2, end: 4, description: "slice")]], + codemapAutoEnabled: false + ), + promptText: "prompt", + contextBuilder: ContextBuilderTabConfig( + instructions: "discover instructions", + selectedContextBuilderPromptIDs: selectedContextBuilderPromptIDs + ) + ) + let workspace = WorkspaceModel( + id: UUID(), + dateModified: Date(timeIntervalSince1970: 123), + name: "Codec", + repoPaths: ["/tmp/root"], + composeTabs: [tab], + activeComposeTabID: tab.id + ) + let codec = EmbeddedWorkspaceCodecV1() + + let encoded = try codec.encode(workspace) + let json = try XCTUnwrap(String(data: encoded.data, encoding: .utf8)) + let decoded = try codec.decode(encoded.data) + + XCTAssertEqual(encoded.schemaVersion, EmbeddedWorkspaceCodecV1.formatVersion) + XCTAssertTrue(json.contains("\"discover\""), json) + XCTAssertFalse(json.contains("\"contextBuilder\""), json) + let object = try XCTUnwrap(JSONSerialization.jsonObject(with: encoded.data) as? [String: Any]) + let composeTabs = try XCTUnwrap(object["composeTabs"] as? [[String: Any]]) + let discover = try XCTUnwrap(composeTabs.first?["discover"] as? [String: Any]) + XCTAssertEqual( + discover["selectedContextBuilderPromptIDs"] as? [String], + selectedContextBuilderPromptIDs.map(\.uuidString) + ) + XCTAssertEqual(decoded.sourceVersion, EmbeddedWorkspaceCodecV1.formatVersion) + XCTAssertFalse(decoded.requiresRewrite) + XCTAssertEqual(decoded.document.composeTabs.first?.contextBuilder.selectedContextBuilderPromptIDs, selectedContextBuilderPromptIDs) + XCTAssertEqual(decoded.document, workspace) + } + + func testDecodeReportsNormalizationWithoutMutatingBytes() throws { + let workspaceID = UUID() + let payload = Data(""" + { + "id": "\(workspaceID.uuidString)", + "schemaVersion": 1, + "dateModified": 0, + "name": "Legacy", + "repoPaths": ["/tmp/root"], + "currentPromptText": "legacy prompt", + "selectedMetaPromptIDs": [], + "composeTabs": [], + "stashedTabs": [] + } + """.utf8) + + let result = try EmbeddedWorkspaceCodecV1().decode(payload) + + XCTAssertTrue(result.requiresRewrite) + XCTAssertEqual(result.document.composeTabs.count, 1) + XCTAssertEqual(result.document.composeTabs[0].promptText, "legacy prompt") + XCTAssertEqual(result.sourceVersion, EmbeddedWorkspaceCodecV1.formatVersion) + XCTAssertEqual(payload, Data(payload)) + } + + func testMalformedComposeTabsProducesWarningAndNormalizedDefaultTab() throws { + let workspaceID = UUID() + let payload = Data(""" + { + "id": "\(workspaceID.uuidString)", + "schemaVersion": 1, + "dateModified": 0, + "name": "Malformed", + "repoPaths": ["/tmp/root"], + "composeTabs": "not-an-array", + "stashedTabs": [] + } + """.utf8) + + let result = try EmbeddedWorkspaceCodecV1().decode(payload) + + XCTAssertEqual(result.warnings.map(\.code), ["compose_tabs_decode_failed"]) + XCTAssertTrue(result.requiresRewrite) + XCTAssertEqual(result.document.composeTabs.count, 1) + } +} diff --git a/Tests/RepoPromptCoreTests/Workspaces/WorkspacePersistenceWriterTests.swift b/Tests/RepoPromptCoreTests/Workspaces/WorkspacePersistenceWriterTests.swift new file mode 100644 index 000000000..168c7f664 --- /dev/null +++ b/Tests/RepoPromptCoreTests/Workspaces/WorkspacePersistenceWriterTests.swift @@ -0,0 +1,170 @@ +import Foundation +@testable import RepoPromptCore +import XCTest + +final class WorkspacePersistenceWriterTests: XCTestCase { + func testSerializesWritesPerURLAndFlushesThroughReceiptCut() async throws { + let root = try makeTemporaryDirectory() + let url = root.appendingPathComponent("workspace.json") + let diagnostics = RecordingWorkspaceDiagnostics() + let writer = WorkspacePersistenceWriter(diagnostics: diagnostics) + let gate = FirstWorkspaceWriteGate() + await writer.setAtomicWriteGateForTesting { + await gate.waitIfFirstWrite() + } + let first = makeSlice1Workspace(name: "First", promptText: "first") + let second = makeSlice1Workspace(id: first.id, name: "Second", promptText: "second", dateModified: Date(timeIntervalSince1970: 200)) + + _ = try await writer.enqueueWorkspace(first, url: url, metadata: makeSlice1Metadata(for: first)) + await gate.waitUntilFirstWriteStarted() + let secondReceipt = try await writer.enqueueWorkspace(second, url: url, metadata: makeSlice1Metadata(for: second)) + let completionTask = Task { await writer.flush(secondReceipt) } + await Task.yield() + XCTAssertFalse(completionTask.isCancelled) + + await gate.releaseFirstWrite() + let completion = await completionTask.value + await writer.setAtomicWriteGateForTesting(nil) + + XCTAssertTrue(completion.succeeded) + let decoded = try EmbeddedWorkspaceCodecV1().decode(Data(contentsOf: url)).document + XCTAssertEqual(decoded.name, "Second") + XCTAssertEqual(decoded.composeTabs[0].promptText, "second") + XCTAssertEqual(diagnostics.writeBeginSequences, ["1", "2"]) + XCTAssertEqual(diagnostics.writeEndSequences, ["1", "2"]) + } + + func testStaleDateModifiedPayloadCannotReplaceNewerDiskWorkspace() async throws { + let root = try makeTemporaryDirectory() + let url = root.appendingPathComponent("workspace.json") + let writer = WorkspacePersistenceWriter() + let workspaceID = UUID() + let newer = makeSlice1Workspace( + id: workspaceID, + name: "Newer", + promptText: "newer", + dateModified: Date(timeIntervalSince1970: 300) + ) + let older = makeSlice1Workspace( + id: workspaceID, + name: "Older", + promptText: "older", + dateModified: Date(timeIntervalSince1970: 200) + ) + + let newerReceipt = try await writer.enqueueWorkspace(newer, url: url, metadata: makeSlice1Metadata(for: newer)) + _ = await writer.flush(newerReceipt) + let olderReceipt = try await writer.enqueueWorkspace(older, url: url, metadata: makeSlice1Metadata(for: older)) + let completion = await writer.flush(olderReceipt) + + XCTAssertTrue(completion.succeeded) + let decoded = try EmbeddedWorkspaceCodecV1().decode(Data(contentsOf: url)).document + XCTAssertEqual(decoded.name, "Newer") + XCTAssertEqual(decoded.composeTabs[0].promptText, "newer") + } + + func testSuccessfulReplacementClearsSupersededFailure() async throws { + let root = try makeTemporaryDirectory() + let directory = root.appendingPathComponent("created-after-failure", isDirectory: true) + let url = directory.appendingPathComponent("workspace.json") + let writer = WorkspacePersistenceWriter() + let first = makeSlice1Workspace(name: "First") + let firstReceipt = try await writer.enqueueWorkspace(first, url: url, metadata: makeSlice1Metadata(for: first)) + + let firstCompletion = await writer.flush(firstReceipt) + XCTAssertFalse(firstCompletion.succeeded) + + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + let second = makeSlice1Workspace( + id: first.id, + name: "Second", + dateModified: Date(timeIntervalSince1970: 200) + ) + let secondReceipt = try await writer.enqueueWorkspace(second, url: url, metadata: makeSlice1Metadata(for: second)) + let secondCompletion = await writer.flush(secondReceipt) + + XCTAssertTrue(secondCompletion.succeeded) + XCTAssertEqual(try EmbeddedWorkspaceCodecV1().decode(Data(contentsOf: url)).document.name, "Second") + } + + func testEnqueuedWriteRemainsDurableWhenWaitingTaskIsCancelled() async throws { + let root = try makeTemporaryDirectory() + let url = root.appendingPathComponent("workspace.json") + let writer = WorkspacePersistenceWriter() + let workspace = makeSlice1Workspace(name: "Durable") + let receipt = try await writer.enqueueWorkspace(workspace, url: url, metadata: makeSlice1Metadata(for: workspace)) + + let waitingTask = Task { await writer.flush(receipt) } + waitingTask.cancel() + let completion = await waitingTask.value + + XCTAssertTrue(completion.succeeded) + XCTAssertTrue(FileManager.default.fileExists(atPath: url.path)) + XCTAssertEqual(try EmbeddedWorkspaceCodecV1().decode(Data(contentsOf: url)).document.id, workspace.id) + } + + private func makeTemporaryDirectory() throws -> URL { + let url = FileManager.default.temporaryDirectory + .appendingPathComponent("WorkspacePersistenceWriterTests-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true) + addTeardownBlock { try? FileManager.default.removeItem(at: url) } + return url + } +} + +private final class RecordingWorkspaceDiagnostics: WorkspaceRepositoryDiagnosticsSink, @unchecked Sendable { + private let lock = NSLock() + private var diagnostics: [WorkspaceRepositoryDiagnostic] = [] + + var writeBeginSequences: [String] { + eventSequences(named: "workspaceSave.write.begin") + } + + var writeEndSequences: [String] { + eventSequences(named: "workspaceSave.write.end") + } + + func record(_ diagnostic: WorkspaceRepositoryDiagnostic) { + lock.lock() + diagnostics.append(diagnostic) + lock.unlock() + } + + private func eventSequences(named name: String) -> [String] { + lock.lock() + defer { lock.unlock() } + return diagnostics.compactMap { diagnostic in + guard case let .event(eventName, fields) = diagnostic, eventName == name else { return nil } + return fields["sequence"] + } + } +} + +private actor FirstWorkspaceWriteGate { + private var writeCount = 0 + private var firstStarted = false + private var firstReleased = false + private var startWaiters: [CheckedContinuation] = [] + private var releaseWaiters: [CheckedContinuation] = [] + + func waitIfFirstWrite() async { + writeCount += 1 + guard writeCount == 1 else { return } + firstStarted = true + startWaiters.forEach { $0.resume() } + startWaiters.removeAll() + guard !firstReleased else { return } + await withCheckedContinuation { releaseWaiters.append($0) } + } + + func waitUntilFirstWriteStarted() async { + guard !firstStarted else { return } + await withCheckedContinuation { startWaiters.append($0) } + } + + func releaseFirstWrite() { + firstReleased = true + releaseWaiters.forEach { $0.resume() } + releaseWaiters.removeAll() + } +} diff --git a/Tests/RepoPromptCoreTests/Workspaces/WorkspaceRepositoryTests.swift b/Tests/RepoPromptCoreTests/Workspaces/WorkspaceRepositoryTests.swift new file mode 100644 index 000000000..6bfda0649 --- /dev/null +++ b/Tests/RepoPromptCoreTests/Workspaces/WorkspaceRepositoryTests.swift @@ -0,0 +1,186 @@ +import Foundation +@testable import RepoPromptCore +import XCTest + +final class WorkspaceRepositoryTests: XCTestCase { + func testSnapshotPreservesIndexOrderAndSkipsMissingEntries() async throws { + let root = try makeTemporaryDirectory() + let graph = Slice1TestWorkspaceGraph(root: root) + let first = makeSlice1Workspace(name: "First", repoPaths: ["/tmp/first"]) + let missing = makeSlice1Workspace(name: "Missing", repoPaths: ["/tmp/missing"]) + let second = makeSlice1Workspace(name: "Second", repoPaths: ["/tmp/second"]) + try writeWorkspace(first, under: root) + try writeWorkspace(second, under: root) + try writeIndex([entry(first), entry(missing), entry(second)], under: root) + + let inventory = await graph.repository.loadInventory() + + XCTAssertEqual(inventory.entries.map(\.id), [first.id, missing.id, second.id]) + XCTAssertEqual(inventory.workspaces.map(\.id), [first.id, second.id]) + } + + func testSnapshotLoadsCustomStoragePath() async throws { + let root = try makeTemporaryDirectory() + let customRoot = try makeTemporaryDirectory() + let graph = Slice1TestWorkspaceGraph(root: root) + let workspace = WorkspaceModel( + name: "Custom", + repoPaths: ["/tmp/custom"], + customStoragePath: customRoot + ) + try EmbeddedWorkspaceCodecV1().encode(workspace).data.write( + to: customRoot.appendingPathComponent("workspace.json"), + options: .atomic + ) + try writeIndex([entry(workspace)], under: root) + + let snapshot = await graph.repository.loadWorkspaceSnapshotFromDisk() + + XCTAssertEqual(snapshot.map(\.id), [workspace.id]) + XCTAssertEqual(snapshot.first?.repoPaths, ["/tmp/custom"]) + } + + func testNormalizationRequiringLoadDoesNotRewriteDocumentOrIndex() async throws { + let root = try makeTemporaryDirectory() + let graph = Slice1TestWorkspaceGraph(root: root) + let workspaceID = UUID() + let workspaceDirectory = root.appendingPathComponent( + "Workspace-Normalization-\(workspaceID.uuidString)", + isDirectory: true + ) + try FileManager.default.createDirectory(at: workspaceDirectory, withIntermediateDirectories: true) + let workspaceURL = workspaceDirectory.appendingPathComponent("workspace.json") + let payload = """ + { + "id": "\(workspaceID.uuidString)", + "schemaVersion": 1, + "dateModified": 0, + "name": "Normalization", + "repoPaths": ["/tmp/root"], + "composeTabs": [], + "stashedTabs": [] + } + """ + try Data(payload.utf8).write(to: workspaceURL) + try writeIndex([ + WorkspaceIndexEntry( + id: workspaceID, + name: "Normalization", + customStoragePath: nil, + isSystemWorkspace: false, + isHiddenInMenus: false + ) + ], under: root) + let indexURL = root.appendingPathComponent("workspacesIndex.json") + let beforeWorkspace = try Data(contentsOf: workspaceURL) + let beforeIndex = try Data(contentsOf: indexURL) + let beforeModified = try workspaceURL.resourceValues(forKeys: [.contentModificationDateKey]).contentModificationDate + + let inventory = await graph.repository.loadInventory() + + let workspace = try XCTUnwrap(inventory.workspaces.first) + XCTAssertEqual(workspace.composeTabs.count, 1) + XCTAssertTrue(try XCTUnwrap(inventory.decodeResults[workspaceID]).requiresRewrite) + XCTAssertEqual(try Data(contentsOf: workspaceURL), beforeWorkspace) + XCTAssertEqual(try Data(contentsOf: indexURL), beforeIndex) + XCTAssertEqual( + try workspaceURL.resourceValues(forKeys: [.contentModificationDateKey]).contentModificationDate, + beforeModified + ) + } + + func testConcurrentMergingIndexSavesPreserveEveryEntry() async throws { + let root = try makeTemporaryDirectory() + let graph = Slice1TestWorkspaceGraph(root: root) + let gate = RepositoryIndexWriteGate() + await graph.writer.setAtomicWriteGateForTesting { await gate.waitIfFirstWrite() } + let first = makeSlice1Workspace(name: "First") + let second = makeSlice1Workspace(name: "Second") + + let firstReceipt = try await graph.repository.saveIndex([entry(first)], mergingExisting: true) + await gate.waitUntilFirstWriteStarted() + let secondReceipt = try await graph.repository.saveIndex([entry(second)], mergingExisting: true) + let completionTask = Task { await graph.repository.flush(secondReceipt) } + await gate.releaseFirstWrite() + let completion = await completionTask.value + await graph.writer.setAtomicWriteGateForTesting(nil) + + XCTAssertTrue(completion.succeeded) + XCTAssertLessThan(firstReceipt.sequence, secondReceipt.sequence) + let layout = FixedWorkspaceRepositoryLayout(repositoryRoot: root) + let entries = try JSONDecoder().decode([WorkspaceIndexEntry].self, from: Data(contentsOf: layout.indexURL)) + XCTAssertEqual(Set(entries.map(\.id)), Set([first.id, second.id])) + } + + func testExplicitSaveWritesDocumentAndIndexThroughSharedWriter() async throws { + let root = try makeTemporaryDirectory() + let graph = Slice1TestWorkspaceGraph(root: root) + let workspace = makeSlice1Workspace(name: "Explicit", promptText: "saved") + + try await graph.repository.save(workspace) + + let layout = FixedWorkspaceRepositoryLayout(repositoryRoot: root) + let data = try Data(contentsOf: layout.workspaceDocumentURL(id: workspace.id, name: workspace.name)) + let decoded = try EmbeddedWorkspaceCodecV1().decode(data).document + XCTAssertEqual(decoded, workspace) + let entries = try JSONDecoder().decode([WorkspaceIndexEntry].self, from: Data(contentsOf: layout.indexURL)) + XCTAssertEqual(entries.map(\.id), [workspace.id]) + } + + private func entry(_ workspace: WorkspaceModel) -> WorkspaceIndexEntry { + WorkspaceIndexEntry(workspace: workspace) + } + + private func writeWorkspace(_ workspace: WorkspaceModel, under root: URL) throws { + let directory = root.appendingPathComponent("Workspace-\(workspace.name)-\(workspace.id.uuidString)") + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + try EmbeddedWorkspaceCodecV1().encode(workspace).data.write( + to: directory.appendingPathComponent("workspace.json"), + options: .atomic + ) + } + + private func writeIndex(_ entries: [WorkspaceIndexEntry], under root: URL) throws { + try JSONEncoder().encode(entries).write( + to: root.appendingPathComponent("workspacesIndex.json"), + options: .atomic + ) + } + + private func makeTemporaryDirectory() throws -> URL { + let url = FileManager.default.temporaryDirectory + .appendingPathComponent("WorkspaceRepositoryTests-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true) + addTeardownBlock { + try? FileManager.default.removeItem(at: url) + } + return url + } +} + +private actor RepositoryIndexWriteGate { + private var firstStarted = false + private var firstReleased = false + private var startWaiters: [CheckedContinuation] = [] + private var releaseWaiters: [CheckedContinuation] = [] + + func waitIfFirstWrite() async { + guard !firstStarted else { return } + firstStarted = true + startWaiters.forEach { $0.resume() } + startWaiters.removeAll() + guard !firstReleased else { return } + await withCheckedContinuation { releaseWaiters.append($0) } + } + + func waitUntilFirstWriteStarted() async { + guard !firstStarted else { return } + await withCheckedContinuation { startWaiters.append($0) } + } + + func releaseFirstWrite() { + firstReleased = true + releaseWaiters.forEach { $0.resume() } + releaseWaiters.removeAll() + } +} diff --git a/Tests/RepoPromptCoreTests/Workspaces/WorkspaceSelectionPersistenceTests.swift b/Tests/RepoPromptCoreTests/Workspaces/WorkspaceSelectionPersistenceTests.swift new file mode 100644 index 000000000..01ed00b24 --- /dev/null +++ b/Tests/RepoPromptCoreTests/Workspaces/WorkspaceSelectionPersistenceTests.swift @@ -0,0 +1,177 @@ +import Foundation +@testable import RepoPromptCore +import XCTest + +final class WorkspaceSelectionPersistenceTests: XCTestCase { + func testWriterPreservesNewerSelectionRevisionAgainstLaterStalePayload() async throws { + let url = try makeWorkspaceURL() + let writer = WorkspacePersistenceWriter() + let workspaceID = UUID() + let tabID = UUID() + let correct = workspace( + id: workspaceID, + tabID: tabID, + selection: selection(count: 7), + dateModified: Date(timeIntervalSince1970: 100), + promptText: "correct" + ) + let correctReceipt = try await writer.enqueueWorkspace( + correct, + url: url, + metadata: makeSlice1Metadata(for: correct, source: "test.correctSelection", selectionRevision: 2) + ) + _ = await writer.flush(correctReceipt) + + let stale = workspace( + id: workspaceID, + tabID: tabID, + selection: selection(count: 15, includeSlices: true), + dateModified: Date(timeIntervalSince1970: 200), + promptText: "stale-non-selection-field" + ) + let staleReceipt = try await writer.enqueueWorkspace( + stale, + url: url, + metadata: makeSlice1Metadata(for: stale, source: "test.staleSelection", selectionRevision: 1) + ) + _ = await writer.flush(staleReceipt) + + let decoded = try EmbeddedWorkspaceCodecV1().decode(Data(contentsOf: url)).document + XCTAssertEqual(decoded.composeTabs[0].selection, correct.composeTabs[0].selection) + XCTAssertEqual(decoded.composeTabs[0].promptText, "stale-non-selection-field") + } + + func testWriterPreservesNewerInactiveTabSelectionAgainstLaterStalePayload() async throws { + let url = try makeWorkspaceURL() + let writer = WorkspacePersistenceWriter() + let workspaceID = UUID() + let activeTabID = UUID() + let inactiveTabID = UUID() + let correctSelection = selection(count: 7) + let staleSelection = selection(count: 15, includeSlices: true) + let activeTab = ComposeTabState(id: activeTabID, name: "Active") + let correctInactive = ComposeTabState(id: inactiveTabID, name: "Inactive", selection: correctSelection) + let correct = WorkspaceModel( + id: workspaceID, + dateModified: Date(timeIntervalSince1970: 100), + name: "Inactive selection", + repoPaths: ["/tmp/root"], + composeTabs: [activeTab, correctInactive], + activeComposeTabID: activeTabID + ) + let correctMetadata = WorkspaceSavePayloadMetadata( + source: "test.correctInactive", + owner: .none, + workspaceID: workspaceID, + workspaceName: correct.name, + workspaceDateModified: correct.dateModified, + activeTabID: activeTabID, + activeSelectionRevision: 0, + activeSelection: activeTab.selection, + selectionRecords: [ + WorkspaceSaveSelectionRecord(tabID: inactiveTabID, revision: 2, selection: correctSelection) + ] + ) + let correctReceipt = try await writer.enqueueWorkspace(correct, url: url, metadata: correctMetadata) + _ = await writer.flush(correctReceipt) + + var stale = correct + stale.dateModified = Date(timeIntervalSince1970: 200) + stale.composeTabs[1].selection = staleSelection + stale.composeTabs[1].promptText = "newer non-selection field" + let staleMetadata = WorkspaceSavePayloadMetadata( + source: "test.staleInactive", + owner: .none, + workspaceID: workspaceID, + workspaceName: stale.name, + workspaceDateModified: stale.dateModified, + activeTabID: activeTabID, + activeSelectionRevision: 0, + activeSelection: activeTab.selection, + selectionRecords: [ + WorkspaceSaveSelectionRecord(tabID: inactiveTabID, revision: 1, selection: staleSelection) + ] + ) + let staleReceipt = try await writer.enqueueWorkspace(stale, url: url, metadata: staleMetadata) + _ = await writer.flush(staleReceipt) + + let decoded = try EmbeddedWorkspaceCodecV1().decode(Data(contentsOf: url)).document + XCTAssertEqual(decoded.composeTabs[1].selection, correctSelection) + XCTAssertEqual(decoded.composeTabs[1].promptText, "newer non-selection field") + } + + func testWriterMergesNewerSelectionIntoNewerDiskWorkspaceInsteadOfSkipping() async throws { + let url = try makeWorkspaceURL() + let writer = WorkspacePersistenceWriter() + let workspaceID = UUID() + let tabID = UUID() + let staleDisk = workspace( + id: workspaceID, + tabID: tabID, + selection: selection(count: 15, includeSlices: true), + dateModified: Date(timeIntervalSince1970: 300), + promptText: "disk-field" + ) + try EmbeddedWorkspaceCodecV1().encode(staleDisk).data.write(to: url, options: .atomic) + + let incoming = workspace( + id: workspaceID, + tabID: tabID, + selection: selection(count: 7), + dateModified: Date(timeIntervalSince1970: 200), + promptText: "incoming-field" + ) + let receipt = try await writer.enqueueWorkspace( + incoming, + url: url, + metadata: makeSlice1Metadata(for: incoming, source: "test.newerSelectionOlderPayload", selectionRevision: 2) + ) + _ = await writer.flush(receipt) + + let decoded = try EmbeddedWorkspaceCodecV1().decode(Data(contentsOf: url)).document + XCTAssertEqual(decoded.composeTabs[0].selection, incoming.composeTabs[0].selection) + XCTAssertEqual(decoded.composeTabs[0].promptText, "disk-field") + XCTAssertGreaterThan(decoded.dateModified, staleDisk.dateModified) + } + + private func makeWorkspaceURL() throws -> URL { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("WorkspaceSelectionPersistenceTests-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + addTeardownBlock { try? FileManager.default.removeItem(at: directory) } + return directory.appendingPathComponent("workspace.json") + } + + private func workspace( + id: UUID, + tabID: UUID, + selection: StoredSelection, + dateModified: Date, + promptText: String + ) -> WorkspaceModel { + let tab = ComposeTabState(id: tabID, name: "T1", selection: selection, promptText: promptText) + return WorkspaceModel( + id: id, + dateModified: dateModified, + name: "Selection Persistence", + repoPaths: ["/tmp/root"], + composeTabs: [tab], + activeComposeTabID: tabID + ) + } + + private func selection(count: Int, includeSlices: Bool = false) -> StoredSelection { + let paths = (0 ..< count).map { "/tmp/root/file\($0).swift" } + let slices: [String: [LineRange]] = if includeSlices, let first = paths.first { + [first: [LineRange(start: 1, end: 3), LineRange(start: 8, end: 13)]] + } else { + [:] + } + return StoredSelection( + selectedPaths: paths, + autoCodemapPaths: Array(paths.prefix(max(0, count / 3))), + slices: slices, + codemapAutoEnabled: !includeSlices + ) + } +} diff --git a/Tests/RepoPromptCoreTests/Workspaces/WorkspaceSessionControllerTests.swift b/Tests/RepoPromptCoreTests/Workspaces/WorkspaceSessionControllerTests.swift new file mode 100644 index 000000000..6d84a0d38 --- /dev/null +++ b/Tests/RepoPromptCoreTests/Workspaces/WorkspaceSessionControllerTests.swift @@ -0,0 +1,239 @@ +import Foundation +@testable import RepoPromptCore +import XCTest + +@MainActor +final class WorkspaceSessionControllerTests: XCTestCase { + func testReplaceAllPublishesImmutableSnapshotsInGenerationOrder() { + let graph = makeGraph() + let controller = makeController(graph: graph) + let first = makeSlice1Workspace(name: "First") + let second = makeSlice1Workspace(name: "Second") + var snapshots: [WorkspaceSessionSnapshot] = [] + let token = controller.observe { snapshots.append($0) } + + controller.replaceAll([first, second], activeWorkspaceID: second.id) + controller.setActiveWorkspaceID(first.id) + + XCTAssertEqual(snapshots.map(\.generation), [0, 1, 2]) + XCTAssertEqual(snapshots[1].workspaces.map(\.id), [first.id, second.id]) + XCTAssertEqual(snapshots[1].activeWorkspaceID, second.id) + XCTAssertEqual(snapshots[2].activeWorkspaceID, first.id) + token.cancel() + } + + func testWorkspaceActiveAndComposeTabMutationsUseSingleAuthority() throws { + let graph = makeGraph() + let controller = makeController(graph: graph) + let first = makeSlice1Workspace(name: "First") + let second = makeSlice1Workspace(name: "Second") + controller.replaceAll([first, second], activeWorkspaceID: first.id) + let firstTabID = try XCTUnwrap(first.activeComposeTabID) + + controller.mutateWorkspace(id: first.id) { workspace in + workspace.name = "Renamed" + workspace.repoPaths = ["/tmp/A", "/tmp/B"] + } + controller.mutateActiveWorkspace { workspace in + workspace.isHiddenInMenus = true + } + controller.mutateComposeTab(workspaceID: first.id, tabID: firstTabID) { tab in + tab.name = "Focused" + tab.promptText = "updated prompt" + } + + let updated = try XCTUnwrap(controller.workspace(id: first.id)) + XCTAssertEqual(updated.name, "Renamed") + XCTAssertEqual(updated.repoPaths, ["/tmp/A", "/tmp/B"]) + XCTAssertTrue(updated.isHiddenInMenus) + XCTAssertEqual(updated.composeTabs[0].name, "Focused") + XCTAssertEqual(updated.composeTabs[0].promptText, "updated prompt") + XCTAssertEqual(controller.workspaces.map(\.id), [first.id, second.id]) + } + + func testTransactionAtomicallyCreatesReordersDeletesAndSelectsFallback() { + let graph = makeGraph() + let controller = makeController(graph: graph) + let first = makeSlice1Workspace(name: "First") + let second = makeSlice1Workspace(name: "Second") + let third = makeSlice1Workspace(name: "Third") + controller.replaceAll([first, second], activeWorkspaceID: first.id) + let generationBefore = controller.snapshot.generation + + controller.transaction { transaction in + transaction.workspaces.append(third) + transaction.workspaces.swapAt(0, 2) + transaction.workspaces.removeAll { $0.id == second.id } + transaction.activeWorkspaceID = third.id + } + + XCTAssertEqual(controller.snapshot.generation, generationBefore + 1) + XCTAssertEqual(controller.workspaces.map(\.id), [third.id, first.id]) + XCTAssertEqual(controller.activeWorkspaceID, third.id) + XCTAssertNil(controller.workspace(id: second.id)) + } + + func testDirtyGenerationAndRepositoryBaselineAdvanceOnlyForCurrentSave() throws { + let graph = makeGraph() + let controller = makeController(graph: graph) + let workspace = makeSlice1Workspace(repoPaths: ["/tmp/A"]) + controller.replaceAll([workspace], activeWorkspaceID: workspace.id) + + XCTAssertFalse(controller.isDirty(workspaceID: workspace.id)) + XCTAssertFalse(controller.hasLocalRepoPathEdit(workspaceID: workspace.id)) + + controller.mutateWorkspace(id: workspace.id) { $0.repoPaths = ["/tmp/B"] } + let firstGeneration = controller.stateGeneration(workspaceID: workspace.id) + let firstSave = try XCTUnwrap(controller.workspace(id: workspace.id)) + XCTAssertTrue(controller.isDirty(workspaceID: workspace.id)) + XCTAssertTrue(controller.hasLocalRepoPathEdit(workspaceID: workspace.id)) + + controller.mutateWorkspace(id: workspace.id) { $0.name = "Newer local state" } + controller.recordSaveCompletion( + workspaceID: workspace.id, + capturedGeneration: firstGeneration, + persistedWorkspace: firstSave + ) + XCTAssertTrue(controller.isDirty(workspaceID: workspace.id)) + XCTAssertEqual(controller.repositoryBaseline(workspaceID: workspace.id), ["/tmp/A"]) + + let currentGeneration = controller.stateGeneration(workspaceID: workspace.id) + let current = try XCTUnwrap(controller.workspace(id: workspace.id)) + controller.recordSaveCompletion( + workspaceID: workspace.id, + capturedGeneration: currentGeneration, + persistedWorkspace: current + ) + XCTAssertFalse(controller.isDirty(workspaceID: workspace.id)) + } + + func testProcessSharedWriterAllocatesSelectionRevisionsAcrossControllers() throws { + let graph = makeGraph() + let firstController = makeController(graph: graph) + let secondController = makeController(graph: graph) + let workspace = makeSlice1Workspace() + let tabID = try XCTUnwrap(workspace.activeComposeTabID) + firstController.replaceAll([workspace], activeWorkspaceID: workspace.id) + secondController.replaceAll([workspace], activeWorkspaceID: workspace.id) + let initial = max( + firstController.selectionRevision(workspaceID: workspace.id, tabID: tabID), + secondController.selectionRevision(workspaceID: workspace.id, tabID: tabID) + ) + XCTAssertEqual(initial, 0) + + firstController.mutateComposeTab(workspaceID: workspace.id, tabID: tabID) { + $0.selection = StoredSelection(selectedPaths: ["/tmp/root/A.swift"]) + } + let firstRevision = firstController.selectionRevision(workspaceID: workspace.id, tabID: tabID) + secondController.mutateComposeTab(workspaceID: workspace.id, tabID: tabID) { + $0.selection = StoredSelection(selectedPaths: ["/tmp/root/B.swift"]) + } + let secondRevision = secondController.selectionRevision(workspaceID: workspace.id, tabID: tabID) + + XCTAssertGreaterThan(firstRevision, initial) + XCTAssertGreaterThan(secondRevision, firstRevision) + } + + func testStaleControllerHydrationCannotOutrankAuthoritativeSelectionMutation() async throws { + let root = try makeSlice1TemporaryDirectory(named: #function) { url in + self.addTeardownBlock { try? FileManager.default.removeItem(at: url) } + } + let graph = Slice1TestWorkspaceGraph(root: root) + let firstController = makeController(graph: graph) + let staleController = makeController(graph: graph) + let workspace = makeSlice1Workspace() + let tabID = try XCTUnwrap(workspace.activeComposeTabID) + let url = root.appendingPathComponent("workspace.json") + firstController.replaceAll([workspace], activeWorkspaceID: workspace.id) + + firstController.mutateComposeTab(workspaceID: workspace.id, tabID: tabID) { + $0.selection = StoredSelection(selectedPaths: ["/tmp/root/Authoritative.swift"]) + } + let authoritative = try XCTUnwrap(firstController.workspace(id: workspace.id)) + let authoritativeMetadata = firstController.saveMetadata(for: authoritative, source: "authoritative", owner: .none) + let authoritativeReceipt = try await graph.writer.enqueueWorkspace( + authoritative, + url: url, + metadata: authoritativeMetadata + ) + let authoritativeCompletion = await graph.writer.flush(authoritativeReceipt) + XCTAssertTrue(authoritativeCompletion.succeeded) + + staleController.replaceAll([workspace], activeWorkspaceID: workspace.id) + XCTAssertEqual(staleController.selectionRevision(workspaceID: workspace.id, tabID: tabID), 0) + staleController.mutateWorkspace(id: workspace.id) { $0.name = "Newer unrelated edit" } + let stale = try XCTUnwrap(staleController.workspace(id: workspace.id)) + let staleMetadata = staleController.saveMetadata(for: stale, source: "stale", owner: .none) + XCTAssertEqual(staleMetadata.activeSelectionRevision, 0) + let staleReceipt = try await graph.writer.enqueueWorkspace(stale, url: url, metadata: staleMetadata) + let staleCompletion = await graph.writer.flush(staleReceipt) + XCTAssertTrue(staleCompletion.succeeded) + + let persisted = try EmbeddedWorkspaceCodecV1().decode(Data(contentsOf: url)).document + XCTAssertEqual(persisted.name, "Newer unrelated edit") + XCTAssertEqual( + persisted.composeTabs[0].selection.selectedPaths, + ["/tmp/root/Authoritative.swift"] + ) + } + + func testHydrationMutationDoesNotAllocateSelectionRevision() throws { + let graph = makeGraph() + let controller = makeController(graph: graph) + let workspace = makeSlice1Workspace() + let tabID = try XCTUnwrap(workspace.activeComposeTabID) + controller.replaceAll([workspace], activeWorkspaceID: workspace.id) + + controller.mutateComposeTab( + workspaceID: workspace.id, + tabID: tabID, + options: .hydration + ) { $0.selection = StoredSelection(selectedPaths: ["/tmp/root/stale.swift"]) } + + XCTAssertEqual(controller.selectionRevision(workspaceID: workspace.id, tabID: tabID), 0) + } + + func testObserverCanCancelDuringPublication() { + let graph = makeGraph() + let controller = makeController(graph: graph) + let workspace = makeSlice1Workspace() + var calls = 0 + var token: WorkspaceSessionObservationToken? + token = controller.observe { _ in + calls += 1 + if calls > 1 { token?.cancel() } + } + + controller.replaceAll([workspace], activeWorkspaceID: workspace.id) + controller.mutateWorkspace(id: workspace.id) { $0.name = "After cancellation" } + + XCTAssertEqual(calls, 2) + } + + func testBindingCandidatesUseActiveWorkspaceAndAccessPolicy() throws { + let graph = makeGraph() + let controller = makeController(graph: graph) + let workspace = makeSlice1Workspace(name: "Bound", repoPaths: ["/tmp/root"]) + let tabID = try XCTUnwrap(workspace.activeComposeTabID) + controller.replaceAll([workspace], activeWorkspaceID: workspace.id) + + let byContext = try XCTUnwrap(controller.bindingCandidate(forContextID: tabID)) + let byWorkingDirectory = controller.bindingCandidates(matchingWorkingDirs: ["/tmp/root/Sources"]) + + XCTAssertEqual(byContext.workspaceID, workspace.id) + XCTAssertEqual(byContext.repoPaths, ["/tmp/root"]) + XCTAssertEqual(byWorkingDirectory.map(\.tabID), [tabID]) + } + + private func makeGraph() -> Slice1TestWorkspaceGraph { + Slice1TestWorkspaceGraph(root: FileManager.default.temporaryDirectory) + } + + private func makeController(graph: Slice1TestWorkspaceGraph) -> WorkspaceSessionController { + WorkspaceSessionController( + repository: graph.repository, + persistenceWriter: graph.writer, + accessPolicy: UnrestrictedWorkspaceAccessPolicy() + ) + } +} diff --git a/Tests/RepoPromptCoreTests/Workspaces/WorkspaceTestSupport.swift b/Tests/RepoPromptCoreTests/Workspaces/WorkspaceTestSupport.swift new file mode 100644 index 000000000..c89bd089d --- /dev/null +++ b/Tests/RepoPromptCoreTests/Workspaces/WorkspaceTestSupport.swift @@ -0,0 +1,81 @@ +import Foundation +@testable import RepoPromptCore + +struct Slice1TestWorkspaceRootProvider: WorkspaceRepositoryRootProviding, Sendable { + let root: URL + + func repositoryRoot() async -> URL { + root + } +} + +struct Slice1TestWorkspaceGraph { + let writer: WorkspacePersistenceWriter + let repository: WorkspaceRepository + + init( + root: URL, + diagnostics: any WorkspaceRepositoryDiagnosticsSink = NoopWorkspaceRepositoryDiagnosticsSink() + ) { + let codec = EmbeddedWorkspaceCodecV1() + let writer = WorkspacePersistenceWriter(codec: codec, diagnostics: diagnostics) + self.writer = writer + repository = WorkspaceRepository( + rootProvider: Slice1TestWorkspaceRootProvider(root: root), + codec: codec, + writer: writer, + diagnostics: diagnostics, + migrationService: NoopWorkspaceLegacyMigrationService() + ) + } +} + +func makeSlice1TemporaryDirectory( + named name: String, + cleanup: @escaping (URL) -> Void +) throws -> URL { + let url = FileManager.default.temporaryDirectory + .appendingPathComponent("\(name)-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true) + cleanup(url) + return url +} + +func makeSlice1Metadata( + for workspace: WorkspaceModel, + source: WorkspaceSaveSource = "test", + selectionRevision: UInt64 = 0 +) -> WorkspaceSavePayloadMetadata { + let activeTab = workspace.activeComposeTabID.flatMap { id in + workspace.composeTabs.first(where: { $0.id == id }) + } + return WorkspaceSavePayloadMetadata( + source: source, + owner: .none, + workspaceID: workspace.id, + workspaceName: workspace.name, + workspaceDateModified: workspace.dateModified, + activeTabID: activeTab?.id, + activeSelectionRevision: selectionRevision, + activeSelection: activeTab?.selection + ) +} + +func makeSlice1Workspace( + id: UUID = UUID(), + name: String = "Workspace", + repoPaths: [String] = ["/tmp/root"], + selection: StoredSelection = StoredSelection(), + promptText: String = "", + dateModified: Date = Date(timeIntervalSince1970: 100) +) -> WorkspaceModel { + let tab = ComposeTabState(name: "T1", selection: selection, promptText: promptText) + return WorkspaceModel( + id: id, + dateModified: dateModified, + name: name, + repoPaths: repoPaths, + composeTabs: [tab], + activeComposeTabID: tab.id + ) +} diff --git a/Tests/RepoPromptHeadlessTests/HeadlessExportWriterTests.swift b/Tests/RepoPromptHeadlessTests/HeadlessExportWriterTests.swift new file mode 100644 index 000000000..153b15ecf --- /dev/null +++ b/Tests/RepoPromptHeadlessTests/HeadlessExportWriterTests.swift @@ -0,0 +1,105 @@ +import Foundation +@testable import RepoPromptHeadless +import XCTest + +final class HeadlessExportWriterTests: XCTestCase { + private var temporaryDirectories: [URL] = [] + + override func tearDownWithError() throws { + for directory in temporaryDirectories { + try? FileManager.default.removeItem(at: directory) + } + temporaryDirectories.removeAll() + } + + func testInStateExportRemainsAnchoredWhenParentIsReplacedAfterOpen() throws { + let directory = try makeTemporaryDirectory() + let paths = HeadlessStatePaths(rootDirectory: directory.appendingPathComponent("State", isDirectory: true)) + try paths.ensureBaseDirectories() + let exportParent = paths.exportsDirectory.appendingPathComponent("Nested", isDirectory: true) + try HeadlessStateFileSecurity.ensurePrivateDirectory(at: exportParent, stateRoot: paths.rootDirectory) + let movedParent = directory.appendingPathComponent("MovedNested", isDirectory: true) + let replacementParent = directory.appendingPathComponent("Replacement", isDirectory: true) + try FileManager.default.createDirectory(at: replacementParent, withIntermediateDirectories: true) + let replacementTarget = replacementParent.appendingPathComponent("export.md") + try Data("outside".utf8).write(to: replacementTarget) + + _ = try HeadlessExportWriter(paths: paths).write( + Data("anchored".utf8), + to: "Nested/export.md", + defaultFileName: "unused.md", + permissions: HeadlessPermissions(), + inStateParentDirectoryOpenedHook: { _ in + try FileManager.default.moveItem(at: exportParent, to: movedParent) + try FileManager.default.createSymbolicLink( + atPath: exportParent.path, + withDestinationPath: replacementParent.path + ) + } + ) + + XCTAssertEqual( + try String(contentsOf: movedParent.appendingPathComponent("export.md"), encoding: .utf8), + "anchored" + ) + XCTAssertEqual(try String(contentsOf: replacementTarget, encoding: .utf8), "outside") + } + + func testAuthorizedExternalExportRemainsAnchoredWhenParentIsReplacedAfterOpen() throws { + let directory = try makeTemporaryDirectory() + let paths = HeadlessStatePaths(rootDirectory: directory.appendingPathComponent("State", isDirectory: true)) + let exportParent = directory.appendingPathComponent("External", isDirectory: true) + let movedParent = directory.appendingPathComponent("MovedExternal", isDirectory: true) + let replacementParent = directory.appendingPathComponent("Replacement", isDirectory: true) + try FileManager.default.createDirectory(at: exportParent, withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: replacementParent, withIntermediateDirectories: true) + let replacementTarget = replacementParent.appendingPathComponent("export.md") + try Data("outside".utf8).write(to: replacementTarget) + + _ = try HeadlessExportWriter(paths: paths).write( + Data("anchored".utf8), + to: exportParent.appendingPathComponent("export.md").path, + defaultFileName: "unused.md", + permissions: HeadlessPermissions(exportOutsideStateDirectory: true), + externalParentDirectoryOpenedHook: { _ in + try FileManager.default.moveItem(at: exportParent, to: movedParent) + try FileManager.default.createSymbolicLink( + atPath: exportParent.path, + withDestinationPath: replacementParent.path + ) + } + ) + + XCTAssertEqual( + try String(contentsOf: movedParent.appendingPathComponent("export.md"), encoding: .utf8), + "anchored" + ) + XCTAssertEqual(try String(contentsOf: replacementTarget, encoding: .utf8), "outside") + } + + func testExportRejectsExistingSymlinkWithoutChangingDestination() throws { + let directory = try makeTemporaryDirectory() + let paths = HeadlessStatePaths(rootDirectory: directory.appendingPathComponent("State", isDirectory: true)) + try paths.ensureBaseDirectories() + let outside = directory.appendingPathComponent("outside.md") + try Data("outside".utf8).write(to: outside) + let link = paths.exportsDirectory.appendingPathComponent("linked.md") + try FileManager.default.createSymbolicLink(atPath: link.path, withDestinationPath: outside.path) + + XCTAssertThrowsError(try HeadlessExportWriter(paths: paths).write( + Data("replacement".utf8), + to: "linked.md", + defaultFileName: "unused.md", + permissions: HeadlessPermissions() + )) + XCTAssertEqual(try String(contentsOf: outside, encoding: .utf8), "outside") + } + + private func makeTemporaryDirectory() throws -> URL { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("RepoPromptHeadlessExportWriterTests-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + temporaryDirectories.append(directory) + return directory + } +} diff --git a/Tests/RepoPromptHeadlessTests/HeadlessMCPServerLifecycleTests.swift b/Tests/RepoPromptHeadlessTests/HeadlessMCPServerLifecycleTests.swift new file mode 100644 index 000000000..2a71ecb9c --- /dev/null +++ b/Tests/RepoPromptHeadlessTests/HeadlessMCPServerLifecycleTests.swift @@ -0,0 +1,417 @@ +import Foundation +@testable import RepoPromptHeadless +import XCTest + +final class HeadlessMCPServerLifecycleTests: XCTestCase { + private var temporaryDirectories: [URL] = [] + + override func tearDownWithError() throws { + for directory in temporaryDirectories { + try? FileManager.default.removeItem(at: directory) + } + temporaryDirectories.removeAll() + } + + func testInitializeOnceAndInitializedNotificationGate() async throws { + let fixture = try makeFixture() + + let earlyInitialized = try await fixture.server.handle(frame: notification("notifications/initialized")) + XCTAssertNil(earlyInitialized.responseData) + XCTAssertFalse(earlyInitialized.shouldExit) + try await assertError(fixture.server, frame: request("ping"), code: -32002) + + let initializeNotification = try await fixture.server.handle(frame: notification("initialize")) + XCTAssertNil(initializeNotification.responseData) + XCTAssertFalse(initializeNotification.shouldExit) + + let initialize = try await fixture.server.handle(frame: initializeRequest()) + let initializeResponse = try responseObject(initialize) + XCTAssertNotNil(initializeResponse["result"] as? [String: Any]) + XCTAssertFalse(initialize.shouldExit) + + try await assertError(fixture.server, frame: initializeRequest(id: 2), code: -32600) + try await assertError( + fixture.server, + frame: request("notifications/initialized", id: NSNull()), + code: -32600 + ) + try await assertError(fixture.server, frame: request("ping", id: 3), code: -32002) + + let initialized = try await fixture.server.handle(frame: notification("notifications/initialized")) + XCTAssertNil(initialized.responseData) + XCTAssertFalse(initialized.shouldExit) + + let ping = try await fixture.server.handle(frame: request("ping", id: 4)) + XCTAssertNotNil(try responseObject(ping)["result"] as? [String: Any]) + try await assertError(fixture.server, frame: initializeRequest(id: 5), code: -32600) + } + + func testRequestOnlyNotificationsAreIgnoredWithoutExecutingTools() async throws { + let fixture = try makeFixture(configureAllowedRoot: true) + let preReadyMutation = try request( + "tools/call", + id: 8, + params: [ + "name": "prompt", + "arguments": ["op": "set", "text": "pre-ready request executed"] + ] + ) + try await assertError(fixture.server, frame: preReadyMutation, code: -32002) + try await makeReady(fixture.server) + + let afterPreReady = try await fixture.server.handle( + frame: request( + "tools/call", + id: 9, + params: ["name": "prompt", "arguments": ["op": "get"]] + ) + ) + let afterPreReadyResult = try XCTUnwrap(try responseObject(afterPreReady)["result"] as? [String: Any]) + let afterPreReadyStructured = try XCTUnwrap(afterPreReadyResult["structuredContent"] as? [String: Any]) + XCTAssertEqual(afterPreReadyStructured["prompt"] as? String, "") + + let validMutation = try await fixture.server.handle( + frame: request( + "tools/call", + id: 10, + params: [ + "name": "prompt", + "arguments": ["op": "set", "text": "baseline"] + ] + ) + ) + let validResult = try XCTUnwrap(try responseObject(validMutation)["result"] as? [String: Any]) + let validStructured = try XCTUnwrap(validResult["structuredContent"] as? [String: Any]) + XCTAssertEqual(validStructured["prompt"] as? String, "baseline") + + for method in ["initialize", "ping", "tools/list", "shutdown"] { + let action = try await fixture.server.handle(frame: notification(method)) + XCTAssertNil(action.responseData, "\(method) notification must not receive a response") + XCTAssertFalse(action.shouldExit, "\(method) notification must not alter exit state") + } + + let mutation = try notification( + "tools/call", + params: [ + "name": "prompt", + "arguments": ["op": "set", "text": "notification executed"] + ] + ) + let mutationAction = await fixture.server.handle(frame: mutation) + XCTAssertNil(mutationAction.responseData) + XCTAssertFalse(mutationAction.shouldExit) + + let ping = try await fixture.server.handle(frame: request("ping", id: 11)) + XCTAssertNotNil(try responseObject(ping)["result"] as? [String: Any]) + + let promptGet = try await fixture.server.handle( + frame: request( + "tools/call", + id: 12, + params: ["name": "prompt", "arguments": ["op": "get"]] + ) + ) + let promptResult = try XCTUnwrap(try responseObject(promptGet)["result"] as? [String: Any]) + let structured = try XCTUnwrap(promptResult["structuredContent"] as? [String: Any]) + XCTAssertEqual(structured["prompt"] as? String, "baseline") + } + + func testMalformedAndNonObjectFramesPreserveJSONRPCErrors() async throws { + let fixture = try makeFixture() + + let malformed = await fixture.server.handle(frame: Data("{".utf8)) + XCTAssertEqual(try errorCode(malformed), -32700) + XCTAssertTrue(try responseObject(malformed)["id"] is NSNull) + XCTAssertFalse(malformed.shouldExit) + + let nonObject = await fixture.server.handle(frame: Data("[]".utf8)) + XCTAssertEqual(try errorCode(nonObject), -32600) + XCTAssertTrue(try responseObject(nonObject)["id"] is NSNull) + XCTAssertFalse(nonObject.shouldExit) + } + + func testShutdownWaitsForExitAndRejectsFurtherRequests() async throws { + let fixture = try makeFixture() + + try await assertError(fixture.server, frame: request("shutdown"), code: -32002) + try await makeReady(fixture.server) + + let earlyExit = try await fixture.server.handle(frame: notification("exit")) + XCTAssertNil(earlyExit.responseData) + XCTAssertFalse(earlyExit.shouldExit) + try await assertError(fixture.server, frame: request("exit", id: 20), code: -32600) + + let shutdown = try await fixture.server.handle(frame: request("shutdown", id: 21)) + let shutdownResponse = try responseObject(shutdown) + XCTAssertTrue(shutdownResponse["result"] is NSNull) + XCTAssertFalse(shutdown.shouldExit) + + try await assertError(fixture.server, frame: request("ping", id: 22), code: -32600) + let exitRequest = try await fixture.server.handle(frame: request("exit", id: 23)) + XCTAssertFalse(exitRequest.shouldExit) + XCTAssertEqual(try errorCode(exitRequest), -32600) + + let exitNotification = try await fixture.server.handle(frame: notification("exit")) + XCTAssertNil(exitNotification.responseData) + XCTAssertTrue(exitNotification.shouldExit) + } + + func testReadyUnknownRequestsStillErrorAndUnknownNotificationsStaySilent() async throws { + let fixture = try makeFixture() + try await makeReady(fixture.server) + + try await assertError(fixture.server, frame: request("unknown/method"), code: -32601) + let notification = try await fixture.server.handle(frame: notification("unknown/method")) + XCTAssertNil(notification.responseData) + XCTAssertFalse(notification.shouldExit) + } + + func testInvalidInitializeParamsDoNotAdvanceLifecycle() async throws { + let fixture = try makeFixture() + + try await assertError(fixture.server, frame: request("initialize"), code: -32602) + let valid = try await fixture.server.handle(frame: initializeRequest(id: 2)) + XCTAssertNotNil(try responseObject(valid)["result"]) + } + + func testMalformedNoIDEnvelopeReturnsInvalidRequestWithNullID() async throws { + let fixture = try makeFixture() + let missingVersion = try JSONSerialization.data(withJSONObject: ["method": "ping"]) + let action = await fixture.server.handle(frame: missingVersion) + let response = try responseObject(action) + XCTAssertTrue(response["id"] is NSNull) + XCTAssertEqual(try errorCode(action), -32600) + } + + func testCancellationNotificationCancelsTrackedToolRequestWhilePingRemainsResponsive() async throws { + let gate = BlockingHeadlessToolCallGate() + let fixture = try makeFixture(toolCallOverride: { _, _ in + try await gate.run() + }) + try await makeReady(fixture.server) + + let searchFrame = try request( + "tools/call", + id: 40, + params: ["name": "file_search", "arguments": ["pattern": "needle"]] + ) + let searchTask = Task { + await fixture.server.handle(frame: searchFrame) + } + await gate.waitUntilStarted() + let activeBeforeCancellation = await fixture.server.activeRequestCountForTesting() + XCTAssertEqual(activeBeforeCancellation, 1) + + let ping = try await fixture.server.handle(frame: request("ping", id: 41)) + XCTAssertNotNil(try responseObject(ping)["result"] as? [String: Any]) + + let cancellation = try await fixture.server.handle( + frame: notification("notifications/cancelled", params: ["requestId": 40]) + ) + XCTAssertNil(cancellation.responseData) + let cancelled = await searchTask.value + XCTAssertEqual(try errorCode(cancelled), -32800) + XCTAssertEqual(try responseObject(cancelled)["id"] as? Int, 40) + let activeAfterCancellation = await fixture.server.activeRequestCountForTesting() + XCTAssertEqual(activeAfterCancellation, 0) + } + + func testShutdownRespondsWhileTrackedToolRequestRemainsExplicitlyCancellable() async throws { + let gate = BlockingHeadlessToolCallGate() + let fixture = try makeFixture(toolCallOverride: { _, _ in + try await gate.run() + }) + try await makeReady(fixture.server) + + let searchFrame = try request( + "tools/call", + id: 50, + params: ["name": "file_search", "arguments": ["pattern": "needle"]] + ) + let searchTask = Task { + await fixture.server.handle(frame: searchFrame) + } + await gate.waitUntilStarted() + + let shutdown = try await fixture.server.handle(frame: request("shutdown", id: 51)) + XCTAssertTrue(try responseObject(shutdown)["result"] is NSNull) + XCTAssertFalse(shutdown.shouldExit) + let activeAfterShutdown = await fixture.server.activeRequestCountForTesting() + XCTAssertEqual(activeAfterShutdown, 1) + + let cancellation = try await fixture.server.handle( + frame: notification("notifications/cancelled", params: ["requestId": 50]) + ) + XCTAssertNil(cancellation.responseData) + let cancelledSearch = await searchTask.value + XCTAssertEqual(try errorCode(cancelledSearch), -32800) + + let exit = try await fixture.server.handle(frame: notification("exit")) + XCTAssertTrue(exit.shouldExit) + } + + func testDuplicateActiveRequestIDIsRejectedAndIDCanBeReusedAfterCancellation() async throws { + let gate = BlockingHeadlessToolCallGate() + let fixture = try makeFixture(toolCallOverride: { _, _ in + try await gate.run() + }) + try await makeReady(fixture.server) + + let frame = try request( + "tools/call", + id: 70, + params: ["name": "file_search", "arguments": ["pattern": "needle"]] + ) + let first = Task { await fixture.server.handle(frame: frame) } + try await waitForActiveRequests(1, server: fixture.server) + + let duplicate = await fixture.server.handle(frame: frame) + XCTAssertEqual(try errorCode(duplicate), -32600) + _ = try await fixture.server.handle( + frame: notification("notifications/cancelled", params: ["requestId": 70]) + ) + let firstCancelled = await first.value + XCTAssertEqual(try errorCode(firstCancelled), -32800) + + let reused = Task { await fixture.server.handle(frame: frame) } + try await waitForActiveRequests(1, server: fixture.server) + _ = try await fixture.server.handle(frame: request("shutdown", id: 71)) + _ = try await fixture.server.handle( + frame: notification("notifications/cancelled", params: ["requestId": 70]) + ) + let reusedCancelled = await reused.value + XCTAssertEqual(try errorCode(reusedCancelled), -32800) + } + + private func makeFixture( + configureAllowedRoot: Bool = false, + toolCallOverride: HeadlessMCPServer.ToolCallOverride? = nil + ) throws -> Fixture { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("rpce-headless-mcp-lifecycle-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + temporaryDirectories.append(directory) + + let store = HeadlessConfigurationStore( + paths: HeadlessStatePaths(rootDirectory: directory.appendingPathComponent("State", isDirectory: true)) + ) + if configureAllowedRoot { + let rootURL = directory.appendingPathComponent("AllowedRoot", isDirectory: true) + try FileManager.default.createDirectory(at: rootURL, withIntermediateDirectories: true) + let root = HeadlessAllowedRoot( + id: UUID(), + name: "Fixture", + path: rootURL.path, + resolvedPath: rootURL.resolvingSymlinksInPath().standardizedFileURL.path, + addedAt: Date() + ) + try store.update { configuration in + configuration.allowedRoots = [root] + } + } + return Fixture(server: HeadlessMCPServer( + configurationStore: store, + toolCallOverride: toolCallOverride + )) + } + + private func waitForActiveRequests(_ expected: Int, server: HeadlessMCPServer) async throws { + let deadline = ContinuousClock.now + .seconds(2) + while ContinuousClock.now < deadline { + if await server.activeRequestCountForTesting() == expected { + return + } + try await Task.sleep(for: .milliseconds(5)) + } + XCTFail("Timed out waiting for \(expected) active request(s)") + } + + private func makeReady(_ server: HeadlessMCPServer) async throws { + let initialize = try await server.handle(frame: initializeRequest()) + XCTAssertNotNil(initialize.responseData) + let initialized = try await server.handle(frame: notification("notifications/initialized")) + XCTAssertNil(initialized.responseData) + } + + private func request( + _ method: String, + id: Any = 1, + params: [String: Any]? = nil + ) throws -> Data { + var object: [String: Any] = [ + "jsonrpc": "2.0", + "id": id, + "method": method + ] + if let params { + object["params"] = params + } + return try JSONSerialization.data(withJSONObject: object) + } + + private func notification(_ method: String, params: [String: Any]? = nil) throws -> Data { + var object: [String: Any] = [ + "jsonrpc": "2.0", + "method": method + ] + if let params { + object["params"] = params + } + return try JSONSerialization.data(withJSONObject: object) + } + + private func initializeRequest(id: Any = 1) throws -> Data { + try request( + "initialize", + id: id, + params: [ + "protocolVersion": "2024-11-05", + "capabilities": [:], + "clientInfo": ["name": "headless-tests", "version": "1"] + ] + ) + } + + private func responseObject(_ action: HeadlessRPCAction) throws -> [String: Any] { + let data = try XCTUnwrap(action.responseData) + return try XCTUnwrap(JSONSerialization.jsonObject(with: data) as? [String: Any]) + } + + private func errorCode(_ action: HeadlessRPCAction) throws -> Int { + let error = try XCTUnwrap(try responseObject(action)["error"] as? [String: Any]) + return try XCTUnwrap(error["code"] as? Int) + } + + private func assertError( + _ server: HeadlessMCPServer, + frame: @autoclosure () throws -> Data, + code: Int, + file: StaticString = #filePath, + line: UInt = #line + ) async throws { + let action = try await server.handle(frame: frame()) + XCTAssertEqual(try errorCode(action), code, file: file, line: line) + XCTAssertFalse(action.shouldExit, file: file, line: line) + } + + private struct Fixture { + let server: HeadlessMCPServer + } +} + +private actor BlockingHeadlessToolCallGate { + private var started = false + + func run() async throws -> HeadlessJSONObject { + started = true + while true { + try await Task.sleep(nanoseconds: 60_000_000_000) + } + } + + func waitUntilStarted() async { + while !started { + await Task.yield() + } + } +} diff --git a/Tests/RepoPromptHeadlessTests/HeadlessNewlineFrameDecoderTests.swift b/Tests/RepoPromptHeadlessTests/HeadlessNewlineFrameDecoderTests.swift new file mode 100644 index 000000000..06ec557f6 --- /dev/null +++ b/Tests/RepoPromptHeadlessTests/HeadlessNewlineFrameDecoderTests.swift @@ -0,0 +1,140 @@ +import Foundation +@testable import RepoPromptHeadless +import XCTest + +final class HeadlessNewlineFrameDecoderTests: XCTestCase { + func testSplitFramesMultipleFramesCRLFAndEmptyLines() { + var decoder = HeadlessNewlineFrameDecoder(maximumFrameBytes: 32) + + XCTAssertEqual(decoder.append(Data("{\"a\":".utf8)), []) + XCTAssertEqual( + decoder.append(Data("1}\n\n{\"b\":2}\r\n".utf8)), + [ + .frame(Data("{\"a\":1}".utf8)), + .frame(Data("{\"b\":2}".utf8)) + ] + ) + } + + func testExactOneMiBBoundaryIsAccepted() throws { + var decoder = HeadlessNewlineFrameDecoder() + let payload = Data(repeating: 0x61, count: HeadlessNewlineFrameDecoder.defaultMaximumFrameBytes) + var chunk = payload + chunk.append(0x0A) + + let events = decoder.append(chunk) + XCTAssertEqual(events.count, 1) + guard case let .frame(frame) = try XCTUnwrap(events.first) else { + return XCTFail("Expected an accepted boundary frame") + } + XCTAssertEqual(frame.count, payload.count) + } + + func testOneMiBPlusOneIsRejectedAtTheDefaultLimit() { + var decoder = HeadlessNewlineFrameDecoder() + var chunk = Data( + repeating: 0x61, + count: HeadlessNewlineFrameDecoder.defaultMaximumFrameBytes + 1 + ) + chunk.append(0x0A) + + XCTAssertEqual( + decoder.append(chunk), + [ + .parseError( + message: "JSON-RPC frame exceeds headless maximum of 1048576 bytes." + ) + ] + ) + } + + func testTerminalCRCountsTowardLimitBeforeNormalization() { + var accepted = HeadlessNewlineFrameDecoder(maximumFrameBytes: 4) + XCTAssertEqual( + accepted.append(Data("abc\r\n".utf8)), + [.frame(Data("abc".utf8))] + ) + + var rejected = HeadlessNewlineFrameDecoder(maximumFrameBytes: 4) + let events = rejected.append(Data("abcd\r\n".utf8)) + XCTAssertEqual(events.count, 1) + XCTAssertEqual(parseErrors(events).count, 1) + } + + func testAggregateChunkOverOneMiBAcceptsIndividuallyValidFrames() { + var decoder = HeadlessNewlineFrameDecoder() + let frameSize = 600_000 + var chunk = Data(repeating: 0x61, count: frameSize) + chunk.append(0x0A) + chunk.append(Data(repeating: 0x62, count: frameSize)) + chunk.append(0x0A) + XCTAssertGreaterThan(chunk.count, HeadlessNewlineFrameDecoder.defaultMaximumFrameBytes) + + let events = decoder.append(chunk) + XCTAssertEqual(frames(events).map(\.count), [frameSize, frameSize]) + XCTAssertTrue(parseErrors(events).isEmpty) + } + + func testOversizedFrameIsDiscardedAndLaterFrameInSameChunkIsDecoded() { + var decoder = HeadlessNewlineFrameDecoder(maximumFrameBytes: 4) + + let events = decoder.append(Data("abcde ignored\n{}\n".utf8)) + XCTAssertEqual( + events, + [ + .parseError(message: "JSON-RPC frame exceeds headless maximum of 4 bytes."), + .frame(Data("{}".utf8)) + ] + ) + } + + func testOversizedFrameSpanningChunksReportsOnceAndResumesAfterNewline() { + var decoder = HeadlessNewlineFrameDecoder(maximumFrameBytes: 4) + + XCTAssertEqual(parseErrors(decoder.append(Data("abcde".utf8))).count, 1) + XCTAssertEqual(decoder.append(Data("still discarded".utf8)), []) + let recovered = decoder.append(Data("\n[]\n".utf8)) + XCTAssertEqual(parseErrors(recovered).count, 0) + XCTAssertEqual(frames(recovered), [Data("[]".utf8)]) + } + + func testFinishNeverEmitsResidualFrameAndRejectsNonWhitespaceResidual() { + var decoder = HeadlessNewlineFrameDecoder(maximumFrameBytes: 32) + XCTAssertEqual(decoder.append(Data("{\"valid\":true}".utf8)), []) + + let events = decoder.finish() + XCTAssertTrue(frames(events).isEmpty) + XCTAssertEqual(parseErrors(events), ["Incomplete newline-delimited JSON-RPC frame at EOF."]) + } + + func testFinishIgnoresWhitespaceAndDoesNotDuplicateOversizeError() { + var whitespace = HeadlessNewlineFrameDecoder(maximumFrameBytes: 8) + XCTAssertEqual(whitespace.append(Data([0x20, 0x09, 0x0D])), []) + XCTAssertEqual(whitespace.finish(), []) + + var oversized = HeadlessNewlineFrameDecoder(maximumFrameBytes: 4) + XCTAssertEqual(parseErrors(oversized.append(Data("abcde".utf8))).count, 1) + XCTAssertEqual(oversized.finish(), []) + } + + func testDecoderCanBeReusedAfterFinish() { + var decoder = HeadlessNewlineFrameDecoder(maximumFrameBytes: 8) + XCTAssertEqual(parseErrors(decoder.append(Data("garbage".utf8))).count, 0) + XCTAssertEqual(parseErrors(decoder.finish()).count, 1) + XCTAssertEqual(decoder.append(Data("{}\n".utf8)), [.frame(Data("{}".utf8))]) + } + + private func frames(_ events: [HeadlessNewlineFrameDecoder.Event]) -> [Data] { + events.compactMap { event in + guard case let .frame(data) = event else { return nil } + return data + } + } + + private func parseErrors(_ events: [HeadlessNewlineFrameDecoder.Event]) -> [String] { + events.compactMap { event in + guard case let .parseError(message) = event else { return nil } + return message + } + } +} diff --git a/Tests/RepoPromptHeadlessTests/HeadlessReadFileSlicerTests.swift b/Tests/RepoPromptHeadlessTests/HeadlessReadFileSlicerTests.swift new file mode 100644 index 000000000..6dffa4395 --- /dev/null +++ b/Tests/RepoPromptHeadlessTests/HeadlessReadFileSlicerTests.swift @@ -0,0 +1,42 @@ +@testable import RepoPromptHeadless +import XCTest + +final class HeadlessReadFileSlicerTests: XCTestCase { + func testRejectsZeroStartLine() { + XCTAssertThrowsError(try HeadlessReadFileSlicer.slice(text: "a\n", startLine: 0, limit: nil)) + } + + func testRejectsLimitWithNegativeStart() { + XCTAssertThrowsError(try HeadlessReadFileSlicer.slice(text: "a\nb\n", startLine: -1, limit: 1)) + } + + func testNegativeStartReadsTailAndPreservesEndings() throws { + let result = try HeadlessReadFileSlicer.slice(text: "a\r\nb\nc", startLine: -2, limit: nil) + XCTAssertEqual(result, HeadlessReadFileSlice(content: "b\nc", totalLines: 3, firstLine: 2, lastLine: 3, message: nil)) + } + + func testZeroLimitReturnsSuccessfulEmptySlice() throws { + let result = try HeadlessReadFileSlicer.slice(text: "a\nb\n", startLine: 2, limit: 0) + XCTAssertEqual(result, HeadlessReadFileSlice(content: "", totalLines: 2, firstLine: 2, lastLine: 1, message: nil)) + } + + func testNegativeLimitWithPositiveStartIsUnbounded() throws { + let result = try HeadlessReadFileSlicer.slice(text: "a\nb\nc", startLine: 2, limit: -1) + XCTAssertEqual(result, HeadlessReadFileSlice(content: "b\nc", totalLines: 3, firstLine: 2, lastLine: 3, message: nil)) + } + + func testStartBeyondEOFReturnsHelpfulMetadata() throws { + let result = try HeadlessReadFileSlicer.slice(text: "a\nb", startLine: 4, limit: nil) + XCTAssertEqual(result, HeadlessReadFileSlice(content: "", totalLines: 2, firstLine: 4, lastLine: 2, message: "Requested start_line exceeds file length.")) + } + + func testEmptyFileHasZeroMetadataWithoutOutOfRangeMessage() throws { + let result = try HeadlessReadFileSlicer.slice(text: "", startLine: 50, limit: nil) + XCTAssertEqual(result, HeadlessReadFileSlice(content: "", totalLines: 0, firstLine: 0, lastLine: 0, message: nil)) + } + + func testTrailingNewlineDoesNotCreatePhantomLine() throws { + let result = try HeadlessReadFileSlicer.slice(text: "a\nb\n", startLine: nil, limit: nil) + XCTAssertEqual(result, HeadlessReadFileSlice(content: "a\nb\n", totalLines: 2, firstLine: 1, lastLine: 2, message: nil)) + } +} diff --git a/Tests/RepoPromptHeadlessTests/HeadlessSearchServiceTests.swift b/Tests/RepoPromptHeadlessTests/HeadlessSearchServiceTests.swift new file mode 100644 index 000000000..6d6c1e489 --- /dev/null +++ b/Tests/RepoPromptHeadlessTests/HeadlessSearchServiceTests.swift @@ -0,0 +1,333 @@ +import Darwin +import Foundation +@testable import RepoPromptHeadless +import XCTest + +final class HeadlessSearchServiceTests: XCTestCase { + func testCatalogTruncationRequiresEligibleOverflowEntry() throws { + try withFixture { fixture in + try fixture.write("only.txt", contents: "visible") + try fixture.write(".git/ignored.txt", contents: "ignored") + + let complete = try HeadlessFileCatalog().scan(roots: [fixture.root], maxEntries: 2) + XCTAssertEqual(complete.entries.count, 2) + XCTAssertEqual(complete.entryLimit, 2) + XCTAssertFalse(complete.wasTruncated) + + try fixture.write("overflow.txt", contents: "visible") + let truncated = try HeadlessFileCatalog().scan(roots: [fixture.root], maxEntries: 2) + XCTAssertEqual(truncated.entries.count, 2) + XCTAssertTrue(truncated.wasTruncated) + } + } + + func testBothModeUsesSharedReturnBudgetAndReportsCompleteTotals() throws { + try withSearchFixture { fixture in + let result = try HeadlessSearchService().search( + roots: [fixture.root], + resolver: fixture.resolver, + arguments: [ + "pattern": "needle", + "mode": "both", + "regex": false, + "max_results": 2 + ] + ) + + XCTAssertEqual(result.structured["total_path_matches"] as? Int, 1) + XCTAssertEqual(result.structured["total_content_matches"] as? Int, 4) + XCTAssertEqual(result.structured["total_matches"] as? Int, 5) + XCTAssertEqual(result.structured["returned_matches"] as? Int, 2) + XCTAssertEqual(result.structured["omitted"] as? Int, 3) + XCTAssertEqual(result.structured["count_only"] as? Bool, false) + XCTAssertEqual(result.structured["totals_complete"] as? Bool, true) + XCTAssertEqual(result.structured["totals_are_lower_bounds"] as? Bool, false) + + let pathMatches = try XCTUnwrap(result.structured["path_matches"] as? [[String: Any]]) + let contentMatches = try XCTUnwrap(result.structured["content_matches"] as? [[String: Any]]) + XCTAssertEqual(pathMatches.count + contentMatches.count, 2) + } + } + + func testCountOnlyReturnsNoArraysAndReportsOnlyMatchesBeyondMaxResultsAsOmitted() throws { + try withSearchFixture { fixture in + let result = try HeadlessSearchService().search( + roots: [fixture.root], + resolver: fixture.resolver, + arguments: [ + "pattern": "needle", + "mode": "both", + "regex": false, + "max_results": 1, + "count_only": true + ] + ) + + XCTAssertEqual(result.structured["total_matches"] as? Int, 5) + XCTAssertEqual(result.structured["returned_matches"] as? Int, 0) + XCTAssertEqual(result.structured["omitted"] as? Int, 4) + XCTAssertEqual(result.structured["count_only"] as? Bool, true) + XCTAssertEqual((result.structured["path_matches"] as? [[String: Any]])?.count, 0) + XCTAssertEqual((result.structured["content_matches"] as? [[String: Any]])?.count, 0) + } + } + + func testCatalogCapMakesTotalsExplicitLowerBounds() throws { + try withFixture { fixture in + try fixture.write("a.txt", contents: "none") + try fixture.write("b.txt", contents: "none") + + let result = try HeadlessSearchService(maxCatalogEntries: 2).search( + roots: [fixture.root], + resolver: fixture.resolver, + arguments: [ + "pattern": "absent", + "mode": "path", + "regex": false + ] + ) + + XCTAssertEqual(result.structured["catalog_entries_scanned"] as? Int, 2) + XCTAssertEqual(result.structured["catalog_entry_limit"] as? Int, 2) + XCTAssertEqual(result.structured["catalog_scan_count"] as? Int, 1) + XCTAssertEqual(result.structured["catalog_truncated"] as? Bool, true) + XCTAssertEqual(result.structured["totals_complete"] as? Bool, false) + XCTAssertEqual(result.structured["totals_are_lower_bounds"] as? Bool, true) + XCTAssertTrue(result.summary.contains("eligible entries remain unscanned")) + } + } + + func testCatalogReadFailureMakesTotalsExplicitLowerBounds() throws { + try withFixture { fixture in + try fixture.write("unreadable.txt", contents: "needle") + let unreadable = fixture.directory.appendingPathComponent("unreadable.txt") + XCTAssertEqual(Darwin.chmod(unreadable.path, 0), 0) + defer { _ = Darwin.chmod(unreadable.path, S_IRUSR | S_IWUSR) } + + let result = try HeadlessSearchService().search( + roots: [fixture.root], + resolver: fixture.resolver, + arguments: ["pattern": "needle", "mode": "both", "regex": false] + ) + + XCTAssertEqual(result.structured["catalog_skipped_entries"] as? Int, 1) + XCTAssertEqual(result.structured["totals_complete"] as? Bool, false) + XCTAssertTrue(result.summary.contains("catalog entry or traversal error")) + } + } + + func testDirectoryExpansionRejectsTruncatedSubset() throws { + try withFixture { fixture in + try fixture.write("a.txt", contents: "a") + try fixture.write("b.txt", contents: "b") + let directory = try fixture.resolver.resolve("Fixture") + + XCTAssertThrowsError(try HeadlessFileCatalog().filesUnder(directory, maxFiles: 1)) { error in + XCTAssertTrue(error.localizedDescription.contains("Directory expansion exceeded")) + } + } + } + + func testContentFileAndByteBudgetsStopBeforeUnboundedReads() throws { + try withFixture { fixture in + for index in 0 ..< 12 { + try fixture.write(String(format: "%03d.txt", index), contents: "0123456789") + } + + var fileLimits = HeadlessSearchLimits() + fileLimits.maxContentFiles = 3 + fileLimits.maxContentBytes = 1000 + fileLimits.maxElapsedNanoseconds = 60_000_000_000 + fileLimits.maxMatcherWorkBytes = 1_000_000 + let fileLimited = try HeadlessSearchService(limits: fileLimits).search( + roots: [fixture.root], + resolver: fixture.resolver, + arguments: ["pattern": "absent", "mode": "content", "regex": false] + ) + + XCTAssertEqual(fileLimited.structured["content_files_attempted"] as? Int, 3) + XCTAssertEqual(fileLimited.structured["content_files_scanned"] as? Int, 3) + XCTAssertEqual(fileLimited.structured["budget_exhaustion_reason"] as? String, "content_file_limit") + XCTAssertEqual(fileLimited.structured["totals_complete"] as? Bool, false) + + var byteLimits = fileLimits + byteLimits.maxContentFiles = 12 + byteLimits.maxContentBytes = 15 + let byteLimited = try HeadlessSearchService(limits: byteLimits).search( + roots: [fixture.root], + resolver: fixture.resolver, + arguments: ["pattern": "absent", "mode": "content", "regex": false] + ) + + XCTAssertEqual(byteLimited.structured["content_files_attempted"] as? Int, 1) + XCTAssertEqual(byteLimited.structured["content_bytes_considered"] as? Int, 10) + XCTAssertEqual(byteLimited.structured["budget_exhaustion_reason"] as? String, "content_byte_limit") + } + } + + func testLargeTreeStopsAtDeterministicElapsedBudget() throws { + try withFixture { fixture in + for index in 0 ..< 200 { + try fixture.write(String(format: "%03d.txt", index), contents: "content") + } + let clock = SteppingMonotonicClock(step: 1_000_000) + var limits = HeadlessSearchLimits() + limits.maxElapsedNanoseconds = 12_000_000 + limits.maxContentFiles = 200 + limits.maxContentBytes = 10000 + limits.maxMatcherWorkBytes = 1_000_000 + + let result = try HeadlessSearchService(limits: limits, monotonicNow: clock.now).search( + roots: [fixture.root], + resolver: fixture.resolver, + arguments: ["pattern": "absent", "mode": "content", "regex": false] + ) + + XCTAssertEqual(result.structured["budget_exhaustion_reason"] as? String, "time_limit") + XCTAssertEqual(result.structured["budget_exhausted"] as? Bool, true) + XCTAssertEqual(result.structured["catalog_truncated"] as? Bool, true) + XCTAssertLessThan(result.structured["catalog_entries_scanned"] as? Int ?? 200, 200) + XCTAssertEqual(result.structured["totals_complete"] as? Bool, false) + } + } + + func testRegexSubjectAndEngineWorkAreBounded() throws { + try withFixture { fixture in + try fixture.write("long.txt", contents: String(repeating: "a", count: 256)) + var limits = HeadlessSearchLimits() + limits.maxRegexSubjectBytes = 32 + limits.maxElapsedNanoseconds = 60_000_000_000 + limits.maxMatcherWorkBytes = 1_000_000 + + let result = try HeadlessSearchService(limits: limits).search( + roots: [fixture.root], + resolver: fixture.resolver, + arguments: ["pattern": "a+$", "mode": "content", "regex": true] + ) + + XCTAssertEqual(result.structured["budget_exhaustion_reason"] as? String, "regex_subject_limit") + XCTAssertEqual(result.structured["total_content_matches"] as? Int, 0) + XCTAssertEqual(result.structured["totals_complete"] as? Bool, false) + + XCTAssertThrowsError(try HeadlessSearchService().search( + roots: [fixture.root], + resolver: fixture.resolver, + arguments: ["pattern": "^(a+)+b$", "mode": "content", "regex": true] + )) { error in + XCTAssertTrue(error.localizedDescription.contains("unsafe regular expression")) + } + } + } + + func testSearchObservesTaskCancellationDuringCatalogTraversal() async throws { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("RepoPromptHeadlessSearchCancellation-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + let root = HeadlessAllowedRoot( + id: UUID(), + name: "Fixture", + path: directory.path, + resolvedPath: directory.resolvingSymlinksInPath().standardizedFileURL.path, + addedAt: Date() + ) + let fixture = Fixture(directory: directory, root: root) + for index in 0 ..< 100 { + try fixture.write(String(format: "%03d.txt", index), contents: "content") + } + let clock = BlockingMonotonicClock() + var limits = HeadlessSearchLimits() + limits.maxElapsedNanoseconds = 60_000_000_000 + let service = HeadlessSearchService(limits: limits, monotonicNow: clock.now) + let task = Task.detached { + try service.search( + roots: [fixture.root], + resolver: fixture.resolver, + arguments: ["pattern": "absent", "mode": "content", "regex": false] + ) + } + + XCTAssertEqual(clock.entered.wait(timeout: .now() + 2), .success) + task.cancel() + clock.resume.signal() + do { + _ = try await task.value + XCTFail("Expected cancellation") + } catch is CancellationError { + // Expected. + } + } + + private func withSearchFixture(_ body: (Fixture) throws -> Void) throws { + try withFixture { fixture in + try fixture.write("alpha.txt", contents: "needle\nneedle\n") + try fixture.write("beta.txt", contents: "needle\n") + try fixture.write("needle-name.txt", contents: "needle\n") + try body(fixture) + } + } + + private func withFixture(_ body: (Fixture) throws -> Void) throws { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("RepoPromptHeadlessSearchTests-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + + let root = HeadlessAllowedRoot( + id: UUID(), + name: "Fixture", + path: directory.path, + resolvedPath: directory.resolvingSymlinksInPath().standardizedFileURL.path, + addedAt: Date() + ) + try body(Fixture(directory: directory, root: root)) + } + + private struct Fixture { + let directory: URL + let root: HeadlessAllowedRoot + + var resolver: HeadlessPathResolver { + HeadlessPathResolver(roots: [root]) + } + + func write(_ relativePath: String, contents: String) throws { + let url = directory.appendingPathComponent(relativePath) + try FileManager.default.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true) + try Data(contents.utf8).write(to: url) + } + } +} + +private final class SteppingMonotonicClock { + private var value: UInt64 = 0 + private let step: UInt64 + + init(step: UInt64) { + self.step = step + } + + func now() -> UInt64 { + defer { value += step } + return value + } +} + +private final class BlockingMonotonicClock: @unchecked Sendable { + let entered = DispatchSemaphore(value: 0) + let resume = DispatchSemaphore(value: 0) + private let lock = NSLock() + private var callCount = 0 + + func now() -> UInt64 { + lock.lock() + callCount += 1 + let shouldBlock = callCount == 2 + lock.unlock() + if shouldBlock { + entered.signal() + resume.wait() + } + return 0 + } +} diff --git a/Tests/RepoPromptHeadlessTests/HeadlessSecureFileAccessTests.swift b/Tests/RepoPromptHeadlessTests/HeadlessSecureFileAccessTests.swift new file mode 100644 index 000000000..23b9da5e7 --- /dev/null +++ b/Tests/RepoPromptHeadlessTests/HeadlessSecureFileAccessTests.swift @@ -0,0 +1,117 @@ +import Darwin +import Foundation +@testable import RepoPromptHeadless +import XCTest + +final class HeadlessSecureFileAccessTests: XCTestCase { + private var temporaryDirectories: [URL] = [] + + override func tearDownWithError() throws { + for directory in temporaryDirectories { + try? FileManager.default.removeItem(at: directory) + } + temporaryDirectories.removeAll() + } + + func testRejectsFIFOAndDirectoryReadsWithoutBlocking() throws { + let fixture = try makeRoot() + let fifo = fixture.url.appendingPathComponent("pipe") + XCTAssertEqual(Darwin.mkfifo(fifo.path, 0o600), 0) + + let access = HeadlessSecureFileAccess() + XCTAssertThrowsError(try access.readRegularFile(root: fixture.root, relativePath: "pipe", maximumBytes: 1024)) + XCTAssertThrowsError(try access.readRegularFile(root: fixture.root, relativePath: "", maximumBytes: 1024)) + } + + func testRejectsLeafAndIntermediateSymlinks() throws { + let fixture = try makeRoot() + let realDirectory = fixture.url.appendingPathComponent("real", isDirectory: true) + try FileManager.default.createDirectory(at: realDirectory, withIntermediateDirectories: true) + try Data("safe".utf8).write(to: realDirectory.appendingPathComponent("file.txt")) + try FileManager.default.createSymbolicLink(atPath: fixture.url.appendingPathComponent("leaf.txt").path, withDestinationPath: realDirectory.appendingPathComponent("file.txt").path) + try FileManager.default.createSymbolicLink(atPath: fixture.url.appendingPathComponent("linked-dir").path, withDestinationPath: realDirectory.path) + + let resolver = HeadlessPathResolver(roots: [fixture.root]) + XCTAssertThrowsError(try resolver.resolve("leaf.txt")) + XCTAssertThrowsError(try resolver.resolve("linked-dir/file.txt")) + } + + func testOpenedDescriptorsAreCloseOnExec() throws { + let fixture = try makeRoot() + let directory = fixture.url.appendingPathComponent("dir", isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + try Data("safe".utf8).write(to: directory.appendingPathComponent("file.txt")) + + var descriptorFlags: [String: Int32] = [:] + let access = HeadlessSecureFileAccess { relativePath, descriptor in + descriptorFlags[relativePath] = Darwin.fcntl(descriptor, F_GETFD) + } + + _ = try access.inspect(root: fixture.root, relativePath: "") + _ = try access.readRegularFile(root: fixture.root, relativePath: "dir/file.txt", maximumBytes: 1024) + + for relativePath in ["", "dir", "dir/file.txt"] { + let flags = try XCTUnwrap(descriptorFlags[relativePath], relativePath) + XCTAssertNotEqual(flags & FD_CLOEXEC, 0, relativePath) + } + } + + func testLeafSwapAfterOpenReadsValidatedDescriptor() throws { + let fixture = try makeRoot() + let target = fixture.url.appendingPathComponent("target.txt") + try Data("original".utf8).write(to: target) + var swapped = false + let access = HeadlessSecureFileAccess { relativePath, _ in + guard relativePath == "target.txt", !swapped else { return } + swapped = true + try? FileManager.default.removeItem(at: target) + try? Data("replacement".utf8).write(to: target) + } + + let snapshot = try access.readRegularFile(root: fixture.root, relativePath: "target.txt", maximumBytes: 1024) + XCTAssertEqual(String(data: snapshot.data, encoding: .utf8), "original") + XCTAssertEqual(try String(contentsOf: target, encoding: .utf8), "replacement") + } + + func testIntermediateSwapAfterOpenStaysWithinOpenedDirectory() throws { + let fixture = try makeRoot() + let directory = fixture.url.appendingPathComponent("dir", isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + try Data("inside".utf8).write(to: directory.appendingPathComponent("file.txt")) + + let outside = try makeTemporaryDirectory() + try Data("outside".utf8).write(to: outside.appendingPathComponent("file.txt")) + let movedDirectory = fixture.url.appendingPathComponent("dir-opened", isDirectory: true) + var swapped = false + let access = HeadlessSecureFileAccess { relativePath, _ in + guard relativePath == "dir", !swapped else { return } + swapped = true + try? FileManager.default.moveItem(at: directory, to: movedDirectory) + try? FileManager.default.createSymbolicLink(atPath: directory.path, withDestinationPath: outside.path) + } + + let snapshot = try access.readRegularFile(root: fixture.root, relativePath: "dir/file.txt", maximumBytes: 1024) + XCTAssertEqual(String(data: snapshot.data, encoding: .utf8), "inside") + } + + private func makeRoot() throws -> (url: URL, root: HeadlessAllowedRoot) { + let url = try makeTemporaryDirectory() + return ( + url, + HeadlessAllowedRoot( + id: UUID(), + name: "Root", + path: url.path, + resolvedPath: url.resolvingSymlinksInPath().standardizedFileURL.path, + addedAt: Date() + ) + ) + } + + private func makeTemporaryDirectory() throws -> URL { + let url = FileManager.default.temporaryDirectory.appendingPathComponent("rpce-headless-secure-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true) + temporaryDirectories.append(url) + return url + } +} diff --git a/Tests/RepoPromptHeadlessTests/HeadlessSelectionToolsTests.swift b/Tests/RepoPromptHeadlessTests/HeadlessSelectionToolsTests.swift new file mode 100644 index 000000000..002b040f2 --- /dev/null +++ b/Tests/RepoPromptHeadlessTests/HeadlessSelectionToolsTests.swift @@ -0,0 +1,170 @@ +import Foundation +@testable import RepoPromptHeadless +import XCTest + +final class HeadlessSelectionToolsTests: XCTestCase { + func testRejectsPathWithSlicesMode() async throws { + let fixture = try makeFixture() + defer { fixture.remove() } + + let error = await commandError(host: fixture.host, arguments: [ + "op": "add", + "path": "Root/one.txt", + "mode": "slices" + ]) + + XCTAssertEqual(error?.exitCode, 2) + XCTAssertTrue(error?.message.contains("cannot create an empty slice selection") == true) + } + + func testRejectsExplicitEmptySliceArrayAndEmptyRanges() async throws { + let fixture = try makeFixture() + defer { fixture.remove() } + + let emptySlicesError = await commandError(host: fixture.host, arguments: [ + "op": "add", + "slices": [] + ]) + XCTAssertEqual(emptySlicesError?.message, "Selection slices must not be empty.") + + let emptyRangesError = await commandError(host: fixture.host, arguments: [ + "op": "add", + "slices": [[ + "path": "Root/one.txt", + "ranges": [] + ]] + ]) + XCTAssertEqual(emptyRangesError?.message, "Slice selection requires at least one range.") + + let invalidLineRangeError = await commandError(host: fixture.host, arguments: [ + "op": "add", + "slices": [["path": "Root/one.txt", "lines": "1-"]] + ]) + XCTAssertEqual(invalidLineRangeError?.message, "Invalid slice line range: 1-") + } + + func testRangeRemovalSplitsSliceAndPreservesDescription() async throws { + let fixture = try makeFixture() + defer { fixture.remove() } + + _ = try await HeadlessSelectionTools.manageSelection(host: fixture.host, arguments: [ + "op": "add", + "slices": [[ + "path": "Root/one.txt", + "ranges": [[ + "start_line": 1, + "end_line": 10, + "description": "important" + ]] + ]] + ]) + _ = try await HeadlessSelectionTools.manageSelection(host: fixture.host, arguments: [ + "op": "remove", + "slices": [[ + "path": "Root/one.txt", + "ranges": [["start_line": 4, "end_line": 6]] + ]] + ]) + + let selection = try await fixture.host.snapshot(requireWorkspace: true).workspace?.selection + XCTAssertEqual(selection, [HeadlessSelectionEntry( + rootID: fixture.root.id, + relativePath: "one.txt", + mode: .slices, + ranges: [ + HeadlessLineRange(startLine: 1, endLine: 3, description: "important"), + HeadlessLineRange(startLine: 7, endLine: 10, description: "important") + ] + )]) + } + + func testRangeRemovalDoesNotWidenOrRemoveFullAndCodemapEntries() async throws { + let fixture = try makeFixture() + defer { fixture.remove() } + + let initial = [ + HeadlessSelectionEntry(rootID: fixture.root.id, relativePath: "one.txt", mode: .full), + HeadlessSelectionEntry(rootID: fixture.root.id, relativePath: "two.txt", mode: .codemapOnly) + ] + _ = try await fixture.host.replaceSelection(initial) + _ = try await HeadlessSelectionTools.manageSelection(host: fixture.host, arguments: [ + "op": "remove", + "slices": [ + ["path": "Root/one.txt", "lines": "1-3"], + ["path": "Root/two.txt", "lines": "1-3"] + ] + ]) + + let selection = try await fixture.host.snapshot(requireWorkspace: true).workspace?.selection + XCTAssertEqual(selection, initial) + } + + func testConcurrentHostsDoNotLoseIndependentSelectionAdds() async throws { + let fixture = try makeFixture() + defer { fixture.remove() } + _ = try await fixture.host.snapshot(requireWorkspace: true) + let secondHost = HeadlessHost(configurationStore: HeadlessConfigurationStore(paths: fixture.paths)) + + async let first: HeadlessJSONObject = HeadlessSelectionTools.manageSelection(host: fixture.host, arguments: [ + "op": "add", + "path": "Root/one.txt" + ]) + async let second: HeadlessJSONObject = HeadlessSelectionTools.manageSelection(host: secondHost, arguments: [ + "op": "add", + "path": "Root/two.txt" + ]) + _ = try await (first, second) + + let selection = try await fixture.host.snapshot(requireWorkspace: true).workspace?.selection + XCTAssertEqual(selection?.map(\.relativePath).sorted(), ["one.txt", "two.txt"]) + } + + private func commandError( + host: HeadlessHost, + arguments: HeadlessJSONObject + ) async -> HeadlessCommandError? { + do { + _ = try await HeadlessSelectionTools.manageSelection(host: host, arguments: arguments) + XCTFail("Expected manage_selection to reject invalid slice input.") + return nil + } catch let error as HeadlessCommandError { + return error + } catch { + XCTFail("Unexpected error: \(error)") + return nil + } + } + + private func makeFixture() throws -> SelectionFixture { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("RepoPromptHeadlessSelectionTests-\(UUID().uuidString)", isDirectory: true) + let rootDirectory = directory.appendingPathComponent("Root", isDirectory: true) + try FileManager.default.createDirectory(at: rootDirectory, withIntermediateDirectories: true) + try Data("one\ntwo\nthree\n".utf8).write(to: rootDirectory.appendingPathComponent("one.txt")) + try Data("alpha\nbeta\n".utf8).write(to: rootDirectory.appendingPathComponent("two.txt")) + + let paths = HeadlessStatePaths(rootDirectory: directory.appendingPathComponent("State", isDirectory: true)) + let configurationStore = HeadlessConfigurationStore(paths: paths) + let root = try HeadlessRootAccessPolicy.makeAllowedRoot(path: rootDirectory.path, name: "Root") + try configurationStore.update { configuration in + configuration.allowedRoots = [root] + } + return SelectionFixture( + directory: directory, + paths: paths, + root: root, + host: HeadlessHost(configurationStore: configurationStore) + ) + } +} + +private struct SelectionFixture { + let directory: URL + let paths: HeadlessStatePaths + let root: HeadlessAllowedRoot + let host: HeadlessHost + + func remove() { + try? FileManager.default.removeItem(at: directory) + } +} diff --git a/Tests/RepoPromptHeadlessTests/HeadlessStateFileSecurityTests.swift b/Tests/RepoPromptHeadlessTests/HeadlessStateFileSecurityTests.swift new file mode 100644 index 000000000..723e9cbf9 --- /dev/null +++ b/Tests/RepoPromptHeadlessTests/HeadlessStateFileSecurityTests.swift @@ -0,0 +1,258 @@ +import Darwin +import Foundation +@testable import RepoPromptHeadless +import XCTest + +final class HeadlessStateFileSecurityTests: XCTestCase { + private var temporaryDirectories: [URL] = [] + + override func tearDownWithError() throws { + for directory in temporaryDirectories { + try? FileManager.default.removeItem(at: directory) + } + temporaryDirectories.removeAll() + } + + func testBaseDirectoriesAndPersistedFilesEnforcePrivateModes() throws { + let paths = HeadlessStatePaths(rootDirectory: try makeTemporaryDirectory().appendingPathComponent("State", isDirectory: true)) + try paths.ensureBaseDirectories() + + XCTAssertEqual(try mode(at: paths.rootDirectory), 0o700) + XCTAssertEqual(try mode(at: paths.workspacesDirectory), 0o700) + XCTAssertEqual(try mode(at: paths.exportsDirectory), 0o700) + + let configurationStore = HeadlessConfigurationStore(paths: paths) + _ = try configurationStore.loadOrCreate() + let workspace = HeadlessWorkspaceDocument(name: "Secure", rootIDs: []) + try HeadlessWorkspaceStore(paths: paths).save(workspace) + + XCTAssertEqual(try mode(at: paths.configFile), 0o600) + XCTAssertEqual( + try mode(at: paths.workspacesDirectory.appendingPathComponent("\(workspace.id.uuidString).json")), + 0o600 + ) + XCTAssertEqual(try mode(at: paths.configLockFile), 0o600) + XCTAssertEqual(try mode(at: paths.workspaceLockFile(for: workspace.id)), 0o600) + } + + func testConfigSymlinkAndDirectoryAreRejectedWithoutChangingTargetBytes() throws { + let directory = try makeTemporaryDirectory() + let paths = HeadlessStatePaths(rootDirectory: directory.appendingPathComponent("State", isDirectory: true)) + try paths.ensureBaseDirectories() + let target = directory.appendingPathComponent("outside.json") + let original = Data("outside".utf8) + try original.write(to: target) + try FileManager.default.createSymbolicLink(atPath: paths.configFile.path, withDestinationPath: target.path) + + XCTAssertThrowsError(try HeadlessConfigurationStore(paths: paths).loadOrCreate()) + XCTAssertEqual(try Data(contentsOf: target), original) + + try FileManager.default.removeItem(at: paths.configFile) + try FileManager.default.createDirectory(at: paths.configFile, withIntermediateDirectories: false) + XCTAssertThrowsError(try HeadlessConfigurationStore(paths: paths).loadOrCreate()) + } + + func testConfigHardLinkIsRejectedWithoutChangingSharedInode() throws { + let directory = try makeTemporaryDirectory() + let paths = HeadlessStatePaths(rootDirectory: directory.appendingPathComponent("State", isDirectory: true)) + try paths.ensureBaseDirectories() + let outside = directory.appendingPathComponent("outside.json") + let original = Data("outside".utf8) + try original.write(to: outside) + XCTAssertEqual(Darwin.link(outside.path, paths.configFile.path), 0) + + XCTAssertThrowsError(try HeadlessConfigurationStore(paths: paths).loadOrCreate()) + XCTAssertEqual(try Data(contentsOf: outside), original) + XCTAssertEqual(try Data(contentsOf: paths.configFile), original) + } + + func testWorkspaceSymlinkIsRejectedWithoutRepairRewrite() throws { + let directory = try makeTemporaryDirectory() + let paths = HeadlessStatePaths(rootDirectory: directory.appendingPathComponent("State", isDirectory: true)) + try paths.ensureBaseDirectories() + let workspace = HeadlessWorkspaceDocument(name: "Outside", rootIDs: []) + let outside = directory.appendingPathComponent("outside-workspace.json") + let original = try HeadlessJSONFormatting.encoder(prettyPrinted: true).encode(workspace) + try original.write(to: outside) + let workspacePath = paths.workspacesDirectory.appendingPathComponent("\(workspace.id.uuidString).json") + try FileManager.default.createSymbolicLink(atPath: workspacePath.path, withDestinationPath: outside.path) + + XCTAssertThrowsError(try HeadlessWorkspaceStore(paths: paths).loadWorkspace(id: workspace.id)) + XCTAssertEqual(try Data(contentsOf: outside), original) + } + + func testLockRejectsSymlinkAndUnsafeTypeAndSetsCloseOnExec() throws { + let directory = try makeTemporaryDirectory() + let paths = HeadlessStatePaths(rootDirectory: directory.appendingPathComponent("State", isDirectory: true)) + try paths.ensureBaseDirectories() + let target = directory.appendingPathComponent("outside.lock") + try Data("sentinel".utf8).write(to: target) + try FileManager.default.createSymbolicLink(atPath: paths.configLockFile.path, withDestinationPath: target.path) + XCTAssertThrowsError(try HeadlessFileLock(path: paths.configLockFile).lock()) + XCTAssertEqual(try String(contentsOf: target, encoding: .utf8), "sentinel") + + try FileManager.default.removeItem(at: paths.configLockFile) + XCTAssertEqual(Darwin.mkfifo(paths.configLockFile.path, 0o600), 0) + XCTAssertThrowsError(try HeadlessFileLock(path: paths.configLockFile).lock()) + try FileManager.default.removeItem(at: paths.configLockFile) + + FileManager.default.createFile(atPath: paths.configLockFile.path, contents: Data()) + XCTAssertEqual(Darwin.chmod(paths.configLockFile.path, 0o666), 0) + var descriptorFlags: Int32 = 0 + let lock = HeadlessFileLock(path: paths.configLockFile) { descriptor in + descriptorFlags = Darwin.fcntl(descriptor, F_GETFD) + } + try lock.lock() + defer { lock.unlock() } + + XCTAssertNotEqual(descriptorFlags & FD_CLOEXEC, 0) + XCTAssertEqual(try mode(at: paths.configLockFile), 0o600) + } + + func testReadRemainsAnchoredWhenStateRootIsReplacedAfterParentOpen() throws { + let directory = try makeTemporaryDirectory() + let paths = HeadlessStatePaths(rootDirectory: directory.appendingPathComponent("State", isDirectory: true)) + try paths.ensureBaseDirectories() + try HeadlessStateFileSecurity.writePrivateFile( + Data("inside".utf8), + to: paths.configFile, + stateRoot: paths.rootDirectory + ) + let movedState = directory.appendingPathComponent("MovedState", isDirectory: true) + let outsideState = directory.appendingPathComponent("OutsideState", isDirectory: true) + try FileManager.default.createDirectory(at: outsideState, withIntermediateDirectories: true) + try Data("outside".utf8).write(to: outsideState.appendingPathComponent("config.json")) + + let data = try HeadlessStateFileSecurity.readPrivateFileIfPresent( + at: paths.configFile, + stateRoot: paths.rootDirectory + ) { _ in + try FileManager.default.moveItem(at: paths.rootDirectory, to: movedState) + try FileManager.default.createSymbolicLink( + atPath: paths.rootDirectory.path, + withDestinationPath: outsideState.path + ) + } + + XCTAssertEqual(String(data: try XCTUnwrap(data), encoding: .utf8), "inside") + } + + func testWriteRemainsAnchoredWhenStateRootIsReplacedAfterParentOpen() throws { + let directory = try makeTemporaryDirectory() + let paths = HeadlessStatePaths(rootDirectory: directory.appendingPathComponent("State", isDirectory: true)) + try paths.ensureBaseDirectories() + let movedState = directory.appendingPathComponent("MovedState", isDirectory: true) + let outsideState = directory.appendingPathComponent("OutsideState", isDirectory: true) + try FileManager.default.createDirectory(at: outsideState, withIntermediateDirectories: true) + let outsideConfig = outsideState.appendingPathComponent("config.json") + try Data("outside".utf8).write(to: outsideConfig) + + try HeadlessStateFileSecurity.writePrivateFile( + Data("anchored".utf8), + to: paths.configFile, + stateRoot: paths.rootDirectory, + parentDirectoryOpenedHook: { _ in + try FileManager.default.moveItem(at: paths.rootDirectory, to: movedState) + try FileManager.default.createSymbolicLink( + atPath: paths.rootDirectory.path, + withDestinationPath: outsideState.path + ) + } + ) + + XCTAssertEqual( + try String(contentsOf: movedState.appendingPathComponent("config.json"), encoding: .utf8), + "anchored" + ) + XCTAssertEqual(try String(contentsOf: outsideConfig, encoding: .utf8), "outside") + } + + func testLockCreationRemainsAnchoredWhenStateRootIsReplacedAfterParentOpen() throws { + let directory = try makeTemporaryDirectory() + let paths = HeadlessStatePaths(rootDirectory: directory.appendingPathComponent("State", isDirectory: true)) + try paths.ensureBaseDirectories() + let movedState = directory.appendingPathComponent("MovedState", isDirectory: true) + let outsideState = directory.appendingPathComponent("OutsideState", isDirectory: true) + try FileManager.default.createDirectory(at: outsideState, withIntermediateDirectories: true) + + let descriptor = try HeadlessStateFileSecurity.openPrivateLockFile( + at: paths.configLockFile, + stateRoot: paths.rootDirectory, + parentDirectoryOpenedHook: { _ in + try FileManager.default.moveItem(at: paths.rootDirectory, to: movedState) + try FileManager.default.createSymbolicLink( + atPath: paths.rootDirectory.path, + withDestinationPath: outsideState.path + ) + } + ) + Darwin.close(descriptor) + + XCTAssertTrue(FileManager.default.fileExists(atPath: movedState.appendingPathComponent("config.lock").path)) + XCTAssertFalse(FileManager.default.fileExists(atPath: outsideState.appendingPathComponent("config.lock").path)) + } + + func testWorkspaceFilenamesAndDecodedIDsMustMatchBeforeAnyRewrite() throws { + let directory = try makeTemporaryDirectory() + let paths = HeadlessStatePaths(rootDirectory: directory.appendingPathComponent("State", isDirectory: true)) + try paths.ensureBaseDirectories() + let store = HeadlessWorkspaceStore(paths: paths) + + let invalidName = paths.workspacesDirectory.appendingPathComponent("not-a-uuid.json") + try Data("{}".utf8).write(to: invalidName) + XCTAssertThrowsError(try store.loadWorkspaces()) + try FileManager.default.removeItem(at: invalidName) + + let filenameID = UUID() + var document = HeadlessWorkspaceDocument(name: "Mismatched", rootIDs: []) + document.id = UUID() + let mismatchedFile = paths.workspacesDirectory.appendingPathComponent("\(filenameID.uuidString).json") + let original = try HeadlessJSONFormatting.encoder(prettyPrinted: false).encode(document) + try original.write(to: mismatchedFile) + + XCTAssertThrowsError(try store.loadWorkspace(id: filenameID)) + XCTAssertEqual(try Data(contentsOf: mismatchedFile), original) + } + + func testOwnershipMismatchFailsClosed() throws { + let file = try makeTemporaryDirectory().appendingPathComponent("owned.json") + FileManager.default.createFile(atPath: file.path, contents: Data()) + let descriptor = Darwin.open(file.path, O_RDONLY | O_CLOEXEC | O_NOFOLLOW) + XCTAssertGreaterThanOrEqual(descriptor, 0) + defer { Darwin.close(descriptor) } + + XCTAssertThrowsError(try HeadlessStateFileSecurity.validateOpenedDescriptor( + descriptor, + path: file.path, + expectedKind: S_IFREG, + requiredMode: 0o600, + expectedOwner: Darwin.geteuid() &+ 1 + )) + } + + func testStateRootSymlinkIsRejected() throws { + let directory = try makeTemporaryDirectory() + let actual = directory.appendingPathComponent("Actual", isDirectory: true) + try FileManager.default.createDirectory(at: actual, withIntermediateDirectories: true) + let linked = directory.appendingPathComponent("Linked", isDirectory: true) + try FileManager.default.createSymbolicLink(atPath: linked.path, withDestinationPath: actual.path) + + XCTAssertThrowsError(try HeadlessStatePaths(rootDirectory: linked).ensureBaseDirectories()) + } + + private func mode(at url: URL) throws -> mode_t { + var status = stat() + guard Darwin.lstat(url.path, &status) == 0 else { + throw POSIXError(POSIXErrorCode(rawValue: errno) ?? .EIO) + } + return status.st_mode & 0o777 + } + + private func makeTemporaryDirectory() throws -> URL { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("RepoPromptHeadlessStateSecurity-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + temporaryDirectories.append(directory) + return directory + } +} diff --git a/Tests/RepoPromptHeadlessTests/HeadlessStateTransactionTests.swift b/Tests/RepoPromptHeadlessTests/HeadlessStateTransactionTests.swift new file mode 100644 index 000000000..5307bf742 --- /dev/null +++ b/Tests/RepoPromptHeadlessTests/HeadlessStateTransactionTests.swift @@ -0,0 +1,144 @@ +import Foundation +@testable import RepoPromptHeadless +import XCTest + +final class HeadlessStateTransactionTests: XCTestCase { + func testConcurrentWorkspaceCreationDoesNotRestoreRevokedPermission() async throws { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("RepoPromptHeadlessStateTransactionTests-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: directory) } + + let paths = HeadlessStatePaths(rootDirectory: directory.appendingPathComponent("State", isDirectory: true)) + let rootDirectory = directory.appendingPathComponent("AllowedRoot", isDirectory: true) + try FileManager.default.createDirectory(at: rootDirectory, withIntermediateDirectories: true) + let root = try HeadlessRootAccessPolicy.makeAllowedRoot(path: rootDirectory.path, name: "Root") + let setupStore = HeadlessConfigurationStore(paths: paths) + _ = try setupStore.update { configuration in + configuration.allowedRoots = [root] + configuration.permissions.writeFiles = true + } + + let statusFile = directory.appendingPathComponent("permission-revocation-status") + let process = try PermissionRevocationProcess( + process: permissionRevocationProcess(paths: paths, statusFile: statusFile) + ) + let hostStore = HeadlessConfigurationStore(paths: paths) + let host = HeadlessHost( + configurationStore: hostStore, + catalogMutationLoadedHook: { + try process.run() + let status = try Self.waitForStatusFile(statusFile) + if status == "acquired" { + process.waitUntilExit() + } + } + ) + + let workspace = try await host.createWorkspace(name: "Concurrent", rootTokens: [root.name]) + process.waitUntilExit() + + XCTAssertEqual(process.terminationStatus, 0) + XCTAssertEqual(try String(contentsOf: statusFile, encoding: .utf8), "blocked") + let finalConfiguration = try setupStore.loadOrCreate() + XCTAssertFalse(finalConfiguration.permissions.writeFiles) + XCTAssertEqual(finalConfiguration.activeWorkspaceID, workspace.id) + XCTAssertEqual( + try HeadlessWorkspaceStore(paths: paths).loadWorkspace(id: workspace.id)?.rootIDs, + [root.id] + ) + } + + private func permissionRevocationProcess(paths: HeadlessStatePaths, statusFile: URL) throws -> Process { + let python = URL(fileURLWithPath: "/usr/bin/python3") + guard FileManager.default.isExecutableFile(atPath: python.path) else { + throw XCTSkip("/usr/bin/python3 is required for the cross-process lock regression.") + } + let headlessExecutable = try builtHeadlessExecutable() + + let script = #""" + import fcntl + import os + import subprocess + import sys + + state_dir, status_path, executable = sys.argv[1], sys.argv[2], sys.argv[3] + lock_path = os.path.join(state_dir, "config.lock") + lock_fd = os.open(lock_path, os.O_CREAT | os.O_RDWR, 0o600) + try: + try: + fcntl.flock(lock_fd, fcntl.LOCK_EX | fcntl.LOCK_NB) + status = "acquired" + fcntl.flock(lock_fd, fcntl.LOCK_UN) + except BlockingIOError: + status = "blocked" + finally: + os.close(lock_fd) + with open(status_path, "w", encoding="utf-8") as status_file: + status_file.write(status) + status_file.flush() + os.fsync(status_file.fileno()) + subprocess.run( + [executable, "--state-dir", state_dir, "config", "permissions", "set", "write_files", "false"], + check=True, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + """# + + let process = Process() + process.executableURL = python + process.arguments = ["-c", script, paths.rootDirectory.path, statusFile.path, headlessExecutable.path] + process.standardOutput = FileHandle.nullDevice + process.standardError = FileHandle.nullDevice + return process + } + + private func builtHeadlessExecutable() throws -> URL { + let startingURLs = [ + URL(fileURLWithPath: CommandLine.arguments[0]).standardizedFileURL, + Bundle(for: Self.self).bundleURL.standardizedFileURL + ] + for startingURL in startingURLs { + var directory = startingURL + for _ in 0 ..< 10 { + directory.deleteLastPathComponent() + let candidate = directory.appendingPathComponent("repoprompt-headless", isDirectory: false) + if FileManager.default.isExecutableFile(atPath: candidate.path) { + return candidate + } + } + } + throw HeadlessCommandError("Unable to locate the built repoprompt-headless executable for the cross-process regression.") + } + + private static func waitForStatusFile(_ url: URL) throws -> String { + let deadline = Date().addingTimeInterval(5) + while Date() < deadline { + if let status = try? String(contentsOf: url, encoding: .utf8), !status.isEmpty { + return status + } + Thread.sleep(forTimeInterval: 0.01) + } + throw HeadlessCommandError("Timed out waiting for concurrent permission revocation to attempt config.lock.") + } +} + +private final class PermissionRevocationProcess: @unchecked Sendable { + private let process: Process + + init(process: Process) { + self.process = process + } + + var terminationStatus: Int32 { + process.terminationStatus + } + + func run() throws { + try process.run() + } + + func waitUntilExit() { + process.waitUntilExit() + } +} diff --git a/Tests/RepoPromptHeadlessTests/HeadlessStdioTransportTests.swift b/Tests/RepoPromptHeadlessTests/HeadlessStdioTransportTests.swift new file mode 100644 index 000000000..ab32fa582 --- /dev/null +++ b/Tests/RepoPromptHeadlessTests/HeadlessStdioTransportTests.swift @@ -0,0 +1,273 @@ +import Foundation +@testable import RepoPromptHeadless +import XCTest + +final class HeadlessStdioTransportTests: XCTestCase { + func testLongRunningRequestDoesNotBlockPingOrCancellationDelivery() async throws { + let fixture = try makeFixture() + defer { try? FileManager.default.removeItem(at: fixture.directory) } + let gate = TransportBlockingToolCallGate() + let server = HeadlessMCPServer(configurationStore: fixture.store, toolCallOverride: { _, _ in + try await gate.run() + }) + let output = LockedTransportOutput() + let transport = HeadlessStdioTransport( + server: server, + writer: HeadlessStdoutWriter(writeHandler: output.append) + ) + + let initializeStopped = try await transport.receive(line(initializeRequest(id: 1))) + XCTAssertFalse(initializeStopped) + let initializedStopped = try await transport.receive(line(notification("notifications/initialized"))) + XCTAssertFalse(initializedStopped) + let searchStopped = try await transport.receive(line(request( + "tools/call", + id: 2, + params: ["name": "file_search", "arguments": ["pattern": "needle"]] + ))) + XCTAssertFalse(searchStopped) + await gate.waitUntilStarted() + + let pingStopped = try await transport.receive(line(request("ping", id: 3))) + XCTAssertFalse(pingStopped) + let ping = try await output.waitForResponse(id: 3) + XCTAssertNotNil(ping["result"] as? [String: Any]) + XCTAssertNil(output.response(id: 2), "The blocked search must not have produced a response yet") + + let cancellationStopped = try await transport.receive(line(notification( + "notifications/cancelled", + params: ["requestId": 2] + ))) + XCTAssertFalse(cancellationStopped) + let cancelled = try await output.waitForResponse(id: 2) + XCTAssertEqual(errorCode(cancelled), -32800) + + let shutdownStopped = try await transport.receive(line(request("shutdown", id: 4))) + XCTAssertFalse(shutdownStopped) + let shutdown = try await output.waitForResponse(id: 4) + XCTAssertTrue(shutdown["result"] is NSNull) + let exitStopped = try await transport.receive(line(notification("exit"))) + XCTAssertTrue(exitStopped) + await transport.waitForPendingResponses() + } + + func testRequestOnlyToolNotificationIsSilentAndNeverExecutesBeforeShutdown() async throws { + let fixture = try makeFixture(configureAllowedRoot: true) + defer { try? FileManager.default.removeItem(at: fixture.directory) } + let output = LockedTransportOutput() + let transport = HeadlessStdioTransport( + server: HeadlessMCPServer(configurationStore: fixture.store), + writer: HeadlessStdoutWriter(writeHandler: output.append) + ) + + let frames = try [ + initializeRequest(id: 20), + notification("notifications/initialized"), + notification( + "tools/call", + params: [ + "name": "prompt", + "arguments": ["op": "set", "text": "notification-must-not-run"] + ] + ), + request( + "tools/call", + id: 21, + params: ["name": "prompt", "arguments": ["op": "get"]] + ), + request("shutdown", id: 22), + notification("exit") + ] + let stopped = await transport.receive(framed(frames)) + XCTAssertTrue(stopped) + await transport.waitForPendingResponses() + + let promptGet = try await output.waitForResponse(id: 21) + let promptResult = try XCTUnwrap(promptGet["result"] as? [String: Any]) + let structured = try XCTUnwrap(promptResult["structuredContent"] as? [String: Any]) + XCTAssertEqual(structured["prompt"] as? String, "") + let shutdown = try await output.waitForResponse(id: 22) + XCTAssertTrue(shutdown["result"] is NSNull) + XCTAssertEqual(output.allObjects().count, 3, "Only initialize, prompt get, and shutdown may respond") + } + + func testShutdownRespondsBeforeExplicitCancellationAndFlushesExactlyOneResponse() async throws { + let fixture = try makeFixture() + defer { try? FileManager.default.removeItem(at: fixture.directory) } + let gate = TransportBlockingToolCallGate() + let server = HeadlessMCPServer(configurationStore: fixture.store, toolCallOverride: { _, _ in + try await gate.run() + }) + let output = LockedTransportOutput() + let transport = HeadlessStdioTransport( + server: server, + writer: HeadlessStdoutWriter(writeHandler: output.append) + ) + + _ = try await transport.receive(line(initializeRequest(id: 10))) + _ = try await transport.receive(line(notification("notifications/initialized"))) + _ = try await transport.receive(line(request( + "tools/call", + id: 11, + params: ["name": "file_search", "arguments": ["pattern": "needle"]] + ))) + await gate.waitUntilStarted() + + let shutdownStopped = try await transport.receive(line(request("shutdown", id: 12))) + XCTAssertFalse(shutdownStopped) + let shutdown = try await output.waitForResponse(id: 12) + XCTAssertTrue(shutdown["result"] is NSNull) + XCTAssertNil(output.response(id: 11)) + + let cancellationStopped = try await transport.receive(line(notification( + "notifications/cancelled", + params: ["requestId": 11] + ))) + XCTAssertFalse(cancellationStopped) + let exitStopped = try await transport.receive(line(notification("exit"))) + XCTAssertTrue(exitStopped) + await transport.waitForPendingResponses() + + let cancelled = try await output.waitForResponse(id: 11) + XCTAssertEqual(errorCode(cancelled), -32800) + XCTAssertEqual(output.responses(id: 10).count, 1) + XCTAssertEqual(output.responses(id: 11).count, 1) + XCTAssertEqual(output.responses(id: 12).count, 1) + } + + private func makeFixture(configureAllowedRoot: Bool = false) throws -> (directory: URL, store: HeadlessConfigurationStore) { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("rpce-headless-stdio-tests-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + let store = HeadlessConfigurationStore( + paths: HeadlessStatePaths(rootDirectory: directory.appendingPathComponent("State", isDirectory: true)) + ) + _ = try store.loadOrCreate() + if configureAllowedRoot { + let rootURL = directory.appendingPathComponent("AllowedRoot", isDirectory: true) + try FileManager.default.createDirectory(at: rootURL, withIntermediateDirectories: true) + let root = HeadlessAllowedRoot( + id: UUID(), + name: "Fixture", + path: rootURL.path, + resolvedPath: rootURL.resolvingSymlinksInPath().standardizedFileURL.path, + addedAt: Date() + ) + try store.update { configuration in + configuration.allowedRoots = [root] + } + } + return (directory, store) + } + + private func request(_ method: String, id: Any, params: [String: Any]? = nil) throws -> Data { + var object: [String: Any] = ["jsonrpc": "2.0", "id": id, "method": method] + if let params { + object["params"] = params + } + return try JSONSerialization.data(withJSONObject: object) + } + + private func notification(_ method: String, params: [String: Any]? = nil) throws -> Data { + var object: [String: Any] = ["jsonrpc": "2.0", "method": method] + if let params { + object["params"] = params + } + return try JSONSerialization.data(withJSONObject: object) + } + + private func initializeRequest(id: Any) throws -> Data { + try request( + "initialize", + id: id, + params: [ + "protocolVersion": "2024-11-05", + "capabilities": [:], + "clientInfo": ["name": "transport-tests", "version": "1"] + ] + ) + } + + private func framed(_ frames: [Data]) -> Data { + var data = Data() + for frame in frames { + data.append(frame) + data.append(0x0A) + } + return data + } + + private func line(_ data: Data) throws -> Data { + var line = data + line.append(0x0A) + return line + } + + private func errorCode(_ response: [String: Any]) -> Int? { + (response["error"] as? [String: Any])?["code"] as? Int + } +} + +private actor TransportBlockingToolCallGate { + private var started = false + + func run() async throws -> HeadlessJSONObject { + started = true + while true { + try await Task.sleep(nanoseconds: 60_000_000_000) + } + } + + func waitUntilStarted() async { + while !started { + await Task.yield() + } + } +} + +private final class LockedTransportOutput: @unchecked Sendable { + private let lock = NSLock() + private var data = Data() + + func append(_ chunk: Data) { + lock.lock() + data.append(chunk) + lock.unlock() + } + + func response(id: Int) -> [String: Any]? { + responses(id: id).first + } + + func responses(id: Int) -> [[String: Any]] { + objects().filter { ($0["id"] as? Int) == id } + } + + func waitForResponse(id: Int) async throws -> [String: Any] { + let deadline = ContinuousClock.now + .seconds(2) + while ContinuousClock.now < deadline { + if let response = response(id: id) { + return response + } + try await Task.sleep(for: .milliseconds(5)) + } + throw TransportTestError.responseTimedOut(id) + } + + func allObjects() -> [[String: Any]] { + objects() + } + + private func objects() -> [[String: Any]] { + lock.lock() + let snapshot = data + lock.unlock() + return snapshot.split(separator: 0x0A).compactMap { line in + try? JSONSerialization.jsonObject(with: Data(line)) as? [String: Any] + } + } +} + +private enum TransportTestError: Error { + case responseTimedOut(Int) +} diff --git a/Tests/RepoPromptHeadlessTests/HeadlessWorkspaceStoreTests.swift b/Tests/RepoPromptHeadlessTests/HeadlessWorkspaceStoreTests.swift new file mode 100644 index 000000000..cde977245 --- /dev/null +++ b/Tests/RepoPromptHeadlessTests/HeadlessWorkspaceStoreTests.swift @@ -0,0 +1,148 @@ +import Foundation +@testable import RepoPromptHeadless +import XCTest + +final class HeadlessWorkspaceStoreTests: XCTestCase { + func testWorkspaceLockPathIsStableAndWorkspaceSpecific() throws { + let paths = HeadlessStatePaths(rootDirectory: URL(fileURLWithPath: "/tmp/headless-state")) + let firstID = try XCTUnwrap(UUID(uuidString: "11111111-1111-1111-1111-111111111111")) + let secondID = try XCTUnwrap(UUID(uuidString: "22222222-2222-2222-2222-222222222222")) + + XCTAssertEqual( + paths.workspaceLockFile(for: firstID).path, + "/tmp/headless-state/Workspaces/11111111-1111-1111-1111-111111111111.lock" + ) + XCTAssertNotEqual(paths.workspaceLockFile(for: firstID), paths.workspaceLockFile(for: secondID)) + } + + func testLoadDropsAndRepairsPersistedEmptySliceEntries() throws { + let fixture = try makeFixture() + defer { fixture.remove() } + var workspace = HeadlessWorkspaceDocument(name: "Workspace", rootIDs: [fixture.rootID]) + workspace.selection = [HeadlessSelectionEntry( + rootID: fixture.rootID, + relativePath: "file.txt", + mode: .slices, + ranges: [] + )] + try fixture.paths.ensureBaseDirectories() + let workspaceFile = fixture.paths.workspacesDirectory + .appendingPathComponent("\(workspace.id.uuidString).json") + let data = try HeadlessJSONFormatting.encoder(prettyPrinted: true).encode(workspace) + try data.write(to: workspaceFile) + + let loaded = try XCTUnwrap(fixture.store.loadWorkspace(id: workspace.id)) + XCTAssertEqual(loaded.selection, []) + + let repairedData = try Data(contentsOf: workspaceFile) + let repaired = try HeadlessJSONFormatting.decoder().decode(HeadlessWorkspaceDocument.self, from: repairedData) + XCTAssertEqual(repaired.selection, []) + } + + func testConcurrentStoreUpdatesKeepEveryTransaction() throws { + let fixture = try makeFixture() + defer { fixture.remove() } + let workspace = HeadlessWorkspaceDocument(name: "Workspace", rootIDs: [fixture.rootID]) + try fixture.store.save(workspace) + + let stores = [ + HeadlessWorkspaceStore(paths: fixture.paths), + HeadlessWorkspaceStore(paths: fixture.paths) + ] + let failures = ThreadSafeFailures() + let group = DispatchGroup() + let queue = DispatchQueue(label: "HeadlessWorkspaceStoreTests.concurrent", attributes: .concurrent) + for index in 0 ..< 40 { + group.enter() + queue.async { + defer { group.leave() } + do { + try stores[index % stores.count].update(id: workspace.id) { document in + document.promptText.append("x") + } + } catch { + failures.append(error) + } + } + } + + XCTAssertEqual(group.wait(timeout: .now() + 10), .success) + XCTAssertEqual(failures.values.map(\.localizedDescription), []) + XCTAssertEqual(try fixture.store.loadWorkspace(id: workspace.id)?.promptText.count, 40) + } + + func testSnapshotRejectsWorkspaceWithUnknownConfiguredRootID() async throws { + let fixture = try makeFixture() + defer { fixture.remove() } + let allowedDirectory = fixture.directory.appendingPathComponent("AllowedRoot", isDirectory: true) + try FileManager.default.createDirectory(at: allowedDirectory, withIntermediateDirectories: true) + let unknownRootID = UUID() + let workspace = HeadlessWorkspaceDocument( + name: "Unknown Root Fixture", + rootIDs: [fixture.rootID, unknownRootID] + ) + try fixture.store.save(workspace) + + let configurationStore = HeadlessConfigurationStore(paths: fixture.paths) + _ = try configurationStore.update { configuration in + configuration.allowedRoots = [HeadlessAllowedRoot( + id: fixture.rootID, + name: "AllowedRoot", + path: allowedDirectory.path, + resolvedPath: allowedDirectory.resolvingSymlinksInPath().standardizedFileURL.path, + addedAt: Date() + )] + configuration.activeWorkspaceID = workspace.id + } + + let host = HeadlessHost(configurationStore: configurationStore) + do { + _ = try await host.snapshot(requireWorkspace: true) + XCTFail("Expected an unknown workspace root ID to fail closed.") + } catch let error as HeadlessCommandError { + XCTAssertTrue(error.message.contains(workspace.name)) + XCTAssertTrue(error.message.contains(unknownRootID.uuidString)) + } + } + + private func makeFixture() throws -> WorkspaceStoreFixture { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("RepoPromptHeadlessWorkspaceStoreTests-\(UUID().uuidString)", isDirectory: true) + let paths = HeadlessStatePaths(rootDirectory: directory) + try paths.ensureBaseDirectories() + return WorkspaceStoreFixture( + directory: directory, + paths: paths, + rootID: UUID(), + store: HeadlessWorkspaceStore(paths: paths) + ) + } +} + +private struct WorkspaceStoreFixture { + let directory: URL + let paths: HeadlessStatePaths + let rootID: UUID + let store: HeadlessWorkspaceStore + + func remove() { + try? FileManager.default.removeItem(at: directory) + } +} + +private final class ThreadSafeFailures: @unchecked Sendable { + private let lock = NSLock() + private var errors: [Error] = [] + + var values: [Error] { + lock.lock() + defer { lock.unlock() } + return errors + } + + func append(_ error: Error) { + lock.lock() + errors.append(error) + lock.unlock() + } +} diff --git a/Tests/RepoPromptHeadlessTests/Helpers/RepoRoot.swift b/Tests/RepoPromptHeadlessTests/Helpers/RepoRoot.swift new file mode 100644 index 000000000..e1ebe753f --- /dev/null +++ b/Tests/RepoPromptHeadlessTests/Helpers/RepoRoot.swift @@ -0,0 +1,23 @@ +import Foundation + +enum HeadlessTestRepoRoot { + static func url(filePath: StaticString = #filePath) throws -> URL { + var current = URL(fileURLWithPath: "\(filePath)") + .deletingLastPathComponent() + .standardizedFileURL + while true { + let manifest = current.appendingPathComponent("Package.swift") + let sourceRoot = current.appendingPathComponent("Sources/RepoPromptHeadless", isDirectory: true) + if FileManager.default.fileExists(atPath: manifest.path), + FileManager.default.fileExists(atPath: sourceRoot.path) + { + return current + } + let parent = current.deletingLastPathComponent().standardizedFileURL + guard parent.path != current.path else { + throw CocoaError(.fileNoSuchFile) + } + current = parent + } + } +} diff --git a/Tests/RepoPromptHeadlessTests/SharedRuntimePhase0HeadlessCharacterizationTests.swift b/Tests/RepoPromptHeadlessTests/SharedRuntimePhase0HeadlessCharacterizationTests.swift new file mode 100644 index 000000000..38d68fc31 --- /dev/null +++ b/Tests/RepoPromptHeadlessTests/SharedRuntimePhase0HeadlessCharacterizationTests.swift @@ -0,0 +1,376 @@ +import Foundation +@testable import RepoPromptHeadless +import XCTest + +final class SharedRuntimePhase0HeadlessCharacterizationTests: XCTestCase { + private static let overlappingToolNames = [ + "bind_context", + "manage_workspaces", + "manage_selection", + "workspace_context", + "get_file_tree", + "get_code_structure", + "read_file", + "file_search", + "prompt" + ] + + private var temporaryDirectories: [URL] = [] + + override func tearDownWithError() throws { + for directory in temporaryDirectories { + try? FileManager.default.removeItem(at: directory) + } + temporaryDirectories.removeAll() + } + + func testHeadlessPhase0CharacterizationSnapshot() async throws { + let fixture = try makeServerFixture() + let initializeAction = try await fixture.server.handle(frame: initializeRequest()) + var initialize = try resultObject(initializeAction) + let replacements = [fixture.stateDirectory.path: "$STATE", fixture.rootDirectory.path: "$ROOT"] + normalizePaths(in: &initialize, replacements: replacements) + _ = try await fixture.server.handle(frame: notification("notifications/initialized")) + + let listAction = try await fixture.server.handle(frame: request("tools/list", id: 2)) + let listResult = try resultObject(listAction) + let descriptors = try XCTUnwrap(listResult["tools"] as? [[String: Any]]) + XCTAssertEqual(descriptors.compactMap { $0["name"] as? String }, Self.overlappingToolNames) + + var responses: [[String: Any]] = [] + for (index, toolName) in Self.overlappingToolNames.enumerated() { + let successAction = try await fixture.server.handle(frame: request( + "tools/call", + id: index + 10, + params: ["name": toolName, "arguments": successArguments(for: toolName)] + )) + var success = try resultObject(successAction) + normalizePaths(in: &success, replacements: replacements) + if toolName == "file_search" { + success = phase0FileSearchSnapshot(from: success) + } + + let failureAction = try await fixture.server.handle(frame: request( + "tools/call", + id: index + 30, + params: ["name": toolName, "arguments": failureArguments(for: toolName)] + )) + var failure = try resultObject(failureAction) + normalizePaths(in: &failure, replacements: replacements) + responses.append(["tool": toolName, "success": success, "failure": failure]) + } + + let snapshot: [String: Any] = [ + "format_version": 1, + "runtime": "headless-v1", + "baseline_commit": "487cd71d892dbc3104689cc42fdb39f6c038e8fb", + "initialize": initialize, + "tool_order": Self.overlappingToolNames, + "descriptors": descriptors, + "argument_coercion": argumentCoercionRecords(), + "responses": responses + ] + try assertOrRecord(snapshot, fixtureName: "headless-characterization.json") + } + + func testToolRegistryCapabilitiesAreExhaustiveAndDisjoint() { + XCTAssertEqual(HeadlessToolRegistry.registrations.map(\.name), Self.overlappingToolNames) + XCTAssertTrue(HeadlessToolRegistry.registrations.allSatisfy { $0.capability == .safeProfile }) + XCTAssertTrue(Set(HeadlessToolRegistry.registrations.map(\.name)).isDisjoint(with: HeadlessToolRegistry.blockedCapabilities.keys)) + XCTAssertEqual( + Set(HeadlessToolRegistry.blockedCapabilities.keys), + Set([ + "file_actions", "apply_edits", "git", "manage_worktree", + "agent_run", "agent_explore", "agent_manage", + "ask_oracle", "oracle_send", "oracle_chat_log", "context_builder", "ask_user", + "share_thoughts", "set_status", "wait_for_next_user_instruction", "app_settings" + ]) + ) + } + + func testDangerousToolsStayHiddenAndFailClosedWhenAllPermissionsAreEnabled() async throws { + let directory = try makeTemporaryDirectory(prefix: "HeadlessToolCapabilities") + let store = HeadlessConfigurationStore(paths: HeadlessStatePaths(rootDirectory: directory)) + _ = try store.update { document in + document.permissions = HeadlessPermissions( + writeFiles: true, + vcsWrite: true, + launchAgents: true, + exportOutsideStateDirectory: true + ) + } + let registry = HeadlessToolRegistry( + host: HeadlessHost(configurationStore: store), + configurationStore: store + ) + + XCTAssertEqual( + registry.listDescriptors().compactMap { $0["name"] as? String }, + Self.overlappingToolNames + ) + for (toolName, capability) in HeadlessToolRegistry.blockedCapabilities { + let response = await registry.call(name: toolName, arguments: [:]) + XCTAssertEqual(response["isError"] as? Bool, true, toolName) + let content = try XCTUnwrap(response["content"] as? [[String: Any]]) + let text = try XCTUnwrap(content.first?["text"] as? String) + XCTAssertTrue(text.contains("Required capability: \(capability.rawValue)"), toolName) + XCTAssertTrue(text.contains("fails closed"), toolName) + } + } + + func testHeadlessV1WorkspaceFixtureLoadsWithoutRewrite() throws { + let repositoryRoot = try HeadlessTestRepoRoot.url() + let source = repositoryRoot + .appendingPathComponent("Tests/SharedRuntimeConvergenceFixtures/Phase0/Headless/ProfileV1", isDirectory: true) + let directory = try makeTemporaryDirectory(prefix: "SharedRuntimePhase0-HeadlessV1") + let stateDirectory = directory.appendingPathComponent("State", isDirectory: true) + try FileManager.default.copyItem(at: source, to: stateDirectory) + + let rootDirectory = directory.appendingPathComponent("FixtureRoot", isDirectory: true) + let sourcesDirectory = rootDirectory.appendingPathComponent("Sources", isDirectory: true) + try FileManager.default.createDirectory(at: sourcesDirectory, withIntermediateDirectories: true) + try "struct Full {}\n".write(to: sourcesDirectory.appendingPathComponent("Full.swift"), atomically: true, encoding: .utf8) + try "one\ntwo\nthree\nfour\n".write(to: sourcesDirectory.appendingPathComponent("Sliced.swift"), atomically: true, encoding: .utf8) + try "struct Structure { func run() {} }\n".write(to: sourcesDirectory.appendingPathComponent("Structure.swift"), atomically: true, encoding: .utf8) + + let configURL = stateDirectory.appendingPathComponent("config.json") + var configText = try String(contentsOf: configURL, encoding: .utf8) + configText = configText + .replacingOccurrences(of: "__FIXTURE_ROOT_PATH__", with: rootDirectory.path) + .replacingOccurrences( + of: "__FIXTURE_ROOT_RESOLVED_PATH__", + with: rootDirectory.resolvingSymlinksInPath().standardizedFileURL.path + ) + try configText.write(to: configURL, atomically: true, encoding: .utf8) + + let workspaceID = try XCTUnwrap(UUID(uuidString: "22222222-2222-2222-2222-222222222222")) + let workspaceURL = stateDirectory + .appendingPathComponent("Workspaces", isDirectory: true) + .appendingPathComponent("\(workspaceID.uuidString).json") + let beforeConfig = try Data(contentsOf: configURL) + let beforeWorkspace = try Data(contentsOf: workspaceURL) + + let paths = HeadlessStatePaths(rootDirectory: stateDirectory) + let configuration = try HeadlessConfigurationStore(paths: paths).loadOrCreate() + let workspace = try XCTUnwrap(HeadlessWorkspaceStore(paths: paths).loadWorkspace(id: workspaceID)) + + XCTAssertEqual(configuration.schemaVersion, 1) + XCTAssertEqual(configuration.activeWorkspaceID, workspaceID) + XCTAssertEqual(configuration.allowedRoots.only?.id.uuidString, "11111111-1111-1111-1111-111111111111") + XCTAssertEqual(configuration.allowedRoots.only?.path, rootDirectory.path) + XCTAssertEqual(workspace.schemaVersion, 1) + XCTAssertEqual(workspace.name, "Phase 0 Headless V1") + XCTAssertEqual(workspace.promptText, "phase zero headless prompt\nsecond line") + XCTAssertEqual(workspace.selection.map(\.mode), [.full, .slices, .codemapOnly]) + XCTAssertEqual(workspace.selection[1].ranges, [ + HeadlessLineRange(startLine: 2, endLine: 4, description: "phase zero slice") + ]) + XCTAssertEqual(try Data(contentsOf: configURL), beforeConfig) + XCTAssertEqual(try Data(contentsOf: workspaceURL), beforeWorkspace) + } + + private func argumentCoercionRecords() -> [[String: Any]] { + [ + ["tool": "bind_context", "input": ["workspace": 7], "observed": optionalAny(HeadlessToolArguments.string(["workspace": 7], key: "workspace"))], + ["tool": "manage_workspaces", "input": ["roots": "Phase0Root"], "observed": HeadlessToolArguments.stringArray(["roots": "Phase0Root"], key: "roots") ?? []], + ["tool": "manage_selection", "input": ["paths": ["a", 7, "b"]], "observed": HeadlessToolArguments.stringArray(["paths": ["a", 7, "b"]], key: "paths") ?? []], + ["tool": "workspace_context", "input": ["include": "prompt"], "observed": HeadlessToolArguments.stringArray(["include": "prompt"], key: "include") ?? []], + ["tool": "get_file_tree", "input": ["max_depth": "3"], "observed": optionalAny(HeadlessToolArguments.int(["max_depth": "3"], key: "max_depth"))], + ["tool": "get_code_structure", "input": ["max_results": NSNumber(value: 4)], "observed": optionalAny(HeadlessToolArguments.int(["max_results": NSNumber(value: 4)], key: "max_results"))], + ["tool": "read_file", "input": ["start_line": "2"], "observed": optionalAny(HeadlessToolArguments.int(["start_line": "2"], key: "start_line"))], + ["tool": "file_search", "input": ["regex": "yes"], "observed": optionalAny(HeadlessToolArguments.bool(["regex": "yes"], key: "regex"))], + ["tool": "prompt", "input": ["text": 7], "observed": optionalAny(HeadlessToolArguments.string(["text": 7], key: "text"))] + ] + } + + private func makeServerFixture() throws -> ServerFixture { + let directory = try makeTemporaryDirectory(prefix: "SharedRuntimePhase0-HeadlessServer") + let stateDirectory = directory.appendingPathComponent("State", isDirectory: true) + let rootDirectory = directory.appendingPathComponent("FixtureRoot", isDirectory: true) + let source = try HeadlessTestRepoRoot.url() + .appendingPathComponent("Tests/SharedRuntimeConvergenceFixtures/Phase0/Headless/ProfileV1", isDirectory: true) + try FileManager.default.copyItem(at: source, to: stateDirectory) + let sourcesDirectory = rootDirectory.appendingPathComponent("Sources", isDirectory: true) + try FileManager.default.createDirectory(at: sourcesDirectory, withIntermediateDirectories: true) + try "struct Full {}\n".write(to: sourcesDirectory.appendingPathComponent("Full.swift"), atomically: true, encoding: .utf8) + try "one\ntwo\nthree\nfour\n".write(to: sourcesDirectory.appendingPathComponent("Sliced.swift"), atomically: true, encoding: .utf8) + try "struct Structure { func run() {} }\n".write(to: sourcesDirectory.appendingPathComponent("Structure.swift"), atomically: true, encoding: .utf8) + let configURL = stateDirectory.appendingPathComponent("config.json") + var configText = try String(contentsOf: configURL, encoding: .utf8) + configText = configText + .replacingOccurrences(of: "__FIXTURE_ROOT_PATH__", with: rootDirectory.path) + .replacingOccurrences( + of: "__FIXTURE_ROOT_RESOLVED_PATH__", + with: rootDirectory.resolvingSymlinksInPath().standardizedFileURL.path + ) + try configText.write(to: configURL, atomically: true, encoding: .utf8) + let store = HeadlessConfigurationStore(paths: HeadlessStatePaths(rootDirectory: stateDirectory)) + return ServerFixture( + stateDirectory: stateDirectory, + rootDirectory: rootDirectory, + server: HeadlessMCPServer(configurationStore: store) + ) + } + + private func successArguments(for toolName: String) -> [String: Any] { + switch toolName { + case "bind_context": ["op": "status"] + case "manage_workspaces": ["op": "list"] + case "manage_selection": ["op": "get"] + case "workspace_context": ["op": "snapshot", "include": ["prompt", "selection"]] + case "get_file_tree": ["type": "roots"] + case "get_code_structure": ["scope": "paths", "paths": ["Sources/Structure.swift"]] + case "read_file": ["path": "Sources/Full.swift"] + case "file_search": ["pattern": "struct Full", "mode": "content", "regex": false] + case "prompt": ["op": "get"] + default: [:] + } + } + + private func failureArguments(for toolName: String) -> [String: Any] { + switch toolName { + case "bind_context", "manage_workspaces", "manage_selection", "workspace_context", "prompt": + ["op": "phase0_invalid"] + case "get_file_tree": ["path": "Missing"] + case "get_code_structure": ["scope": "phase0_invalid"] + case "read_file": [:] + case "file_search": [:] + default: [:] + } + } + + private func makeTemporaryDirectory(prefix: String) throws -> URL { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("\(prefix)-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + temporaryDirectories.append(directory) + return directory + } + + private func request(_ method: String, id: Any, params: [String: Any]? = nil) throws -> Data { + var object: [String: Any] = ["jsonrpc": "2.0", "id": id, "method": method] + if let params { object["params"] = params } + return try JSONSerialization.data(withJSONObject: object) + } + + private func notification(_ method: String) throws -> Data { + try JSONSerialization.data(withJSONObject: ["jsonrpc": "2.0", "method": method]) + } + + private func initializeRequest() throws -> Data { + try request( + "initialize", + id: 1, + params: [ + "protocolVersion": "2024-11-05", + "capabilities": [:], + "clientInfo": ["name": "phase0-characterization", "version": "1"] + ] + ) + } + + private func resultObject(_ action: HeadlessRPCAction) throws -> [String: Any] { + let data = try XCTUnwrap(action.responseData) + let response = try XCTUnwrap(JSONSerialization.jsonObject(with: data) as? [String: Any]) + return try XCTUnwrap(response["result"] as? [String: Any]) + } + + private func normalizePaths(in object: inout [String: Any], replacements: [String: String]) { + object = Self.normalizedValue(object, replacements: replacements) as? [String: Any] ?? object + } + + private static func normalizedValue(_ value: Any, replacements: [String: String]) -> Any { + if let string = value as? String { + if string.range( + of: #"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z$"#, + options: .regularExpression + ) != nil { + return "$TIMESTAMP" + } + return replacements.reduce(string) { result, replacement in + result.replacingOccurrences(of: replacement.key, with: replacement.value) + } + } + if let array = value as? [Any] { + return array.map { normalizedValue($0, replacements: replacements) } + } + if let object = value as? [String: Any] { + return object.mapValues { normalizedValue($0, replacements: replacements) } + } + return value + } + + private func phase0FileSearchSnapshot(from response: [String: Any]) -> [String: Any] { + var response = response + if var content = response["content"] as? [[String: Any]], + !content.isEmpty, + let text = content[0]["text"] as? String + { + let currentOnlyPrefixes = [ + "- **Catalog entries processed**:", + "- **Content files read**:", + "- **Content bytes read**:", + "- **Matcher work bytes**:", + "- **Elapsed budget**:" + ] + content[0]["text"] = text + .components(separatedBy: .newlines) + .filter { line in !currentOnlyPrefixes.contains(where: line.hasPrefix) } + .joined(separator: "\n") + response["content"] = content + } + if var structured = response["structuredContent"] as? [String: Any] { + for key in [ + "catalog_entries_processed", + "content_files_attempted", + "content_file_limit", + "content_bytes_scanned", + "content_bytes_considered", + "content_byte_limit", + "matcher_work_bytes", + "matcher_work_byte_limit", + "regex_subject_byte_limit", + "elapsed_milliseconds", + "elapsed_time_limit_milliseconds", + "budget_exhausted", + "budget_exhaustion_reason" + ] { + structured.removeValue(forKey: key) + } + response["structuredContent"] = structured + } + return response + } + + private func assertOrRecord(_ snapshot: [String: Any], fixtureName: String) throws { + let fixtureURL = try HeadlessTestRepoRoot.url() + .appendingPathComponent("Tests/SharedRuntimeConvergenceFixtures/Phase0/Headless", isDirectory: true) + .appendingPathComponent(fixtureName) + let data = try JSONSerialization.data(withJSONObject: snapshot, options: [.prettyPrinted, .sortedKeys, .withoutEscapingSlashes]) + let existing = try Data(contentsOf: fixtureURL) + if ProcessInfo.processInfo.environment["RECORD_SHARED_RUNTIME_PHASE0"] == "1" { + try data.write(to: fixtureURL, options: .atomic) + return + } + let expected = try JSONSerialization.jsonObject(with: existing) as? NSDictionary + let actual = try JSONSerialization.jsonObject(with: data) as? NSDictionary + XCTAssertEqual(actual, expected) + } + + private func optionalAny(_ value: (some Any)?) -> Any { + if let value { return value } + return NSNull() + } + + private struct ServerFixture { + let stateDirectory: URL + let rootDirectory: URL + let server: HeadlessMCPServer + } +} + +private extension Array { + var only: Element? { + count == 1 ? first : nil + } +} diff --git a/Tests/RepoPromptPOSIXSupportTests/POSIXDescriptorSupportTests.swift b/Tests/RepoPromptPOSIXSupportTests/POSIXDescriptorSupportTests.swift new file mode 100644 index 000000000..8019b88b9 --- /dev/null +++ b/Tests/RepoPromptPOSIXSupportTests/POSIXDescriptorSupportTests.swift @@ -0,0 +1,70 @@ +import Darwin +import Foundation +import RepoPromptPOSIXSupport +import XCTest + +final class POSIXDescriptorSupportTests: XCTestCase { + func testPathReturnsOpenedFilePath() throws { + let file = FileManager.default.temporaryDirectory + .appendingPathComponent("RepoPromptPOSIXSupportTests-\(UUID().uuidString)") + try Data("test".utf8).write(to: file) + defer { try? FileManager.default.removeItem(at: file) } + + let descriptor = Darwin.open(file.path, O_RDONLY | O_CLOEXEC) + XCTAssertGreaterThanOrEqual(descriptor, 0) + defer { + if descriptor >= 0 { Darwin.close(descriptor) } + } + + let descriptorPath = try POSIXDescriptorSupport.path(for: descriptor) + XCTAssertEqual( + URL(fileURLWithPath: descriptorPath).resolvingSymlinksInPath().standardizedFileURL.path, + file.resolvingSymlinksInPath().standardizedFileURL.path + ) + } + + func testPathForInvalidDescriptorReturnsTypedError() { + XCTAssertThrowsError(try POSIXDescriptorSupport.path(for: -1)) { error in + XCTAssertEqual(error as? POSIXDescriptorPathError, .invalidFileDescriptor(fd: -1)) + } + } + + func testPathLookupFailureReturnsTypedErrno() throws { + let descriptor = Darwin.open("/dev/null", O_RDONLY | O_CLOEXEC) + XCTAssertGreaterThanOrEqual(descriptor, 0) + guard descriptor >= 0 else { return } + XCTAssertEqual(Darwin.close(descriptor), 0) + + XCTAssertThrowsError(try POSIXDescriptorSupport.path(for: descriptor)) { error in + XCTAssertEqual(error as? POSIXDescriptorPathError, .getPathFailed(fd: descriptor, errno: EBADF)) + } + } + + func testSetCloseOnExecPreservesOtherDescriptorFlags() throws { + var descriptors = [Int32](repeating: -1, count: 2) + XCTAssertEqual(Darwin.pipe(&descriptors), 0) + defer { + if descriptors[0] >= 0 { Darwin.close(descriptors[0]) } + if descriptors[1] >= 0 { Darwin.close(descriptors[1]) } + } + + let before = fcntl(descriptors[0], F_GETFD) + XCTAssertGreaterThanOrEqual(before, 0) + + try POSIXDescriptorSupport.setCloseOnExec(descriptors[0]) + + let after = fcntl(descriptors[0], F_GETFD) + XCTAssertNotEqual(after & FD_CLOEXEC, 0) + XCTAssertEqual(after & ~FD_CLOEXEC, before & ~FD_CLOEXEC) + } + + func testInvalidDescriptorReturnsTypedError() { + XCTAssertThrowsError(try POSIXDescriptorSupport.setCloseOnExec(-1)) { error in + XCTAssertEqual(error as? POSIXDescriptorConfigurationError, .invalidFileDescriptor(fd: -1)) + } + } + + func testShutdownIgnoresNegativeDescriptor() { + POSIXDescriptorSupport.shutdownSocketReadWrite(-1) + } +} diff --git a/Tests/RepoPromptTests/AgentMode/AgentContextExportResolverTests.swift b/Tests/RepoPromptTests/AgentMode/AgentContextExportResolverTests.swift index d8e237e92..bece5d93b 100644 --- a/Tests/RepoPromptTests/AgentMode/AgentContextExportResolverTests.swift +++ b/Tests/RepoPromptTests/AgentMode/AgentContextExportResolverTests.swift @@ -1,4 +1,5 @@ @testable import RepoPrompt +import RepoPromptCore import XCTest final class AgentContextExportResolverTests: XCTestCase { diff --git a/Tests/RepoPromptTests/AgentMode/AgentModeChatSwitchActivationTests.swift b/Tests/RepoPromptTests/AgentMode/AgentModeChatSwitchActivationTests.swift index 5ef3c9492..69972228f 100644 --- a/Tests/RepoPromptTests/AgentMode/AgentModeChatSwitchActivationTests.swift +++ b/Tests/RepoPromptTests/AgentMode/AgentModeChatSwitchActivationTests.swift @@ -55,9 +55,18 @@ final class AgentModeChatSwitchActivationTests: XCTestCase { func testWarmSwitchNotificationIsWindowScoped() async throws { try await withFixture { fixtureA in - let initialPresentation = fixtureA.viewModel.activeTranscriptPresentation - try await withFixture { fixtureB in + // Creating another full window fixture can legitimately refresh shared app-shell + // workspace state. Re-establish A's active binding before isolating B's tab switch. + XCTAssertTrue(fixtureA.viewModel.test_publishTranscriptPresentation(tabID: fixtureA.tabAID)) + let initialPresentation = fixtureA.viewModel.activeTranscriptPresentation + assertPresentation( + initialPresentation, + tabID: fixtureA.tabAID, + sessionID: fixtureA.sessionAID, + session: fixtureA.sessionA, + expectedTexts: fixtureA.tabATexts + ) XCTAssertEqual(fixtureA.viewModel.activeTranscriptPresentation, initialPresentation) await fixtureB.window.promptManager.switchComposeTab(fixtureB.tabBID) @@ -121,15 +130,15 @@ final class AgentModeChatSwitchActivationTests: XCTestCase { let tabA = ComposeTabState(id: tabAID, name: "A", activeAgentSessionID: sessionAID) let tabB = ComposeTabState(id: tabBID, name: "B", activeAgentSessionID: sessionBID) - let workspaceIndex = try XCTUnwrap( - window.workspaceManager.workspaces.firstIndex(where: { $0.id == workspace.id }) - ) - window.workspaceManager.workspaces[workspaceIndex].composeTabs = [tabA, tabB] - window.workspaceManager.workspaces[workspaceIndex].activeComposeTabID = tabAID - window.promptManager.loadComposeTabsFromWorkspace( - window.workspaceManager.workspaces[workspaceIndex], - syncPromptText: true - ) + let workspaceWithTabs = try XCTUnwrap(window.workspaceManager.mutateWorkspace( + id: workspace.id, + touchDateModified: false, + markDirty: false + ) { workspace in + workspace.composeTabs = [tabA, tabB] + workspace.activeComposeTabID = tabAID + }) + window.promptManager.loadComposeTabsFromWorkspace(workspaceWithTabs, syncPromptText: true) let viewModel = window.agentModeViewModel let sessionA = viewModel.session(for: tabAID) @@ -178,8 +187,7 @@ final class AgentModeChatSwitchActivationTests: XCTestCase { ) } catch { window.beginClose() - await window.tearDown() - WindowStatesManager.shared.unregisterWindowState(window) + await WindowStatesManager.shared.unregisterWindowStateAndWait(window) try? FileManager.default.removeItem(at: rootURL) throw error } @@ -187,8 +195,7 @@ final class AgentModeChatSwitchActivationTests: XCTestCase { private func cleanup(_ fixture: Fixture) async { fixture.window.beginClose() - await fixture.window.tearDown() - WindowStatesManager.shared.unregisterWindowState(fixture.window) + await WindowStatesManager.shared.unregisterWindowStateAndWait(fixture.window) try? FileManager.default.removeItem(at: fixture.rootURL) } diff --git a/Tests/RepoPromptTests/AgentMode/AgentProviderContextBuilderTests.swift b/Tests/RepoPromptTests/AgentMode/AgentProviderContextBuilderTests.swift index b5ecf6bff..0da7d088c 100644 --- a/Tests/RepoPromptTests/AgentMode/AgentProviderContextBuilderTests.swift +++ b/Tests/RepoPromptTests/AgentMode/AgentProviderContextBuilderTests.swift @@ -2,6 +2,28 @@ import XCTest final class AgentProviderContextBuilderTests: XCTestCase { + private enum AccountingTestError: Error { + case failed + } + + private actor AccountingCancellationGate { + private var started = false + private var waiters: [CheckedContinuation] = [] + + func markStarted() { + started = true + waiters.forEach { $0.resume() } + waiters.removeAll() + } + + func waitUntilStarted() async { + guard !started else { return } + await withCheckedContinuation { continuation in + waiters.append(continuation) + } + } + } + private var temporaryRoots: [URL] = [] override func tearDownWithError() throws { @@ -59,6 +81,43 @@ final class AgentProviderContextBuilderTests: XCTestCase { XCTAssertFalse(block.contains("let origin = \"worktree\""), block) } + func testForkFileContentsBlockFailsClosedWhenAccountingThrows() async { + let block = await AgentProviderContextBuilder.forkFileContentsBlock( + selection: StoredSelection(selectedPaths: ["Sources/Oversized.swift"], codemapAutoEnabled: false), + tokenCap: 1, + store: WorkspaceFileContextStore(), + lookupContext: .visibleWorkspace, + accountingOperation: { _, _ in throw AccountingTestError.failed }, + overTokenCapSummaryProvider: { _, _ in "unsafe fallback summary" } + ) + + XCTAssertEqual(block, "") + } + + func testForkFileContentsBlockFailsClosedWhenAccountingIsCancelled() async { + let gate = AccountingCancellationGate() + let task = Task { + await AgentProviderContextBuilder.forkFileContentsBlock( + selection: StoredSelection(selectedPaths: ["Sources/Oversized.swift"], codemapAutoEnabled: false), + tokenCap: 1, + store: WorkspaceFileContextStore(), + lookupContext: .visibleWorkspace, + accountingOperation: { _, _ in + await gate.markStarted() + try await Task.sleep(nanoseconds: 60_000_000_000) + throw AccountingTestError.failed + }, + overTokenCapSummaryProvider: { _, _ in "unsafe fallback summary" } + ) + } + await gate.waitUntilStarted() + + task.cancel() + let block = await task.value + + XCTAssertEqual(block, "") + } + private func makeBoundFixture() async throws -> ( logicalRoot: URL, worktreeRoot: URL, diff --git a/Tests/RepoPromptTests/AgentMode/ContextBuilderRenderingParityCharacterizationTests.swift b/Tests/RepoPromptTests/AgentMode/ContextBuilderRenderingParityCharacterizationTests.swift new file mode 100644 index 000000000..c187c0e11 --- /dev/null +++ b/Tests/RepoPromptTests/AgentMode/ContextBuilderRenderingParityCharacterizationTests.swift @@ -0,0 +1,250 @@ +import Foundation +@testable import RepoPrompt +@testable import RepoPromptCore +import XCTest + +@MainActor +final class ContextBuilderRenderingParityCharacterizationTests: XCTestCase { + private let firstPromptID = UUID(uuidString: "11111111-1111-1111-1111-111111111111")! + private let secondPromptID = UUID(uuidString: "22222222-2222-2222-2222-222222222222")! + private let laterPromptID = UUID(uuidString: "33333333-3333-3333-3333-333333333333")! + + func testCompleteUserMessageEnvelopeFreezesOrderAndBytes() throws { + let customPromptText = try XCTUnwrap(try ContextBuilderPromptStorage.promptText( + for: [secondPromptID, firstPromptID], + in: [ + ContextBuilderPrompt(id: firstPromptID, title: "First", content: "FIRST CUSTOM"), + ContextBuilderPrompt( + id: XCTUnwrap(UUID(uuidString: "44444444-4444-4444-4444-444444444444")), + title: "Unselected", + content: "UNSELECTED" + ), + ContextBuilderPrompt(id: secondPromptID, title: "Second", content: "SECOND CUSTOM") + ] + )) + + let message = ContextBuilderAgentViewModel.renderUserMessageEnvelope( + fileTree: "Root\n├── A.swift\n└── B.swift", + userPrompt: "Implement the parity checkpoint.", + customPromptText: customPromptText, + discoverInstructions: "Inspect before editing.", + adjustedBudget: 8500 + ) + + XCTAssertEqual(message, """ + + Root + ├── A.swift + └── B.swift + + + Implement the parity checkpoint. + + + FIRST CUSTOM + + + + SECOND CUSTOM + + + Inspect before editing. + + + 8500 + + Make a best effort to ensure the complete prompt (including all selected files and context) fits within the prescribed token budget of 8500 tokens. + + Context Optimization Strategy: + - For MCP modes (like the current context_builder mode), selected files are automatically compressed to show only their codemaps (API signatures) instead of full content, dramatically reducing token usage + - Codemaps provide type definitions, function signatures, and structure without full implementation details + - Additional codemaps may be automatically included for types referenced by selected files (in 'auto' mode) + - Use the MCP tools to check current token counts and adjust selection as needed to stay within budget + + Prioritize including files most relevant to the user's task while staying within the token budget. + For additional files that may not fit, but are important, mention them in the prompt, with a short description for what they contain that may be relevant to the task. + + + The final prompt should be written with clear formatting, isolating important concepts in xml tags, and making use of clean markdown where possible. + Do not add any outer wrapping for the complete prompt, as it will already be wrapped in . + + + """) + XCTAssertFalse(message.contains("UNSELECTED")) + } + + func testEmptyAndWhitespaceOptionalSectionsProduceExactMetadataOnlyEnvelope() { + let empty = ContextBuilderAgentViewModel.renderUserMessageEnvelope( + fileTree: "", + userPrompt: "", + customPromptText: nil, + discoverInstructions: "", + adjustedBudget: 42 + ) + let whitespace = ContextBuilderAgentViewModel.renderUserMessageEnvelope( + fileTree: " \n\t", + userPrompt: " \n", + customPromptText: nil, + discoverInstructions: "\t", + adjustedBudget: 42 + ) + let expected = """ + + 42 + + Make a best effort to ensure the complete prompt (including all selected files and context) fits within the prescribed token budget of 42 tokens. + + Context Optimization Strategy: + - For MCP modes (like the current context_builder mode), selected files are automatically compressed to show only their codemaps (API signatures) instead of full content, dramatically reducing token usage + - Codemaps provide type definitions, function signatures, and structure without full implementation details + - Additional codemaps may be automatically included for types referenced by selected files (in 'auto' mode) + - Use the MCP tools to check current token counts and adjust selection as needed to stay within budget + + Prioritize including files most relevant to the user's task while staying within the token budget. + For additional files that may not fit, but are important, mention them in the prompt, with a short description for what they contain that may be relevant to the task. + + + The final prompt should be written with clear formatting, isolating important concepts in xml tags, and making use of clean markdown where possible. + Do not add any outer wrapping for the complete prompt, as it will already be wrapped in . + + + """ + + XCTAssertEqual(empty, expected) + XCTAssertEqual(whitespace, expected) + XCTAssertFalse(empty.contains("")) + XCTAssertFalse(empty.contains("")) + XCTAssertFalse(empty.contains("")) + } + + func testMCPInstructionsOverrideRemainsSeparateFromCapturedTabPrompt() throws { + let session = ContextBuilderAgentViewModel.TabSession(tabID: UUID()) + session.contextBuilderInstructions = "MCP OVERRIDE INSTRUCTIONS" + let tabSnapshot = ComposeTabState( + id: session.tabID, + selection: StoredSelection(selectedPaths: ["/workspace/TabPrompt.swift"]), + promptText: "ORIGINAL TAB PROMPT" + ) + ContextBuilderAgentViewModel.captureRunStartState( + for: session, + selectedContextBuilderPromptIDs: [], + workspaceSnapshot: tabSnapshot, + isCurrentTab: true, + livePromptText: { "LIVE PROMPT MUST NOT WIN" } + ) + + let input = try capturedInput( + ContextBuilderAgentViewModel.resolveUserMessageSource( + for: session, + workspaceSnapshot: { nil }, + isCurrentTab: true + ) + ) + let message = ContextBuilderAgentViewModel.renderUserMessageEnvelope( + fileTree: "", + userPrompt: input.promptText, + customPromptText: nil, + discoverInstructions: session.contextBuilderInstructions, + adjustedBudget: 100 + ) + + XCTAssertTrue(message.contains("\nORIGINAL TAB PROMPT\n")) + XCTAssertTrue(message.contains("\nMCP OVERRIDE INSTRUCTIONS\n")) + XCTAssertFalse(message.contains("\nMCP OVERRIDE INSTRUCTIONS")) + XCTAssertFalse(message.contains("\nORIGINAL TAB PROMPT")) + XCTAssertFalse(message.contains("LIVE PROMPT MUST NOT WIN")) + } + + func testRunStartCaptureWinsAfterTabSwitchAndPostCaptureEdits() async throws { + let tabID = try XCTUnwrap(UUID(uuidString: "AAAAAAAA-AAAA-AAAA-AAAA-AAAAAAAAAAAA")) + let session = ContextBuilderAgentViewModel.TabSession(tabID: tabID) + let capturedSelection = StoredSelection( + selectedPaths: ["/workspace/FrozenA.swift", "/workspace/FrozenB.swift"], + autoCodemapPaths: ["/workspace/FrozenMap.swift"], + slices: ["/workspace/FrozenB.swift": [LineRange(start: 2, end: 5, description: "frozen slice")]], + codemapAutoEnabled: false + ) + let capturedSnapshot = ComposeTabState( + id: tabID, + selection: capturedSelection, + promptText: "FROZEN TAB PROMPT" + ) + let capturedPromptIDs: Set = [firstPromptID, secondPromptID] + ContextBuilderAgentViewModel.captureRunStartState( + for: session, + selectedContextBuilderPromptIDs: capturedPromptIDs, + workspaceSnapshot: capturedSnapshot, + isCurrentTab: true, + livePromptText: { "LIVE PROMPT MUST NOT WIN" } + ) + + session.selectedContextBuilderPromptIDs = [laterPromptID] + let editedSnapshot = ComposeTabState( + id: tabID, + selection: StoredSelection(selectedPaths: ["/workspace/Later.swift"]), + promptText: "POST-CAPTURE PROMPT EDIT" + ) + var workspaceSnapshotWasRead = false + let input = try capturedInput( + ContextBuilderAgentViewModel.resolveUserMessageSource( + for: session, + workspaceSnapshot: { + workspaceSnapshotWasRead = true + return editedSnapshot + }, + isCurrentTab: false + ) + ) + + XCTAssertFalse(workspaceSnapshotWasRead) + XCTAssertEqual(input.promptText, "FROZEN TAB PROMPT") + XCTAssertEqual(input.selection, capturedSelection) + XCTAssertEqual(input.contextBuilderPromptIDs, capturedPromptIDs) + + let orderedPrompts = [ + ContextBuilderPrompt(id: firstPromptID, title: "Frozen First", content: "FROZEN CUSTOM ONE"), + ContextBuilderPrompt(id: secondPromptID, title: "Frozen Second", content: "FROZEN CUSTOM TWO"), + ContextBuilderPrompt(id: laterPromptID, title: "Later", content: "POST-CAPTURE CUSTOM EDIT") + ] + var renderedSelection: StoredSelection? + var renderedPromptIDs: Set? + let message = await ContextBuilderAgentViewModel.renderUserMessage( + input: input, + adjustedBudget: 500, + fileTreeRenderer: { selection in + renderedSelection = selection + return selection.selectedPaths.joined(separator: "\n") + }, + discoverInstructions: { "FROZEN INSTRUCTIONS" }, + customPromptRenderer: { promptIDs in + renderedPromptIDs = promptIDs + return ContextBuilderPromptStorage.promptText(for: promptIDs, in: orderedPrompts) + } + ) + + XCTAssertEqual(renderedSelection, capturedSelection) + XCTAssertEqual(renderedPromptIDs, capturedPromptIDs) + XCTAssertTrue(message.contains("/workspace/FrozenA.swift\n/workspace/FrozenB.swift")) + XCTAssertTrue(message.contains("FROZEN TAB PROMPT")) + XCTAssertTrue(message.contains("FROZEN CUSTOM ONE")) + XCTAssertTrue(message.contains("FROZEN CUSTOM TWO")) + XCTAssertFalse(message.contains("/workspace/Later.swift")) + XCTAssertFalse(message.contains("POST-CAPTURE PROMPT EDIT")) + XCTAssertFalse(message.contains("POST-CAPTURE CUSTOM EDIT")) + XCTAssertFalse(message.contains("LIVE PROMPT MUST NOT WIN")) + } + + private func capturedInput( + _ source: ContextBuilderAgentViewModel.UserMessageSource + ) throws -> ContextBuilderAgentViewModel.UserMessageInput { + guard case let .captured(input) = source else { + XCTFail("Expected captured run-start input, got \(source)") + throw TestFailure.unexpectedSource + } + return input + } + + private enum TestFailure: Error { + case unexpectedSource + } +} diff --git a/Tests/RepoPromptTests/App/RepoPromptCoreHostLifecycleTests.swift b/Tests/RepoPromptTests/App/RepoPromptCoreHostLifecycleTests.swift new file mode 100644 index 000000000..7b090bb86 --- /dev/null +++ b/Tests/RepoPromptTests/App/RepoPromptCoreHostLifecycleTests.swift @@ -0,0 +1,86 @@ +@testable import RepoPrompt +@testable import RepoPromptCore +import XCTest + +@MainActor +final class RepoPromptCoreHostLifecycleTests: XCTestCase { + func testSessionLifecycleActivatesDrainsAndRetiresRoutingID() { + let registry = MCPRuntimeSessionRegistry() + let graph = EmbeddedWorkspaceRepositoryFactory.make() + let host = RepoPromptCoreHost( + workspaceRepository: graph.repository, + workspacePersistenceWriter: graph.writer, + workspaceAccessPolicy: UnrestrictedWorkspaceAccessPolicy(), + runtimeSessionRegistry: registry, + runtimeFactory: RepoPromptEmbeddedWorkspaceRuntimeFactory() + ) + let handle = host.makeEmbeddedSession(routingSessionID: MCPRoutingSessionID(rawValue: 42)) + + XCTAssertEqual(handle.snapshot.lifecycle, .created) + XCTAssertFalse(registry.hasActiveWindow(id: 42)) + + registry.setMCPEnabled(windowID: 42, enabled: true) + XCTAssertTrue(host.activate(handle)) + XCTAssertEqual(handle.snapshot.lifecycle, .active) + XCTAssertTrue(registry.isInvocationAllowed(windowID: 42)) + + host.beginDraining(handle) + host.beginDraining(handle) + XCTAssertEqual(handle.snapshot.lifecycle, .draining) + XCTAssertFalse(registry.isInvocationAllowed(windowID: 42)) + + host.remove(handle) + host.remove(handle) + XCTAssertEqual(handle.snapshot.lifecycle, .removed) + XCTAssertFalse(registry.hasActiveWindow(id: 42)) + #if DEBUG + XCTAssertTrue(registry.debugIsRetired(windowID: 42)) + #endif + } + + func testDuplicateRoutingIDActivationIsRejectedAndCannotTeardownOwner() { + let registry = MCPRuntimeSessionRegistry() + let graph = EmbeddedWorkspaceRepositoryFactory.make() + let host = RepoPromptCoreHost( + workspaceRepository: graph.repository, + workspacePersistenceWriter: graph.writer, + workspaceAccessPolicy: UnrestrictedWorkspaceAccessPolicy(), + runtimeSessionRegistry: registry, + runtimeFactory: RepoPromptEmbeddedWorkspaceRuntimeFactory() + ) + let owner = host.makeEmbeddedSession(routingSessionID: MCPRoutingSessionID(rawValue: 84)) + let duplicate = host.makeEmbeddedSession(routingSessionID: MCPRoutingSessionID(rawValue: 84)) + + registry.setMCPEnabled(windowID: 84, enabled: true) + XCTAssertTrue(host.activate(owner)) + XCTAssertFalse(host.activate(duplicate)) + XCTAssertEqual(duplicate.snapshot.lifecycle, .created) + XCTAssertTrue(registry.isInvocationAllowed(windowID: 84)) + + host.beginDraining(duplicate) + host.remove(duplicate) + XCTAssertEqual(duplicate.snapshot.lifecycle, .removed) + XCTAssertEqual(owner.snapshot.lifecycle, .active) + XCTAssertTrue(registry.isInvocationAllowed(windowID: 84)) + + host.beginDraining(owner) + host.remove(owner) + XCTAssertFalse(registry.hasActiveWindow(id: 84)) + #if DEBUG + XCTAssertTrue(registry.debugIsRetired(windowID: 84)) + #endif + } + + func testDetachedWorkspaceSessionControllerReturnsNoBindingCandidates() { + let graph = EmbeddedWorkspaceRepositoryFactory.make() + let controller = WorkspaceSessionController( + repository: graph.repository, + persistenceWriter: graph.writer, + accessPolicy: UnrestrictedWorkspaceAccessPolicy() + ) + + XCTAssertNil(controller.activeWorkspace) + XCTAssertNil(controller.bindingCandidate(forContextID: UUID())) + XCTAssertTrue(controller.bindingCandidates(matchingWorkingDirs: ["/tmp"]).isEmpty) + } +} diff --git a/Tests/RepoPromptTests/App/WorkspaceSessionObservationBridgeTests.swift b/Tests/RepoPromptTests/App/WorkspaceSessionObservationBridgeTests.swift new file mode 100644 index 000000000..60cdc4faa --- /dev/null +++ b/Tests/RepoPromptTests/App/WorkspaceSessionObservationBridgeTests.swift @@ -0,0 +1,51 @@ +import Combine +import Foundation +@testable import RepoPrompt +@testable import RepoPromptCore +import XCTest + +@MainActor +final class WorkspaceSessionObservationBridgeTests: XCTestCase { + func testBridgePublishesOrderedReadOnlyControllerSnapshots() { + let graph = makeGraph() + let controller = WorkspaceSessionController( + repository: graph.repository, + persistenceWriter: graph.writer, + accessPolicy: UnrestrictedWorkspaceAccessPolicy() + ) + let bridge = WorkspaceSessionObservationBridge(controller: controller) + var generations: [UInt64] = [] + let cancellable = bridge.$snapshot.dropFirst().sink { generations.append($0.generation) } + let first = WorkspaceModel(name: "First", repoPaths: ["/tmp/first"]) + let second = WorkspaceModel(name: "Second", repoPaths: ["/tmp/second"]) + + controller.replaceAll([first, second], activeWorkspaceID: second.id) + controller.mutateWorkspace(id: second.id) { $0.name = "Renamed" } + controller.setActiveWorkspaceID(first.id) + + XCTAssertEqual(generations, [1, 2, 3]) + XCTAssertEqual(bridge.workspaces.map(\.name), ["First", "Renamed"]) + XCTAssertEqual(bridge.activeWorkspaceID, first.id) + withExtendedLifetime(cancellable) {} + } + + private func makeGraph() -> (writer: WorkspacePersistenceWriter, repository: WorkspaceRepository) { + let codec = EmbeddedWorkspaceCodecV1() + let writer = WorkspacePersistenceWriter(codec: codec) + let repository = WorkspaceRepository( + rootProvider: ObservationBridgeRootProvider(root: FileManager.default.temporaryDirectory), + codec: codec, + writer: writer, + migrationService: NoopWorkspaceLegacyMigrationService() + ) + return (writer, repository) + } +} + +private struct ObservationBridgeRootProvider: WorkspaceRepositoryRootProviding { + let root: URL + + func repositoryRoot() async -> URL { + root + } +} diff --git a/Tests/RepoPromptTests/App/WorkspaceSlice1CompositionTests.swift b/Tests/RepoPromptTests/App/WorkspaceSlice1CompositionTests.swift new file mode 100644 index 000000000..11e5b2de7 --- /dev/null +++ b/Tests/RepoPromptTests/App/WorkspaceSlice1CompositionTests.swift @@ -0,0 +1,149 @@ +import Foundation +@testable import RepoPrompt +@testable import RepoPromptCore +import XCTest + +@MainActor +final class WorkspaceSlice1CompositionTests: XCTestCase { + func testAppContainerSharesOneRepositoryAndWriterWithEverySessionController() { + let container = RepoPromptAppCoreContainer.shared + let first = container.coreHost.makeEmbeddedSession(routingSessionID: MCPRoutingSessionID(rawValue: 91001)) + let second = container.coreHost.makeEmbeddedSession(routingSessionID: MCPRoutingSessionID(rawValue: 91002)) + + XCTAssertTrue(first.session.workspaceRepository === container.workspaceRepository) + XCTAssertTrue(second.session.workspaceRepository === container.workspaceRepository) + XCTAssertTrue(first.session.workspacePersistenceWriter === container.workspacePersistenceWriter) + XCTAssertTrue(second.session.workspacePersistenceWriter === container.workspacePersistenceWriter) + XCTAssertTrue(first.session.workspaceSessionController.repository === container.workspaceRepository) + XCTAssertTrue(first.session.workspaceSessionController.persistenceWriter === container.workspacePersistenceWriter) + } + + func testProductionConstructorsRemainAppOwnedAndAppV1IsTheOnlySelectedCodec() throws { + let root = try RepoRoot.url() + .resolvingSymlinksInPath() + .standardizedFileURL + let sources = root.appendingPathComponent("Sources", isDirectory: true) + let enumerator = try XCTUnwrap(FileManager.default.enumerator(at: sources, includingPropertiesForKeys: nil)) + var repositoryConstructors: [String] = [] + var controllerConstructors: [String] = [] + var selectedV2Codecs: [String] = [] + + for case let url as URL in enumerator where url.pathExtension == "swift" { + let source = try String(contentsOf: url, encoding: .utf8) + let canonicalURL = url.resolvingSymlinksInPath().standardizedFileURL + let relative = RepoRoot.relativePath(for: canonicalURL, relativeTo: root) + if source.contains("WorkspaceRepository(") { repositoryConstructors.append(relative) } + if source.contains("WorkspaceSessionController(") { controllerConstructors.append(relative) } + if source.contains("CanonicalWorkspaceCodecV2(") { selectedV2Codecs.append(relative) } + } + + XCTAssertEqual(repositoryConstructors, ["Sources/RepoPrompt/App/CoreAdapters/EmbeddedWorkspaceRepositoryFactory.swift"]) + XCTAssertEqual(controllerConstructors, ["Sources/RepoPrompt/Infrastructure/Core/RepoPromptCoreHost.swift"]) + XCTAssertTrue(selectedV2Codecs.isEmpty) + } + + func testWindowRetainsObservationAndCanonicalSelectionController() throws { + let previousAutoStart = GlobalSettingsStore.shared.mcpAutoStart() + GlobalSettingsStore.shared.setMCPAutoStart(false, commit: false) + defer { GlobalSettingsStore.shared.setMCPAutoStart(previousAutoStart, commit: false) } + let window = WindowState() + defer { window.beginClose() } + let workspace = WorkspaceModel(name: "Lifetime", repoPaths: ["/tmp/lifetime"]) + let tabID = try XCTUnwrap(workspace.activeComposeTabID) + + window.workspaceManager.replaceWorkspaceInventory([workspace], activeWorkspaceID: workspace.id) + + XCTAssertTrue(window.workspaceObservation === window.workspaceManager.workspaceObservation) + XCTAssertEqual(window.selectionCoordinator.activeTabID(), tabID) + XCTAssertEqual(window.selectionCoordinator.controller.sessionController.activeWorkspace?.id, workspace.id) + } + + func testPollSaveDoesNotAdvanceRepoBaselineFromStaleGeneration() async throws { + let previousAutoStart = GlobalSettingsStore.shared.mcpAutoStart() + GlobalSettingsStore.shared.setMCPAutoStart(false, commit: false) + defer { GlobalSettingsStore.shared.setMCPAutoStart(previousAutoStart, commit: false) } + let storage = FileManager.default.temporaryDirectory + .appendingPathComponent("WorkspaceSlice1SaveRace-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: storage, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: storage) } + let window = WindowState() + defer { window.beginClose() } + await window.workspaceManager.awaitInitialized() + let workspace = WorkspaceModel( + name: "Save Race", + repoPaths: ["/tmp/original"], + customStoragePath: storage + ) + window.workspaceManager.replaceWorkspaceInventory([workspace], activeWorkspaceID: workspace.id) + window.workspaceManager.mutateWorkspace(id: workspace.id) { $0.repoPaths = ["/tmp/captured"] } + + let gate = Slice1AppWorkspaceWriteGate() + let writer = window.workspaceManager.sessionController.persistenceWriter + await writer.setAtomicWriteGateForTesting { await gate.waitIfFirstWrite() } + let saveTask = Task { + await window.workspaceManager.pollAndSaveStateAsync(source: "slice1.race") + } + await gate.waitUntilFirstWriteStarted() + window.workspaceManager.mutateWorkspace(id: workspace.id) { $0.name = "Newer local mutation" } + await gate.releaseFirstWrite() + await saveTask.value + await writer.setAtomicWriteGateForTesting(nil) + + XCTAssertTrue(window.workspaceManager.sessionController.isDirty(workspaceID: workspace.id)) + XCTAssertEqual( + window.workspaceManager.sessionController.repositoryBaseline(workspaceID: workspace.id), + ["/tmp/original"] + ) + } + + func testWorkspaceManagerSourceContainsNoSecondWritableCanonicalState() throws { + let source = try String( + contentsOf: RepoRoot.url().appendingPathComponent( + "Sources/RepoPrompt/Features/Workspaces/ViewModels/WorkspaceManagerViewModel.swift" + ), + encoding: .utf8 + ) + + XCTAssertNil(source.range(of: #"@Published\s+(?:private\(set\)\s+)?var\s+workspaces\b"#, options: .regularExpression)) + XCTAssertNil(source.range(of: #"@Published\s+(?:private\(set\)\s+)?var\s+activeWorkspaceID\b"#, options: .regularExpression)) + XCTAssertNotNil( + source.range( + of: #"var\s+workspaces\s*:\s*\[WorkspaceModel\]\s*\{\s*sessionController\.workspaces\s*\}"#, + options: .regularExpression + ) + ) + XCTAssertNotNil( + source.range( + of: #"var\s+activeWorkspaceID\s*:\s*UUID\?\s*\{\s*sessionController\.activeWorkspaceID\s*\}"#, + options: .regularExpression + ) + ) + } +} + +private actor Slice1AppWorkspaceWriteGate { + private var firstStarted = false + private var firstReleased = false + private var startWaiters: [CheckedContinuation] = [] + private var releaseWaiters: [CheckedContinuation] = [] + + func waitIfFirstWrite() async { + guard !firstStarted else { return } + firstStarted = true + startWaiters.forEach { $0.resume() } + startWaiters.removeAll() + guard !firstReleased else { return } + await withCheckedContinuation { releaseWaiters.append($0) } + } + + func waitUntilFirstWriteStarted() async { + guard !firstStarted else { return } + await withCheckedContinuation { startWaiters.append($0) } + } + + func releaseFirstWrite() { + firstReleased = true + releaseWaiters.forEach { $0.resume() } + releaseWaiters.removeAll() + } +} diff --git a/Tests/RepoPromptTests/ContextBuilder/ContextBuilderNestedMCPFailureTests.swift b/Tests/RepoPromptTests/ContextBuilder/ContextBuilderNestedMCPFailureTests.swift index 5ecc8a9e7..933a05270 100644 --- a/Tests/RepoPromptTests/ContextBuilder/ContextBuilderNestedMCPFailureTests.swift +++ b/Tests/RepoPromptTests/ContextBuilder/ContextBuilderNestedMCPFailureTests.swift @@ -128,10 +128,6 @@ import XCTest XCTAssertFalse(nestedEvents.contains { $0.phase == .handlerCompleted }) XCTAssertTrue(nestedEvents.contains { $0.phase == .cleanupGraceExpired }) XCTAssertTrue(nestedEvents.contains { $0.phase == .connectionForceDisconnectRequested }) - let nestedConnectionIsTerminal = await manager.debugIsExecutionWatchdogTerminal( - connectionID: nestedConnectionID - ) - XCTAssertTrue(nestedConnectionIsTerminal) let providerFailureCount = await state.providerFailureCount() XCTAssertEqual(providerFailureCount, 1) XCTAssertTrue(try Self.toolResultText(response).contains("failed:")) @@ -305,7 +301,13 @@ import XCTest func dispose() async {} private func cleanup(_ endpoint: PersistentMCPTestEndpoint) async { + await endpoint.client.waitUntilPendingWorkDrained() + XCTAssertTrue( + endpoint.client.pendingWorkSnapshotForTesting().isIdle, + "Nested MCP client still has pending request/interceptor work" + ) endpoint.client.close() + await endpoint.client.waitUntilReaderLoopStopped() await endpoint.connectionManager.stop() await networkManager.debugRemoveConnection(endpoint.connectionID) await networkManager.clearClientConnectionPolicy(for: endpoint.clientName) diff --git a/Tests/RepoPromptTests/ContextBuilder/ContextBuilderRunLifecycleTests.swift b/Tests/RepoPromptTests/ContextBuilder/ContextBuilderRunLifecycleTests.swift index 4864b5667..f0b72090b 100644 --- a/Tests/RepoPromptTests/ContextBuilder/ContextBuilderRunLifecycleTests.swift +++ b/Tests/RepoPromptTests/ContextBuilder/ContextBuilderRunLifecycleTests.swift @@ -133,11 +133,10 @@ final class ContextBuilderRunLifecycleTests: XCTestCase { let previousMCPAutoStart = GlobalSettingsStore.shared.mcpAutoStart() GlobalSettingsStore.shared.setMCPAutoStart(false, commit: false) - let testMCPService = MCPService() let composition = WindowStateCompositionFactory.make( windowID: -74, deferredInitialAgentSystemWorkspaceRefresh: true, - sharedMCPService: testMCPService, + coreContainer: RepoPromptAppCoreContainer.shared, contextBuilderProviderFactory: { _, _, _ in providers.next() } ) GlobalSettingsStore.shared.setMCPAutoStart(previousMCPAutoStart, commit: false) diff --git a/Tests/RepoPromptTests/Helpers/TestWorkspaceRuntime.swift b/Tests/RepoPromptTests/Helpers/TestWorkspaceRuntime.swift new file mode 100644 index 000000000..1012c544f --- /dev/null +++ b/Tests/RepoPromptTests/Helpers/TestWorkspaceRuntime.swift @@ -0,0 +1,113 @@ +import Foundation +@testable import RepoPrompt +@testable import RepoPromptCore +@testable import RepoPromptCoreMacOS + +func makeAppTestWorkspaceRuntimeDependencies( + maxPendingWatcherEntries: Int = 50000, + maxParallelScans: Int? = nil, + maxFoldersPerBatch: Int = 256, + diagnostics: (any WorkspaceRuntimeDiagnosticsSink)? = nil +) -> WorkspaceRuntimeDependencies { + WorkspaceExternalFileReaderProvider.install { MacOSWorkspaceExternalFileReader() } + let diagnostics = diagnostics ?? EmbeddedWorkspaceRuntimeDiagnosticsSink() + WorkspaceRuntimePerf.installProcessSink(diagnostics) + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("RepoPromptTests-Runtime", isDirectory: true) + .appendingPathComponent(UUID().uuidString, isDirectory: true) + return WorkspaceRuntimeDependencies( + watcherFactory: MacOSFSEventsWatcherFactory(), + directoryListingBackend: MacOSWorkspaceDirectoryListingBackend(), + fileContentSnapshotReader: MacOSFileContentSnapshotReader(), + mutationBackend: EmbeddedWorkspaceFileMutationBackend(), + partitionRoot: root.appendingPathComponent("Partitions", isDirectory: true), + codeMapCacheRoot: root.appendingPathComponent("CodeMapCaches", isDirectory: true), + configuration: WorkspaceRuntimeConfiguration( + maxPendingWatcherEntries: maxPendingWatcherEntries, + maxParallelScans: maxParallelScans, + maxFoldersPerBatch: maxFoldersPerBatch, + agentSupportRoot: root.appendingPathComponent("Agents", isDirectory: true), + globalIgnoreDefaults: "" + ), + diagnostics: diagnostics + ) +} + +extension WorkspaceFileContextStore { + init() { + self.init(runtimeDependencies: makeAppTestWorkspaceRuntimeDependencies()) + } +} + +@MainActor +extension WorkspaceFilesViewModel { + convenience init(workspaceFileContextStore: WorkspaceFileContextStore) { + let runtime = RepoPromptEmbeddedWorkspaceRuntimeFactory().makeRuntime() + self.init( + workspaceFileContextStore: workspaceFileContextStore, + selectionSliceCoordinator: runtime.selectionSliceCoordinator + ) + } +} + +extension FileSystemService { + init( + path: String, + respectGitignore: Bool = true, + respectRepoIgnore: Bool = true, + respectCursorignore: Bool = true, + skipSymlinks: Bool = true, + enableHierarchicalIgnores: Bool = true + ) async throws { + try await self.init( + path: path, + respectGitignore: respectGitignore, + respectRepoIgnore: respectRepoIgnore, + respectCursorignore: respectCursorignore, + skipSymlinks: skipSymlinks, + enableHierarchicalIgnores: enableHierarchicalIgnores, + dependencies: makeAppTestWorkspaceRuntimeDependencies() + ) + } + + #if DEBUG + init( + path: String, + respectGitignore: Bool = true, + respectRepoIgnore: Bool = true, + respectCursorignore: Bool = true, + skipSymlinks: Bool = true, + enableHierarchicalIgnores: Bool = true, + testVisitedPaths: Set? = nil, + testVisitedItems: [String: Bool]? = nil, + testIgnoreRules: IgnoreRules? = nil, + isTestMode: Bool = false, + fileManagerOverride: (any FileSystemProviding)? = nil, + maxParallelScansOverride: Int? = nil, + maxFoldersPerBatchOverride: Int? = nil, + maxPendingWatcherIngressEntriesOverride: Int? = nil + ) async throws { + try await self.init( + path: path, + respectGitignore: respectGitignore, + respectRepoIgnore: respectRepoIgnore, + respectCursorignore: respectCursorignore, + skipSymlinks: skipSymlinks, + enableHierarchicalIgnores: enableHierarchicalIgnores, + testVisitedPaths: testVisitedPaths, + testVisitedItems: testVisitedItems, + testIgnoreRules: testIgnoreRules, + isTestMode: isTestMode, + fileManagerOverride: fileManagerOverride, + maxParallelScansOverride: maxParallelScansOverride, + maxFoldersPerBatchOverride: maxFoldersPerBatchOverride, + maxPendingWatcherIngressEntriesOverride: maxPendingWatcherIngressEntriesOverride, + dependencies: makeAppTestWorkspaceRuntimeDependencies( + maxPendingWatcherEntries: maxPendingWatcherIngressEntriesOverride ?? 50000, + maxParallelScans: maxParallelScansOverride, + maxFoldersPerBatch: maxFoldersPerBatchOverride ?? 256 + ) + ) + } + #endif +} diff --git a/Tests/RepoPromptTests/MCP/Control/BootstrapSocketOwnershipTests.swift b/Tests/RepoPromptTests/MCP/Control/BootstrapSocketOwnershipTests.swift index 00131b5b1..7c3d47ab3 100644 --- a/Tests/RepoPromptTests/MCP/Control/BootstrapSocketOwnershipTests.swift +++ b/Tests/RepoPromptTests/MCP/Control/BootstrapSocketOwnershipTests.swift @@ -124,11 +124,11 @@ final class BootstrapSocketOwnershipTests: XCTestCase { func testSameFlavorDuplicateServerCannotDeleteFirstListener() async throws { let socketURL = temporaryDirectory.appendingPathComponent("server.sock") let first = BootstrapSocketServer(socketURL: socketURL) - try await first.start { _, _, _, _ in .reject() } + try await first.start { _, _, _ in .reject() } let second = BootstrapSocketServer(socketURL: socketURL) do { - try await second.start { _, _, _, _ in .reject() } + try await second.start { _, _, _ in .reject() } XCTFail("Expected duplicate ownership failure") } catch { guard case BootstrapSocketOwnership.OwnershipError.lockUnavailable = error else { @@ -151,8 +151,8 @@ final class BootstrapSocketOwnershipTests: XCTestCase { let releaseURL = temporaryDirectory.appendingPathComponent("repoprompt-ce-7.sock") let debugServer = BootstrapSocketServer(socketURL: debugURL) let releaseServer = BootstrapSocketServer(socketURL: releaseURL) - try await debugServer.start { _, _, _, _ in .reject() } - try await releaseServer.start { _, _, _, _ in .reject() } + try await debugServer.start { _, _, _ in .reject() } + try await releaseServer.start { _, _, _ in .reject() } await releaseServer.stop() XCTAssertNotNil(BootstrapSocketOwnership.identity(atPath: debugURL.path)) diff --git a/Tests/RepoPromptTests/MCP/Control/MCPAppProxyAcceptedTransportLeaseTests.swift b/Tests/RepoPromptTests/MCP/Control/MCPAppProxyAcceptedTransportLeaseTests.swift new file mode 100644 index 000000000..9fc0bc142 --- /dev/null +++ b/Tests/RepoPromptTests/MCP/Control/MCPAppProxyAcceptedTransportLeaseTests.swift @@ -0,0 +1,113 @@ +import Darwin +@testable import RepoPrompt +import RepoPromptCore +import XCTest + +final class MCPAppProxyAcceptedTransportLeaseTests: XCTestCase { + private final class TransportRecorder: @unchecked Sendable { + private let lock = NSLock() + private var transport: (any MCPAppProxyAcceptedTransport)? + + func record(_ transport: any MCPAppProxyAcceptedTransport) { + lock.lock() + self.transport = transport + lock.unlock() + } + + var hasTransport: Bool { + lock.lock() + defer { lock.unlock() } + return transport != nil + } + } + + func testLeaseTransitionsFromListenerOwnershipThroughTransferAndOneTimeClaim() throws { + let descriptors = try Self.makeSocketPair() + defer { Self.closeIfOpen(descriptors[1]) } + + let lease = MacOSBootstrapAcceptedTransportLease(fileDescriptor: descriptors[0]) + XCTAssertEqual(lease.state, .listenerOwned) + XCTAssertTrue(lease.reserveForAdmission()) + XCTAssertEqual(lease.state, .admissionReserved) + + let published = TransportRecorder() + XCTAssertTrue(lease.transfer { transport in + published.record(transport) + return true + }) + XCTAssertEqual(lease.state, .transferred) + XCTAssertTrue(published.hasTransport) + XCTAssertEqual(lease.claimConnectedFileDescriptor(), descriptors[0]) + XCTAssertNil(lease.claimConnectedFileDescriptor()) + + Self.closeIfOpen(descriptors[0]) + } + + func testRollbackClosesReservedTransportExactlyOnce() throws { + let descriptors = try Self.makeSocketPair() + defer { Self.closeIfOpen(descriptors[1]) } + + let lease = MacOSBootstrapAcceptedTransportLease(fileDescriptor: descriptors[0]) + XCTAssertTrue(lease.reserveForAdmission()) + lease.rollback() + lease.rollback() + + XCTAssertEqual(lease.state, .closed) + XCTAssertTrue(Self.isClosed(descriptors[0])) + XCTAssertTrue(Self.peerObservedEOF(on: descriptors[1])) + } + + func testFailedPublicationClosesTransferredTransport() throws { + let descriptors = try Self.makeSocketPair() + defer { Self.closeIfOpen(descriptors[1]) } + + let lease = MacOSBootstrapAcceptedTransportLease(fileDescriptor: descriptors[0]) + XCTAssertTrue(lease.reserveForAdmission()) + XCTAssertFalse(lease.transfer { _ in false }) + + XCTAssertEqual(lease.state, .closed) + XCTAssertTrue(Self.isClosed(descriptors[0])) + XCTAssertTrue(Self.peerObservedEOF(on: descriptors[1])) + } + + func testReentrantCloseDuringPublicationDoesNotDeadlockAndRollsBack() throws { + let descriptors = try Self.makeSocketPair() + defer { Self.closeIfOpen(descriptors[1]) } + + let lease = MacOSBootstrapAcceptedTransportLease(fileDescriptor: descriptors[0]) + XCTAssertTrue(lease.reserveForAdmission()) + XCTAssertFalse(lease.transfer { transport in + transport.close() + return true + }) + + XCTAssertEqual(lease.state, .closed) + XCTAssertTrue(Self.isClosed(descriptors[0])) + XCTAssertTrue(Self.peerObservedEOF(on: descriptors[1])) + } + + private static func makeSocketPair() throws -> [Int32] { + var descriptors = [Int32](repeating: -1, count: 2) + guard Darwin.socketpair(AF_UNIX, SOCK_STREAM, 0, &descriptors) == 0 else { + throw NSError(domain: NSPOSIXErrorDomain, code: Int(errno)) + } + return descriptors + } + + private static func closeIfOpen(_ fd: Int32) { + guard fd >= 0, fcntl(fd, F_GETFD) >= 0 else { return } + Darwin.close(fd) + } + + private static func isClosed(_ fd: Int32) -> Bool { + errno = 0 + return fcntl(fd, F_GETFD) == -1 && errno == EBADF + } + + private static func peerObservedEOF(on fd: Int32) -> Bool { + var descriptor = pollfd(fd: fd, events: Int16(POLLIN | POLLHUP | POLLERR), revents: 0) + guard Darwin.poll(&descriptor, 1, 2000) > 0 else { return false } + var byte: UInt8 = 0 + return Darwin.recv(fd, &byte, 1, Int32(MSG_PEEK | MSG_DONTWAIT)) == 0 + } +} diff --git a/Tests/RepoPromptTests/MCP/Control/MCPBootstrapContractCharacterizationTests.swift b/Tests/RepoPromptTests/MCP/Control/MCPBootstrapContractCharacterizationTests.swift new file mode 100644 index 000000000..1f02c10e3 --- /dev/null +++ b/Tests/RepoPromptTests/MCP/Control/MCPBootstrapContractCharacterizationTests.swift @@ -0,0 +1,134 @@ +import Darwin +import Foundation +@testable import RepoPrompt +@testable import RepoPromptCore +import RepoPromptShared +import XCTest + +final class MCPBootstrapContractCharacterizationTests: XCTestCase { + func testBootstrapRequestEncodingContract() throws { + let request = MCPBootstrapRequest( + sessionToken: "characterization-token", + clientPid: 4242, + clientName: "RepoPrompt CLI (Characterization)", + protocolVersion: 2 + ) + + let json = try jsonObject(request) + + XCTAssertEqual(Set(json.allKeys.compactMap { $0 as? String }), [ + "type", + "sessionToken", + "clientPid", + "clientName", + "protocolVersion" + ]) + XCTAssertEqual(json["type"] as? String, "connect") + XCTAssertEqual(json["sessionToken"] as? String, "characterization-token") + XCTAssertEqual(json["clientPid"] as? Int, 4242) + XCTAssertEqual(json["clientName"] as? String, "RepoPrompt CLI (Characterization)") + XCTAssertEqual(json["protocolVersion"] as? Int, 2) + } + + func testBootstrapResponseEncodingContract() throws { + let accepted = try jsonObject(MCPBootstrapResponse.accepted()) + XCTAssertEqual(Set(accepted.allKeys.compactMap { $0 as? String }), ["type"]) + XCTAssertEqual(accepted["type"] as? String, "accepted") + + let rejected = try jsonObject(MCPBootstrapResponse.rejected( + reason: "protocol mismatch", + errorCode: MCPBootstrapErrorCode.protocolVersionMismatch.rawValue + )) + XCTAssertEqual(Set(rejected.allKeys.compactMap { $0 as? String }), ["type", "reason", "errorCode"]) + XCTAssertEqual(rejected["type"] as? String, "rejected") + XCTAssertEqual(rejected["reason"] as? String, "protocol mismatch") + XCTAssertEqual(rejected["errorCode"] as? String, "protocol_version_mismatch") + } + + func testBootstrapVersionsErrorCodesAndV7FlavorSocketPathsRemainStable() { + XCTAssertEqual(MCPBootstrapProtocol.currentVersion, 2) + XCTAssertEqual(MCPConstants.bootstrapProtocolVersion, MCPBootstrapProtocol.currentVersion) + XCTAssertEqual(MCPBootstrapTiming.initialResponseTimeout, 5) + XCTAssertEqual(MCPFilesystemIdentity.currentProtocolVersion, 7) + XCTAssertEqual(MCPFilesystemConstants.socketVersion, MCPFilesystemIdentity.currentProtocolVersion) + + let userID = UInt32(getuid()) + let debugPath = MCPFilesystemIdentity.repoPromptCE(.debug).bootstrapSocketURL(userID: userID).path + let releasePath = MCPFilesystemIdentity.repoPromptCE(.release).bootstrapSocketURL(userID: userID).path + XCTAssertEqual(debugPath, "/tmp/repoprompt-ce-mcp-\(userID)/repoprompt-ce-D-7.sock") + XCTAssertEqual(releasePath, "/tmp/repoprompt-ce-mcp-\(userID)/repoprompt-ce-7.sock") + XCTAssertNotEqual(debugPath, releasePath) + XCTAssertLessThan(debugPath.utf8.count, 104) + XCTAssertLessThan(releasePath.utf8.count, 104) + + #if DEBUG + XCTAssertEqual(MCPFilesystemConstants.bootstrapSocketURL().path, debugPath) + #else + XCTAssertEqual(MCPFilesystemConstants.bootstrapSocketURL().path, releasePath) + #endif + + XCTAssertEqual(MCPBootstrapErrorCode.approvalDenied.rawValue, "approval_denied") + XCTAssertEqual(MCPBootstrapErrorCode.protocolVersionMismatch.rawValue, "protocol_version_mismatch") + XCTAssertEqual(MCPBootstrapErrorCode.serverNotReady.rawValue, "server_not_ready") + XCTAssertEqual(MCPBootstrapErrorCode.serverUnavailable.rawValue, "server_unavailable") + XCTAssertEqual(MCPBootstrapErrorCode.connectionLimitReached.rawValue, "connection_limit_reached") + XCTAssertEqual(MCPBootstrapErrorCode.capacityExceeded.rawValue, "capacity_exceeded") + XCTAssertEqual(MCPBootstrapErrorCode.sessionBlocked.rawValue, "session_blocked") + XCTAssertEqual(MCPBootstrapErrorCode.clientCooldown.rawValue, "client_cooldown") + } + + func testHandshakeFallbackPIDRemainsDiagnosticOnly() { + let forgedPID = 4242 + let identity = MCPPeerIdentity(socketObservedPID: nil, handshakeClaimedPID: forgedPID) + + XCTAssertNil(identity.trustedPID) + XCTAssertEqual(identity.diagnosticPID, forgedPID) + XCTAssertEqual(identity.provenance, .handshakeFallback) + } + + func testHandshakeClaimedPIDValidationRejectsOutOfRangeValues() { + XCTAssertFalse(MCPPeerIdentity.isValidHandshakeClaimedPID(0)) + XCTAssertFalse(MCPPeerIdentity.isValidHandshakeClaimedPID(-1)) + XCTAssertTrue(MCPPeerIdentity.isValidHandshakeClaimedPID(1)) + XCTAssertTrue(MCPPeerIdentity.isValidHandshakeClaimedPID(Int(Int32.max))) + XCTAssertFalse(MCPPeerIdentity.isValidHandshakeClaimedPID(Int(Int32.max) + 1)) + } + + func testBootstrapHandshakeDTOsAreSingleSourcedInRepoPromptShared() throws { + let root = try RepoRoot.url() + let sharedMessages = root.appendingPathComponent("Sources/RepoPromptShared/MCP/MCPBootstrapMessages.swift") + let appMessages = root.appendingPathComponent("Sources/RepoPrompt/Infrastructure/MCP/AppShared/MCPBootstrapMessages.swift") + let cliMessages = root.appendingPathComponent("Sources/RepoPromptMCP/Shared/MCPBootstrapMessages.swift") + + XCTAssertTrue(FileManager.default.fileExists(atPath: sharedMessages.path)) + XCTAssertFalse(FileManager.default.fileExists(atPath: appMessages.path)) + XCTAssertFalse(FileManager.default.fileExists(atPath: cliMessages.path)) + } + + func testNonImportableCLIProxyRetainsCharacterizedBootstrapEncodingSeams() throws { + let root = try RepoRoot.url() + let proxy = try sourceText("Sources/RepoPromptMCP/main.swift", relativeTo: root) + let interactive = try sourceText( + "Sources/RepoPromptMCP/Interactive/InteractiveMCPClientSession.swift", + relativeTo: root + ) + + XCTAssertTrue(proxy.contains("let kBootstrapProtocolVersion = MCPBootstrapProtocol.currentVersion")) + XCTAssertTrue(proxy.contains("socketURL = MCPFilesystemConstants.bootstrapSocketURL()")) + XCTAssertTrue(proxy.contains("protocolVersion: kBootstrapProtocolVersion")) + XCTAssertTrue(proxy.contains("payload.append(UInt8(ascii: \"\\n\"))")) + + XCTAssertTrue(interactive.contains("let socketURL = MCPFilesystemConstants.bootstrapSocketURL()")) + XCTAssertTrue(interactive.contains("protocolVersion: MCPBootstrapProtocol.currentVersion")) + XCTAssertTrue(interactive.contains("payload.append(UInt8(ascii: \"\\n\"))")) + } + + private func jsonObject(_ value: some Encodable) throws -> NSDictionary { + let data = try JSONEncoder().encode(value) + return try XCTUnwrap(JSONSerialization.jsonObject(with: data) as? NSDictionary) + } + + private func sourceText(_ relativePath: String, relativeTo root: URL) throws -> String { + try String(contentsOf: root.appendingPathComponent(relativePath), encoding: .utf8) + } +} diff --git a/Tests/RepoPromptTests/MCP/Control/MCPResponseSendDeadlineConfigurationTests.swift b/Tests/RepoPromptTests/MCP/Control/MCPResponseSendDeadlineConfigurationTests.swift index 591a6076d..cc412b248 100644 --- a/Tests/RepoPromptTests/MCP/Control/MCPResponseSendDeadlineConfigurationTests.swift +++ b/Tests/RepoPromptTests/MCP/Control/MCPResponseSendDeadlineConfigurationTests.swift @@ -36,38 +36,69 @@ final class MCPResponseSendDeadlineConfigurationTests: XCTestCase { ) let root = try RepoRoot.url() + let appSourceRoot = root.appendingPathComponent("Sources/RepoPrompt") + let cliSourceRoot = root.appendingPathComponent("Sources/RepoPromptMCP") + let server = try source( - root: root, - path: "Sources/RepoPrompt/Infrastructure/MCP/BootstrapSocketConnectionManager.swift" + declaring: "BootstrapSocketConnectionManager", + under: appSourceRoot ) XCTAssertTrue(server.contains("responseSendTimeout: MCPTimeoutPolicy.responseSendDeadline")) let appTransport = try source( - root: root, - path: "Sources/RepoPrompt/Infrastructure/MCP/UnixSocketMCPTransport.swift" + declaring: "UnixSocketMCPTransport", + under: appSourceRoot ) XCTAssertTrue(appTransport.contains( "writeStallTimeout: TimeInterval = MCPTimeoutPolicy.transportWriteStallTimeoutSeconds" )) let cliTransport = try source( - root: root, - path: "Sources/RepoPromptMCP/Transports/BootstrapSocketMCPTransport.swift" + declaring: "BootstrapSocketMCPTransport", + under: cliSourceRoot ) XCTAssertTrue(cliTransport.contains( "writeStallTimeout: TimeInterval = MCPTimeoutPolicy.transportWriteStallTimeoutSeconds" )) let cliWriter = try source( - root: root, - path: "Sources/RepoPromptMCP/Transports/NonBlockingFDWriter.swift" + declaring: "NonBlockingFDWriter", + under: cliSourceRoot ) XCTAssertTrue(cliWriter.contains( "stallTimeout: TimeInterval = MCPTimeoutPolicy.transportWriteStallTimeoutSeconds" )) } - private func source(root: URL, path: String) throws -> String { - try String(contentsOf: root.appendingPathComponent(path), encoding: .utf8) + private func source(declaring declarationName: String, under sourceRoot: URL) throws -> String { + let escapedName = NSRegularExpression.escapedPattern(for: declarationName) + let declaration = try NSRegularExpression( + pattern: #"(?m)^\s*(?:(?:public|package|internal|private|fileprivate|open|final|indirect|nonisolated)\s+)*(?:actor|class|struct|enum|protocol)\s+"# + + escapedName + + #"\b"# + ) + let swiftFiles = try XCTUnwrap( + FileManager.default.enumerator( + at: sourceRoot, + includingPropertiesForKeys: [.isRegularFileKey], + options: [.skipsHiddenFiles] + ) + ) + + var owners: [(url: URL, source: String)] = [] + for case let fileURL as URL in swiftFiles where fileURL.pathExtension == "swift" { + let source = try String(contentsOf: fileURL, encoding: .utf8) + let range = NSRange(source.startIndex..., in: source) + if declaration.firstMatch(in: source, range: range) != nil { + owners.append((fileURL, source)) + } + } + + XCTAssertEqual( + owners.count, + 1, + "Expected exactly one Swift declaration owner for \(declarationName) under \(sourceRoot.path); found \(owners.map(\.url.path))" + ) + return try XCTUnwrap(owners.first?.source) } } diff --git a/Tests/RepoPromptTests/MCP/Control/MCPSocketDescriptorHardeningTests.swift b/Tests/RepoPromptTests/MCP/Control/MCPSocketDescriptorHardeningTests.swift index 02443ee2f..4f1ac9d84 100644 --- a/Tests/RepoPromptTests/MCP/Control/MCPSocketDescriptorHardeningTests.swift +++ b/Tests/RepoPromptTests/MCP/Control/MCPSocketDescriptorHardeningTests.swift @@ -1,11 +1,12 @@ import Darwin import Foundation @testable import RepoPrompt +import RepoPromptPOSIXSupport import RepoPromptShared import XCTest final class MCPSocketDescriptorHardeningTests: XCTestCase { - func testSharedHelperSetsAndPreservesDescriptorFlags() throws { + func testPOSIXSupportHelperSetsAndPreservesDescriptorFlags() throws { var descriptors = [Int32](repeating: -1, count: 2) XCTAssertEqual(Darwin.pipe(&descriptors), 0) defer { @@ -46,8 +47,13 @@ final class MCPSocketDescriptorHardeningTests: XCTestCase { let server = BootstrapSocketServer(socketURL: fixture.socketURL) let acceptedFlag = OptionalBoolRecorder() - try await server.start { fd, _, _, _ in - await acceptedFlag.record(Self.hasCloseOnExec(fd)) + try await server.start { inboundConnection, _, _ in + let transportLease = try? XCTUnwrap( + inboundConnection.transportLease as? MacOSBootstrapAcceptedTransportLease + ) + await acceptedFlag.record( + transportLease.map { Self.hasCloseOnExec($0.fileDescriptor) } ?? false + ) return .reject() } do { @@ -92,7 +98,7 @@ final class MCPSocketDescriptorHardeningTests: XCTestCase { var clientFDs: [Int32] = [] defer { clientFDs.forEach(Self.closeIfOpen) } - try await server.start { _, _, _, _ in + try await server.start { _, _, _ in await admissionCount.increment() return .reject() } @@ -240,7 +246,7 @@ final class MCPSocketDescriptorHardeningTests: XCTestCase { await server.stop() do { - try await server.start { _, _, _, _ in .reject() } + try await server.start { _, _, _ in .reject() } XCTFail("Expected tombstoned bootstrap listener start to fail") } catch BootstrapSocketError.startCancelled {} catch { XCTFail("Unexpected tombstoned listener start error: \(error)") @@ -287,13 +293,11 @@ final class MCPSocketDescriptorHardeningTests: XCTestCase { let gate = SuspendedAdmissionGate() let postAcceptCount = AsyncCounter() let abortCount = AsyncCounter() - let transferredFDs = SynchronousFDRecorder() - defer { transferredFDs.closeAll() } - try await server.start { _, _, _, _ in + try await server.start { _, _, _ in await gate.suspendUntilReleased() return .accept( - publishTransferredFD: { transferredFDs.record($0) }, + publishTransferredTransport: { _ in true }, postAccept: { await postAcceptCount.increment() }, onAcceptAborted: { await abortCount.increment() } ) @@ -538,7 +542,7 @@ final class MCPSocketDescriptorHardeningTests: XCTestCase { #endif } - func testNonImportableCLISourcesUseSharedDescriptorHardening() throws { + func testNonImportableCLISourcesUsePOSIXSupportDescriptorHardening() throws { let root = try RepoRoot.url() let main = try Self.sourceText("Sources/RepoPromptMCP/main.swift", relativeTo: root) let interactive = try Self.sourceText( @@ -550,6 +554,10 @@ final class MCPSocketDescriptorHardeningTests: XCTestCase { relativeTo: root ) + XCTAssertTrue(main.contains("import RepoPromptPOSIXSupport")) + XCTAssertTrue(interactive.contains("import RepoPromptPOSIXSupport")) + XCTAssertTrue(transport.contains("import RepoPromptPOSIXSupport")) + XCTAssertGreaterThanOrEqual( main.occurrenceCount(of: "POSIXDescriptorSupport.setCloseOnExec"), 2, @@ -593,33 +601,6 @@ final class MCPSocketDescriptorHardeningTests: XCTestCase { ) } - func testAppTransportReplacesTerminalInboundChannelsBeforeReconnect() throws { - let root = try RepoRoot.url() - let transport = try Self.sourceText( - "Sources/RepoPrompt/Infrastructure/MCP/UnixSocketMCPTransport.swift", - relativeTo: root - ) - - Self.assertSourceContains( - [ - "private struct InboundChannel {", - "private struct CloseChannel {", - "private let receiveBufferCapacity: Int", - "private var inboundChannel: InboundChannel", - "private var closeChannel: CloseChannel", - "prepareForConnectionAttempt()", - "if streamFinished || closeSignaled {", - "inboundChannel = InboundChannel(capacity: receiveBufferCapacity)", - "closeChannel = CloseChannel()", - "let inboundChannel = inboundChannel", - "inboundChannel.gate.offer(frame, to: inboundChannel.continuation)", - "inboundChannel.continuation.finish", - "closeChannel.continuation.finish()" - ], - in: transport - ) - } - func testNonImportableCLITransportClaimsEarlyAndDelayedReaderCancellationExactlyOnce() throws { let root = try RepoRoot.url() let transport = try Self.sourceText( diff --git a/Tests/RepoPromptTests/MCP/Control/MCPToolExecutionWatchdogIntegrationTests.swift b/Tests/RepoPromptTests/MCP/Control/MCPToolExecutionWatchdogIntegrationTests.swift index ea900ed1d..0283b4cbe 100644 --- a/Tests/RepoPromptTests/MCP/Control/MCPToolExecutionWatchdogIntegrationTests.swift +++ b/Tests/RepoPromptTests/MCP/Control/MCPToolExecutionWatchdogIntegrationTests.swift @@ -562,9 +562,7 @@ import XCTest await Self.assertSocketClosed(first) await Self.assertSocketClosed(queued) let enteredCount = await operationGate.enteredCount() - let isTerminal = await manager.debugIsExecutionWatchdogTerminal(connectionID: endpoint.connectionID) XCTAssertEqual(enteredCount, 1) - XCTAssertTrue(isTerminal) let events = recorder.snapshot().filter { $0.connectionID == endpoint.connectionID && $0.toolName == MCPWindowToolName.readFile @@ -593,7 +591,13 @@ import XCTest _ endpoint: PersistentMCPTestEndpoint, manager: ServerNetworkManager ) async { + await endpoint.client.waitUntilPendingWorkDrained() + XCTAssertTrue( + endpoint.client.pendingWorkSnapshotForTesting().isIdle, + "Watchdog MCP client still has pending request/interceptor work" + ) endpoint.client.close() + await endpoint.client.waitUntilReaderLoopStopped() await endpoint.connectionManager.stop() await manager.debugRemoveConnection(endpoint.connectionID) await manager.debugClearPersistedRoutingState(for: endpoint.clientName) diff --git a/Tests/RepoPromptTests/MCP/Control/PersistentAgentModeMCPReadFileConnectionTests.swift b/Tests/RepoPromptTests/MCP/Control/PersistentAgentModeMCPReadFileConnectionTests.swift index 70026bade..dfce4be88 100644 --- a/Tests/RepoPromptTests/MCP/Control/PersistentAgentModeMCPReadFileConnectionTests.swift +++ b/Tests/RepoPromptTests/MCP/Control/PersistentAgentModeMCPReadFileConnectionTests.swift @@ -1,6 +1,7 @@ import Darwin import Foundation @testable import RepoPrompt +@testable import RepoPromptCore import XCTest @MainActor @@ -167,6 +168,7 @@ final class PersistentAgentModeMCPReadFileConnectionTests: XCTestCase { try await startTask.value } catch { startTask.cancel() + fixture.socketClient.close() await manager.stop() _ = try? await startTask.value throw error @@ -649,6 +651,7 @@ final class PersistentAgentModeMCPReadFileConnectionTests: XCTestCase { try? FileManager.default.removeItem(at: rootURL) throw error } + await ServerNetworkManager.shared.setEnabled(true) let previousAutoStart = GlobalSettingsStore.shared.mcpAutoStart() GlobalSettingsStore.shared.setMCPAutoStart(false, commit: false) @@ -659,6 +662,8 @@ final class PersistentAgentModeMCPReadFileConnectionTests: XCTestCase { // earlier tests are filtered by window ID instead of relying on singleton cleanliness. WindowStatesManager.shared.registerWindowState(routingGuardWindow) GlobalSettingsStore.shared.setMCPAutoStart(previousAutoStart, commit: false) + await window.workspaceManager.awaitInitialized() + await routingGuardWindow.workspaceManager.awaitInitialized() var rootID: UUID? var catalogService: MCPWindowToolCatalogService? @@ -672,14 +677,14 @@ final class PersistentAgentModeMCPReadFileConnectionTests: XCTestCase { repoPaths: [rootURL.path], ephemeral: true ) - let workspaceIndex = try XCTUnwrap( - window.workspaceManager.workspaces.firstIndex { $0.id == workspace.id } + let configuredWorkspace = try XCTUnwrap( + window.workspaceManager.mutateWorkspace(id: workspace.id) { workspace in + workspace.composeTabs = [ + ComposeTabState(id: tabID, name: "Persistent Agent Mode MCP Read") + ] + workspace.activeComposeTabID = tabID + } ) - window.workspaceManager.workspaces[workspaceIndex].composeTabs = [ - ComposeTabState(id: tabID, name: "Persistent Agent Mode MCP Read") - ] - window.workspaceManager.workspaces[workspaceIndex].activeComposeTabID = tabID - let configuredWorkspace = window.workspaceManager.workspaces[workspaceIndex] await window.workspaceManager.switchWorkspace( to: configuredWorkspace, saveState: false, @@ -697,6 +702,13 @@ final class PersistentAgentModeMCPReadFileConnectionTests: XCTestCase { let resolvedCatalogService = window.mcpServer.windowMCPToolCatalogService catalogService = resolvedCatalogService + guard ServerNetworkManager.shared.runtimeSessionRegistry.setMCPEnabled( + windowID: window.windowID, + expectedSessionID: window.coreSessionHandle.sessionID, + enabled: true + ) else { + throw ClientFixtureError.runtimeSessionEnablementFailed + } ServiceRegistry.register(resolvedCatalogService) var socketFDs = [Int32](repeating: -1, count: 2) @@ -721,7 +733,10 @@ final class PersistentAgentModeMCPReadFileConnectionTests: XCTestCase { let resolvedConnectionManager = try BootstrapSocketConnectionManager( connectionID: connectionID, sessionToken: sessionToken, - clientPid: Int(getpid()), + peerIdentity: MCPPeerIdentity( + socketObservedPID: Int(getpid()), + handshakeClaimedPID: Int(getpid()) + ), clientName: AgentProviderKind.codexMCPClientID, purpose: .agentModeRun, codeMapsDisabled: false, @@ -761,8 +776,8 @@ final class PersistentAgentModeMCPReadFileConnectionTests: XCTestCase { lease: resolvedLease ) } catch { - await connectionManager?.stop() socketClient?.close() + await connectionManager?.stop() await ServerNetworkManager.shared.removeConnection(connectionID) await ServerNetworkManager.shared.clearExpectedAgentPID( getpid(), @@ -788,8 +803,8 @@ final class PersistentAgentModeMCPReadFileConnectionTests: XCTestCase { if let rootID { await window.workspaceFileContextStore.unloadRoot(id: rootID) } - WindowStatesManager.shared.unregisterWindowState(routingGuardWindow) - WindowStatesManager.shared.unregisterWindowState(window) + await WindowStatesManager.shared.unregisterWindowStateAndWait(routingGuardWindow) + await WindowStatesManager.shared.unregisterWindowStateAndWait(window) try? FileManager.default.removeItem(at: rootURL) throw error } @@ -835,8 +850,8 @@ final class PersistentAgentModeMCPReadFileConnectionTests: XCTestCase { guard !cleanedUp else { return } cleanedUp = true - await connectionManager.stop() socketClient.close() + await connectionManager.stop() await networkManager.removeConnection(Self.connectionID) await networkManager.clearExpectedAgentPID( getpid(), @@ -858,8 +873,8 @@ final class PersistentAgentModeMCPReadFileConnectionTests: XCTestCase { ) ServiceRegistry.unregister(catalogService) await window.workspaceFileContextStore.unloadRoot(id: rootID) - WindowStatesManager.shared.unregisterWindowState(routingGuardWindow) - WindowStatesManager.shared.unregisterWindowState(window) + await WindowStatesManager.shared.unregisterWindowStateAndWait(routingGuardWindow) + await WindowStatesManager.shared.unregisterWindowStateAndWait(window) try? FileManager.default.removeItem(at: rootURL) } } @@ -867,6 +882,7 @@ final class PersistentAgentModeMCPReadFileConnectionTests: XCTestCase { private enum ClientFixtureError: Error { case exactAbsoluteCatalogMiss case leaseAcquisitionFailed + case runtimeSessionEnablementFailed } private struct RetainedConnectionSnapshot: Equatable { diff --git a/Tests/RepoPromptTests/MCP/Control/PersistentMCPDistinctConnectionConcurrencyTests.swift b/Tests/RepoPromptTests/MCP/Control/PersistentMCPDistinctConnectionConcurrencyTests.swift index 8dfe99805..0c9b5ae02 100644 --- a/Tests/RepoPromptTests/MCP/Control/PersistentMCPDistinctConnectionConcurrencyTests.swift +++ b/Tests/RepoPromptTests/MCP/Control/PersistentMCPDistinctConnectionConcurrencyTests.swift @@ -2,10 +2,90 @@ import Darwin import Foundation import MCP @testable import RepoPrompt +@testable import RepoPromptCore import XCTest @MainActor final class PersistentMCPDistinctConnectionConcurrencyTests: XCTestCase { + func testBootstrapStopCompletesForPartiallyInitializedBoundConnection() async throws { + #if DEBUG + try await MCPSharedServerTestLease.shared.withLease { lease in + _ = lease + let networkManager = ServerNetworkManager.shared + await networkManager.setEnabled(true) + + var socketFDs = [Int32](repeating: -1, count: 2) + guard Darwin.socketpair(AF_UNIX, SOCK_STREAM, 0, &socketFDs) == 0 else { + throw PersistentMCPTestSocketClient.ClientError.posix(operation: "socketpair", code: errno) + } + var clientFD = socketFDs[0] + defer { + if clientFD >= 0 { + Darwin.close(clientFD) + } + } + + let connectionID = UUID() + let sessionToken = "persistent-mcp-partial-stop-\(UUID().uuidString)" + let clientName = "persistent-mcp-partial-stop-\(UUID().uuidString)" + let manager = try BootstrapSocketConnectionManager( + connectionID: connectionID, + sessionToken: sessionToken, + peerIdentity: MCPPeerIdentity( + socketObservedPID: Int(getpid()), + handshakeClaimedPID: Int(getpid()) + ), + clientName: clientName, + purpose: .unknown, + codeMapsDisabled: false, + connectedFD: socketFDs[1], + parentManager: networkManager + ) + await networkManager.debugRegisterConnectionForSocketFixture( + connectionID: connectionID, + connection: manager, + clientName: clientName, + sessionToken: sessionToken + ) + + let startTask = Task { + try await manager.start { _ in true } + } + while await !(manager.debugTransportCleanupSnapshot()).hasActiveReader { + await Task.yield() + } + + await withTaskGroup(of: Void.self) { group in + group.addTask { await manager.stop() } + group.addTask { await manager.stop() } + } + _ = try? await startTask.value + + Darwin.close(clientFD) + clientFD = -1 + + while true { + let snapshot = await manager.debugTransportCleanupSnapshot() + if !snapshot.hasActiveReader, + snapshot.pendingReaderCancellationCount == 0, + !snapshot.readerIsRetained, + !snapshot.socketIsOwned + { + break + } + await Task.yield() + } + + await networkManager.debugRemoveConnection(connectionID) + await networkManager.debugClearPersistedRoutingState(for: clientName) + let finalState = await manager.connectionState() + XCTAssertEqual(finalState, .cancelled) + } + #else + throw XCTSkip("Bootstrap socket shutdown regression requires DEBUG transport diagnostics.") + #endif + } + func testDistinctConnectionsOverlapWithoutCrossRoutingReadOrSearchResults() async throws { #if DEBUG try await MCPSharedServerTestLease.shared.withLease { lease in @@ -29,7 +109,8 @@ final class PersistentMCPDistinctConnectionConcurrencyTests: XCTestCase { try await MCPSharedServerTestLease.shared.withLease { lease in let fixture = try await PersistentMCPTestFixture.make( lease: lease, - contextASearchFileCount: 12 + contextASearchFileCount: 12, + includeStandardAuxiliaryEndpoints: false ) do { try await runK12SharedWindowSearchCheckpoint(fixture: fixture) @@ -62,6 +143,27 @@ final class PersistentMCPDistinctConnectionConcurrencyTests: XCTestCase { #endif } + func testQueuedCallRejectsRouteInvalidatedWhileWaitingForConnectionPermit() async throws { + #if DEBUG + try await MCPSharedServerTestLease.shared.withLease { lease in + let fixture = try await PersistentMCPTestFixture.make( + lease: lease, + includeStandardAuxiliaryEndpoints: false + ) + do { + try await runQueuedCatalogInvalidationCheckpoint(fixture: fixture) + await fixture.cleanup() + try await fixture.assertCleanedUp() + } catch { + await fixture.cleanup() + throw error + } + } + #else + throw XCTSkip("Queued MCP dispatch regression requires DEBUG limiter observation.") + #endif + } + func testRemoveConnectionSourceStillDropsPerConnectionLimiter() throws { let sourceURL = try RepoRoot.url() .appendingPathComponent("Sources/RepoPrompt/Infrastructure/MCP/MCPConnectionManager.swift") @@ -113,51 +215,75 @@ final class PersistentMCPDistinctConnectionConcurrencyTests: XCTestCase { await store.setSearchLanePermitAcquiredHandlerForTesting { await gate.markStartedAndWaitForRelease() } - let broadTask = Task { - try await primary.callTool( - name: MCPWindowToolName.search, - arguments: Self.sharedWindowBroadSearchArguments(countOnly: false) - ) - } - var scopedTasks: [Task<(URL, PersistentMCPTestRPCResponse), Error>] = [] do { - let broadStarted = await gate.waitUntilStarted() - XCTAssertTrue(broadStarted) - scopedTasks = zip(endpoints.dropFirst(), searchFiles.dropFirst()).map { endpoint, fileURL in - Task { - let response = try await endpoint.callTool( - name: MCPWindowToolName.search, - arguments: Self.sharedWindowScopedSearchArguments(path: fileURL.path) - ) - return (fileURL, response) - } - } - for task in scopedTasks { - let (fileURL, response) = try await task.value - let text = try Self.toolText(from: response) - Self.assertHealthySearchText(text) - XCTAssertTrue(text.contains(fileURL.lastPathComponent), text) - for otherFileURL in searchFiles where otherFileURL != fileURL { - XCTAssertFalse(text.contains(otherFileURL.lastPathComponent), text) - } - XCTAssertFalse(text.contains(fixture.contextB.fileURL.lastPathComponent), text) - } + try await withThrowingTaskGroup( + of: (K12ScopedSearchOperation, PersistentMCPTestRPCResponse).self + ) { group in + do { + group.addTask { + let response = try await primary.callTool( + name: MCPWindowToolName.search, + arguments: Self.sharedWindowBroadSearchArguments(countOnly: false) + ) + return (.broad, response) + } + let broadStarted = await gate.waitUntilStarted() + XCTAssertTrue(broadStarted) + + for (index, endpoint) in endpoints.dropFirst().enumerated() { + let fileURL = searchFiles[index + 1] + group.addTask { + let response = try await endpoint.callTool( + name: MCPWindowToolName.search, + arguments: Self.sharedWindowScopedSearchArguments(path: fileURL.path) + ) + return (.scoped(index), response) + } + } - await gate.release() - let broadResponse = try await broadTask.value - let broadText = try Self.toolText(from: broadResponse) - Self.assertHealthySearchText(broadText) - for fileURL in searchFiles { - XCTAssertTrue(broadText.contains(fileURL.lastPathComponent), broadText) + var broadResponse: PersistentMCPTestRPCResponse? + var scopedResponseCount = 0 + while scopedResponseCount < endpoints.count - 1, + let (operation, response) = try await group.next() + { + switch operation { + case .broad: + broadResponse = response + case let .scoped(index): + let fileURL = searchFiles[index + 1] + let text = try Self.toolText(from: response) + Self.assertHealthySearchText(text) + XCTAssertTrue(text.contains(fileURL.lastPathComponent), text) + for otherFileURL in searchFiles where otherFileURL != fileURL { + XCTAssertFalse(text.contains(otherFileURL.lastPathComponent), text) + } + XCTAssertFalse(text.contains(fixture.contextB.fileURL.lastPathComponent), text) + scopedResponseCount += 1 + } + } + XCTAssertEqual(scopedResponseCount, endpoints.count - 1) + + await gate.release() + while let (operation, response) = try await group.next() { + switch operation { + case .broad: + broadResponse = response + case .scoped: + XCTFail("Scoped K12 response was returned more than once") + } + } + let broadText = try Self.toolText(from: XCTUnwrap(broadResponse)) + Self.assertHealthySearchText(broadText) + for fileURL in searchFiles { + XCTAssertTrue(broadText.contains(fileURL.lastPathComponent), broadText) + } + } catch { + await gate.release() + group.cancelAll() + throw error + } } } catch { - broadTask.cancel() - scopedTasks.forEach { $0.cancel() } - await gate.release() - _ = try? await broadTask.value - for task in scopedTasks { - _ = try? await task.value - } await store.setSearchLanePermitAcquiredHandlerForTesting(nil) throw error } @@ -237,56 +363,96 @@ final class PersistentMCPDistinctConnectionConcurrencyTests: XCTestCase { await store.setSearchLanePermitAcquiredHandlerForTesting { await gate.markStartedAndWaitForRelease() } - let active = Task { - try await endpoints[0].callTool( - name: MCPWindowToolName.search, - arguments: Self.sharedWindowBroadSearchArguments(countOnly: false) - ) - } - var queued: Task? - var excess: [Task] = [] do { - guard await gate.waitUntilStarted() else { - XCTFail("The first K12 broad search did not acquire the shared lane") - throw ClientFixtureError.broadSearchDidNotStart - } - queued = Task { - try await endpoints[1].callTool( - name: MCPWindowToolName.search, - arguments: Self.sharedWindowBroadSearchArguments(countOnly: false) - ) - } - await Self.waitForSearchLaneWaiter(store: store, expectedCount: 1) - let saturated = await store.searchLaneSnapshotForTesting() - XCTAssertEqual(saturated.activePermitCount, 1) - XCTAssertEqual(saturated.waiterCount, 1) - XCTAssertEqual(saturated.maximumActivePermitCount, 1) - XCTAssertEqual(saturated.maximumWaiterCount, 1) - - excess = endpoints.dropFirst(2).map { endpoint in - Task { - try await endpoint.callTool( - name: MCPWindowToolName.search, - arguments: Self.sharedWindowBroadSearchArguments(countOnly: false) + try await withThrowingTaskGroup( + of: (K12BurstOperation, PersistentMCPTestRPCResponse).self + ) { group in + do { + group.addTask { + let response = try await endpoints[0].callTool( + name: MCPWindowToolName.search, + arguments: Self.sharedWindowBroadSearchArguments(countOnly: false) + ) + return (.active, response) + } + guard await gate.waitUntilStarted() else { + XCTFail("The first K12 broad search did not acquire the shared lane") + throw ClientFixtureError.broadSearchDidNotStart + } + group.addTask { + let response = try await endpoints[1].callTool( + name: MCPWindowToolName.search, + arguments: Self.sharedWindowBroadSearchArguments(countOnly: false) + ) + return (.queued, response) + } + await Self.waitForSearchLaneWaiter(store: store, expectedCount: 1) + let saturated = await store.searchLaneSnapshotForTesting() + XCTAssertEqual(saturated.activePermitCount, 1) + XCTAssertEqual(saturated.waiterCount, 1) + XCTAssertEqual(saturated.maximumActivePermitCount, 1) + XCTAssertEqual(saturated.maximumWaiterCount, 1) + + for (index, endpoint) in endpoints.dropFirst(2).enumerated() { + group.addTask { + let response = try await endpoint.callTool( + name: MCPWindowToolName.search, + arguments: Self.sharedWindowBroadSearchArguments(countOnly: false) + ) + return (.excess(index), response) + } + } + + var activeResponse: PersistentMCPTestRPCResponse? + var queuedResponse: PersistentMCPTestRPCResponse? + var excessResponseCount = 0 + while excessResponseCount < endpoints.count - 2, + let (operation, response) = try await group.next() + { + switch operation { + case .active: + activeResponse = response + case .queued: + queuedResponse = response + case .excess: + try Self.assertSearchBackpressureResult(response) + excessResponseCount += 1 + } + } + XCTAssertEqual(excessResponseCount, endpoints.count - 2) + let overloaded = await store.searchLaneSnapshotForTesting() + XCTAssertEqual(overloaded.activePermitCount, 1) + XCTAssertEqual(overloaded.waiterCount, 1) + XCTAssertEqual(overloaded.overloadCount, endpoints.count - 2) + + await gate.release() + while let (operation, response) = try await group.next() { + switch operation { + case .active: + activeResponse = response + case .queued: + queuedResponse = response + case .excess: + XCTFail("K12 excess response was returned more than once") + } + } + try Self.assertSharedWindowBroadSearchResult( + XCTUnwrap(activeResponse), + searchFiles: searchFiles ) + try Self.assertSharedWindowBroadSearchResult( + XCTUnwrap(queuedResponse), + searchFiles: searchFiles + ) + } catch { + await gate.release() + group.cancelAll() + throw error } } - for task in excess { - try await Self.assertSearchBackpressureResult(task.value) - } - let overloaded = await store.searchLaneSnapshotForTesting() - XCTAssertEqual(overloaded.activePermitCount, 1) - XCTAssertEqual(overloaded.waiterCount, 1) - XCTAssertEqual(overloaded.overloadCount, endpoints.count - 2) - - await gate.release() - let activeResponse = try await active.value - let queuedResponse = try await XCTUnwrap(queued).value - try Self.assertSharedWindowBroadSearchResult(activeResponse, searchFiles: searchFiles) - try Self.assertSharedWindowBroadSearchResult(queuedResponse, searchFiles: searchFiles) await store.setSearchLanePermitAcquiredHandlerForTesting(nil) - for endpoint in endpoints.dropFirst(2) { + for (index, endpoint) in endpoints.dropFirst(2).enumerated() { let retry = try await endpoint.callTool( name: MCPWindowToolName.search, arguments: Self.sharedWindowBroadSearchArguments(countOnly: false) @@ -301,22 +467,100 @@ final class PersistentMCPDistinctConnectionConcurrencyTests: XCTestCase { XCTAssertEqual(settled.grantCount, endpoints.count) await restoreBroadSearchAdmissionConfiguration(baselineConfiguration, store: store) } catch { - active.cancel() - queued?.cancel() - excess.forEach { $0.cancel() } await gate.release() - _ = try? await active.value - if let queued { - _ = try? await queued.value - } - for task in excess { - _ = try? await task.value - } await restoreBroadSearchAdmissionConfiguration(baselineConfiguration, store: store) throw error } } + func runQueuedCatalogInvalidationCheckpoint(fixture: PersistentMCPTestFixture) async throws { + let endpoint = try fixture.endpointA() + try await Self.bind(endpoint, to: fixture.contextA.tabID) + let probe = RouteInvocationProbe() + let service = fixture.contextA.catalogService + await fixture.networkManager.debugSetResolvedToolOperationOverride( + toolName: MCPWindowToolName.readFile + ) { + await probe.invokeOld() + return .object(["revision": .string("old")]) + } + + do { + try await withThrowingTaskGroup( + of: (QueuedCatalogOperation, PersistentMCPTestRPCResponse).self + ) { group in + do { + group.addTask { + do { + let response = try await endpoint.callTool( + name: MCPWindowToolName.readFile, + arguments: ["path": fixture.contextA.fileURL.path] + ) + await probe.failOldInvocationStartIfNeeded() + return (.first, response) + } catch { + await probe.failOldInvocationStartIfNeeded() + throw error + } + } + try await probe.waitUntilOldInvocationStarted() + + group.addTask { + let response = try await endpoint.callTool( + name: MCPWindowToolName.readFile, + arguments: ["path": fixture.contextA.fileURL.path] + ) + return (.queued, response) + } + await fixture.networkManager.debugWaitForLimiterWaiter(for: endpoint.connectionID) + + fixture.networkManager.serviceRegistry.invalidateCatalog(for: service) + _ = await fixture.networkManager.serviceRegistry.awaitCurrentSnapshot() + await probe.releaseOldInvocation() + + var responses: [QueuedCatalogOperation: PersistentMCPTestRPCResponse] = [:] + while let (operation, response) = try await group.next() { + responses[operation] = response + } + let firstResponse = try XCTUnwrap(responses[.first]) + let queuedResponse = try XCTUnwrap(responses[.queued]) + XCTAssertTrue(try Self.toolText(from: firstResponse).contains("old")) + let queuedError = try Self.toolErrorText(from: queuedResponse) + XCTAssertTrue(queuedError.contains("catalog changed while this call was queued"), queuedError) + } catch { + await probe.failOldInvocationStartIfNeeded() + await probe.releaseOldInvocation() + group.cancelAll() + throw error + } + } + await fixture.networkManager.debugSetResolvedToolOperationOverride( + toolName: MCPWindowToolName.readFile, + operation: nil + ) + + let freshResponse = try await endpoint.callTool( + name: MCPWindowToolName.readFile, + arguments: ["path": fixture.contextA.fileURL.path] + ) + try Self.assertReadResult( + freshResponse, + contains: fixture.contextA.sentinel, + excludes: fixture.contextB.sentinel + ) + let oldCount = await probe.oldInvocationCount() + XCTAssertEqual(oldCount, 1) + } catch { + await probe.failOldInvocationStartIfNeeded() + await probe.releaseOldInvocation() + await fixture.networkManager.debugSetResolvedToolOperationOverride( + toolName: MCPWindowToolName.readFile, + operation: nil + ) + throw error + } + } + func runCheckpoint(fixture: PersistentMCPTestFixture) async throws { let endpointA = try fixture.endpointA() let endpointB = try fixture.endpointB() @@ -454,97 +698,124 @@ final class PersistentMCPDistinctConnectionConcurrencyTests: XCTestCase { await gate.markStartedAndWaitForRelease() } do { - let firstHeldSearch = Task { - try await endpointA.callTool(name: MCPWindowToolName.search, arguments: Self.searchArguments) - } - await fulfillment(of: [heldSearchStarted], timeout: 1) - let secondHeldSearch = Task { - try await endpointAQueued.callTool(name: MCPWindowToolName.search, arguments: Self.searchArguments) - } - await Self.waitForSearchLaneWaiter(store: heldStore, expectedCount: 1) - let heldSearchStartedCount = await gate.startedCountSnapshot() - XCTAssertEqual(heldSearchStartedCount, 1) - let heldSnapshot = await heldStore.searchLaneSnapshotForTesting() - XCTAssertEqual(heldSnapshot.activePermitCount, 1) - XCTAssertEqual(heldSnapshot.waiterCount, 1) - - let overflow = try await endpointAOverflow.callTool(name: MCPWindowToolName.search, arguments: Self.searchArguments) - try Self.assertSearchBackpressureResult(overflow) - - let sameConnectionReadQueued = expectation(description: "same-connection read queued in limiter") - let observerInstalled = await fixture.networkManager.setConnectionLimiterStateObserverForTesting( - connectionID: endpointA.connectionID - ) { snapshot in - if snapshot.permits == 0, - snapshot.waiterCount == 1, - snapshot.inFlight == 2 - { - sameConnectionReadQueued.fulfill() - } - } - XCTAssertTrue(observerInstalled) - let sameConnectionRead = Task { - try await endpointA.callTool( - name: MCPWindowToolName.readFile, - arguments: ["path": fixture.contextA.fileURL.path] - ) - } - await fulfillment(of: [sameConnectionReadQueued], timeout: 1) - _ = await fixture.networkManager.setConnectionLimiterStateObserverForTesting( - connectionID: endpointA.connectionID, - observer: nil - ) - let queuedLimiterState = await fixture.networkManager.connectionLimiterSnapshotForTesting( - connectionID: endpointA.connectionID - ) - XCTAssertEqual(queuedLimiterState?.permits, 0) - XCTAssertEqual(queuedLimiterState?.waiterCount, 1) - XCTAssertEqual(queuedLimiterState?.inFlight, 2) + try await withThrowingTaskGroup( + of: (BroadSearchAdmissionOperation, PersistentMCPTestRPCResponse).self + ) { group in + do { + group.addTask { + let response = try await endpointA.callTool( + name: MCPWindowToolName.search, + arguments: Self.searchArguments + ) + return (.heldSearch, response) + } + await fulfillment(of: [heldSearchStarted], timeout: 1) + group.addTask { + let response = try await endpointAQueued.callTool( + name: MCPWindowToolName.search, + arguments: Self.searchArguments + ) + return (.queuedSearch, response) + } + await Self.waitForSearchLaneWaiter(store: heldStore, expectedCount: 1) + let heldSearchStartedCount = await gate.startedCountSnapshot() + XCTAssertEqual(heldSearchStartedCount, 1) + let heldSnapshot = await heldStore.searchLaneSnapshotForTesting() + XCTAssertEqual(heldSnapshot.activePermitCount, 1) + XCTAssertEqual(heldSnapshot.waiterCount, 1) + + let overflow = try await endpointAOverflow.callTool( + name: MCPWindowToolName.search, + arguments: Self.searchArguments + ) + try Self.assertSearchBackpressureResult(overflow) + + let sameConnectionReadQueued = expectation(description: "same-connection read queued in limiter") + let observerInstalled = await fixture.networkManager.setConnectionLimiterStateObserverForTesting( + connectionID: endpointA.connectionID + ) { snapshot in + if snapshot.permits == 0, + snapshot.waiterCount == 1, + snapshot.inFlight == 2 + { + sameConnectionReadQueued.fulfill() + } + } + XCTAssertTrue(observerInstalled) + group.addTask { + let response = try await endpointA.callTool( + name: MCPWindowToolName.readFile, + arguments: ["path": fixture.contextA.fileURL.path] + ) + return (.serializedRead, response) + } + await fulfillment(of: [sameConnectionReadQueued], timeout: 1) + _ = await fixture.networkManager.setConnectionLimiterStateObserverForTesting( + connectionID: endpointA.connectionID, + observer: nil + ) + let queuedLimiterState = await fixture.networkManager.connectionLimiterSnapshotForTesting( + connectionID: endpointA.connectionID + ) + XCTAssertEqual(queuedLimiterState?.permits, 0) + XCTAssertEqual(queuedLimiterState?.waiterCount, 1) + XCTAssertEqual(queuedLimiterState?.inFlight, 2) - let exactRead = try await endpointARead.callTool( - name: MCPWindowToolName.readFile, - arguments: ["path": fixture.contextA.fileURL.path] - ) - try Self.assertReadResult(exactRead, contains: fixture.contextA.sentinel, excludes: fixture.contextB.sentinel) - try await Self.assertRetainedReadSpellings(endpointARead, context: fixture.contextA) - let sameStorePathControl = try await endpointARead.callTool( - name: MCPWindowToolName.search, - arguments: Self.pathSearchArguments - ) - try Self.assertSearchResult(sameStorePathControl, contains: fixture.contextA.fileURL.lastPathComponent, excludes: fixture.contextB.fileURL.lastPathComponent) - let sameStoreScopedContentControl = try await endpointARead.callTool( - name: MCPWindowToolName.search, - arguments: Self.scopedSearchArguments(path: fixture.contextA.fileURL.path) - ) - try Self.assertSearchResult(sameStoreScopedContentControl, contains: fixture.contextA.fileURL.lastPathComponent, excludes: fixture.contextB.fileURL.lastPathComponent) + let exactRead = try await endpointARead.callTool( + name: MCPWindowToolName.readFile, + arguments: ["path": fixture.contextA.fileURL.path] + ) + try Self.assertReadResult(exactRead, contains: fixture.contextA.sentinel, excludes: fixture.contextB.sentinel) + try await Self.assertRetainedReadSpellings(endpointARead, context: fixture.contextA) + let sameStorePathControl = try await endpointARead.callTool( + name: MCPWindowToolName.search, + arguments: Self.pathSearchArguments + ) + try Self.assertSearchResult(sameStorePathControl, contains: fixture.contextA.fileURL.lastPathComponent, excludes: fixture.contextB.fileURL.lastPathComponent) + let sameStoreScopedContentControl = try await endpointARead.callTool( + name: MCPWindowToolName.search, + arguments: Self.scopedSearchArguments(path: fixture.contextA.fileURL.path) + ) + try Self.assertSearchResult(sameStoreScopedContentControl, contains: fixture.contextA.fileURL.lastPathComponent, excludes: fixture.contextB.fileURL.lastPathComponent) - let peerRead = try await endpointB.callTool( - name: MCPWindowToolName.readFile, - arguments: ["path": fixture.contextB.fileURL.path] - ) - try Self.assertReadResult(peerRead, contains: fixture.contextB.sentinel, excludes: fixture.contextA.sentinel) - let peerPathControl = try await endpointB.callTool( - name: MCPWindowToolName.search, - arguments: Self.pathSearchArguments - ) - try Self.assertSearchResult(peerPathControl, contains: fixture.contextB.fileURL.lastPathComponent, excludes: fixture.contextA.fileURL.lastPathComponent) - let peerSearch = try await endpointB.callTool(name: MCPWindowToolName.search, arguments: Self.searchArguments) - try Self.assertSearchResult(peerSearch, contains: fixture.contextB.fileURL.lastPathComponent, excludes: fixture.contextA.fileURL.lastPathComponent) + let peerRead = try await endpointB.callTool( + name: MCPWindowToolName.readFile, + arguments: ["path": fixture.contextB.fileURL.path] + ) + try Self.assertReadResult(peerRead, contains: fixture.contextB.sentinel, excludes: fixture.contextA.sentinel) + let peerPathControl = try await endpointB.callTool( + name: MCPWindowToolName.search, + arguments: Self.pathSearchArguments + ) + try Self.assertSearchResult(peerPathControl, contains: fixture.contextB.fileURL.lastPathComponent, excludes: fixture.contextA.fileURL.lastPathComponent) + let peerSearch = try await endpointB.callTool(name: MCPWindowToolName.search, arguments: Self.searchArguments) + try Self.assertSearchResult(peerSearch, contains: fixture.contextB.fileURL.lastPathComponent, excludes: fixture.contextA.fileURL.lastPathComponent) - let limiterStateAfterPeerCalls = await fixture.networkManager.connectionLimiterSnapshotForTesting( - connectionID: endpointA.connectionID - ) - XCTAssertEqual(limiterStateAfterPeerCalls?.permits, 0) - XCTAssertEqual(limiterStateAfterPeerCalls?.waiterCount, 1) - XCTAssertEqual(limiterStateAfterPeerCalls?.inFlight, 2) + let limiterStateAfterPeerCalls = await fixture.networkManager.connectionLimiterSnapshotForTesting( + connectionID: endpointA.connectionID + ) + XCTAssertEqual(limiterStateAfterPeerCalls?.permits, 0) + XCTAssertEqual(limiterStateAfterPeerCalls?.waiterCount, 1) + XCTAssertEqual(limiterStateAfterPeerCalls?.inFlight, 2) + + await gate.release() + var responses: [BroadSearchAdmissionOperation: PersistentMCPTestRPCResponse] = [:] + while let (operation, response) = try await group.next() { + responses[operation] = response + } + let firstHeld = try XCTUnwrap(responses[.heldSearch]) + let secondHeld = try XCTUnwrap(responses[.queuedSearch]) + let serializedRead = try XCTUnwrap(responses[.serializedRead]) + try Self.assertSearchResult(firstHeld, contains: fixture.contextA.fileURL.lastPathComponent, excludes: fixture.contextB.fileURL.lastPathComponent) + try Self.assertSearchResult(secondHeld, contains: fixture.contextA.fileURL.lastPathComponent, excludes: fixture.contextB.fileURL.lastPathComponent) + try Self.assertReadResult(serializedRead, contains: fixture.contextA.sentinel, excludes: fixture.contextB.sentinel) + } catch { + await gate.release() + group.cancelAll() + throw error + } + } - await gate.release() - let firstHeld = try await firstHeldSearch.value - let secondHeld = try await secondHeldSearch.value - let serializedRead = try await sameConnectionRead.value - try Self.assertSearchResult(firstHeld, contains: fixture.contextA.fileURL.lastPathComponent, excludes: fixture.contextB.fileURL.lastPathComponent) - try Self.assertSearchResult(secondHeld, contains: fixture.contextA.fileURL.lastPathComponent, excludes: fixture.contextB.fileURL.lastPathComponent) - try Self.assertReadResult(serializedRead, contains: fixture.contextA.sentinel, excludes: fixture.contextB.sentinel) let settledLimiterState = await fixture.networkManager.connectionLimiterSnapshotForTesting( connectionID: endpointA.connectionID ) @@ -552,7 +823,10 @@ final class PersistentMCPDistinctConnectionConcurrencyTests: XCTestCase { XCTAssertEqual(settledLimiterState?.waiterCount, 0) XCTAssertEqual(settledLimiterState?.inFlight, 0) - let settledRetry = try await endpointAOverflow.callTool(name: MCPWindowToolName.search, arguments: Self.searchArguments) + let settledRetry = try await endpointAOverflow.callTool( + name: MCPWindowToolName.search, + arguments: Self.searchArguments + ) try Self.assertSearchResult(settledRetry, contains: fixture.contextA.fileURL.lastPathComponent, excludes: fixture.contextB.fileURL.lastPathComponent) let snapshot = await heldStore.searchLaneSnapshotForTesting() XCTAssertTrue(snapshot.isIdle) @@ -1047,6 +1321,14 @@ final class PersistentMCPDistinctConnectionConcurrencyTests: XCTestCase { return text } + static func toolErrorText(from response: PersistentMCPTestRPCResponse) throws -> String { + let object = try responseObject(from: response) + let result = try XCTUnwrap(object["result"] as? [String: Any]) + let content = try XCTUnwrap(result["content"] as? [[String: Any]]) + XCTAssertEqual(result["isError"] as? Bool, true) + return content.compactMap { $0["text"] as? String }.joined() + } + nonisolated static func responseObject(from response: PersistentMCPTestRPCResponse) throws -> [String: Any] { let data = try XCTUnwrap(response.rawJSON.data(using: .utf8)) let object = try XCTUnwrap(JSONSerialization.jsonObject(with: data) as? [String: Any]) @@ -1105,13 +1387,15 @@ final class PersistentMCPDistinctConnectionConcurrencyTests: XCTestCase { static func make( lease: MCPSharedServerTestLease.Ownership, contextBuilderProviderFactory: ContextBuilderAgentViewModel.ProviderFactory? = nil, - contextASearchFileCount: Int = 1 + contextASearchFileCount: Int = 1, + includeStandardAuxiliaryEndpoints: Bool = true ) async throws -> PersistentMCPTestFixture { _ = lease let rootURL = FileManager.default.temporaryDirectory .appendingPathComponent("PersistentMCPDistinctConnectionConcurrencyTests", isDirectory: true) .appendingPathComponent(UUID().uuidString, isDirectory: true) try FileManager.default.createDirectory(at: rootURL, withIntermediateDirectories: true) + await ServerNetworkManager.shared.setEnabled(true) let previousAutoStart = GlobalSettingsStore.shared.mcpAutoStart() GlobalSettingsStore.shared.setMCPAutoStart(false, commit: false) @@ -1160,10 +1444,12 @@ final class PersistentMCPDistinctConnectionConcurrencyTests: XCTestCase { ) constructedFixture = fixture fixture.firstPersistentMCPTestEndpoint = try await PersistentMCPTestEndpoint.make(label: "a", networkManager: fixture.networkManager) - fixture.secondPersistentMCPTestEndpoint = try await PersistentMCPTestEndpoint.make(label: "b", networkManager: fixture.networkManager) - fixture.queuedSearchPersistentMCPTestEndpoint = try await PersistentMCPTestEndpoint.make(label: "a-queued-search", networkManager: fixture.networkManager) - fixture.overflowSearchPersistentMCPTestEndpoint = try await PersistentMCPTestEndpoint.make(label: "a-overflow-search", networkManager: fixture.networkManager) - fixture.exactReadPersistentMCPTestEndpoint = try await PersistentMCPTestEndpoint.make(label: "a-exact-read", networkManager: fixture.networkManager) + if includeStandardAuxiliaryEndpoints { + fixture.secondPersistentMCPTestEndpoint = try await PersistentMCPTestEndpoint.make(label: "b", networkManager: fixture.networkManager) + fixture.queuedSearchPersistentMCPTestEndpoint = try await PersistentMCPTestEndpoint.make(label: "a-queued-search", networkManager: fixture.networkManager) + fixture.overflowSearchPersistentMCPTestEndpoint = try await PersistentMCPTestEndpoint.make(label: "a-overflow-search", networkManager: fixture.networkManager) + fixture.exactReadPersistentMCPTestEndpoint = try await PersistentMCPTestEndpoint.make(label: "a-exact-read", networkManager: fixture.networkManager) + } return fixture } catch { if let constructedFixture { @@ -1172,8 +1458,8 @@ final class PersistentMCPDistinctConnectionConcurrencyTests: XCTestCase { if let contextB { await cleanupContext(contextB) } if let contextA { await cleanupContext(contextA) } if let ownedRoutingService { ServiceRegistry.unregister(ownedRoutingService) } - WindowStatesManager.shared.unregisterWindowState(windowB) - WindowStatesManager.shared.unregisterWindowState(windowA) + await WindowStatesManager.shared.unregisterWindowStateAndWait(windowB) + await WindowStatesManager.shared.unregisterWindowStateAndWait(windowA) try? FileManager.default.removeItem(at: rootURL) } throw error @@ -1252,17 +1538,24 @@ final class PersistentMCPDistinctConnectionConcurrencyTests: XCTestCase { func cleanup() async { guard !cleanedUp else { return } cleanedUp = true - let fixedEndpoints = [ - firstPersistentMCPTestEndpoint, - secondPersistentMCPTestEndpoint, - queuedSearchPersistentMCPTestEndpoint, - overflowSearchPersistentMCPTestEndpoint, - exactReadPersistentMCPTestEndpoint - ].compactMap(\.self) - for endpoint in fixedEndpoints + additionalPersistentMCPTestEndpoints { + let allEndpoints = createdEndpoints() + for endpoint in allEndpoints { + await endpoint.client.waitUntilPendingWorkDrained() + } + await assertNoPendingWork() + for endpoint in allEndpoints { endpoint.client.close() - await endpoint.connectionManager.stop() + } + for endpoint in allEndpoints { + await endpoint.client.waitUntilReaderLoopStopped() + } + for endpoint in allEndpoints { await networkManager.debugRemoveConnection(endpoint.connectionID) + } + for endpoint in allEndpoints { + await waitForTransportCleanup(endpoint) + } + for endpoint in allEndpoints { await networkManager.clearClientConnectionPolicy(for: endpoint.clientName) await networkManager.debugClearPersistedRoutingState(for: endpoint.clientName) contextA.window.mcpServer.removeTabContext( @@ -1278,25 +1571,62 @@ final class PersistentMCPDistinctConnectionConcurrencyTests: XCTestCase { runID: nil ) } - await contextA.window.mcpServer.shutdownListener() ServiceRegistry.unregister(contextB.catalogService) ServiceRegistry.unregister(contextA.catalogService) await contextB.window.workspaceFileContextStore.unloadRoot(id: contextB.rootID) await contextA.window.workspaceFileContextStore.unloadRoot(id: contextA.rootID) - contextB.window.workspaceManager.workspaces.removeAll { $0.id == contextB.workspaceID } - contextA.window.workspaceManager.workspaces.removeAll { $0.id == contextA.workspaceID } - WindowStatesManager.shared.unregisterWindowState(contextB.window) - WindowStatesManager.shared.unregisterWindowState(contextA.window) + contextB.window.workspaceManager.workspaceTransaction { transaction in + transaction.workspaces.removeAll { $0.id == contextB.workspaceID } + } + contextA.window.workspaceManager.workspaceTransaction { transaction in + transaction.workspaces.removeAll { $0.id == contextA.workspaceID } + } + await WindowStatesManager.shared.unregisterWindowStateAndWait(contextB.window) + await WindowStatesManager.shared.unregisterWindowStateAndWait(contextA.window) if let ownedRoutingService { ServiceRegistry.unregister(ownedRoutingService) } try? FileManager.default.removeItem(at: rootURL) } + func assertNoPendingWork() async { + for endpoint in createdEndpoints() { + let clientWork = endpoint.client.pendingWorkSnapshotForTesting() + XCTAssertTrue(clientWork.isIdle, "Pending client work for \(endpoint.connectionID): \(clientWork)") + let hasInFlightCalls = await networkManager.hasInFlightCalls(for: endpoint.connectionID) + XCTAssertFalse(hasInFlightCalls, "Pending server work for \(endpoint.connectionID)") + if let limiter = await networkManager.connectionLimiterSnapshotForTesting( + connectionID: endpoint.connectionID + ) { + XCTAssertEqual(limiter.waiterCount, 0) + XCTAssertEqual(limiter.inFlight, 0) + } + } + + let contentReadLimiter = await PersistentMCPDistinctConnectionConcurrencyTests + .waitForContentReadLimiterIdle() + XCTAssertTrue(contentReadLimiter.isIdle, "Pending content-read work: \(contentReadLimiter)") + + for context in [contextA, contextB] { + let searchLane = await context.window.workspaceFileContextStore.searchLaneSnapshotForTesting() + let searchCache = await context.window.workspaceFileContextStore + .searchDecodedContentCacheSnapshotForTesting() + XCTAssertTrue(searchLane.isIdle, "Pending search-lane work for context \(context.tabID): \(searchLane)") + XCTAssertEqual(searchCache.activeFlightCount, 0) + XCTAssertEqual(searchCache.waiterCount, 0) + } + } + func assertCleanedUp() async throws { - for endpoint in try endpoints() { + for endpoint in createdEndpoints() { let hasInFlightCalls = await networkManager.hasInFlightCalls(for: endpoint.connectionID) let selectedWindow = await networkManager.selectedWindow(for: endpoint.connectionID) XCTAssertFalse(hasInFlightCalls) XCTAssertNil(selectedWindow) + let cleanupState = await networkManager.debugConnectionCleanupState(for: endpoint.connectionID) + XCTAssertFalse(cleanupState.hasLimiter) + XCTAssertFalse(cleanupState.hasRoutingMetadata) + XCTAssertFalse(cleanupState.hasExecutionPolicyMetadata) + XCTAssertFalse(cleanupState.hasSessionMetadata) + XCTAssertFalse(cleanupState.hasWatchdogMetadata) let policy = await networkManager.debugConnectionPolicyState(for: endpoint.connectionID) XCTAssertTrue(policy.restrictedTools.isEmpty) XCTAssertTrue(policy.additionalTools.isEmpty) @@ -1306,6 +1636,7 @@ final class PersistentMCPDistinctConnectionConcurrencyTests: XCTestCase { XCTAssertTrue(pendingPolicies.isEmpty) XCTAssertEqual(contextA.window.mcpServer.connectionBindingSnapshot(forConnection: endpoint.connectionID).bindingKind, .unbound) XCTAssertEqual(contextB.window.mcpServer.connectionBindingSnapshot(forConnection: endpoint.connectionID).bindingKind, .unbound) + XCTAssertTrue(endpoint.client.readerLoopStoppedForTesting()) do { _ = try await endpoint.client.request(method: "tools/list", params: [:]) XCTFail("closed socket unexpectedly accepted a request") @@ -1317,6 +1648,30 @@ final class PersistentMCPDistinctConnectionConcurrencyTests: XCTestCase { } } + private func waitForTransportCleanup(_ endpoint: PersistentMCPTestEndpoint) async { + while true { + let snapshot = await endpoint.connectionManager.debugTransportCleanupSnapshot() + if !snapshot.hasActiveReader, + snapshot.pendingReaderCancellationCount == 0, + !snapshot.readerIsRetained, + !snapshot.socketIsOwned + { + return + } + await Task.yield() + } + } + + private func createdEndpoints() -> [PersistentMCPTestEndpoint] { + [ + firstPersistentMCPTestEndpoint, + secondPersistentMCPTestEndpoint, + queuedSearchPersistentMCPTestEndpoint, + overflowSearchPersistentMCPTestEndpoint, + exactReadPersistentMCPTestEndpoint + ].compactMap(\.self) + additionalPersistentMCPTestEndpoints + } + private static func makeContext( rootURL: URL, fileName: String, @@ -1353,7 +1708,9 @@ final class PersistentMCPDistinctConnectionConcurrencyTests: XCTestCase { ComposeTabState(id: tabID, name: "Distinct MCP Connection \(label)") ] configuredWorkspace.activeComposeTabID = tabID - window.workspaceManager.workspaces.append(configuredWorkspace) + window.workspaceManager.workspaceTransaction { transaction in + transaction.workspaces.append(configuredWorkspace) + } let rootRecord = try await window.workspaceFileContextStore.loadRoot(path: rootURL.path) let exactHit = await WorkspaceReadableFileService(store: window.workspaceFileContextStore) .resolveExactAbsoluteWorkspaceCatalogHit(fileURL.path, rootScope: .visibleWorkspace) @@ -1361,6 +1718,13 @@ final class PersistentMCPDistinctConnectionConcurrencyTests: XCTestCase { throw ClientFixtureError.exactAbsoluteCatalogMiss } let catalogService = window.mcpServer.windowMCPToolCatalogService + guard ServerNetworkManager.shared.runtimeSessionRegistry.setMCPEnabled( + windowID: window.windowID, + expectedSessionID: window.coreSessionHandle.sessionID, + enabled: true + ) else { + throw ClientFixtureError.runtimeSessionEnablementFailed + } ServiceRegistry.register(catalogService) return PersistentMCPTestContext( rootURL: rootURL, @@ -1379,7 +1743,11 @@ final class PersistentMCPDistinctConnectionConcurrencyTests: XCTestCase { if let existing = ServiceRegistry.services.first(where: { $0 is WindowRoutingService }) as? WindowRoutingService { return (existing, false) } - let service = WindowRoutingService(windowStates: .shared, networkMgr: .shared) + let service = WindowRoutingService( + windowStates: .shared, + networkMgr: .shared, + workspaceRepository: RepoPromptAppCoreContainer.shared.workspaceRepository + ) for _ in 0 ..< 100 { let registered = ServiceRegistry.services.contains { $0 as AnyObject === service as AnyObject } let names = await service.tools.map(\.name) @@ -1395,7 +1763,9 @@ final class PersistentMCPDistinctConnectionConcurrencyTests: XCTestCase { private static func cleanupContext(_ context: PersistentMCPTestContext) async { ServiceRegistry.unregister(context.catalogService) await context.window.workspaceFileContextStore.unloadRoot(id: context.rootID) - context.window.workspaceManager.workspaces.removeAll { $0.id == context.workspaceID } + context.window.workspaceManager.workspaceTransaction { transaction in + transaction.workspaces.removeAll { $0.id == context.workspaceID } + } try? FileManager.default.removeItem(at: context.rootURL) } } @@ -1504,7 +1874,10 @@ final class PersistentMCPDistinctConnectionConcurrencyTests: XCTestCase { let manager = try BootstrapSocketConnectionManager( connectionID: connectionID, sessionToken: sessionToken, - clientPid: Int(getpid()), + peerIdentity: MCPPeerIdentity( + socketObservedPID: Int(getpid()), + handshakeClaimedPID: Int(getpid()) + ), clientName: clientName, purpose: .unknown, codeMapsDisabled: false, @@ -1559,7 +1932,10 @@ final class PersistentMCPDistinctConnectionConcurrencyTests: XCTestCase { return endpoint } catch { startTask.cancel() + await client.waitUntilPendingWorkDrained() + XCTAssertTrue(client.pendingWorkSnapshotForTesting().isIdle) client.close() + await client.waitUntilReaderLoopStopped() await manager.stop() await networkManager.debugRemoveConnection(connectionID) await networkManager.debugClearPersistedRoutingState(for: clientName) @@ -1591,6 +1967,28 @@ final class PersistentMCPDistinctConnectionConcurrencyTests: XCTestCase { } } + private enum BroadSearchAdmissionOperation: Hashable { + case heldSearch + case queuedSearch + case serializedRead + } + + private enum K12ScopedSearchOperation: Hashable { + case broad + case scoped(Int) + } + + private enum K12BurstOperation: Hashable { + case active + case queued + case excess(Int) + } + + private enum QueuedCatalogOperation: Hashable { + case first + case queued + } + struct PersistentMCPTestRPCResponse { let id: Int let rawJSON: String @@ -1607,7 +2005,6 @@ final class PersistentMCPDistinctConnectionConcurrencyTests: XCTestCase { } private let writeQueue = DispatchQueue(label: "PersistentMCPDistinctConnectionConcurrencyTests.write") - private let readQueue = DispatchQueue(label: "PersistentMCPDistinctConnectionConcurrencyTests.read") private let stateLock = NSLock() private var fd: Int32 private var nextRequestID = 1 @@ -1615,12 +2012,26 @@ final class PersistentMCPDistinctConnectionConcurrencyTests: XCTestCase { private var responseInterceptors: [Int: @Sendable (String) async throws -> String] = [:] private var notifications: [String] = [] private var isClosed = false + private var readerLoopStopped = false + private var responseTasks: [UUID: TrackedResponseTask] = [:] + private var pendingWorkWaiters: [CheckedContinuation] = [] + private let readerCompletion = DispatchGroup() + private var readerThread: Thread? init(fd: Int32) { self.fd = fd - readQueue.async { [weak self] in - self?.readerLoop() + readerCompletion.enter() + let completion = readerCompletion + let thread = Thread { [self, completion] in + defer { + markReaderLoopStopped() + completion.leave() + } + readerLoop() } + thread.name = "PersistentMCPDistinctConnectionConcurrencyTests.read" + readerThread = thread + thread.start() } deinit { @@ -1631,6 +2042,52 @@ final class PersistentMCPDistinctConnectionConcurrencyTests: XCTestCase { close(with: ClientError.closed) } + func waitUntilReaderLoopStopped() async { + await withCheckedContinuation { continuation in + readerCompletion.notify(queue: .global(qos: .utility)) { + continuation.resume() + } + } + } + + func readerLoopStoppedForTesting() -> Bool { + withStateLock { readerLoopStopped } + } + + func waitUntilPendingWorkDrained() async { + while true { + let joinableTasks: [(UUID, Task)]? = withStateLock { + guard pendingWorkIsJoinable else { return nil } + return responseTasks.compactMap { taskID, trackedTask in + trackedTask.task.map { (taskID, $0) } + } + } + guard let joinableTasks else { + await waitUntilPendingWorkIsJoinable() + continue + } + guard !joinableTasks.isEmpty else { return } + for (_, task) in joinableTasks { + await task.value + } + withStateLock { + for (taskID, _) in joinableTasks { + responseTasks.removeValue(forKey: taskID) + } + } + } + } + + func pendingWorkSnapshotForTesting() -> PendingWorkSnapshot { + withStateLock { + PendingWorkSnapshot( + pendingRequestCount: pending.count, + responseInterceptorCount: responseInterceptors.count, + activeResponseTaskCount: responseTasks.count + ) + } + } + func nextRequestIDForTesting() -> Int { withStateLock { nextRequestID } } @@ -1654,20 +2111,31 @@ final class PersistentMCPDistinctConnectionConcurrencyTests: XCTestCase { func request(method: String, params: [String: Any], timeoutSeconds: Int = 10) async throws -> PersistentMCPTestRPCResponse { let id = allocateRequestID() - let rawJSON = try await withTaskCancellationHandler { + let requestData = try encodeJSON([ + "jsonrpc": "2.0", + "id": id, + "method": method, + "params": params + ]) + let timeout = RequestTimeout(seconds: timeoutSeconds) { [self] in + _ = failPending(id: id, error: ClientError.timedOut(id)) + } + do { + let rawJSON = try await waitForResponse(id: id, requestData: requestData) + await timeout.cancelAndWait() + return PersistentMCPTestRPCResponse(id: id, rawJSON: rawJSON) + } catch { + await timeout.cancelAndWait() + throw error + } + } + + private func waitForResponse(id: Int, requestData: Data) async throws -> String { + try await withTaskCancellationHandler { try await withCheckedThrowingContinuation { continuation in do { try register(continuation, for: id) - try sendJSON([ - "jsonrpc": "2.0", - "id": id, - "method": method, - "params": params - ]) - Task { [weak self] in - try? await Task.sleep(for: .seconds(timeoutSeconds)) - self?.failPending(id: id, error: ClientError.timedOut(id)) - } + try sendData(requestData) } catch { if !failPending(id: id, error: error) { continuation.resume(throwing: error) @@ -1677,7 +2145,6 @@ final class PersistentMCPDistinctConnectionConcurrencyTests: XCTestCase { } onCancel: { self.failPending(id: id, error: CancellationError()) } - return PersistentMCPTestRPCResponse(id: id, rawJSON: rawJSON) } private func allocateRequestID() -> Int { @@ -1696,8 +2163,16 @@ final class PersistentMCPDistinctConnectionConcurrencyTests: XCTestCase { } private func sendJSON(_ object: [String: Any]) throws { + try sendData(encodeJSON(object)) + } + + private func encodeJSON(_ object: [String: Any]) throws -> Data { var line = try JSONSerialization.data(withJSONObject: object, options: [.sortedKeys]) line.append(0x0A) + return line + } + + private func sendData(_ line: Data) throws { try writeQueue.sync { var written = 0 while written < line.count { @@ -1771,16 +2246,20 @@ final class PersistentMCPDistinctConnectionConcurrencyTests: XCTestCase { throw ClientError.unexpectedResponseID(id) } guard let rawJSON = String(data: line, encoding: .utf8) else { throw ClientError.invalidResponse } - guard let interceptor = pendingResponse.interceptor else { + guard let interceptor = pendingResponse.interceptor, + let responseTaskID = pendingResponse.responseTaskID + else { continuation.resume(returning: rawJSON) + resumePendingWorkWaitersIfJoinable() return true } - Task { [weak self] in + startResponseTask(id: responseTaskID) { [self] in do { - try await continuation.resume(returning: interceptor(rawJSON)) + let intercepted = try await interceptor(rawJSON) + continuation.resume(returning: intercepted) } catch { continuation.resume(throwing: error) - self?.close(with: error) + close(with: error) } } return true @@ -1799,20 +2278,29 @@ final class PersistentMCPDistinctConnectionConcurrencyTests: XCTestCase { } private func takePending(id: Int) -> CheckedContinuation? { - withStateLock { + let continuation = withStateLock { responseInterceptors.removeValue(forKey: id) return pending.removeValue(forKey: id) } + resumePendingWorkWaitersIfJoinable() + return continuation } private func takePendingResponse( id: Int ) -> ( continuation: CheckedContinuation?, - interceptor: (@Sendable (String) async throws -> String)? + interceptor: (@Sendable (String) async throws -> String)?, + responseTaskID: UUID? ) { withStateLock { - (pending.removeValue(forKey: id), responseInterceptors.removeValue(forKey: id)) + let continuation = pending.removeValue(forKey: id) + let interceptor = responseInterceptors.removeValue(forKey: id) + let responseTaskID = interceptor.map { _ in UUID() } + if let responseTaskID { + responseTasks[responseTaskID] = TrackedResponseTask(task: nil) + } + return (continuation, interceptor, responseTaskID) } } @@ -1823,6 +2311,58 @@ final class PersistentMCPDistinctConnectionConcurrencyTests: XCTestCase { return true } + private func markReaderLoopStopped() { + withStateLock { + readerLoopStopped = true + readerThread = nil + } + } + + private func startResponseTask( + id taskID: UUID, + operation: @escaping @Sendable () async -> Void + ) { + let task = Task { + await operation() + } + withStateLock { + precondition(responseTasks[taskID] != nil) + responseTasks[taskID]?.task = task + } + resumePendingWorkWaitersIfJoinable() + } + + private var pendingWorkIsJoinable: Bool { + pending.isEmpty + && responseInterceptors.isEmpty + && responseTasks.values.allSatisfy { $0.task != nil } + } + + private func waitUntilPendingWorkIsJoinable() async { + await withCheckedContinuation { continuation in + let resumeImmediately = withStateLock { + guard !pendingWorkIsJoinable else { return true } + pendingWorkWaiters.append(continuation) + return false + } + if resumeImmediately { + continuation.resume() + } + } + } + + private func resumePendingWorkWaitersIfJoinable() { + let waiters: [CheckedContinuation] = withStateLock { + guard pendingWorkIsJoinable else { return [] } + let waiters = pendingWorkWaiters + pendingWorkWaiters.removeAll() + return waiters + } + for waiter in waiters { + waiter.resume() + } + } + private func close(with error: Error) { let snapshot: (Int32, [CheckedContinuation]) = withStateLock { guard !isClosed else { return (-1, []) } @@ -1838,6 +2378,7 @@ final class PersistentMCPDistinctConnectionConcurrencyTests: XCTestCase { for continuation in snapshot.1 { continuation.resume(throwing: error) } + resumePendingWorkWaitersIfJoinable() } private func withStateLock(_ operation: () throws -> T) rethrows -> T { @@ -1847,6 +2388,109 @@ final class PersistentMCPDistinctConnectionConcurrencyTests: XCTestCase { } } + private final class RequestTimeout: @unchecked Sendable { + private let source: DispatchSourceTimer + private let completion = DispatchGroup() + + init(seconds: Int, operation: @escaping @Sendable () -> Void) { + source = DispatchSource.makeTimerSource( + queue: DispatchQueue.global(qos: .userInitiated) + ) + completion.enter() + source.setEventHandler { [weak self] in + operation() + self?.source.cancel() + } + source.setCancelHandler { [completion] in + completion.leave() + } + source.schedule(deadline: .now() + .seconds(seconds)) + source.resume() + } + + func cancelAndWait() async { + source.cancel() + await withCheckedContinuation { continuation in + completion.notify(queue: .global(qos: .utility)) { + continuation.resume() + } + } + } + } + + private struct TrackedResponseTask { + var task: Task? + } + + struct PendingWorkSnapshot: Equatable { + let pendingRequestCount: Int + let responseInterceptorCount: Int + let activeResponseTaskCount: Int + + var isIdle: Bool { + pendingRequestCount == 0 + && responseInterceptorCount == 0 + && activeResponseTaskCount == 0 + } + } + + private actor CompletionSignal { + private var marked = false + + func mark() { + marked = true + } + + func isMarked() -> Bool { + marked + } + } + + private actor RouteInvocationProbe { + enum ProbeError: Error { + case oldInvocationDidNotStart + } + + private var oldCount = 0 + private var oldStarted = false + private var oldStartFailed = false + private var oldReleased = false + private var oldStartWaiters: [CheckedContinuation] = [] + private var oldReleaseWaiters: [CheckedContinuation] = [] + + func invokeOld() async { + oldCount += 1 + oldStarted = true + oldStartWaiters.forEach { $0.resume() } + oldStartWaiters.removeAll() + guard !oldReleased else { return } + await withCheckedContinuation { oldReleaseWaiters.append($0) } + } + + func waitUntilOldInvocationStarted() async throws { + guard !oldStarted else { return } + guard !oldStartFailed else { throw ProbeError.oldInvocationDidNotStart } + try await withCheckedThrowingContinuation { oldStartWaiters.append($0) } + } + + func failOldInvocationStartIfNeeded() { + guard !oldStarted, !oldStartFailed else { return } + oldStartFailed = true + oldStartWaiters.forEach { $0.resume(throwing: ProbeError.oldInvocationDidNotStart) } + oldStartWaiters.removeAll() + } + + func releaseOldInvocation() { + oldReleased = true + oldReleaseWaiters.forEach { $0.resume() } + oldReleaseWaiters.removeAll() + } + + func oldInvocationCount() -> Int { + oldCount + } + } + private actor SearchAdmissionGate { private let onStarted: @Sendable () -> Void private var startedCount = 0 @@ -1892,6 +2536,7 @@ final class PersistentMCPDistinctConnectionConcurrencyTests: XCTestCase { case broadSearchDidNotStart case exactAbsoluteCatalogMiss case routingServiceUnavailable + case runtimeSessionEnablementFailed case toolReturnedError(String) } #endif diff --git a/Tests/RepoPromptTests/MCP/Control/ProcessLauncherDescriptorInheritanceTests.swift b/Tests/RepoPromptTests/MCP/Control/ProcessLauncherDescriptorInheritanceTests.swift index e8468a124..fe5d4b900 100644 --- a/Tests/RepoPromptTests/MCP/Control/ProcessLauncherDescriptorInheritanceTests.swift +++ b/Tests/RepoPromptTests/MCP/Control/ProcessLauncherDescriptorInheritanceTests.swift @@ -1,6 +1,8 @@ import Darwin import Foundation @testable import RepoPrompt +@testable import RepoPromptCore +@testable import RepoPromptCoreMacOS import XCTest final class ProcessLauncherDescriptorInheritanceTests: XCTestCase { @@ -199,7 +201,7 @@ final class ProcessLauncherDescriptorInheritanceTests: XCTestCase { func testLauncherSourcesCheckSpawnFileActionAndAttributeInitializationResults() throws { let root = try RepoRoot.url() let launcher = try String( - contentsOf: root.appendingPathComponent("Sources/RepoPrompt/Infrastructure/Process/ProcessLauncher.swift"), + contentsOf: root.appendingPathComponent("Sources/RepoPromptCoreMacOS/Process/POSIXProcessLauncher.swift"), encoding: .utf8 ) let runner = try String( diff --git a/Tests/RepoPromptTests/MCP/Control/ServerControllerAdmissionTests.swift b/Tests/RepoPromptTests/MCP/Control/ServerControllerAdmissionTests.swift index ceec422ae..43a59140f 100644 --- a/Tests/RepoPromptTests/MCP/Control/ServerControllerAdmissionTests.swift +++ b/Tests/RepoPromptTests/MCP/Control/ServerControllerAdmissionTests.swift @@ -1,3 +1,4 @@ +import Foundation @testable import RepoPrompt import XCTest @@ -31,14 +32,77 @@ final class ServerControllerAdmissionTests: XCTestCase { let sanitized = ServerController.test_sanitizedAlwaysAllowedClients([ "RepoPrompt CLI", "RepoPrompt CLI (Exec)", - "RepoPrompt CLI 1.2.3", + " RepoPrompt CLI 1.2.3 ", "claude-code", - "custom-client" + "custom-client", + "Spoofed RepoPrompt CLI" ]) - XCTAssertEqual(sanitized, ["claude-code", "custom-client"]) + XCTAssertEqual(sanitized, ["claude-code", "custom-client", "Spoofed RepoPrompt CLI"]) #else throw XCTSkip("DEBUG-only ServerController admission seams are unavailable in release builds") #endif } + + func testBundledHelperPathVerificationAcceptsSymlinkEquivalentPath() throws { + #if DEBUG + let fixture = try makeTemporaryDirectory() + defer { try? FileManager.default.removeItem(at: fixture) } + let expected = fixture.appendingPathComponent("repoprompt-mcp") + let symlink = fixture.appendingPathComponent("rpce-cli-debug") + XCTAssertTrue(FileManager.default.createFile(atPath: expected.path, contents: Data("helper".utf8))) + try FileManager.default.createSymbolicLink(at: symlink, withDestinationURL: expected) + + XCTAssertTrue(ServerController.test_bundledHelperPathMatches( + expectedURL: expected, + actualPath: symlink.path + )) + #else + throw XCTSkip("DEBUG-only ServerController admission seams are unavailable in release builds") + #endif + } + + func testBundledHelperPathVerificationRejectsAlternateExecutablePath() throws { + #if DEBUG + let fixture = try makeTemporaryDirectory() + defer { try? FileManager.default.removeItem(at: fixture) } + let expected = fixture.appendingPathComponent("repoprompt-mcp") + let alternate = fixture.appendingPathComponent("alternate-repoprompt-mcp") + XCTAssertTrue(FileManager.default.createFile(atPath: expected.path, contents: Data("same bytes".utf8))) + XCTAssertTrue(FileManager.default.createFile(atPath: alternate.path, contents: Data("same bytes".utf8))) + + XCTAssertFalse(ServerController.test_bundledHelperPathMatches( + expectedURL: expected, + actualPath: alternate.path + )) + #else + throw XCTSkip("DEBUG-only ServerController admission seams are unavailable in release builds") + #endif + } + + func testBundledHelperAdmissionRetainsTrustedPeerExecutablePathChain() throws { + let source = try String( + contentsOf: RepoRoot.url() + .appendingPathComponent("Sources/RepoPrompt/Infrastructure/MCP/ServerController.swift"), + encoding: .utf8 + ) + var cursor = source.startIndex + for marker in [ + "Bundle.main.url(forAuxiliaryExecutable: \"repoprompt-mcp\")", + "await networkManager.peerPID(for: connectionID)", + "bundledHelperPeerVerifier.matches(BundledHelperPeerVerificationInput(", + "expectedExecutableURL: expectedURL", + "peerPID: peerPID" + ] { + let range = try XCTUnwrap(source.range(of: marker, range: cursor ..< source.endIndex)) + cursor = range.upperBound + } + } + + private func makeTemporaryDirectory() throws -> URL { + let url = FileManager.default.temporaryDirectory + .appendingPathComponent("ServerControllerAdmissionTests-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true) + return url + } } diff --git a/Tests/RepoPromptTests/MCP/MCPFilesystemIdentityTests.swift b/Tests/RepoPromptTests/MCP/MCPFilesystemIdentityTests.swift index 6ff5bf954..3db8998c5 100644 --- a/Tests/RepoPromptTests/MCP/MCPFilesystemIdentityTests.swift +++ b/Tests/RepoPromptTests/MCP/MCPFilesystemIdentityTests.swift @@ -1,3 +1,4 @@ +import Darwin import Foundation @testable import RepoPrompt import RepoPromptShared @@ -39,6 +40,13 @@ final class MCPFilesystemIdentityTests: XCTestCase { XCTAssertEqual(release.claudeWrapperCommandName, "claude-rpce") } + func testSocketPathsUseExplicitPlatformNeutralUserID() { + let identity = MCPFilesystemIdentity.repoPromptCE(.debug) + + XCTAssertEqual(identity.socketDirectoryURL(userID: 501).path, "/tmp/repoprompt-ce-mcp-501") + XCTAssertEqual(identity.bootstrapSocketURL(userID: 501).path, "/tmp/repoprompt-ce-mcp-501/repoprompt-ce-D-7.sock") + } + func testAppConstantsDelegateToSharedIdentity() { #if DEBUG let expected = MCPFilesystemIdentity.repoPromptCE(.debug) @@ -47,7 +55,10 @@ final class MCPFilesystemIdentityTests: XCTestCase { #endif XCTAssertEqual(MCPFilesystemConstants.identity, expected) - XCTAssertEqual(MCPFilesystemConstants.bootstrapSocketURL(), expected.bootstrapSocketURL()) + XCTAssertEqual( + MCPFilesystemConstants.bootstrapSocketURL(), + expected.bootstrapSocketURL(userID: UInt32(getuid())) + ) XCTAssertEqual(MCPFilesystemConstants.eventsDirectoryURL(), expected.externalEventsDirectoryURL()) } @@ -61,7 +72,15 @@ final class MCPFilesystemIdentityTests: XCTestCase { for path in paths { let source = try String(contentsOf: root.appendingPathComponent(path), encoding: .utf8) XCTAssertTrue(source.contains("MCPFilesystemIdentity.repoPromptCE"), path) + XCTAssertTrue(source.contains("getuid()"), path) XCTAssertFalse(source.contains("socketVersion = 6"), path) } + + let sharedSource = try String( + contentsOf: root.appendingPathComponent("Sources/RepoPromptShared/MCP/MCPFilesystemIdentity.swift"), + encoding: .utf8 + ) + XCTAssertFalse(sharedSource.contains("import Darwin")) + XCTAssertFalse(sharedSource.contains("getuid()")) } } diff --git a/Tests/RepoPromptTests/MCP/MCPReadSearchLatencyDiagnosticsGuardTests.swift b/Tests/RepoPromptTests/MCP/MCPReadSearchLatencyDiagnosticsGuardTests.swift index a7de932b7..51911bd4e 100644 --- a/Tests/RepoPromptTests/MCP/MCPReadSearchLatencyDiagnosticsGuardTests.swift +++ b/Tests/RepoPromptTests/MCP/MCPReadSearchLatencyDiagnosticsGuardTests.swift @@ -557,7 +557,7 @@ XCTAssertTrue(snapshot.stages.allSatisfy { $0.sampleCount == 1 }) } - func testServiceToolLookupInnerAttributionHooksRemainCompileGatedAndOwnMeasuredOperations() throws { + func testServiceToolLookupInnerAttributionHooksRemainCompileGatedOwnedAndReleaseEquivalent() throws { func assertCompileGated( _ marker: Range, in source: String, @@ -598,6 +598,51 @@ return String(source[start.lowerBound ..< end.lowerBound]) } + let manager = try source("Sources/RepoPrompt/Infrastructure/MCP/MCPConnectionManager.swift") + let lookupBegin = try XCTUnwrap(manager.range(of: "let serviceToolLookupState = EditFlowPerf.begin(")) + let directInvocation = try XCTUnwrap(manager.range(of: "try await toolDef.callAsFunction(effectiveArgs)", range: lookupBegin.upperBound ..< manager.endIndex)) + let lookup = String(manager[lookupBegin.lowerBound ..< directInvocation.lowerBound]) + + var searchStart = lookup.startIndex + for hook in [ + "for indexedRoute in indexedTools.routes(forCanonicalName: toolName)", + "let toolDef = indexedRoute.tool", + "runtimeSessionRegistry.isInvocationAllowed(windowID: routeWindowID)", + "let publicWindowIDInjectionState = EditFlowPerf.begin(EditFlowPerf.Stage.MCPToolCall.serviceToolLookupPublicWindowIDInjection)", + "let routingWindowID: Int? = {", + "let selectedSchemaDeclaresWindowID =", + "routingWindowID != nil", + "capturedArguments[\"window_id\"] == nil", + "capturedArgsForFormatter[\"window_id\"] == nil", + "self.schemaDeclaresWindowID(schema: toolDef.inputSchema)", + "schemaDeclaresWindowID: selectedSchemaDeclaresWindowID", + "args: capturedArguments", + "schemaDeclaresWindowID: selectedSchemaDeclaresWindowID", + "args: capturedArgsForFormatter", + "EditFlowPerf.end(EditFlowPerf.Stage.MCPToolCall.serviceToolLookupPublicWindowIDInjection, publicWindowIDInjectionState)", + "EditFlowPerf.end(EditFlowPerf.Stage.MCPToolCall.serviceToolLookup, serviceToolLookupState)" + ] { + let match = try XCTUnwrap( + lookup.range(of: hook, range: searchStart ..< lookup.endIndex), + "Missing or out-of-order ServiceToolLookup attribution hook: \(hook)" + ) + searchStart = match.upperBound + } + + XCTAssertEqual(manager.components(separatedBy: "let serviceTools = await service.tools").count - 1, 0) + XCTAssertEqual(manager.components(separatedBy: "guard let toolDef = serviceTools.first(where: { $0.name == toolName })").count - 1, 0) + XCTAssertEqual(lookup.components(separatedBy: "self.schemaDeclaresWindowID(schema: toolDef.inputSchema)").count - 1, 1) + XCTAssertEqual(lookup.components(separatedBy: "schemaDeclaresWindowID: selectedSchemaDeclaresWindowID").count - 1, 2) + XCTAssertEqual(lookup.components(separatedBy: "args: capturedArguments").count - 1, 1) + XCTAssertEqual(lookup.components(separatedBy: "args: capturedArgsForFormatter").count - 1, 1) + XCTAssertEqual(manager.components(separatedBy: "let resolvedOperation: @Sendable () async throws -> Value =").count - 1, 1) + XCTAssertEqual(manager.components(separatedBy: "try await ensureIndexedRouteStillInvocable()").count - 1, 1) + XCTAssertEqual(manager.components(separatedBy: "try await toolDef.callAsFunction(effectiveArgs)").count - 1, 1) + XCTAssertEqual(manager.components(separatedBy: "try await dispatchResolvedProvider(resolvedOperation)").count - 1, 2) + XCTAssertEqual(lookup.components(separatedBy: "serviceToolLookupPublicWindowIDInjection").count - 1, 2) + XCTAssertGreaterThanOrEqual(lookup.components(separatedBy: "#if DEBUG || EDIT_FLOW_PERF").count - 1, 1) + XCTAssertFalse(manager.contains("service.call(")) + func measuredRegion( stage: String, in source: String, @@ -619,16 +664,6 @@ return String(source[begin.lowerBound ..< end.upperBound]) } - func assertMeasured( - stage: String, - operation: String, - in source: String, - context: String - ) throws { - let region = try measuredRegion(stage: stage, in: source, context: context) - XCTAssertTrue(region.contains(operation), "\(context) no longer owns \(operation)") - } - func assertDeferredMeasurement( stage: String, operation: String, @@ -668,19 +703,7 @@ // Structural exception: recorder tests prove the stage inventory. These checks retain // only telemetry ownership and DEBUG-gating facts that runtime tests cannot observe. - let manager = try source("Sources/RepoPrompt/Infrastructure/MCP/MCPConnectionManager.swift") - try assertMeasured( - stage: "serviceToolLookupServiceToolsAwait", - operation: "await service.tools", - in: manager, - context: "service-tools await" - ) - try assertMeasured( - stage: "serviceToolLookupToolDefinitionScan", - operation: ".first(where:", - in: manager, - context: "tool-definition scan" - ) + // Indexed route lookup replaces the former per-call service tools await and definition scan. let injectionRegion = try measuredRegion( stage: "serviceToolLookupPublicWindowIDInjection", in: manager, @@ -820,8 +843,8 @@ XCTAssertTrue(viewModel.contains("#else\n Task { await updateToolRegistration() }")) XCTAssertEqual(viewModel.components(separatedBy: "await updateToolRegistration()").count - 1, 2) XCTAssertTrue(viewModel.contains("private func updateToolRegistration(invalidateCatalogBeforeUpdate: Bool = true) async {")) - XCTAssertTrue(viewModel.contains("let invalidateCatalogBeforeUpdate = !windowToolsEnabled\n || !ServiceRegistry.services.contains { service in\n (service as AnyObject) === (windowToolCatalogService as AnyObject)\n }")) - XCTAssertEqual(viewModel.components(separatedBy: "await updateToolRegistration(invalidateCatalogBeforeUpdate:").count - 1, 1) + XCTAssertTrue(viewModel.contains("let invalidateCatalogBeforeUpdate = !windowToolsEnabled\n || !serviceRegistry.contains(windowToolCatalogService)")) + XCTAssertEqual(viewModel.components(separatedBy: "await updateToolRegistration(invalidateCatalogBeforeUpdate:").count - 1, 2) XCTAssertTrue(viewModel.contains("await updateToolRegistration(invalidateCatalogBeforeUpdate: invalidateCatalogBeforeUpdate)\n #if DEBUG || EDIT_FLOW_PERF\n EditFlowPerf.end(EditFlowPerf.Stage.MCPWindowToolCatalog.registrationUpdateAgentBootstrap")) let bootstrapStart = try XCTUnwrap(viewModel.range(of: "func ensureServerReadyForAgentBootstrap() async {")) @@ -837,11 +860,14 @@ let helperStart = try XCTUnwrap(viewModel.range(of: "private func updateToolRegistration(invalidateCatalogBeforeUpdate: Bool = true) async {")) let policy = try XCTUnwrap(viewModel.range(of: "if invalidateCatalogBeforeUpdate {", range: helperStart.upperBound ..< viewModel.endIndex)) let invalidate = try XCTUnwrap(viewModel.range(of: "invalidateToolsCache()", range: policy.upperBound ..< viewModel.endIndex)) - let enabled = try XCTUnwrap(viewModel.range(of: "if windowToolsEnabled {", range: invalidate.upperBound ..< viewModel.endIndex)) - let register = try XCTUnwrap(viewModel.range(of: "ServiceRegistry.register(windowToolCatalogService)", range: enabled.upperBound ..< viewModel.endIndex)) + let enabled = try XCTUnwrap(viewModel.range( + of: "if windowToolsEnabled,\n runtimeSessionRegistry.hasActiveSession(", + range: invalidate.upperBound ..< viewModel.endIndex + )) + let register = try XCTUnwrap(viewModel.range(of: "serviceRegistry.register(windowToolCatalogService)", range: enabled.upperBound ..< viewModel.endIndex)) let join = try XCTUnwrap(viewModel.range(of: "try await service.join(windowID: windowID)", range: register.upperBound ..< viewModel.endIndex)) let enabledRefresh = try XCTUnwrap(viewModel.range(of: "await service.refreshState()", range: join.upperBound ..< viewModel.endIndex)) - let unregister = try XCTUnwrap(viewModel.range(of: "ServiceRegistry.unregister(windowToolCatalogService)", range: enabledRefresh.upperBound ..< viewModel.endIndex)) + let unregister = try XCTUnwrap(viewModel.range(of: "serviceRegistry.unregister(windowToolCatalogService)", range: enabledRefresh.upperBound ..< viewModel.endIndex)) let leave = try XCTUnwrap(viewModel.range(of: "await service.leave(windowID: windowID)", range: unregister.upperBound ..< viewModel.endIndex)) let disabledRefresh = try XCTUnwrap(viewModel.range(of: "await service.refreshState()", range: leave.upperBound ..< viewModel.endIndex)) XCTAssertLessThan(policy.lowerBound, invalidate.lowerBound) @@ -856,15 +882,17 @@ XCTAssertEqual(readiness.components(separatedBy: "MCPWindowToolCatalog.readinessWarmAccess").count - 1, 2) XCTAssertTrue(readiness.contains("_ = await mcpServer.windowMCPTools")) - let dedupe = try XCTUnwrap(registry.range(of: "if _services.contains(where:")) - let append = try XCTUnwrap(registry.range(of: "_services.append(service)", range: dedupe.upperBound ..< registry.endIndex)) + let dedupe = try XCTUnwrap(registry.range(of: "guard !contains(service) else { return }")) + let invalidation = try XCTUnwrap(registry.range(of: "invalidateSnapshot()", range: dedupe.upperBound ..< registry.endIndex)) + let append = try XCTUnwrap(registry.range(of: "registeredServices.append(RegisteredService(", range: invalidation.upperBound ..< registry.endIndex)) let publication = try XCTUnwrap(registry.range(of: "MCPWindowToolCatalog.serviceRegistryToolsPublication", range: append.upperBound ..< registry.endIndex)) - let broadcast = try XCTUnwrap(registry.range(of: "await ServerNetworkManager.shared.broadcastToolListChanged()", range: publication.upperBound ..< registry.endIndex)) - XCTAssertLessThan(dedupe.lowerBound, append.lowerBound) + XCTAssertLessThan(invalidation.lowerBound, publication.lowerBound) + XCTAssertLessThan(dedupe.lowerBound, invalidation.lowerBound) + XCTAssertLessThan(invalidation.lowerBound, append.lowerBound) XCTAssertLessThan(append.lowerBound, publication.lowerBound) - XCTAssertLessThan(publication.lowerBound, broadcast.lowerBound) XCTAssertEqual(registry.components(separatedBy: "MCPWindowToolCatalog.serviceRegistryToolsPublication").count - 1, 1) - XCTAssertTrue(registry.contains("#else\n await ToolAvailabilityStore.shared.registerTools(service.tools)")) + XCTAssertTrue(registry.contains("guard boundary.generation == requestedGeneration")) + XCTAssertTrue(registry.contains("routesByCanonicalName[canonicalName, default: []].append(route)")) let enable = try XCTUnwrap(codexRunner.range(of: "await mcpServerEnabler()")) let send = try XCTUnwrap(codexRunner.range(of: "let outcome = await codexCoordinator.sendCodexNativeMessage(", range: enable.upperBound ..< codexRunner.endIndex)) @@ -1047,7 +1075,7 @@ XCTAssertTrue(viewModel.contains("Stage.ReadFile.AutoSelect.\(hook)"), "Missing view-model nested auto-select hook: \(hook)") } - let mutations = try source("Sources/RepoPrompt/Infrastructure/WorkspaceContext/Selection/WorkspaceSelectionMutationService.swift") + let mutations = try source("Sources/RepoPromptCore/WorkspaceContext/Selection/WorkspaceSelectionMutationService.swift") for hook in [ "candidateResolutionTotal", "structuralMerge", @@ -1059,15 +1087,15 @@ XCTAssertTrue(mutations.contains("Stage.ReadFile.AutoSelect.\(hook)"), "Missing mutation-service nested auto-select hook: \(hook)") } - let extractor = try source("Sources/RepoPrompt/Features/CodeMap/CodeMapExtractor.swift") - let workspaceOverloadStart = try XCTUnwrap(extractor.range(of: "static func resolveReferencedFilePaths(\n from selectedFiles: [WorkspaceFileRecord]")) - let workspaceOverloadEnd = try XCTUnwrap(extractor.range(of: "/// Returns the list of file paths", range: workspaceOverloadStart.upperBound ..< extractor.endIndex)) - let workspaceOverload = String(extractor[workspaceOverloadStart.lowerBound ..< workspaceOverloadEnd.lowerBound]) - XCTAssertTrue(workspaceOverload.contains("Stage.ReadFile.AutoSelect.acceptedFileAPIFilter")) - XCTAssertTrue(workspaceOverload.contains("Stage.ReadFile.AutoSelect.autoReferencedAPIComputation")) + let extractor = try source("Sources/RepoPromptCore/CodeMap/CodeMapExtractor.swift") + XCTAssertTrue(extractor.contains("package static func resolveReferencedFilePaths(\n from selectedFiles: [WorkspaceFileRecord]")) + XCTAssertTrue(extractor.contains("WorkspaceRuntimePerf.Stage.ReadFile.AutoSelect.acceptedFileAPIFilter")) + XCTAssertTrue(extractor.contains("WorkspaceRuntimePerf.Stage.ReadFile.AutoSelect.autoReferencedAPIComputation")) - let fileViewModelOverloadStart = try XCTUnwrap(extractor.range(of: "static func resolveReferencedFilePaths(\n from selectedFiles: [FileViewModel]")) - let fileViewModelOverload = String(extractor[fileViewModelOverloadStart.lowerBound ..< workspaceOverloadStart.lowerBound]) + let appExtractor = try source("Sources/RepoPrompt/Features/CodeMap/CodeMapExtractor.swift") + let fileViewModelOverloadStart = try XCTUnwrap(appExtractor.range(of: "static func resolveReferencedFilePaths(\n from selectedFiles: [FileViewModel]")) + let fileViewModelOverloadEnd = try XCTUnwrap(appExtractor.range(of: "/// Returns the list of file paths", range: fileViewModelOverloadStart.upperBound ..< appExtractor.endIndex)) + let fileViewModelOverload = String(appExtractor[fileViewModelOverloadStart.lowerBound ..< fileViewModelOverloadEnd.lowerBound]) XCTAssertFalse(fileViewModelOverload.contains("Stage.ReadFile.AutoSelect")) let provider = try source("Sources/RepoPrompt/Infrastructure/MCP/WindowTools/MCPFileToolProvider.swift") @@ -1088,7 +1116,7 @@ XCTAssertLessThan(dependencyAwait.lowerBound, valueEncoding.lowerBound) let viewModel = try source("Sources/RepoPrompt/Infrastructure/MCP/ViewModels/MCPServerViewModel.swift") - let mutations = try source("Sources/RepoPrompt/Infrastructure/WorkspaceContext/Selection/WorkspaceSelectionMutationService.swift") + let mutations = try source("Sources/RepoPromptCore/WorkspaceContext/Selection/WorkspaceSelectionMutationService.swift") let nestedSources = viewModel + mutations for outcome in [ "eligible", @@ -1140,7 +1168,7 @@ }) } - func testReadFileAutoSelectionQueueAndDurabilityHooksRemainOwnedByCoordinatorAndDiskWriter() throws { + func testReadFileAutoSelectionQueueAndDurabilityHooksRemainOwnedByCoordinatorCoreWriterAndAppAdapter() throws { let coordinator = try source("Sources/RepoPrompt/Infrastructure/MCP/ViewModels/MCPReadFileAutoSelectionCoordinator.swift") for hook in [ "responseEnqueue", @@ -1156,11 +1184,23 @@ let viewModel = try source("Sources/RepoPrompt/Infrastructure/MCP/ViewModels/MCPServerViewModel+TabContext.swift") XCTAssertTrue(viewModel.contains("Stage.ReadFile.AutoSelect.canonicalStoredCommit")) - let workspaceManager = try source("Sources/RepoPrompt/Features/Workspaces/ViewModels/WorkspaceManagerViewModel.swift") - XCTAssertTrue(workspaceManager.contains("Stage.WorkspaceDurability.flushWait")) - XCTAssertTrue(workspaceManager.contains("Stage.WorkspaceDurability.atomicWrite")) + let coreWriter = try source("Sources/RepoPromptCore/Workspaces/WorkspacePersistenceWriter.swift") + for event in [ + "workspaceSave.flush.begin", + "workspaceSave.flush.end", + "workspaceSave.write.begin", + "workspaceSave.write.end" + ] { + XCTAssertTrue(coreWriter.contains(event), "Missing neutral Core durability event: \(event)") + } + let diagnosticsAdapter = try source( + "Sources/RepoPrompt/App/CoreAdapters/EmbeddedWorkspaceRepositoryDiagnosticsAdapter.swift" + ) + XCTAssertTrue(diagnosticsAdapter.contains("Stage.WorkspaceDurability.flushWait")) + XCTAssertTrue(diagnosticsAdapter.contains("Stage.WorkspaceDurability.atomicWrite")) XCTAssertFalse(coordinator.contains("EditFlowPerf.Dimensions(path:")) - XCTAssertFalse(workspaceManager.contains("EditFlowPerf.Dimensions(path:")) + XCTAssertFalse(coreWriter.contains("EditFlowPerf.Dimensions(path:")) + XCTAssertFalse(diagnosticsAdapter.contains("EditFlowPerf.Dimensions(path:")) } func testReadFileAutoSelectionQueueRecorderCapturesSanitizedStagesAndLifecycle() throws { @@ -1201,28 +1241,19 @@ } func testAcceptedFileAPIFilterInnerAttributionRemainsBehaviorNeutralScopedAndOrdered() throws { - let extractor = try source("Sources/RepoPrompt/Features/CodeMap/CodeMapExtractor.swift") - let helperStart = try XCTUnwrap(extractor.range(of: " private static func acceptedFileAPIs(from files: [WorkspaceFileRecord], allFileAPIs: [FileAPI]) -> [FileAPI] {")) - let helperEnd = try XCTUnwrap(extractor.range(of: " private static func isUnderCurrentRoots", range: helperStart.upperBound ..< extractor.endIndex)) + let extractor = try source("Sources/RepoPromptCore/CodeMap/CodeMapExtractor.swift") + let helperStart = try XCTUnwrap(extractor.range(of: " private static func acceptedFileAPIs(\n from files: [WorkspaceFileRecord],\n allFileAPIs: [FileAPI]")) + let helperEnd = try XCTUnwrap(extractor.range(of: " package static func getAutoReferencedAPIs(", range: helperStart.upperBound ..< extractor.endIndex)) let helper = String(extractor[helperStart.lowerBound ..< helperEnd.lowerBound]) var searchStart = helper.startIndex for hook in [ "guard !files.isEmpty, !allFileAPIs.isEmpty else { return [] }", - "#if DEBUG || EDIT_FLOW_PERF", - "EditFlowPerf.begin(EditFlowPerf.Stage.ReadFile.AutoSelect.AcceptedFileAPIFilter.pathGrouping)", - "let apisByPath = Dictionary(grouping: allFileAPIs, by: { standardizedAPIFilePath($0) })", - "EditFlowPerf.end(EditFlowPerf.Stage.ReadFile.AutoSelect.AcceptedFileAPIFilter.pathGrouping, pathGrouping)", - "EditFlowPerf.begin(EditFlowPerf.Stage.ReadFile.AutoSelect.AcceptedFileAPIFilter.selectedRecordProjection)", - "let selectedAPIs = files.compactMap { file in", - "apisByPath[file.standardizedFullPath]?.first", - "EditFlowPerf.end(EditFlowPerf.Stage.ReadFile.AutoSelect.AcceptedFileAPIFilter.selectedRecordProjection, selectedRecordProjection)", - "return selectedAPIs", - "#else", - "let apisByPath = Dictionary(grouping: allFileAPIs, by: { standardizedAPIFilePath($0) })", - "return files.compactMap { file in", - "apisByPath[file.standardizedFullPath]?.first", - "#endif" + "WorkspaceRuntimePerf.Stage.ReadFile.AutoSelect.AcceptedFileAPIFilter.pathGrouping", + "let apisByPath = Dictionary(grouping: allFileAPIs, by: standardizedAPIFilePath)", + "WorkspaceRuntimePerf.Stage.ReadFile.AutoSelect.AcceptedFileAPIFilter.selectedRecordProjection", + "let selectedAPIs = files.compactMap { apisByPath[$0.standardizedFullPath]?.first }", + "return selectedAPIs" ] { let match = try XCTUnwrap(helper.range(of: hook, range: searchStart ..< helper.endIndex), "Missing or out-of-order accepted-file attribution hook: \(hook)") searchStart = match.upperBound @@ -1235,7 +1266,7 @@ "UserDefaults", "app_settings", "nonisolated", - "EditFlowPerf.Dimensions", + "WorkspaceRuntimePerf.Dimensions", "print(", "Logger", "os_log", @@ -1248,33 +1279,34 @@ } let indexedHelperStart = try XCTUnwrap(extractor.range(of: " private static func acceptedFileAPIs(\n from files: [WorkspaceFileRecord],\n firstFileAPIByStandardizedNestedPath: [String: FileAPI]")) - let indexedHelperEnd = try XCTUnwrap(extractor.range(of: " private static func isUnderCurrentRoots", range: indexedHelperStart.upperBound ..< extractor.endIndex)) + let indexedHelperEnd = try XCTUnwrap(extractor.range(of: " package static func getAutoReferencedAPIs(", range: indexedHelperStart.upperBound ..< extractor.endIndex)) let indexedHelper = String(extractor[indexedHelperStart.lowerBound ..< indexedHelperEnd.lowerBound]) XCTAssertTrue(indexedHelper.contains("guard !files.isEmpty, !firstFileAPIByStandardizedNestedPath.isEmpty else { return [] }")) - XCTAssertTrue(indexedHelper.contains("EditFlowPerf.begin(EditFlowPerf.Stage.ReadFile.AutoSelect.AcceptedFileAPIFilter.selectedRecordProjection)")) - XCTAssertTrue(indexedHelper.contains("firstFileAPIByStandardizedNestedPath[file.standardizedFullPath]")) - XCTAssertTrue(indexedHelper.contains("EditFlowPerf.end(EditFlowPerf.Stage.ReadFile.AutoSelect.AcceptedFileAPIFilter.selectedRecordProjection, selectedRecordProjection)")) + XCTAssertTrue(indexedHelper.contains("WorkspaceRuntimePerf.Stage.ReadFile.AutoSelect.AcceptedFileAPIFilter.selectedRecordProjection")) + XCTAssertTrue(indexedHelper.contains("firstFileAPIByStandardizedNestedPath[$0.standardizedFullPath]")) XCTAssertFalse(indexedHelper.contains("pathGrouping")) XCTAssertFalse(indexedHelper.contains("Dictionary(grouping:")) - let fileViewModelHelperStart = try XCTUnwrap(extractor.range(of: " private static func acceptedFileAPIs(from files: [FileViewModel]) -> [FileAPI] {")) - let fileViewModelHelper = String(extractor[fileViewModelHelperStart.lowerBound ..< helperStart.lowerBound]) + let appExtractor = try source("Sources/RepoPrompt/Features/CodeMap/CodeMapExtractor.swift") + let fileViewModelHelperStart = try XCTUnwrap(appExtractor.range(of: " private static func acceptedFileAPIs(from files: [FileViewModel]) -> [FileAPI] {")) + let fileViewModelHelperEnd = try XCTUnwrap(appExtractor.range(of: " private static func acceptedFileAPIs(from files: [WorkspaceFileRecord]", range: fileViewModelHelperStart.upperBound ..< appExtractor.endIndex)) + let fileViewModelHelper = String(appExtractor[fileViewModelHelperStart.lowerBound ..< fileViewModelHelperEnd.lowerBound]) XCTAssertFalse(fileViewModelHelper.contains("AcceptedFileAPIFilter")) - let resolverStart = try XCTUnwrap(extractor.range(of: "static func resolveReferencedFilePaths(\n from selectedFiles: [WorkspaceFileRecord]")) - let resolverEnd = try XCTUnwrap(extractor.range(of: "/// Returns the list of file paths", range: resolverStart.upperBound ..< extractor.endIndex)) + let resolverStart = try XCTUnwrap(extractor.range(of: "package static func resolveReferencedFilePaths(\n from selectedFiles: [WorkspaceFileRecord]")) + let resolverEnd = try XCTUnwrap(extractor.range(of: " private static func resolveReferencedFilePaths(", range: resolverStart.upperBound ..< extractor.endIndex)) let resolver = String(extractor[resolverStart.lowerBound ..< resolverEnd.lowerBound]) - let outerBegin = try XCTUnwrap(resolver.range(of: "EditFlowPerf.begin(EditFlowPerf.Stage.ReadFile.AutoSelect.acceptedFileAPIFilter)")) + let outerBegin = try XCTUnwrap(resolver.range(of: "WorkspaceRuntimePerf.Stage.ReadFile.AutoSelect.acceptedFileAPIFilter")) let helperCall = try XCTUnwrap(resolver.range(of: "acceptedFileAPIs(from: selectedFiles, allFileAPIs: allFileAPIs)", range: outerBegin.upperBound ..< resolver.endIndex)) - let outerEnd = try XCTUnwrap(resolver.range(of: "EditFlowPerf.end(EditFlowPerf.Stage.ReadFile.AutoSelect.acceptedFileAPIFilter, acceptedFileAPIFilter)", range: helperCall.upperBound ..< resolver.endIndex)) + let outerEnd = try XCTUnwrap(resolver.range(of: "acceptedFileAPIFilter\n )", range: helperCall.upperBound ..< resolver.endIndex)) let indexedResolver = try XCTUnwrap(resolver.range(of: "firstFileAPIByStandardizedNestedPath: [String: FileAPI]")) let indexedHelperCall = try XCTUnwrap(resolver.range(of: "firstFileAPIByStandardizedNestedPath: firstFileAPIByStandardizedNestedPath", range: indexedResolver.upperBound ..< resolver.endIndex)) - let lowerComputation = try XCTUnwrap(resolver.range(of: "EditFlowPerf.begin(EditFlowPerf.Stage.ReadFile.AutoSelect.autoReferencedAPIComputation)", range: indexedHelperCall.upperBound ..< resolver.endIndex)) + let lowerComputation = try XCTUnwrap(extractor.range(of: "WorkspaceRuntimePerf.Stage.ReadFile.AutoSelect.autoReferencedAPIComputation", range: resolverEnd.upperBound ..< extractor.endIndex)) XCTAssertLessThan(outerBegin.lowerBound, helperCall.lowerBound) XCTAssertLessThan(helperCall.lowerBound, outerEnd.lowerBound) XCTAssertLessThan(outerEnd.lowerBound, indexedResolver.lowerBound) XCTAssertLessThan(indexedResolver.lowerBound, indexedHelperCall.lowerBound) - XCTAssertLessThan(indexedHelperCall.lowerBound, lowerComputation.lowerBound) + XCTAssertLessThan(resolverEnd.lowerBound, lowerComputation.lowerBound) } func testAcceptedFileAPIFilterInnerAttributionRecorderCapturesEmptyDimensions() throws { @@ -1300,7 +1332,7 @@ } func testAllCodemapFileAPIsActorOwnedCacheHooksRemainScopedAndExhaustivelyInvalidated() throws { - let store = try source("Sources/RepoPrompt/Infrastructure/WorkspaceContext/WorkspaceFileContextStore.swift") + let store = try source("Sources/RepoPromptCore/WorkspaceContext/WorkspaceFileContextStore.swift") XCTAssertTrue(store.contains("private var cachedCodemapFileAPIAggregate: WorkspaceCodemapFileAPIAggregate?")) XCTAssertFalse(store.contains("private var cachedAllCodemapFileAPIs: [FileAPI]?")) XCTAssertEqual(store.components(separatedBy: "invalidateAllCodemapFileAPIsCache").count - 1, 9) @@ -1313,24 +1345,24 @@ XCTAssertFalse(invalidator.contains(forbidden), "Forbidden aggregate-cache invalidator semantic: \(forbidden)") } - let compatibilityAccessorStart = try XCTUnwrap(store.range(of: " func allCodemapFileAPIs() -> [FileAPI] {")) - let accessorStart = try XCTUnwrap(store.range(of: " func codemapFileAPIAggregate() -> WorkspaceCodemapFileAPIAggregate {", range: compatibilityAccessorStart.upperBound ..< store.endIndex)) + let compatibilityAccessorStart = try XCTUnwrap(store.range(of: " package func allCodemapFileAPIs() -> [FileAPI] {")) + let accessorStart = try XCTUnwrap(store.range(of: " package func codemapFileAPIAggregate() -> WorkspaceCodemapFileAPIAggregate {", range: compatibilityAccessorStart.upperBound ..< store.endIndex)) let compatibilityAccessor = String(store[compatibilityAccessorStart.lowerBound ..< accessorStart.lowerBound]) XCTAssertTrue(compatibilityAccessor.contains("codemapFileAPIAggregate().orderedFileAPIs")) - let accessorEnd = try XCTUnwrap(store.range(of: " func codemapSnapshotDictionary()", range: accessorStart.upperBound ..< store.endIndex)) + let accessorEnd = try XCTUnwrap(store.range(of: " package func codemapSnapshotDictionary()", range: accessorStart.upperBound ..< store.endIndex)) let accessor = String(store[accessorStart.lowerBound ..< accessorEnd.lowerBound]) var searchStart = accessor.startIndex for hook in [ - "EditFlowPerf.begin(EditFlowPerf.Stage.ReadFile.AutoSelect.AllCodemapFileAPIs.actorBodyTotal)", - "defer { EditFlowPerf.end(EditFlowPerf.Stage.ReadFile.AutoSelect.AllCodemapFileAPIs.actorBodyTotal, actorBodyTotal) }", + "WorkspaceRuntimePerf.begin(WorkspaceRuntimePerf.Stage.ReadFile.AutoSelect.AllCodemapFileAPIs.actorBodyTotal)", + "defer { WorkspaceRuntimePerf.end(WorkspaceRuntimePerf.Stage.ReadFile.AutoSelect.AllCodemapFileAPIs.actorBodyTotal, actorBodyTotal) }", "if let cachedCodemapFileAPIAggregate {", "return cachedCodemapFileAPIAggregate", "#if DEBUG || EDIT_FLOW_PERF", - "EditFlowPerf.begin(EditFlowPerf.Stage.ReadFile.AutoSelect.AllCodemapFileAPIs.stateSnapshot)", + "WorkspaceRuntimePerf.begin(WorkspaceRuntimePerf.Stage.ReadFile.AutoSelect.AllCodemapFileAPIs.stateSnapshot)", "codemapSnapshotsByFileID.values", ".filter { isDiscoverableFileID($0.fileID) }", - "EditFlowPerf.end(EditFlowPerf.Stage.ReadFile.AutoSelect.AllCodemapFileAPIs.stateSnapshot, stateSnapshot)", - "EditFlowPerf.begin(EditFlowPerf.Stage.ReadFile.AutoSelect.AllCodemapFileAPIs.materialization)", + "WorkspaceRuntimePerf.end(WorkspaceRuntimePerf.Stage.ReadFile.AutoSelect.AllCodemapFileAPIs.stateSnapshot, stateSnapshot)", + "WorkspaceRuntimePerf.begin(WorkspaceRuntimePerf.Stage.ReadFile.AutoSelect.AllCodemapFileAPIs.materialization)", ".sorted { $0.fullPath < $1.fullPath }", ".compactMap(\\.fileAPI)", "#else", @@ -1345,7 +1377,7 @@ "let aggregate = WorkspaceCodemapFileAPIAggregate(", "orderedFileAPIs: APIs,", "firstFileAPIByStandardizedNestedPath: firstFileAPIByStandardizedNestedPath", - "EditFlowPerf.end(EditFlowPerf.Stage.ReadFile.AutoSelect.AllCodemapFileAPIs.materialization, materialization)", + "WorkspaceRuntimePerf.end(WorkspaceRuntimePerf.Stage.ReadFile.AutoSelect.AllCodemapFileAPIs.materialization, materialization)", "cachedCodemapFileAPIAggregate = aggregate", "return aggregate" ] { @@ -1360,7 +1392,7 @@ "UserDefaults", "app_settings", "nonisolated", - "EditFlowPerf.Dimensions", + "WorkspaceRuntimePerf.Dimensions", "print(", "Logger", "os_log", @@ -1384,11 +1416,11 @@ XCTAssertTrue(store.contains(requiredInvalidationWiring), "Missing aggregate-cache invalidation wiring: \(requiredInvalidationWiring)") } - let unloadStart = try XCTUnwrap(store.range(of: " func unloadRoots(ids rootIDs: [UUID]) async {")) - let unloadEnd = try XCTUnwrap(store.range(of: " func file(rootID: UUID, relativePath: String)", range: unloadStart.upperBound ..< store.endIndex)) + let unloadStart = try XCTUnwrap(store.range(of: " package func unloadRoots(ids rootIDs: [UUID]) async {")) + let unloadEnd = try XCTUnwrap(store.range(of: " package func file(rootID: UUID, relativePath: String)", range: unloadStart.upperBound ..< store.endIndex)) let unload = String(store[unloadStart.lowerBound ..< unloadEnd.lowerBound]) + let stopWatching = try XCTUnwrap(unload.range(of: "await stopWatchingRoot(id: entry.rootID, service: entry.state.service)")) let rootDetach = try XCTUnwrap(unload.range(of: "rootStatesByID.removeValue(forKey: rootID)")) - let stopWatching = try XCTUnwrap(unload.range(of: "await reconcileWatcherServiceState(entry.state.service, rootID: entry.rootID)")) let managedOnlyCleanup = try XCTUnwrap(unload.range(of: "managedOnlyFileIDs.remove(fileID)")) let rootSnapshotCleanup = try XCTUnwrap(unload.range(of: "removeCodemapSnapshots(forRootID: rootID)")) XCTAssertLessThan(rootDetach.lowerBound, stopWatching.lowerBound) @@ -1398,8 +1430,8 @@ XCTAssertFalse(String(unload[managedOnlyCleanup.lowerBound ..< rootSnapshotCleanup.lowerBound]).contains("await")) let provider = try source("Sources/RepoPrompt/Infrastructure/MCP/WindowTools/MCPFileToolProvider.swift") - let mutations = try source("Sources/RepoPrompt/Infrastructure/WorkspaceContext/Selection/WorkspaceSelectionMutationService.swift") - let extractor = try source("Sources/RepoPrompt/Features/CodeMap/CodeMapExtractor.swift") + let mutations = try source("Sources/RepoPromptCore/WorkspaceContext/Selection/WorkspaceSelectionMutationService.swift") + let extractor = try source("Sources/RepoPromptCore/CodeMap/CodeMapExtractor.swift") let diagnostics = try diagnosticsSource() + source("Sources/RepoPrompt/Features/Diagnostics/MCP/MCPConnectionManager+DebugDiagnosticsReadSearchLatency.swift") for forbiddenOwner in [provider, mutations, extractor, diagnostics] { XCTAssertFalse(forbiddenOwner.contains("AllCodemapFileAPIs")) @@ -1408,15 +1440,15 @@ XCTAssertFalse(forbiddenOwner.contains("invalidateAllCodemapFileAPIsCache")) } - let recomputeStart = try XCTUnwrap(mutations.range(of: " func recomputeAutoCodemaps(")) + let recomputeStart = try XCTUnwrap(mutations.range(of: " package func recomputeAutoCodemaps(")) let recompute = String(mutations[recomputeStart.lowerBound ..< mutations.endIndex]) - let outerBegin = try XCTUnwrap(recompute.range(of: "let codemapAPILoad = EditFlowPerf.begin(EditFlowPerf.Stage.ReadFile.AutoSelect.codemapAPILoad)")) + let outerBegin = try XCTUnwrap(recompute.range(of: "let codemapAPILoad = WorkspaceRuntimePerf.begin(WorkspaceRuntimePerf.Stage.ReadFile.AutoSelect.codemapAPILoad)")) let outerAwait = try XCTUnwrap(recompute.range(of: "let aggregate = await store.codemapFileAPIAggregate()", range: outerBegin.upperBound ..< recompute.endIndex)) XCTAssertEqual(recompute.components(separatedBy: "await store.codemapFileAPIAggregate()").count - 1, 1) XCTAssertFalse(recompute.contains("await store.allCodemapFileAPIs()")) XCTAssertTrue(recompute.contains("among: aggregate.orderedFileAPIs")) XCTAssertTrue(recompute.contains("firstFileAPIByStandardizedNestedPath: aggregate.firstFileAPIByStandardizedNestedPath")) - let outerEnd = try XCTUnwrap(recompute.range(of: "EditFlowPerf.end(EditFlowPerf.Stage.ReadFile.AutoSelect.codemapAPILoad, codemapAPILoad)", range: outerAwait.upperBound ..< recompute.endIndex)) + let outerEnd = try XCTUnwrap(recompute.range(of: "WorkspaceRuntimePerf.end(WorkspaceRuntimePerf.Stage.ReadFile.AutoSelect.codemapAPILoad, codemapAPILoad)", range: outerAwait.upperBound ..< recompute.endIndex)) XCTAssertLessThan(outerBegin.lowerBound, outerAwait.lowerBound) XCTAssertLessThan(outerAwait.lowerBound, outerEnd.lowerBound) } @@ -1457,7 +1489,7 @@ XCTAssertTrue(viewModel.contains(hook), "Missing view-model read-resolution hook: \(hook)") } - let readableService = try source("Sources/RepoPrompt/Infrastructure/WorkspaceContext/WorkspaceReadableFileService.swift") + let readableService = try source("Sources/RepoPromptCore/WorkspaceContext/WorkspaceReadableFileService.swift") for hook in [ "exactCatalogLookupAwait", "explicitMaterialization", @@ -1467,7 +1499,7 @@ XCTAssertTrue(readableService.contains(hook), "Missing readable-service resolution hook: \(hook)") } - let store = try source("Sources/RepoPrompt/Infrastructure/WorkspaceContext/WorkspaceFileContextStore.swift") + let store = try source("Sources/RepoPromptCore/WorkspaceContext/WorkspaceFileContextStore.swift") XCTAssertTrue(store.contains("exactCatalogLookupActorBody")) XCTAssertTrue(store.contains("exactCatalogLookupRoute")) XCTAssertTrue(store.contains("Dimensions(status: exactCatalogLookupRoute, outcome: exactCatalogLookupOutcome)")) @@ -1480,13 +1512,13 @@ XCTAssertFalse(viewModel.contains("let readableServiceOutcome =")) XCTAssertTrue(viewModel.contains("Dimensions(outcome: {\n switch readableFile")) - let readableService = try source("Sources/RepoPrompt/Infrastructure/WorkspaceContext/WorkspaceReadableFileService.swift") + let readableService = try source("Sources/RepoPromptCore/WorkspaceContext/WorkspaceReadableFileService.swift") XCTAssertFalse(readableService.contains("let exactCatalogLookupOutcome =")) XCTAssertFalse(readableService.contains("let explicitMaterializationOutcome =")) XCTAssertTrue(readableService.contains("Dimensions(outcome: {\n switch exactCatalogLookup")) XCTAssertTrue(readableService.contains("Dimensions(outcome: {\n switch materialization")) - let store = try source("Sources/RepoPrompt/Infrastructure/WorkspaceContext/WorkspaceFileContextStore.swift") + let store = try source("Sources/RepoPromptCore/WorkspaceContext/WorkspaceFileContextStore.swift") XCTAssertTrue(store.contains("#if DEBUG || EDIT_FLOW_PERF\n var exactCatalogLookupOutcome")) XCTAssertTrue(store.contains("var exactCatalogLookupRoute = \"empty\"")) XCTAssertTrue(store.contains("exactCatalogLookupRoute = \"absolute\"")) @@ -1497,7 +1529,7 @@ } func testSearchCatalogSnapshotCacheRemainsBoundedGenerationKeyedAndCoarselyDiagnosed() throws { - let store = try source("Sources/RepoPrompt/Infrastructure/WorkspaceContext/WorkspaceFileContextStore.swift") + let store = try source("Sources/RepoPromptCore/WorkspaceContext/WorkspaceFileContextStore.swift") XCTAssertTrue(store.contains("private static let maxCachedSearchCatalogSnapshotScopes = 16")) XCTAssertTrue(store.contains("private var searchCatalogSnapshotsByScope: [WorkspaceLookupRootScope: SearchCatalogSnapshotCacheEntry] = [:]")) XCTAssertTrue(store.contains("case .sessionBoundWorkspace:\n scopedSnapshotGeneration(scope: .allLoaded)")) @@ -1509,7 +1541,8 @@ "guard !statesToUnload.isEmpty else { return }", "clearSearchCatalogSnapshotCache()", "await searchDecodedContentCache.invalidate(rootID: entry.rootID)", - "#if DEBUG" + "#if DEBUG", + "if let rootUnloadDidDetachHandler" ] ) XCTAssertTrue(store.contains("bumpCatalogGenerations(affectedRootKinds: affectedRootKinds)\n clearSearchCatalogSnapshotCache()\n invalidatePathMatchCache()")) @@ -1903,56 +1936,56 @@ } func testContentReadWorkerPermitAndBroadSearchAdmissionHooksRemainOwnedSanitizedAndOrdered() throws { - let contentLoading = try source("Sources/RepoPrompt/Infrastructure/FileSystem/FileSystemService+ContentLoading.swift") - XCTAssertTrue(contentLoading.contains("EditFlowPerf.Stage.FileSystem.contentReadWorkerPermitWait")) - XCTAssertTrue(contentLoading.contains("EditFlowPerf.Lifecycle.FileSystem.contentReadWorkerPermitWaitBegan")) - XCTAssertTrue(contentLoading.contains("EditFlowPerf.Lifecycle.FileSystem.contentReadWorkerPermitAcquired")) - XCTAssertTrue(contentLoading.contains("EditFlowPerf.Lifecycle.FileSystem.contentReadWorkerPermitCancelled")) - XCTAssertTrue(contentLoading.contains("EditFlowPerf.Lifecycle.FileSystem.contentReadWorkerOverloaded")) + let contentLoading = try source("Sources/RepoPromptCore/FileSystem/FileSystemService+ContentLoading.swift") + XCTAssertTrue(contentLoading.contains("WorkspaceRuntimePerf.Stage.FileSystem.contentReadWorkerPermitWait")) + XCTAssertTrue(contentLoading.contains("WorkspaceRuntimePerf.Lifecycle.FileSystem.contentReadWorkerPermitWaitBegan")) + XCTAssertTrue(contentLoading.contains("WorkspaceRuntimePerf.Lifecycle.FileSystem.contentReadWorkerPermitAcquired")) + XCTAssertTrue(contentLoading.contains("WorkspaceRuntimePerf.Lifecycle.FileSystem.contentReadWorkerPermitCancelled")) + XCTAssertTrue(contentLoading.contains("WorkspaceRuntimePerf.Lifecycle.FileSystem.contentReadWorkerOverloaded")) XCTAssertTrue(contentLoading.contains("maxQueuedWaiterCount: 512")) XCTAssertTrue(contentLoading.contains("ownerID: request.schedulerOwnerID")) XCTAssertTrue(contentLoading.contains("agePromotionNanoseconds")) XCTAssertTrue(contentLoading.contains("maxConsecutiveInteractiveGrants")) - XCTAssertTrue(contentLoading.contains("EditFlowPerf.Stage.FileSystem.contentReadWorkerBody")) - XCTAssertTrue(contentLoading.contains("workloadClass: request.workloadClass")) + XCTAssertTrue(contentLoading.contains("WorkspaceRuntimePerf.Stage.FileSystem.contentReadWorkerBody")) + XCTAssertTrue(contentLoading.contains("workloadClass: request.workloadClass.rawValue")) XCTAssertTrue(contentLoading.contains("contentSource: \"disk\"")) XCTAssertTrue(contentLoading.contains("workerBodyFileBytes = telemetryFileBytes(validated.fileSize)")) - XCTAssertFalse(contentLoading.contains("EditFlowPerf.Dimensions(path:")) + XCTAssertFalse(contentLoading.contains("WorkspaceRuntimePerf.Dimensions(path:")) assertSourceOrder( in: contentLoading, hooks: [ - "let permitWaitState = EditFlowPerf.begin(", + "let permitWaitState = WorkspaceRuntimePerf.begin(", "let acquisition: PermitAcquisition", "acquisition = try await acquire(", - "EditFlowPerf.end(\n EditFlowPerf.Stage.FileSystem.contentReadWorkerPermitWait", + "WorkspaceRuntimePerf.end(\n WorkspaceRuntimePerf.Stage.FileSystem.contentReadWorkerPermitWait", "defer { release(acquisition) }", "return try await body()" ] ) - let coordinator = try source("Sources/RepoPrompt/Features/Search/StoreBackedWorkspaceSearchLane.swift") - XCTAssertTrue(coordinator.contains("EditFlowPerf.Stage.Search.broadAdmissionWait")) - XCTAssertTrue(coordinator.contains("EditFlowPerf.Lifecycle.Search.broadAdmissionWaitBegan")) - XCTAssertTrue(coordinator.contains("EditFlowPerf.Lifecycle.Search.broadAdmissionPermitAcquired")) - XCTAssertTrue(coordinator.contains("EditFlowPerf.Lifecycle.Search.broadAdmissionPermitCancelled")) - XCTAssertTrue(coordinator.contains("EditFlowPerf.Lifecycle.Search.broadAdmissionPermitReleased")) - XCTAssertTrue(coordinator.contains("EditFlowPerf.Lifecycle.Search.broadAdmissionOverloaded")) - XCTAssertTrue(coordinator.contains("EditFlowPerf.Lifecycle.Search.broadAdmissionWaitExpired")) - XCTAssertTrue(coordinator.contains("EditFlowPerf.Stage.Search.broadAdmissionLeaseHold")) + let coordinator = try source("Sources/RepoPromptCore/WorkspaceContext/Search/StoreBackedWorkspaceSearchLane.swift") + XCTAssertTrue(coordinator.contains("WorkspaceRuntimePerf.Stage.Search.broadAdmissionWait")) + XCTAssertTrue(coordinator.contains("WorkspaceRuntimePerf.Lifecycle.Search.broadAdmissionWaitBegan")) + XCTAssertTrue(coordinator.contains("WorkspaceRuntimePerf.Lifecycle.Search.broadAdmissionPermitAcquired")) + XCTAssertTrue(coordinator.contains("WorkspaceRuntimePerf.Lifecycle.Search.broadAdmissionPermitCancelled")) + XCTAssertTrue(coordinator.contains("WorkspaceRuntimePerf.Lifecycle.Search.broadAdmissionPermitReleased")) + XCTAssertTrue(coordinator.contains("WorkspaceRuntimePerf.Lifecycle.Search.broadAdmissionOverloaded")) + XCTAssertTrue(coordinator.contains("WorkspaceRuntimePerf.Lifecycle.Search.broadAdmissionWaitExpired")) + XCTAssertTrue(coordinator.contains("WorkspaceRuntimePerf.Stage.Search.broadAdmissionLeaseHold")) XCTAssertTrue(coordinator.contains("storeCapacity: 1")) XCTAssertTrue(coordinator.contains("globalCapacity: 0")) XCTAssertTrue(coordinator.contains("storeQueueDepth: metrics.queueDepth")) XCTAssertTrue(coordinator.contains("globalQueueDepth: 0")) XCTAssertTrue(coordinator.contains("queueAgeBucket: queueAgeBucket")) - XCTAssertFalse(coordinator.contains("EditFlowPerf.Dimensions(path:")) + XCTAssertFalse(coordinator.contains("WorkspaceRuntimePerf.Dimensions(path:")) assertSourceOrder( in: coordinator, hooks: [ - "let waitState = EditFlowPerf.begin(", + "let waitState = WorkspaceRuntimePerf.begin(", "acquisition = try await acquire(", - "let leaseHoldState = EditFlowPerf.begin(", + "let leaseHoldState = WorkspaceRuntimePerf.begin(", "defer {", - "EditFlowPerf.Stage.Search.broadAdmissionLeaseHold", + "WorkspaceRuntimePerf.Stage.Search.broadAdmissionLeaseHold", "release(acquisition)", "return try await operation(fileSearchActor)" ] @@ -1976,10 +2009,10 @@ XCTAssertEqual(manager.components(separatedBy: "try await toolDef.callAsFunction(effectiveArgs)").count - 1, 1) XCTAssertFalse(manager.contains("service.call(")) - let readable = try source("Sources/RepoPrompt/Infrastructure/WorkspaceContext/WorkspaceReadableFileService.swift") - XCTAssertTrue(readable.contains("EditFlowPerf.Stage.ReadFile.explicitIngressFreshnessWait")) - XCTAssertTrue(readable.contains("EditFlowPerf.Lifecycle.ReadFile.explicitFreshnessBegan")) - XCTAssertTrue(readable.contains("EditFlowPerf.Lifecycle.ReadFile.explicitFreshnessEnded")) + let readable = try source("Sources/RepoPromptCore/WorkspaceContext/WorkspaceReadableFileService.swift") + XCTAssertTrue(readable.contains("WorkspaceRuntimePerf.Stage.ReadFile.explicitIngressFreshnessWait")) + XCTAssertTrue(readable.contains("WorkspaceRuntimePerf.Lifecycle.ReadFile.explicitFreshnessBegan")) + XCTAssertTrue(readable.contains("WorkspaceRuntimePerf.Lifecycle.ReadFile.explicitFreshnessEnded")) let server = try source("Sources/RepoPrompt/Infrastructure/MCP/ViewModels/MCPServerViewModel.swift") assertSourceOrder( @@ -2003,40 +2036,40 @@ ] ) - let store = try source("Sources/RepoPrompt/Infrastructure/WorkspaceContext/WorkspaceFileContextStore.swift") + let store = try source("Sources/RepoPromptCore/WorkspaceContext/WorkspaceFileContextStore.swift") for hook in [ - "EditFlowPerf.Stage.ReadFile.storeReadContentForwardAwait", - "EditFlowPerf.Lifecycle.ReadFile.storeReadContentEntered", - "EditFlowPerf.Lifecycle.ReadFile.storeReadContentReturned", - "EditFlowPerf.Stage.ReadFile.folderResolutionGeneralLookupFallback", - "EditFlowPerf.Stage.ReadFile.pathLookupStaticSnapshotBuild", - "EditFlowPerf.Stage.Search.contentFreshnessValidationStoreActorBody", - "EditFlowPerf.Lifecycle.Search.contentFreshnessStoreEntered", - "EditFlowPerf.Lifecycle.Search.contentFreshnessStoreReturned" + "WorkspaceRuntimePerf.Stage.ReadFile.storeReadContentForwardAwait", + "WorkspaceRuntimePerf.Lifecycle.ReadFile.storeReadContentEntered", + "WorkspaceRuntimePerf.Lifecycle.ReadFile.storeReadContentReturned", + "WorkspaceRuntimePerf.Stage.ReadFile.folderResolutionGeneralLookupFallback", + "WorkspaceRuntimePerf.Stage.ReadFile.pathLookupStaticSnapshotBuild", + "WorkspaceRuntimePerf.Stage.Search.contentFreshnessValidationStoreActorBody", + "WorkspaceRuntimePerf.Lifecycle.Search.contentFreshnessStoreEntered", + "WorkspaceRuntimePerf.Lifecycle.Search.contentFreshnessStoreReturned" ] { XCTAssertTrue(store.contains(hook), "Missing store attribution hook: \(hook)") } - let fileSystem = try source("Sources/RepoPrompt/Infrastructure/FileSystem/FileSystemService+ContentLoading.swift") + let fileSystem = try source("Sources/RepoPromptCore/FileSystem/FileSystemService+ContentLoading.swift") for hook in [ - "EditFlowPerf.Stage.FileSystem.contentLoadTotal", - "EditFlowPerf.Stage.FileSystem.contentReadRequestPreparation", - "EditFlowPerf.Stage.FileSystem.contentReadOffActorAwait", - "EditFlowPerf.Lifecycle.FileSystem.contentLoadEntered", - "EditFlowPerf.Lifecycle.FileSystem.contentReadRequestPrepared", - "EditFlowPerf.Lifecycle.FileSystem.contentReadOffActorScheduled", - "EditFlowPerf.Lifecycle.FileSystem.contentReadWorkerReturned", - "EditFlowPerf.Lifecycle.FileSystem.contentLoadReturned" + "WorkspaceRuntimePerf.Stage.FileSystem.contentLoadTotal", + "WorkspaceRuntimePerf.Stage.FileSystem.contentReadRequestPreparation", + "WorkspaceRuntimePerf.Stage.FileSystem.contentReadOffActorAwait", + "WorkspaceRuntimePerf.Lifecycle.FileSystem.contentLoadEntered", + "WorkspaceRuntimePerf.Lifecycle.FileSystem.contentReadRequestPrepared", + "WorkspaceRuntimePerf.Lifecycle.FileSystem.contentReadOffActorScheduled", + "WorkspaceRuntimePerf.Lifecycle.FileSystem.contentReadWorkerReturned", + "WorkspaceRuntimePerf.Lifecycle.FileSystem.contentLoadReturned" ] { XCTAssertTrue(fileSystem.contains(hook), "Missing filesystem attribution hook: \(hook)") } - let fileEvents = try source("Sources/RepoPrompt/Infrastructure/FileSystem/FileSystemService+FSEvents.swift") - XCTAssertTrue(fileEvents.contains("EditFlowPerf.Stage.Search.contentFreshnessValidationRootActorBody")) - XCTAssertTrue(fileEvents.contains("EditFlowPerf.Lifecycle.Search.contentFreshnessRootEntered")) - XCTAssertTrue(fileEvents.contains("EditFlowPerf.Lifecycle.Search.contentFreshnessRootReturned")) + let fileEvents = try source("Sources/RepoPromptCore/FileSystem/FileSystemService+Watching.swift") + XCTAssertTrue(fileEvents.contains("WorkspaceRuntimePerf.Stage.Search.contentFreshnessValidationRootActorBody")) + XCTAssertTrue(fileEvents.contains("WorkspaceRuntimePerf.Lifecycle.Search.contentFreshnessRootEntered")) + XCTAssertTrue(fileEvents.contains("WorkspaceRuntimePerf.Lifecycle.Search.contentFreshnessRootReturned")) - let bootstrap = try source("Sources/RepoPrompt/Infrastructure/MCP/BootstrapSocketServer.swift") + let bootstrap = try source("Sources/RepoPrompt/Infrastructure/MCP/AppProxy/MacOSBootstrapSocketServer.swift") for hook in [ "EditFlowPerf.Lifecycle.Bootstrap.socketAccepted", "EditFlowPerf.Lifecycle.Bootstrap.handshakeIOQueued", @@ -2050,19 +2083,25 @@ ] { XCTAssertTrue(bootstrap.contains(hook), "Missing bootstrap attribution hook: \(hook)") } + XCTAssertTrue(bootstrap.contains(""" + defer { + handshakeSocket.rollback() + inFlightHandshakeSockets.removeValue(forKey: handshakeID) + """)) assertSourceOrder( in: bootstrap, hooks: [ "EditFlowPerf.Lifecycle.Bootstrap.acceptedResponseSent", - "handshakeSocket.transferOwnershipIfOpen(", + "guard handshakeSocket.transfer(", + "publish: publishTransferredTransport", "EditFlowPerf.Lifecycle.Bootstrap.ownershipTransferred", "await postAccept()" ] ) for privacySafeSource in [manager, readable, server, provider, store, fileSystem, fileEvents, bootstrap] { - XCTAssertFalse(privacySafeSource.contains("EditFlowPerf.Dimensions(path:")) - XCTAssertFalse(privacySafeSource.contains("EditFlowPerf.Dimensions(payload:")) + XCTAssertFalse(privacySafeSource.contains("Dimensions(path:")) + XCTAssertFalse(privacySafeSource.contains("Dimensions(payload:")) } XCTAssertFalse(bootstrap.contains("EditFlowPerf.Dimensions(client")) XCTAssertFalse(bootstrap.contains("EditFlowPerf.Dimensions(session")) @@ -2070,11 +2109,11 @@ } func testSearchTailTelemetryRemainsCoarseAndPrivacySafe() throws { - let searchMatch = try source("Sources/RepoPrompt/Features/Search/SearchMatch.swift") + let searchMatch = try source("Sources/RepoPromptCore/WorkspaceContext/Search/SearchMatch.swift") for hook in [ - "EditFlowPerf.Stage.Search.contentFreshnessValidation", - "EditFlowPerf.Stage.Search.contentScanTotal", - "EditFlowPerf.Stage.Search.resultConstruction", + "WorkspaceRuntimePerf.Stage.Search.contentFreshnessValidation", + "WorkspaceRuntimePerf.Stage.Search.contentScanTotal", + "WorkspaceRuntimePerf.Stage.Search.resultConstruction", "admittedFileCount:", "scannedFileCount:", "batchSize:", @@ -2086,8 +2125,8 @@ ] { XCTAssertTrue(searchMatch.contains(hook), "Missing coarse search-tail telemetry hook: \(hook)") } - XCTAssertFalse(searchMatch.contains("EditFlowPerf.Dimensions(path:")) - XCTAssertFalse(searchMatch.contains("EditFlowPerf.Dimensions(pattern:")) + XCTAssertFalse(searchMatch.contains("WorkspaceRuntimePerf.Dimensions(path:")) + XCTAssertFalse(searchMatch.contains("WorkspaceRuntimePerf.Dimensions(pattern:")) XCTAssertFalse(searchMatch.contains("workspaceName:")) assertSourceOrder( in: searchMatch, @@ -2106,7 +2145,7 @@ } func testAdmissionDebugControlsRemainIdleOnlyAggregateBoundedAndPrivacySafe() throws { - let coordinator = try source("Sources/RepoPrompt/Features/Search/StoreBackedWorkspaceSearchLane.swift") + let coordinator = try source("Sources/RepoPromptCore/WorkspaceContext/Search/StoreBackedWorkspaceSearchLane.swift") XCTAssertTrue(coordinator.contains("snapshotForTesting")) XCTAssertTrue(coordinator.contains("configureForTesting")) XCTAssertTrue(coordinator.contains("resetConfigurationForTesting")) @@ -2127,7 +2166,7 @@ XCTAssertFalse(sibling.contains("store_identifier")) XCTAssertFalse(sibling.contains("workspace_name")) - let contentLoading = try source("Sources/RepoPrompt/Infrastructure/FileSystem/FileSystemService+ContentLoading.swift") + let contentLoading = try source("Sources/RepoPromptCore/FileSystem/FileSystemService+ContentLoading.swift") XCTAssertTrue(contentLoading.contains("struct Snapshot: Equatable")) XCTAssertTrue(contentLoading.contains("activePermitCount == 0 && queuedWaiterCount == 0 && ownerLaneCount == 0")) XCTAssertTrue(contentLoading.contains("maxQueuedWaiterCount: 512")) @@ -2174,32 +2213,35 @@ XCTAssertTrue(viewModel.contains("EditFlowPerf.Lifecycle.MCPRunTool.unregister")) XCTAssertTrue(viewModel.contains("EditFlowPerf.Lifecycle.MCPRunTool.idleWaitersResumed")) - let fileSystemService = try source("Sources/RepoPrompt/Infrastructure/FileSystem/FileSystemService.swift") - let fileSystem = try source("Sources/RepoPrompt/Infrastructure/FileSystem/FileSystemService+FSEvents.swift") + let fileSystemService = try source("Sources/RepoPromptCore/FileSystem/FileSystemService.swift") + let fileSystem = try source("Sources/RepoPromptCore/FileSystem/FileSystemService+Watching.swift") assertSourceOrder( in: fileSystem, hooks: [ "watcherIngressMailbox.accept(", - "EditFlowPerf.Lifecycle.FileSystem.callbackAccepted", + "WorkspaceRuntimePerf.Lifecycle.FileSystem.callbackAccepted", "func drainAcceptedWatcherIngressMailbox()", - "EditFlowPerf.Lifecycle.FileSystem.serviceEnqueueEntered", + "WorkspaceRuntimePerf.Lifecycle.FileSystem.serviceEnqueueEntered", "watcherAcceptedWatermark: batch.watcherAcceptedHighWatermark" ] ) - let store = try source("Sources/RepoPrompt/Infrastructure/WorkspaceContext/WorkspaceFileContextStore.swift") + let store = try source("Sources/RepoPromptCore/WorkspaceContext/WorkspaceFileContextStore.swift") assertSourceOrder( in: store, hooks: [ - "EditFlowPerf.Lifecycle.WorkspaceIngress.storeSinkScheduled", + "openPublisherIngress(", + "WorkspaceRuntimePerf.Lifecycle.WorkspaceIngress.storeSinkBegan", + "handleObservedPublisherFileSystemPublication(", + "subscribeToChanges", "publisherIngressCoordinator.accept(" ] ) - XCTAssertTrue(store.contains("EditFlowPerf.Lifecycle.WorkspaceIngress.storeSinkBegan")) - XCTAssertTrue(store.contains("handleObservedFileSystemDeltas(")) - XCTAssertTrue(store.contains("EditFlowPerf.Lifecycle.WorkspaceIngress.storeCanonicalApplyCompleted")) - XCTAssertTrue(store.contains("EditFlowPerf.Lifecycle.WorkspaceIngress.rootFlushBegan")) - XCTAssertTrue(store.contains("EditFlowPerf.Lifecycle.WorkspaceIngress.rootFlushEnded")) + XCTAssertTrue(store.contains("WorkspaceRuntimePerf.Lifecycle.WorkspaceIngress.storeSinkBegan")) + XCTAssertTrue(store.contains("handleObservedPublisherFileSystemPublication(")) + XCTAssertTrue(store.contains("WorkspaceRuntimePerf.Lifecycle.WorkspaceIngress.storeCanonicalApplyCompleted")) + XCTAssertTrue(store.contains("WorkspaceRuntimePerf.Lifecycle.WorkspaceIngress.rootFlushBegan")) + XCTAssertTrue(store.contains("WorkspaceRuntimePerf.Lifecycle.WorkspaceIngress.rootFlushEnded")) XCTAssertTrue(store.contains("ingressSequence: publication.watcherAcceptedWatermark?.rawValue")) XCTAssertTrue(store.contains("barrierSequence: publication.servicePublicationSequence")) XCTAssertTrue(fileSystemService.contains("ingressSequence: watcherAcceptedWatermark?.rawValue")) @@ -2242,7 +2284,7 @@ "let exactPathIssueDetection = EditFlowPerf.begin(EditFlowPerf.Stage.ReadFile.exactPathIssueDetection)" ] ) - let search = try source("Sources/RepoPrompt/Features/Search/StoreBackedWorkspaceSearch.swift") + let search = try source("Sources/RepoPromptCore/WorkspaceContext/Search/StoreBackedWorkspaceSearch.swift") assertSourceOrder( in: search, hooks: [ @@ -2255,14 +2297,14 @@ } func testFileSystemChangePublisherSendsRemainCentralized() throws { - let service = try source("Sources/RepoPrompt/Infrastructure/FileSystem/FileSystemService.swift") - let fsevents = try source("Sources/RepoPrompt/Infrastructure/FileSystem/FileSystemService+FSEvents.swift") - let operations = try source("Sources/RepoPrompt/Infrastructure/FileSystem/FileSystemService+FileOperations.swift") + let service = try source("Sources/RepoPromptCore/FileSystem/FileSystemService.swift") + let fsevents = try source("Sources/RepoPromptCore/FileSystem/FileSystemService+Watching.swift") + let operations = try source("Sources/RepoPromptCore/FileSystem/FileSystemService+MutationCoherence.swift") XCTAssertTrue(service.contains("source: FileSystemDeltaPublicationSource")) - XCTAssertEqual(service.components(separatedBy: "changePublisher.send(publication)").count - 1, 3) - XCTAssertFalse(fsevents.contains("changePublisher.send")) - XCTAssertFalse(operations.contains("changePublisher.send")) + XCTAssertEqual(service.components(separatedBy: "publicationHub.publish(publication)").count - 1, 1) + XCTAssertFalse(fsevents.contains("publicationHub.publish")) + XCTAssertFalse(operations.contains("publicationHub.publish")) XCTAssertTrue(fsevents.contains("source: .watcherBarrierNoop")) XCTAssertTrue(fsevents.contains("watcherAcceptedWatermark: batch.watcherAcceptedHighWatermark")) XCTAssertEqual(operations.components(separatedBy: "source: .syntheticMutation").count - 1, 5) diff --git a/Tests/RepoPromptTests/MCP/MCPRenderingParityCharacterizationTests.swift b/Tests/RepoPromptTests/MCP/MCPRenderingParityCharacterizationTests.swift new file mode 100644 index 000000000..780e6c8e8 --- /dev/null +++ b/Tests/RepoPromptTests/MCP/MCPRenderingParityCharacterizationTests.swift @@ -0,0 +1,540 @@ +import Foundation +@testable import RepoPrompt +@testable import RepoPromptCore +import XCTest + +/// Characterizes the app-owned Slice 3 MCP projection behavior at checkpoint a0b968e. +@MainActor +final class MCPRenderingParityCharacterizationTests: XCTestCase { + func testCodeStructureBudgetAlgorithmsFreezeLimitsAndOversizedFirstBehavior() { + struct Case { + let costs: [Int] + let maxResults: Int + let included: [String] + let omittedByMaxResults: Int + let omittedByTokenBudget: Int + } + + let cases = [ + Case(costs: [1000, 2000], maxResults: 10, included: ["0", "1"], omittedByMaxResults: 0, omittedByTokenBudget: 0), + Case(costs: [3000, 3000], maxResults: 10, included: ["0", "1"], omittedByMaxResults: 0, omittedByTokenBudget: 0), + Case(costs: [3000, 3001], maxResults: 10, included: ["0"], omittedByMaxResults: 0, omittedByTokenBudget: 1), + Case(costs: [6001, 1], maxResults: 10, included: ["0"], omittedByMaxResults: 0, omittedByTokenBudget: 1), + Case(costs: [100, 6000, 1], maxResults: 10, included: ["0"], omittedByMaxResults: 0, omittedByTokenBudget: 2), + Case(costs: [100, 100, 100], maxResults: 2, included: ["0", "1"], omittedByMaxResults: 1, omittedByTokenBudget: 0), + Case(costs: [100, 100], maxResults: 0, included: [], omittedByMaxResults: 2, omittedByTokenBudget: 0), + Case(costs: [100, 100], maxResults: -1, included: [], omittedByMaxResults: 2, omittedByTokenBudget: 0) + ] + + for testCase in cases { + let viewModelCandidates = testCase.costs.enumerated().map { + MCPServerViewModel.CodeStructureBudgetCandidate(key: String($0.offset), estimatedTokens: $0.element) + } + let viewModelResult = MCPServerViewModel.applyCodeStructureOutputBudget( + viewModelCandidates, + maxResults: testCase.maxResults + ) + + let helperCandidates = testCase.costs.enumerated().map { + MCPWindowWorkspaceToolHelpers.CodeStructureBudgetCandidate(key: String($0.offset), estimatedTokens: $0.element) + } + let helperResult = MCPWindowWorkspaceToolHelpers.applyCodeStructureOutputBudget( + helperCandidates, + maxResults: testCase.maxResults + ) + + XCTAssertEqual(viewModelResult.includedKeys, testCase.included) + XCTAssertEqual(viewModelResult.omittedByMaxResults, testCase.omittedByMaxResults) + XCTAssertEqual(viewModelResult.omittedByTokenBudget, testCase.omittedByTokenBudget) + XCTAssertEqual(viewModelResult.omittedTotal, testCase.omittedByMaxResults + testCase.omittedByTokenBudget) + XCTAssertEqual(helperResult.includedKeys, testCase.included) + XCTAssertEqual(helperResult.omittedByMaxResults, testCase.omittedByMaxResults) + XCTAssertEqual(helperResult.omittedByTokenBudget, testCase.omittedByTokenBudget) + XCTAssertEqual(helperResult.omittedTotal, testCase.omittedByMaxResults + testCase.omittedByTokenBudget) + } + } + + func testCodeStructureDTOFreezesOrderingFullPathDedupAndOmissionCounts() async throws { + let root = try makeTemporaryRoot(name: "CodeStructureParity") + defer { try? FileManager.default.removeItem(at: root) } + + let alpha = root.appendingPathComponent("Alpha/A.swift") + let zeta = root.appendingPathComponent("Zeta/B.swift") + let missingBeta = root.appendingPathComponent("Beta/Missing.swift") + let missingOmega = root.appendingPathComponent("Omega/Missing.swift") + try write("func alpha() {}", to: alpha) + try write("func zeta() {}", to: zeta) + try write("struct MissingBeta {}", to: missingBeta) + try write("struct MissingOmega {}", to: missingOmega) + + let window = makeWindowWithoutAutoStart() + let store = window.workspaceFileContextStore + let rootRecord = try await store.loadRoot(path: root.path) + await store.applyObservedCodemapResults([ + WorkspaceObservedCodemapResult(fullPath: alpha.path, modificationDate: Date(timeIntervalSince1970: 1), fileAPI: makeFileAPI(path: alpha.path, symbolName: "symbolA")), + WorkspaceObservedCodemapResult(fullPath: zeta.path, modificationDate: Date(timeIntervalSince1970: 2), fileAPI: makeFileAPI(path: zeta.path, symbolName: "symbolB")) + ]) + + let records = await store.files(inRoot: rootRecord.id) + let alphaRecord = try XCTUnwrap(records.first { $0.standardizedRelativePath == "Alpha/A.swift" }) + let zetaRecord = try XCTUnwrap(records.first { $0.standardizedRelativePath == "Zeta/B.swift" }) + let missingBetaRecord = try XCTUnwrap(records.first { $0.standardizedRelativePath == "Beta/Missing.swift" }) + let missingOmegaRecord = try XCTUnwrap(records.first { $0.standardizedRelativePath == "Omega/Missing.swift" }) + let samePathDifferentID = try WorkspaceFileRecord( + id: XCTUnwrap(UUID(uuidString: "44444444-4444-4444-4444-444444444444")), + rootID: alphaRecord.rootID, + name: alphaRecord.name, + relativePath: "Alpha/../Alpha/A.swift", + fullPath: root.appendingPathComponent("Alpha/../Alpha/A.swift").path, + parentFolderID: alphaRecord.parentFolderID, + modificationDate: alphaRecord.modificationDate + ) + let unsortedWithDuplicate = [zetaRecord, missingOmegaRecord, alphaRecord, samePathDifferentID, missingBetaRecord] + + let full = try await window.mcpServer.buildCodeStructureDTO( + fromRecords: unsortedWithDuplicate, + maxResults: 10, + includeUnmappedPaths: true + ) + + XCTAssertEqual(full.fileCount, 2) + XCTAssertLessThan( + try XCTUnwrap(full.content.range(of: "File: Alpha/A.swift")?.lowerBound), + try XCTUnwrap(full.content.range(of: "File: Zeta/B.swift")?.lowerBound) + ) + XCTAssertEqual(occurrences(of: "symbolA", in: full.content), 1) + XCTAssertEqual(occurrences(of: "symbolB", in: full.content), 1) + XCTAssertEqual(full.unmappedPaths, ["Beta/Missing.swift", "Omega/Missing.swift"]) + XCTAssertNil(full.omittedCount) + XCTAssertNil(full.omittedTotal) + XCTAssertNil(full.tokenBudgetOmittedCount) + XCTAssertNil(full.tokenBudgetHit) + + let limited = try await window.mcpServer.buildCodeStructureDTO( + fromRecords: unsortedWithDuplicate, + maxResults: 1, + includeUnmappedPaths: true + ) + + XCTAssertEqual(limited.fileCount, 1) + XCTAssertTrue(limited.content.contains("symbolA")) + XCTAssertFalse(limited.content.contains("symbolB")) + XCTAssertEqual(limited.unmappedPaths, ["Beta/Missing.swift", "Omega/Missing.swift"]) + XCTAssertEqual(limited.omittedCount, 1) + XCTAssertEqual(limited.omittedTotal, 1) + XCTAssertNil(limited.tokenBudgetOmittedCount) + XCTAssertNil(limited.tokenBudgetHit) + } + + func testSelectionAndAlternateCopyPresetTokenProjectionsRemainFrozen() async throws { + let root = try makeTemporaryRoot(name: "SelectionParity") + defer { try? FileManager.default.removeItem(at: root) } + + let fullURL = root.appendingPathComponent("Sources/Full.swift") + let slicedURL = root.appendingPathComponent("Sources/Sliced.swift") + let codemapURL = root.appendingPathComponent("Sources/Auto.swift") + try write("full content", to: fullURL) + try write("one\ntwo\nthree", to: slicedURL) + try write("func autoSource() {}", to: codemapURL) + + let window = makeWindowWithoutAutoStart() + let store = window.workspaceFileContextStore + _ = try await store.loadRoot(path: root.path) + await store.applyObservedCodemapResults([ + WorkspaceObservedCodemapResult(fullPath: fullURL.path, modificationDate: Date(timeIntervalSince1970: 1), fileAPI: makeFileAPI(path: fullURL.path, symbolName: "fullAPI")), + WorkspaceObservedCodemapResult(fullPath: slicedURL.path, modificationDate: Date(timeIntervalSince1970: 2), fileAPI: makeFileAPI(path: slicedURL.path, symbolName: "sliceAPI")), + WorkspaceObservedCodemapResult(fullPath: codemapURL.path, modificationDate: Date(timeIntervalSince1970: 3), fileAPI: makeFileAPI(path: codemapURL.path, symbolName: "autoAPI")) + ]) + + let range = LineRange(start: 2, end: 2, description: "middle") + let source = MCPServerViewModel.StoredSelectionSource( + stored: StoredSelection( + selectedPaths: [fullURL.path, slicedURL.path], + autoCodemapPaths: [codemapURL.path], + slices: [slicedURL.path: [range]], + codemapAutoEnabled: true + ), + codeMapUsage: .auto + ) + let collections = await MCPServerViewModel.SelectionReplyAssembler.collect(from: source, owner: window.mcpServer) + XCTAssertEqual(collections.selected.map(\.file.standardizedRelativePath), ["Sources/Full.swift", "Sources/Sliced.swift"]) + XCTAssertEqual(collections.selected.map(\.ranges), [nil, [range]]) + XCTAssertEqual(collections.codemap.map(\.file.standardizedRelativePath), ["Sources/Auto.swift"]) + XCTAssertEqual(collections.codemap.map(\.origin), [.auto]) + XCTAssertTrue(collections.codemapAutoEnabled) + XCTAssertEqual(collections.codeMapUsage, .auto) + XCTAssertEqual(collections.invalid, []) + + let full = try XCTUnwrap(collections.selected.first { $0.file.standardizedRelativePath == "Sources/Full.swift" }?.entry) + let sliced = try XCTUnwrap(collections.selected.first { $0.file.standardizedRelativePath == "Sources/Sliced.swift" }?.entry) + let codemap = try XCTUnwrap(collections.codemap.first?.entry) + let entryResults: [UUID: PromptEntriesEvaluation.EntryResult] = [ + full.file.id: .init(fileID: full.file.id, renderMode: .full, displayTokens: 100, fullTokens: 100, codemapTokens: 10), + sliced.file.id: .init(fileID: sliced.file.id, renderMode: .slice, displayTokens: 20, fullTokens: 200, codemapTokens: 11), + codemap.file.id: .init(fileID: codemap.file.id, renderMode: .codemap, displayTokens: 12, fullTokens: 300, codemapTokens: 12) + ] + let formatter = MCPServerViewModel.PathFormatter(format: .full, owner: window.mcpServer) + let tokenServices = MCPServerViewModel.TokenServices(owner: window.mcpServer) + + let selected = await makeSelectionReply( + collections: collections, + formatter: formatter, + tokenServices: tokenServices, + copyUsage: .selected, + includeFiles: true, + entryResults: entryResults + ) + XCTAssertEqual(selected.files.map(\.path), [full.file.fullPath, sliced.file.fullPath, codemap.file.fullPath]) + XCTAssertEqual(selected.files.map(\.renderMode), ["full", "slice", "codemap"]) + XCTAssertEqual(selected.files.map(\.tokens), [100, 20, 12]) + XCTAssertEqual(selected.files.map(\.isAuto), [false, false, true]) + XCTAssertEqual(selected.totalTokens, 132) + XCTAssertEqual(selected.summary, .init(fullCount: 1, sliceCount: 1, codemapCount: 1, fullTokens: 100, sliceTokens: 20, codemapTokens: 12)) + XCTAssertEqual(selected.fileSlices?.map(\.path), [sliced.file.fullPath]) + XCTAssertEqual(selected.files.map(\.copyPreset?.renderMode), ["codemap", "codemap", "hidden"]) + XCTAssertEqual(selected.files.map(\.copyPreset?.tokens), [10, 11, 0]) + XCTAssertEqual(selected.files.map(\.copyPreset?.codemapOrigin), ["selected_mode", "selected_mode", nil]) + XCTAssertEqual(selected.userCopyTokens, 21) + XCTAssertEqual(selected.userCopyContentTokens, 0) + XCTAssertEqual(selected.userCopyCodemapTokens, 21) + XCTAssertEqual(selected.copyPresetProjection, .init(codeMapUsage: "selected", includesFiles: true, totalTokens: 21)) + + let complete = await makeSelectionReply( + collections: collections, + formatter: formatter, + tokenServices: tokenServices, + copyUsage: .complete, + includeFiles: true, + entryResults: entryResults + ) + XCTAssertEqual(complete.files.map(\.copyPreset?.tokens), [10, 11, nil]) + XCTAssertEqual(complete.userCopyTokens, 33) + XCTAssertEqual(complete.userCopyContentTokens, 0) + XCTAssertEqual(complete.userCopyCodemapTokens, 33) + XCTAssertEqual(complete.copyPresetProjection, .init(codeMapUsage: "complete", includesFiles: true, totalTokens: 33)) + + let none = await makeSelectionReply( + collections: collections, + formatter: formatter, + tokenServices: tokenServices, + copyUsage: .none, + includeFiles: true, + entryResults: entryResults + ) + XCTAssertEqual(none.files.map(\.copyPreset?.renderMode), [nil, nil, "hidden"]) + XCTAssertEqual(none.userCopyTokens, 120) + XCTAssertEqual(none.userCopyContentTokens, 120) + XCTAssertEqual(none.userCopyCodemapTokens, 0) + XCTAssertEqual(none.copyPresetProjection, .init(codeMapUsage: "none", includesFiles: true, totalTokens: 120)) + + let selectedWithoutFiles = await makeSelectionReply( + collections: collections, + formatter: formatter, + tokenServices: tokenServices, + copyUsage: .selected, + includeFiles: false, + entryResults: entryResults + ) + XCTAssertEqual(selectedWithoutFiles.copyPresetProjection, .init(codeMapUsage: "selected", includesFiles: false, totalTokens: 12)) + + let noneWithoutFiles = await makeSelectionReply( + collections: collections, + formatter: formatter, + tokenServices: tokenServices, + copyUsage: .none, + includeFiles: false, + entryResults: entryResults + ) + XCTAssertEqual(noneWithoutFiles.copyPresetProjection, .init(codeMapUsage: "none", includesFiles: false, totalTokens: 0)) + + XCTAssertNil(MCPServerViewModel.SelectionReplyAssembler.computeCopyPresetProjection( + autoRenderMode: "full", + autoTokens: 100, + hasCodemap: true, + copyUsage: .auto, + codemapTokens: 10 + )) + XCTAssertNil(MCPServerViewModel.SelectionReplyAssembler.computeCopyPresetProjection( + autoRenderMode: "slice", + autoTokens: 20, + hasCodemap: false, + copyUsage: .selected, + codemapTokens: 0 + )) + XCTAssertEqual( + MCPServerViewModel.SelectionReplyAssembler.computeCopyPresetProjection( + autoRenderMode: "codemap", + autoTokens: 12, + hasCodemap: true, + copyUsage: .none, + codemapTokens: 12 + ), + .init(tokens: 0, renderMode: "hidden", ranges: nil, codemapOrigin: nil) + ) + } + + func testWorkspaceContextIncludeSetsFreezeEmptyComponentsAndAppEnvelope() async throws { + let previousCodeMapsDisabled = GlobalSettingsStore.shared.globalCodeMapsDisabled() + GlobalSettingsStore.shared.setCodeMapsGloballyDisabled(false, commit: false) + defer { GlobalSettingsStore.shared.setCodeMapsGloballyDisabled(previousCodeMapsDisabled, commit: false) } + + let window = makeWindowWithoutAutoStart() + let override = try CopyPreset( + id: XCTUnwrap(UUID(uuidString: "AAAAAAAA-BBBB-CCCC-DDDD-EEEEEEEEEEEE")), + name: "Parity Override", + includeFiles: true, + includeUserPrompt: false, + includeMetaPrompts: false, + includeFileTree: false, + fileTreeMode: FileTreeOption.none, + codeMapUsage: CodeMapUsage.none, + gitInclusion: GitInclusion.none + ) + let context = try MCPServerViewModel.TabContextSnapshot( + tabID: XCTUnwrap(UUID(uuidString: "AAAAAAAA-AAAA-AAAA-AAAA-AAAAAAAAAAAA")), + windowID: window.windowID, + workspaceID: nil, + promptText: "Frozen prompt", + selection: StoredSelection(), + selectedMetaPromptIDs: [], + tabName: "Parity", + runID: nil, + explicitlyBound: true + ) + + let empty = try await window.mcpServer.buildTabWorkspaceContext( + context: context, + include: [], + display: .relative, + copyPresetOverride: override + ) + XCTAssertEqual(empty.prompt, "") + XCTAssertNil(empty.selection) + XCTAssertNil(empty.fileBlocks) + XCTAssertNil(empty.codeStructure) + XCTAssertNil(empty.fileTree) + XCTAssertNil(empty.tokenStats) + XCTAssertNil(empty.userTokenStats) + XCTAssertNil(empty.tokenStatsNote) + XCTAssertNil(empty.copyPresets) + XCTAssertNil(empty.worktreeScope) + XCTAssertEqual(empty.copyPreset?.effective.id, override.id.uuidString) + XCTAssertEqual(empty.copyPreset?.effective.name, override.name) + XCTAssertEqual(empty.copyPreset?.isOverridden, empty.copyPreset?.active.id != override.id.uuidString) + + let promptOnly = try await window.mcpServer.buildTabWorkspaceContext( + context: context, + include: ["prompt"], + display: .relative, + copyPresetOverride: override + ) + XCTAssertEqual(promptOnly.prompt, "Frozen prompt") + XCTAssertNil(promptOnly.selection) + XCTAssertNil(promptOnly.fileBlocks) + XCTAssertNil(promptOnly.codeStructure) + + let filesOnly = try await window.mcpServer.buildTabWorkspaceContext( + context: context, + include: ["files"], + display: .relative, + copyPresetOverride: override + ) + XCTAssertEqual(filesOnly.prompt, "") + XCTAssertNil(filesOnly.selection) + XCTAssertEqual(filesOnly.fileBlocks, []) + XCTAssertNil(filesOnly.codeStructure) + + let selectionAndCode = try await window.mcpServer.buildTabWorkspaceContext( + context: context, + include: ["selection", "code"], + display: .relative, + copyPresetOverride: override + ) + XCTAssertEqual(selectionAndCode.prompt, "") + XCTAssertEqual(selectionAndCode.selection?.files, []) + XCTAssertEqual(selectionAndCode.selection?.totalTokens, 0) + XCTAssertEqual(selectionAndCode.selection?.summary, .init(fullCount: 0, sliceCount: 0, codemapCount: 0, fullTokens: 0, sliceTokens: 0, codemapTokens: 0)) + XCTAssertNil(selectionAndCode.fileBlocks) + XCTAssertNil(selectionAndCode.codeStructure) + XCTAssertEqual(selectionAndCode.copyPreset?.effective.id, override.id.uuidString) + + let tokensOnly = try await window.mcpServer.buildTabWorkspaceContext( + context: context, + include: ["tokens"], + display: .relative, + copyPresetOverride: override + ) + XCTAssertEqual(tokensOnly.prompt, "") + XCTAssertNil(tokensOnly.selection) + XCTAssertNil(tokensOnly.fileBlocks) + XCTAssertNil(tokensOnly.codeStructure) + XCTAssertEqual(tokensOnly.tokenStats, .init(total: 0, files: 0)) + XCTAssertNil(tokensOnly.userTokenStats) + XCTAssertNil(tokensOnly.tokenStatsNote) + XCTAssertEqual(tokensOnly.copyPreset?.effective.id, override.id.uuidString) + } + + func testMCPDTOJSONFreezesNilZeroEmptyAndDistinctPromptEnvelopes() throws { + let zeroStats = MCPServerViewModel.makeTokenStats( + filesTokens: 0, + breakdown: .init(prompt: 0, duplicatePrompt: 0, instructions: 0, fileTree: 0, gitDiff: 0, metadata: 0) + ) + XCTAssertEqual(try json(zeroStats), #"{"files":0,"total":0}"#) + + let emptyCode = ToolResultDTOs.SelectedCodeStructureDTO(fileCount: 0, content: "", unmappedPaths: []) + XCTAssertEqual(try json(emptyCode), #"{"content":"","file_count":0,"unmapped_paths":[]}"#) + + let emptySelection = ToolResultDTOs.SelectedFilesReply( + files: [], + totalTokens: 0, + fileSlices: nil, + summary: .init(fullCount: 0, sliceCount: 0, codemapCount: 0, fullTokens: 0, sliceTokens: 0, codemapTokens: 0) + ) + XCTAssertEqual( + try json(emptySelection), + #"{"files":[],"summary":{"codemap_count":0,"codemap_tokens":0,"full_count":0,"full_tokens":0,"slice_count":0,"slice_tokens":0},"total_tokens":0}"# + ) + + let reply = ToolResultDTOs.SelectionReply( + files: nil, + totalTokens: nil, + status: "", + invalidPaths: [], + blocks: [], + codemapAutoEnabled: false + ) + XCTAssertEqual(try json(reply), #"{"blocks":[],"codemap_auto_enabled":false,"invalid_paths":[],"status":""}"#) + + let context = ToolResultDTOs.PromptContextDTO( + prompt: "", + selection: nil, + fileBlocks: [], + codeStructure: nil, + fileTree: nil, + tokenStats: nil, + userTokenStats: nil, + tokenStatsNote: nil, + copyPreset: nil, + copyPresets: [] + ) + XCTAssertEqual(try json(context), #"{"copy_presets":[],"file_blocks":[],"prompt":""}"#) + + let descriptor = ToolResultDTOs.CopyPresetDescriptorDTO(id: "", name: "", kind: nil, isBuiltIn: false) + let prompt = ToolResultDTOs.PromptReply( + prompt: "", + lines: 0, + copyPresetName: nil, + chatPresetName: nil, + chatMode: nil, + includesFiles: nil, + includesFileTree: nil, + includesCodemaps: nil, + includesGitDiff: nil, + includesUserPrompt: nil, + includesMetaPrompts: nil, + includesStoredPrompts: nil, + fileTreeMode: nil, + codeMapUsage: nil, + gitInclusion: nil, + effectiveTokens: nil, + fullFilesTokens: nil, + codeMapFileCount: nil, + codeMapTokens: nil, + codeMapFiles: nil + ) + let export = ToolResultDTOs.PromptExportReply(path: "", tokens: 0, bytes: 0, files: [], copyPreset: nil) + + XCTAssertEqual( + try json(ToolResultDTOs.PromptToolEnvelope.forPrompt(prompt, op: "get")), + #"{"op":"get","prompt":{"lines":0,"prompt":""}}"# + ) + XCTAssertEqual( + try json(ToolResultDTOs.PromptToolEnvelope.forExport(export)), + #"{"export":{"bytes":0,"files":[],"path":"","tokens":0},"op":"export"}"# + ) + XCTAssertEqual( + try json(ToolResultDTOs.PromptToolEnvelope.forPresetsList([])), + #"{"op":"list_presets","presets_list":{"presets":[]}}"# + ) + XCTAssertEqual( + try json(ToolResultDTOs.PromptToolEnvelope.forSelectPreset(descriptor)), + #"{"op":"select_preset","selected_preset":{"id":"","is_built_in":false,"name":""}}"# + ) + } + + private func makeSelectionReply( + collections: MCPServerViewModel.SelectionReplyAssembler.SelectionCollections, + formatter: MCPServerViewModel.PathFormatter, + tokenServices: MCPServerViewModel.TokenServices, + copyUsage: CodeMapUsage, + includeFiles: Bool, + entryResults: [UUID: PromptEntriesEvaluation.EntryResult] + ) async -> ToolResultDTOs.SelectedFilesReply { + await MCPServerViewModel.SelectionReplyAssembler.buildSelectedFilesReply( + collections: collections, + formatter: formatter, + tokens: tokenServices, + userPresetState: .init( + copyCodeMapUsage: copyUsage.rawValue, + chatCodeMapUsage: CodeMapUsage.auto.rawValue, + copyTokens: nil, + chatTokens: nil, + normalizedCodeMapUsage: CodeMapUsage.auto.rawValue + ), + copyUsage: copyUsage, + projection: .init(includeFiles: includeFiles, codeMapUsage: copyUsage), + entryResultsByFileID: entryResults + ) + } + + private func makeFileAPI(path: String, symbolName: String) -> FileAPI { + FileAPI( + filePath: path, + imports: [], + classes: [], + functions: [ + FunctionInfo( + name: symbolName, + parameters: [], + returnType: nil, + definitionLine: "func \(symbolName)()", + lineNumber: 1 + ) + ], + enums: [], + globalVars: [], + macros: [], + referencedTypes: [] + ) + } + + private func makeWindowWithoutAutoStart() -> WindowState { + let previousAutoStart = GlobalSettingsStore.shared.mcpAutoStart() + GlobalSettingsStore.shared.setMCPAutoStart(false, commit: false) + defer { GlobalSettingsStore.shared.setMCPAutoStart(previousAutoStart, commit: false) } + return WindowState() + } + + private func makeTemporaryRoot(name: String) throws -> URL { + let url = FileManager.default.temporaryDirectory + .appendingPathComponent("RepoPromptTests", isDirectory: true) + .appendingPathComponent("\(name)-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true) + return url + } + + private func write(_ content: String, to url: URL) throws { + try FileManager.default.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true) + try content.write(to: url, atomically: true, encoding: .utf8) + } + + private func json(_ value: some Encodable) throws -> String { + let encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys, .withoutEscapingSlashes] + return try XCTUnwrap(String(data: encoder.encode(value), encoding: .utf8)) + } + + private func occurrences(of needle: String, in haystack: String) -> Int { + haystack.components(separatedBy: needle).count - 1 + } +} diff --git a/Tests/RepoPromptTests/MCP/MCPRuntimeRegistryTests.swift b/Tests/RepoPromptTests/MCP/MCPRuntimeRegistryTests.swift new file mode 100644 index 000000000..75f0c156e --- /dev/null +++ b/Tests/RepoPromptTests/MCP/MCPRuntimeRegistryTests.swift @@ -0,0 +1,300 @@ +import Foundation +import JSONSchema +@testable import RepoPrompt +@testable import RepoPromptCore +import XCTest + +@MainActor +final class MCPRuntimeSessionRegistryTests: XCTestCase { + func testPendingEnableInsertionOrderDrainingAndRetirement() { + let registry = MCPRuntimeSessionRegistry() + let graph = EmbeddedWorkspaceRepositoryFactory.make() + let repository = graph.repository + let policy = UnrestrictedWorkspaceAccessPolicy() + let first = RepoPromptCoreSession( + routingSessionID: MCPRoutingSessionID(rawValue: 1), + workspaceRepository: repository, + workspacePersistenceWriter: graph.writer, + workspaceAccessPolicy: policy, + runtime: RepoPromptEmbeddedWorkspaceRuntimeFactory().makeRuntime() + ) + let second = RepoPromptCoreSession( + routingSessionID: MCPRoutingSessionID(rawValue: 2), + workspaceRepository: repository, + workspacePersistenceWriter: graph.writer, + workspaceAccessPolicy: policy, + runtime: RepoPromptEmbeddedWorkspaceRuntimeFactory().makeRuntime() + ) + + registry.setMCPEnabled(windowID: first.routingSessionID.rawValue, enabled: true) + XCTAssertEqual(registry.register(session: first), .accepted) + XCTAssertEqual(registry.register(session: second), .accepted) + registry.setMCPEnabled(windowID: second.routingSessionID.rawValue, enabled: true) + + var snapshot = registry.routingSnapshot() + XCTAssertEqual(snapshot.orderedActiveWindowIDs, [1, 2]) + XCTAssertEqual(snapshot.firstMCPEnabledWindowID, 1) + XCTAssertTrue(snapshot.isMultiWindowModeEffectivelyActive) + + XCTAssertTrue(registry.beginDraining(windowID: 1, expectedSessionID: first.sessionID)) + snapshot = registry.routingSnapshot() + XCTAssertEqual(snapshot.orderedActiveWindowIDs, [2]) + XCTAssertEqual(snapshot.firstMCPEnabledWindowID, 2) + XCTAssertFalse(registry.isInvocationAllowed(windowID: 1)) + + XCTAssertTrue(registry.remove(windowID: 1, expectedSessionID: first.sessionID)) + registry.setMCPEnabled(windowID: 1, enabled: true) + XCTAssertEqual(registry.register(session: first), .retiredRoutingID) + XCTAssertFalse(registry.hasActiveWindow(id: 1)) + #if DEBUG + XCTAssertTrue(registry.debugIsRetired(windowID: 1)) + #endif + } + + func testDuplicateRoutingIDCannotReplaceOrDrainOwningSession() { + let registry = MCPRuntimeSessionRegistry() + let graph = EmbeddedWorkspaceRepositoryFactory.make() + let repository = graph.repository + let policy = UnrestrictedWorkspaceAccessPolicy() + let owner = RepoPromptCoreSession( + routingSessionID: MCPRoutingSessionID(rawValue: 7), + workspaceRepository: repository, + workspacePersistenceWriter: graph.writer, + workspaceAccessPolicy: policy, + runtime: RepoPromptEmbeddedWorkspaceRuntimeFactory().makeRuntime() + ) + let duplicate = RepoPromptCoreSession( + routingSessionID: MCPRoutingSessionID(rawValue: 7), + workspaceRepository: repository, + workspacePersistenceWriter: graph.writer, + workspaceAccessPolicy: policy, + runtime: RepoPromptEmbeddedWorkspaceRuntimeFactory().makeRuntime() + ) + + XCTAssertEqual(registry.register(session: owner), .accepted) + registry.setMCPEnabled(windowID: 7, enabled: true) + XCTAssertEqual(registry.register(session: duplicate), .routingIDInUse) + XCTAssertFalse(registry.beginDraining(windowID: 7, expectedSessionID: duplicate.sessionID)) + XCTAssertFalse(registry.remove(windowID: 7, expectedSessionID: duplicate.sessionID)) + XCTAssertFalse(registry.setMCPEnabled( + windowID: 7, + expectedSessionID: duplicate.sessionID, + enabled: false + )) + XCTAssertTrue(registry.isInvocationAllowed(windowID: 7)) + XCTAssertEqual(registry.session(withRoutingID: 7)?.sessionID, owner.sessionID) + } +} + +@MainActor +final class MCPServiceRegistryTests: XCTestCase { + func testRegistriesAreInstanceOwnedAndIndexCanonicalNames() async { + let firstRegistry = MCPServiceRegistry() + let secondRegistry = MCPServiceRegistry() + let service = StaticToolService(tools: [Self.makeTool(name: "discover_prompt")]) + + firstRegistry.register(service) + let firstSnapshot = await firstRegistry.awaitCurrentSnapshot() + let secondSnapshot = secondRegistry.routeSnapshot() + + XCTAssertEqual(firstSnapshot.routes(forCanonicalName: "prompt").map(\.tool.name), ["discover_prompt"]) + XCTAssertTrue(secondSnapshot.orderedRoutes.isEmpty) + } + + func testUnregisterSynchronouslyFiltersCommittedRoutes() async { + let registry = MCPServiceRegistry() + let service = StaticToolService(tools: [Self.makeTool(name: "read_file")]) + + registry.register(service) + _ = await registry.awaitCurrentSnapshot() + XCTAssertEqual(registry.routeSnapshot().routes(forCanonicalName: "read_file").count, 1) + + registry.unregister(service) + XCTAssertTrue(registry.routeSnapshot().routes(forCanonicalName: "read_file").isEmpty) + } + + func testPublicationBoundaryWaitsForCapturedServiceButNotLaterRegistration() async { + let registry = MCPServiceRegistry() + let first = StaticToolService(tools: [Self.makeTool(name: "first_tool")]) + registry.register(first) + _ = await registry.awaitCurrentSnapshot() + + let capturedGate = CatalogBarrier() + let captured = BarrierToolService( + tools: [Self.makeTool(name: "captured_tool")], + gate: capturedGate + ) + registry.register(captured) + let boundary = registry.capturePublicationBoundary() + let completed = CompletionSignal() + let snapshotTask = Task { + let snapshot = await registry.snapshot(for: boundary) + await completed.mark() + return snapshot + } + await capturedGate.waitUntilStartedCount(2) + let completedBeforeRelease = await completed.isMarked() + XCTAssertFalse(completedBeforeRelease) + + let laterGate = CatalogBarrier() + let later = BarrierToolService( + tools: [Self.makeTool(name: "later_tool")], + gate: laterGate + ) + registry.register(later) + await laterGate.waitUntilStartedCount(1) + + await capturedGate.release() + let snapshot = await snapshotTask.value + let completedAfterRelease = await completed.isMarked() + XCTAssertTrue(completedAfterRelease) + XCTAssertEqual(snapshot.routes(forCanonicalName: "first_tool").count, 1) + XCTAssertEqual(snapshot.routes(forCanonicalName: "captured_tool").count, 1) + XCTAssertTrue(snapshot.routes(forCanonicalName: "later_tool").isEmpty) + + await laterGate.release() + _ = await registry.awaitCurrentSnapshot() + } + + func testCatalogInvalidationStalesOnlyRoutesFromThatService() async throws { + let registry = MCPServiceRegistry() + let first = MutableToolService(tools: [Self.makeTool(name: "first_tool")]) + let second = MutableToolService(tools: [Self.makeTool(name: "second_tool")]) + registry.register(first) + registry.register(second) + let snapshot = await registry.awaitCurrentSnapshot() + let firstRoute = try XCTUnwrap(snapshot.routes(forCanonicalName: "first_tool").first) + let secondRoute = try XCTUnwrap(snapshot.routes(forCanonicalName: "second_tool").first) + + await second.replaceTools([Self.makeTool(name: "second_tool")]) + registry.invalidateCatalog(for: second) + + XCTAssertTrue(registry.isCurrent(firstRoute)) + XCTAssertFalse(registry.isCurrent(secondRoute)) + _ = await registry.awaitCurrentSnapshot() + } + + func testUnregisterAndReregisterSameServiceCannotReviveOldRoute() async throws { + let registry = MCPServiceRegistry() + let service = MutableToolService(tools: [Self.makeTool(name: "revision_one")]) + registry.register(service) + let firstSnapshot = await registry.awaitCurrentSnapshot() + let oldRoute = try XCTUnwrap(firstSnapshot.routes(forCanonicalName: "revision_one").first) + + registry.unregister(service) + await service.replaceTools([Self.makeTool(name: "revision_two")]) + registry.register(service) + let secondSnapshot = await registry.awaitCurrentSnapshot() + + XCTAssertFalse(registry.isCurrent(oldRoute)) + XCTAssertEqual(secondSnapshot.routes(forCanonicalName: "revision_two").count, 1) + } + + func testRegistrySourceKeepsGenerationGate() throws { + let source = try String( + contentsOf: RepoRoot.url().appendingPathComponent("Sources/RepoPrompt/Infrastructure/MCP/ServiceRegistry.swift"), + encoding: .utf8 + ) + XCTAssertTrue(source.contains("guard boundary.generation == requestedGeneration")) + XCTAssertTrue(source.contains("isCurrent(serviceIdentity: route.serviceIdentity, catalogRevision: route.catalogRevision)")) + XCTAssertTrue(source.contains("nextCatalogRevision &+= 1")) + XCTAssertTrue(source.contains("routesByCanonicalName[canonicalName, default: []].append(route)")) + XCTAssertTrue(source.contains("committedSnapshot.orderedRoutes.filter { $0.serviceIdentity != serviceIdentity }")) + } + + private static func makeTool(name: String) -> Tool { + Tool( + name: name, + description: "test", + inputSchema: .object(properties: [:]) + ) { _ in + ["ok": true] + } + } +} + +private final class StaticToolService: Service { + let storedTools: [Tool] + + init(tools: [Tool]) { + storedTools = tools + } + + var tools: [Tool] { + get async { storedTools } + } +} + +private actor MutableToolService: Service { + private var storedTools: [Tool] + + init(tools: [Tool]) { + storedTools = tools + } + + var tools: [Tool] { + get async { storedTools } + } + + func replaceTools(_ tools: [Tool]) { + storedTools = tools + } +} + +private final class BarrierToolService: Service { + let storedTools: [Tool] + let gate: CatalogBarrier + + init(tools: [Tool], gate: CatalogBarrier) { + storedTools = tools + self.gate = gate + } + + var tools: [Tool] { + get async { + await gate.wait() + return storedTools + } + } +} + +private actor CatalogBarrier { + private var startedCount = 0 + private var released = false + private var startedWaiters: [(count: Int, continuation: CheckedContinuation)] = [] + private var releaseWaiters: [CheckedContinuation] = [] + + func wait() async { + startedCount += 1 + let ready = startedWaiters.filter { startedCount >= $0.count } + startedWaiters.removeAll { startedCount >= $0.count } + ready.forEach { $0.continuation.resume() } + guard !released else { return } + await withCheckedContinuation { releaseWaiters.append($0) } + } + + func waitUntilStartedCount(_ count: Int) async { + guard startedCount < count else { return } + await withCheckedContinuation { continuation in + startedWaiters.append((count, continuation)) + } + } + + func release() { + released = true + releaseWaiters.forEach { $0.resume() } + releaseWaiters.removeAll() + } +} + +private actor CompletionSignal { + private var marked = false + + func mark() { + marked = true + } + + func isMarked() -> Bool { + marked + } +} diff --git a/Tests/RepoPromptTests/MCP/MCPServiceParticipationTests.swift b/Tests/RepoPromptTests/MCP/MCPServiceParticipationTests.swift new file mode 100644 index 000000000..1de67f5c9 --- /dev/null +++ b/Tests/RepoPromptTests/MCP/MCPServiceParticipationTests.swift @@ -0,0 +1,261 @@ +import Foundation +@testable import RepoPrompt +import XCTest + +@MainActor +final class MCPServiceParticipationTests: XCTestCase { + func testOlderJoinEligibilityCompletionCannotOverrideNewerLeave() async throws { + let eligibility = EligibilityBarrier() + let listener = ListenerProbe() + let service = makeService(listener: listener) { windowID in + await eligibility.request(windowID: windowID) + } + + let join = Task { try await service.join(windowID: 1) } + await eligibility.waitUntilRequestCount(1) + let leave = Task { await service.leave(windowID: 1) } + await eligibility.waitUntilRequestCount(2) + + await eligibility.resumeRequest(at: 1, eligible: false) + await leave.value + await eligibility.resumeRequest(at: 0, eligible: true) + try await join.value + + let state = await service.currentState() + let listenerState = await listener.snapshot() + XCTAssertFalse(state.isRunning) + XCTAssertEqual(listenerState.startCount, 0) + XCTAssertEqual(listenerState.stopCount, 0) + } + + func testOlderLeaveEligibilityCompletionCannotStopNewerJoin() async throws { + let eligibility = EligibilityBarrier() + let listener = ListenerProbe() + let service = makeService(listener: listener) { windowID in + await eligibility.request(windowID: windowID) + } + + let leave = Task { await service.leave(windowID: 2) } + await eligibility.waitUntilRequestCount(1) + let join = Task { try await service.join(windowID: 2) } + await eligibility.waitUntilRequestCount(2) + + await eligibility.resumeRequest(at: 1, eligible: true) + try await join.value + await eligibility.resumeRequest(at: 0, eligible: false) + await leave.value + + let state = await service.currentState() + let listenerState = await listener.snapshot() + XCTAssertTrue(state.isRunning) + XCTAssertEqual(listenerState.startCount, 1) + XCTAssertEqual(listenerState.stopCount, 0) + + await service.fullShutdown() + } + + func testStartCompletionReconcilesToNewerLeaveDesiredState() async throws { + let eligibility = EligibilityState() + let listener = ListenerProbe(suspendStarts: true) + let service = makeService(listener: listener) { windowID in + await eligibility.isEligible(windowID: windowID) + } + + await eligibility.setEligible(true, windowID: 3) + let join = Task { try await service.join(windowID: 3) } + await listener.waitUntilStartCount(1) + + await eligibility.setEligible(false, windowID: 3) + let leave = Task { await service.leave(windowID: 3) } + await listener.releaseStarts() + + try await join.value + await leave.value + + let state = await service.currentState() + let listenerState = await listener.snapshot() + XCTAssertFalse(state.isRunning) + XCTAssertEqual(listenerState.startCount, 1) + XCTAssertEqual(listenerState.stopCount, 1) + } + + func testForceStopAllowsLaterEligibleJoinToRestart() async throws { + let eligibility = EligibilityState() + let listener = ListenerProbe() + let service = makeService(listener: listener) { windowID in + await eligibility.isEligible(windowID: windowID) + } + + await eligibility.setEligible(true, windowID: 4) + try await service.join(windowID: 4) + await service.fullShutdown() + try await service.join(windowID: 4) + + let state = await service.currentState() + let listenerState = await listener.snapshot() + XCTAssertTrue(state.isRunning) + XCTAssertEqual(listenerState.startCount, 2) + XCTAssertEqual(listenerState.fullShutdownCount, 1) + + await service.fullShutdown() + } + + func testRepeatedJoinRetriesStartFailureWithoutDuplicateRunningStart() async throws { + let eligibility = EligibilityState() + let listener = ListenerProbe(failingStartCount: 1) + let service = makeService(listener: listener) { windowID in + await eligibility.isEligible(windowID: windowID) + } + + await eligibility.setEligible(true, windowID: 5) + do { + try await service.join(windowID: 5) + XCTFail("Expected first listener start to fail") + } catch ListenerProbe.ProbeError.startFailed { + // Expected. + } + + try await service.join(windowID: 5) + try await service.join(windowID: 5) + + let state = await service.currentState() + let listenerState = await listener.snapshot() + XCTAssertTrue(state.isRunning) + XCTAssertEqual(listenerState.startCount, 2) + + await service.fullShutdown() + } + + private func makeService( + listener: ListenerProbe, + eligibility: @escaping @Sendable (Int) async -> Bool + ) -> MCPService { + let networkManager = ServerNetworkManager( + runtimeSessionRegistry: MCPRuntimeSessionRegistry(), + serviceRegistry: MCPServiceRegistry() + ) + return MCPService( + networkManager: networkManager, + listenerOperations: MCPService.ListenerOperations( + start: { try await listener.start() }, + stop: { await listener.stop() }, + fullShutdown: { await listener.fullShutdown() } + ), + participationEligibility: eligibility, + configureControllerCallbacks: false + ) + } +} + +private actor EligibilityBarrier { + private struct Request { + let windowID: Int + let continuation: CheckedContinuation + } + + private var requests: [Request] = [] + private var countWaiters: [(count: Int, continuation: CheckedContinuation)] = [] + + func request(windowID: Int) async -> Bool { + await withCheckedContinuation { continuation in + requests.append(Request(windowID: windowID, continuation: continuation)) + let ready = countWaiters.filter { requests.count >= $0.count } + countWaiters.removeAll { requests.count >= $0.count } + ready.forEach { $0.continuation.resume() } + } + } + + func waitUntilRequestCount(_ count: Int) async { + guard requests.count < count else { return } + await withCheckedContinuation { continuation in + countWaiters.append((count, continuation)) + } + } + + func resumeRequest(at index: Int, eligible: Bool) { + requests[index].continuation.resume(returning: eligible) + } +} + +private actor EligibilityState { + private var eligibleWindowIDs: Set = [] + + func setEligible(_ eligible: Bool, windowID: Int) { + if eligible { + eligibleWindowIDs.insert(windowID) + } else { + eligibleWindowIDs.remove(windowID) + } + } + + func isEligible(windowID: Int) -> Bool { + eligibleWindowIDs.contains(windowID) + } +} + +private actor ListenerProbe { + enum ProbeError: Error { + case startFailed + } + + struct Snapshot { + let startCount: Int + let stopCount: Int + let fullShutdownCount: Int + } + + private let suspendStarts: Bool + private let failingStartCount: Int + private var startCount = 0 + private var stopCount = 0 + private var fullShutdownCount = 0 + private var startCountWaiters: [(count: Int, continuation: CheckedContinuation)] = [] + private var startReleaseWaiters: [CheckedContinuation] = [] + private var startsReleased = false + + init(suspendStarts: Bool = false, failingStartCount: Int = 0) { + self.suspendStarts = suspendStarts + self.failingStartCount = failingStartCount + } + + func start() async throws { + startCount += 1 + let ready = startCountWaiters.filter { startCount >= $0.count } + startCountWaiters.removeAll { startCount >= $0.count } + ready.forEach { $0.continuation.resume() } + if startCount <= failingStartCount { + throw ProbeError.startFailed + } + guard suspendStarts, !startsReleased else { return } + await withCheckedContinuation { startReleaseWaiters.append($0) } + } + + func stop() { + stopCount += 1 + } + + func fullShutdown() { + fullShutdownCount += 1 + } + + func waitUntilStartCount(_ count: Int) async { + guard startCount < count else { return } + await withCheckedContinuation { continuation in + startCountWaiters.append((count, continuation)) + } + } + + func releaseStarts() { + startsReleased = true + startReleaseWaiters.forEach { $0.resume() } + startReleaseWaiters.removeAll() + } + + func snapshot() -> Snapshot { + Snapshot( + startCount: startCount, + stopCount: stopCount, + fullShutdownCount: fullShutdownCount + ) + } +} diff --git a/Tests/RepoPromptTests/MCP/SharedRuntimePhase0CharacterizationTests.swift b/Tests/RepoPromptTests/MCP/SharedRuntimePhase0CharacterizationTests.swift new file mode 100644 index 000000000..4903f1def --- /dev/null +++ b/Tests/RepoPromptTests/MCP/SharedRuntimePhase0CharacterizationTests.swift @@ -0,0 +1,276 @@ +import CryptoKit +import Foundation +import MCP +import Ontology +@testable import RepoPrompt +@testable import RepoPromptCore +import XCTest + +@MainActor +final class SharedRuntimePhase0CharacterizationTests: XCTestCase { + private static let appPublishedOverlapOrder = [ + "bind_context", + "manage_workspaces", + "manage_selection", + "get_code_structure", + "get_file_tree", + "read_file", + "file_search", + "workspace_context", + "prompt" + ] + + func testAppPhase0CharacterizationSnapshot() async throws { + let window = Self.makeWindowWithoutAutoStart() + let registry = MCPServiceRegistry() + let routing = WindowRoutingService( + windowStates: WindowStatesManager.shared, + networkMgr: ServerNetworkManager.shared, + serviceRegistry: registry, + workspaceRepository: RepoPromptAppCoreContainer.shared.workspaceRepository + ) + + do { + let routingTools = try await Self.awaitRoutingTools(routing) + let windowTools = await window.mcpServer.windowMCPTools + let allTools = routingTools + windowTools + let toolsByName = Dictionary(allTools.map { ($0.name, $0) }, uniquingKeysWith: { first, _ in first }) + let overlappingTools = try Self.appPublishedOverlapOrder.map { name in + try XCTUnwrap(toolsByName[name], "Missing app tool descriptor: \(name)") + } + + let snapshot: [String: Any] = try [ + "format_version": 1, + "runtime": "app-v1", + "baseline_commit": "042a500b03b39d04237ec5544811696cf6b2f2f9", + "tool_order": overlappingTools.map(\.name), + "descriptors": overlappingTools.map(Self.descriptorRecord), + "normalized_arguments": Self.normalizedArgumentRecords(), + "responses": Self.responseRecords() + ] + + registry.unregister(routing) + await window.tearDown() + try Self.assertOrRecord(snapshot, fixtureName: "app-characterization.json") + } catch { + registry.unregister(routing) + await window.tearDown() + throw error + } + } + + func testAppV1WorkspaceFixtureLoadsWithoutRewrite() async throws { + let repositoryRoot = try RepoRoot.url() + let source = repositoryRoot + .appendingPathComponent("Tests/SharedRuntimeConvergenceFixtures/Phase0/App/WorkspaceV1", isDirectory: true) + let temporaryRoot = FileManager.default.temporaryDirectory + .appendingPathComponent("SharedRuntimePhase0-AppV1-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.copyItem(at: source, to: temporaryRoot) + addTeardownBlock { try? FileManager.default.removeItem(at: temporaryRoot) } + + let workspaceURL = temporaryRoot + .appendingPathComponent("Workspace-Phase 0 App V1-AAAAAAAA-AAAA-AAAA-AAAA-AAAAAAAAAAAA", isDirectory: true) + .appendingPathComponent("workspace.json") + let indexURL = temporaryRoot.appendingPathComponent("workspacesIndex.json") + let beforeWorkspace = try Data(contentsOf: workspaceURL) + let beforeIndex = try Data(contentsOf: indexURL) + + let codec = EmbeddedWorkspaceCodecV1() + let writer = WorkspacePersistenceWriter(codec: codec) + let repository = WorkspaceRepository( + rootProvider: Phase0WorkspaceRootProvider(root: temporaryRoot), + codec: codec, + writer: writer, + migrationService: NoopWorkspaceLegacyMigrationService() + ) + let inventory = await repository.loadInventory() + let workspace = try XCTUnwrap(inventory.workspaces.only) + let tab = try XCTUnwrap(workspace.composeTabs.only) + + XCTAssertEqual(workspace.id.uuidString, "AAAAAAAA-AAAA-AAAA-AAAA-AAAAAAAAAAAA") + XCTAssertEqual(workspace.name, "Phase 0 App V1") + XCTAssertEqual(workspace.repoPaths, ["__APP_FIXTURE_ROOT__"]) + XCTAssertEqual(workspace.activeComposeTabID?.uuidString, "BBBBBBBB-BBBB-BBBB-BBBB-BBBBBBBBBBBB") + XCTAssertEqual(tab.promptText, "phase zero app prompt") + XCTAssertEqual(tab.selection.selectedPaths, [ + "__APP_FIXTURE_ROOT__/Sources/Full.swift", + "__APP_FIXTURE_ROOT__/Sources/Sliced.swift" + ]) + XCTAssertEqual(tab.selection.autoCodemapPaths, ["__APP_FIXTURE_ROOT__/Sources/Structure.swift"]) + XCTAssertEqual(tab.selection.slices["__APP_FIXTURE_ROOT__/Sources/Sliced.swift"], [ + LineRange(start: 2, end: 4, description: "phase zero slice") + ]) + XCTAssertFalse(tab.selection.codemapAutoEnabled) + XCTAssertFalse(try XCTUnwrap(inventory.decodeResults[workspace.id]).requiresRewrite) + XCTAssertEqual(try Data(contentsOf: workspaceURL), beforeWorkspace) + XCTAssertEqual(try Data(contentsOf: indexURL), beforeIndex) + } + + private static func awaitRoutingTools(_ routing: WindowRoutingService) async throws -> [RepoPrompt.Tool] { + for _ in 0 ..< 200 { + let tools = await routing.tools.filter { ["bind_context", "manage_workspaces"].contains($0.name) } + if tools.count == 2 { return tools } + await Task.yield() + } + XCTFail("Timed out waiting for routing tool descriptors") + return [] + } + + private static func descriptorRecord(_ tool: RepoPrompt.Tool) throws -> [String: Any] { + let schema = try Value(tool.inputSchema) + return try [ + "name": tool.name, + "enabled_by_default": tool.isEnabledByDefault, + "description": tool.description, + "description_sha256": digest(tool.description), + "input_schema": canonicalJSONString(schema), + "schema_sha256": digest(canonicalJSONString(schema)), + "annotations": [ + "title": optionalAny(tool.annotations.title), + "read_only": optionalAny(tool.annotations.readOnlyHint), + "destructive": optionalAny(tool.annotations.destructiveHint), + "idempotent": optionalAny(tool.annotations.idempotentHint), + "open_world": optionalAny(tool.annotations.openWorldHint) + ] + ] + } + + private static func normalizedArgumentRecords() throws -> [[String: Any]] { + let contextID = "CCCCCCCC-CCCC-CCCC-CCCC-CCCCCCCCCCCC" + let payloads: [(String, [String: Value])] = [ + ("bind_context", ["op": .string("status"), "working_dirs": .string(" /tmp/a, /tmp/b ")]), + ("manage_workspaces", ["action": .string("list")]), + ("manage_selection", ["op": .string("get")]), + ("workspace_context", ["op": .string("snapshot")]), + ("get_file_tree", ["type": .string("roots")]), + ("get_code_structure", ["scope": .string("selected")]), + ("read_file", ["path": .string("Sources/App.swift")]), + ("file_search", ["pattern": .string("needle"), "regex": .bool(false)]), + ("prompt", ["op": .string("get")]) + ] + + return try payloads.map { toolName, innerPayload in + var wrapped = innerPayload + wrapped["context_id"] = .string(contextID) + wrapped["_windowID"] = .string("41") + wrapped["_rawJSON"] = .string("yes") + let normalized = MCPToolArgsNormalizer.normalize( + params: [toolName: .object(wrapped)], + originalToolName: toolName, + canonicalToolName: toolName + ) + return try [ + "tool": toolName, + "payload": canonicalJSONString(.object(normalized.payload)), + "tab_id": optionalAny(normalized.tabID?.uuidString), + "window_id": optionalAny(normalized.windowID), + "context_id": optionalAny(normalized.contextID?.uuidString), + "working_dirs": normalized.workingDirs, + "raw_json": normalized.rawJSON, + "warnings": normalized.warnings + ] + } + } + + private static func responseRecords() throws -> [[String: Any]] { + try appPublishedOverlapOrder.map { toolName in + let args: [String: Value] = switch toolName { + case "bind_context": ["op": .string("status")] + case "manage_workspaces": ["action": .string("list")] + case "manage_selection": ["op": .string("get")] + case "workspace_context": ["op": .string("snapshot")] + case "get_file_tree": ["type": .string("roots")] + case "get_code_structure": ["scope": .string("selected")] + case "read_file": ["path": .string("Sources/App.swift")] + case "file_search": ["pattern": .string("needle")] + case "prompt": ["op": .string("get")] + default: [:] + } + let structured: Value = .object([ + "status": .string("ok"), + "tool": .string(toolName), + "items": .array([.string("phase0")]) + ]) + let formatted = ToolOutputFormatter.buildContentBlocks( + toolName: toolName, + args: args, + result: structured, + emitResources: false + ) + let raw = ToolOutputFormatter.buildContentBlocks( + toolName: toolName, + args: ["_rawJSON": .bool(true)], + result: structured, + emitResources: false + ) + return try [ + "tool": toolName, + "source": "representative formatter-boundary fixture", + "structured": canonicalJSONString(structured), + "text": firstText(formatted), + "raw_text": firstText(raw) + ] + } + } + + private static func firstText(_ blocks: [MCP.Tool.Content]) throws -> String { + let first = try XCTUnwrap(blocks.first) + guard case let .text(text, _, _) = first else { + XCTFail("Expected text content") + return "" + } + return text + } + + private static func assertOrRecord(_ snapshot: [String: Any], fixtureName: String) throws { + let fixtureURL = try RepoRoot.url() + .appendingPathComponent("Tests/SharedRuntimeConvergenceFixtures/Phase0/App", isDirectory: true) + .appendingPathComponent(fixtureName) + let data = try JSONSerialization.data(withJSONObject: snapshot, options: [.prettyPrinted, .sortedKeys, .withoutEscapingSlashes]) + let existing = try Data(contentsOf: fixtureURL) + if ProcessInfo.processInfo.environment["RECORD_SHARED_RUNTIME_PHASE0"] == "1" { + try data.write(to: fixtureURL, options: .atomic) + return + } + let expected = try JSONSerialization.jsonObject(with: existing) as? NSDictionary + let actual = try JSONSerialization.jsonObject(with: data) as? NSDictionary + XCTAssertEqual(actual, expected) + } + + private static func makeWindowWithoutAutoStart() -> WindowState { + let previousAutoStart = GlobalSettingsStore.shared.mcpAutoStart() + GlobalSettingsStore.shared.setMCPAutoStart(false, commit: false) + let window = WindowState() + GlobalSettingsStore.shared.setMCPAutoStart(previousAutoStart, commit: false) + return window + } + + private static func canonicalJSONString(_ value: Value) throws -> String { + let encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys, .withoutEscapingSlashes] + return try String(decoding: encoder.encode(value), as: UTF8.self) + } + + private static func digest(_ string: String) -> String { + SHA256.hash(data: Data(string.utf8)).map { String(format: "%02x", $0) }.joined() + } + + private static func optionalAny(_ value: (some Any)?) -> Any { + if let value { return value } + return NSNull() + } +} + +private struct Phase0WorkspaceRootProvider: WorkspaceRepositoryRootProviding { + let root: URL + + func repositoryRoot() async -> URL { + root + } +} + +private extension Array { + var only: Element? { + count == 1 ? first : nil + } +} diff --git a/Tests/RepoPromptTests/MCP/TabContextRoutingTests.swift b/Tests/RepoPromptTests/MCP/TabContextRoutingTests.swift index 135cd2c83..917b2fcd1 100644 --- a/Tests/RepoPromptTests/MCP/TabContextRoutingTests.swift +++ b/Tests/RepoPromptTests/MCP/TabContextRoutingTests.swift @@ -112,6 +112,97 @@ final class TabContextRoutingTests: XCTestCase { } } + func testBindingResolverPreservesRequestedMappingReuseLivePersistedAndSingleHostPriority() async throws { + let contextID = UUID() + let workspaceID = UUID() + let matches = (1 ... 6).map { windowID in + match(windowID: windowID, tabID: contextID, workspaceID: workspaceID) + } + + func resolvedWindowID( + requestedWindowID: Int? = nil, + existingWindowID: Int? = nil, + reusableWindowID: Int? = nil, + preferredLiveRunWindowID: Int? = nil, + preferredWindowID: Int? = nil + ) async throws -> Int? { + let resolver = makeResolver( + matchesByContextID: [contextID: matches], + existingWindowID: existingWindowID, + reusableWindowID: reusableWindowID, + preferredLiveRunWindowID: preferredLiveRunWindowID, + preferredWindowID: preferredWindowID + ) + return try await resolver.resolveLogicalContextBinding( + connectionID: UUID(), + explicitContextID: contextID, + legacyTabID: nil, + workingDirs: [], + requestedWindowID: requestedWindowID + )?.windowID + } + + let requested = try await resolvedWindowID( + requestedWindowID: 1, + existingWindowID: 2, + reusableWindowID: 3, + preferredLiveRunWindowID: 4, + preferredWindowID: 5 + ) + XCTAssertEqual(requested, 1) + + let existing = try await resolvedWindowID( + existingWindowID: 2, + reusableWindowID: 3, + preferredLiveRunWindowID: 4, + preferredWindowID: 5 + ) + XCTAssertEqual(existing, 2) + + let reused = try await resolvedWindowID( + reusableWindowID: 3, + preferredLiveRunWindowID: 4, + preferredWindowID: 5 + ) + XCTAssertEqual(reused, 3) + + let live = try await resolvedWindowID( + preferredLiveRunWindowID: 4, + preferredWindowID: 5 + ) + XCTAssertEqual(live, 4) + + let persisted = try await resolvedWindowID(preferredWindowID: 5) + XCTAssertEqual(persisted, 5) + + let invalidImplicitCandidatesSkipped = try await resolvedWindowID( + existingWindowID: 99, + reusableWindowID: 98, + preferredLiveRunWindowID: 97, + preferredWindowID: 6 + ) + XCTAssertEqual(invalidImplicitCandidatesSkipped, 6) + + let singleHostContextID = UUID() + let singleHostResolver = makeResolver( + matchesByContextID: [ + singleHostContextID: [match(windowID: 9, tabID: singleHostContextID, workspaceID: workspaceID)] + ], + existingWindowID: 99, + reusableWindowID: 98, + preferredLiveRunWindowID: 97, + preferredWindowID: 96 + ) + let singleHostResolution = try await singleHostResolver.resolveLogicalContextBinding( + connectionID: UUID(), + explicitContextID: singleHostContextID, + legacyTabID: nil, + workingDirs: [], + requestedWindowID: nil + ) + XCTAssertEqual(singleHostResolution?.windowID, 9) + } + @MainActor func testPendingRunScopedStoreRequiresExactRunHint() { var store = MCPServerViewModel.PendingRunScopedContextStore() @@ -343,14 +434,19 @@ final class TabContextRoutingTests: XCTestCase { ephemeral: true ) await window.workspaceManager.switchWorkspace(to: workspace, saveState: false, reason: "persistResolvedTabContextSnapshotTest") - let workspaceIndex = try XCTUnwrap(window.workspaceManager.workspaces.firstIndex { $0.id == workspace.id }) - window.workspaceManager.workspaces[workspaceIndex].composeTabs = [ - ComposeTabState(id: activeTabID, name: "Active", selection: activeSelection), - ComposeTabState(id: inactiveTabID, name: "Agent", selection: inactiveInitialSelection) - ] - window.workspaceManager.workspaces[workspaceIndex].activeComposeTabID = activeTabID + let workspaceWithTabs = try XCTUnwrap(window.workspaceManager.mutateWorkspace( + id: workspace.id, + touchDateModified: false, + markDirty: false + ) { workspace in + workspace.composeTabs = [ + ComposeTabState(id: activeTabID, name: "Active", selection: activeSelection), + ComposeTabState(id: inactiveTabID, name: "Agent", selection: inactiveInitialSelection) + ] + workspace.activeComposeTabID = activeTabID + }) await window.workspaceManager.switchWorkspace( - to: window.workspaceManager.workspaces[workspaceIndex], + to: workspaceWithTabs, saveState: false, reason: "persistResolvedTabContextSnapshotTestTabs" ) @@ -425,17 +521,16 @@ final class TabContextRoutingTests: XCTestCase { selectedPaths: ["/tmp/new-agent.swift"], codemapAutoEnabled: false ) - let manager = FakeMCPSelectionManager( + let window = await makeSelectionTestWindow( tabs: [ ComposeTabState(id: activeTabID, name: "Active", selection: activeSelection), ComposeTabState(id: inactiveTabID, name: "Agent", selection: inactiveSelection) ], activeTabID: activeTabID ) - let coordinator = WorkspaceSelectionCoordinator( - workspaceManager: manager, - store: WorkspaceFileContextStore() - ) + defer { window.beginClose() } + let manager = window.workspaceManager + let coordinator = window.selectionCoordinator var changes: [WorkspaceSelectionCoordinator.Change] = [] coordinator.changes .sink { changes.append($0) } @@ -459,17 +554,16 @@ final class TabContextRoutingTests: XCTestCase { let inactiveTabID = UUID() let activeSelection = StoredSelection(selectedPaths: ["/tmp/active.swift"]) let inactiveSelection = StoredSelection(selectedPaths: ["/tmp/agent.swift"], codemapAutoEnabled: false) - let manager = FakeMCPSelectionManager( + let window = await makeSelectionTestWindow( tabs: [ ComposeTabState(id: activeTabID, name: "Active", selection: activeSelection), ComposeTabState(id: inactiveTabID, name: "Agent", selection: inactiveSelection) ], activeTabID: activeTabID ) - let coordinator = WorkspaceSelectionCoordinator( - workspaceManager: manager, - store: WorkspaceFileContextStore() - ) + defer { window.beginClose() } + let manager = window.workspaceManager + let coordinator = window.selectionCoordinator var changes: [WorkspaceSelectionCoordinator.Change] = [] coordinator.changes .sink { changes.append($0) } @@ -676,6 +770,23 @@ final class TabContextRoutingTests: XCTestCase { ) } + @MainActor + private func makeSelectionTestWindow(tabs: [ComposeTabState], activeTabID: UUID) async -> WindowState { + let previousAutoStart = GlobalSettingsStore.shared.mcpAutoStart() + GlobalSettingsStore.shared.setMCPAutoStart(false, commit: false) + let window = WindowState() + GlobalSettingsStore.shared.setMCPAutoStart(previousAutoStart, commit: false) + await window.workspaceManager.awaitInitialized() + let workspace = WorkspaceModel( + name: "Test Workspace", + repoPaths: [], + composeTabs: tabs, + activeComposeTabID: activeTabID + ) + window.workspaceManager.replaceWorkspaceInventory([workspace], activeWorkspaceID: workspace.id) + return window + } + @MainActor private func makeTabContext(runID: UUID?, windowID: Int) -> MCPServerViewModel.TabContextSnapshot { MCPServerViewModel.TabContextSnapshot( @@ -692,34 +803,6 @@ final class TabContextRoutingTests: XCTestCase { } } -@MainActor -private final class FakeMCPSelectionManager: WorkspaceSelectionHost { - var activeWorkspace: WorkspaceModel? - - init(tabs: [ComposeTabState], activeTabID: UUID) { - activeWorkspace = WorkspaceModel( - name: "Test Workspace", - repoPaths: [], - composeTabs: tabs, - activeComposeTabID: activeTabID - ) - } - - func composeTab(with id: UUID) -> ComposeTabState? { - activeWorkspace?.composeTabs.first(where: { $0.id == id }) - } - - func publishActiveComposeTabSnapshot(commitToMemory: Bool, touchModified: Bool) {} - - func updateComposeTabStoredOnly(_ tab: ComposeTabState) { - guard var workspace = activeWorkspace, - let index = workspace.composeTabs.firstIndex(where: { $0.id == tab.id }) - else { return } - workspace.composeTabs[index] = tab - activeWorkspace = workspace - } -} - private func XCTAssertThrowsErrorAsync( _ expression: () async throws -> some Any, _ message: @autoclosure () -> String = "", diff --git a/Tests/RepoPromptTests/MCP/ToolCatalogSnapshotTests.swift b/Tests/RepoPromptTests/MCP/ToolCatalogSnapshotTests.swift index acaa25e9e..bbd65aec4 100644 --- a/Tests/RepoPromptTests/MCP/ToolCatalogSnapshotTests.swift +++ b/Tests/RepoPromptTests/MCP/ToolCatalogSnapshotTests.swift @@ -185,6 +185,8 @@ final class ToolCatalogSnapshotTests: XCTestCase { #if DEBUG try await MCPSharedServerTestLease.shared.withLease { _ in let window = Self.makeWindowWithoutAutoStart() + WindowStatesManager.shared.registerWindowState(window) + defer { WindowStatesManager.shared.unregisterWindowState(window) } let catalogService = window.mcpServer.windowMCPToolCatalogService try await Self.withIsolatedBootstrapSocketNamespace(window: window, catalogService: catalogService) { socketURL in @@ -196,6 +198,7 @@ final class ToolCatalogSnapshotTests: XCTestCase { (service as AnyObject) === (window.mcpServer as AnyObject) }) + try await Self.waitForSocket(at: socketURL) let attributes = try FileManager.default.attributesOfItem(atPath: socketURL.path) XCTAssertEqual(attributes[.type] as? FileAttributeType, .typeSocket) @@ -421,6 +424,14 @@ final class ToolCatalogSnapshotTests: XCTestCase { } #endif + private static func waitForSocket(at socketURL: URL) async throws { + for _ in 0 ..< 200 { + if FileManager.default.fileExists(atPath: socketURL.path) { return } + try await Task.sleep(for: .milliseconds(10)) + } + throw CocoaError(.fileNoSuchFile, userInfo: [NSFilePathErrorKey: socketURL.path]) + } + private static func schemaProperties(for tool: RepoPrompt.Tool) throws -> [String: Value] { let schema = try XCTUnwrap(Value(tool.inputSchema).objectValue) return try XCTUnwrap(schema["properties"]?.objectValue) diff --git a/Tests/RepoPromptTests/Prompt/AIMessagePromptAssemblyParityTests.swift b/Tests/RepoPromptTests/Prompt/AIMessagePromptAssemblyParityTests.swift new file mode 100644 index 000000000..9a5e8e667 --- /dev/null +++ b/Tests/RepoPromptTests/Prompt/AIMessagePromptAssemblyParityTests.swift @@ -0,0 +1,429 @@ +import Foundation +@testable import RepoPrompt +import RepoPromptCore +import XCTest + +final class AIMessagePromptAssemblyParityTests: XCTestCase { + func testCoreStandardChatMatchesLegacyAcrossStandardPolicyMatrix() { + let orders: [[PromptSection]] = [ + PromptAssemblyBuilder.defaultSectionOrder, + [.metaPrompts, .userInstructions, .fileContents, .fileMap, .gitDiff], + [.fileContents, .userInstructions, .fileContents, .metaPrompts, .userInstructions, .gitDiff, .fileMap, .metaPrompts] + ] + let disabledSets: [Set] = [ + [], + Set(PromptSection.allCases), + [.fileMap], + [.fileContents], + [.gitDiff], + [.metaPrompts], + [.userInstructions], + [.fileMap, .metaPrompts, .userInstructions] + ] + let finalUserContents = [ + "FINAL", + " \t\r\n", + "\nPREWRAPPED-LF\n\n", + "\r\nPREWRAPPED-CRLF\r\n\r\n" + ] + + for order in orders { + for disabled in disabledSets { + for duplicate in [false, true] { + for finalUserContent in finalUserContents { + let conversation = [ + ConversationEntry(role: .user, content: "EARLY"), + ConversationEntry(role: .assistant, content: "ASSISTANT"), + ConversationEntry(role: .user, content: finalUserContent) + ] + assertCoreMatchesLegacy( + conversation: conversation, + order: order, + disabled: disabled, + duplicateUserInstructionsAtTop: duplicate + ) + } + } + } + } + } + + func testCoreStandardChatPreservesEmbeddedSystemAndEmptyOrUserlessTransportBehavior() { + let nonempty = makeMessage( + conversation: [ConversationEntry(role: .user, content: "FINAL")], + strategy: .coreStandardChat + ) + let tail = nonempty.buildTail(embedSystemPrompt: false) + XCTAssertFalse(tail.isEmpty) + XCTAssertEqual(nonempty.buildTail(embedSystemPrompt: true), tail + "\n\n\n\nSYSTEM") + + let emptyTail = PromptPackagingService.buildAIMessage( + systemPrompt: "SYSTEM", + metaInstructions: [], + fileTree: "", + fileContents: [], + conversation: [ConversationEntry(role: .user, content: "FINAL")], + temperature: nil, + promptSectionsOrder: PromptAssemblyBuilder.defaultSectionOrder, + disabledPromptSections: [], + tailAssemblyStrategy: .coreStandardChat + ) + XCTAssertEqual(emptyTail.buildTail(embedSystemPrompt: false), "") + XCTAssertEqual(emptyTail.buildTail(embedSystemPrompt: true), "SYSTEM") + + for conversation in [ + [ConversationEntry](), + [ConversationEntry(role: .assistant, content: "ASSISTANT-ONLY")] + ] { + assertCoreMatchesLegacy( + conversation: conversation, + order: [.userInstructions, .fileMap, .metaPrompts, .fileContents, .gitDiff, .userInstructions], + disabled: [.userInstructions], + duplicateUserInstructionsAtTop: true + ) + } + + let coreNoUser = makeMessage( + conversation: [ConversationEntry(role: .assistant, content: "ASSISTANT-ONLY")], + strategy: .coreStandardChat + ) + XCTAssertEqual( + PromptPackagingService.exactChatPayload(for: coreNoUser, source: .immutableSnapshot).text, + "SYSTEMASSISTANT-ONLY" + ) + } + + func testTopOnlyDuplicateAdapterPreservesWhitespaceEligibilityAndTrailingBytes() { + for userContent in ["", " \t\r\n", "USER\n", "USER\r\n"] { + let legacy = directMessage( + userContent: userContent, + strategy: .legacy + ) + let core = directMessage( + userContent: userContent, + strategy: .coreStandardChat + ) + XCTAssertEqual(core.buildTail(embedSystemPrompt: false), legacy.buildTail(embedSystemPrompt: false)) + XCTAssertEqual(core.buildTail(embedSystemPrompt: true), legacy.buildTail(embedSystemPrompt: true)) + } + } + + func testCoreStandardChatMatchesHistoricalBytesForEmptyWhitespaceAndUserlessFixtures() { + assertCoreMatchesLegacy( + conversation: [], + order: [], + disabled: [], + duplicateUserInstructionsAtTop: true, + systemPrompt: "", + metaInstructions: [], + fileTree: "", + fileContents: [], + gitDiff: nil + ) + assertCoreMatchesLegacy( + conversation: [ConversationEntry(role: .user, content: " \t\r\n")], + order: [.fileContents, .fileMap, .gitDiff, .metaPrompts, .fileContents], + disabled: [.gitDiff], + duplicateUserInstructionsAtTop: true, + metaInstructions: [], + fileTree: " \t", + fileContents: ["", " \r\n"], + gitDiff: " \t\r\n" + ) + assertCoreMatchesLegacy( + conversation: [ConversationEntry(role: .assistant, content: "ASSISTANT-ONLY")], + order: [.metaPrompts, .fileMap, .metaPrompts, .fileContents, .gitDiff], + disabled: [.fileMap, .fileContents, .gitDiff, .metaPrompts, .userInstructions], + duplicateUserInstructionsAtTop: true, + metaInstructions: [], + fileTree: "TREE", + fileContents: ["FILE"], + gitDiff: "DIFF" + ) + } + + func testCoreBackedLegacyFactualPropertiesPreserveHistoricalBytes() { + let cases: [(fileTree: String, fileBlocks: [String], gitDiff: String?)] = [ + ("", [], nil), + (" \t", [""], " \r\n"), + ("TREE\n", ["FIRST", "SECOND"], "DIFF\n"), + ("TREE\r\n", ["FIRST\n", "SECOND\r\n"], "DIFF\r\n") + ] + + for values in cases { + let message = AIMessage( + systemPrompt: "SYSTEM", + metaPrompts: ["META"], + fileTree: values.fileTree, + fileBlocks: values.fileBlocks, + gitDiff: values.gitDiff, + conversationMessages: [], + temperature: nil, + promptSectionsOrder: PromptAssemblyBuilder.defaultSectionOrder, + disabledPromptSections: [] + ) + + XCTAssertEqual(message.fileTreeXML, historicalFileTreeXML(values.fileTree)) + XCTAssertEqual(message.fileBlocksXML, historicalFileBlocksXML(values.fileBlocks)) + XCTAssertEqual(message.gitDiffXML, historicalGitDiffXML(values.gitDiff)) + XCTAssertEqual( + message.combinedXML, + [ + message.systemPromptXML, + message.metaPromptsXML, + historicalFileTreeXML(values.fileTree), + historicalFileBlocksXML(values.fileBlocks), + historicalGitDiffXML(values.gitDiff) + ] + .filter { !$0.isEmpty } + .joined(separator: "\n\n") + ) + } + } + + private func assertCoreMatchesLegacy( + conversation: [ConversationEntry], + order: [PromptSection], + disabled: Set, + duplicateUserInstructionsAtTop: Bool, + systemPrompt: String = "SYSTEM", + metaInstructions: [MetaInstruction] = [ + MetaInstruction(title: "Rules LF", content: "META-LF\n"), + MetaInstruction(title: "Rules CRLF", content: "META-CRLF\r\n") + ], + fileTree: String = "TREE-LF\n", + fileContents: [String] = ["FIRST\n", "SECOND\r\n"], + gitDiff: String? = "DIFF-CRLF\r\n", + file: StaticString = #filePath, + line: UInt = #line + ) { + let legacy = makeMessage( + conversation: conversation, + order: order, + disabled: disabled, + duplicateUserInstructionsAtTop: duplicateUserInstructionsAtTop, + systemPrompt: systemPrompt, + metaInstructions: metaInstructions, + fileTree: fileTree, + fileContents: fileContents, + gitDiff: gitDiff, + strategy: .legacy + ) + let core = makeMessage( + conversation: conversation, + order: order, + disabled: disabled, + duplicateUserInstructionsAtTop: duplicateUserInstructionsAtTop, + systemPrompt: systemPrompt, + metaInstructions: metaInstructions, + fileTree: fileTree, + fileContents: fileContents, + gitDiff: gitDiff, + strategy: .coreStandardChat + ) + + for embedSystemPrompt in [false, true] { + let historicalTail = historicalLegacyTail(for: legacy, embedSystemPrompt: embedSystemPrompt) + XCTAssertEqual(legacy.buildTail(embedSystemPrompt: embedSystemPrompt), historicalTail, file: file, line: line) + XCTAssertEqual(core.buildTail(embedSystemPrompt: embedSystemPrompt), historicalTail, file: file, line: line) + let historicalChat = historicalChatTransport(for: legacy, embedSystemPrompt: embedSystemPrompt) + XCTAssertEqual(chatTransport(legacy, embedSystemPrompt: embedSystemPrompt), historicalChat, file: file, line: line) + XCTAssertEqual(chatTransport(core, embedSystemPrompt: embedSystemPrompt), historicalChat, file: file, line: line) + } + + let historicalResponses = historicalResponsesTransport(for: legacy) + XCTAssertEqual(responsesTransport(legacy), historicalResponses, file: file, line: line) + XCTAssertEqual(responsesTransport(core), historicalResponses, file: file, line: line) + + let legacyPayload = PromptPackagingService.exactChatPayload(for: legacy, source: .activeLive) + let corePayload = PromptPackagingService.exactChatPayload(for: core, source: .activeLive) + XCTAssertEqual(corePayload.text, legacyPayload.text, file: file, line: line) + XCTAssertEqual(corePayload.projection.total, legacyPayload.projection.total, file: file, line: line) + } + + private func makeMessage( + conversation: [ConversationEntry], + order: [PromptSection] = PromptAssemblyBuilder.defaultSectionOrder, + disabled: Set = [], + duplicateUserInstructionsAtTop: Bool = false, + systemPrompt: String = "SYSTEM", + metaInstructions: [MetaInstruction] = [ + MetaInstruction(title: "Rules LF", content: "META-LF\n"), + MetaInstruction(title: "Rules CRLF", content: "META-CRLF\r\n") + ], + fileTree: String = "TREE-LF\n", + fileContents: [String] = ["FIRST\n", "SECOND\r\n"], + gitDiff: String? = "DIFF-CRLF\r\n", + strategy: AIMessage.TailAssemblyStrategy + ) -> AIMessage { + PromptPackagingService.buildAIMessage( + systemPrompt: systemPrompt, + metaInstructions: metaInstructions, + fileTree: fileTree, + fileContents: fileContents, + gitDiff: gitDiff, + conversation: conversation, + temperature: nil, + promptSectionsOrder: order, + disabledPromptSections: disabled, + duplicateUserInstructionsAtTop: duplicateUserInstructionsAtTop, + tailAssemblyStrategy: strategy + ) + } + + private func directMessage( + userContent: String, + strategy: AIMessage.TailAssemblyStrategy + ) -> AIMessage { + AIMessage( + systemPrompt: "SYSTEM", + metaPrompts: [], + fileTree: "", + fileBlocks: [], + conversationMessages: [ConversationEntry(role: .user, content: userContent)], + temperature: nil, + promptSectionsOrder: [.userInstructions, .userInstructions], + disabledPromptSections: [], + duplicateUserInstructionsAtTop: true, + tailAssemblyStrategy: strategy + ) + } + + private func chatTransport( + _ message: AIMessage, + embedSystemPrompt: Bool + ) -> [TransportMessage] { + message.openAIChatMessages(embedSystemPrompt: embedSystemPrompt).map { item in + let text: String = switch item.content { + case let .text(text): + text + case let .contentArray(items): + items.compactMap { content in + if case let .text(text) = content { return text } + return nil + }.joined() + } + return TransportMessage(role: String(describing: item.role), text: text) + } + } + + private func responsesTransport(_ message: AIMessage) -> [TransportMessage] { + switch message.openAIResponsesInput() { + case let .array(items): + items.compactMap { item in + guard case let .message(message) = item else { return nil } + guard case let .text(text) = message.content else { return nil } + return TransportMessage(role: message.role, text: text) + } + default: + [] + } + } + + private func historicalLegacyTail( + for message: AIMessage, + embedSystemPrompt: Bool + ) -> String { + var parts: [String] = [] + if message.duplicateUserInstructionsAtTop, + let userBlock = message.conversationMessages.last(where: { $0.role == .user })?.content, + !userBlock.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + { + parts.append(userBlock) + } + + for section in message.promptSectionsOrder where !message.disabledPromptSections.contains(section) { + switch section { + case .fileMap: + if !message.fileTree.isEmpty { parts.append(historicalFileTreeXML(message.fileTree)) } + case .fileContents: + if !message.fileBlocks.isEmpty { parts.append(historicalFileBlocksXML(message.fileBlocks)) } + case .metaPrompts: + if !message.metaPrompts.isEmpty { parts.append(message.metaPrompts.joined(separator: "\n")) } + case .gitDiff: + if let diff = message.gitDiff, !diff.isEmpty { parts.append(historicalGitDiffXML(diff)) } + case .userInstructions: + continue + } + } + + if embedSystemPrompt, !message.systemPrompt.isEmpty { + if !parts.isEmpty { parts.append("") } + parts.append(message.systemPrompt) + } + return parts.joined(separator: "\n\n") + } + + private func historicalChatTransport( + for message: AIMessage, + embedSystemPrompt: Bool + ) -> [TransportMessage] { + let tail = historicalLegacyTail(for: message, embedSystemPrompt: embedSystemPrompt) + var messages: [TransportMessage] = [] + if !embedSystemPrompt, !message.systemPrompt.isEmpty { + messages.append(TransportMessage(role: "system", text: message.systemPrompt)) + } + + let lastUserIndex = message.conversationMessages.lastIndex { $0.role == .user } + for (index, entry) in message.conversationMessages.enumerated() { + let text = entry.role == .user && index == lastUserIndex && !tail.isEmpty + ? tail + "\n" + entry.content + : entry.content + messages.append( + TransportMessage( + role: entry.role == .user ? "user" : "assistant", + text: text + ) + ) + } + return messages + } + + private func historicalResponsesTransport(for message: AIMessage) -> [TransportMessage] { + let tail = historicalLegacyTail(for: message, embedSystemPrompt: false) + let additions = tail.isEmpty ? "" : tail + "\n\n" + var messages: [TransportMessage] = [] + var firstUser = true + + for entry in message.conversationMessages { + switch entry.role { + case .user: + let text = firstUser ? additions + entry.content : entry.content + firstUser = false + messages.append(TransportMessage(role: "user", text: text)) + case .assistant: + messages.append(TransportMessage(role: "assistant", text: entry.content)) + } + } + + if messages.isEmpty, !additions.isEmpty { + messages.append(TransportMessage(role: "user", text: additions)) + } + return messages + } + + private func historicalFileTreeXML(_ fileTree: String) -> String { + guard !fileTree.isEmpty else { return "" } + return "\n\(fileTree)\n" + } + + private func historicalFileBlocksXML(_ fileBlocks: [String]) -> String { + guard !fileBlocks.isEmpty else { return "" } + var result = "\n" + for block in fileBlocks { + result += block + "\n\n" + } + result += "" + return result + } + + private func historicalGitDiffXML(_ gitDiff: String?) -> String { + guard let gitDiff, !gitDiff.isEmpty else { return "" } + return "\n\(gitDiff)\n" + } +} + +private struct TransportMessage: Equatable { + let role: String + let text: String +} diff --git a/Tests/RepoPromptTests/Prompt/AIProviderInputProjectionFoundationTests.swift b/Tests/RepoPromptTests/Prompt/AIProviderInputProjectionFoundationTests.swift new file mode 100644 index 000000000..3c7cf197f --- /dev/null +++ b/Tests/RepoPromptTests/Prompt/AIProviderInputProjectionFoundationTests.swift @@ -0,0 +1,295 @@ +@testable import RepoPrompt +@testable import RepoPromptCore +import XCTest + +final class AIProviderInputProjectionFoundationTests: XCTestCase { + private let expectedTail = """ + + TREE + + + + FILE + + + + + DIFF + + + META + """ + + func testPreparedChatInputFeedsSDKConversionWithoutChangingRolesOrBytes() { + let message = makeMessage(conversation: [ + .init(role: .user, content: "EARLY"), + .init(role: .assistant, content: "ASSISTANT"), + .init(role: .user, content: "FINAL") + ]) + + let separateSystem = message.preparedOpenAIChatInput(embedSystemPrompt: false) + XCTAssertEqual(separateSystem.messages, [ + .init(role: .system, content: "SYSTEM"), + .init(role: .user, content: "EARLY"), + .init(role: .assistant, content: "ASSISTANT"), + .init(role: .user, content: expectedTail + "\nFINAL") + ]) + assertPreparedChatFeedsSDK(message, embedSystemPrompt: false) + + let embeddedSystem = message.preparedOpenAIChatInput(embedSystemPrompt: true) + XCTAssertEqual(embeddedSystem.messages, [ + .init(role: .user, content: "EARLY"), + .init(role: .assistant, content: "ASSISTANT"), + .init(role: .user, content: expectedTail + "\n\n\n\nSYSTEM\nFINAL") + ]) + assertPreparedChatFeedsSDK(message, embedSystemPrompt: true) + } + + func testPreparedResponsesInputFeedsSDKConversionAndPreservesEmptyConversationAndAssistantOnlyQuirks() { + let conversationMessage = makeMessage(conversation: [ + .init(role: .assistant, content: "ASSISTANT-FIRST"), + .init(role: .user, content: "FIRST-USER"), + .init(role: .user, content: "SECOND-USER") + ]) + XCTAssertEqual(conversationMessage.preparedOpenAIResponsesInput(), .init( + instructions: "SYSTEM", + messages: [ + .init(role: .assistant, content: "ASSISTANT-FIRST"), + .init(role: .user, content: expectedTail + "\n\nFIRST-USER"), + .init(role: .user, content: "SECOND-USER") + ] + )) + assertPreparedResponsesFeedsSDK(conversationMessage) + + let emptyConversation = makeMessage(conversation: []) + XCTAssertEqual(emptyConversation.preparedOpenAIResponsesInput(), .init( + instructions: "SYSTEM", + messages: [.init(role: .user, content: expectedTail + "\n\n")] + )) + assertPreparedResponsesFeedsSDK(emptyConversation) + + let assistantOnly = makeMessage(conversation: [ + .init(role: .assistant, content: "ASSISTANT-ONLY") + ]) + XCTAssertEqual(assistantOnly.preparedOpenAIResponsesInput(), .init( + instructions: "SYSTEM", + messages: [.init(role: .assistant, content: "ASSISTANT-ONLY")] + )) + assertPreparedResponsesFeedsSDK(assistantOnly) + + let noTailEmptyConversation = AIMessage( + systemPrompt: "", + metaPrompts: [], + fileTree: "", + fileBlocks: [], + conversationMessages: [], + temperature: nil, + promptSectionsOrder: PromptAssemblyBuilder.defaultSectionOrder, + disabledPromptSections: [] + ) + XCTAssertEqual(noTailEmptyConversation.preparedOpenAIResponsesInput(), .init( + instructions: nil, + messages: [] + )) + assertPreparedResponsesFeedsSDK(noTailEmptyConversation) + } + + func testPreflightResolverOnlyClaimsConfigurationIndependentOpenAIAndOpenRouterRoutes() { + let message = makeMessage(conversation: [.init(role: .user, content: "FINAL")]) + + let chat = AIProviderInputProjectionResolver.preflight( + message: message, + model: .openaiCustom(name: "chat-model") + ) + XCTAssertEqual(chat.routeResolution, .preflightResolved) + XCTAssertEqual(chat.transport, .openAIChat) + XCTAssertEqual(chat.fragments.first?.channel, .message(role: .system)) + XCTAssertEqual( + chat.renderedText, + message.preparedOpenAIChatInput(embedSystemPrompt: false).messages.map(\.content).joined() + ) + + let responses = AIProviderInputProjectionResolver.preflight(message: message, model: .gpt5) + XCTAssertEqual(responses.routeResolution, .preflightResolved) + XCTAssertEqual(responses.transport, .openAIResponses) + XCTAssertEqual(responses.fragments.first, .init(channel: .instructions, text: "SYSTEM")) + XCTAssertEqual( + responses.renderedText, + "SYSTEM" + message.preparedOpenAIResponsesInput().messages.map(\.content).joined() + ) + + let openRouter = AIProviderInputProjectionResolver.preflight( + message: message, + model: .openrouterCustom(name: "router-model") + ) + XCTAssertEqual(openRouter.routeResolution, .preflightResolved) + XCTAssertEqual(openRouter.transport, .openAIChat) + } + + func testPreflightResolverLeavesLegacyAzureRoutedModelsUnresolved() { + let message = makeMessage(conversation: [.init(role: .user, content: "FINAL")]) + let legacyModels: [AIModel] = [.gpt4o, .o1Mini, .o1Preview, .o3, .o3Low, .o3High] + + for model in legacyModels { + XCTAssertEqual(model.providerType, .azure) + let projection = AIProviderInputProjectionResolver.preflight(message: message, model: model) + XCTAssertEqual(projection.transport, .unresolved) + XCTAssertEqual(projection.routeResolution, .unresolved) + XCTAssertEqual(projection.fallbackReason, .providerRuntimeConfigurationRequired) + } + } + + func testPreflightResolverUsesExplicitUnresolvedFallbackOutsideNarrowRoutes() { + let message = makeMessage(conversation: [.init(role: .user, content: "FINAL")]) + + for model in [AIModel.azureCustom(name: "deployment"), .customProviderUser(name: "custom")] { + let projection = AIProviderInputProjectionResolver.preflight(message: message, model: model) + XCTAssertEqual(projection.transport, .unresolved) + XCTAssertEqual(projection.routeResolution, .unresolved) + XCTAssertEqual(projection.fallbackReason, .providerRuntimeConfigurationRequired) + } + + for model in [AIModel.claude4Sonnet, .deepseekChat, .ollama] { + let projection = AIProviderInputProjectionResolver.preflight(message: message, model: model) + XCTAssertEqual(projection.transport, .unresolved) + XCTAssertEqual(projection.routeResolution, .unresolved) + XCTAssertEqual(projection.fallbackReason, .providerProjectionUnavailable) + } + } + + func testChatInputTokenEstimateKeepsRouteResolutionIndependentFromEstimateBasisAndSource() { + let input = AIMessage(systemPrompt: "", userMessage: "12345678") + .preparedOpenAIChatInput(embedSystemPrompt: false) + let projections = [ + AIProviderInputProjection.unresolved( + neutralChatInput: input, + fallbackReason: .providerProjectionUnavailable + ), + .preflightResolved(chatInput: input), + .providerResolved(chatInput: input) + ] + + let estimates = projections.map { + ChatInputTokenEstimate(inputProjection: $0, source: .immutableSnapshot) + } + XCTAssertEqual(estimates.map(\.inputProjection.routeResolution), [ + .unresolved, + .preflightResolved, + .providerResolved + ]) + for estimate in estimates { + XCTAssertEqual(estimate.tokenProjection.provenance.basis, .renderedPayloadEstimate) + XCTAssertEqual(estimate.tokenProjection.provenance.source, .immutableSnapshot) + XCTAssertEqual(estimate.tokenProjection.total, TokenCalculationService.estimateTokens(for: "12345678")) + } + } + + func testProviderStreamStartDefaultForwardsExistingStreamWithoutClaimingProjection() async throws { + let provider = ForwardingProvider() + let message = AIMessage(systemPrompt: "SYSTEM", userMessage: "USER") + let start = try await provider.streamMessageWithInputProjection( + message, + model: .gpt4o, + maxTokens: 42 + ) + + XCTAssertNil(start.inputProjection) + XCTAssertEqual(provider.receivedMaxTokens, 42) + var resultTypes: [String] = [] + for try await result in start.stream { + resultTypes.append(result.type) + } + XCTAssertEqual(resultTypes, ["message_stop"]) + } + + private func makeMessage(conversation: [ConversationEntry]) -> AIMessage { + AIMessage( + systemPrompt: "SYSTEM", + metaPrompts: ["META"], + fileTree: "TREE", + fileBlocks: ["FILE"], + gitDiff: "DIFF", + conversationMessages: conversation, + temperature: nil, + promptSectionsOrder: PromptAssemblyBuilder.defaultSectionOrder, + disabledPromptSections: [], + tailAssemblyStrategy: .coreStandardChat + ) + } + + private func assertPreparedChatFeedsSDK( + _ message: AIMessage, + embedSystemPrompt: Bool, + file: StaticString = #filePath, + line: UInt = #line + ) { + let prepared = message.preparedOpenAIChatInput(embedSystemPrompt: embedSystemPrompt) + let sdkMessages = message.openAIChatMessages(embedSystemPrompt: embedSystemPrompt).map { sdkMessage in + let role: AIProviderInputRole = switch String(describing: sdkMessage.role) { + case "system": .system + case "user": .user + default: .assistant + } + let content: String = switch sdkMessage.content { + case let .text(text): + text + case let .contentArray(items): + items.compactMap { item in + if case let .text(text) = item { return text } + return nil + }.joined() + } + return AIMessage.PreparedMessage(role: role, content: content) + } + XCTAssertEqual(prepared.messages, sdkMessages, file: file, line: line) + } + + private func assertPreparedResponsesFeedsSDK( + _ message: AIMessage, + file: StaticString = #filePath, + line: UInt = #line + ) { + let prepared = message.preparedOpenAIResponsesInput() + let sdkMessages: [AIMessage.PreparedMessage] = switch message.openAIResponsesInput() { + case let .array(items): + items.compactMap { item in + guard case let .message(message) = item, + case let .text(text) = message.content + else { return nil } + return .init( + role: message.role == "user" ? .user : .assistant, + content: text + ) + } + default: + [] + } + XCTAssertEqual(prepared.messages, sdkMessages, file: file, line: line) + } +} + +private final class ForwardingProvider: AIProvider { + var receivedMaxTokens: Int? + + func streamMessage( + _: AIMessage, + model _: AIModel, + maxTokens: Int? + ) async throws -> AsyncThrowingStream { + receivedMaxTokens = maxTokens + return AsyncThrowingStream { continuation in + continuation.yield(.init(type: "message_stop", text: nil)) + continuation.finish() + } + } + + func completeMessage( + _: AIMessage, + model _: AIModel, + maxTokens _: Int? + ) async throws -> AICompletionResult { + .init(text: "") + } + + func dispose() async {} +} diff --git a/Tests/RepoPromptTests/Prompt/PromptContextAccountingServiceTests.swift b/Tests/RepoPromptTests/Prompt/PromptContextAccountingServiceTests.swift deleted file mode 100644 index 9a49d676c..000000000 --- a/Tests/RepoPromptTests/Prompt/PromptContextAccountingServiceTests.swift +++ /dev/null @@ -1,191 +0,0 @@ -@testable import RepoPrompt -import XCTest - -final class PromptContextAccountingServiceTests: XCTestCase { - private var temporaryRoots: [URL] = [] - - override func tearDownWithError() throws { - for url in temporaryRoots { - try? FileManager.default.removeItem(at: url) - } - temporaryRoots.removeAll() - try super.tearDownWithError() - } - - func testExactSelectedFilesPreserveStoredSelectionOrderAfterBatchLookupAndConcurrentReads() async throws { - let root = try makeTemporaryRoot(name: "AccountingOrder") - let fileA = root.appendingPathComponent("A.swift") - let fileB = root.appendingPathComponent("B.swift") - let fileC = root.appendingPathComponent("C.swift") - try write("alpha", to: fileA) - try write("beta", to: fileB) - try write("gamma", to: fileC) - - let store = WorkspaceFileContextStore() - _ = try await store.loadRoot(path: root.path) - let service = PromptContextAccountingService() - let selection = StoredSelection( - selectedPaths: [fileC.path, fileA.path, fileB.path], - autoCodemapPaths: [], - slices: [:], - codemapAutoEnabled: false - ) - - let resolution = await service.resolveEntries(selection: selection, store: store, codeMapUsage: .none) - - XCTAssertEqual(resolution.entries.map(\.file.standardizedRelativePath), ["C.swift", "A.swift", "B.swift"]) - XCTAssertEqual(resolution.entries.map(\.loadedContent), ["gamma", "alpha", "beta"]) - XCTAssertEqual(resolution.missingPaths, []) - XCTAssertEqual(resolution.invalidPaths, []) - } - - func testDuplicateSelectedPathsPreserveExistingEntryDedupOrder() async throws { - let root = try makeTemporaryRoot(name: "AccountingDuplicates") - let fileA = root.appendingPathComponent("A.swift") - let fileB = root.appendingPathComponent("B.swift") - try write("alpha", to: fileA) - try write("beta", to: fileB) - - let store = WorkspaceFileContextStore() - _ = try await store.loadRoot(path: root.path) - let service = PromptContextAccountingService() - let selection = StoredSelection( - selectedPaths: [fileA.path, fileA.path, fileB.path], - autoCodemapPaths: [], - slices: [:], - codemapAutoEnabled: false - ) - - let resolution = await service.resolveEntries(selection: selection, store: store, codeMapUsage: .none) - - XCTAssertEqual(resolution.entries.map(\.file.standardizedRelativePath), ["A.swift", "B.swift"]) - XCTAssertEqual(resolution.entries.map(\.loadedContent), ["alpha", "beta"]) - XCTAssertEqual(resolution.missingPaths, []) - XCTAssertEqual(resolution.invalidPaths, []) - } - - func testSelectedCodemapUsageDoesNotLoadContentWhenCodemapExists() async throws { - let root = try makeTemporaryRoot(name: "AccountingSelectedCodemap") - let fileURL = root.appendingPathComponent("A.swift") - try write("struct A { func fullContent() {} }", to: fileURL) - - let store = WorkspaceFileContextStore() - _ = try await store.loadRoot(path: root.path) - await store.applyObservedCodemapResults([ - WorkspaceObservedCodemapResult(fullPath: fileURL.path, modificationDate: Date(), fileAPI: makeFileAPI(path: fileURL.path)) - ]) - let service = PromptContextAccountingService() - let selection = StoredSelection( - selectedPaths: [fileURL.path], - autoCodemapPaths: [], - slices: [:], - codemapAutoEnabled: false - ) - - let resolution = await service.resolveEntries(selection: selection, store: store, codeMapUsage: .selected) - - let entry = try XCTUnwrap(resolution.entries.first) - XCTAssertEqual(resolution.entries.count, 1) - XCTAssertTrue(entry.isCodemap) - XCTAssertEqual(entry.mode, .codemap) - XCTAssertNil(entry.lineRanges) - XCTAssertNil(entry.loadedContent) - XCTAssertEqual(resolution.missingPaths, []) - XCTAssertEqual(resolution.invalidPaths, []) - } - - func testMissingSelectedPathsRemainMissingAndInvalidPathsRemainEmpty() async throws { - let root = try makeTemporaryRoot(name: "AccountingMissing") - try write("alpha", to: root.appendingPathComponent("A.swift")) - - let store = WorkspaceFileContextStore() - _ = try await store.loadRoot(path: root.path) - let service = PromptContextAccountingService() - let missingPath = root.appendingPathComponent("Missing.swift").path - let unresolvedRelativePath = "DefinitelyMissing.swift" - let selection = StoredSelection( - selectedPaths: [missingPath, unresolvedRelativePath], - autoCodemapPaths: [], - slices: [:], - codemapAutoEnabled: false - ) - - let resolution = await service.resolveEntries(selection: selection, store: store, codeMapUsage: .none) - - XCTAssertEqual(resolution.entries, []) - XCTAssertEqual(resolution.missingPaths, [unresolvedRelativePath, missingPath].sorted()) - XCTAssertEqual(resolution.invalidPaths, []) - } - - func testExpandedSelectedFolderFilesRemainRelativePathOrderedWithContents() async throws { - let root = try makeTemporaryRoot(name: "AccountingFolder") - try write("b", to: root.appendingPathComponent("Sources/B.swift")) - try write("a", to: root.appendingPathComponent("Sources/Nested/A.swift")) - try write("notes", to: root.appendingPathComponent("Sources/notes.txt")) - try write("outside", to: root.appendingPathComponent("Outside.swift")) - - let store = WorkspaceFileContextStore() - _ = try await store.loadRoot(path: root.path) - let expansion = await store.expandFolderInputToFiles("Sources", rootScope: .visibleWorkspace) - XCTAssertTrue(expansion.handled) - XCTAssertEqual(expansion.files.map(\.standardizedRelativePath), [ - "Sources/B.swift", - "Sources/Nested/A.swift", - "Sources/notes.txt" - ]) - - let service = PromptContextAccountingService() - let selection = StoredSelection( - selectedPaths: expansion.files.map(\.standardizedFullPath), - autoCodemapPaths: [], - slices: [:], - codemapAutoEnabled: false - ) - - let resolution = await service.resolveEntries(selection: selection, store: store, codeMapUsage: .none) - - XCTAssertEqual(resolution.entries.map(\.file.standardizedRelativePath), [ - "Sources/B.swift", - "Sources/Nested/A.swift", - "Sources/notes.txt" - ]) - XCTAssertEqual(resolution.entries.map(\.loadedContent), ["b", "a", "notes"]) - XCTAssertEqual(resolution.missingPaths, []) - XCTAssertEqual(resolution.invalidPaths, []) - } - - private func makeTemporaryRoot(name: String) throws -> URL { - let url = FileManager.default.temporaryDirectory - .appendingPathComponent("RepoPromptTests", isDirectory: true) - .appendingPathComponent("\(name)-\(UUID().uuidString)", isDirectory: true) - try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true) - temporaryRoots.append(url) - return url - } - - private func write(_ content: String, to url: URL) throws { - try FileManager.default.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true) - try content.write(to: url, atomically: true, encoding: .utf8) - } - - private func makeFileAPI(path: String) -> FileAPI { - FileAPI( - filePath: path, - imports: [], - classes: [], - functions: [ - FunctionInfo( - name: "codemapOnlySymbol", - parameters: [], - returnType: nil, - definitionLine: "func codemapOnlySymbol()", - lineNumber: 1 - ) - ], - enums: [], - globalVars: [], - macros: [], - referencedTypes: [] - ) - } -} diff --git a/Tests/RepoPromptTests/Prompt/PromptContextPreAssemblyServiceTests.swift b/Tests/RepoPromptTests/Prompt/PromptContextPreAssemblyServiceTests.swift index f353a37f8..8f6c2b8da 100644 --- a/Tests/RepoPromptTests/Prompt/PromptContextPreAssemblyServiceTests.swift +++ b/Tests/RepoPromptTests/Prompt/PromptContextPreAssemblyServiceTests.swift @@ -1,4 +1,5 @@ @testable import RepoPrompt +import RepoPromptCore import XCTest final class PromptContextPreAssemblyServiceTests: XCTestCase { @@ -124,7 +125,7 @@ final class PromptContextPreAssemblyServiceTests: XCTestCase { includeUserPrompt: false, filePathDisplay: .relative, codemapSnapshots: includeResult.codemapSnapshots, - promptSectionsOrder: PromptAssemblyBuilder.defaultSectionOrder, + promptSectionsOrder: PromptSection.allCases, disabledPromptSections: [], duplicateUserInstructionsAtTop: false ) @@ -139,7 +140,7 @@ final class PromptContextPreAssemblyServiceTests: XCTestCase { includeUserPrompt: false, filePathDisplay: .relative, codemapSnapshots: respectResult.codemapSnapshots, - promptSectionsOrder: PromptAssemblyBuilder.defaultSectionOrder, + promptSectionsOrder: PromptSection.allCases, disabledPromptSections: [], duplicateUserInstructionsAtTop: false ) diff --git a/Tests/RepoPromptTests/Prompt/PromptMigrationRemovalTests.swift b/Tests/RepoPromptTests/Prompt/PromptMigrationRemovalTests.swift index 52a7b1465..3dc222154 100644 --- a/Tests/RepoPromptTests/Prompt/PromptMigrationRemovalTests.swift +++ b/Tests/RepoPromptTests/Prompt/PromptMigrationRemovalTests.swift @@ -1,4 +1,5 @@ @testable import RepoPrompt +import RepoPromptCore import XCTest final class PromptMigrationRemovalTests: XCTestCase { @@ -56,6 +57,265 @@ final class PromptMigrationRemovalTests: XCTestCase { XCTAssertFalse(encoded.contains("includeMCPMetadata")) } + func testPromptSnapshotProjectionDelegatesReconstructionAndGuardsAsyncPublication() throws { + let root = try RepoRoot.url(filePath: #filePath) + let snapshotSource = try String( + contentsOf: root.appendingPathComponent( + "Sources/RepoPrompt/Features/Prompt/ViewModels/PromptViewModel+PromptSnapshotEntries.swift" + ), + encoding: .utf8 + ) + let viewModelSource = try String( + contentsOf: root.appendingPathComponent( + "Sources/RepoPrompt/Features/Prompt/ViewModels/PromptViewModel.swift" + ), + encoding: .utf8 + ) + let adapterSource = try String( + contentsOf: root.appendingPathComponent( + "Sources/RepoPrompt/Features/Prompt/Services/WorkspacePromptProjectionAdapter.swift" + ), + encoding: .utf8 + ) + + XCTAssertTrue(snapshotSource.contains("WorkspacePromptProjectionAdapter(store: workspaceFileContextStore)")) + XCTAssertTrue(snapshotSource.contains("chatPromptEntriesProjectionGeneration == generation")) + XCTAssertTrue(snapshotSource.contains("chatPromptEntriesRequest().key == request.key")) + XCTAssertTrue(adapterSource.contains("captureWorkspaceFileContext")) + XCTAssertTrue(adapterSource.contains("WorkspaceContextProjectionService")) + XCTAssertTrue(adapterSource.contains("sections: [.selection]")) + + for removedReconstruction in [ + "buildPromptSnapshotEntriesForCurrentChatProjection", + "fileManager.selectedFiles", + "fileManager.autoCodemapFiles", + "selectionSlicesByFileID", + "validatedCurrentFileAPIs", + "switch codeMapUsage" + ] { + XCTAssertFalse(snapshotSource.contains(removedReconstruction), removedReconstruction) + } + XCTAssertFalse(viewModelSource.contains("chatCodemapFileAPIs")) + XCTAssertFalse(viewModelSource.contains("refreshChatCodemapFileAPIsFromStore")) + XCTAssertFalse(adapterSource.contains("switch codeMapUsage")) + } + + func testPromptTokenEstimatesUseExactRenderedPayloadAndRemovedArithmeticCannotReturn() throws { + let root = try RepoRoot.url(filePath: #filePath) + let packagingSource = try String( + contentsOf: root.appendingPathComponent( + "Sources/RepoPrompt/Features/Prompt/Services/PromptPackagingService.swift" + ), + encoding: .utf8 + ) + let viewModelSource = try String( + contentsOf: root.appendingPathComponent( + "Sources/RepoPrompt/Features/Prompt/ViewModels/PromptViewModel.swift" + ), + encoding: .utf8 + ) + + XCTAssertTrue(packagingSource.contains("TokenProjectionService.exactRenderedPayload")) + XCTAssertTrue(packagingSource.contains("PromptGitDiffArtifactClassifier")) + XCTAssertTrue(packagingSource.contains("exactChatPayload")) + XCTAssertEqual(packagingSource.components(separatedBy: "rootFolderName = \"_git_data\"").count - 1, 1) + XCTAssertTrue(viewModelSource.contains("buildClipboardPayload")) + XCTAssertTrue(viewModelSource.contains("packagePromptResult")) + XCTAssertTrue(viewModelSource.contains("exactPayload.projection.total")) + + for removedTokenPath in [ + "Int(Double(text.count) / 4.0)", + "ChatContextTokenBaselineCache", + "baseTokensWithoutPromptText", + "supportsPromptTextDeltas", + "promptTextDuplicateFactor", + "chatContextTokenBaselineCacheKey" + ] { + XCTAssertFalse(viewModelSource.contains(removedTokenPath), removedTokenPath) + } + } + + func testCanonicalTokenProjectionOwnershipAndRecountDelegationCannotRegress() throws { + let root = try RepoRoot.url(filePath: #filePath) + let coreProjection = try String( + contentsOf: root.appendingPathComponent( + "Sources/RepoPromptCore/WorkspaceContext/Projection/TokenProjection.swift" + ), + encoding: .utf8 + ) + let coreService = try String( + contentsOf: root.appendingPathComponent( + "Sources/RepoPromptCore/WorkspaceContext/Projection/TokenProjectionService.swift" + ), + encoding: .utf8 + ) + let contextRequest = try String( + contentsOf: root.appendingPathComponent( + "Sources/RepoPromptCore/WorkspaceContext/Projection/WorkspaceContextProjection.swift" + ), + encoding: .utf8 + ) + let contextService = try String( + contentsOf: root.appendingPathComponent( + "Sources/RepoPromptCore/WorkspaceContext/Projection/WorkspaceContextProjectionService.swift" + ), + encoding: .utf8 + ) + let adapter = try String( + contentsOf: root.appendingPathComponent( + "Sources/RepoPrompt/Features/Prompt/Services/WorkspacePromptProjectionAdapter.swift" + ), + encoding: .utf8 + ) + let recount = try String( + contentsOf: root.appendingPathComponent( + "Sources/RepoPrompt/Features/Prompt/ViewModels/TokenCountingViewModel.swift" + ), + encoding: .utf8 + ) + + XCTAssertTrue(coreProjection.contains("package struct TokenProjection")) + XCTAssertTrue(coreService.contains("package enum TokenProjectionService")) + XCTAssertTrue(coreService.contains("activeLiveWorkspaceEstimates")) + XCTAssertTrue(contextRequest.contains("package enum WorkspaceTokenProjectionInput")) + XCTAssertTrue(contextService.contains("case let .activeLive(input)")) + XCTAssertTrue(contextService.contains("TokenProjectionService.activeLiveWorkspaceEstimates")) + XCTAssertTrue(adapter.contains("tokenProjectionInput: WorkspaceTokenProjectionInput")) + XCTAssertTrue(recount.contains("tokenProjectionInput: .activeLive")) + XCTAssertTrue(recount.contains(".virtualRecomputed")) + XCTAssertFalse(recount.contains("private let tokenCalculationService")) + XCTAssertFalse(recount.contains("normalizedTotal - normalizedFiles")) + XCTAssertFalse(adapter.contains("normalizedTotal - normalizedFiles")) + } + + func testStandardPromptConstructionDelegatesToCoreWithoutMigratingExcludedConsumers() throws { + let root = try RepoRoot.url(filePath: #filePath) + let aiMessageSource = try String( + contentsOf: root.appendingPathComponent("Sources/RepoPrompt/Infrastructure/AI/AIMessage.swift"), + encoding: .utf8 + ) + let packagingSource = try String( + contentsOf: root.appendingPathComponent( + "Sources/RepoPrompt/Features/Prompt/Services/PromptPackagingService.swift" + ), + encoding: .utf8 + ) + let viewModelSource = try String( + contentsOf: root.appendingPathComponent( + "Sources/RepoPrompt/Features/Prompt/ViewModels/PromptViewModel.swift" + ), + encoding: .utf8 + ) + let headlessPlanSource = try String( + contentsOf: root.appendingPathComponent( + "Sources/RepoPrompt/Features/Prompt/ViewModels/PromptViewModel+HeadlessPlan.swift" + ), + encoding: .utf8 + ) + + XCTAssertTrue(aiMessageSource.contains("enum TailAssemblyStrategy")) + XCTAssertTrue(aiMessageSource.contains("case legacy")) + XCTAssertTrue(aiMessageSource.contains("case coreStandardChat")) + XCTAssertTrue(aiMessageSource.contains("envelopePolicy: .chatStyleTree")) + XCTAssertTrue(aiMessageSource.contains("layout: .blankLineSeparatedFragments")) + XCTAssertTrue(aiMessageSource.contains("disabledPromptSections.union([.userInstructions])")) + XCTAssertTrue(aiMessageSource.contains("duplicateUserInstructionsAtTop: false")) + XCTAssertTrue(aiMessageSource.contains("return [tail, \"\", systemPrompt].joined(separator: \"\\n\\n\")")) + XCTAssertTrue(aiMessageSource.contains("private let renderedFactualSnippets: PromptRenderedFactualSnippets")) + XCTAssertEqual(aiMessageSource.components(separatedBy: "PromptRenderingService.renderFactualSnippets(").count - 1, 1) + for legacyProperty in ["systemPromptXML", "metaPromptsXML", "fileTreeXML", "fileBlocksXML", "gitDiffXML", "combinedXML"] { + XCTAssertTrue(aiMessageSource.contains("var \(legacyProperty): String"), legacyProperty) + } + + XCTAssertTrue(packagingSource.contains("tailAssemblyStrategy: AIMessage.TailAssemblyStrategy = .legacy")) + XCTAssertTrue(packagingSource.contains("tailAssemblyStrategy: tailAssemblyStrategy")) + XCTAssertTrue(packagingSource.contains("return AIMessage(\n systemPrompt: systemPrompt,\n userMessage: userMessage")) + XCTAssertTrue(packagingSource.contains("exactRenderedPayload(renderedChatPayload(for: message)")) + + XCTAssertEqual(viewModelSource.components(separatedBy: "tailAssemblyStrategy: .coreStandardChat").count - 1, 1) + XCTAssertTrue(viewModelSource.contains("exactChatPayload(for: message, source: tokenSource)")) + XCTAssertFalse(headlessPlanSource.contains("coreStandardChat")) + } + + func testProviderAwareAccountingFoundationRemainsNeutralAdditiveAndAppOwned() throws { + let root = try RepoRoot.url(filePath: #filePath) + let coreProjectionSource = try String( + contentsOf: root.appendingPathComponent( + "Sources/RepoPromptCore/WorkspaceContext/Projection/TokenProjection.swift" + ), + encoding: .utf8 + ) + let coreProjectionServiceSource = try String( + contentsOf: root.appendingPathComponent( + "Sources/RepoPromptCore/WorkspaceContext/Projection/TokenProjectionService.swift" + ), + encoding: .utf8 + ) + let projectionSource = try String( + contentsOf: root.appendingPathComponent( + "Sources/RepoPrompt/Infrastructure/AI/Models/AIProviderInputProjection.swift" + ), + encoding: .utf8 + ) + let aiMessageSource = try String( + contentsOf: root.appendingPathComponent("Sources/RepoPrompt/Infrastructure/AI/AIMessage.swift"), + encoding: .utf8 + ) + let providerFactorySource = try String( + contentsOf: root.appendingPathComponent( + "Sources/RepoPrompt/Infrastructure/AI/Providers/AIProviderFactory.swift" + ), + encoding: .utf8 + ) + let providerCapabilitySource = try String( + contentsOf: root.appendingPathComponent( + "Sources/RepoPrompt/Infrastructure/AI/Providers/AIProviderInputProjectionCapability.swift" + ), + encoding: .utf8 + ) + let queriesSource = try String( + contentsOf: root.appendingPathComponent( + "Sources/RepoPrompt/Infrastructure/AI/AIQueriesService.swift" + ), + encoding: .utf8 + ) + + XCTAssertTrue(coreProjectionSource.contains("case renderedPayloadEstimate")) + XCTAssertTrue(coreProjectionServiceSource.contains("package static func renderedPayloadEstimate")) + XCTAssertFalse(coreProjectionSource.contains("AIProviderInputProjection")) + XCTAssertFalse(coreProjectionServiceSource.contains("AIProviderInputProjection")) + + for declaration in [ + "struct AIProviderInputProjection", + "struct ChatInputTokenEstimate", + "enum AIProviderInputProjectionResolver" + ] { + XCTAssertTrue(projectionSource.contains(declaration), declaration) + } + XCTAssertTrue(projectionSource.contains("enum RouteResolution")) + XCTAssertTrue(projectionSource.contains("case providerResolved")) + XCTAssertTrue(projectionSource.contains("case providerRuntimeConfigurationRequired")) + XCTAssertTrue(projectionSource.contains("case providerProjectionUnavailable")) + XCTAssertTrue(projectionSource.contains("private init(")) + XCTAssertTrue(projectionSource.contains("fragments: fragments(for: input)")) + XCTAssertTrue(projectionSource.contains("TokenProjectionService.renderedPayloadEstimate")) + XCTAssertFalse(projectionSource.contains("TokenProjectionService.exactRenderedPayload")) + + XCTAssertTrue(aiMessageSource.contains("struct PreparedOpenAIChatInput")) + XCTAssertTrue(aiMessageSource.contains("struct PreparedOpenAIResponsesInput")) + XCTAssertTrue(aiMessageSource.contains("preparedOpenAIChatInput(embedSystemPrompt:")) + XCTAssertTrue(aiMessageSource.contains("let prepared = preparedOpenAIResponsesInput()")) + + XCTAssertTrue(providerFactorySource.contains("func streamMessageWithInputProjection(")) + XCTAssertTrue(providerCapabilitySource.contains("struct AIProviderStreamStart")) + XCTAssertEqual( + providerCapabilitySource.components(separatedBy: "func streamMessageWithInputProjection(").count - 1, + 1 + ) + XCTAssertTrue(providerCapabilitySource.contains("inputProjection: nil")) + XCTAssertFalse(queriesSource.contains("streamMessageWithInputProjection")) + } + func testLegacyCopyOverridesAndCustomizationsIgnoreRemovedFields() throws { let presetID = try XCTUnwrap(UUID(uuidString: "00000000-0000-0000-0000-000000000456")) let overridesRaw = """ diff --git a/Tests/RepoPromptTests/Prompt/PromptRenderingParityCharacterizationTests.swift b/Tests/RepoPromptTests/Prompt/PromptRenderingParityCharacterizationTests.swift new file mode 100644 index 000000000..2741dc6a1 --- /dev/null +++ b/Tests/RepoPromptTests/Prompt/PromptRenderingParityCharacterizationTests.swift @@ -0,0 +1,743 @@ +import Foundation +@testable import RepoPrompt +@testable import RepoPromptCore +import XCTest + +final class PromptRenderingParityCharacterizationTests: XCTestCase { + private let rootID = UUID(uuidString: "AAAAAAAA-AAAA-AAAA-AAAA-AAAAAAAAAAAA")! + private let rootPath = "/workspace/Alpha" + private let modificationDate = Date(timeIntervalSince1970: 1000) + + func testPromptSectionIdentityAndDefaultOrderRemainFrozen() { + XCTAssertEqual(PromptSection.allCases.map(\.rawValue), [ + "fileMap", + "fileContents", + "metaPrompts", + "userInstructions", + "gitDiff" + ]) + XCTAssertEqual(PromptAssemblyBuilder.defaultSectionOrder, [ + .fileMap, + .fileContents, + .gitDiff, + .metaPrompts, + .userInstructions + ]) + } + + func testPromptAssemblyFreezesDisabledSectionsSeparatorsTrailingNewlinesAndDuplicateUserPromptBehavior() { + let order: [PromptSection] = [.metaPrompts, .fileContents, .userInstructions, .fileMap, .gitDiff] + let snippets: [PromptSection: String] = [ + .fileMap: "MAP", + .fileContents: "FILES\n\n", + .metaPrompts: "META\n", + .userInstructions: "USER\n\n", + .gitDiff: "" + ] + + XCTAssertEqual( + PromptAssemblyBuilder.build( + order: order, + disabled: [.fileContents, .userInstructions], + duplicateUserInstructionsAtTop: false, + snippets: snippets + ), + "META\nMAP\n" + ) + XCTAssertEqual( + PromptAssemblyBuilder.build( + order: order, + disabled: [.fileContents, .userInstructions], + duplicateUserInstructionsAtTop: true, + snippets: snippets + ), + "USER\n\nMETA\nMAP\n" + ) + XCTAssertEqual( + PromptAssemblyBuilder.build( + order: order, + disabled: [.fileContents], + duplicateUserInstructionsAtTop: true, + snippets: snippets + ), + "USER\n\nMETA\nUSER\n\nMAP\n" + ) + } + + func testResolvedEntryRenderingFreezesFullSliceCodemapAndMissingCodemapFallbackOrdering() { + let full = makeEntry( + id: "BBBBBBBB-BBBB-BBBB-BBBB-BBBBBBBBBBBB", + relativePath: "Sources/Full.swift", + content: "struct Full {}\n" + ) + let sliced = makeEntry( + id: "CCCCCCCC-CCCC-CCCC-CCCC-CCCCCCCCCCCC", + relativePath: "Sources/Sliced.swift", + content: "one\ntwo\nthree\nfour\n", + isCodemap: false, + ranges: [ + LineRange(start: 3, end: 3, description: "third"), + LineRange(start: 1, end: 1) + ] + ) + let codemap = makeEntry( + id: "DDDDDDDD-DDDD-DDDD-DDDD-DDDDDDDDDDDD", + relativePath: "Sources/Structure.swift", + content: nil, + isCodemap: true + ) + let missingCodemap = makeEntry( + id: "EEEEEEEE-EEEE-EEEE-EEEE-EEEEEEEEEEEE", + relativePath: "Sources/MissingCodemap.swift", + content: "struct MissingCodemapFallback {}\n", + isCodemap: true + ) + let codemapSnapshot = makeCodemapSnapshot(for: codemap) + + let records = PromptPackagingService.generateFileBlocksDetailed( + files: [full, codemap, sliced, missingCodemap], + filePathDisplay: .full, + codemapSnapshots: [codemap.file.id: codemapSnapshot] + ) + + let expectedFull = "File: /workspace/Alpha/Sources/Full.swift\n```swift\nstruct Full {}\n\n```" + let expectedCodemap = "File: /workspace/Alpha/Sources/Structure.swift\nImports:\n---\n\nFunctions:\n - L7: func codemapOnlySymbol()\n---\n" + let expectedSlice = "File: /workspace/Alpha/Sources/Sliced.swift\n(lines 1)\n```swift\none\n\n```\n\n(lines 3: third)\n```swift\nthree\n\n```" + let expectedMissingCodemap = "File: /workspace/Alpha/Sources/MissingCodemap.swift\n```swift\nstruct MissingCodemapFallback {}\n\n```" + + XCTAssertEqual(records.map(\.file.relativePath), [ + "Sources/Full.swift", + "Sources/Structure.swift", + "Sources/Sliced.swift", + "Sources/MissingCodemap.swift" + ]) + XCTAssertEqual(records.map(\.isCodemap), [false, true, false, false]) + XCTAssertEqual(records.map(\.text), [ + expectedFull, + expectedCodemap, + expectedSlice, + expectedMissingCodemap + ]) + + let partitioned = PromptPackagingService.generatePartitionedFileBlocks( + [full, codemap, sliced, missingCodemap], + filePathDisplay: .full, + codemapSnapshots: [codemap.file.id: codemapSnapshot] + ) + XCTAssertEqual(partitioned.codemapBlocks, [expectedCodemap]) + XCTAssertEqual(partitioned.contentBlocks, [expectedFull, expectedSlice, expectedMissingCodemap]) + XCTAssertEqual(occurrences(of: "codemapOnlySymbol", in: partitioned.codemapBlocks.joined()), 1) + XCTAssertEqual(occurrences(of: "codemapOnlySymbol", in: partitioned.contentBlocks.joined()), 0) + XCTAssertEqual(occurrences(of: "MissingCodemapFallback", in: partitioned.codemapBlocks.joined()), 0) + XCTAssertEqual(occurrences(of: "MissingCodemapFallback", in: partitioned.contentBlocks.joined()), 1) + } + + func testResolvedAdapterPreservesMultiRootLabelsResolverPrecedenceAndOmittedEntryIdentity() throws { + let betaRootID = try XCTUnwrap(UUID(uuidString: "99999999-9999-9999-9999-999999999999")) + let alpha = makeEntry( + id: "22222222-2222-2222-2222-222222222222", + relativePath: "Sources/Alpha.swift", + content: "struct Alpha {}\n" + ) + let omitted = makeEntry( + id: "33333333-3333-3333-3333-333333333333", + relativePath: "Sources/Omitted.swift", + content: nil + ) + let beta = makeEntry( + id: "44444444-4444-4444-4444-444444444444", + relativePath: "Sources/Beta.swift", + content: "struct Beta {}\n", + rootID: betaRootID, + rootPath: "/workspace/Beta" + ) + + let records = PromptPackagingService.generateFileBlocksDetailed( + files: [alpha, omitted, beta], + filePathDisplay: .relative, + displayPathResolver: { entry in + entry.file.id == beta.file.id ? "override/Beta.swift" : nil + } + ) + + XCTAssertEqual(records.map(\.file.relativePath), ["Sources/Alpha.swift", "Sources/Beta.swift"]) + XCTAssertEqual(records.map(\.text), [ + "File: Alpha/Sources/Alpha.swift\n```swift\nstruct Alpha {}\n\n```", + "File: override/Beta.swift\n```swift\nstruct Beta {}\n\n```" + ]) + + let coreBlocks = PromptRenderingService.renderFileBlocks([ + PromptRenderingFileValue( + displayPath: "Alpha/Sources/Alpha.swift", + fileName: alpha.file.name, + content: alpha.loadedContent + ), + PromptRenderingFileValue( + displayPath: "Sources/Omitted.swift", + fileName: omitted.file.name, + content: omitted.loadedContent + ), + PromptRenderingFileValue( + displayPath: "override/Beta.swift", + fileName: beta.file.name, + content: beta.loadedContent + ) + ]) + XCTAssertEqual(records.map(\.text), coreBlocks.map(\.text)) + } + + @MainActor + func testPromptFileEntryAdapterPreservesAsyncSlicesMultiRootLabelsAndCodemapProjection() async { + let alpha = makeFileViewModel( + rootPath: "/workspace/Alpha", + relativePath: "Sources/Alpha.swift", + content: "one\ntwo\nthree\n" + ) + let beta = makeFileViewModel( + rootPath: "/workspace/Beta", + relativePath: "Sources/Beta.swift", + content: "struct BetaFullContentMustNotRender {}\n" + ) + beta.setCodeMap(makeFileAPI(path: beta.fullPath, symbol: "betaCodemapSymbol")) + + let records = await PromptPackagingService.generateFileBlocksDetailed( + files: [ + PromptFileEntry( + file: alpha, + isCodemap: false, + ranges: [LineRange(start: 2, end: 2, description: "middle")] + ), + PromptFileEntry(file: beta, isCodemap: true, ranges: nil) + ], + filePathDisplay: .relative + ) + + XCTAssertEqual(records.map(\.file.id), [alpha.id, beta.id]) + XCTAssertEqual(records.map(\.isCodemap), [false, true]) + XCTAssertEqual( + records[0].text, + "File: Alpha/Sources/Alpha.swift\n(lines 2: middle)\n```swift\ntwo\n\n```" + ) + XCTAssertTrue(records[1].text.contains("File: Beta/Sources/Beta.swift"), records[1].text) + XCTAssertTrue(records[1].text.contains("betaCodemapSymbol"), records[1].text) + XCTAssertFalse(records[1].text.contains("BetaFullContentMustNotRender"), records[1].text) + } + + func testResolvedClipboardPackagingFreezesDiffArtifactOrderingAndNonDuplication() async { + let full = makeEntry( + id: "BBBBBBBB-BBBB-BBBB-BBBB-BBBBBBBBBBBB", + relativePath: "Sources/Full.swift", + content: "struct Full {}\n" + ) + let diffOne = makeEntry( + id: "FFFFFFFF-FFFF-FFFF-FFFF-FFFFFFFFFFFF", + relativePath: "_git_data/repos/demo/2026-06-05/diff/first.patch", + content: "PATCH-ONE" + ) + let codemap = makeEntry( + id: "DDDDDDDD-DDDD-DDDD-DDDD-DDDDDDDDDDDD", + relativePath: "Sources/Structure.swift", + content: nil, + isCodemap: true + ) + let sliced = makeEntry( + id: "CCCCCCCC-CCCC-CCCC-CCCC-CCCCCCCCCCCC", + relativePath: "Sources/Sliced.swift", + content: "one\ntwo\nthree\nfour\n", + ranges: [ + LineRange(start: 3, end: 3, description: "third"), + LineRange(start: 1, end: 1) + ] + ) + let diffTwo = makeEntry( + id: "11111111-1111-1111-1111-111111111111", + relativePath: "_git_data/repos/demo/2026-06-05/diffs/second.DIFF", + content: "PATCH-TWO" + ) + let missingCodemap = makeEntry( + id: "EEEEEEEE-EEEE-EEEE-EEEE-EEEEEEEEEEEE", + relativePath: "Sources/MissingCodemap.swift", + content: "struct MissingCodemapFallback {}\n", + isCodemap: true + ) + let entries = [full, diffOne, codemap, sliced, diffTwo, missingCodemap] + let codemapSnapshot = makeCodemapSnapshot(for: codemap) + + let partitioned = PromptPackagingService.partitionPromptEntriesForGitDiff(entries) + XCTAssertEqual(partitioned.diffEntries.map(\.file.relativePath), [ + "_git_data/repos/demo/2026-06-05/diff/first.patch", + "_git_data/repos/demo/2026-06-05/diffs/second.DIFF" + ]) + XCTAssertEqual(partitioned.codeEntries.map(\.file.relativePath), [ + "Sources/Full.swift", + "Sources/Structure.swift", + "Sources/Sliced.swift", + "Sources/MissingCodemap.swift" + ]) + XCTAssertEqual(PromptPackagingService.selectedGitDiffText(fromDiffEntries: partitioned.diffEntries), "PATCH-ONE\n\nPATCH-TWO") + let resolvedDiff = await PromptPackagingService.resolveGitDiff(fromDiffEntries: partitioned.diffEntries) { + "GENERATED-FALLBACK" + } + XCTAssertEqual(resolvedDiff, "PATCH-ONE\n\nPATCH-TWO") + + let content = await PromptPackagingService.generateClipboardContent( + metaInstructions: [], + userInstructions: "Ship it", + files: entries, + fileTreeContent: "ROOT TREE", + gitDiff: "GENERATED-FALLBACK", + includeSavedPrompts: true, + includeFiles: true, + includeUserPrompt: true, + filePathDisplay: .full, + codemapSnapshots: [codemap.file.id: codemapSnapshot], + promptSectionsOrder: PromptAssemblyBuilder.defaultSectionOrder, + disabledPromptSections: [], + duplicateUserInstructionsAtTop: false + ) + + let expectedFull = "File: /workspace/Alpha/Sources/Full.swift\n```swift\nstruct Full {}\n\n```" + let expectedCodemap = "File: /workspace/Alpha/Sources/Structure.swift\nImports:\n---\n\nFunctions:\n - L7: func codemapOnlySymbol()\n---\n" + let expectedSlice = "File: /workspace/Alpha/Sources/Sliced.swift\n(lines 1)\n```swift\none\n\n```\n\n(lines 3: third)\n```swift\nthree\n\n```" + let expectedMissingCodemap = "File: /workspace/Alpha/Sources/MissingCodemap.swift\n```swift\nstruct MissingCodemapFallback {}\n\n```" + let expected = "\nROOT TREE\n\n\(expectedCodemap)\n\n" + + "\n\(expectedFull)\n\n\(expectedSlice)\n\n\(expectedMissingCodemap)\n\n" + + "\nPATCH-ONE\n\nPATCH-TWO\n\n" + + "\nShip it\n\n" + + XCTAssertEqual(content, expected) + XCTAssertEqual(occurrences(of: "codemapOnlySymbol", in: content), 1) + XCTAssertEqual(occurrences(of: "struct Full", in: content), 1) + XCTAssertEqual(occurrences(of: "MissingCodemapFallback", in: content), 1) + XCTAssertEqual(occurrences(of: "PATCH-ONE", in: content), 1) + XCTAssertEqual(occurrences(of: "PATCH-TWO", in: content), 1) + XCTAssertEqual(occurrences(of: "GENERATED-FALLBACK", in: content), 0) + XCTAssertEqual(occurrences(of: "_git_data/", in: content), 0) + + let exactPayload = PromptPackagingService.exactRenderedPayload(content, source: .immutableSnapshot) + XCTAssertEqual(exactPayload.text, expected) + XCTAssertEqual(exactPayload.projection.provenance.view, .userConfigured) + XCTAssertEqual(exactPayload.projection.provenance.scope, .export) + XCTAssertEqual(exactPayload.projection.provenance.source, .immutableSnapshot) + XCTAssertEqual(exactPayload.projection.provenance.basis, .exactRenderedPayload) + XCTAssertEqual(exactPayload.projection.components, .init()) + XCTAssertEqual( + exactPayload.projection.total, + TokenCalculationService.estimateTokens(for: expected) + ) + } + + func testStandardAppPromptPayloadGoldensCaptureCurrentOwnershipBeforeMigration() async throws { + let file = makeEntry( + id: "77777777-7777-7777-7777-777777777777", + relativePath: "Sources/App.swift", + content: "print(\"hi\")\n" + ) + let clipboard = await PromptPackagingService.generateClipboardContent( + metaInstructions: [MetaInstruction(title: "Rules", content: "META")], + userInstructions: "FINAL", + files: [file], + fileTreeContent: "TREE", + gitDiff: "DIFF", + includeSavedPrompts: true, + includeFiles: true, + includeUserPrompt: true, + filePathDisplay: .relative, + promptSectionsOrder: PromptAssemblyBuilder.defaultSectionOrder, + disabledPromptSections: [], + duplicateUserInstructionsAtTop: false + ) + let expectedClipboard = """ + + TREE + + + File: Sources/App.swift + ```swift + print("hi") + + ``` + + + DIFF + + + META + + + FINAL + + + """ + XCTAssertEqual(clipboard, expectedClipboard) + let exactClipboard = PromptPackagingService.exactRenderedPayload(clipboard, source: .immutableSnapshot) + XCTAssertEqual(Array(exactClipboard.text.utf8), Array(expectedClipboard.utf8)) + XCTAssertEqual( + exactClipboard.projection.total, + TokenCalculationService.estimateTokens(for: expectedClipboard) + ) + + let message = PromptPackagingService.buildAIMessage( + systemPrompt: "SYSTEM", + metaInstructions: [MetaInstruction(title: "Rules", content: "META")], + fileTree: "TREE", + fileContents: ["File: Sources/App.swift\n```swift\nprint(\"hi\")\n```"], + gitDiff: "DIFF", + conversation: [ + ConversationEntry(role: .user, content: "EARLY"), + ConversationEntry(role: .assistant, content: "ASSISTANT"), + ConversationEntry(role: .user, content: "FINAL") + ], + temperature: nil, + promptSectionsOrder: PromptAssemblyBuilder.defaultSectionOrder, + disabledPromptSections: [], + duplicateUserInstructionsAtTop: false, + tailAssemblyStrategy: .coreStandardChat + ) + let expectedTail = """ + + TREE + + + + File: Sources/App.swift + ```swift + print("hi") + ``` + + + + + DIFF + + + + META + + """ + let expectedFinalUser = """ + + FINAL + + """ + XCTAssertEqual(message.buildTail(embedSystemPrompt: false), expectedTail) + XCTAssertEqual(message.fileTreeXML, "\nTREE\n") + XCTAssertEqual( + message.fileBlocksXML, + "\nFile: Sources/App.swift\n```swift\nprint(\"hi\")\n```\n\n" + ) + XCTAssertEqual(message.gitDiffXML, "\nDIFF\n") + XCTAssertEqual( + message.combinedXML, + "\nSYSTEM\n\n\n\n\nMETA\n\n\n\n\n\nTREE\n\n\n\nFile: Sources/App.swift\n```swift\nprint(\"hi\")\n```\n\n\n\n\nDIFF\n" + ) + + let coreFactual = PromptRenderingService.renderFactualSnippets( + fileTreeContent: "TREE", + codemapBlocks: [], + contentBlocks: ["File: Sources/App.swift\n```swift\nprint(\"hi\")\n```"], + gitDiff: "DIFF", + envelopePolicy: .chatStyleTree + ) + XCTAssertEqual( + try PromptAssemblyBuilder.build( + order: PromptAssemblyBuilder.defaultSectionOrder, + disabled: [], + duplicateUserInstructionsAtTop: false, + snippets: [ + .fileMap: XCTUnwrap(coreFactual.fileMap), + .fileContents: XCTUnwrap(coreFactual.fileContents), + .gitDiff: XCTUnwrap(coreFactual.gitDiff), + .metaPrompts: "\nMETA\n" + ], + layout: .blankLineSeparatedFragments + ), + expectedTail + ) + + let chatMessages = message.openAIChatMessages(embedSystemPrompt: false) + let chatRoleNames = chatMessages.map { String(describing: $0.role) } + XCTAssertEqual(chatRoleNames, ["system", "user", "assistant", "user"]) + let finalChatText: String? = if let finalChatMessage = chatMessages.last { + switch finalChatMessage.content { + case let .text(text): + text + case let .contentArray(items): + items.compactMap { item in + if case let .text(text) = item { return text } + return nil + }.joined() + } + } else { + nil + } + XCTAssertEqual(finalChatText, expectedTail + "\n" + expectedFinalUser) + + let responseMessages: [(role: String, text: String)] = switch message.openAIResponsesInput() { + case let .array(items): + items.compactMap { item in + guard case let .message(message) = item else { return nil } + guard case let .text(text) = message.content else { + return nil + } + return (role: message.role, text: text) + } + default: + [] + } + XCTAssertEqual(responseMessages.map(\.role), ["user", "assistant", "user"]) + XCTAssertEqual(responseMessages.first?.text, expectedTail + "\n\nEARLY") + XCTAssertEqual(responseMessages.last?.text, expectedFinalUser) + + let exactChat = PromptPackagingService.exactChatPayload(for: message, source: .activeLive) + let expectedExactChatBytes = Array( + ("SYSTEM" + "EARLY" + "ASSISTANT" + expectedTail + "\n" + expectedFinalUser).utf8 + ) + XCTAssertEqual(Array(exactChat.text.utf8), expectedExactChatBytes) + } + + func testCompleteAlternateCodemapCandidatesMatchPromptAccountingEligibility() async throws { + let root = try makeTemporaryRoot(name: "CompleteAlternateParity") + defer { try? FileManager.default.removeItem(at: root) } + + let selectedURL = root.appendingPathComponent("Selected.swift") + let autoURL = root.appendingPathComponent("Auto.swift") + let completeOnlyURL = root.appendingPathComponent("CompleteOnly.swift") + try write("selected content", to: selectedURL) + try write("auto content", to: autoURL) + try write("complete content", to: completeOnlyURL) + + let store = WorkspaceFileContextStore() + _ = try await store.loadRoot(path: root.path) + await store.applyObservedCodemapResults([ + WorkspaceObservedCodemapResult( + fullPath: selectedURL.path, + modificationDate: modificationDate, + fileAPI: makeFileAPI(path: selectedURL.path, symbol: "selectedSymbol") + ), + WorkspaceObservedCodemapResult( + fullPath: autoURL.path, + modificationDate: modificationDate, + fileAPI: makeFileAPI(path: autoURL.path, symbol: "autoSymbol") + ), + WorkspaceObservedCodemapResult( + fullPath: completeOnlyURL.path, + modificationDate: modificationDate, + fileAPI: makeFileAPI(path: completeOnlyURL.path, symbol: "completeOnlySymbol") + ) + ]) + let selection = StoredSelection( + selectedPaths: [selectedURL.path], + autoCodemapPaths: [autoURL.path], + codemapAutoEnabled: true + ) + + let accounting = try await RepoPromptCore.PromptContextAccountingService().resolveEntries( + selection: selection, + store: store, + codeMapUsage: .complete + ) + XCTAssertEqual( + Set(accounting.entries.filter(\.isCodemap).map(\.file.standardizedRelativePath)), + Set(["Auto.swift", "CompleteOnly.swift"]) + ) + + let capture = try await store.captureWorkspaceFileContext( + selection: selection, + fileTreeRequest: WorkspaceFileTreeSnapshotRequest( + mode: .none, + filePathDisplay: .relative, + onlyIncludeRootsWithSelectedFiles: false, + includeLegend: false, + showCodeMapMarkers: false, + rootScope: .allLoaded + ), + profile: .uiAssisted + ) + let codemapsByFileID = Dictionary(uniqueKeysWithValues: capture.codemapSnapshots.map { ($0.fileID, $0) }) + let selectedRecord = try XCTUnwrap(capture.materializedFiles.first { + $0.standardizedRelativePath == "Selected.swift" + }) + let selectedCodemapTokens = try XCTUnwrap(codemapsByFileID[selectedRecord.id]?.fileAPI?.apiTokenCount) + let accountingCodemapTokens = try accounting.entries.filter(\.isCodemap).reduce(into: 0) { total, entry in + total += try XCTUnwrap(codemapsByFileID[entry.file.id]?.fileAPI?.apiTokenCount) + } + + let normalizedAccounting = try await RepoPromptCore.PromptContextAccountingService().calculatePromptStats( + request: PromptContextAccountingRequest( + selection: selection, + codeMapUsage: .auto, + filePathDisplay: .relative + ), + store: store + ) + let projection = try await WorkspacePromptProjectionAdapter(store: store).projectTokens( + selection: selection, + codeMapUsage: .auto, + filePathDisplay: .relative, + alternatePolicy: .init(includeFiles: true, codeMapUsage: .complete), + resolvedEntries: normalizedAccounting.resolvedEntries, + promptFileEntrySnapshots: normalizedAccounting.promptFileEntrySnapshots, + tokenProjectionInput: .activeLive(.init( + reportedTotal: normalizedAccounting.tokenResult.totalTokenCount, + prompt: 0, + fileTree: 0, + meta: 0, + git: 0 + )) + ) + + XCTAssertEqual(projection.selection.files.map(\.file.standardizedRelativePath), [ + "Selected.swift", + "Auto.swift" + ]) + XCTAssertEqual( + projection.selection.alternate?.codemapTokens, + selectedCodemapTokens + accountingCodemapTokens + ) + XCTAssertEqual( + projection.tokens.userConfigured?.components.codemaps, + selectedCodemapTokens + accountingCodemapTokens + ) + } + + private func makeEntry( + id: String, + relativePath: String, + content: String?, + isCodemap: Bool = false, + ranges: [LineRange]? = nil, + rootID: UUID? = nil, + rootPath: String? = nil + ) -> ResolvedPromptFileEntry { + let fileID = UUID(uuidString: id)! + let entryRootID = rootID ?? self.rootID + let entryRootPath = rootPath ?? self.rootPath + let file = WorkspaceFileRecord( + id: fileID, + rootID: entryRootID, + name: (relativePath as NSString).lastPathComponent, + relativePath: relativePath, + fullPath: "\(entryRootPath)/\(relativePath)", + parentFolderID: nil, + modificationDate: modificationDate + ) + return ResolvedPromptFileEntry( + file: file, + isCodemap: isCodemap, + lineRanges: ranges, + mode: ranges == nil ? (isCodemap ? .codemap : .fullFile) : .sliced, + loadedContent: content, + rootFolderPath: entryRootPath + ) + } + + private func makeFileViewModel( + rootPath: String, + relativePath: String, + content: String + ) -> FileViewModel { + let fullPath = "\(rootPath)/\(relativePath)" + return FileViewModel( + file: File( + name: (relativePath as NSString).lastPathComponent, + path: fullPath, + modificationDate: modificationDate + ), + rootPath: rootPath, + rootIdentifier: UUID(), + rootFolderPath: rootPath, + fileSystemService: nil, + relativePathOverride: relativePath, + contentProvider: PromptRenderingContentProvider( + content: content, + modificationDate: modificationDate, + fullPath: fullPath + ) + ) + } + + private func makeFileAPI(path: String, symbol: String) -> FileAPI { + FileAPI( + filePath: path, + imports: [], + classes: [], + functions: [ + FunctionInfo( + name: symbol, + parameters: [], + returnType: nil, + definitionLine: "func \(symbol)()", + lineNumber: 7 + ) + ], + enums: [], + globalVars: [], + macros: [], + referencedTypes: [] + ) + } + + private func makeCodemapSnapshot(for entry: ResolvedPromptFileEntry) -> WorkspaceCodemapSnapshot { + WorkspaceCodemapSnapshot( + fileID: entry.file.id, + rootID: entry.file.rootID, + rootPath: rootPath, + relativePath: entry.file.relativePath, + fullPath: entry.file.fullPath, + modificationDate: modificationDate, + fileAPI: makeFileAPI(path: entry.file.fullPath, symbol: "codemapOnlySymbol") + ) + } + + private func makeTemporaryRoot(name: String) throws -> URL { + let url = FileManager.default.temporaryDirectory + .appendingPathComponent("RepoPromptTests", isDirectory: true) + .appendingPathComponent("\(name)-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true) + return url + } + + private func write(_ content: String, to url: URL) throws { + try FileManager.default.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true) + try content.write(to: url, atomically: true, encoding: .utf8) + } + + private func occurrences(of needle: String, in haystack: String) -> Int { + haystack.components(separatedBy: needle).count - 1 + } +} + +private final class PromptRenderingContentProvider: FileViewModelContentProvider, @unchecked Sendable { + private let content: String + private let storedModificationDate: Date + private let fullPath: String + + init(content: String, modificationDate: Date, fullPath: String) { + self.content = content + storedModificationDate = modificationDate + self.fullPath = fullPath + } + + func loadContentWithDate() async throws -> (content: String?, modificationDate: Date) { + (content, storedModificationDate) + } + + func regularFileExistsOnDisk() async -> Bool { + true + } + + func modificationDate() async throws -> Date { + storedModificationDate + } + + func fullPathForReveal() async -> String { + fullPath + } + + func editContent(_ newContent: String) async throws {} + + func move(toRelativePath newRelativePath: String) async throws {} + + func moveToTrash() async throws {} +} diff --git a/Tests/RepoPromptTests/Prompt/PromptTokenEstimateParityTests.swift b/Tests/RepoPromptTests/Prompt/PromptTokenEstimateParityTests.swift new file mode 100644 index 000000000..61b48ff22 --- /dev/null +++ b/Tests/RepoPromptTests/Prompt/PromptTokenEstimateParityTests.swift @@ -0,0 +1,273 @@ +import Foundation +@testable import RepoPrompt +@testable import RepoPromptCore +import XCTest + +final class PromptTokenEstimateParityTests: XCTestCase { + private let rootID = UUID(uuidString: "AAAAAAAA-AAAA-AAAA-AAAA-AAAAAAAAAAAA")! + private let modificationDate = Date(timeIntervalSince1970: 1000) + + func testClipboardExactProjectionCountsTitleDatetimeAndIntentionalDuplicationFromRenderedBytes() async { + let renderingDate = Date(timeIntervalSince1970: 1_700_000_000) + let formatter = DateFormatter() + formatter.dateFormat = "yyyy-MM-dd'T'HH:mm" + let dateString = formatter.string(from: renderingDate) + let files: [ResolvedPromptFileEntry] = [] + + let text = await PromptPackagingService.generateClipboardContent( + metaInstructions: [MetaInstruction(title: "Rules", content: "Be exact")], + userInstructions: "Ship it", + files: files, + fileTreeContent: nil, + includeSavedPrompts: true, + includeFiles: true, + includeUserPrompt: true, + filePathDisplay: .full, + includeDatetimeInUserInstructions: true, + renderingDate: renderingDate, + promptSectionsOrder: PromptAssemblyBuilder.defaultSectionOrder, + disabledPromptSections: [.userInstructions], + duplicateUserInstructionsAtTop: true, + tabTitle: "Plan & " + ) + let payload = PromptPackagingService.exactRenderedPayload(text, source: .immutableSnapshot) + + XCTAssertTrue(text.hasPrefix("\nPlan & <Review>\n\n"), text) + XCTAssertTrue(text.contains(""), text) + XCTAssertEqual(occurrences(of: "Ship it", in: text), 1) + XCTAssertEqual(occurrences(of: "Be exact", in: text), 1) + assertExactProjection(payload.projection, text: text, source: .immutableSnapshot) + } + + func testGenericTitleIsOmittedFromExactClipboardPayload() async { + let files: [ResolvedPromptFileEntry] = [] + let text = await PromptPackagingService.generateClipboardContent( + metaInstructions: [], + userInstructions: "Prompt", + files: files, + fileTreeContent: nil, + includeSavedPrompts: false, + includeFiles: false, + includeUserPrompt: true, + filePathDisplay: .full, + promptSectionsOrder: PromptAssemblyBuilder.defaultSectionOrder, + disabledPromptSections: [], + duplicateUserInstructionsAtTop: false, + tabTitle: "T42" + ) + let payload = PromptPackagingService.exactRenderedPayload(text, source: .virtualRecomputed) + + XCTAssertFalse(text.contains(""), text) + assertExactProjection(payload.projection, text: text, source: .virtualRecomputed) + } + + func testSelectedGitArtifactUsesSingleClassifierSuppressesFallbackAndCountsRenderedBlocksOnce() async { + let full = makeEntry(relativePath: "Sources/Full.swift", content: "struct Full {}\n") + let diff = makeEntry( + relativePath: "_git_data/repos/demo/2026-06-05/diff/all.patch", + content: "PATCH-CONTENT" + ) + let entries = [full, diff] + + XCTAssertTrue(PromptGitDiffArtifactClassifier.isDiffArtifactPath(diff.file.fullPath)) + XCTAssertTrue(PromptGitDiffArtifactClassifier.isDiffArtifactPath("/workspace/_git_data/repos/demo/diffs/ALL.DIFF")) + XCTAssertFalse(PromptGitDiffArtifactClassifier.isDiffArtifactPath("/workspace/_git_data/repos/demo/index/map.txt")) + XCTAssertFalse(PromptGitDiffArtifactClassifier.isDiffArtifactPath("/workspace/Sources/change.patch")) + + let text = await PromptPackagingService.generateClipboardContent( + metaInstructions: [], + userInstructions: "Review", + files: entries, + fileTreeContent: nil, + gitDiff: "GENERATED-FALLBACK", + includeSavedPrompts: false, + includeFiles: true, + includeUserPrompt: true, + filePathDisplay: .full, + promptSectionsOrder: PromptAssemblyBuilder.defaultSectionOrder, + disabledPromptSections: [], + duplicateUserInstructionsAtTop: false + ) + let payload = PromptPackagingService.exactRenderedPayload(text, source: .immutableSnapshot) + + XCTAssertEqual(occurrences(of: "PATCH-CONTENT", in: text), 1) + XCTAssertEqual(occurrences(of: "GENERATED-FALLBACK", in: text), 0) + XCTAssertEqual(occurrences(of: "struct Full", in: text), 1) + XCTAssertEqual(occurrences(of: "_git_data/", in: text), 0) + assertExactProjection(payload.projection, text: text, source: .immutableSnapshot) + } + + func testCanonicalChatPayloadCountsEachMessageContentAndWrapperExactlyAsPackaged() { + let message = PromptPackagingService.buildAIMessage( + systemPrompt: "SYSTEM", + metaInstructions: [MetaInstruction(title: "Meta", content: "META-CONTENT")], + fileTree: "TREE-CONTENT", + fileContents: ["FILE-CONTENT"], + gitDiff: "DIFF-CONTENT", + conversation: [ + ConversationEntry(role: .user, content: "EARLY-PROMPT-CONTENT"), + ConversationEntry(role: .assistant, content: "HISTORY-CONTENT"), + ConversationEntry(role: .user, content: "USER-CONTENT") + ], + temperature: nil, + promptSectionsOrder: PromptAssemblyBuilder.defaultSectionOrder, + disabledPromptSections: [], + duplicateUserInstructionsAtTop: true, + tailAssemblyStrategy: .coreStandardChat + ) + let payload = PromptPackagingService.exactChatPayload(for: message, source: .activeLive) + + XCTAssertEqual(payload.text, flattenedOpenAIChatPayload(for: message)) + XCTAssertEqual(occurrences(of: "SYSTEM", in: payload.text), 1) + XCTAssertEqual(occurrences(of: "EARLY-PROMPT-CONTENT", in: payload.text), 1) + XCTAssertEqual(occurrences(of: "HISTORY-CONTENT", in: payload.text), 1) + XCTAssertEqual(occurrences(of: "TREE-CONTENT", in: payload.text), 1) + XCTAssertEqual(occurrences(of: "FILE-CONTENT", in: payload.text), 1) + XCTAssertEqual(occurrences(of: "DIFF-CONTENT", in: payload.text), 1) + XCTAssertEqual(occurrences(of: "META-CONTENT", in: payload.text), 1) + XCTAssertEqual(occurrences(of: "USER-CONTENT", in: payload.text), 2) + assertExactProjection(payload.projection, text: payload.text, source: .activeLive) + } + + func testCanonicalChatPayloadMatchesTransportWhenConversationHasNoUser() { + let message = PromptPackagingService.buildAIMessage( + systemPrompt: "SYSTEM", + metaInstructions: [MetaInstruction(title: "Meta", content: "UNEMITTED-META")], + fileTree: "UNEMITTED-TREE", + fileContents: ["UNEMITTED-FILE"], + gitDiff: "UNEMITTED-DIFF", + conversation: [ConversationEntry(role: .assistant, content: "ASSISTANT-ONLY")], + temperature: nil, + promptSectionsOrder: PromptAssemblyBuilder.defaultSectionOrder, + disabledPromptSections: [], + duplicateUserInstructionsAtTop: true, + tailAssemblyStrategy: .coreStandardChat + ) + let payload = PromptPackagingService.exactChatPayload(for: message, source: .immutableSnapshot) + + XCTAssertEqual(payload.text, flattenedOpenAIChatPayload(for: message)) + XCTAssertEqual(payload.text, "SYSTEMASSISTANT-ONLY") + XCTAssertFalse(payload.text.contains("UNEMITTED"), payload.text) + assertExactProjection(payload.projection, text: payload.text, source: .immutableSnapshot) + } + + @MainActor + func testSelectedGitArtifactCountsAsFileAndSuppressesGeneratedGitComponent() async throws { + let rootURL = FileManager.default.temporaryDirectory + .appendingPathComponent("RepoPromptTests", isDirectory: true) + .appendingPathComponent("PromptTokenArtifact-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: rootURL) } + + let artifactURL = rootURL + .appendingPathComponent("_git_data/repos/demo/2026-06-05/diff", isDirectory: true) + .appendingPathComponent("all.patch") + try FileManager.default.createDirectory( + at: artifactURL.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + let artifactContent = "PATCH-CONTENT\n" + try artifactContent.write(to: artifactURL, atomically: true, encoding: .utf8) + + let store = WorkspaceFileContextStore() + _ = try await store.loadRoot(path: rootURL.path) + let fileManager = WorkspaceFilesViewModel(workspaceFileContextStore: store) + let gitViewModel = GitViewModel(fileManager: fileManager) + let selection = StoredSelection(selectedPaths: [artifactURL.path]) + let viewModel = TokenCountingViewModel() + viewModel.configure( + fileManager: fileManager, + gitViewModel: gitViewModel, + getPromptText: { "" }, + getSelectedInstructionsText: { "" }, + getSettings: { + TokenCountingViewModel.TokenCalculationSettings( + fileTreeOption: .none, + codeMapUsage: .none, + filePathDisplayOption: .relative, + includeFilesInClipboard: true, + duplicateUserInstructionsAtTop: false, + onlyIncludeRootsWithSelectedFiles: false, + codeMapsGloballyDisabled: false + ) + }, + getCopyContext: { + TokenCountingViewModel.CopyContextSnapshot( + includeFiles: true, + includeUserPrompt: false, + includeMetaPrompts: false, + includeFileTree: false, + fileTreeMode: .none, + codeMapUsage: .none, + gitInclusion: .complete, + duplicateUserInstructionsAtTop: false + ) + }, + getStoredSelection: { selection } + ) + viewModel.suspendAutomaticRecounts() + + await viewModel.forceImmediateRecount() + let breakdown = viewModel.latestTokenBreakdown() + + XCTAssertEqual(breakdown.files, TokenCalculationService.estimateTokens(for: artifactContent)) + XCTAssertEqual(breakdown.git, 0) + XCTAssertEqual(breakdown.total, breakdown.files + breakdown.other) + await viewModel.stopTokenCountUpdateTimer() + } + + private func makeEntry(relativePath: String, content: String?) -> ResolvedPromptFileEntry { + let rootPath = "/workspace/Alpha" + let file = WorkspaceFileRecord( + id: UUID(), + rootID: rootID, + name: (relativePath as NSString).lastPathComponent, + relativePath: relativePath, + fullPath: "\(rootPath)/\(relativePath)", + parentFolderID: nil, + modificationDate: modificationDate + ) + return ResolvedPromptFileEntry( + file: file, + loadedContent: content, + rootFolderPath: rootPath + ) + } + + private func flattenedOpenAIChatPayload(for message: AIMessage) -> String { + message.openAIChatMessages(embedSystemPrompt: false).map { transportMessage in + switch transportMessage.content { + case let .text(text): + text + case let .contentArray(items): + items.compactMap { item in + if case let .text(text) = item { return text } + return nil + }.joined() + } + }.joined() + } + + private func assertExactProjection( + _ projection: TokenProjection, + text: String, + source: TokenProjection.Source, + file: StaticString = #filePath, + line: UInt = #line + ) { + XCTAssertEqual(projection.provenance.view, .userConfigured, file: file, line: line) + XCTAssertEqual(projection.provenance.scope, .export, file: file, line: line) + XCTAssertEqual(projection.provenance.source, source, file: file, line: line) + XCTAssertEqual(projection.provenance.basis, .exactRenderedPayload, file: file, line: line) + XCTAssertEqual(projection.components, .init(), file: file, line: line) + XCTAssertEqual( + projection.total, + TokenCalculationService.estimateTokens(for: text), + file: file, + line: line + ) + } + + private func occurrences(of needle: String, in haystack: String) -> Int { + haystack.components(separatedBy: needle).count - 1 + } +} diff --git a/Tests/RepoPromptTests/Prompt/TokenCountingViewModelProjectionTests.swift b/Tests/RepoPromptTests/Prompt/TokenCountingViewModelProjectionTests.swift new file mode 100644 index 000000000..ee54de83f --- /dev/null +++ b/Tests/RepoPromptTests/Prompt/TokenCountingViewModelProjectionTests.swift @@ -0,0 +1,842 @@ +import Combine +import Foundation +@testable import RepoPrompt +@testable import RepoPromptCore +import XCTest + +final class TokenCountingViewModelProjectionTests: XCTestCase { + @MainActor + func testHeavyProjectionPublishesCoreFileSubdivisionsWithoutDoubleCounting() async throws { + let fixture = try await makeFixture(name: "CoreSubdivisions", includesAutoCodemap: true) + defer { try? FileManager.default.removeItem(at: fixture.rootURL) } + var promptText = "Explain the selection" + let instructionsText = "Be concise" + let viewModel = makeViewModel( + fixture: fixture, + promptText: { promptText }, + instructionsText: { instructionsText }, + codeMapUsage: .auto + ) + viewModel.suspendAutomaticRecounts() + + await viewModel.forceImmediateRecount() + + let breakdown = viewModel.latestTokenBreakdown() + XCTAssertGreaterThan(viewModel.totalTokenCountFilesOnly, 0) + XCTAssertGreaterThan(viewModel.codeMapTokenCount, 0) + XCTAssertEqual( + viewModel.totalFileTokensDisplay, + viewModel.totalTokenCountFilesOnly + viewModel.codeMapTokenCount + ) + XCTAssertEqual(breakdown.files, viewModel.totalFileTokensDisplay) + XCTAssertEqual( + breakdown.total, + breakdown.files + breakdown.prompt + breakdown.meta + breakdown.fileTree + breakdown.git + breakdown.other + ) + XCTAssertEqual(viewModel.totalTokenCount, breakdown.total) + XCTAssertTrue(viewModel.hasAcceptedSelectionProjectionForTesting) + + promptText = "Explain the selection with examples" + viewModel.markPromptDirty() + await viewModel.processPendingRecountForTesting() + XCTAssertEqual(viewModel.latestTokenBreakdown().files, breakdown.files) + await viewModel.stopTokenCountUpdateTimer() + } + + @MainActor + func testLightRecountReusesAcceptedSelectionAndHeavyDirtyRecaptures() async throws { + let fixture = try await makeFixture(name: "LightReuse", includesAutoCodemap: false) + defer { try? FileManager.default.removeItem(at: fixture.rootURL) } + let captureCounter = CaptureCounter() + var promptText = "Initial" + let viewModel = makeViewModel( + fixture: fixture, + promptText: { promptText }, + instructionsText: { "" }, + codeMapUsage: .none, + projectionAdapterFactory: countingAdapterFactory(counter: captureCounter) + ) + viewModel.suspendAutomaticRecounts() + + await viewModel.forceImmediateRecount() + let initialCaptureCount = await captureCounter.value() + XCTAssertEqual(initialCaptureCount, 1) + let initialFileTokens = viewModel.latestTokenBreakdown().files + + promptText = "Updated prompt text" + viewModel.markPromptDirty() + await viewModel.processPendingRecountForTesting() + let lightCaptureCount = await captureCounter.value() + XCTAssertEqual(lightCaptureCount, 1) + XCTAssertEqual(viewModel.latestTokenBreakdown().files, initialFileTokens) + + let publishedBeforeHeavyDirty = viewModel.latestTokenBreakdown() + viewModel.markDirty(.settings) + XCTAssertFalse(viewModel.hasAcceptedSelectionProjectionForTesting) + XCTAssertEqual(viewModel.latestTokenBreakdown().total, publishedBeforeHeavyDirty.total) + XCTAssertEqual(viewModel.latestTokenBreakdown().files, publishedBeforeHeavyDirty.files) + await viewModel.processPendingRecountForTesting() + let heavyCaptureCount = await captureCounter.value() + XCTAssertEqual(heavyCaptureCount, 2) + XCTAssertTrue(viewModel.hasAcceptedSelectionProjectionForTesting) + await viewModel.stopTokenCountUpdateTimer() + } + + @MainActor + func testFilesDisabledPublishesOnlyCodemapDetails() async throws { + let fixture = try await makeFixture(name: "FilesDisabled", includesAutoCodemap: true) + defer { try? FileManager.default.removeItem(at: fixture.rootURL) } + let viewModel = makeViewModel( + fixture: fixture, + promptText: { "" }, + instructionsText: { "" }, + codeMapUsage: .auto, + includeFiles: false + ) + viewModel.suspendAutomaticRecounts() + + await viewModel.forceImmediateRecount() + + XCTAssertEqual(viewModel.totalTokenCountFilesOnly, 0) + XCTAssertGreaterThan(viewModel.codeMapTokenCount, 0) + XCTAssertEqual(viewModel.charCount, 0) + XCTAssertEqual(viewModel.codeMapFileCount, 1) + XCTAssertEqual(viewModel.latestTokenBreakdown().files, viewModel.codeMapTokenCount) + XCTAssertEqual(viewModel.folderTokenInfo.values.reduce(0) { $0 + $1.count }, viewModel.codeMapTokenCount) + await viewModel.stopTokenCountUpdateTimer() + } + + @MainActor + func testInputRevisionGuardRejectsStaleHeavyPublicationAndPendingHeavyRecovers() async throws { + let fixture = try await makeFixture(name: "StaleHeavy", includesAutoCodemap: false) + defer { try? FileManager.default.removeItem(at: fixture.rootURL) } + let gate = FirstCaptureGate() + var selection = fixture.selection + let viewModel = makeViewModel( + fixture: fixture, + promptText: { "" }, + instructionsText: { "" }, + codeMapUsage: .none, + selection: { selection }, + projectionAdapterFactory: gatedAdapterFactory(gate: gate) + ) + viewModel.suspendAutomaticRecounts() + var publications = 0 + let subscription = viewModel.tokenCalculationCompletedPublisher.sink { publications += 1 } + defer { subscription.cancel() } + + let staleRun = Task { @MainActor in + await viewModel.forceImmediateRecount() + } + await gate.waitUntilStarted() + selection = StoredSelection() + viewModel.markDirty(.selection) + XCTAssertFalse(viewModel.hasAcceptedSelectionProjectionForTesting) + await gate.release() + await staleRun.value + + XCTAssertEqual(viewModel.totalTokenCount, 0) + XCTAssertFalse(viewModel.hasAcceptedSelectionProjectionForTesting) + XCTAssertEqual(publications, 0) + + await viewModel.processPendingRecountForTesting() + let recoveredCaptureCount = await gate.captureCount() + XCTAssertEqual(recoveredCaptureCount, 2) + XCTAssertEqual(viewModel.totalTokenCount, 0) + XCTAssertTrue(viewModel.hasAcceptedSelectionProjectionForTesting) + XCTAssertEqual(publications, 1) + await viewModel.stopTokenCountUpdateTimer() + } + + @MainActor + func testActiveLiveResidualSurvivesVirtualLightRecountAndEachAcceptedResultPublishesOnce() async throws { + let fixture = try await makeFixture(name: "Residual", includesAutoCodemap: true) + defer { try? FileManager.default.removeItem(at: fixture.rootURL) } + let residual = 17 + let coreAccounting = RepoPromptCore.PromptContextAccountingService() + let lightRecorder = LightProjectionRecorder() + var promptText = "Initial prompt" + let viewModel = makeViewModel( + fixture: fixture, + promptText: { promptText }, + instructionsText: { "Meta" }, + codeMapUsage: .selected, + accountingOperation: { request, store, capture in + let result = try await coreAccounting.calculatePromptStats( + request: request, + store: store, + capture: capture + ) + return Self.addingResidual(residual, to: result) + }, + lightProjectionOperation: { selection, source, nonFile in + await lightRecorder.record(source: source, nonFile: nonFile) + return TokenProjectionService.workspaceComponentEstimates( + from: selection, + source: source, + nonFile: nonFile + ) + } + ) + viewModel.suspendAutomaticRecounts() + var publications = 0 + let subscription = viewModel.tokenCalculationCompletedPublisher.sink { publications += 1 } + defer { subscription.cancel() } + + await viewModel.forceImmediateRecount() + + let heavy = try XCTUnwrap(viewModel.publishedTokenProjectionForTesting) + XCTAssertEqual(heavy.provenance.source, .activeLive) + XCTAssertEqual(heavy.components.other, residual) + XCTAssertEqual(publications, 1) + + promptText = "Updated prompt with more detail" + viewModel.markPromptDirty() + await viewModel.processPendingRecountForTesting() + + let light = try XCTUnwrap(viewModel.publishedTokenProjectionForTesting) + XCTAssertEqual(light.provenance.source, .virtualRecomputed) + XCTAssertEqual(light.components.other, residual) + XCTAssertEqual(viewModel.latestTokenBreakdown().other, residual) + XCTAssertEqual(publications, 2) + let recordedLight = await lightRecorder.lastRecord() + let lightRecord = try XCTUnwrap(recordedLight) + XCTAssertEqual(lightRecord.source, .virtualRecomputed) + XCTAssertEqual(lightRecord.other, residual) + await viewModel.stopTokenCountUpdateTimer() + } + + @MainActor + func testConfiguredPoliciesKeepNormalizedAccountingAndPublishExactDetailMembership() async throws { + let cases: [(CodeMapUsage, Bool, Int)] = [ + (.auto, true, 1), + (.selected, true, 2), + (.complete, true, 3), + (.none, true, 0), + (.auto, false, 1), + (.selected, false, 1), + (.complete, false, 1), + (.none, false, 0) + ] + + for (usage, includeFiles, expectedCodemapCount) in cases { + let fixture = try await makeFixture( + name: "Policy-\(usage)-\(includeFiles)", + includesAutoCodemap: true, + includesCompleteCodemap: true + ) + defer { try? FileManager.default.removeItem(at: fixture.rootURL) } + let usageRecorder = AccountingUsageRecorder() + let coreAccounting = RepoPromptCore.PromptContextAccountingService() + let viewModel = makeViewModel( + fixture: fixture, + promptText: { "" }, + instructionsText: { "" }, + codeMapUsage: usage, + includeFiles: includeFiles, + accountingOperation: { request, store, capture in + await usageRecorder.record(request.codeMapUsage) + return try await coreAccounting.calculatePromptStats( + request: request, + store: store, + capture: capture + ) + } + ) + viewModel.suspendAutomaticRecounts() + var publications = 0 + let subscription = viewModel.tokenCalculationCompletedPublisher.sink { publications += 1 } + + await viewModel.forceImmediateRecount() + + let recordedUsages = await usageRecorder.values() + XCTAssertEqual(recordedUsages, [.auto]) + XCTAssertEqual(viewModel.codeMapFileCount, expectedCodemapCount, "\(usage), includeFiles=\(includeFiles)") + XCTAssertEqual( + viewModel.fileTokenInfo.values.reduce(0) { $0 + $1.count }, + viewModel.latestTokenBreakdown().files + ) + XCTAssertEqual( + viewModel.folderTokenInfo.values.reduce(0) { $0 + $1.count }, + viewModel.latestTokenBreakdown().files + ) + XCTAssertEqual(viewModel.codeMapContent.isEmpty, expectedCodemapCount == 0) + if usage == .complete, includeFiles { + let roots = await fixture.store.roots() + var records: [WorkspaceFileRecord] = [] + for root in roots { + await records.append(contentsOf: fixture.store.files(inRoot: root.id)) + } + let completeOnly = try XCTUnwrap(records.first { $0.name == "CompleteOnly.swift" }) + XCTAssertEqual(viewModel.fileTokenInfo[completeOnly.id]?.fullCount, 0) + } + XCTAssertEqual(publications, 1) + subscription.cancel() + await viewModel.stopTokenCountUpdateTimer() + } + } + + @MainActor + func testStaleLightCannotPublishAfterHeavyDirtyAndSuccessorPublishesOnce() async throws { + let fixture = try await makeFixture(name: "StaleLight", includesAutoCodemap: true) + defer { try? FileManager.default.removeItem(at: fixture.rootURL) } + let gate = FirstLightProjectionGate() + var promptText = "Initial" + let viewModel = makeViewModel( + fixture: fixture, + promptText: { promptText }, + instructionsText: { "" }, + codeMapUsage: .auto, + lightProjectionOperation: { selection, source, nonFile in + await gate.enter() + return TokenProjectionService.workspaceComponentEstimates( + from: selection, + source: source, + nonFile: nonFile + ) + } + ) + viewModel.suspendAutomaticRecounts() + var publications = 0 + let subscription = viewModel.tokenCalculationCompletedPublisher.sink { publications += 1 } + defer { subscription.cancel() } + + await viewModel.forceImmediateRecount() + let accepted = viewModel.latestTokenBreakdown() + XCTAssertEqual(publications, 1) + + promptText = "Stale update" + viewModel.markPromptDirty() + let staleLight = Task { @MainActor in + await viewModel.processPendingRecountForTesting() + } + await gate.waitUntilStarted() + viewModel.markDirty(.settings) + await gate.release() + await staleLight.value + + XCTAssertEqual(publications, 1) + XCTAssertEqual(viewModel.latestTokenBreakdown().total, accepted.total) + + await viewModel.processPendingRecountForTesting() + XCTAssertEqual(publications, 2) + XCTAssertEqual(viewModel.publishedTokenProjectionForTesting?.provenance.source, .activeLive) + await viewModel.stopTokenCountUpdateTimer() + } + + @MainActor + func testCancelledLightDoesNotPublishAndValidSuccessorRecovers() async throws { + let fixture = try await makeFixture(name: "CancelledLight", includesAutoCodemap: false) + defer { try? FileManager.default.removeItem(at: fixture.rootURL) } + let gate = FirstLightProjectionGate() + var promptText = "Initial" + let viewModel = makeViewModel( + fixture: fixture, + promptText: { promptText }, + instructionsText: { "" }, + codeMapUsage: .none, + lightProjectionOperation: { selection, source, nonFile in + await gate.enter() + try Task.checkCancellation() + return TokenProjectionService.workspaceComponentEstimates( + from: selection, + source: source, + nonFile: nonFile + ) + } + ) + viewModel.suspendAutomaticRecounts() + var publications = 0 + let subscription = viewModel.tokenCalculationCompletedPublisher.sink { publications += 1 } + defer { subscription.cancel() } + + await viewModel.forceImmediateRecount() + let accepted = viewModel.latestTokenBreakdown() + + promptText = "Cancelled" + viewModel.markPromptDirty() + let cancelled = Task { @MainActor in + await viewModel.processPendingRecountForTesting() + } + await gate.waitUntilStarted() + cancelled.cancel() + await gate.release() + await cancelled.value + + XCTAssertEqual(publications, 1) + XCTAssertEqual(viewModel.latestTokenBreakdown().total, accepted.total) + + promptText = "Recovered" + viewModel.markPromptDirty() + await viewModel.processPendingRecountForTesting() + XCTAssertEqual(publications, 2) + XCTAssertEqual(viewModel.publishedTokenProjectionForTesting?.provenance.source, .virtualRecomputed) + await viewModel.stopTokenCountUpdateTimer() + } + + @MainActor + func testHeavyErrorRetriesInsideSameRecountAndPublishesOnce() async throws { + let fixture = try await makeFixture(name: "HeavyError", includesAutoCodemap: false) + defer { try? FileManager.default.removeItem(at: fixture.rootURL) } + let accounting = AccountingFailureController() + let viewModel = makeViewModel( + fixture: fixture, + promptText: { "" }, + instructionsText: { "" }, + codeMapUsage: .none, + accountingOperation: { request, store, capture in + try await accounting.calculate(request: request, store: store, capture: capture) + } + ) + viewModel.suspendAutomaticRecounts() + var publications = 0 + let subscription = viewModel.tokenCalculationCompletedPublisher.sink { publications += 1 } + defer { subscription.cancel() } + + await viewModel.forceImmediateRecount() + let accepted = viewModel.latestTokenBreakdown() + XCTAssertEqual(publications, 1) + + viewModel.markDirty(.settings) + await viewModel.processPendingRecountForTesting() + XCTAssertEqual(publications, 2) + XCTAssertEqual(viewModel.latestTokenBreakdown().total, accepted.total) + let accountingCalls = await accounting.callCount() + XCTAssertEqual(accountingCalls, 3) + await viewModel.stopTokenCountUpdateTimer() + } + + @MainActor + func testProjectionCoherenceErrorRetriesInsideSameRecountAndPublishesOnce() async throws { + let fixture = try await makeFixture(name: "ProjectionRetry", includesAutoCodemap: false) + defer { try? FileManager.default.removeItem(at: fixture.rootURL) } + let accounting = ProjectionMismatchController() + let viewModel = makeViewModel( + fixture: fixture, + promptText: { "" }, + instructionsText: { "" }, + codeMapUsage: .none, + accountingOperation: { request, store, capture in + try await accounting.calculate(request: request, store: store, capture: capture) + } + ) + viewModel.suspendAutomaticRecounts() + var publications = 0 + let subscription = viewModel.tokenCalculationCompletedPublisher.sink { publications += 1 } + defer { subscription.cancel() } + + await viewModel.forceImmediateRecount() + XCTAssertEqual(publications, 1) + + viewModel.markDirty(.settings) + await viewModel.processPendingRecountForTesting() + + XCTAssertEqual(publications, 2) + let accountingCalls = await accounting.callCount() + XCTAssertEqual(accountingCalls, 3) + await viewModel.stopTokenCountUpdateTimer() + } + + private static func addingResidual( + _ residual: Int, + to result: PromptContextAccountingResult + ) -> PromptContextAccountingResult { + let token = result.tokenResult + return PromptContextAccountingResult( + tokenResult: TokenCalculationResult( + totalTokenCount: token.totalTokenCount + residual, + totalTokenCountFilesOnly: token.totalTokenCountFilesOnly, + fileTokenInfo: token.fileTokenInfo, + folderTokenInfo: token.folderTokenInfo, + tokenCountString: token.tokenCountString, + tokenCountFilesOnlyString: token.tokenCountFilesOnlyString, + charCount: token.charCount, + fileTreeContent: token.fileTreeContent, + fileTreeTokenCount: token.fileTreeTokenCount, + fileTreeTokenCountRaw: token.fileTreeTokenCountRaw, + codeMapContent: token.codeMapContent, + codeMapFileCount: token.codeMapFileCount, + codeMapTokenCount: token.codeMapTokenCount + ), + resolvedEntries: result.resolvedEntries, + promptFileEntrySnapshots: result.promptFileEntrySnapshots, + tokenCalculationSnapshot: result.tokenCalculationSnapshot, + missingPaths: result.missingPaths, + invalidPaths: result.invalidPaths, + codemapSnapshotsUsed: result.codemapSnapshotsUsed, + captureProvenance: result.captureProvenance + ) + } + + private struct Fixture { + let rootURL: URL + let store: WorkspaceFileContextStore + let fileManager: WorkspaceFilesViewModel + let gitViewModel: GitViewModel + let selection: StoredSelection + } + + @MainActor + private func makeFixture( + name: String, + includesAutoCodemap: Bool, + includesCompleteCodemap: Bool = false + ) async throws -> Fixture { + let rootURL = FileManager.default.temporaryDirectory + .appendingPathComponent("RepoPromptTests", isDirectory: true) + .appendingPathComponent("TokenCountingProjection-\(name)-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: rootURL, withIntermediateDirectories: true) + let selectedURL = rootURL.appendingPathComponent("Selected.swift") + try "struct Selected { let value = 1 }\n".write(to: selectedURL, atomically: true, encoding: .utf8) + + var autoURL: URL? + if includesAutoCodemap { + let url = rootURL.appendingPathComponent("Auto.swift") + try "struct Auto { func helper() {} }\n".write(to: url, atomically: true, encoding: .utf8) + autoURL = url + } + var completeURL: URL? + if includesCompleteCodemap { + let url = rootURL.appendingPathComponent("CompleteOnly.swift") + try "struct CompleteOnly { func helper() {} }\n".write(to: url, atomically: true, encoding: .utf8) + completeURL = url + } + + let store = WorkspaceFileContextStore() + _ = try await store.loadRoot(path: rootURL.path) + var codemapResults = [ + WorkspaceObservedCodemapResult( + fullPath: selectedURL.path, + modificationDate: Date(timeIntervalSince1970: 0), + fileAPI: makeFileAPI(path: selectedURL.path, symbol: "selectedSymbol") + ) + ] + if let autoURL { + codemapResults.append(WorkspaceObservedCodemapResult( + fullPath: autoURL.path, + modificationDate: Date(timeIntervalSince1970: 0), + fileAPI: makeFileAPI(path: autoURL.path, symbol: "autoSymbol") + )) + } + if let completeURL { + codemapResults.append(WorkspaceObservedCodemapResult( + fullPath: completeURL.path, + modificationDate: Date(timeIntervalSince1970: 0), + fileAPI: makeFileAPI(path: completeURL.path, symbol: "completeSymbol") + )) + } + await store.applyObservedCodemapResults(codemapResults) + let fileManager = WorkspaceFilesViewModel(workspaceFileContextStore: store) + let gitViewModel = GitViewModel(fileManager: fileManager) + let selection = StoredSelection( + selectedPaths: [selectedURL.path], + autoCodemapPaths: autoURL.map { [$0.path] } ?? [], + codemapAutoEnabled: includesAutoCodemap + ) + return Fixture( + rootURL: rootURL, + store: store, + fileManager: fileManager, + gitViewModel: gitViewModel, + selection: selection + ) + } + + @MainActor + private func makeViewModel( + fixture: Fixture, + promptText: @escaping () -> String, + instructionsText: @escaping () -> String, + codeMapUsage: CodeMapUsage, + includeFiles: Bool = true, + selection: (() -> StoredSelection)? = nil, + projectionAdapterFactory: @escaping TokenCountingViewModel.ProjectionAdapterFactory = { store in + WorkspacePromptProjectionAdapter(store: store) + }, + accountingOperation: TokenCountingViewModel.AccountingOperation? = nil, + lightProjectionOperation: @escaping TokenCountingViewModel.LightProjectionOperation = { selection, source, nonFile in + TokenProjectionService.workspaceComponentEstimates( + from: selection, + source: source, + nonFile: nonFile + ) + } + ) -> TokenCountingViewModel { + let viewModel = TokenCountingViewModel( + projectionAdapterFactory: projectionAdapterFactory, + accountingOperation: accountingOperation, + lightProjectionOperation: lightProjectionOperation + ) + viewModel.configure( + fileManager: fixture.fileManager, + gitViewModel: fixture.gitViewModel, + getPromptText: promptText, + getSelectedInstructionsText: instructionsText, + getSettings: { + TokenCountingViewModel.TokenCalculationSettings( + fileTreeOption: .none, + codeMapUsage: codeMapUsage, + filePathDisplayOption: .relative, + includeFilesInClipboard: includeFiles, + duplicateUserInstructionsAtTop: false, + onlyIncludeRootsWithSelectedFiles: false, + codeMapsGloballyDisabled: false + ) + }, + getCopyContext: { + TokenCountingViewModel.CopyContextSnapshot( + includeFiles: includeFiles, + includeUserPrompt: true, + includeMetaPrompts: true, + includeFileTree: false, + fileTreeMode: .none, + codeMapUsage: codeMapUsage, + gitInclusion: .none, + duplicateUserInstructionsAtTop: false + ) + }, + getStoredSelection: { + selection?() ?? fixture.selection + } + ) + return viewModel + } + + @MainActor + private func countingAdapterFactory( + counter: CaptureCounter + ) -> TokenCountingViewModel.ProjectionAdapterFactory { + { store in + WorkspacePromptProjectionAdapter { selection, request, profile, coverage in + await counter.increment() + return try await store.captureWorkspaceFileContext( + selection: selection, + fileTreeRequest: request, + profile: profile, + coverage: coverage + ) + } + } + } + + @MainActor + private func gatedAdapterFactory( + gate: FirstCaptureGate + ) -> TokenCountingViewModel.ProjectionAdapterFactory { + { store in + WorkspacePromptProjectionAdapter { selection, request, profile, coverage in + await gate.captureStarted() + return try await store.captureWorkspaceFileContext( + selection: selection, + fileTreeRequest: request, + profile: profile, + coverage: coverage + ) + } + } + } + + private func makeFileAPI(path: String, symbol: String) -> FileAPI { + FileAPI( + filePath: path, + imports: [], + classes: [], + functions: [ + FunctionInfo( + name: symbol, + parameters: [], + returnType: nil, + definitionLine: "func \(symbol)()", + lineNumber: 1 + ) + ], + enums: [], + globalVars: [], + macros: [], + referencedTypes: [] + ) + } +} + +private actor LightProjectionRecorder { + struct Record { + let source: TokenProjection.Source + let other: Int + } + + private var records: [Record] = [] + + func record( + source: TokenProjection.Source, + nonFile: TokenProjectionService.WorkspaceNonFileComponents + ) { + records.append(Record(source: source, other: nonFile.other)) + } + + func lastRecord() -> Record? { + records.last + } +} + +private actor AccountingUsageRecorder { + private var usages: [CodeMapUsage] = [] + + func record(_ usage: CodeMapUsage) { + usages.append(usage) + } + + func values() -> [CodeMapUsage] { + usages + } +} + +private actor FirstLightProjectionGate { + private var count = 0 + private var started = false + private var released = false + private var startWaiters: [CheckedContinuation<Void, Never>] = [] + private var releaseWaiters: [CheckedContinuation<Void, Never>] = [] + + func enter() async { + count += 1 + guard count == 1 else { return } + started = true + let waiters = startWaiters + startWaiters.removeAll() + waiters.forEach { $0.resume() } + guard !released else { return } + await withCheckedContinuation { continuation in + releaseWaiters.append(continuation) + } + } + + func waitUntilStarted() async { + if started { return } + await withCheckedContinuation { continuation in + startWaiters.append(continuation) + } + } + + func release() { + released = true + let waiters = releaseWaiters + releaseWaiters.removeAll() + waiters.forEach { $0.resume() } + } +} + +private actor AccountingFailureController { + private enum Failure: Error { + case injected + } + + private let core = RepoPromptCore.PromptContextAccountingService() + private var calls = 0 + + func calculate( + request: PromptContextAccountingRequest, + store: WorkspaceFileContextStore, + capture: WorkspaceFileContextCapture + ) async throws -> PromptContextAccountingResult { + calls += 1 + if calls == 2 { + throw Failure.injected + } + return try await core.calculatePromptStats( + request: request, + store: store, + capture: capture + ) + } + + func callCount() -> Int { + calls + } +} + +private actor ProjectionMismatchController { + private let core = RepoPromptCore.PromptContextAccountingService() + private var calls = 0 + + func calculate( + request: PromptContextAccountingRequest, + store: WorkspaceFileContextStore, + capture: WorkspaceFileContextCapture + ) async throws -> PromptContextAccountingResult { + calls += 1 + let result = try await core.calculatePromptStats( + request: request, + store: store, + capture: capture + ) + guard calls == 2, + let resolved = result.resolvedEntries.first, + let snapshot = result.promptFileEntrySnapshots.first + else { return result } + return PromptContextAccountingResult( + tokenResult: result.tokenResult, + resolvedEntries: result.resolvedEntries + [resolved], + promptFileEntrySnapshots: result.promptFileEntrySnapshots + [snapshot], + tokenCalculationSnapshot: result.tokenCalculationSnapshot, + missingPaths: result.missingPaths, + invalidPaths: result.invalidPaths, + codemapSnapshotsUsed: result.codemapSnapshotsUsed, + captureProvenance: result.captureProvenance + ) + } + + func callCount() -> Int { + calls + } +} + +private actor CaptureCounter { + private var count = 0 + + func increment() { + count += 1 + } + + func value() -> Int { + count + } +} + +private actor FirstCaptureGate { + private var count = 0 + private var started = false + private var released = false + private var startWaiters: [CheckedContinuation<Void, Never>] = [] + private var releaseWaiters: [CheckedContinuation<Void, Never>] = [] + + func captureStarted() async { + count += 1 + guard count == 1 else { return } + started = true + let waiters = startWaiters + startWaiters.removeAll() + waiters.forEach { $0.resume() } + guard !released else { return } + await withCheckedContinuation { continuation in + releaseWaiters.append(continuation) + } + } + + func waitUntilStarted() async { + if started { return } + await withCheckedContinuation { continuation in + startWaiters.append(continuation) + } + } + + func release() { + released = true + let waiters = releaseWaiters + releaseWaiters.removeAll() + waiters.forEach { $0.resume() } + } + + func captureCount() -> Int { + count + } +} diff --git a/Tests/RepoPromptTests/Prompt/WorkspacePromptProjectionAdapterTests.swift b/Tests/RepoPromptTests/Prompt/WorkspacePromptProjectionAdapterTests.swift new file mode 100644 index 000000000..054925c6c --- /dev/null +++ b/Tests/RepoPromptTests/Prompt/WorkspacePromptProjectionAdapterTests.swift @@ -0,0 +1,1001 @@ +import Foundation +@testable import RepoPrompt +@testable import RepoPromptCore +import XCTest + +final class WorkspacePromptProjectionAdapterTests: XCTestCase { + private enum TestError: Error { + case unexpectedCaptureRequest + } + + private actor EvaluationBatchRecorder { + private var counts: [Int] = [] + + func record(_ count: Int) { + counts.append(count) + } + + func snapshot() -> [Int] { + counts + } + } + + private actor SnapshotBatchRecorder { + private var tokenCounts: [[Int]] = [] + + func record(_ snapshots: [PromptFileEntrySnapshot]) { + tokenCounts.append(snapshots.map { $0.cachedFullTokenCount ?? -1 }) + } + + func snapshot() -> [[Int]] { + tokenCounts + } + } + + func testProjectionPreservesSelectedFolderSliceAndAutoCodemapOrderWithCaptureProvenance() async throws { + let root = makeRoot() + let folder = makeFolder(root: root, path: "Sources") + let selected = makeFile(root: root, path: "Selected.swift") + let folderSecond = makeFile(root: root, path: "Sources/Second.swift", parentFolderID: folder.id) + let folderFirst = makeFile(root: root, path: "Sources/First.swift", parentFolderID: folder.id) + let sliced = makeFile(root: root, path: "Sliced.swift") + let auto = makeFile(root: root, path: "Auto.swift") + let ranges = [LineRange(start: 4, end: 8, description: "body")] + let selection = StoredSelection( + selectedPaths: [selected.fullPath, folder.fullPath], + autoCodemapPaths: [auto.fullPath], + slices: [sliced.fullPath: ranges], + codemapAutoEnabled: true + ) + let capture = makeCapture( + root: root, + files: [auto, sliced, folderFirst, selected, folderSecond], + folders: [folder], + selection: selection, + selectedPaths: [ + .init(input: selected.fullPath, resolution: .file(selected)), + .init(input: folder.fullPath, resolution: .folder( + folder, + descendantFiles: [folderSecond, folderFirst, selected] + )) + ], + autoCodemapPaths: [.init(input: auto.fullPath, resolution: .file(auto))], + slices: [.init(path: sliced.fullPath, ranges: ranges, file: sliced, issue: nil)], + codemapSnapshots: [makeCodemap(file: auto, root: root, symbol: "AutoSymbol")] + ) + let adapter = WorkspacePromptProjectionAdapter { capturedSelection, request, profile, coverage in + guard capturedSelection == selection, + request.mode == .none, + request.rootScope == .allLoaded, + profile == .uiAssisted, + coverage == .projection(codemapCoverage: .referenced) + else { throw TestError.unexpectedCaptureRequest } + return capture + } + + let projection = try await adapter.project( + selection: selection, + codeMapUsage: .auto, + filePathDisplay: .relative + ) + + XCTAssertEqual(projection.provenance, capture.provenance) + XCTAssertEqual(projection.entries.map(\.file.id), [ + selected.id, + folderSecond.id, + folderFirst.id, + sliced.id, + auto.id + ]) + XCTAssertEqual(projection.entries.map(\.mode), [.full, .full, .full, .slice, .codemap]) + XCTAssertEqual(projection.entries.map(\.ranges), [nil, nil, nil, ranges, nil]) + XCTAssertEqual(projection.entries.map(\.codemapOrigin), [nil, nil, nil, nil, .auto]) + XCTAssertEqual(projection.entries.map(\.metadata.displayPath), [ + "Selected.swift", + "Sources/Second.swift", + "Sources/First.swift", + "Sliced.swift", + "Auto.swift" + ]) + } + + func testProjectionUsesCoreSelectedAndCompleteCodemapModesAndOrigins() async throws { + let root = makeRoot() + let selectedWithAPI = makeFile(root: root, path: "SelectedWithAPI.swift") + let selectedWithoutAPI = makeFile(root: root, path: "SelectedWithoutAPI.swift") + let completeSecond = makeFile(root: root, path: "CompleteSecond.swift") + let completeFirst = makeFile(root: root, path: "CompleteFirst.swift") + let selection = StoredSelection(selectedPaths: [selectedWithAPI.fullPath, selectedWithoutAPI.fullPath]) + let capture = makeCapture( + root: root, + files: [selectedWithAPI, selectedWithoutAPI, completeSecond, completeFirst], + selection: selection, + selectedPaths: [ + .init(input: selectedWithAPI.fullPath, resolution: .file(selectedWithAPI)), + .init(input: selectedWithoutAPI.fullPath, resolution: .file(selectedWithoutAPI)) + ], + codemapSnapshots: [ + makeCodemap(file: completeSecond, root: root, symbol: "SecondSymbol"), + makeCodemap(file: selectedWithAPI, root: root, symbol: "SelectedSymbol"), + makeCodemap(file: completeFirst, root: root, symbol: "FirstSymbol") + ] + ) + let adapter = WorkspacePromptProjectionAdapter { _, _, _, _ in capture } + + let selectedProjection = try await adapter.project( + selection: selection, + codeMapUsage: .selected, + filePathDisplay: .full + ) + XCTAssertEqual(selectedProjection.entries.map(\.file.id), [selectedWithAPI.id, selectedWithoutAPI.id]) + XCTAssertEqual(selectedProjection.entries.map(\.mode), [.codemap, .full]) + XCTAssertEqual(selectedProjection.entries.map(\.codemapOrigin), [.selectedMode, nil]) + + let completeProjection = try await adapter.project( + selection: selection, + codeMapUsage: .complete, + filePathDisplay: .full + ) + XCTAssertEqual(completeProjection.entries.map(\.file.id), [ + selectedWithAPI.id, + selectedWithoutAPI.id, + completeSecond.id, + completeFirst.id + ]) + XCTAssertEqual(completeProjection.entries.map(\.mode), [.full, .full, .codemap, .codemap]) + XCTAssertEqual(completeProjection.entries.map(\.codemapOrigin), [nil, nil, .auto, .auto]) + } + + func testTokenProjectionMatchesFactsByOccurrenceIdentityAndBuildsAlternateViews() async throws { + let root = makeRoot() + let full = makeFile(root: root, path: "Full.swift") + let sliced = makeFile(root: root, path: "Sliced.swift") + let auto = makeFile(root: root, path: "Auto.swift") + let ranges = [ + LineRange(start: 3, end: 3, description: "third"), + LineRange(start: 1, end: 1) + ] + let fullCodemap = makeCodemap(file: full, root: root, symbol: "FullSymbol") + let autoCodemap = makeCodemap(file: auto, root: root, symbol: "AutoSymbol") + let selection = StoredSelection( + selectedPaths: [full.fullPath], + autoCodemapPaths: [auto.fullPath], + slices: [sliced.fullPath: ranges], + codemapAutoEnabled: true + ) + let capture = makeCapture( + root: root, + files: [full, sliced, auto], + selection: selection, + selectedPaths: [.init(input: full.fullPath, resolution: .file(full))], + autoCodemapPaths: [.init(input: auto.fullPath, resolution: .file(auto))], + slices: [.init(path: sliced.fullPath, ranges: ranges, file: sliced, issue: nil)], + codemapSnapshots: [fullCodemap, autoCodemap] + ) + let fullContent = "struct Full { let value = 1 }\n" + let slicedContent = "one\ntwo\nthree\nfour\n" + let autoTokens = try XCTUnwrap(autoCodemap.fileAPI?.apiTokenCount) + let fullCodemapTokens = try XCTUnwrap(fullCodemap.fileAPI?.apiTokenCount) + let resolvedEntries = [ + ResolvedPromptFileEntry(file: full, loadedContent: fullContent, rootFolderPath: root.fullPath), + ResolvedPromptFileEntry( + file: sliced, + lineRanges: ranges, + mode: .sliced, + loadedContent: slicedContent, + rootFolderPath: root.fullPath + ), + ResolvedPromptFileEntry(file: auto, isCodemap: true, mode: .codemap, rootFolderPath: root.fullPath) + ] + let snapshots = [ + PromptFileEntrySnapshot( + fileID: full.id, + relativePath: full.relativePath, + isCodemapRequested: false, + ranges: nil, + cachedFullTokenCount: TokenCalculationService.estimateTokens(for: fullContent), + loadedContent: fullContent, + codeMapContent: nil, + availableCodeMapTokenCount: fullCodemapTokens + ), + PromptFileEntrySnapshot( + fileID: sliced.id, + relativePath: sliced.relativePath, + isCodemapRequested: false, + ranges: ranges, + cachedFullTokenCount: TokenCalculationService.estimateTokens(for: slicedContent), + loadedContent: slicedContent, + codeMapContent: nil, + availableCodeMapTokenCount: 0 + ), + PromptFileEntrySnapshot( + fileID: auto.id, + relativePath: auto.relativePath, + isCodemapRequested: true, + ranges: nil, + cachedFullTokenCount: nil, + loadedContent: nil, + codeMapContent: "Auto map", + availableCodeMapTokenCount: autoTokens + ) + ] + let adapter = WorkspacePromptProjectionAdapter { _, _, _, _ in capture } + + let projection = try await adapter.projectTokens( + selection: selection, + codeMapUsage: .auto, + filePathDisplay: .relative, + alternatePolicy: .init(includeFiles: true, codeMapUsage: .selected), + resolvedEntries: resolvedEntries, + promptFileEntrySnapshots: snapshots, + tokenProjectionInput: .activeLive(.init( + reportedTotal: 1000, + prompt: 7, + fileTree: 5, + meta: 3, + git: 2 + )) + ) + + XCTAssertEqual(projection.provenance, capture.provenance) + XCTAssertEqual(projection.selection.files.map(\.mode), [.full, .slice, .codemap]) + XCTAssertEqual(projection.selection.files.map(\.ranges), [nil, ranges, nil]) + XCTAssertEqual(projection.selection.files[0].alternate?.mode, .codemap) + XCTAssertEqual(projection.selection.files[0].alternate?.tokens, fullCodemapTokens) + XCTAssertNil(projection.selection.files[2].alternate) + XCTAssertEqual(projection.tokens.normalized.components.files, projection.selection.summary.totalTokens) + XCTAssertEqual( + projection.tokens.normalized.components.filesContent, + projection.selection.summary.fullTokens + projection.selection.summary.sliceTokens + ) + XCTAssertEqual(projection.tokens.normalized.components.codemaps, autoTokens) + XCTAssertEqual(projection.tokens.normalized.components.prompt, 7) + XCTAssertEqual(projection.tokens.normalized.total, 1000) + let normalizedComponentFloor = projection.selection.summary.totalTokens + 7 + 5 + 3 + 2 + XCTAssertEqual(projection.tokens.normalized.components.other, 1000 - normalizedComponentFloor) + XCTAssertEqual( + projection.tokens.userConfigured?.components.other, + projection.tokens.normalized.components.other + ) + XCTAssertEqual(projection.tokens.userConfigured?.components.codemaps, fullCodemapTokens + autoTokens) + XCTAssertEqual( + projection.tokens.userConfigured?.components.files, + fullCodemapTokens + projection.selection.summary.sliceTokens + autoTokens + ) + XCTAssertEqual(projection.selection.alternate?.includedFiles.map(\.file.id), [full.id, sliced.id, auto.id]) + } + + func testTokenProjectionIncludesCompleteOnlyCodemapsAndCanHideContent() async throws { + let root = makeRoot() + let selected = makeFile(root: root, path: "Selected.swift") + let auto = makeFile(root: root, path: "Auto.swift") + let completeOnly = makeFile(root: root, path: "CompleteOnly.swift") + let selectedCodemap = makeCodemap(file: selected, root: root, symbol: "SelectedSymbol") + let autoCodemap = makeCodemap(file: auto, root: root, symbol: "AutoSymbol") + let completeCodemap = makeCodemap(file: completeOnly, root: root, symbol: "CompleteSymbol") + let selection = StoredSelection( + selectedPaths: [selected.fullPath], + autoCodemapPaths: [auto.fullPath], + codemapAutoEnabled: true + ) + let capture = makeCapture( + root: root, + files: [selected, auto, completeOnly], + selection: selection, + selectedPaths: [.init(input: selected.fullPath, resolution: .file(selected))], + autoCodemapPaths: [.init(input: auto.fullPath, resolution: .file(auto))], + codemapSnapshots: [selectedCodemap, autoCodemap, completeCodemap] + ) + let selectedContent = "struct Selected {}\n" + let autoTokens = try XCTUnwrap(autoCodemap.fileAPI?.apiTokenCount) + let selectedTokens = try XCTUnwrap(selectedCodemap.fileAPI?.apiTokenCount) + let completeTokens = try XCTUnwrap(completeCodemap.fileAPI?.apiTokenCount) + let adapter = WorkspacePromptProjectionAdapter { _, _, _, coverage in + guard coverage == .projection(codemapCoverage: .allAvailable) else { + throw TestError.unexpectedCaptureRequest + } + return capture + } + + let projection = try await adapter.projectTokens( + selection: selection, + codeMapUsage: .auto, + filePathDisplay: .relative, + alternatePolicy: .init(includeFiles: false, codeMapUsage: .complete), + resolvedEntries: [ + ResolvedPromptFileEntry(file: selected, loadedContent: selectedContent, rootFolderPath: root.fullPath), + ResolvedPromptFileEntry(file: auto, isCodemap: true, mode: .codemap, rootFolderPath: root.fullPath) + ], + promptFileEntrySnapshots: [ + PromptFileEntrySnapshot( + fileID: selected.id, + relativePath: selected.relativePath, + isCodemapRequested: false, + ranges: nil, + cachedFullTokenCount: TokenCalculationService.estimateTokens(for: selectedContent), + loadedContent: selectedContent, + codeMapContent: nil, + availableCodeMapTokenCount: selectedTokens + ), + PromptFileEntrySnapshot( + fileID: auto.id, + relativePath: auto.relativePath, + isCodemapRequested: true, + ranges: nil, + cachedFullTokenCount: nil, + loadedContent: nil, + codeMapContent: "Auto map", + availableCodeMapTokenCount: autoTokens + ) + ], + tokenProjectionInput: .activeLive(.init( + reportedTotal: 0, + prompt: 4, + fileTree: 0, + meta: 0, + git: 0 + )) + ) + + XCTAssertEqual(projection.selection.alternate?.codemapTokens, selectedTokens + autoTokens + completeTokens) + XCTAssertEqual(projection.selection.alternate?.includedTotalTokens, autoTokens) + XCTAssertEqual(projection.selection.alternate?.includedFiles.map(\.file.id), [auto.id]) + XCTAssertEqual(projection.tokens.userConfigured?.components.files, autoTokens) + XCTAssertEqual(projection.tokens.userConfigured?.components.filesContent, nil) + XCTAssertEqual(projection.tokens.userConfigured?.components.codemaps, autoTokens) + XCTAssertEqual(projection.tokens.userConfigured?.total, autoTokens + 4) + } + + func testTokenProjectionRejectsUnusedAccountingOccurrenceFacts() async throws { + let root = makeRoot() + let selected = makeFile(root: root, path: "Selected.swift") + let extra = makeFile(root: root, path: "Extra.swift") + let selection = StoredSelection(selectedPaths: [selected.fullPath]) + let capture = makeCapture( + root: root, + files: [selected, extra], + selection: selection, + selectedPaths: [.init(input: selected.fullPath, resolution: .file(selected))] + ) + let selectedContent = "struct Selected {}\n" + let extraContent = "struct Extra {}\n" + let adapter = WorkspacePromptProjectionAdapter { _, _, _, _ in capture } + + do { + _ = try await adapter.projectTokens( + selection: selection, + codeMapUsage: .none, + filePathDisplay: .relative, + alternatePolicy: nil, + resolvedEntries: [ + ResolvedPromptFileEntry(file: selected, loadedContent: selectedContent), + ResolvedPromptFileEntry(file: extra, loadedContent: extraContent) + ], + promptFileEntrySnapshots: [ + PromptFileEntrySnapshot( + fileID: selected.id, + relativePath: selected.relativePath, + isCodemapRequested: false, + ranges: nil, + cachedFullTokenCount: TokenCalculationService.estimateTokens(for: selectedContent), + loadedContent: selectedContent, + codeMapContent: nil, + availableCodeMapTokenCount: 0 + ), + PromptFileEntrySnapshot( + fileID: extra.id, + relativePath: extra.relativePath, + isCodemapRequested: false, + ranges: nil, + cachedFullTokenCount: TokenCalculationService.estimateTokens(for: extraContent), + loadedContent: extraContent, + codeMapContent: nil, + availableCodeMapTokenCount: 0 + ) + ], + tokenProjectionInput: .activeLive(.init( + reportedTotal: 0, + prompt: 0, + fileTree: 0, + meta: 0, + git: 0 + )) + ) + XCTFail("Expected unused accounting occurrence facts") + } catch let error as WorkspacePromptProjectionAdapter.Error { + guard case let .unusedTokenFacts(identities) = error else { + return XCTFail("Unexpected error: \(error)") + } + XCTAssertEqual(identities.map(\.fileID), [extra.id]) + } + } + + func testTokenProjectionRejectsFactsFromAnOlderFileRevision() async throws { + let root = makeRoot() + let captured = WorkspaceFileRecord( + rootID: root.id, + name: "Selected.swift", + relativePath: "Selected.swift", + fullPath: root.fullPath + "/Selected.swift", + parentFolderID: nil, + modificationDate: Date(timeIntervalSince1970: 2) + ) + let accounted = WorkspaceFileRecord( + id: captured.id, + rootID: captured.rootID, + name: captured.name, + relativePath: captured.relativePath, + fullPath: captured.fullPath, + parentFolderID: captured.parentFolderID, + modificationDate: Date(timeIntervalSince1970: 1) + ) + let selection = StoredSelection(selectedPaths: [captured.fullPath]) + let capture = makeCapture( + root: root, + files: [captured], + selection: selection, + selectedPaths: [.init(input: captured.fullPath, resolution: .file(captured))] + ) + let content = "struct Selected {}\n" + let adapter = WorkspacePromptProjectionAdapter { _, _, _, _ in capture } + + do { + _ = try await adapter.projectTokens( + selection: selection, + codeMapUsage: .none, + filePathDisplay: .relative, + alternatePolicy: nil, + resolvedEntries: [ResolvedPromptFileEntry(file: accounted, loadedContent: content)], + promptFileEntrySnapshots: [ + PromptFileEntrySnapshot( + fileID: accounted.id, + relativePath: accounted.relativePath, + isCodemapRequested: false, + ranges: nil, + cachedFullTokenCount: TokenCalculationService.estimateTokens(for: content), + loadedContent: content, + codeMapContent: nil, + availableCodeMapTokenCount: 0 + ) + ], + tokenProjectionInput: .activeLive(.init( + reportedTotal: 0, + prompt: 0, + fileTree: 0, + meta: 0, + git: 0 + )) + ) + XCTFail("Expected stale occurrence token facts") + } catch let error as WorkspacePromptProjectionAdapter.Error { + guard case let .missingTokenFacts(identity) = error else { + return XCTFail("Unexpected error: \(error)") + } + XCTAssertEqual(identity.fileID, captured.id) + XCTAssertEqual(identity.standardizedPath, captured.standardizedFullPath) + } + } + + func testTokenProjectionThrowsWhenOccurrenceIdentityHasNoRequiredFact() async throws { + let root = makeRoot() + let selected = makeFile(root: root, path: "Selected.swift") + let mismatched = WorkspaceFileRecord( + id: selected.id, + rootID: selected.rootID, + name: selected.name, + relativePath: "Renamed.swift", + fullPath: root.fullPath + "/Renamed.swift", + parentFolderID: nil + ) + let selection = StoredSelection(selectedPaths: [selected.fullPath]) + let capture = makeCapture( + root: root, + files: [selected], + selection: selection, + selectedPaths: [.init(input: selected.fullPath, resolution: .file(selected))] + ) + let content = "struct Selected {}\n" + let adapter = WorkspacePromptProjectionAdapter { _, _, _, _ in capture } + + do { + _ = try await adapter.projectTokens( + selection: selection, + codeMapUsage: .none, + filePathDisplay: .relative, + alternatePolicy: nil, + resolvedEntries: [ResolvedPromptFileEntry(file: mismatched, loadedContent: content)], + promptFileEntrySnapshots: [ + PromptFileEntrySnapshot( + fileID: mismatched.id, + relativePath: mismatched.relativePath, + isCodemapRequested: false, + ranges: nil, + cachedFullTokenCount: TokenCalculationService.estimateTokens(for: content), + loadedContent: content, + codeMapContent: nil, + availableCodeMapTokenCount: 0 + ) + ], + tokenProjectionInput: .activeLive(.init( + reportedTotal: 0, + prompt: 0, + fileTree: 0, + meta: 0, + git: 0 + )) + ) + XCTFail("Expected missing occurrence token facts") + } catch let error as WorkspacePromptProjectionAdapter.Error { + guard case let .missingTokenFacts(identity) = error else { + return XCTFail("Unexpected error: \(error)") + } + XCTAssertEqual(identity.fileID, selected.id) + XCTAssertEqual(identity.standardizedPath, selected.standardizedFullPath) + XCTAssertEqual(identity.mode, .full) + XCTAssertEqual(identity.ranges, []) + } + } + + func testIdenticalDuplicateSnapshotsAreConsumedFIFO() async throws { + let root = makeRoot() + let selected = makeFile(root: root, path: "Selected.swift") + let selection = StoredSelection(selectedPaths: [selected.fullPath]) + let capture = makeCapture( + root: root, + files: [selected], + selection: selection, + selectedPaths: [.init(input: selected.fullPath, resolution: .file(selected))] + ) + let recorder = SnapshotBatchRecorder() + let tokenService = TokenCalculationService() + let adapter = WorkspacePromptProjectionAdapter( + capture: { _, _, _, _ in capture }, + evaluatePromptEntries: { snapshots in + await recorder.record(snapshots) + return await tokenService.evaluatePromptEntries(snapshots) + } + ) + let resolvedEntries = [ + ResolvedPromptFileEntry(file: selected), + ResolvedPromptFileEntry(file: selected) + ] + let snapshots = [11, 22].map { tokens in + PromptFileEntrySnapshot( + fileID: selected.id, + relativePath: selected.relativePath, + isCodemapRequested: false, + ranges: nil, + cachedFullTokenCount: tokens, + loadedContent: nil, + codeMapContent: nil, + availableCodeMapTokenCount: 0 + ) + } + + do { + _ = try await adapter.projectTokens( + selection: selection, + codeMapUsage: .none, + filePathDisplay: .relative, + alternatePolicy: nil, + resolvedEntries: resolvedEntries, + promptFileEntrySnapshots: snapshots, + tokenProjectionInput: .emptyVirtual + ) + XCTFail("Expected one unused duplicate fact") + } catch let error as WorkspacePromptProjectionAdapter.Error { + guard case let .unusedTokenFacts(identities) = error else { + return XCTFail("Unexpected error: \(error)") + } + XCTAssertEqual(identities.map(\.fileID), [selected.id]) + } + let batches = await recorder.snapshot() + XCTAssertEqual(batches, [[11], [22]]) + } + + func testEarlierEvaluationFailurePrecedesLaterMissingSnapshot() async throws { + let root = makeRoot() + let first = makeFile(root: root, path: "First.swift") + let later = makeFile(root: root, path: "Later.swift") + let selection = StoredSelection(selectedPaths: [first.fullPath]) + let adapter = WorkspacePromptProjectionAdapter( + capture: { _, _, _, _ in throw TestError.unexpectedCaptureRequest }, + evaluatePromptEntries: { _ in .empty } + ) + + do { + _ = try await adapter.projectTokens( + selection: selection, + codeMapUsage: .none, + filePathDisplay: .relative, + alternatePolicy: nil, + resolvedEntries: [ + ResolvedPromptFileEntry(file: first), + ResolvedPromptFileEntry(file: later) + ], + promptFileEntrySnapshots: [ + PromptFileEntrySnapshot( + fileID: first.id, + relativePath: first.relativePath, + isCodemapRequested: false, + ranges: nil, + cachedFullTokenCount: 1, + loadedContent: nil, + codeMapContent: nil, + availableCodeMapTokenCount: 0 + ) + ], + tokenProjectionInput: .emptyVirtual + ) + XCTFail("Expected the earlier evaluation failure") + } catch let error as WorkspacePromptProjectionAdapter.Error { + guard case let .missingTokenFacts(identity) = error else { + return XCTFail("Unexpected error: \(error)") + } + XCTAssertEqual(identity.fileID, first.id) + } + } + + func testDuplicateAccountingFactsPreserveUnusedOrderAndUseOrdinalBatches() async throws { + let root = makeRoot() + let selected = makeFile(root: root, path: "Selected.swift") + let duplicate = makeFile(root: root, path: "Duplicate.swift") + let tail = makeFile(root: root, path: "Tail.swift") + let ranges = [LineRange(start: 1, end: 1)] + let selection = StoredSelection(selectedPaths: [selected.fullPath]) + let capture = makeCapture( + root: root, + files: [selected, duplicate, tail], + selection: selection, + selectedPaths: [.init(input: selected.fullPath, resolution: .file(selected))] + ) + let recorder = EvaluationBatchRecorder() + let tokenService = TokenCalculationService() + let adapter = WorkspacePromptProjectionAdapter( + capture: { _, _, _, _ in capture }, + evaluatePromptEntries: { snapshots in + await recorder.record(snapshots.count) + return await tokenService.evaluatePromptEntries(snapshots) + } + ) + let duplicateContent = "one\ntwo\n" + let resolvedEntries = [ + ResolvedPromptFileEntry(file: selected, loadedContent: "selected"), + ResolvedPromptFileEntry(file: duplicate, loadedContent: duplicateContent), + ResolvedPromptFileEntry( + file: duplicate, + lineRanges: ranges, + mode: .sliced, + loadedContent: duplicateContent + ), + ResolvedPromptFileEntry(file: tail, loadedContent: "tail") + ] + let snapshots = [ + PromptFileEntrySnapshot( + fileID: selected.id, + relativePath: selected.relativePath, + isCodemapRequested: false, + ranges: nil, + cachedFullTokenCount: 1, + loadedContent: "selected", + codeMapContent: nil, + availableCodeMapTokenCount: 0 + ), + PromptFileEntrySnapshot( + fileID: duplicate.id, + relativePath: duplicate.relativePath, + isCodemapRequested: false, + ranges: nil, + cachedFullTokenCount: 2, + loadedContent: duplicateContent, + codeMapContent: nil, + availableCodeMapTokenCount: 0 + ), + PromptFileEntrySnapshot( + fileID: duplicate.id, + relativePath: duplicate.relativePath, + isCodemapRequested: false, + ranges: ranges, + cachedFullTokenCount: 2, + loadedContent: duplicateContent, + codeMapContent: nil, + availableCodeMapTokenCount: 0 + ), + PromptFileEntrySnapshot( + fileID: tail.id, + relativePath: tail.relativePath, + isCodemapRequested: false, + ranges: nil, + cachedFullTokenCount: 1, + loadedContent: "tail", + codeMapContent: nil, + availableCodeMapTokenCount: 0 + ) + ] + + do { + _ = try await adapter.projectTokens( + selection: selection, + codeMapUsage: .none, + filePathDisplay: .relative, + alternatePolicy: nil, + resolvedEntries: resolvedEntries, + promptFileEntrySnapshots: snapshots, + tokenProjectionInput: .emptyVirtual + ) + XCTFail("Expected unused duplicate token facts") + } catch let error as WorkspacePromptProjectionAdapter.Error { + guard case let .unusedTokenFacts(identities) = error else { + return XCTFail("Unexpected error: \(error)") + } + XCTAssertEqual(identities.map(\.fileID), [duplicate.id, duplicate.id, tail.id]) + XCTAssertEqual(identities.map(\.mode), [.full, .slice, .full]) + } + let batchCounts = await recorder.snapshot() + XCTAssertEqual(batchCounts, [3, 1]) + } + + func testFiveThousandUniqueOccurrencesUseOneTokenEvaluationBatch() async throws { + let root = makeRoot() + let count = 5000 + let files = (0 ..< count).map { index in + makeFile(root: root, path: String(format: "File%05d.swift", index)) + } + let selection = StoredSelection(selectedPaths: files.map(\.fullPath)) + let capture = makeCapture( + root: root, + files: files, + selection: selection, + selectedPaths: files.map { .init(input: $0.fullPath, resolution: .file($0)) } + ) + let snapshots = files.map { file in + PromptFileEntrySnapshot( + fileID: file.id, + relativePath: file.relativePath, + isCodemapRequested: false, + ranges: nil, + cachedFullTokenCount: 1, + loadedContent: nil, + codeMapContent: nil, + availableCodeMapTokenCount: 0 + ) + } + let recorder = EvaluationBatchRecorder() + let tokenService = TokenCalculationService() + let adapter = WorkspacePromptProjectionAdapter( + capture: { _, _, _, coverage in + guard coverage == .projection(codemapCoverage: .referenced) else { + throw TestError.unexpectedCaptureRequest + } + return capture + }, + evaluatePromptEntries: { entries in + await recorder.record(entries.count) + return await tokenService.evaluatePromptEntries(entries) + } + ) + + let projection = try await adapter.projectTokens( + selection: selection, + codeMapUsage: .none, + filePathDisplay: .relative, + alternatePolicy: nil, + resolvedEntries: files.map { ResolvedPromptFileEntry(file: $0) }, + promptFileEntrySnapshots: snapshots, + tokenProjectionInput: .emptyVirtual + ) + + XCTAssertEqual(projection.selection.files.count, count) + let batchCounts = await recorder.snapshot() + XCTAssertEqual(batchCounts, [count]) + } + + @MainActor + func testLiveMappingRequiresCurrentRecordIdentityAndPreservesProjectedModeAndRanges() { + let root = makeRoot() + let sliced = makeFile(root: root, path: "Sliced.swift") + let codemap = makeFile(root: root, path: "Codemap.swift") + let stale = makeFile(root: root, path: "Stale.swift") + let ranges = [LineRange(start: 2, end: 3)] + let slicedViewModel = makeFileViewModel(sliced, root: root) + let codemapViewModel = makeFileViewModel(codemap, root: root) + let replacementAtStalePath = makeFileViewModel( + WorkspaceFileRecord( + id: UUID(), + rootID: stale.rootID, + name: stale.name, + relativePath: stale.relativePath, + fullPath: stale.fullPath, + parentFolderID: stale.parentFolderID + ), + root: root + ) + let projection = WorkspacePromptProjectionAdapter.Projection( + provenance: makeProvenance(), + entries: [ + .init( + file: sliced, + metadata: makeMetadata(file: sliced, root: root), + mode: .slice, + ranges: ranges, + codemapOrigin: nil + ), + .init( + file: codemap, + metadata: makeMetadata(file: codemap, root: root), + mode: .codemap, + ranges: nil, + codemapOrigin: .auto + ), + .init( + file: stale, + metadata: makeMetadata(file: stale, root: root), + mode: .full, + ranges: nil, + codemapOrigin: nil + ) + ] + ) + let filesByPath = [ + sliced.standardizedFullPath: slicedViewModel, + codemap.standardizedFullPath: codemapViewModel, + stale.standardizedFullPath: replacementAtStalePath + ] + let adapter = WorkspacePromptProjectionAdapter { _, _, _, _ in + throw TestError.unexpectedCaptureRequest + } + + let entries = adapter.mapToLivePromptEntries(projection) { file in + filesByPath[file.standardizedFullPath] + } + + XCTAssertEqual(entries.map(\.file.id), [sliced.id, codemap.id]) + XCTAssertEqual(entries.map(\.isCodemap), [false, true]) + XCTAssertEqual(entries.map(\.ranges), [ranges, nil]) + } + + private func makeCapture( + root: WorkspaceRootRecord, + files: [WorkspaceFileRecord], + folders: [WorkspaceFolderRecord] = [], + selection: StoredSelection, + selectedPaths: [WorkspaceFileContextCapture.SelectionPath], + autoCodemapPaths: [WorkspaceFileContextCapture.SelectionPath] = [], + slices: [WorkspaceFileContextCapture.Slice] = [], + codemapSnapshots: [WorkspaceCodemapSnapshot] = [] + ) -> WorkspaceFileContextCapture { + let provenance = makeProvenance() + let diagnostics = WorkspaceCatalogDiagnostics( + generation: provenance.catalogGeneration, + rootScope: provenance.rootScope, + rootCount: 1, + folderCount: folders.count, + fileCount: files.count + ) + return WorkspaceFileContextCapture( + provenance: provenance, + storedSelection: selection, + selectedPaths: selectedPaths, + autoCodemapPaths: autoCodemapPaths, + slices: slices, + catalog: WorkspaceSearchCatalogSnapshot( + generation: provenance.catalogGeneration, + rootScope: provenance.rootScope, + roots: [root], + files: files, + entries: files.map { WorkspaceSearchCatalogEntry(file: $0, root: root) }, + diagnostics: diagnostics + ), + materializedFolders: folders, + materializedFiles: files, + codemapSnapshots: codemapSnapshots, + fileTree: FileTreeSelectionSnapshot( + roots: [], + selectedFileIDs: Set(files.map(\.id)), + mode: "none", + showFullPaths: false, + onlyIncludeRootsWithSelectedFiles: false, + includeLegend: false + ) + ) + } + + private func makeProvenance() -> WorkspaceFileContextCapture.Provenance { + WorkspaceFileContextCapture.Provenance( + captureGeneration: 17, + catalogGeneration: 11, + catalogValidationToken: 23, + rootScope: .allLoaded, + ingressSamples: [] + ) + } + + private func makeRoot() -> WorkspaceRootRecord { + WorkspaceRootRecord( + id: UUID(uuidString: "AAAAAAAA-AAAA-AAAA-AAAA-AAAAAAAAAAAA")!, + name: "Repo", + fullPath: "/repo" + ) + } + + private func makeFolder(root: WorkspaceRootRecord, path: String) -> WorkspaceFolderRecord { + WorkspaceFolderRecord( + id: UUID(uuidString: "BBBBBBBB-BBBB-BBBB-BBBB-BBBBBBBBBBBB")!, + rootID: root.id, + name: (path as NSString).lastPathComponent, + relativePath: path, + fullPath: root.fullPath + "/" + path, + parentFolderID: nil + ) + } + + private func makeFile( + root: WorkspaceRootRecord, + path: String, + parentFolderID: UUID? = nil + ) -> WorkspaceFileRecord { + WorkspaceFileRecord( + rootID: root.id, + name: (path as NSString).lastPathComponent, + relativePath: path, + fullPath: root.fullPath + "/" + path, + parentFolderID: parentFolderID + ) + } + + private func makeCodemap( + file: WorkspaceFileRecord, + root: WorkspaceRootRecord, + symbol: String + ) -> WorkspaceCodemapSnapshot { + WorkspaceCodemapSnapshot( + fileID: file.id, + rootID: root.id, + rootPath: root.fullPath, + relativePath: file.relativePath, + fullPath: file.fullPath, + modificationDate: Date(timeIntervalSince1970: 0), + fileAPI: FileAPI( + filePath: file.fullPath, + imports: [], + classes: [.init(name: symbol, methods: [], properties: [])], + functions: [], + enums: [], + globalVars: [], + macros: [], + referencedTypes: [] + ) + ) + } + + private func makeMetadata( + file: WorkspaceFileRecord, + root: WorkspaceRootRecord + ) -> WorkspaceSelectionProjection.PathMetadata { + .init( + displayPath: file.relativePath, + rootPath: root.fullPath, + pathWithinRoot: file.relativePath + ) + } + + @MainActor + private func makeFileViewModel( + _ record: WorkspaceFileRecord, + root: WorkspaceRootRecord + ) -> FileViewModel { + FileViewModel( + file: File( + id: record.id, + name: record.name, + path: record.fullPath, + modificationDate: Date(timeIntervalSince1970: 0) + ), + rootPath: root.fullPath, + rootIdentifier: root.id, + rootFolderPath: root.fullPath, + fileSystemService: nil, + relativePathOverride: record.relativePath + ) + } +} diff --git a/Tests/RepoPromptTests/Security/AgentPermissionSecureStoreTests.swift b/Tests/RepoPromptTests/Security/AgentPermissionSecureStoreTests.swift index a49fe5ca8..3cfa216cc 100644 --- a/Tests/RepoPromptTests/Security/AgentPermissionSecureStoreTests.swift +++ b/Tests/RepoPromptTests/Security/AgentPermissionSecureStoreTests.swift @@ -1,5 +1,6 @@ import Foundation @testable import RepoPrompt +@testable import RepoPromptCore import XCTest final class AgentPermissionSecureStoreTests: XCTestCase { @@ -90,7 +91,7 @@ final class AgentPermissionSecureStoreTests: XCTestCase { @MainActor func testCodexPermissionReadInteractionDeniedFailsClosedAndMarksDiagnosticsDegraded() throws { - let secureStrings = FakeSecurePlainStringStore(plainGetError: KeychainService.KeychainError.interactionNotAllowed) + let secureStrings = FakeSecurePlainStringStore(plainGetError: SecureStorageError.interactionNotAllowed) let store = makeStore(secureStrings: secureStrings) let permissions = store.codexPermissions() @@ -196,9 +197,9 @@ private final class FakeSecurePlainStringStore: SecurePlainStringStoring { var saveError: Error? var failSaveKeys: Set<String> = [] - private(set) var plainGetAccessModes: [KeychainAccessMode] = [] - private(set) var plainSaveAccessModes: [KeychainAccessMode] = [] - private(set) var plainDeleteAccessModes: [KeychainAccessMode] = [] + private(set) var plainGetAccessModes: [SecureStorageAccessMode] = [] + private(set) var plainSaveAccessModes: [SecureStorageAccessMode] = [] + private(set) var plainDeleteAccessModes: [SecureStorageAccessMode] = [] private(set) var savedPlainValues: [(key: String, value: String)] = [] init( @@ -215,32 +216,32 @@ private final class FakeSecurePlainStringStore: SecurePlainStringStoring { self.persistsValuesAcrossLaunches = persistsValuesAcrossLaunches } - func getPlainValue(for account: SecureStorageAccount, accessMode: KeychainAccessMode) throws -> String? { + func getPlainValue(for key: String, accessMode: SecureStorageAccessMode) throws -> String? { plainGetAccessModes.append(accessMode) if let plainGetError { throw plainGetError } - return plainValues[account.identifier] + return plainValues[key] } func savePlainValue( _ value: String, - for account: SecureStorageAccount, - accessMode: KeychainAccessMode + for key: String, + accessMode: SecureStorageAccessMode ) throws { plainSaveAccessModes.append(accessMode) if let saveError { throw saveError } - if failSaveKeys.contains(account.identifier) { - throw KeychainService.KeychainError.invalidData + if failSaveKeys.contains(key) { + throw SecureStorageError.invalidData } - plainValues[account.identifier] = value - savedPlainValues.append((key: account.identifier, value: value)) + plainValues[key] = value + savedPlainValues.append((key: key, value: value)) } - func deletePlainValue(for account: SecureStorageAccount, accessMode: KeychainAccessMode) throws { + func deletePlainValue(for key: String, accessMode: SecureStorageAccessMode) throws { plainDeleteAccessModes.append(accessMode) - plainValues.removeValue(forKey: account.identifier) + plainValues.removeValue(forKey: key) } } diff --git a/Tests/RepoPromptTests/Security/DebugSecureStorageRuntimePolicyTests.swift b/Tests/RepoPromptTests/Security/DebugSecureStorageRuntimePolicyTests.swift index b2d185a14..dc20a2872 100644 --- a/Tests/RepoPromptTests/Security/DebugSecureStorageRuntimePolicyTests.swift +++ b/Tests/RepoPromptTests/Security/DebugSecureStorageRuntimePolicyTests.swift @@ -1,4 +1,6 @@ @testable import RepoPrompt +@testable import RepoPromptCore +@testable import RepoPromptCoreMacOS import XCTest final class RuntimeCodeSigningPolicyTests: XCTestCase { diff --git a/Tests/RepoPromptTests/Security/KeychainServiceTests.swift b/Tests/RepoPromptTests/Security/KeychainServiceTests.swift index cc776f2f8..81e964906 100644 --- a/Tests/RepoPromptTests/Security/KeychainServiceTests.swift +++ b/Tests/RepoPromptTests/Security/KeychainServiceTests.swift @@ -1,5 +1,7 @@ import Foundation @testable import RepoPrompt +@testable import RepoPromptCore +@testable import RepoPromptCoreMacOS import Security import XCTest @@ -34,7 +36,7 @@ final class KeychainServiceTests: XCTestCase { } func testReadMapsSecurityStatusesToSanitizedErrors() { - let scenarios: [(OSStatus, KeychainService.KeychainError)] = [ + let scenarios: [(OSStatus, SecureStorageError)] = [ (errSecItemNotFound, .itemNotFound), (errSecInteractionNotAllowed, .interactionNotAllowed), (errSecUserCanceled, .userInteractionCancelled), @@ -48,7 +50,7 @@ final class KeychainServiceTests: XCTestCase { try service.get(for: "api-key", accessMode: .nonInteractive(reason: .test)), "status=\(status)" ) { error in - XCTAssertEqual(error as? KeychainService.KeychainError, expectedError, "status=\(status)") + XCTAssertEqual(error as? SecureStorageError, expectedError, "status=\(status)") } } } @@ -63,7 +65,7 @@ final class KeychainServiceTests: XCTestCase { XCTAssertThrowsError( try service.get(for: "api-key", accessMode: .nonInteractive(reason: .test)) ) { error in - XCTAssertEqual(error as? KeychainService.KeychainError, .itemNotFound) + XCTAssertEqual(error as? SecureStorageError, .itemNotFound) } XCTAssertEqual(fake.copyQueries.map { $0.stringValue(for: kSecAttrService) }, [canonicalService]) @@ -87,7 +89,7 @@ final class KeychainServiceTests: XCTestCase { XCTAssertThrowsError( try service.get(for: "api-key", accessMode: .nonInteractive(reason: .test)) ) { error in - XCTAssertEqual(error as? KeychainService.KeychainError, .interactionNotAllowed) + XCTAssertEqual(error as? SecureStorageError, .interactionNotAllowed) } XCTAssertEqual(fake.copyQueries.map { $0.stringValue(for: kSecAttrService) }, [canonicalService]) diff --git a/Tests/RepoPromptTests/Security/LocalSigningIdentityRegistryTests.swift b/Tests/RepoPromptTests/Security/LocalSigningIdentityRegistryTests.swift index 3d2888000..82f7d8213 100644 --- a/Tests/RepoPromptTests/Security/LocalSigningIdentityRegistryTests.swift +++ b/Tests/RepoPromptTests/Security/LocalSigningIdentityRegistryTests.swift @@ -1,5 +1,6 @@ import Darwin @testable import RepoPrompt +@testable import RepoPromptCoreMacOS import XCTest final class LocalSigningIdentityRegistryTests: XCTestCase { diff --git a/Tests/RepoPromptTests/Security/SecureStorageAccountCatalogTests.swift b/Tests/RepoPromptTests/Security/SecureStorageAccountCatalogTests.swift index b08767f55..d9b08bf60 100644 --- a/Tests/RepoPromptTests/Security/SecureStorageAccountCatalogTests.swift +++ b/Tests/RepoPromptTests/Security/SecureStorageAccountCatalogTests.swift @@ -99,13 +99,15 @@ final class SecureStorageAccountCatalogTests: XCTestCase { func testSecureStorageBackendBoundaryRemainsCentralized() throws { let root = try RepoRoot.url() - let sourceRoot = root.appendingPathComponent("Sources/RepoPrompt", isDirectory: true) + let sourceRoot = root.appendingPathComponent("Sources", isDirectory: true) let allowedFiles: Set = [ - "Sources/RepoPrompt/Infrastructure/Security/EphemeralSecureKeyValueStore.swift", - "Sources/RepoPrompt/Infrastructure/Security/KeychainService.swift", - "Sources/RepoPrompt/Infrastructure/Security/SecureKeyService.swift", - "Sources/RepoPrompt/Infrastructure/Security/SecureKeyValueStorageBackend.swift", - "Sources/RepoPrompt/Infrastructure/Security/SecureStorageRepairService.swift" + "Sources/RepoPrompt/Infrastructure/Security/MacOS/AppSecureKeyValueStorageFactory.swift", + "Sources/RepoPrompt/Infrastructure/Security/SecureStorageRepairService.swift", + "Sources/RepoPromptCore/Platform/RepoPromptCorePlatformDependencies.swift", + "Sources/RepoPromptCore/Platform/SecureKeyValueStorageBackend.swift", + "Sources/RepoPromptCore/Security/EphemeralSecureKeyValueStore.swift", + "Sources/RepoPromptCore/Security/SecureKeyService.swift", + "Sources/RepoPromptCoreMacOS/Security/KeychainService.swift" ] var filesUsingBackend: Set<String> = [] diff --git a/Tests/RepoPromptTests/Security/SecureStorageRepairServiceTests.swift b/Tests/RepoPromptTests/Security/SecureStorageRepairServiceTests.swift index db1414c2d..585bb224e 100644 --- a/Tests/RepoPromptTests/Security/SecureStorageRepairServiceTests.swift +++ b/Tests/RepoPromptTests/Security/SecureStorageRepairServiceTests.swift @@ -1,5 +1,6 @@ import Foundation @testable import RepoPrompt +@testable import RepoPromptCore import XCTest final class SecureStorageRepairServiceTests: XCTestCase { @@ -133,13 +134,13 @@ private final class FakeSecureStorageBackend: SecureKeyValueStorageBackend, @unc struct Call: Equatable { let operation: Operation let account: SecureStorageAccount - let accessMode: KeychainAccessMode + let accessMode: SecureStorageAccessMode } let persistsValuesAcrossLaunches = true - var getErrors: [SecureStorageAccount: KeychainService.KeychainError] = [:] - var saveErrors: [SecureStorageAccount: KeychainService.KeychainError] = [:] - var deleteErrors: [SecureStorageAccount: KeychainService.KeychainError] = [:] + var getErrors: [SecureStorageAccount: SecureStorageError] = [:] + var saveErrors: [SecureStorageAccount: SecureStorageError] = [:] + var deleteErrors: [SecureStorageAccount: SecureStorageError] = [:] var savedValueOverride: String? private var values: [String: String] @@ -150,7 +151,7 @@ private final class FakeSecureStorageBackend: SecureKeyValueStorageBackend, @unc self.values = Dictionary(uniqueKeysWithValues: values.map { ($0.key.identifier, $0.value) }) } - func save(_ value: String, for key: String, accessMode: KeychainAccessMode) throws { + func save(_ value: String, for key: String, accessMode: SecureStorageAccessMode) throws { try withLock { let account = try account(for: key) calls.append(Call(operation: .save, account: account, accessMode: accessMode)) @@ -159,17 +160,17 @@ private final class FakeSecureStorageBackend: SecureKeyValueStorageBackend, @unc } } - func get(for key: String, accessMode: KeychainAccessMode) throws -> String { + func get(for key: String, accessMode: SecureStorageAccessMode) throws -> String { try withLock { let account = try account(for: key) calls.append(Call(operation: .get, account: account, accessMode: accessMode)) if let error = getErrors[account] { throw error } - guard let value = values[key] else { throw KeychainService.KeychainError.itemNotFound } + guard let value = values[key] else { throw SecureStorageError.itemNotFound } return value } } - func delete(for key: String, accessMode: KeychainAccessMode) throws { + func delete(for key: String, accessMode: SecureStorageAccessMode) throws { try withLock { let account = try account(for: key) calls.append(Call(operation: .delete, account: account, accessMode: accessMode)) @@ -184,7 +185,7 @@ private final class FakeSecureStorageBackend: SecureKeyValueStorageBackend, @unc private func account(for key: String) throws -> SecureStorageAccount { guard let account = SecureStorageAccountCatalog.allAccounts.first(where: { $0.identifier == key }) else { - throw KeychainService.KeychainError.itemNotFound + throw SecureStorageError.itemNotFound } return account } diff --git a/Tests/RepoPromptTests/Security/SecureStorageRepairViewModelTests.swift b/Tests/RepoPromptTests/Security/SecureStorageRepairViewModelTests.swift index 298a24b39..95f925c19 100644 --- a/Tests/RepoPromptTests/Security/SecureStorageRepairViewModelTests.swift +++ b/Tests/RepoPromptTests/Security/SecureStorageRepairViewModelTests.swift @@ -1,5 +1,6 @@ import Foundation @testable import RepoPrompt +@testable import RepoPromptCore import XCTest @MainActor @@ -35,14 +36,14 @@ private final class CountingSecureStorageBackend: SecureKeyValueStorageBackend, private(set) var getCount = 0 private let lock = NSLock() - func save(_ value: String, for key: String, accessMode: KeychainAccessMode) throws {} + func save(_ value: String, for key: String, accessMode: SecureStorageAccessMode) throws {} - func get(for key: String, accessMode: KeychainAccessMode) throws -> String { + func get(for key: String, accessMode: SecureStorageAccessMode) throws -> String { lock.lock() getCount += 1 lock.unlock() - throw KeychainService.KeychainError.itemNotFound + throw SecureStorageError.itemNotFound } - func delete(for key: String, accessMode: KeychainAccessMode) throws {} + func delete(for key: String, accessMode: SecureStorageAccessMode) throws {} } diff --git a/Tests/RepoPromptTests/Services/FileSystem/FileSystemContentLoadingConcurrencyTests.swift b/Tests/RepoPromptTests/Services/FileSystem/FileSystemContentLoadingConcurrencyTests.swift index ab5bf73b2..d750ca5d6 100644 --- a/Tests/RepoPromptTests/Services/FileSystem/FileSystemContentLoadingConcurrencyTests.swift +++ b/Tests/RepoPromptTests/Services/FileSystem/FileSystemContentLoadingConcurrencyTests.swift @@ -1,5 +1,5 @@ -import CoreServices @testable import RepoPrompt +@testable import RepoPromptCore import XCTest final class FileSystemContentLoadingConcurrencyTests: XCTestCase { @@ -311,10 +311,12 @@ final class FileSystemContentLoadingConcurrencyTests: XCTestCase { let queued = Task { try await EditFlowPerf.$currentLifecycleCorrelation.withValue(correlation) { - try await service.loadContent( - ofRelativePath: queuedPath, - workloadClass: .interactiveRead - ) + try await withEmbeddedWorkspaceRuntimeDiagnostics { + try await service.loadContent( + ofRelativePath: queuedPath, + workloadClass: .interactiveRead + ) + } } } let waitBegan = await waitForLifecycleEvent( @@ -376,10 +378,12 @@ final class FileSystemContentLoadingConcurrencyTests: XCTestCase { let cancelled = Task { try await EditFlowPerf.$currentLifecycleCorrelation.withValue(correlation) { - try await service.loadContent( - ofRelativePath: "Cancelled.txt", - workloadClass: .interactiveRead - ) + try await withEmbeddedWorkspaceRuntimeDiagnostics { + try await service.loadContent( + ofRelativePath: "Cancelled.txt", + workloadClass: .interactiveRead + ) + } } } let waitBegan = await waitForLifecycleEvent( @@ -705,8 +709,8 @@ final class FileSystemContentLoadingConcurrencyTests: XCTestCase { } #endif - private var createdFileFlags: FSEventStreamEventFlags { - FSEventStreamEventFlags(kFSEventStreamEventFlagItemCreated | kFSEventStreamEventFlagItemIsFile) + private var createdFileFlags: FileSystemWatchEventFlags { + [.itemCreated, .itemIsFile] } private func makeService(root: URL, skipSymlinks: Bool = true) async throws -> FileSystemService { diff --git a/Tests/RepoPromptTests/Services/FileSystem/IgnoreDebugMetricsRecorderTests.swift b/Tests/RepoPromptTests/Services/FileSystem/IgnoreDebugMetricsRecorderTests.swift index 94f33f1cd..1bc037485 100644 --- a/Tests/RepoPromptTests/Services/FileSystem/IgnoreDebugMetricsRecorderTests.swift +++ b/Tests/RepoPromptTests/Services/FileSystem/IgnoreDebugMetricsRecorderTests.swift @@ -1,5 +1,6 @@ #if DEBUG @testable import RepoPrompt + @testable import RepoPromptCore import XCTest final class IgnoreDebugMetricsRecorderTests: XCTestCase { diff --git a/Tests/RepoPromptTests/WorkspaceContext/WorkspaceFileContextStoreTests.swift b/Tests/RepoPromptTests/WorkspaceContext/WorkspaceFileContextStoreTests.swift index 09d59ea8a..47eedca38 100644 --- a/Tests/RepoPromptTests/WorkspaceContext/WorkspaceFileContextStoreTests.swift +++ b/Tests/RepoPromptTests/WorkspaceContext/WorkspaceFileContextStoreTests.swift @@ -1,5 +1,5 @@ -import CoreServices @testable import RepoPrompt +@testable import RepoPromptCore import XCTest final class WorkspaceFileContextStoreTests: XCTestCase { @@ -60,7 +60,7 @@ final class WorkspaceFileContextStoreTests: XCTestCase { WorkspaceObservedCodemapResult(fullPath: fileURL.path, modificationDate: Date(), fileAPI: makeFileAPI(path: fileURL.path)) ]) - let service = PromptContextAccountingService() + let service = RepoPrompt.PromptContextAccountingService() let selection = StoredSelection( selectedPaths: [fileURL.path], autoCodemapPaths: [], @@ -814,8 +814,393 @@ final class WorkspaceFileContextStoreTests: XCTestCase { XCTAssertEqual(buckets.first(where: { $0.sanitizedDimensions.contains("cacheHit=true") })?.sampleCount, 1) XCTAssertEqual(capture.droppedSampleCount, 0) } + + func testWorkspaceCaptureRetriesAcrossCatalogMutationWithoutMixingGenerations() async throws { + let root = try makeTemporaryRoot(name: "WorkspaceCaptureGenerationRace") + let originalURL = root.appendingPathComponent("Original.swift") + let replacementURL = root.appendingPathComponent("Replacement.swift") + try write("original", to: originalURL) + + let store = WorkspaceFileContextStore() + let record = try await store.loadRoot(path: root.path) + await store.applyObservedCodemapResults([ + WorkspaceObservedCodemapResult( + fullPath: originalURL.path, + modificationDate: Date(), + fileAPI: makeFileAPI(path: originalURL.path, symbolName: "OriginalSymbol") + ) + ]) + let initialCatalogGeneration = await store.catalogGeneration(rootScope: .visibleWorkspace) + let sliceRanges = [ + LineRange(start: 8, end: 10, description: "later"), + LineRange(start: 1, end: 2, description: "earlier") + ] + let selection = StoredSelection( + selectedPaths: [originalURL.path], + autoCodemapPaths: [originalURL.path], + slices: [originalURL.path: sliceRanges], + codemapAutoEnabled: false + ) + let treeRequest = WorkspaceFileTreeSnapshotRequest( + mode: .full, + filePathDisplay: .relative, + onlyIncludeRootsWithSelectedFiles: false, + includeLegend: false, + rootScope: .visibleWorkspace + ) + + let preparationGate = AsyncGate() + await store.setWorkspaceCaptureDidPrepareHandler { attempt, _ in + guard attempt == 1 else { return } + await preparationGate.markStartedAndWaitForRelease() + } + let captureTask = Task { + try await store.captureWorkspaceFileContext( + selection: selection, + fileTreeRequest: treeRequest, + profile: .mcpRead + ) + } + await preparationGate.waitUntilStarted() + + try FileManager.default.removeItem(at: originalURL) + try write("replacement", to: replacementURL) + await store.replayObservedFileSystemDeltas( + rootID: record.id, + deltas: [.fileRemoved("Original.swift"), .fileAdded("Replacement.swift")] + ) + await store.applyObservedCodemapResults([ + WorkspaceObservedCodemapResult( + fullPath: replacementURL.path, + modificationDate: Date(), + fileAPI: makeFileAPI(path: replacementURL.path, symbolName: "ReplacementSymbol") + ) + ]) + await preparationGate.release() + + let capture = try await captureTask.value + await store.setWorkspaceCaptureDidPrepareHandler(nil) + + let preparationStartCount = await preparationGate.startCount() + XCTAssertEqual(preparationStartCount, 1) + XCTAssertEqual(capture.storedSelection, selection) + XCTAssertEqual(capture.provenance.captureGeneration, 1) + XCTAssertGreaterThan(capture.provenance.catalogValidationToken, initialCatalogGeneration) + XCTAssertEqual(capture.provenance.catalogGeneration, capture.catalog.generation) + XCTAssertEqual(capture.provenance.rootScope, .visibleWorkspace) + XCTAssertEqual(capture.provenance.ingressSamples.map(\.rootID), [record.id]) + XCTAssertEqual(capture.catalog.files.map(\.standardizedRelativePath), ["Replacement.swift"]) + XCTAssertEqual(capture.codemapSnapshots.map(\.relativePath), ["Replacement.swift"]) + XCTAssertEqual(capture.slices.map(\.ranges), [sliceRanges]) + XCTAssertNil(capture.slices.first?.file) + XCTAssertEqual(capture.slices.first?.issue, .unresolved(input: originalURL.path)) + XCTAssertTrue(capture.fileTree.selectedFileIDs.isEmpty) + + XCTAssertEqual(capture.selectedPaths.count, 1) + if case .unresolved = capture.selectedPaths[0].resolution { + // Expected from the post-mutation generation. + } else { + XCTFail("Expected removed selected path to be unresolved in the retried capture") + } + XCTAssertEqual(capture.autoCodemapPaths.count, 1) + if case .unresolved = capture.autoCodemapPaths[0].resolution { + // Expected from the post-mutation generation. + } else { + XCTFail("Expected removed codemap path to be unresolved in the retried capture") + } + + let tree = CodeMapExtractor.generateFileTree(using: capture.fileTree) + XCTAssertTrue(tree.contains("Replacement.swift +")) + XCTAssertFalse(tree.contains("Original.swift")) + } + + func testWorkspaceCaptureFreezesCodemapAndTreeTogetherWithoutCatalogRetry() async throws { + let root = try makeTemporaryRoot(name: "WorkspaceCaptureCodemapRace") + let fileURL = root.appendingPathComponent("Codemap.swift") + try write("struct Codemap {}", to: fileURL) + + let store = WorkspaceFileContextStore() + _ = try await store.loadRoot(path: root.path) + let initialCatalogGeneration = await store.catalogGeneration(rootScope: .visibleWorkspace) + let treeRequest = WorkspaceFileTreeSnapshotRequest( + mode: .full, + filePathDisplay: .relative, + onlyIncludeRootsWithSelectedFiles: false, + includeLegend: false, + rootScope: .visibleWorkspace + ) + + let preparationGate = AsyncGate() + await store.setWorkspaceCaptureDidPrepareHandler { attempt, _ in + guard attempt == 1 else { return } + await preparationGate.markStartedAndWaitForRelease() + } + let captureTask = Task { + try await store.captureWorkspaceFileContext( + selection: StoredSelection(), + fileTreeRequest: treeRequest, + profile: .mcpRead + ) + } + await preparationGate.waitUntilStarted() + + await store.applyObservedCodemapResults([ + WorkspaceObservedCodemapResult( + fullPath: fileURL.path, + modificationDate: Date(), + fileAPI: makeFileAPI(path: fileURL.path, symbolName: "CodemapSymbol") + ) + ]) + await preparationGate.release() + + let capture = try await captureTask.value + await store.setWorkspaceCaptureDidPrepareHandler(nil) + let preparationStartCount = await preparationGate.startCount() + + XCTAssertEqual(preparationStartCount, 1) + XCTAssertEqual(capture.provenance.catalogValidationToken, initialCatalogGeneration) + XCTAssertEqual(capture.codemapSnapshots.map(\.relativePath), ["Codemap.swift"]) + let tree = CodeMapExtractor.generateFileTree(using: capture.fileTree) + XCTAssertTrue(tree.contains("Codemap.swift +")) + } + + func testCancelledWorkspaceCaptureLeavesBarrierWatcherAndCaptureStateHealthy() async throws { + let root = try makeTemporaryRoot(name: "WorkspaceCaptureCancellation") + try write("seed", to: root.appendingPathComponent("Seed.swift")) + let lateURL = root.appendingPathComponent("Late.swift") + + let store = WorkspaceFileContextStore() + let record = try await store.loadRoot(path: root.path) + try await store.startWatchingRoot(id: record.id) + let rootID = record.id + let treeRequest = WorkspaceFileTreeSnapshotRequest( + mode: .full, + filePathDisplay: .relative, + onlyIncludeRootsWithSelectedFiles: false, + includeLegend: false, + rootScope: .visibleWorkspace + ) + + let barrierGate = AsyncGate() + await store.setScopedIngressBarrierWillFlushHandler { observedRootID in + guard observedRootID == rootID else { return } + await barrierGate.markStartedAndWaitForRelease() + } + let cancelledCapture = Task { + try await store.captureWorkspaceFileContext( + selection: StoredSelection(), + fileTreeRequest: treeRequest, + profile: .mcpRead + ) + } + await barrierGate.waitUntilStarted() + cancelledCapture.cancel() + await barrierGate.release() + + do { + _ = try await cancelledCapture.value + XCTFail("Expected workspace capture cancellation") + } catch is CancellationError { + // Expected after the shared barrier completes without being cancelled. + } + await store.setScopedIngressBarrierWillFlushHandler(nil) + + let watcherActiveAfterCancellation = try await store.rootWatcherIsActiveForTesting(rootID: rootID) + let pendingIngressAfterCancellation = await store.publisherIngressCountForTesting(rootID: rootID) + XCTAssertTrue(watcherActiveAfterCancellation) + XCTAssertEqual(pendingIngressAfterCancellation, 0) + + try write("late", to: lateURL) + try await store.publishSyntheticFileSystemDeltasForTesting( + rootID: rootID, + deltas: [.fileAdded("Late.swift")] + ) + let capture = try await store.captureWorkspaceFileContext( + selection: StoredSelection(), + fileTreeRequest: treeRequest, + profile: .mcpRead + ) + + XCTAssertEqual(capture.provenance.captureGeneration, 1) + XCTAssertEqual(capture.catalog.files.map(\.standardizedRelativePath), ["Late.swift", "Seed.swift"]) + XCTAssertGreaterThan(capture.provenance.ingressSamples.first?.appliedServicePublicationSequence ?? 0, 0) + let pendingIngressAfterRecovery = await store.publisherIngressCountForTesting(rootID: rootID) + let watcherActiveAfterRecovery = try await store.rootWatcherIsActiveForTesting(rootID: rootID) + XCTAssertEqual(pendingIngressAfterRecovery, 0) + XCTAssertTrue(watcherActiveAfterRecovery) + await store.stopWatchingRoot(id: rootID) + } #endif + func testWorkspaceCaptureIsSendable() { + func requireSendable(_: (some Sendable).Type) {} + requireSendable(WorkspaceFileContextCapture.self) + } + + func testWorkspaceCaptureIncludesManagedOnlyRecordsAndPreservesSelectionOrdering() async throws { + let root = try makeTemporaryRoot(name: "WorkspaceCaptureManagedOnly") + try write("*.ignored\n", to: root.appendingPathComponent(".gitignore")) + let visibleURL = root.appendingPathComponent("Visible.swift") + try write("visible", to: visibleURL) + + let store = WorkspaceFileContextStore() + _ = try await store.loadRoot(path: root.path) + let host = WorkspaceFileEditHost( + store: store, + lookupRootScope: .visibleWorkspace, + createPathResolutionPolicy: .canonicalAliasFirst, + selectCreatedFiles: false + ) + try await host.writeText(path: "Hidden.ignored", content: "hidden", overwrite: false) + let hiddenURL = root.appendingPathComponent("Hidden.ignored") + + var slices: [String: [LineRange]] = [:] + slices[visibleURL.path] = [LineRange(start: 5, end: 7)] + slices[hiddenURL.path] = [LineRange(start: 1, end: 2)] + let expectedSliceOrder = Array(slices.keys) + let selection = StoredSelection( + selectedPaths: [hiddenURL.path, visibleURL.path], + autoCodemapPaths: [visibleURL.path, hiddenURL.path], + slices: slices, + codemapAutoEnabled: false + ) + let capture = try await store.captureWorkspaceFileContext( + selection: selection, + fileTreeRequest: WorkspaceFileTreeSnapshotRequest( + mode: .selected, + filePathDisplay: .relative, + onlyIncludeRootsWithSelectedFiles: true, + includeLegend: false, + rootScope: .visibleWorkspace + ), + profile: .mcpRead + ) + + XCTAssertEqual(capture.storedSelection, selection) + XCTAssertEqual(capture.selectedPaths.map(\.input), selection.selectedPaths) + XCTAssertEqual(capture.autoCodemapPaths.map(\.input), selection.autoCodemapPaths) + XCTAssertEqual(capture.slices.map(\.path), expectedSliceOrder) + XCTAssertFalse(capture.catalog.files.contains { $0.standardizedFullPath == hiddenURL.path }) + XCTAssertTrue(capture.materializedFiles.contains { $0.standardizedFullPath == hiddenURL.path }) + + guard case let .file(hiddenFile) = capture.selectedPaths[0].resolution else { + return XCTFail("Expected managed-only selected file to resolve") + } + XCTAssertTrue(capture.fileTree.selectedFileIDs.contains(hiddenFile.id)) + XCTAssertTrue(capture.materializedFiles.contains { $0.id == hiddenFile.id }) + let tree = CodeMapExtractor.generateFileTree(using: capture.fileTree) + XCTAssertTrue(tree.contains("Hidden.ignored *")) + } + + func testProjectionCaptureCoverageScopesCatalogRecordsAndCodemaps() async throws { + let root = try makeTemporaryRoot(name: "ProjectionCaptureCoverage") + let selectedURL = root.appendingPathComponent("Selected.swift") + let autoURL = root.appendingPathComponent("Auto.swift") + let sliceURL = root.appendingPathComponent("Slice.swift") + let completeOnlyURL = root.appendingPathComponent("CompleteOnly.swift") + let unrelatedURL = root.appendingPathComponent("Unrelated.swift") + let selectedFolderURL = root.appendingPathComponent("Sources", isDirectory: true) + try FileManager.default.createDirectory(at: selectedFolderURL, withIntermediateDirectories: true) + let folderFirstURL = selectedFolderURL.appendingPathComponent("First.swift") + let folderSecondURL = selectedFolderURL.appendingPathComponent("Second.swift") + for url in [ + selectedURL, + autoURL, + sliceURL, + completeOnlyURL, + unrelatedURL, + folderFirstURL, + folderSecondURL + ] { + try write("struct \(url.deletingPathExtension().lastPathComponent) {}", to: url) + } + + let store = WorkspaceFileContextStore() + _ = try await store.loadRoot(path: root.path) + await store.applyObservedCodemapResults([ + WorkspaceObservedCodemapResult( + fullPath: autoURL.path, + modificationDate: Date(timeIntervalSince1970: 1), + fileAPI: makeFileAPI(path: autoURL.path, symbolName: "AutoSymbol") + ), + WorkspaceObservedCodemapResult( + fullPath: completeOnlyURL.path, + modificationDate: Date(timeIntervalSince1970: 2), + fileAPI: makeFileAPI(path: completeOnlyURL.path, symbolName: "CompleteSymbol") + ) + ]) + let ranges = [LineRange(start: 1, end: 1)] + let selection = StoredSelection( + selectedPaths: [selectedURL.path, selectedFolderURL.path], + autoCodemapPaths: [autoURL.path], + slices: [sliceURL.path: ranges], + codemapAutoEnabled: true + ) + let noneTree = WorkspaceFileTreeSnapshotRequest( + mode: .none, + filePathDisplay: .relative, + onlyIncludeRootsWithSelectedFiles: false, + includeLegend: false, + rootScope: .allLoaded + ) + + let referenced = try await store.captureWorkspaceFileContext( + selection: selection, + fileTreeRequest: noneTree, + coverage: .projection(codemapCoverage: .referenced) + ) + XCTAssertEqual(referenced.coverage, .projection(codemapCoverage: .referenced)) + XCTAssertEqual(referenced.catalog.roots.count, 1) + XCTAssertTrue(referenced.catalog.files.isEmpty) + XCTAssertTrue(referenced.catalog.entries.isEmpty) + XCTAssertEqual(referenced.catalog.diagnostics.fileCount, 0) + XCTAssertEqual(referenced.catalog.diagnostics.folderCount, 0) + XCTAssertTrue(referenced.fileTree.roots.isEmpty) + XCTAssertEqual(referenced.materializedFolders.map(\.standardizedRelativePath), ["Sources"]) + XCTAssertEqual( + referenced.materializedFiles.map(\.standardizedRelativePath), + ["Auto.swift", "Selected.swift", "Slice.swift", "Sources/First.swift", "Sources/Second.swift"] + ) + XCTAssertEqual(referenced.codemapSnapshots.map(\.relativePath), ["Auto.swift"]) + + let allAvailable = try await store.captureWorkspaceFileContext( + selection: selection, + fileTreeRequest: noneTree, + coverage: .projection(codemapCoverage: .allAvailable) + ) + XCTAssertEqual(allAvailable.coverage, .projection(codemapCoverage: .allAvailable)) + XCTAssertEqual( + allAvailable.materializedFiles.map(\.standardizedRelativePath), + [ + "Auto.swift", + "CompleteOnly.swift", + "Selected.swift", + "Slice.swift", + "Sources/First.swift", + "Sources/Second.swift" + ] + ) + XCTAssertEqual( + allAvailable.codemapSnapshots.map(\.relativePath), + ["Auto.swift", "CompleteOnly.swift"] + ) + + let treeFallback = try await store.captureWorkspaceFileContext( + selection: selection, + fileTreeRequest: WorkspaceFileTreeSnapshotRequest( + mode: .selected, + filePathDisplay: .relative, + onlyIncludeRootsWithSelectedFiles: true, + includeLegend: false, + rootScope: .allLoaded + ), + coverage: .projection(codemapCoverage: .referenced) + ) + XCTAssertEqual(treeFallback.coverage, .complete) + XCTAssertEqual(treeFallback.catalog.files.count, 7) + XCTAssertEqual(treeFallback.materializedFiles.count, 7) + } + func testSearchCatalogSnapshotCacheInvalidatesAcrossAddRemoveMoveAndRootLifecycle() async throws { let rootA = try makeTemporaryRoot(name: "SearchSnapshotLifecycleA") let rootB = try makeTemporaryRoot(name: "SearchSnapshotLifecycleB") @@ -3380,8 +3765,8 @@ final class WorkspaceFileContextStoreTests: XCTestCase { return finalCounters } - private var createdFileFlags: FSEventStreamEventFlags { - FSEventStreamEventFlags(kFSEventStreamEventFlagItemCreated | kFSEventStreamEventFlagItemIsFile) + private var createdFileFlags: FileSystemWatchEventFlags { + [.itemCreated, .itemIsFile] } private actor OrderedIngressRecorder { diff --git a/Tests/RepoPromptTests/WorkspaceContext/WorkspaceLoadingDiagnosticsGuardTests.swift b/Tests/RepoPromptTests/WorkspaceContext/WorkspaceLoadingDiagnosticsGuardTests.swift index 2155bff68..6d20b25d1 100644 --- a/Tests/RepoPromptTests/WorkspaceContext/WorkspaceLoadingDiagnosticsGuardTests.swift +++ b/Tests/RepoPromptTests/WorkspaceContext/WorkspaceLoadingDiagnosticsGuardTests.swift @@ -32,7 +32,7 @@ final class WorkspaceLoadingDiagnosticsGuardTests: XCTestCase { XCTAssertFalse(diagnostics.contains("debugCountTreeShape"), "Diagnostics must not recursively count FolderViewModel trees as the loading source of truth.") XCTAssertFalse(diagnostics.contains("rootSummary"), "Diagnostics must not describe workspace size from rootFolders tree summaries.") - let storePath = root.appendingPathComponent("Sources/RepoPrompt/Infrastructure/WorkspaceContext/WorkspaceFileContextStore.swift") + let storePath = root.appendingPathComponent("Sources/RepoPromptCore/WorkspaceContext/WorkspaceFileContextStore.swift") let storeSource = try String(contentsOf: storePath, encoding: .utf8) XCTAssertFalse(storeSource.contains("WorkspaceRootLoadDebugContext"), "Root-load trace context should not live in the core workspace store API.") XCTAssertFalse(storeSource.contains("debugContext:"), "WorkspaceFileContextStore.loadRoot should not accept measurement-only debug context.") diff --git a/Tests/RepoPromptTests/WorkspaceContext/WorkspaceSelectionCoordinatorTests.swift b/Tests/RepoPromptTests/WorkspaceContext/WorkspaceSelectionCoordinatorTests.swift index 177c535f1..f32bb8622 100644 --- a/Tests/RepoPromptTests/WorkspaceContext/WorkspaceSelectionCoordinatorTests.swift +++ b/Tests/RepoPromptTests/WorkspaceContext/WorkspaceSelectionCoordinatorTests.swift @@ -1,5 +1,6 @@ import Combine @testable import RepoPrompt +@testable import RepoPromptCore import XCTest @MainActor @@ -11,209 +12,108 @@ final class WorkspaceSelectionCoordinatorTests: XCTestCase { super.tearDown() } - func testActiveSelectionSnapshotReturnsActiveTabSelectionAndFlushesPendingUIWhenRequested() { + func testActiveSelectionSnapshotReadsCanonicalActiveTab() { let initial = StoredSelection(selectedPaths: ["/tmp/initial.swift"], codemapAutoEnabled: true) - let pending = StoredSelection( - selectedPaths: ["/tmp/pending.swift"], - autoCodemapPaths: ["/tmp/dependency.swift"], - slices: ["/tmp/pending.swift": [LineRange(start: 1, end: 3)]], - codemapAutoEnabled: false - ) let harness = CoordinatorHarness(initialSelection: initial) - harness.manager.pendingUISelection = pending - let coordinator = WorkspaceSelectionCoordinator(workspaceManager: harness.manager, store: harness.store) - let unflushed = coordinator.activeSelectionSnapshot(flushPendingUI: false) - XCTAssertEqual(unflushed.tabID, harness.tabID) - XCTAssertEqual(unflushed.selection, initial) - XCTAssertEqual(harness.manager.publishSnapshotCallCount, 0) - - var changes: [WorkspaceSelectionCoordinator.Change] = [] - coordinator.changes - .sink { changes.append($0) } - .store(in: &cancellables) + let snapshot = harness.coordinator.activeSelectionSnapshot(flushPendingUI: false) - let flushed = coordinator.activeSelectionSnapshot(flushPendingUI: true) - XCTAssertEqual(flushed.tabID, harness.tabID) - XCTAssertEqual(flushed.selection, pending) - XCTAssertEqual(harness.manager.publishSnapshotCallCount, 1) - XCTAssertEqual(changes.last, .init(tabID: harness.tabID, selection: pending, source: .uiFlush)) + XCTAssertEqual(snapshot.tabID, harness.tabID) + XCTAssertEqual(snapshot.selection, initial) + XCTAssertFalse(snapshot.isVirtual) } - func testPersistActiveSelectionWritesActiveTabAndEmitsChange() async { - let initial = StoredSelection(selectedPaths: ["/tmp/initial.swift"]) + func testPersistActiveSelectionWritesCanonicalTabAndPublishesWhileMirrorGuardIsActive() async { + let harness = CoordinatorHarness(initialSelection: StoredSelection(selectedPaths: ["/tmp/initial.swift"])) let next = StoredSelection( selectedPaths: ["/tmp/next.swift"], autoCodemapPaths: ["/tmp/next_dependency.swift"], slices: ["/tmp/next.swift": [LineRange(start: 4, end: 8)]], codemapAutoEnabled: false ) - let harness = CoordinatorHarness(initialSelection: initial) - let coordinator = WorkspaceSelectionCoordinator(workspaceManager: harness.manager, store: harness.store) var changes: [WorkspaceSelectionCoordinator.Change] = [] - coordinator.changes - .sink { changes.append($0) } + var mirrorGuardWasActive = false + harness.coordinator.changes + .sink { change in + changes.append(change) + mirrorGuardWasActive = harness.coordinator.isApplyingSelectionMirror + } .store(in: &cancellables) - XCTAssertFalse(coordinator.isApplyingSelectionMirror) - let persisted = await coordinator.persistActiveSelection(next, source: .runtimeMutation, mirrorToUI: true) + let persisted = await harness.coordinator.persistActiveSelection(next, source: .runtimeMutation, mirrorToUI: true) XCTAssertEqual(persisted, next) - XCTAssertEqual(harness.manager.composeTab(with: harness.tabID)?.selection, next) - XCTAssertEqual(harness.manager.updateStoredOnlyCallCount, 1) + XCTAssertEqual(harness.session.workspaceSessionController.workspace(id: harness.workspaceID)?.composeTabs.first?.selection, next) XCTAssertEqual(changes.last, .init(tabID: harness.tabID, selection: next, source: .runtimeMutation)) - XCTAssertFalse(coordinator.isApplyingSelectionMirror) - } - - func testPersistActiveSelectionNoOpsWhenSelectionIsUnchanged() async { - let initial = StoredSelection(selectedPaths: ["/tmp/initial.swift"]) - let harness = CoordinatorHarness(initialSelection: initial) - let coordinator = WorkspaceSelectionCoordinator(workspaceManager: harness.manager, store: harness.store) - var changes: [WorkspaceSelectionCoordinator.Change] = [] - coordinator.changes - .sink { changes.append($0) } - .store(in: &cancellables) - - let persisted = await coordinator.persistActiveSelection(initial, source: .runtimeMutation, mirrorToUI: true) - - XCTAssertEqual(persisted, initial) - XCTAssertEqual(harness.manager.composeTab(with: harness.tabID)?.selection, initial) - XCTAssertEqual(harness.manager.updateStoredOnlyCallCount, 0) - XCTAssertTrue(changes.isEmpty) - XCTAssertFalse(coordinator.isApplyingSelectionMirror) + XCTAssertTrue(mirrorGuardWasActive) + XCTAssertFalse(harness.coordinator.isApplyingSelectionMirror) } - func testPersistVirtualSelectionStoresImmediatelyAndEmitsVirtualChange() { - let initial = StoredSelection(selectedPaths: ["/tmp/initial.swift"]) + func testPersistVirtualSelectionPublishesVirtualChange() { + let harness = CoordinatorHarness(initialSelection: StoredSelection()) let next = StoredSelection( selectedPaths: ["/tmp/virtual.swift"], slices: ["/tmp/virtual.swift": [LineRange(start: 2, end: 5)]], codemapAutoEnabled: false ) - let harness = CoordinatorHarness(initialSelection: initial) - let coordinator = WorkspaceSelectionCoordinator(workspaceManager: harness.manager, store: harness.store) var changes: [WorkspaceSelectionCoordinator.Change] = [] - coordinator.changes + harness.coordinator.changes .sink { changes.append($0) } .store(in: &cancellables) - let persisted = coordinator.persistVirtualSelection(next, for: harness.tabID) + let persisted = harness.coordinator.persistVirtualSelection(next, for: harness.tabID) XCTAssertEqual(persisted, next) - XCTAssertEqual(harness.manager.composeTab(with: harness.tabID)?.selection, next) - XCTAssertEqual(harness.manager.updateStoredOnlyCallCount, 1) + XCTAssertEqual(harness.session.workspaceSessionController.workspace(id: harness.workspaceID)?.composeTabs.first?.selection, next) XCTAssertEqual(changes.last, .init(tabID: harness.tabID, selection: next, source: .virtual)) } - func testPersistVirtualSelectionNoOpsWhenSelectionIsUnchanged() { - let initial = StoredSelection(selectedPaths: ["/tmp/initial.swift"]) - let harness = CoordinatorHarness(initialSelection: initial) - let coordinator = WorkspaceSelectionCoordinator(workspaceManager: harness.manager, store: harness.store) + func testPersistSelectionUsesRequestedSourceForActiveTab() async { + let harness = CoordinatorHarness(initialSelection: StoredSelection()) + let next = StoredSelection(selectedPaths: ["/tmp/mcp.swift"]) var changes: [WorkspaceSelectionCoordinator.Change] = [] - coordinator.changes + harness.coordinator.changes .sink { changes.append($0) } .store(in: &cancellables) - let persisted = coordinator.persistVirtualSelection(initial, for: harness.tabID) + let persisted = await harness.coordinator.persistSelection(next, for: harness.tabID, source: .mcpTabContext) - XCTAssertEqual(persisted, initial) - XCTAssertEqual(harness.manager.composeTab(with: harness.tabID)?.selection, initial) - XCTAssertEqual(harness.manager.updateStoredOnlyCallCount, 0) - XCTAssertTrue(changes.isEmpty) + XCTAssertEqual(persisted, next) + XCTAssertEqual(changes.last, .init(tabID: harness.tabID, selection: next, source: .mcpTabContext)) } - func testPersistSelectionRoutesInactiveTabAndEmitsMCPSource() async { - let initial = StoredSelection(selectedPaths: ["/tmp/active.swift"]) - let inactiveTabID = UUID() - let inactiveInitial = StoredSelection(selectedPaths: ["/tmp/inactive-old.swift"]) - let next = StoredSelection( - selectedPaths: ["/tmp/inactive-new.swift"], - autoCodemapPaths: ["/tmp/inactive-dependency.swift"], - codemapAutoEnabled: false - ) - let harness = CoordinatorHarness(initialSelection: initial) - harness.manager.appendTab(ComposeTabState(id: inactiveTabID, name: "Agent", selection: inactiveInitial)) - let coordinator = WorkspaceSelectionCoordinator(workspaceManager: harness.manager, store: harness.store) + func testDirectCoreSessionMutationPublishesMirrorChangeThroughCombineBridge() { + let harness = CoordinatorHarness(initialSelection: StoredSelection()) + let next = StoredSelection(selectedPaths: ["/tmp/direct.swift"]) var changes: [WorkspaceSelectionCoordinator.Change] = [] - coordinator.changes + harness.coordinator.changes .sink { changes.append($0) } .store(in: &cancellables) - let persisted = await coordinator.persistSelection(next, for: inactiveTabID, source: .mcpTabContext) + harness.session.workspaceSessionController.mutateComposeTab( + workspaceID: harness.workspaceID, + tabID: harness.tabID + ) { $0.selection = next } - XCTAssertEqual(persisted, next) - XCTAssertEqual(harness.manager.composeTab(with: inactiveTabID)?.selection, next) - XCTAssertEqual(harness.manager.composeTab(with: harness.tabID)?.selection, initial) - XCTAssertEqual(harness.manager.updateStoredOnlyCallCount, 1) - XCTAssertEqual(changes.last, .init(tabID: inactiveTabID, selection: next, source: .mcpTabContext)) + XCTAssertEqual(changes.last, .init(tabID: harness.tabID, selection: next, source: .mirror)) } - func testSelectionSnapshotForInactiveTabDoesNotFlushActiveUI() { - let initial = StoredSelection(selectedPaths: ["/tmp/active.swift"]) - let inactiveTabID = UUID() - let inactiveSelection = StoredSelection(selectedPaths: ["/tmp/agent.swift"], codemapAutoEnabled: false) - let harness = CoordinatorHarness(initialSelection: initial) - harness.manager.pendingUISelection = StoredSelection(selectedPaths: ["/tmp/pending-active.swift"]) - harness.manager.appendTab(ComposeTabState(id: inactiveTabID, name: "Agent", selection: inactiveSelection)) - let coordinator = WorkspaceSelectionCoordinator(workspaceManager: harness.manager, store: harness.store) - - let snapshot = coordinator.selectionSnapshot(for: inactiveTabID, flushPendingUIIfActive: true) - - XCTAssertEqual(snapshot, .init(tabID: inactiveTabID, selection: inactiveSelection, isVirtual: true)) - XCTAssertEqual(harness.manager.publishSnapshotCallCount, 0) - } + func testApplyingSelectionMirrorGuardSuppressesFlushAttempt() async { + let harness = CoordinatorHarness(initialSelection: StoredSelection(selectedPaths: ["/tmp/initial.swift"])) - func testApplyingSelectionMirrorGuardSuppressesFlushPublication() async { - let initial = StoredSelection(selectedPaths: ["/tmp/initial.swift"]) - let pending = StoredSelection(selectedPaths: ["/tmp/pending.swift"]) - let harness = CoordinatorHarness(initialSelection: initial) - harness.manager.pendingUISelection = pending - let coordinator = WorkspaceSelectionCoordinator(workspaceManager: harness.manager, store: harness.store) - - await coordinator.withApplyingSelectionMirror { - XCTAssertTrue(coordinator.isApplyingSelectionMirror) - let snapshot = coordinator.activeSelectionSnapshot(flushPendingUI: true) - XCTAssertEqual(snapshot.selection, initial) - XCTAssertEqual(harness.manager.publishSnapshotCallCount, 0) + await harness.coordinator.withApplyingSelectionMirror { + XCTAssertTrue(harness.coordinator.isApplyingSelectionMirror) + let snapshot = harness.coordinator.activeSelectionSnapshot(flushPendingUI: true) + XCTAssertEqual(snapshot.selection.selectedPaths, ["/tmp/initial.swift"]) } - XCTAssertFalse(coordinator.isApplyingSelectionMirror) - let flushed = coordinator.activeSelectionSnapshot(flushPendingUI: true) - XCTAssertEqual(flushed.selection, pending) - XCTAssertEqual(harness.manager.publishSnapshotCallCount, 1) - } - - func testUIFlushDoesNotRepublishWhenSubscriberFlushesUnchangedSelection() { - let initial = StoredSelection(selectedPaths: ["/tmp/initial.swift"]) - let pending = StoredSelection(selectedPaths: ["/tmp/pending.swift"]) - let harness = CoordinatorHarness(initialSelection: initial) - harness.manager.pendingUISelection = pending - let coordinator = WorkspaceSelectionCoordinator(workspaceManager: harness.manager, store: harness.store) - var changes: [WorkspaceSelectionCoordinator.Change] = [] - - coordinator.changes - .sink { change in - changes.append(change) - _ = coordinator.activeSelectionSnapshot(flushPendingUI: true) - } - .store(in: &cancellables) - - let flushed = coordinator.activeSelectionSnapshot(flushPendingUI: true) - - XCTAssertEqual(flushed.selection, pending) - XCTAssertEqual(changes, [.init(tabID: harness.tabID, selection: pending, source: .uiFlush)]) - XCTAssertEqual(harness.manager.publishSnapshotCallCount, 2) + XCTAssertFalse(harness.coordinator.isApplyingSelectionMirror) } func testSaveSnapshotPrefersMatchingCanonicalSelectionOverStaleUISnapshot() { let activeTabID = UUID() - let liveUI = StoredSelection( - selectedPaths: ["/tmp/stale.swift"], - slices: ["/tmp/stale.swift": [LineRange(start: 1, end: 2)]], - codemapAutoEnabled: true - ) + let liveUI = StoredSelection(selectedPaths: ["/tmp/stale.swift"]) let canonical = StoredSelection(selectedPaths: ["/tmp/fixture.swift"], codemapAutoEnabled: false) - let stored = StoredSelection(selectedPaths: ["/tmp/stored.swift"]) let decision = WorkspaceManagerViewModel.selectionForSaveSnapshot( @@ -233,89 +133,51 @@ final class WorkspaceSelectionCoordinatorTests: XCTestCase { let stored = StoredSelection(selectedPaths: ["/tmp/stored.swift"], codemapAutoEnabled: false) let canonical = StoredSelection(selectedPaths: ["/tmp/other.swift"], codemapAutoEnabled: false) let activeTabID = UUID() - let scenarios: [(name: String, canonicalSelection: StoredSelection?, canonicalTabID: UUID?)] = [ - ("canonical tab does not match", canonical, UUID()), - ("canonical selection is missing", nil, nil) - ] - for scenario in scenarios { + for scenario in [(canonical as StoredSelection?, UUID() as UUID?), (nil, nil)] { let decision = WorkspaceManagerViewModel.selectionForSaveSnapshot( liveUISelection: liveUI, storedSelection: stored, - canonicalSelection: scenario.canonicalSelection, - canonicalTabID: scenario.canonicalTabID, + canonicalSelection: scenario.0, + canonicalTabID: scenario.1, activeTabID: activeTabID ) - - XCTAssertEqual(decision.selection, stored, scenario.name) - XCTAssertEqual(decision.owner, .storedComposeTab, scenario.name) + XCTAssertEqual(decision.selection, stored) + XCTAssertEqual(decision.owner, .storedComposeTab) } } } @MainActor private final class CoordinatorHarness { - let store = WorkspaceFileContextStore() - let fileManager = WorkspaceFilesViewModel(workspaceFileContextStore: WorkspaceFileContextStore()) - let tabID = UUID() - let manager: FakeWorkspaceSelectionManager + let session: RepoPromptCoreSession + let coordinator: WorkspaceSelectionCoordinator + let workspaceID: UUID + let tabID: UUID init(initialSelection: StoredSelection) { - let tab = ComposeTabState(id: tabID, name: "Test", selection: initialSelection) + let graph = EmbeddedWorkspaceRepositoryFactory.make() + let runtime = RepoPromptEmbeddedWorkspaceRuntimeFactory().makeRuntime() + session = RepoPromptCoreSession( + routingSessionID: MCPRoutingSessionID(rawValue: 99001), + workspaceRepository: graph.repository, + workspacePersistenceWriter: graph.writer, + workspaceAccessPolicy: UnrestrictedWorkspaceAccessPolicy(), + runtime: runtime + ) + let tab = ComposeTabState(name: "Test", selection: initialSelection) let workspace = WorkspaceModel( name: "Test Workspace", repoPaths: [], composeTabs: [tab], - activeComposeTabID: tabID + activeComposeTabID: tab.id ) - manager = FakeWorkspaceSelectionManager(workspace: workspace, fileManager: fileManager) - } -} - -@MainActor -private final class FakeWorkspaceSelectionManager: WorkspaceSelectionHost { - var activeWorkspace: WorkspaceModel? - let fileManager: WorkspaceFilesViewModel - var pendingUISelection: StoredSelection? - private(set) var publishSnapshotCallCount = 0 - private(set) var updateStoredOnlyCallCount = 0 - - init(workspace: WorkspaceModel, fileManager: WorkspaceFilesViewModel) { - activeWorkspace = workspace - self.fileManager = fileManager - } - - func composeTab(with id: UUID) -> ComposeTabState? { - activeWorkspace?.composeTabs.first(where: { $0.id == id }) - } - - func publishActiveComposeTabSnapshot(commitToMemory: Bool, touchModified: Bool) { - publishSnapshotCallCount += 1 - guard commitToMemory, - let pendingUISelection, - var workspace = activeWorkspace, - let activeID = workspace.activeComposeTabID, - let index = workspace.composeTabs.firstIndex(where: { $0.id == activeID }) - else { return } - workspace.composeTabs[index].selection = pendingUISelection - if touchModified { - workspace.composeTabs[index].lastModified = Date() - } - activeWorkspace = workspace - } - - func appendTab(_ tab: ComposeTabState) { - guard var workspace = activeWorkspace else { return } - workspace.composeTabs.append(tab) - activeWorkspace = workspace - } - - func updateComposeTabStoredOnly(_ tab: ComposeTabState) { - updateStoredOnlyCallCount += 1 - guard var workspace = activeWorkspace, - let index = workspace.composeTabs.firstIndex(where: { $0.id == tab.id }) - else { return } - workspace.composeTabs[index] = tab - activeWorkspace = workspace + coordinator = WorkspaceSelectionCoordinator( + controller: session.workspaceSelectionController, + store: session.workspaceFileContextStore + ) + workspaceID = workspace.id + tabID = tab.id + session.workspaceSessionController.replaceAll([workspace], activeWorkspaceID: workspace.id) } } diff --git a/Tests/RepoPromptTests/WorkspaceContext/WorkspaceSelectionPersistenceTests.swift b/Tests/RepoPromptTests/WorkspaceContext/WorkspaceSelectionPersistenceTests.swift index 8f38d0021..ced343575 100644 --- a/Tests/RepoPromptTests/WorkspaceContext/WorkspaceSelectionPersistenceTests.swift +++ b/Tests/RepoPromptTests/WorkspaceContext/WorkspaceSelectionPersistenceTests.swift @@ -1,117 +1,16 @@ @testable import RepoPrompt +@testable import RepoPromptCore import XCTest -final class WorkspaceSelectionPersistenceTests: XCTestCase { - override func tearDown() async throws { - await WorkspaceManagerViewModel.WorkspaceDiskWriter.shared.removeAllForTesting() - try await super.tearDown() - } - - func testDiskWriterPreservesNewerSelectionRevisionAgainstLaterStalePayload() async throws { - let tempDir = FileManager.default.temporaryDirectory - .appendingPathComponent("WorkspaceSelectionPersistenceTests-") - .appendingPathComponent(UUID().uuidString, isDirectory: true) - try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) - defer { try? FileManager.default.removeItem(at: tempDir) } - let url = tempDir.appendingPathComponent("workspace.json") - await WorkspaceManagerViewModel.WorkspaceDiskWriter.shared.removeAllForTesting() - - let workspaceID = UUID() - let tabID = UUID() - let correct = Self.workspace( - id: workspaceID, - tabID: tabID, - selection: Self.selection(count: 7), - dateModified: Date(timeIntervalSince1970: 100), - promptText: "correct" - ) - let correctData = try JSONEncoder().encode(correct) - let correctMetadata = WorkspaceManagerViewModel.metadata( - for: correct, - source: "test.correctSelection", - activeSelectionRevision: 1 - ) - - await WorkspaceManagerViewModel.WorkspaceDiskWriter.shared.enqueueWorkspace(data: correctData, url: url, metadata: correctMetadata) - await WorkspaceManagerViewModel.WorkspaceDiskWriter.shared.flush(url: url) - - let stale = Self.workspace( - id: workspaceID, - tabID: tabID, - selection: Self.selection(count: 15, includeSlices: true), - dateModified: Date(timeIntervalSince1970: 200), - promptText: "stale-non-selection-field" - ) - let staleData = try JSONEncoder().encode(stale) - let staleMetadata = WorkspaceManagerViewModel.metadata( - for: stale, - source: "test.staleSelection", - activeSelectionRevision: 0 - ) - - await WorkspaceManagerViewModel.WorkspaceDiskWriter.shared.enqueueWorkspace(data: staleData, url: url, metadata: staleMetadata) - await WorkspaceManagerViewModel.WorkspaceDiskWriter.shared.flush(url: url) - - let decoded = try JSONDecoder().decode(WorkspaceModel.self, from: Data(contentsOf: url)) - let activeSelection = try XCTUnwrap(decoded.composeTabs.first(where: { $0.id == tabID })?.selection) - XCTAssertEqual(activeSelection, correct.composeTabs[0].selection) - XCTAssertEqual(decoded.composeTabs[0].promptText, "stale-non-selection-field") - } - - func testDiskWriterMergesNewerSelectionIntoNewerDiskInsteadOfSkipping() async throws { - let tempDir = FileManager.default.temporaryDirectory - .appendingPathComponent("WorkspaceSelectionPersistenceTests-") - .appendingPathComponent(UUID().uuidString, isDirectory: true) - try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) - defer { try? FileManager.default.removeItem(at: tempDir) } - let url = tempDir.appendingPathComponent("workspace.json") - await WorkspaceManagerViewModel.WorkspaceDiskWriter.shared.removeAllForTesting() - - let workspaceID = UUID() - let tabID = UUID() - let staleDisk = Self.workspace( - id: workspaceID, - tabID: tabID, - selection: Self.selection(count: 15, includeSlices: true), - dateModified: Date(timeIntervalSince1970: 300), - promptText: "disk-field" - ) - try JSONEncoder().encode(staleDisk).write(to: url, options: .atomic) - - let incoming = Self.workspace( - id: workspaceID, - tabID: tabID, - selection: Self.selection(count: 7), - dateModified: Date(timeIntervalSince1970: 200), - promptText: "incoming-field" - ) - let metadata = WorkspaceManagerViewModel.metadata( - for: incoming, - source: "test.newerSelectionOlderPayload", - activeSelectionRevision: 2 - ) - - try await WorkspaceManagerViewModel.WorkspaceDiskWriter.shared.enqueueWorkspace( - data: JSONEncoder().encode(incoming), - url: url, - metadata: metadata - ) - await WorkspaceManagerViewModel.WorkspaceDiskWriter.shared.flush(url: url) - - let decoded = try JSONDecoder().decode(WorkspaceModel.self, from: Data(contentsOf: url)) - XCTAssertEqual(decoded.composeTabs[0].selection, incoming.composeTabs[0].selection) - XCTAssertEqual(decoded.composeTabs[0].promptText, "disk-field") - } - - func testDiskWriterFlushAndAtomicWriteTelemetryCarryDurabilityAttributionWithoutPaths() async throws { +final class WorkspaceSelectionPersistenceAppDiagnosticsTests: XCTestCase { + func testCoreWriterDiagnosticsBridgePreservesDurabilityAttributionWithoutPaths() async throws { let tempDir = FileManager.default.temporaryDirectory - .appendingPathComponent("WorkspaceSelectionPersistenceTests-") + .appendingPathComponent("WorkspaceSelectionPersistenceAppDiagnosticsTests-") .appendingPathComponent(UUID().uuidString, isDirectory: true) try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) defer { try? FileManager.default.removeItem(at: tempDir) } let url = tempDir.appendingPathComponent("workspace.json") - let writer = WorkspaceManagerViewModel.WorkspaceDiskWriter.shared - await writer.removeAllForTesting() + let writer = WorkspacePersistenceWriter(diagnostics: EmbeddedWorkspaceRepositoryDiagnosticsAdapter()) defer { EditFlowPerf.resetDebugCaptureForTesting() } switch EditFlowPerf.beginDebugCapture(label: "workspace-durability", maxSamples: 100) { @@ -128,16 +27,16 @@ final class WorkspaceSelectionPersistenceTests: XCTestCase { await gate.markStartedAndWaitForRelease() } - await EditFlowPerf.$currentLifecycleCorrelation.withValue(firstCorrelation) { + _ = await EditFlowPerf.$currentLifecycleCorrelation.withValue(firstCorrelation) { await writer.enqueue(data: Data("first durable payload".utf8), url: url) } await gate.waitUntilStarted() - await EditFlowPerf.$currentLifecycleCorrelation.withValue(secondCorrelation) { + let secondReceipt = await EditFlowPerf.$currentLifecycleCorrelation.withValue(secondCorrelation) { await writer.enqueue(data: Data("second durable payload".utf8), url: url) } let flushTask = Task { await EditFlowPerf.$currentLifecycleCorrelation.withValue(secondCorrelation) { - await writer.flush(url: url) + _ = await writer.flush(secondReceipt) } await flushFinished.mark() } diff --git a/Tests/RepoPromptTests/WorkspaceRootSyncTests.swift b/Tests/RepoPromptTests/WorkspaceRootSyncTests.swift index 87faf7d00..deb266783 100644 --- a/Tests/RepoPromptTests/WorkspaceRootSyncTests.swift +++ b/Tests/RepoPromptTests/WorkspaceRootSyncTests.swift @@ -1,4 +1,5 @@ @testable import RepoPrompt +@testable import RepoPromptCore import XCTest @MainActor @@ -155,7 +156,8 @@ final class WorkspaceRootSyncTests: XCTestCase { } """ - let decoded = try JSONDecoder().decode(WorkspaceModel.self, from: Data(payload.utf8)) + let result = try EmbeddedWorkspaceCodecV1().decode(Data(payload.utf8)) + let decoded = result.document XCTAssertEqual(decoded.composeTabs.count, 1) XCTAssertEqual(decoded.activeComposeTabID, decoded.composeTabs[0].id) @@ -163,7 +165,7 @@ final class WorkspaceRootSyncTests: XCTestCase { XCTAssertEqual(decoded.composeTabs[0].expandedFolders, []) XCTAssertEqual(decoded.composeTabs[0].contextOverrides, ContextBuilderOverrides()) XCTAssertEqual(decoded.composeTabs[0].contextBuilder.instructions, "") - XCTAssertTrue(decoded.normalizationRequiresSave) + XCTAssertTrue(result.requiresRewrite) let encoded = try String(data: JSONEncoder().encode(decoded), encoding: .utf8) ?? "" XCTAssertFalse(encoded.contains("workingFilePaths"), encoded) diff --git a/Tests/SharedRuntimeConvergenceFixtures/Phase0/App/WorkspaceV1/Workspace-Phase 0 App V1-AAAAAAAA-AAAA-AAAA-AAAA-AAAAAAAAAAAA/workspace.json b/Tests/SharedRuntimeConvergenceFixtures/Phase0/App/WorkspaceV1/Workspace-Phase 0 App V1-AAAAAAAA-AAAA-AAAA-AAAA-AAAAAAAAAAAA/workspace.json new file mode 100644 index 000000000..44670e10b --- /dev/null +++ b/Tests/SharedRuntimeConvergenceFixtures/Phase0/App/WorkspaceV1/Workspace-Phase 0 App V1-AAAAAAAA-AAAA-AAAA-AAAA-AAAAAAAAAAAA/workspace.json @@ -0,0 +1,36 @@ +{ + "id": "AAAAAAAA-AAAA-AAAA-AAAA-AAAAAAAAAAAA", + "schemaVersion": 1, + "dateModified": 0, + "isSystemWorkspace": false, + "isHiddenInMenus": false, + "name": "Phase 0 App V1", + "repoPaths": ["__APP_FIXTURE_ROOT__"], + "presets": [], + "lastUsed": 0, + "currentPromptText": "phase zero app prompt", + "selectedMetaPromptIDs": [], + "composeTabs": [ + { + "id": "BBBBBBBB-BBBB-BBBB-BBBB-BBBBBBBBBBBB", + "name": "T1", + "lastModified": 0, + "isPinned": false, + "selection": { + "selectedPaths": ["__APP_FIXTURE_ROOT__/Sources/Full.swift","__APP_FIXTURE_ROOT__/Sources/Sliced.swift"], + "autoCodemapPaths": ["__APP_FIXTURE_ROOT__/Sources/Structure.swift"], + "slices": { + "__APP_FIXTURE_ROOT__/Sources/Sliced.swift": [{"start": 2,"end": 4,"description": "phase zero slice"}] + }, + "codemapAutoEnabled": false + }, + "expandedFolders": ["__APP_FIXTURE_ROOT__/Sources"], + "promptText": "phase zero app prompt", + "selectedMetaPromptIDs": [], + "contextOverrides": {"useOverridePrompt": false,"overridePromptText": ""}, + "discover": {"instructions": "","selectedContextBuilderPromptIDs": []} + } + ], + "activeComposeTabID": "BBBBBBBB-BBBB-BBBB-BBBB-BBBBBBBBBBBB", + "stashedTabs": [] +} diff --git a/Tests/SharedRuntimeConvergenceFixtures/Phase0/App/WorkspaceV1/workspacesIndex.json b/Tests/SharedRuntimeConvergenceFixtures/Phase0/App/WorkspaceV1/workspacesIndex.json new file mode 100644 index 000000000..826efc58f --- /dev/null +++ b/Tests/SharedRuntimeConvergenceFixtures/Phase0/App/WorkspaceV1/workspacesIndex.json @@ -0,0 +1,8 @@ +[ + { + "id": "AAAAAAAA-AAAA-AAAA-AAAA-AAAAAAAAAAAA", + "name": "Phase 0 App V1", + "isSystemWorkspace": false, + "isHiddenInMenus": false + } +] diff --git a/Tests/SharedRuntimeConvergenceFixtures/Phase0/App/app-characterization.json b/Tests/SharedRuntimeConvergenceFixtures/Phase0/App/app-characterization.json new file mode 100644 index 000000000..08658a369 --- /dev/null +++ b/Tests/SharedRuntimeConvergenceFixtures/Phase0/App/app-characterization.json @@ -0,0 +1,347 @@ +{ + "baseline_commit" : "042a500b03b39d04237ec5544811696cf6b2f2f9", + "descriptors" : [ + { + "annotations" : { + "destructive" : false, + "idempotent" : null, + "open_world" : false, + "read_only" : false, + "title" : null + }, + "description" : "List, inspect, and bind sticky RepoPrompt window/tab context for this MCP connection.\n\nOperations:\n• list – return **all** open windows, their compose tabs, and this connection's current binding\n• status – return this connection's current binding only\n• bind – bind by working_dirs (preferred), context_id, or window_id\n\n**Recommended binding flow:**\nBind by `working_dirs` using absolute workspace root paths:\n\t`{\"op\":\"bind\",\"working_dirs\":[\"/path/to/root1\",\"/path/to/root2\"]}`\nRepoPrompt first looks for an exact workspace `repo_paths` set match (order-insensitive). If no exact match exists, RepoPrompt may fall back to a workspace whose `repo_paths` is a strict superset of the requested roots. Both modes match workspace roots only — not descendant paths.\nIf the matching workspace is already open, RepoPrompt prefers that window. If it exists but is not open, RepoPrompt opens a window and switches to it. Add `create_if_missing=true` to create a new workspace after approval when neither exact nor superset workspace matches.\n\nParameters:\n- op: \"list\" | \"status\" | \"bind\" (required)\n- working_dirs: string | string[] (for bind: preferred — absolute workspace roots; exact match first, repo_paths superset fallback)\n- context_id: string (for bind: canonical compose-tab context UUID from a previous list)\n- window_id: integer (for list: filter to one window; for bind with working_dirs: disambiguate when multiple workspaces match; for bind alone: set window affinity)\n- create_if_missing: boolean (for bind with working_dirs; create a new workspace after approval when no exact or superset workspace matches)\n- tab_name: string (optional workspace name hint when creating via working_dirs + create_if_missing)\n\n**Binding modes:**\n- **Window affinity** (from working_dirs or window_id): routes tool calls to whichever tab is currently active in that window. Most agents should use this.\n- **Tab binding** (from context_id): pins tool calls to a specific compose tab, even if you switch to another tab. Use when you need a stable context that won't change.\n\n**Discovery:**\n- Use `bind_context list` to see what's currently open (windows, active workspaces, tabs, context_ids)\n- Use `manage_workspaces list` to see saved visible workspaces, or `include_hidden=true` to include recoverable hidden workspaces", + "description_sha256" : "ff77f254f2aeeb52463a1233ba77f1b047536fc52d35af48dbcfbe3ee0696ecc", + "enabled_by_default" : true, + "input_schema" : "{\"properties\":{\"context_id\":{\"description\":\"For bind: canonical compose-tab context UUID\",\"type\":\"string\"},\"create_if_missing\":{\"description\":\"For bind with working_dirs: create a new workspace after approval if no exact or superset workspace matches\",\"type\":\"boolean\"},\"op\":{\"description\":\"Operation: 'list', 'status', or 'bind'\",\"enum\":[\"list\",\"status\",\"bind\"],\"type\":\"string\"},\"tab_name\":{\"description\":\"Optional workspace name when creating via working_dirs + create_if_missing\",\"type\":\"string\"},\"window_id\":{\"description\":\"For list: filter to one window. For bind with working_dirs: disambiguate when multiple workspaces match. For bind alone: set window affinity.\",\"type\":\"integer\"},\"working_dirs\":{\"description\":\"For bind: comma-separated absolute workspace root paths; exact match first, then repo_paths superset fallback\",\"type\":\"string\"}},\"required\":[\"op\"],\"type\":\"object\"}", + "name" : "bind_context", + "schema_sha256" : "8aaf0472fed4d64315e908fcfb84be34c27a13b80dcbe1a5feb77d30f13ec90f" + }, + { + "annotations" : { + "destructive" : true, + "idempotent" : null, + "open_world" : false, + "read_only" : false, + "title" : null + }, + "description" : "Manage workspaces and compose-tab lifecycle across RepoPrompt windows.\n\n**This is the workspace inventory view.** `bind_context` remains the canonical API for per-window tab routing and context_id discovery. Legacy-compatible `list_tabs` and `select_tab` actions are restored for older clients, but new integrations should prefer `bind_context`.\n\nActions:\n• list – Return known visible workspaces by default (id, name, repoPaths, showing window IDs, is_hidden)\n• switch – Switch a window to a specified workspace\n• create – Create a new workspace (optional folder_path)\n• hide – Hide a workspace from default workspace lists without deleting it\n• unhide – Restore a hidden workspace to default workspace lists\n• delete – Delete a workspace permanently (optionally close window)\n• add_folder – Add a folder to a workspace (defaults to active workspace)\n• remove_folder – Remove a folder from a workspace (defaults to active workspace)\n• list_tabs – List compose tabs in one window (Legacy compatibility — prefer bind_context op=list)\n• select_tab – Bind this connection to a compose tab (Legacy compatibility — prefer bind_context op=bind context_id=<id>)\n• create_tab – Create a new compose tab in the background\n• close_tab – Close a compose tab safely\n\nParameters:\n- action: \"list\" | \"switch\" | \"create\" | \"hide\" | \"unhide\" | \"delete\" | \"add_folder\" | \"remove_folder\" | \"list_tabs\" | \"select_tab\" | \"create_tab\" | \"close_tab\" (required)\n- workspace: string (required for 'switch', 'hide', 'unhide', 'delete'; optional for 'add_folder', 'remove_folder' - defaults to active workspace; UUID or name)\n- name: string (required for 'create'; optional for 'create_tab')\n- folder_path: string (required for 'add_folder', 'remove_folder'; optional for 'create' to initialize with a root folder; absolute path)\n- tab: string (required for 'select_tab'; optional for 'close_tab'; UUID or name)\n- mode: \"blank\" | \"fork\" (optional for 'create_tab'; default \"blank\")\n- source_tab: string (optional for 'create_tab' when mode=\"fork\"; UUID or name)\n- bind: boolean (optional for 'create_tab'; default true)\n- focus: boolean (optional for 'select_tab' or 'create_tab'; if true, also switches the UI to show the tab)\n- allow_active: boolean (optional for 'close_tab'; default false)\n- window_id: integer (optional; target window, defaults to selected or only window)\n- open_in_new_window: boolean (optional for 'switch' or 'create'; when true, opens workspace in a new window and binds the connection to it)\n- switch_to_created: boolean (optional for 'create'; when true, switches to the newly created workspace)\n- close_window: boolean (optional for 'delete'; when true, switches away without saving, deletes the workspace, then requests window close)\n- include_hidden: boolean (optional; default false. For 'list', includes hidden workspaces. For name-based 'switch'/'delete', allows hidden matches. UUID lookup remains explicit and can resolve hidden workspaces.)\n\nHidden workspaces remain persisted/recoverable. Default 'list' and name-based 'switch'/'delete' exclude hidden workspaces unless include_hidden=true; 'hide'/'unhide' are non-destructive. Explicit UUID switch/delete can target hidden workspaces without unhiding them.\n\n**Relationship with bind_context:**\n- `manage_workspaces.list` returns workspace inventory: names, folder paths, and which windows show each workspace\n- `bind_context.list` returns per-window routing state: windows, active tabs, context_ids, and current binding\n- When the same workspace is open in multiple windows, compose tabs are shared — use `bind_context` to discover per-window context_ids\n\ncreate_tab defaults to bind=true and focus=false so automation can create isolated background tabs without stealing UI focus.\n\nIMPORTANT: The 'focus' parameter switches the visible tab in the UI, which can be disruptive to the user's workflow. Only set focus=true when the user explicitly requests to see or switch to a specific tab. For background operations, omit focus or set it to false. The 'close_tab' action refuses to close the last remaining tab, the active visible tab unless allow_active=true, or any tab with a live bound run.", + "description_sha256" : "2ea65c3ff723f461a8eab72ef00d43c90dd82a4872436fbb4ed3428c119b555f", + "enabled_by_default" : true, + "input_schema" : "{\"properties\":{\"action\":{\"description\":\"Action to perform. Legacy compatibility: prefer bind_context for list_tabs/select_tab when building new integrations.\",\"enum\":[\"list\",\"switch\",\"create\",\"hide\",\"unhide\",\"delete\",\"add_folder\",\"remove_folder\",\"list_tabs\",\"select_tab\",\"create_tab\",\"close_tab\"],\"type\":\"string\"},\"allow_active\":{\"description\":\"For 'close_tab': allow closing the currently active visible tab\",\"type\":\"boolean\"},\"bind\":{\"description\":\"For 'create_tab': if true, bind this MCP connection to the new tab (default true)\",\"type\":\"boolean\"},\"close_window\":{\"description\":\"For 'delete': when true, switches away without saving, deletes the workspace, then requests window close.\",\"type\":\"boolean\"},\"focus\":{\"description\":\"For 'select_tab' or 'create_tab': if true, also switches the UI to show the tab\",\"type\":\"boolean\"},\"folder_path\":{\"description\":\"Absolute folder path (required for 'add_folder', 'remove_folder'; optional for 'create' to initialize with a root folder)\",\"type\":\"string\"},\"include_hidden\":{\"description\":\"Default false. For list, includes hidden workspaces. For name-based switch/delete, allows hidden matches; UUID lookup remains explicit.\",\"type\":\"boolean\"},\"mode\":{\"description\":\"For 'create_tab': creation mode ('blank' or 'fork')\",\"type\":\"string\"},\"name\":{\"description\":\"Name for new workspace (required for 'create'; optional for 'create_tab')\",\"type\":\"string\"},\"open_in_new_window\":{\"description\":\"For 'switch' or 'create': when true, opens workspace in a new window and binds connection to it. Returns window_id in response.\",\"type\":\"boolean\"},\"source_tab\":{\"description\":\"For 'create_tab' with mode='fork': source compose tab UUID or name\",\"type\":\"string\"},\"switch_to_created\":{\"description\":\"For 'create': when true, switches to the newly created workspace in the target window.\",\"type\":\"boolean\"},\"tab\":{\"description\":\"Compose tab UUID or name (required for 'select_tab'; optional for 'close_tab')\",\"type\":\"string\"},\"window_id\":{\"description\":\"Optional window ID; defaults to selected or only window\",\"type\":\"integer\"},\"workspace\":{\"description\":\"Workspace UUID or name (required for 'switch', 'hide', 'unhide', 'delete'; optional for 'add_folder', 'remove_folder' - defaults to active workspace)\",\"type\":\"string\"}},\"required\":[\"action\"],\"type\":\"object\"}", + "name" : "manage_workspaces", + "schema_sha256" : "ee13405b233c029cf8f68f29a0ebf14e1fafd2815cab6bce4a8b431b6bad06bc" + }, + { + "annotations" : { + "destructive" : false, + "idempotent" : null, + "open_world" : false, + "read_only" : false, + "title" : null + }, + "description" : "Manage the file selection used by all tools.\n\n**Operations**: get | add | remove | set | clear | preview | promote | demote\n\n**Modes** (how files appear in context):\n- `full` (default): Complete file content\n- `slices`: Specific line ranges only\n- `codemap_only`: API signatures only (function/type definitions)\n\n**Key behaviors**:\n- Incremental context changes use `op=add` / `op=remove`\n- `op=set` with `mode=full`: Complete selection replacement\n- `op=set` with `mode=codemap_only`: Complete codemap-only replacement\n- `op=set` with `mode=slices`: File-scoped slice replacement (requires `#L` ranges or `slices` entries; preserves unrelated full files and slices)\n- Mixed full-file + slice additions use `op=add` with both `paths` and `slices`\n- Auto-codemap: When adding files with `mode=full/slices`, related files get auto-added as codemaps\n- Manual mode: Using `mode=codemap_only`, `promote`, or `demote` disables auto-management\n\n**Path handling**:\n- Accepts files or directories (directories expand recursively)\n- Relative or absolute paths accepted\n- Multi-root: prefix with root name (e.g., \"ProjectA/src/main.swift\")\n- Single-root: prefix optional\n- Fuzzy matching enabled by default\n\n**Options**:\n- `view`: \"summary\" | \"files\" | \"content\" | \"codemaps\" (default: \"summary\")\n- `path_display`: \"relative\" | \"full\" (default: \"relative\")\n- `strict`: When true, errors if no paths resolve (default: false)\n\n**Examples**:\n- Get selection: `{\"op\":\"get\",\"view\":\"files\"}`\n- Add files: `{\"op\":\"add\",\"paths\":[\"src/main.swift\"]}`\n- Add slices: `{\"op\":\"add\",\"slices\":[{\"path\":\"file.swift\",\"ranges\":[{\"start_line\":45,\"end_line\":120}]}]}`\n- Set codemap-only: `{\"op\":\"set\",\"paths\":[\"utils/\"],\"mode\":\"codemap_only\"}`\n- Promote codemap→full: `{\"op\":\"promote\",\"paths\":[\"helper.swift\"]}`\n\nRelated: get_file_tree, file_search, workspace_context, prompt, apply_edits", + "description_sha256" : "586c45be3d3a4ade60e29ccbbdce01b050be03860a67eee41a3eac95d441d7d9", + "enabled_by_default" : true, + "input_schema" : "{\"properties\":{\"mode\":{\"description\":\"How to represent files in selection: 'full' (complete content), 'slices' (line ranges), or 'codemap_only' (signatures only). With op=set, mode changes semantics (see 'op=set semantics' above).\",\"enum\":[\"full\",\"slices\",\"codemap_only\"],\"type\":\"string\"},\"op\":{\"description\":\"Operation\",\"enum\":[\"get\",\"add\",\"remove\",\"set\",\"clear\",\"preview\",\"promote\",\"demote\"],\"type\":\"string\"},\"path_display\":{\"description\":\"Path display for blocks\",\"enum\":[\"full\",\"relative\"],\"type\":\"string\"},\"paths\":{\"description\":\"File or folder paths (required for add/remove/set)\",\"items\":{\"description\":\"Relative or absolute file or folder path\",\"type\":\"string\"},\"type\":\"array\"},\"slices\":{\"description\":\"Selection slices to apply (path + line ranges)\",\"items\":{\"properties\":{\"lines\":{\"description\":\"Comma-separated shorthand like '10-20,40'\",\"type\":\"string\"},\"path\":{\"description\":\"Relative or absolute file path\",\"type\":\"string\"},\"ranges\":{\"description\":\"Explicit line ranges (inclusive)\",\"items\":{\"properties\":{\"description\":{\"description\":\"Optional slice description (aliases: desc, label)\",\"type\":\"string\"},\"end_line\":{\"description\":\"1-based end line\",\"type\":\"integer\"},\"start_line\":{\"description\":\"1-based start line\",\"type\":\"integer\"}},\"required\":[\"start_line\"],\"type\":\"object\"},\"type\":\"array\"}},\"required\":[\"path\"],\"type\":\"object\"},\"type\":\"array\"},\"strict\":{\"description\":\"Throw when no paths resolve (mutations)\",\"type\":\"boolean\"},\"view\":{\"description\":\"Amount of detail to return\",\"enum\":[\"summary\",\"files\",\"content\",\"codemaps\"],\"type\":\"string\"}},\"type\":\"object\"}", + "name" : "manage_selection", + "schema_sha256" : "4b7a043e8e48130ee84cc6bbf7b9fd597b495aef238d44f17df6600088a2bb6f" + }, + { + "annotations" : { + "destructive" : false, + "idempotent" : true, + "open_world" : false, + "read_only" : true, + "title" : null + }, + "description" : "Return code structure (function/type signatures) for files.\n\n**Scopes**:\n- `paths` (default): Analyze specific files/directories. Requires `paths` parameter.\n- `selected`: Analyze current selection. Also reports files without codemaps.\n\n**Parameters**:\n- `paths`: File or directory paths (directories are recursive)\n- `max_results`: Limit considered codemaps (default: 10). Larger values opt in to broader scans.\n\n**Note**: Files without parseable structure are skipped. Use with get_file_tree and file_search for discovery.\nRendered codemap output is capped near 6k tokens even when `max_results` is larger; narrow `paths` to change which files fit.\nLine numbers are included in the output and match `read_file` line numbering, so you can jump directly to where a function/type is declared within a file. Code structure is refreshed after file edits, so results stay current.\n\n**Examples**:\n- Specific files: `{\"paths\":[\"src/auth/\"]}`\n- Current selection: `{\"scope\":\"selected\"}`", + "description_sha256" : "616a8669bc14e69afaad4cfec4e4da2b1f5dcb7142030fb615857175b2146cb4", + "enabled_by_default" : true, + "input_schema" : "{\"properties\":{\"max_results\":{\"description\":\"Maximum number of codemaps to consider before the ~6k-token response cap is applied (default: 10)\",\"type\":\"integer\"},\"paths\":{\"description\":\"Array of file or directory paths (when scope='paths')\",\"items\":{\"description\":\"File path or directory path (absolute or relative)\",\"type\":\"string\"},\"type\":\"array\"},\"scope\":{\"description\":\"Scope of operation: current selection or explicit paths\",\"enum\":[\"paths\",\"selected\"],\"type\":\"string\"}},\"type\":\"object\"}", + "name" : "get_code_structure", + "schema_sha256" : "f9bbf57f060e5cf70b8104a4b800100459376fb4438bfa80ef8f388aedf7c913" + }, + { + "annotations" : { + "destructive" : false, + "idempotent" : true, + "open_world" : false, + "read_only" : true, + "title" : null + }, + "description" : "Generate ASCII directory tree of the project.\n\n**Types**:\n- `files` (default): Directory tree with files\n- `roots`: List loaded root folders only\n\n**Modes** (for type=\"files\"):\n- `auto` (default): Full tree, auto-trims depth if too large (~10k token target)\n- `full`: Complete tree (can be very large)\n- `folders`: Directories only, no files\n- `selected`: Only selected files and their parent directories\n\n**Options**:\n- `path`: Start from specific folder (modes/max_depth apply from there)\n- `max_depth`: Limit depth (root=0, immediate children=1, etc.)\n\n**Markers**: `*` = selected file, `+` = has codemap\n\n**Worktree scope**: When an agent session is bound to a Git worktree, displayed paths may remain logical/canonical while filesystem reads use the bound worktree. Responses include `worktree_scope` when this remapping is active.\n\n**Examples**:\n- Auto tree: `{}`\n- Folders only: `{\"mode\":\"folders\"}`\n- Subtree: `{\"path\":\"src/components\",\"max_depth\":2}`\n- Selected files: `{\"mode\":\"selected\"}`", + "description_sha256" : "9bf648121646b463554d58373f61c2dcede04640482994e0cf1533d21ae77093", + "enabled_by_default" : true, + "input_schema" : "{\"properties\":{\"max_depth\":{\"description\":\"Maximum depth (root = 0)\",\"type\":\"integer\"},\"mode\":{\"description\":\"Filter mode (for 'files' type only, default: 'auto')\",\"enum\":[\"auto\",\"full\",\"folders\",\"selected\"],\"type\":\"string\"},\"path\":{\"description\":\"Optional starting folder (absolute or relative) when type='files'. When provided, the tree is generated from this folder and 'mode' and 'max_depth' apply from that subtree.\",\"type\":\"string\"},\"type\":{\"description\":\"Tree type to generate (default: 'files')\",\"enum\":[\"files\",\"roots\"],\"type\":\"string\"}},\"type\":\"object\"}", + "name" : "get_file_tree", + "schema_sha256" : "91972027e030989cf242fed03377bdc5056c6317cc77d351d3fa5348dd1767a0" + }, + { + "annotations" : { + "destructive" : false, + "idempotent" : true, + "open_world" : false, + "read_only" : true, + "title" : null + }, + "description" : "Read file contents with optional line range.\n\n**Parameters**:\n- `path`: File path (required)\n- `start_line`: 1-based line number, or negative for tail behavior\n- `limit`: Number of lines (only with positive start_line)\n\n**Behaviors**:\n- No params: Entire file\n- `start_line=10`: From line 10 to end\n- `start_line=10, limit=20`: Lines 10-29\n- `start_line=-10`: Last 10 lines (like `tail -10`)\n\n**Worktree scope**: When an agent session is bound to a Git worktree, displayed paths may remain logical/canonical while filesystem reads use the bound worktree. Responses include `worktree_scope` when this remapping is active.\n\n**Examples**:\n- Full file: `{\"path\":\"src/main.swift\"}`\n- Lines 50-100: `{\"path\":\"file.swift\",\"start_line\":50,\"limit\":51}`\n- Last 20 lines: `{\"path\":\"file.swift\",\"start_line\":-20}`", + "description_sha256" : "f5ccd98a8fc0956c4ebcff540ffc8c0eaf0aaeb654b2f8edc0495c059fcf2807", + "enabled_by_default" : true, + "input_schema" : "{\"properties\":{\"limit\":{\"description\":\"Number of lines to read\",\"type\":\"integer\"},\"path\":{\"description\":\"File path\",\"type\":\"string\"},\"start_line\":{\"description\":\"Line to start from (1-based) or negative for tail behavior (-N reads last N lines)\",\"type\":\"integer\"}},\"required\":[\"path\"],\"type\":\"object\"}", + "name" : "read_file", + "schema_sha256" : "d023edb446167481751886bebeac7dc8896e2b3f57c12b18591761f846618bb1" + }, + { + "annotations" : { + "destructive" : false, + "idempotent" : true, + "open_world" : false, + "read_only" : true, + "title" : null + }, + "description" : "Search files by path pattern and/or content.\n\n**Modes**:\n- `auto` (default): Detects path vs content search from pattern\n- `path`: Match file paths only (glob-style with regex=false, full regex otherwise)\n- `content`: Search inside file contents\n- `both`: Search paths and contents\n\n**Matching** (regex auto-detected by default):\n- Regex mode: Full regex support (groups, lookarounds, anchors)\n- Literal mode (regex=false): Special chars matched literally, `*`/`?` wildcards for paths\n- Tip: Set `regex=false` to force literal substring matching\n\n**Key options**:\n- `pattern`: Search term (required)\n- `max_results`: Result limit (default: 50)\n- `context_lines`: Lines before/after matches (alias: `-C`)\n- `whole_word`: Match whole words only\n- `count_only`: Return counts only, no content\n- `filter.extensions`: Limit to extensions (e.g., [\".swift\"])\n- `filter.paths`: Limit to paths/folders (can also be a loaded root name like 'RepoPrompt')\n- `filter.exclude`: Skip matching patterns\n\n**Worktree scope**: When an agent session is bound to a Git worktree, displayed paths may remain logical/canonical while filesystem searches use the bound worktree. Responses include `worktree_scope` when this remapping is active.\n\n**Examples**:\n- Literal: `{\"pattern\":\"frame(minWidth:\",\"regex\":false}`\n- Regex OR: `{\"pattern\":\"performSearch|searchUsers\"}`\n- Find files: `{\"pattern\":\"*.swift\",\"mode\":\"path\",\"regex\":false}`\n- With context: `{\"pattern\":\"TODO\",\"context_lines\":2}`\n- Scoped: `{\"pattern\":\"auth\",\"filter\":{\"paths\":[\"src/auth/\"]}}`\n\nResponse capped at ~50k chars; excess results omitted (count reported).", + "description_sha256" : "f2c9e16ca780c4e94f795b6c9489658856052e6d159aa467a64c906ee48a3fe4", + "enabled_by_default" : true, + "input_schema" : "{\"properties\":{\"context_lines\":{\"description\":\"Lines of context before/after matches (alias: -C)\",\"type\":\"integer\"},\"count_only\":{\"description\":\"Return only match count\",\"type\":\"boolean\"},\"filter\":{\"description\":\"File filtering options (alias: use 'path' string parameter for single-file search)\",\"properties\":{\"exclude\":{\"description\":\"Skip files/paths matching these patterns\",\"items\":{\"description\":\"Pattern like 'node_modules' or '*.log'\",\"type\":\"string\"},\"type\":\"array\"},\"extensions\":{\"description\":\"Only search files with these extensions\",\"items\":{\"description\":\"File extension like '.js' or '.swift'\",\"type\":\"string\"},\"type\":\"array\"},\"paths\":{\"description\":\"Limit search to specific file or folder paths, or a loaded root name\",\"items\":{\"description\":\"Absolute path, relative path, or loaded root name (e.g., 'RepoPrompt')\",\"type\":\"string\"},\"type\":\"array\"}},\"type\":\"object\"},\"max_results\":{\"description\":\"Maximum total results (default: 50)\",\"type\":\"integer\"},\"mode\":{\"description\":\"Search scope: auto-detects if not specified\",\"enum\":[\"auto\",\"path\",\"content\",\"both\"],\"type\":\"string\"},\"path\":{\"description\":\"Alias for filter.paths with a single file or folder path\",\"type\":\"string\"},\"pattern\":{\"description\":\"Search pattern\",\"type\":\"string\"},\"regex\":{\"description\":\"Use regex matching (default: auto based on pattern)\",\"type\":\"boolean\"},\"whole_word\":{\"description\":\"Match whole words only\",\"type\":\"boolean\"}},\"required\":[\"pattern\"],\"type\":\"object\"}", + "name" : "file_search", + "schema_sha256" : "08904f5e241c06414ff476b80b81338a5798961a69d93227d7ed098694546b99" + }, + { + "annotations" : { + "destructive" : false, + "idempotent" : true, + "open_world" : false, + "read_only" : true, + "title" : null + }, + "description" : "Canonical workspace context render/export tool.\n\nDefault behavior returns a snapshot of prompt, selection, code structure, and tokens.\nUse `op` for render/export helpers, or omit it for the default snapshot.\n\n**Default includes**: `[\"prompt\",\"selection\",\"code\",\"tokens\"]`\n\n**Available includes**:\n- `prompt`: Current prompt text\n- `selection`: Selected files summary\n- `code`: Code structure (codemaps) for selection\n- `files`: Full file contents\n- `tree`: File tree of selected files\n- `tokens`: Token breakdown by component\n\n**Operations**:\n- `snapshot` (default) — build/render the current workspace context snapshot\n- `export` — write the rendered export to disk\n- `list_presets` — list copy presets\n- `select_preset` — select the active copy preset for the bound tab\n\n**Options**:\n- `include`: Array of sections to include for snapshot rendering\n- `path_display`: \"relative\" | \"full\"\n- `copy_preset`: Override copy preset for token calculation / export rendering\n\n**Worktree scope**: When an agent session is bound to a Git worktree, displayed paths may remain logical/canonical while filesystem reads/searches use the bound worktree. Responses include `worktree_scope` when this remapping is active.\n\n**Examples**:\n- Default snapshot: `{}`\n- With file contents: `{\"include\":[\"prompt\",\"selection\",\"files\"]}`\n- Export: `{\"op\":\"export\",\"path\":\"context.txt\"}`\n- Preset override: `{\"copy_preset\":\"Plan\"}`\n\nRelated: manage_selection, get_file_tree, ask_oracle", + "description_sha256" : "fb968e72d430d354b03a0dfdb5251d95bbdea2a38cddcd58fe402f6bcb4f1035", + "enabled_by_default" : true, + "input_schema" : "{\"properties\":{\"copy_preset\":{\"description\":\"Preset UUID, kind, or name\",\"type\":\"string\"},\"include\":{\"description\":\"What to include (defaults to prompt, selection, code, tokens)\",\"items\":{\"enum\":[\"prompt\",\"selection\",\"code\",\"files\",\"tree\",\"tokens\"],\"type\":\"string\"},\"type\":\"array\"},\"op\":{\"description\":\"Operation (default: 'snapshot')\",\"enum\":[\"snapshot\",\"export\",\"list_presets\",\"select_preset\"],\"type\":\"string\"},\"path\":{\"description\":\"File path for export operation\",\"type\":\"string\"},\"path_display\":{\"description\":\"Path display for blocks\",\"enum\":[\"full\",\"relative\"],\"type\":\"string\"},\"preset\":{\"description\":\"Preset UUID, kind, or name\",\"type\":\"string\"}},\"type\":\"object\"}", + "name" : "workspace_context", + "schema_sha256" : "d41b9e8db1ccb1ce385d2d20619485a211bda4a8474270ef0c08fc77647e8376" + }, + { + "annotations" : { + "destructive" : false, + "idempotent" : null, + "open_world" : false, + "read_only" : false, + "title" : null + }, + "description" : "Get or modify the shared prompt (instructions/notes).\n\n**Operations**: get | set | append | clear | export | list_presets | select_preset\n\n**Parameters by op**:\n- `set`/`append`: `text` (required)\n- `export`: `path` (required), `copy_preset` (optional override)\n- `select_preset`: `preset` (required) - UUID, kind, or name\n\n**Notes**:\n- `select_preset` requires an explicitly bound tab context (not available during discovery runs)\n- `export` writes clipboard content to file so it can be copy/pasted into ChatGPT (or another AI) for a second opinion; use `copy_preset` to override format\n- `list_presets` returns all available copy presets with configurations\n\n**Examples**:\n- Get: `{\"op\":\"get\"}`\n- Set: `{\"op\":\"set\",\"text\":\"Focus on error handling\"}`\n- Export: `{\"op\":\"export\",\"path\":\"context.txt\"}`\n- List presets: `{\"op\":\"list_presets\"}`\n- Select preset: `{\"op\":\"select_preset\",\"preset\":\"Plan\"}`\n\nRelated: workspace_context, manage_selection, ask_oracle", + "description_sha256" : "e1377f12a6495829c0ade3e37b9325f7a07dc2065288b16bb810d01a4df9e55d", + "enabled_by_default" : true, + "input_schema" : "{\"properties\":{\"copy_preset\":{\"description\":\"Preset UUID, kind, or name\",\"type\":\"string\"},\"op\":{\"description\":\"Operation (default: 'get')\",\"enum\":[\"get\",\"set\",\"append\",\"clear\",\"export\",\"list_presets\",\"select_preset\"],\"type\":\"string\"},\"path\":{\"description\":\"File path (required for export)\",\"type\":\"string\"},\"preset\":{\"description\":\"Preset UUID, kind, or name\",\"type\":\"string\"},\"text\":{\"description\":\"Text for set/append\",\"type\":\"string\"}},\"type\":\"object\"}", + "name" : "prompt", + "schema_sha256" : "8c8ea22a39bbb9e10c364ad483527faf109a52e1eb9c45c0c939f569ecf144d1" + } + ], + "format_version" : 1, + "normalized_arguments" : [ + { + "context_id" : "CCCCCCCC-CCCC-CCCC-CCCC-CCCCCCCCCCCC", + "payload" : "{\"op\":\"status\"}", + "raw_json" : true, + "tab_id" : null, + "tool" : "bind_context", + "warnings" : [ + "Unwrapped tool-name wrapper at top level" + ], + "window_id" : 41, + "working_dirs" : [ + "/tmp/a", + "/tmp/b" + ] + }, + { + "context_id" : "CCCCCCCC-CCCC-CCCC-CCCC-CCCCCCCCCCCC", + "payload" : "{\"action\":\"list\"}", + "raw_json" : true, + "tab_id" : null, + "tool" : "manage_workspaces", + "warnings" : [ + "Unwrapped tool-name wrapper at top level" + ], + "window_id" : 41, + "working_dirs" : [ + + ] + }, + { + "context_id" : "CCCCCCCC-CCCC-CCCC-CCCC-CCCCCCCCCCCC", + "payload" : "{\"op\":\"get\"}", + "raw_json" : true, + "tab_id" : null, + "tool" : "manage_selection", + "warnings" : [ + "Unwrapped tool-name wrapper at top level" + ], + "window_id" : 41, + "working_dirs" : [ + + ] + }, + { + "context_id" : "CCCCCCCC-CCCC-CCCC-CCCC-CCCCCCCCCCCC", + "payload" : "{\"op\":\"snapshot\"}", + "raw_json" : true, + "tab_id" : null, + "tool" : "workspace_context", + "warnings" : [ + "Unwrapped tool-name wrapper at top level" + ], + "window_id" : 41, + "working_dirs" : [ + + ] + }, + { + "context_id" : "CCCCCCCC-CCCC-CCCC-CCCC-CCCCCCCCCCCC", + "payload" : "{\"type\":\"roots\"}", + "raw_json" : true, + "tab_id" : null, + "tool" : "get_file_tree", + "warnings" : [ + "Unwrapped tool-name wrapper at top level" + ], + "window_id" : 41, + "working_dirs" : [ + + ] + }, + { + "context_id" : "CCCCCCCC-CCCC-CCCC-CCCC-CCCCCCCCCCCC", + "payload" : "{\"scope\":\"selected\"}", + "raw_json" : true, + "tab_id" : null, + "tool" : "get_code_structure", + "warnings" : [ + "Unwrapped tool-name wrapper at top level" + ], + "window_id" : 41, + "working_dirs" : [ + + ] + }, + { + "context_id" : "CCCCCCCC-CCCC-CCCC-CCCC-CCCCCCCCCCCC", + "payload" : "{\"path\":\"Sources/App.swift\"}", + "raw_json" : true, + "tab_id" : null, + "tool" : "read_file", + "warnings" : [ + "Unwrapped tool-name wrapper at top level" + ], + "window_id" : 41, + "working_dirs" : [ + + ] + }, + { + "context_id" : "CCCCCCCC-CCCC-CCCC-CCCC-CCCCCCCCCCCC", + "payload" : "{\"pattern\":\"needle\",\"regex\":false}", + "raw_json" : true, + "tab_id" : null, + "tool" : "file_search", + "warnings" : [ + "Unwrapped tool-name wrapper at top level" + ], + "window_id" : 41, + "working_dirs" : [ + + ] + }, + { + "context_id" : "CCCCCCCC-CCCC-CCCC-CCCC-CCCCCCCCCCCC", + "payload" : "{\"op\":\"get\"}", + "raw_json" : true, + "tab_id" : null, + "tool" : "prompt", + "warnings" : [ + "Unwrapped tool-name wrapper at top level" + ], + "window_id" : 41, + "working_dirs" : [ + + ] + } + ], + "responses" : [ + { + "raw_text" : "{\"items\":[\"phase0\"],\"status\":\"ok\",\"tool\":\"bind_context\"}", + "source" : "representative formatter-boundary fixture", + "structured" : "{\"items\":[\"phase0\"],\"status\":\"ok\",\"tool\":\"bind_context\"}", + "text" : "```json\n{\n \"items\" : [\n \"phase0\"\n ],\n \"status\" : \"ok\",\n \"tool\" : \"bind_context\"\n}\n```", + "tool" : "bind_context" + }, + { + "raw_text" : "{\"items\":[\"phase0\"],\"status\":\"ok\",\"tool\":\"manage_workspaces\"}", + "source" : "representative formatter-boundary fixture", + "structured" : "{\"items\":[\"phase0\"],\"status\":\"ok\",\"tool\":\"manage_workspaces\"}", + "text" : "```json\n{\n \"items\" : [\n \"phase0\"\n ],\n \"status\" : \"ok\",\n \"tool\" : \"manage_workspaces\"\n}\n```", + "tool" : "manage_workspaces" + }, + { + "raw_text" : "{\"items\":[\"phase0\"],\"status\":\"ok\",\"tool\":\"manage_selection\"}", + "source" : "representative formatter-boundary fixture", + "structured" : "{\"items\":[\"phase0\"],\"status\":\"ok\",\"tool\":\"manage_selection\"}", + "text" : "## Selection ✅\n**0 total tokens**", + "tool" : "manage_selection" + }, + { + "raw_text" : "{\"items\":[\"phase0\"],\"status\":\"ok\",\"tool\":\"get_code_structure\"}", + "source" : "representative formatter-boundary fixture", + "structured" : "{\"items\":[\"phase0\"],\"status\":\"ok\",\"tool\":\"get_code_structure\"}", + "text" : "```json\n{\n \"items\" : [\n \"phase0\"\n ],\n \"status\" : \"ok\",\n \"tool\" : \"get_code_structure\"\n}\n```", + "tool" : "get_code_structure" + }, + { + "raw_text" : "{\"items\":[\"phase0\"],\"status\":\"ok\",\"tool\":\"get_file_tree\"}", + "source" : "representative formatter-boundary fixture", + "structured" : "{\"items\":[\"phase0\"],\"status\":\"ok\",\"tool\":\"get_file_tree\"}", + "text" : "```json\n{\n \"items\" : [\n \"phase0\"\n ],\n \"status\" : \"ok\",\n \"tool\" : \"get_file_tree\"\n}\n```", + "tool" : "get_file_tree" + }, + { + "raw_text" : "{\"items\":[\"phase0\"],\"status\":\"ok\",\"tool\":\"read_file\"}", + "source" : "representative formatter-boundary fixture", + "structured" : "{\"items\":[\"phase0\"],\"status\":\"ok\",\"tool\":\"read_file\"}", + "text" : "## File Read ✅\n- **Path**: `Sources/App.swift`\n- **Lines**: 0–1 of 1\n\n```swift\n\n```", + "tool" : "read_file" + }, + { + "raw_text" : "{\"items\":[\"phase0\"],\"status\":\"ok\",\"tool\":\"file_search\"}", + "source" : "representative formatter-boundary fixture", + "structured" : "{\"items\":[\"phase0\"],\"status\":\"ok\",\"tool\":\"file_search\"}", + "text" : "```json\n{\n \"items\" : [\n \"phase0\"\n ],\n \"status\" : \"ok\",\n \"tool\" : \"file_search\"\n}\n```", + "tool" : "file_search" + }, + { + "raw_text" : "{\"items\":[\"phase0\"],\"status\":\"ok\",\"tool\":\"workspace_context\"}", + "source" : "representative formatter-boundary fixture", + "structured" : "{\"items\":[\"phase0\"],\"status\":\"ok\",\"tool\":\"workspace_context\"}", + "text" : "```json\n{\n \"items\" : [\n \"phase0\"\n ],\n \"status\" : \"ok\",\n \"tool\" : \"workspace_context\"\n}\n```", + "tool" : "workspace_context" + }, + { + "raw_text" : "{\"items\":[\"phase0\"],\"status\":\"ok\",\"tool\":\"prompt\"}", + "source" : "representative formatter-boundary fixture", + "structured" : "{\"items\":[\"phase0\"],\"status\":\"ok\",\"tool\":\"prompt\"}", + "text" : "```json\n{\n \"items\" : [\n \"phase0\"\n ],\n \"status\" : \"ok\",\n \"tool\" : \"prompt\"\n}\n```", + "tool" : "prompt" + } + ], + "runtime" : "app-v1", + "tool_order" : [ + "bind_context", + "manage_workspaces", + "manage_selection", + "get_code_structure", + "get_file_tree", + "read_file", + "file_search", + "workspace_context", + "prompt" + ] +} \ No newline at end of file diff --git a/Tests/SharedRuntimeConvergenceFixtures/Phase0/Headless/ProfileV1/Workspaces/22222222-2222-2222-2222-222222222222.json b/Tests/SharedRuntimeConvergenceFixtures/Phase0/Headless/ProfileV1/Workspaces/22222222-2222-2222-2222-222222222222.json new file mode 100644 index 000000000..c175932c9 --- /dev/null +++ b/Tests/SharedRuntimeConvergenceFixtures/Phase0/Headless/ProfileV1/Workspaces/22222222-2222-2222-2222-222222222222.json @@ -0,0 +1,14 @@ +{ + "schema_version": 1, + "id": "22222222-2222-2222-2222-222222222222", + "name": "Phase 0 Headless V1", + "root_ids": ["11111111-1111-1111-1111-111111111111"], + "prompt_text": "phase zero headless prompt\nsecond line", + "selection": [ + {"root_id": "11111111-1111-1111-1111-111111111111","relative_path": "Sources/Full.swift","mode": "full","ranges": []}, + {"root_id": "11111111-1111-1111-1111-111111111111","relative_path": "Sources/Sliced.swift","mode": "slices","ranges": [{"start_line": 2,"end_line": 4,"description": "phase zero slice"}]}, + {"root_id": "11111111-1111-1111-1111-111111111111","relative_path": "Sources/Structure.swift","mode": "codemap_only","ranges": []} + ], + "created_at": "2026-01-02T03:04:07Z", + "updated_at": "2026-01-02T03:04:08Z" +} diff --git a/Tests/SharedRuntimeConvergenceFixtures/Phase0/Headless/ProfileV1/config.json b/Tests/SharedRuntimeConvergenceFixtures/Phase0/Headless/ProfileV1/config.json new file mode 100644 index 000000000..e53a8ca3b --- /dev/null +++ b/Tests/SharedRuntimeConvergenceFixtures/Phase0/Headless/ProfileV1/config.json @@ -0,0 +1,19 @@ +{ + "schema_version": 1, + "allowed_roots": [{ + "id": "11111111-1111-1111-1111-111111111111", + "name": "Phase0Root", + "path": "__FIXTURE_ROOT_PATH__", + "resolved_path": "__FIXTURE_ROOT_RESOLVED_PATH__", + "added_at": "2026-01-02T03:04:05Z" + }], + "active_workspace_id": "22222222-2222-2222-2222-222222222222", + "permissions": { + "write_files": false, + "vcs_write": false, + "launch_agents": false, + "export_outside_state_directory": false + }, + "created_at": "2026-01-02T03:04:05Z", + "updated_at": "2026-01-02T03:04:06Z" +} diff --git a/Tests/SharedRuntimeConvergenceFixtures/Phase0/Headless/headless-characterization.json b/Tests/SharedRuntimeConvergenceFixtures/Phase0/Headless/headless-characterization.json new file mode 100644 index 000000000..4efaad8a6 --- /dev/null +++ b/Tests/SharedRuntimeConvergenceFixtures/Phase0/Headless/headless-characterization.json @@ -0,0 +1,953 @@ +{ + "argument_coercion" : [ + { + "input" : { + "workspace" : 7 + }, + "observed" : null, + "tool" : "bind_context" + }, + { + "input" : { + "roots" : "Phase0Root" + }, + "observed" : [ + "Phase0Root" + ], + "tool" : "manage_workspaces" + }, + { + "input" : { + "paths" : [ + "a", + 7, + "b" + ] + }, + "observed" : [ + "a", + "b" + ], + "tool" : "manage_selection" + }, + { + "input" : { + "include" : "prompt" + }, + "observed" : [ + "prompt" + ], + "tool" : "workspace_context" + }, + { + "input" : { + "max_depth" : "3" + }, + "observed" : 3, + "tool" : "get_file_tree" + }, + { + "input" : { + "max_results" : 4 + }, + "observed" : 4, + "tool" : "get_code_structure" + }, + { + "input" : { + "start_line" : "2" + }, + "observed" : 2, + "tool" : "read_file" + }, + { + "input" : { + "regex" : "yes" + }, + "observed" : true, + "tool" : "file_search" + }, + { + "input" : { + "text" : 7 + }, + "observed" : null, + "tool" : "prompt" + } + ], + "baseline_commit" : "487cd71d892dbc3104689cc42fdb39f6c038e8fb", + "descriptors" : [ + { + "description" : "List, inspect, or bind the single headless session to a configured workspace.", + "inputSchema" : { + "additionalProperties" : true, + "properties" : { + "op" : { + "enum" : [ + "list", + "get", + "status", + "bind" + ], + "type" : "string" + }, + "workspace" : { + "description" : "Workspace id or name for bind.", + "type" : "string" + } + }, + "type" : "object" + }, + "name" : "bind_context" + }, + { + "description" : "Manage headless workspaces without adding arbitrary filesystem roots.", + "inputSchema" : { + "additionalProperties" : true, + "properties" : { + "action" : { + "description" : "Alias for op.", + "type" : "string" + }, + "name" : { + "description" : "Workspace name.", + "type" : "string" + }, + "new_name" : { + "description" : "New workspace name for rename.", + "type" : "string" + }, + "op" : { + "enum" : [ + "list", + "get", + "create", + "select", + "switch", + "rename" + ], + "type" : "string" + }, + "roots" : { + "description" : "Configured root ids/names/paths to include.", + "items" : { + "type" : "string" + }, + "type" : "array" + }, + "workspace" : { + "description" : "Workspace id or name.", + "type" : "string" + } + }, + "type" : "object" + }, + "name" : "manage_workspaces" + }, + { + "description" : "Read or mutate the active workspace selection using allowed root-contained paths only.", + "inputSchema" : { + "additionalProperties" : true, + "properties" : { + "mode" : { + "enum" : [ + "full", + "slices", + "codemap_only" + ], + "type" : "string" + }, + "op" : { + "enum" : [ + "get", + "preview", + "add", + "remove", + "set", + "clear" + ], + "type" : "string" + }, + "path" : { + "description" : "Single-path alias.", + "type" : "string" + }, + "paths" : { + "items" : { + "type" : "string" + }, + "type" : "array" + }, + "slices" : { + "type" : "array" + }, + "view" : { + "enum" : [ + "summary", + "files", + "content", + "codemaps" + ], + "type" : "string" + } + }, + "type" : "object" + }, + "name" : "manage_selection" + }, + { + "description" : "Render or export the active workspace prompt/selection/code/files/tree context.", + "inputSchema" : { + "additionalProperties" : true, + "properties" : { + "include" : { + "description" : "prompt, selection, code, tokens, files, tree", + "items" : { + "type" : "string" + }, + "type" : "array" + }, + "op" : { + "enum" : [ + "snapshot", + "export" + ], + "type" : "string" + }, + "path" : { + "description" : "Export path; relative paths stay under state Exports/.", + "type" : "string" + }, + "path_display" : { + "enum" : [ + "relative", + "full" + ], + "type" : "string" + } + }, + "type" : "object" + }, + "name" : "workspace_context" + }, + { + "annotations" : { + "readOnlyHint" : true + }, + "description" : "Return an ASCII tree for configured roots, a subpath, or selected files.", + "inputSchema" : { + "additionalProperties" : true, + "properties" : { + "max_depth" : { + "type" : "integer" + }, + "mode" : { + "enum" : [ + "auto", + "full", + "folders", + "selected" + ], + "type" : "string" + }, + "path" : { + "type" : "string" + }, + "type" : { + "enum" : [ + "files", + "roots" + ], + "type" : "string" + } + }, + "type" : "object" + }, + "name" : "get_file_tree" + }, + { + "annotations" : { + "readOnlyHint" : true + }, + "description" : "Return lightweight headless code signatures for paths or selected files.", + "inputSchema" : { + "additionalProperties" : true, + "properties" : { + "max_results" : { + "type" : "integer" + }, + "paths" : { + "items" : { + "type" : "string" + }, + "type" : "array" + }, + "scope" : { + "enum" : [ + "paths", + "selected" + ], + "type" : "string" + } + }, + "type" : "object" + }, + "name" : "get_code_structure" + }, + { + "annotations" : { + "readOnlyHint" : true + }, + "description" : "Read a UTF-8 file under configured roots with optional line slicing.", + "inputSchema" : { + "additionalProperties" : true, + "properties" : { + "limit" : { + "type" : "integer" + }, + "path" : { + "type" : "string" + }, + "start_line" : { + "type" : "integer" + } + }, + "required" : [ + "path" + ], + "type" : "object" + }, + "name" : "read_file" + }, + { + "annotations" : { + "readOnlyHint" : true + }, + "description" : "Search paths and/or UTF-8 file contents under configured roots.", + "inputSchema" : { + "additionalProperties" : true, + "properties" : { + "context_lines" : { + "type" : "integer" + }, + "count_only" : { + "type" : "boolean" + }, + "filter" : { + "type" : "object" + }, + "max_results" : { + "type" : "integer" + }, + "mode" : { + "enum" : [ + "auto", + "path", + "content", + "both" + ], + "type" : "string" + }, + "path" : { + "type" : "string" + }, + "pattern" : { + "type" : "string" + }, + "regex" : { + "type" : "boolean" + }, + "whole_word" : { + "type" : "boolean" + } + }, + "required" : [ + "pattern" + ], + "type" : "object" + }, + "name" : "file_search" + }, + { + "description" : "Get, set, append, clear, export, or list the built-in headless prompt preset.", + "inputSchema" : { + "additionalProperties" : true, + "properties" : { + "op" : { + "enum" : [ + "get", + "set", + "append", + "clear", + "export", + "list_presets" + ], + "type" : "string" + }, + "path" : { + "description" : "Export path; relative paths stay under state Exports/.", + "type" : "string" + }, + "text" : { + "type" : "string" + } + }, + "type" : "object" + }, + "name" : "prompt" + } + ], + "format_version" : 1, + "initialize" : { + "capabilities" : { + "tools" : { + + } + }, + "headless" : { + "configuredRootCount" : 1, + "safeToolsEnabled" : true, + "stateDirectory" : "$STATE" + }, + "instructions" : "RepoPrompt Headless is running the standalone read-oriented safe profile over direct stdio. Configure allowed roots with `repoprompt-headless config roots add /absolute/path --name NAME`. Only bind_context, constrained manage_workspaces, manage_selection, workspace_context, get_file_tree, get_code_structure, read_file, file_search, and prompt are enabled.", + "protocolVersion" : "2024-11-05", + "serverInfo" : { + "name" : "RepoPrompt Headless", + "version" : "1.0.6" + } + }, + "responses" : [ + { + "failure" : { + "content" : [ + { + "text" : "Unsupported bind_context op 'phase0_invalid'. Supported ops: list, get, bind.", + "type" : "text" + } + ], + "isError" : true + }, + "success" : { + "content" : [ + { + "text" : "## Headless Context Binding ✅\n- **Active workspace**: Phase 0 Headless V1 (`22222222-2222-2222-2222-222222222222`)\n- **Roots**: Phase0Root\n- **State directory**: `bound`", + "type" : "text" + } + ], + "isError" : false, + "structuredContent" : { + "config" : { + "active_workspace_id" : "22222222-2222-2222-2222-222222222222", + "allowed_roots" : [ + { + "added_at" : "$TIMESTAMP", + "id" : "11111111-1111-1111-1111-111111111111", + "name" : "Phase0Root", + "path" : "$ROOT", + "resolved_path" : "$ROOT" + } + ], + "created_at" : "$TIMESTAMP", + "permissions" : { + "export_outside_state_directory" : false, + "launch_agents" : false, + "vcs_write" : false, + "write_files" : false + }, + "schema_version" : 1, + "updated_at" : "$TIMESTAMP" + }, + "roots" : [ + { + "added_at" : "$TIMESTAMP", + "id" : "11111111-1111-1111-1111-111111111111", + "name" : "Phase0Root", + "path" : "$ROOT", + "resolved_path" : "$ROOT" + } + ], + "workspace" : { + "created_at" : "$TIMESTAMP", + "id" : "22222222-2222-2222-2222-222222222222", + "name" : "Phase 0 Headless V1", + "prompt_text" : "phase zero headless prompt\nsecond line", + "root_ids" : [ + "11111111-1111-1111-1111-111111111111" + ], + "schema_version" : 1, + "selection" : [ + { + "mode" : "full", + "ranges" : [ + + ], + "relative_path" : "Sources/Full.swift", + "root_id" : "11111111-1111-1111-1111-111111111111" + }, + { + "mode" : "slices", + "ranges" : [ + { + "description" : "phase zero slice", + "end_line" : 4, + "start_line" : 2 + } + ], + "relative_path" : "Sources/Sliced.swift", + "root_id" : "11111111-1111-1111-1111-111111111111" + }, + { + "mode" : "codemap_only", + "ranges" : [ + + ], + "relative_path" : "Sources/Structure.swift", + "root_id" : "11111111-1111-1111-1111-111111111111" + } + ], + "updated_at" : "$TIMESTAMP" + } + } + }, + "tool" : "bind_context" + }, + { + "failure" : { + "content" : [ + { + "text" : "Unsupported manage_workspaces op 'phase0_invalid'. Supported ops: list, get, create, select, rename.", + "type" : "text" + } + ], + "isError" : true + }, + "success" : { + "content" : [ + { + "text" : "## Headless Workspaces ✅\n- **Workspaces**: 1\n- **Active**: `22222222-2222-2222-2222-222222222222`\n* Phase 0 Headless V1 (`22222222-2222-2222-2222-222222222222`) roots=1 selection=3", + "type" : "text" + } + ], + "isError" : false, + "structuredContent" : { + "active_workspace_id" : "22222222-2222-2222-2222-222222222222", + "workspaces" : [ + { + "created_at" : "$TIMESTAMP", + "id" : "22222222-2222-2222-2222-222222222222", + "name" : "Phase 0 Headless V1", + "prompt_text" : "phase zero headless prompt\nsecond line", + "root_ids" : [ + "11111111-1111-1111-1111-111111111111" + ], + "schema_version" : 1, + "selection" : [ + { + "mode" : "full", + "ranges" : [ + + ], + "relative_path" : "Sources/Full.swift", + "root_id" : "11111111-1111-1111-1111-111111111111" + }, + { + "mode" : "slices", + "ranges" : [ + { + "description" : "phase zero slice", + "end_line" : 4, + "start_line" : 2 + } + ], + "relative_path" : "Sources/Sliced.swift", + "root_id" : "11111111-1111-1111-1111-111111111111" + }, + { + "mode" : "codemap_only", + "ranges" : [ + + ], + "relative_path" : "Sources/Structure.swift", + "root_id" : "11111111-1111-1111-1111-111111111111" + } + ], + "updated_at" : "$TIMESTAMP" + } + ] + } + }, + "tool" : "manage_workspaces" + }, + { + "failure" : { + "content" : [ + { + "text" : "Unsupported manage_selection op 'phase0_invalid'. Supported ops: get, preview, add, remove, set, clear.", + "type" : "text" + } + ], + "isError" : true + }, + "success" : { + "content" : [ + { + "text" : "## Selection ✅\n- **Files**: 3\n- **Auto-codemap**: disabled in headless v1\n- `Phase0Root/Sources/Full.swift` (full)\n- `Phase0Root/Sources/Sliced.swift` (slices, lines 2-4)\n- `Phase0Root/Sources/Structure.swift` (codemap_only)", + "type" : "text" + } + ], + "isError" : false, + "structuredContent" : { + "codemap_auto_enabled" : false, + "files" : [ + { + "mode" : "full", + "ranges" : [ + + ], + "relative_path" : "Sources/Full.swift", + "root_id" : "11111111-1111-1111-1111-111111111111" + }, + { + "mode" : "slices", + "ranges" : [ + { + "description" : "phase zero slice", + "end_line" : 4, + "start_line" : 2 + } + ], + "relative_path" : "Sources/Sliced.swift", + "root_id" : "11111111-1111-1111-1111-111111111111" + }, + { + "mode" : "codemap_only", + "ranges" : [ + + ], + "relative_path" : "Sources/Structure.swift", + "root_id" : "11111111-1111-1111-1111-111111111111" + } + ], + "summary" : "3 selected entries", + "total_tokens" : 0 + } + }, + "tool" : "manage_selection" + }, + { + "failure" : { + "content" : [ + { + "text" : "Unsupported workspace_context op 'phase0_invalid'. Supported ops: snapshot, export.", + "type" : "text" + } + ], + "isError" : true + }, + "success" : { + "content" : [ + { + "text" : "## Prompt Context ✅\n- **Workspace**: Phase 0 Headless V1\n- **Copy preset**: Headless Default\n\n### Prompt\n```text\nphase zero headless prompt\nsecond line\n```\n\n### Selection\n- Phase0Root/Sources/Full.swift (full)\n- Phase0Root/Sources/Sliced.swift (slices)\n- Phase0Root/Sources/Structure.swift (codemap_only)", + "type" : "text" + } + ], + "isError" : false, + "structuredContent" : { + "include" : [ + "prompt", + "selection" + ], + "prompt" : "phase zero headless prompt\nsecond line", + "roots" : [ + { + "added_at" : "$TIMESTAMP", + "id" : "11111111-1111-1111-1111-111111111111", + "name" : "Phase0Root", + "path" : "$ROOT", + "resolved_path" : "$ROOT" + } + ], + "selection" : [ + { + "mode" : "full", + "ranges" : [ + + ], + "relative_path" : "Sources/Full.swift", + "root_id" : "11111111-1111-1111-1111-111111111111" + }, + { + "mode" : "slices", + "ranges" : [ + { + "description" : "phase zero slice", + "end_line" : 4, + "start_line" : 2 + } + ], + "relative_path" : "Sources/Sliced.swift", + "root_id" : "11111111-1111-1111-1111-111111111111" + }, + { + "mode" : "codemap_only", + "ranges" : [ + + ], + "relative_path" : "Sources/Structure.swift", + "root_id" : "11111111-1111-1111-1111-111111111111" + } + ], + "workspace" : { + "created_at" : "$TIMESTAMP", + "id" : "22222222-2222-2222-2222-222222222222", + "name" : "Phase 0 Headless V1", + "prompt_text" : "phase zero headless prompt\nsecond line", + "root_ids" : [ + "11111111-1111-1111-1111-111111111111" + ], + "schema_version" : 1, + "selection" : [ + { + "mode" : "full", + "ranges" : [ + + ], + "relative_path" : "Sources/Full.swift", + "root_id" : "11111111-1111-1111-1111-111111111111" + }, + { + "mode" : "slices", + "ranges" : [ + { + "description" : "phase zero slice", + "end_line" : 4, + "start_line" : 2 + } + ], + "relative_path" : "Sources/Sliced.swift", + "root_id" : "11111111-1111-1111-1111-111111111111" + }, + { + "mode" : "codemap_only", + "ranges" : [ + + ], + "relative_path" : "Sources/Structure.swift", + "root_id" : "11111111-1111-1111-1111-111111111111" + } + ], + "updated_at" : "$TIMESTAMP" + } + } + }, + "tool" : "workspace_context" + }, + { + "failure" : { + "content" : [ + { + "text" : "Path is not available under the active workspace roots: Missing", + "type" : "text" + } + ], + "isError" : true + }, + "success" : { + "content" : [ + { + "text" : "## File Tree ✅\n- **Roots**: 1\n\n- Phase0Root: `$ROOT`", + "type" : "text" + } + ], + "isError" : false, + "structuredContent" : { + "roots" : [ + { + "added_at" : "$TIMESTAMP", + "id" : "11111111-1111-1111-1111-111111111111", + "name" : "Phase0Root", + "path" : "$ROOT", + "resolved_path" : "$ROOT" + } + ], + "roots_count" : 1 + } + }, + "tool" : "get_file_tree" + }, + { + "failure" : { + "content" : [ + { + "text" : "Unsupported get_code_structure scope 'phase0_invalid'. Expected paths or selected.", + "type" : "text" + } + ], + "isError" : true + }, + "success" : { + "content" : [ + { + "text" : "## Code Structure ✅\n- **Files with codemap**: 1\n- **Parser**: `headless-lightweight`\n\n```text\nFile: Phase0Root/Sources/Structure.swift\nParser: headless-lightweight\nL1: struct Structure\n```", + "type" : "text" + } + ], + "isError" : false, + "structuredContent" : { + "files" : [ + { + "parser" : "headless-lightweight", + "path" : "Phase0Root/Sources/Structure.swift", + "relative_path" : "Sources/Structure.swift", + "symbols" : [ + { + "kind" : "type", + "line" : 1, + "signature" : "struct Structure" + } + ] + } + ], + "files_with_codemap" : 1, + "parser" : "headless-lightweight", + "skipped" : [ + + ] + } + }, + "tool" : "get_code_structure" + }, + { + "failure" : { + "content" : [ + { + "text" : "Missing required argument 'path'.", + "type" : "text" + } + ], + "isError" : true + }, + "success" : { + "content" : [ + { + "text" : "## File Read ✅\n- **Path**: `Phase0Root/Sources/Full.swift`\n- **Lines**: 1–1 of 1\n\n\n```swift\nstruct Full {}\n\n```", + "type" : "text" + } + ], + "isError" : false, + "structuredContent" : { + "content" : "struct Full {}\n", + "display_path" : "Phase0Root/Sources/Full.swift", + "first_line" : 1, + "last_line" : 1, + "message" : null, + "path" : "$ROOT/Sources/Full.swift", + "total_lines" : 1 + } + }, + "tool" : "read_file" + }, + { + "failure" : { + "content" : [ + { + "text" : "Missing required argument 'pattern'.", + "type" : "text" + } + ], + "isError" : true + }, + "success" : { + "content" : [ + { + "text" : "## Search Results ✅\n- **Pattern**: `struct Full`\n- **Mode**: `content`\n- **Total matches**: 1\n- **Path matches**: 0\n- **Content matches**: 1\n- **Returned matches**: 1\n- **Omitted by max_results**: 0\n- **Catalog entries scanned**: 5\n- **Catalog entry limit**: 20000 across 1 scan(s)\n\n### Content Matches\n- `Phase0Root/Sources/Full.swift:1` struct Full {}", + "type" : "text" + } + ], + "isError" : false, + "structuredContent" : { + "catalog_entries_considered" : 5, + "catalog_entries_scanned" : 5, + "catalog_entry_limit" : 20000, + "catalog_scan_count" : 1, + "catalog_skipped_entries" : 0, + "catalog_truncated" : false, + "content_files_scanned" : 3, + "content_files_skipped" : 0, + "content_matches" : [ + { + "context" : [ + { + "line" : 1, + "text" : "struct Full {}" + } + ], + "line" : 1, + "path" : "Phase0Root/Sources/Full.swift", + "relative_path" : "Sources/Full.swift", + "root" : "Phase0Root", + "text" : "struct Full {}" + } + ], + "content_totals_complete" : true, + "count_only" : false, + "mode" : "content", + "omitted" : 0, + "omitted_is_lower_bound" : false, + "path_matches" : [ + + ], + "path_totals_complete" : true, + "pattern" : "struct Full", + "regex" : false, + "returned_matches" : 1, + "total_content_matches" : 1, + "total_matches" : 1, + "total_matches_is_lower_bound" : false, + "total_path_matches" : 0, + "totals_are_lower_bounds" : false, + "totals_complete" : true, + "whole_word" : false + } + }, + "tool" : "file_search" + }, + { + "failure" : { + "content" : [ + { + "text" : "Unsupported prompt op 'phase0_invalid'. Supported ops: get, set, append, clear, export, list_presets.", + "type" : "text" + } + ], + "isError" : true + }, + "success" : { + "content" : [ + { + "text" : "## Prompt ✅\n\n```text\nphase zero headless prompt\nsecond line\n```", + "type" : "text" + } + ], + "isError" : false, + "structuredContent" : { + "op" : "get", + "prompt" : "phase zero headless prompt\nsecond line" + } + }, + "tool" : "prompt" + } + ], + "runtime" : "headless-v1", + "tool_order" : [ + "bind_context", + "manage_workspaces", + "manage_selection", + "workspace_context", + "get_file_tree", + "get_code_structure", + "read_file", + "file_search", + "prompt" + ] +} \ No newline at end of file diff --git a/Tests/SharedRuntimeConvergenceFixtures/Phase0/differential-ledger.json b/Tests/SharedRuntimeConvergenceFixtures/Phase0/differential-ledger.json new file mode 100644 index 000000000..191abbdfe --- /dev/null +++ b/Tests/SharedRuntimeConvergenceFixtures/Phase0/differential-ledger.json @@ -0,0 +1,20 @@ +{ + "format_version": 1, + "allowed_product_differences": [ + "initialize and product/profile metadata", + "profile and state-root paths", + "unsupported capability omissions", + "standalone initialization and configuration instructions" + ], + "tools": [ + {"name":"bind_context","descriptor":"phase_1_blocker","arguments":"phase_1_blocker","structured_text_response":"phase_1_blocker","allowed":[]}, + {"name":"manage_workspaces","descriptor":"phase_1_blocker","arguments":"phase_1_blocker","structured_text_response":"phase_1_blocker","allowed":[]}, + {"name":"manage_selection","descriptor":"phase_1_blocker","arguments":"phase_1_blocker","structured_text_response":"phase_1_blocker","allowed":[]}, + {"name":"workspace_context","descriptor":"phase_1_blocker","arguments":"phase_1_blocker","structured_text_response":"phase_1_blocker","allowed":[]}, + {"name":"get_file_tree","descriptor":"phase_1_blocker","arguments":"phase_1_blocker","structured_text_response":"phase_1_blocker","allowed":[]}, + {"name":"get_code_structure","descriptor":"phase_1_blocker","arguments":"phase_1_blocker","structured_text_response":"phase_1_blocker","allowed":[]}, + {"name":"read_file","descriptor":"phase_1_blocker","arguments":"phase_1_blocker","structured_text_response":"phase_1_blocker","allowed":[]}, + {"name":"file_search","descriptor":"phase_1_blocker","arguments":"phase_1_blocker","structured_text_response":"phase_1_blocker","allowed":[]}, + {"name":"prompt","descriptor":"phase_1_blocker","arguments":"phase_1_blocker","structured_text_response":"phase_1_blocker","allowed":[]} + ] +} diff --git a/Tests/SharedRuntimeConvergenceFixtures/Phase0/manifest.json b/Tests/SharedRuntimeConvergenceFixtures/Phase0/manifest.json new file mode 100644 index 000000000..a8cf6bd0d --- /dev/null +++ b/Tests/SharedRuntimeConvergenceFixtures/Phase0/manifest.json @@ -0,0 +1,34 @@ +{ + "format_version": 1, + "branch": "core_split", + "freeze_head": "487cd71d892dbc3104689cc42fdb39f6c038e8fb", + "baselines": { + "packaging": "2b350916d52809dd036331a746d888132019ce75", + "app_mcp": "042a500b03b39d04237ec5544811696cf6b2f2f9", + "headless": "487cd71d892dbc3104689cc42fdb39f6c038e8fb" + }, + "overlapping_tools": ["bind_context","manage_workspaces","manage_selection","workspace_context","get_file_tree","get_code_structure","read_file","file_search","prompt"], + "allowed_product_differences": [ + "initialize and product/profile metadata", + "profile and state-root paths", + "unsupported capability omissions", + "standalone initialization and configuration instructions" + ], + "phase_1_or_later_blockers": [ + "descriptor descriptions, schemas, annotations, and order are independently owned", + "app wrapper normalization and headless per-field coercion differ", + "structured DTO shapes, text formatting, and errors differ", + "headless owns duplicate workspace, search, codemap, selection, prompt, and safe-provider implementations", + "app-v1 and headless-v1 use different workspace schemas and repository layouts", + "mature host/session/runtime ownership remains under Sources/RepoPrompt", + "RepoPromptShared still owns POSIX descriptor support", + "internal Core implementation targets are still exposed as library products" + ], + "invariant_gates": { + "routing_order": ["TabContextRoutingTests","BindContextRoutingRecoveryTests","MCPResolvedToolDispatchSourceGuardTests"], + "watcher_barriers": ["FileSystemAcceptedIngressBarrierTests","WorkspaceFileContextStoreTests"], + "socket_bootstrap_order": ["MCPBootstrapContractCharacterizationTests","MCPSocketDescriptorHardeningTests"], + "process_semantics": ["ProcessLauncherDescriptorInheritanceTests"], + "packaging_separation": ["Scripts/test_shared_runtime_phase0_characterization.py","Scripts/test_release_tooling.py","Scripts/smoke_headless_mcp.sh","Scripts/smoke_embedded_mcp_helper.sh"] + } +} diff --git a/docs/architecture/headless-core.md b/docs/architecture/headless-core.md new file mode 100644 index 000000000..56ba57630 --- /dev/null +++ b/docs/architecture/headless-core.md @@ -0,0 +1,390 @@ +# Headless Core Architecture Lock + +Status: Phase 2 Slice 3 ownership is integrated and undergoing convergence hardening as of 2026-06-08. Shared remains platform-neutral with one narrow CryptoKit hashing exception; Core owns canonical app workspace/session authority plus the neutral filesystem, catalog, path, search, selection, slices, token-accounting, codemap, syntax, factual prompt rendering/assembly, and workspace projection closure. CoreMacOS owns directory listing and FSEvents watching. The app constructs and consumes the Core runtime through app-only observation, UI, prompt, and policy adapters; it must not retain parallel canonical state. Phase 0 characterization artifacts remain frozen at their original historical baseline, while the complete headless source/test trees are separately locked at the reviewed post-remediation state-security baseline. Headless still owns its parallel v1 runtime. Full shared-runtime convergence remains incomplete until Phase 3 explicitly adopts Core from headless and converges the deferred MCP/provider surface. + +## Locked target graph + +The target graph below is the convergence destination. Today the app constructs the mature shared workspace/file-context runtime from Core and CoreMacOS through app-owned adapters, while the separately packaged headless executable uses its own reviewed-baseline-locked v1 runtime without requiring `RepoPrompt.app` to be installed or running. + +```text + RepoPromptShared + protocol DTOs · framing · socket contract + │ + ┌─────────────┴─────────────┐ + │ │ + RepoPromptCore RepoPromptCoreMacOS + sessions · workspaces · MCP FSEvents · POSIX · Keychain + dispatch · neutral policies code signing · peer PID lookup + │ │ + ┌───────────┴──────────┐ ┌────────┴─────────┐ + │ │ │ │ + RepoPrompt.app repoprompt-headless repoprompt-mcp + AppKit/SwiftUI shell direct stdio MCP existing app proxy +``` + +The current package graph contains bounded contract/adapter roots plus the standalone executable and its separate package/install/smoke boundary: + +| Reserved target or product | Reserved source root | Responsibility | +| --- | --- | --- | +| `RepoPromptCore` internal target | `Sources/RepoPromptCore` | Canonical persisted workspace/session authority plus neutral filesystem/catalog/path/search/selection/slices/token/codemap/syntax runtime and platform contracts | +| `RepoPromptCoreMacOS` internal target | `Sources/RepoPromptCoreMacOS` | Workspace directory listing, FSEvents, POSIX process/descriptor-write, Keychain, code-signing inspection, peer verification, and macOS adapters | +| `RepoPromptPOSIXSupport` internal target | `Sources/RepoPromptPOSIXSupport` | Shared close-on-exec and socket-shutdown helpers used by current POSIX importers | +| `RepoPromptSyntaxCBridge` target | `Sources/RepoPromptSyntaxCBridge` | Narrow Tree-sitter declarations and grammar/scanner linkage without an app target-wide bridging header | +| `repoprompt-headless` executable | `Sources/RepoPromptHeadless` | Independent direct-stdio JSON-RPC host with fail-closed config/state/root policy, permission defaults, terminal doctor/config commands, the read-oriented safe MCP profile, and separate package/install/smoke lane | + +Existing app/proxy owners remain compatible through Phase 2 Slice 3: + +| Existing target | Current responsibility retained during Item 0 | +| --- | --- | +| `RepoPrompt` | SwiftUI/AppKit shell, sole constructor/consumer of the Slice 2 runtime, and owner of composition, observation, mutation, diagnostics, readiness, UI conversion, MCP, and prompt projection/policy adapters | +| `RepoPromptMCP` / `repoprompt-mcp` | Existing app-bundled socket proxy, interactive client, and exec client | +| `RepoPromptShared` | Platform-neutral app/CLI MCP wire contracts; only `MCP/JSONRPCBridgeLedger.swift` may import `CryptoKit` for deterministic SHA-256 frame correlation | + +Keep `platforms: [.macOS(.v14)]` during this migration. The first milestone is a standalone Swift-toolchain core boundary, not a Linux or Windows product claim. + +## Phase 2 Slice 3 runtime lock + +- `RepoPromptCore.WorkspaceSessionController` remains the sole mutable workspace/session authority; `WorkspacePersistenceWriter` and `EmbeddedWorkspaceCodecV1` preserve explicit-write-only app-v1 persistence and keep canonical-v2 writes inactive. +- Core owns the canonical neutral filesystem/catalog/path/search/selection/slices/token/codemap/syntax closure, including accepted-ingress/watermark/unload barriers and bounded search admission/backpressure. +- `RepoPromptCoreMacOS` owns workspace directory listing and FSEvents watching behind injected Core contracts. Core has no default macOS watcher construction or Application Support discovery. +- `RepoPromptEmbeddedWorkspaceRuntimeFactory` is the sole production factory. The app supplies CoreMacOS listing/watching plus app mutation, diagnostics, readiness, observation, cache-root, and view-model adapters. +- The temporary Slice 1 `WorkspaceSessionSelectionForwarder` and obsolete app runtime source paths are deleted; app behavior is adapted rather than duplicated. +- Core owns deterministic factual rendering/assembly plus workspace selection, token, code-structure, and context projections. The app retains entry conversion, artifact classification, live token-fact materialization, display/codemap mapping, Git fallback, prompt/chat/clipboard policy, Context Builder/MCP envelopes, MCP provider/catalog/DTO/formatter/dispatch ownership, and app-proxy transport through explicit adapters such as `WorkspacePromptProjectionAdapter`. +- Phase 0 fixtures plus the original characterization document/script remain byte-for-byte frozen at `48a335e`; they continue to describe the historical characterization event rather than current source provenance. +- The complete `Sources/RepoPromptHeadless/**` and `Tests/RepoPromptHeadlessTests/**` trees are independently locked by `Scripts/Fixtures/shared-runtime-headless-reviewed.sha256` after review of the standalone state-directory, state-file, and lock-file security remediation. Headless does not construct the new runtime. +- The active `Scripts/test_shared_runtime_phase2_boundaries.py` check enforces authority, no-read-rewrite, runtime ownership, sole construction, importer-backed dependencies, canonical single-source owners, app-adapter delegation, the immutable Phase 0 artifacts, the complete reviewed headless manifest, and the neutral prompt/projection boundary. `Scripts/test_shared_runtime_phase2_slice1_boundaries.py` remains a historical Slice 1 authority/Phase 0 checkpoint and is not a current headless-baseline owner. + +## Locked ownership rules + +`RepoPromptCore` must not import `AppKit`, `SwiftUI`, `Sparkle`, `KeyboardShortcuts`, `CoreServices`, `Security`, `Darwin`, `OSLog`, or `os`. It must not own Apple signposts or reference app-owned runtime types such as `WindowState`, `WindowStatesManager`, `NSApplication`, or `NSWorkspace`. Platform-neutral counters and elapsed-duration metrics remain allowed. + +The core runtime abstraction is a window-independent multi-session host. App windows and MCP contexts project onto core sessions; windows do not own reusable runtime state. The public compatibility schema continues to use `window_id` during the migration. Existing app routing also has a hidden strong per-call `_windowID` override; these two spellings are related compatibility surfaces but are not interchangeable in every code path. + +The current app-bundled proxy remains separate from the future direct-stdio host. Do not turn `repoprompt-mcp` into the standalone host and do not make the migration depend on a shared-daemon IPC protocol. + +## App-proxy compatibility guarantees + +Later items may centralize or move implementations only if these behaviors remain intact: + +| Contract | Locked current behavior | +| --- | --- | +| App bootstrap endpoints | Debug: `/tmp/repoprompt-ce-mcp-{uid}/repoprompt-ce-D-7.sock`; release: `/tmp/repoprompt-ce-mcp-{uid}/repoprompt-ce-7.sock` | +| Socket namespace version | `7`, with build flavor encoded in the socket name | +| Bootstrap protocol version | `2` | +| Request encoding | newline-delimited JSON with `type`, `sessionToken`, `clientPid`, optional `clientName`, and `protocolVersion` | +| Response encoding | newline-delimited JSON with `type`, optional `reason`, and optional `errorCode` | +| Bootstrap error-code raw values | `approval_denied`, `protocol_version_mismatch`, `server_not_ready`, `server_unavailable`, `connection_limit_reached`, `capacity_exceeded`, `session_blocked`, and `client_cooldown` | +| App bundle helper | regular executable at `Contents/MacOS/repoprompt-mcp` | +| Compatibility helper links | `Contents/Resources/repoprompt-mcp -> ../MacOS/repoprompt-mcp` and `Contents/Resources/bin/repoprompt-mcp -> ../../MacOS/repoprompt-mcp` | +| App packaging | embed and sign the app proxy helper only; never embed the independently packaged standalone host | +| CLI admission | spoofable `RepoPrompt CLI` names bypass the generic allow-list only after trusted peer PID lookup and canonical bundled-executable path equality | +| Persisted MCP allow-list | entries matching the trimmed, case-sensitive `RepoPrompt CLI` prefix are removed and cannot be persisted | + +The app and CLI now consume the bootstrap DTOs and flavor-aware filesystem identity centralized in `RepoPromptShared`. Keep those shared contracts single-sourced and platform-neutral while later runtime ownership moves: `MCPFilesystemIdentity` derives the v7 debug/release endpoints from an explicit user ID, and the app and `RepoPromptMCP` adapters resolve `getuid()` locally. + +## Command surfaces and managed paths + +Slice 5C keeps the app proxy and standalone host as separate command families: + +| Command | Backing executable | Transport/state | Validation purpose | +| --- | --- | --- | --- | +| `rpce-cli` / `rpce-cli-debug` | app-bundled `RepoPrompt.app/Contents/MacOS/repoprompt-mcp` | Connects to the running app bootstrap socket and uses app windows, workspaces, approvals, and app secure-storage policy | App-proxy MCP behavior and live app integration | +| `rpce-headless` / `rpce-headless-debug` | independently staged `HeadlessTools/{Release,Debug}/repoprompt-headless` | Direct stdio JSON-RPC; uses `~/Library/Application Support/RepoPrompt CE/Headless/` plus a separate secure-storage namespace; never launches or connects to `RepoPrompt.app` | Standalone safe read-oriented MCP behavior | + +Managed standalone links are intentionally outside the app bundle: + +```text +/usr/local/bin/rpce-headless-debug + -> ~/Library/Application Support/RepoPrompt CE/repoprompt_headless_debug + -> ~/Library/Application Support/RepoPrompt CE/HeadlessTools/Debug/repoprompt-headless + +/usr/local/bin/rpce-headless + -> ~/Library/Application Support/RepoPrompt CE/repoprompt_headless + -> ~/Library/Application Support/RepoPrompt CE/HeadlessTools/Release/repoprompt-headless +``` + +`Scripts/package_app.sh` remains the app-bundle owner and must not mention standalone command names. `Scripts/package_headless.sh`, `Scripts/install_headless_cli.sh`, and `Scripts/smoke_headless_mcp.sh` own standalone packaging, managed links, and direct-stdio smoke validation. + +### Current routing priority + +Preserve the current `tools/call` routing order while runtime ownership changes later: + +1. Logical `context_id` / legacy `_tabID` pre-resolution where applicable. +2. Hidden `_windowID` strong per-call override. +3. Existing connection-to-window mapping. +4. Same-client reusable-window mapping for a replacement or new connection. +5. Same-process live-run affinity. +6. Persisted token-backed affinity. +7. Single-window fallback to the first MCP-enabled window under the existing effective multi-window policy. +8. Multi-window guidance failure when selection remains unresolved. +9. Run-scoped tab rebind fallback, then legacy tab-binding compatibility, before invocation. + +`MCPBindingResolver` uses the matching logical-context subset of that order: requested window, existing mapping, same-client reuse, live-run affinity, persisted affinity, only-hosting-window fallback, then ambiguity failure. + +## Standalone security defaults and Slice 5C status + +Slices 5A-5C implement these locked constraints for the first standalone profile: + +- `repoprompt-headless` serves direct MCP over stdin/stdout and must not connect to or bind the app-proxy socket. +- Standalone operation must not launch `RepoPrompt.app`, require `Bundle.main`, reuse app workspace persistence implicitly, or reuse app secrets implicitly. +- The default standalone profile root is separate: + + ```text + ~/Library/Application Support/RepoPrompt CE/Headless/ + config.json + Workspaces/ + Exports/ + ``` + +- Standalone secure storage uses a separate namespace and noninteractive reads while serving. Secret writes require explicit terminal commands. +- Root access fails closed: resolve symlinks, use URL-component containment, reject workspace operations outside configured roots, and start unbound when no roots are configured. +- Standalone state directories are owner-only (`0700`), persisted state and lock files are owner-only (`0600`), and descriptor-relative `O_NOFOLLOW`/`O_CLOEXEC` access keeps reads, atomic replacements, and locks anchored to the validated state root while rejecting unsafe file types, owners, links, and path replacement races. +- Mutation and automation permissions default to `false`: + + | Permission | Default | + | --- | --- | + | `write_files` | `false` | + | `vcs_write` | `false` | + | `launch_agents` | `false` | + | `export_outside_state_directory` | `false` | + +- The first standalone safe profile is read-oriented and exposes only `bind_context`, constrained `manage_workspaces`, `manage_selection`, `workspace_context`, `get_file_tree`, `get_code_structure`, `read_file`, `file_search`, and `prompt`. +- Mutation, VCS-write, broader export, oracle, Context Builder, Agent Mode, app settings, and app lifecycle capabilities remain omitted or operation-gated in standalone v1. +- Slice 5C packaging validates this profile with `Scripts/smoke_headless_mcp.sh` over direct stdio: initialize, `tools/list`, `read_file`, `file_search`, export permission rejection, gated-tool rejection, and shutdown. + +## Phase 0 regenerated move inventory + +This current-owner inventory supersedes the historical Item 0 path table below for implementation planning. It records the frozen `487cd71` checkout without moving any file in Phase 0. + +| Current owner/path family | Future destination/disposition | Phase 0 evidence | +| --- | --- | --- | +| `Sources/RepoPrompt/Infrastructure/Core/RepoPromptCoreHost.swift`; neutral MCP service/tool/runtime registries under `Infrastructure/MCP` | `RepoPromptCore/Runtime`; rename window-scoped reusable abstractions to session-scoped | `RepoPromptCoreHostLifecycleTests`, `MCPRuntimeRegistryTests`, dispatch-source guards | +| `Features/Workspaces/WorkspaceModel.swift`, `Features/Workspaces/Core/WorkspaceRepository.swift`, neutral workspace session/controller state and Codable dependencies | `RepoPromptCore/Workspaces`; app ObservableObject/AppStorage/folder-picker adapters remain in `RepoPrompt` | app-v1 Phase 0 fixture plus repository/no-rewrite characterization | +| Neutral `Infrastructure/WorkspaceContext/**` indexing, path, search, selection, slices, token accounting, and store behavior | `RepoPromptCore/WorkspaceContext`; watcher factory and diagnostics injected, macOS mechanics remain in CoreMacOS | `WorkspaceFileContextStoreTests`, path/search/selection suites, accepted-ingress barrier tests | +| Neutral `Features/CodeMap/**`, `Infrastructure/SyntaxParsing/**`, and required PCRE/C/parser wrappers | `RepoPromptCore/CodeMap` and `RepoPromptCore/SyntaxParsing`; File/Folder view-model and AppKit presentation adapters remain app-owned | CodeMap goldens and parser/scanner compatibility tests | +| Neutral prompt assembly, workspace-context rendering, resolved tree, selection reply, and token accounting behavior | `RepoPromptCore` prompt/context ownership; `PromptFileEntry` view-model adapter remains app-owned | Phase 0 formatter snapshots and existing prompt/context tests | +| `MCPWindowToolCatalogService`, context/runtime/group/names/helpers, and safe file/selection/prompt providers | Core session MCP catalog/providers/dispatch after capability-facet split | independent nine-tool app descriptor/normalization/formatter snapshot | +| apply edits, file mutation, VCS/worktree, Oracle, Context Builder, ask-user, Agent Mode, settings, lifecycle, approval, wake/power | remain in `RepoPrompt` | capability omission ledger; no Phase 0 move | +| `Sources/RepoPromptPOSIXSupport/Descriptors/POSIXDescriptorSupport.swift` | stay in internal POSIX support | moved in Phase 1; descriptor/process characterization remains frozen | +| app `MacOSBootstrapSocketServer`, accepted-FD manager, Unix transport and socket mechanics | `RepoPromptCoreMacOS/MCP/AppProxy`; app admission/approval/limits/diagnostics/routing remain app-owned | bootstrap contract and socket ownership/order tests | +| `RepoPromptCorePlatformDependencies.swift` and static process facade | delete after watcher/process/storage/transport injection reaches real owners | process inheritance/SIGPIPE/failure tests | +| headless workspace models/store, resolver/catalog/search/codemap, registry and nine local tool implementations | replace with Core implementations, migrate v1 storage, then delete | headless-v1 fixture, descriptor/call snapshots, direct-stdio smoke | +| headless CLI, configuration/state paths/root policy/file lock, JSON-RPC adapter, stdio transport/writer/output | remain in `RepoPromptHeadless` | lifecycle tests and direct-stdio smoke | +| Core/CoreMacOS/SyntaxBridge implementation targets | package-internal targets only | public library products removed in Phase 1 | + +The historical table remains below only as an audit record. Where it conflicts with this inventory or the convergence design, this section controls. + +## Concurrency lock for later implementation + +| Component | Required isolation | +| --- | --- | +| `RepoPromptCoreHost`, `RepoPromptCoreSession`, `WorkspaceSessionController`, `MCPRuntimeSessionRegistry`, `MCPServiceRegistry` | `@MainActor` | +| `WorkspaceFileContextStore`, `WorkspaceSearchService`, `MCPConnectionRuntime`, `MCPToolDispatchEngine` | actor | +| macOS FSEvents callbacks | dedicated dispatch queue bridged into async streams consumed by actors | +| app UI adapters | `@MainActor` | +| standalone stdio read/write pumps | independent tasks; serialize stdout protocol writes and send diagnostics only to stderr | + +## Historical Item 0 move inventory + +This inventory records the ownership plan captured before the bounded Item 5 split. It is retained for archaeology and does not override the regenerated Phase 0 inventory above. Its `Current path` column is historical unless a later section explicitly says a seam remains app-owned; the landed Item 5 roots and explicit deferrals below are authoritative. + +### Workspace ownership + +| Current path | Reserved owner | Disposition | Notes | +| --- | --- | --- | --- | +| `Sources/RepoPrompt/Features/Workspaces/WorkspaceModel.swift` | `RepoPromptCore` | move | Workspace serialization, compose tabs, stored selections, and preset DTOs; classify `OSLog` during Item 4 | +| `Sources/RepoPrompt/Features/Workspaces/WorkspaceRootActions.swift` | `RepoPromptCore` | move | Root reorder and normalization helpers | +| `Sources/RepoPrompt/Features/Workspaces/WorkspaceSwitchSessionRegistry.swift` | `RepoPromptCore` | move | Active-session provider protocol, snapshots, and cancellation registry | +| `Sources/RepoPrompt/Features/Workspaces/ViewModels/WorkspaceSaveDiagnostics.swift` | `RepoPromptCore` | move | Save and selection revision metadata; logging remains an Item 4 audit | +| `Sources/RepoPrompt/Features/Workspaces/WorkspaceSwitchSessionProviders.swift` | app shell/adapter | stay | Bridges workspace switching to oracle, Context Builder, and Agent Mode view models | +| `Sources/RepoPrompt/Features/Workspaces/WorkspaceSwitchingModels.swift` | `RepoPromptCore` + app shell/adapter | split | Foundation models are reusable; SwiftUI modifier and `@ObservedObject` bridge remain shell-only | +| `Sources/RepoPrompt/Features/Workspaces/ViewModels/WorkspaceManagerViewModel.swift` | `RepoPromptCore` + app shell/adapter | split | Separate repository/controller behavior from `ObservableObject`, `@Published`, `@AppStorage`, overlays, folder picker, UI view-model, and window coupling | +| `Sources/RepoPrompt/Features/Workspaces/Views/` | app shell/adapter | stay | SwiftUI workspace presentation | + +Routing-critical helpers currently nested in `WorkspaceManagerViewModel.swift` must be classified together during the split: `normalizedRepoPathsForComparison`, `repoPathsEquivalent`, `normalizedExactWorkspaceDirectorySet`, `loadableRepoPaths`, `exactWorkspaceMatches`, `supersetWorkspaceMatches`, `bindingCandidates`, `hasAnyWorkspaceMatch`, workspace path builders, file load/save helpers, `WorkspaceFileDecodeCache`, `WorkspaceDiskWriter`, compose-tab snapshot resolution, stored-selection rebasing, selection application, and save-metadata generation. + +### MCP view-model and routing ownership + +| Current path | Reserved owner | Disposition | Notes | +| --- | --- | --- | --- | +| `Sources/RepoPrompt/Infrastructure/MCP/ViewModels/MCPServerViewModel.swift` | `RepoPromptCore` + app shell/adapter | split | Extract tool/session runtime; retain observable dashboard, approval overlay, external events, and AppKit activation in the app adapter | +| `Sources/RepoPrompt/Infrastructure/MCP/ViewModels/MCPServerViewModel+CopyPresets.swift` | `RepoPromptCore` | move | Preset parsing and DTO projection | +| `Sources/RepoPrompt/Infrastructure/MCP/ViewModels/MCPServerViewModel+SelectionCore.swift` | `RepoPromptCore` | move | Selection assembly, token helpers, path projection, and code-structure assembly | +| `Sources/RepoPrompt/Infrastructure/MCP/ViewModels/MCPServerViewModel+SelectionEngine.swift` | `RepoPromptCore` | move | Selection mutation logic | +| `Sources/RepoPrompt/Infrastructure/MCP/ViewModels/MCPServerViewModel+SelectionParsing.swift` | `RepoPromptCore` | move | Argument and line-range parsing | +| `Sources/RepoPrompt/Infrastructure/MCP/ViewModels/MCPServerViewModel+SelectionReply.swift` | `RepoPromptCore` | move | Selection replies and virtual prompt evaluation | +| `Sources/RepoPrompt/Infrastructure/MCP/ViewModels/MCPServerViewModel+TabContext.swift` | `RepoPromptCore` + app shell/adapter | split | Session/tab affinity, run mappings, snapshots, and compatibility fallback; retain `WindowState` hooks in app adapters | +| `Sources/RepoPrompt/Infrastructure/MCP/ViewModels/MCPServerViewModel+TokenStats.swift` | `RepoPromptCore` | move | Token-stat DTO assembly | +| `Sources/RepoPrompt/Infrastructure/MCP/ViewModels/MCPServerViewModel+WorkspaceContext.swift` | `RepoPromptCore` | move | Workspace-context and token-breakdown assembly | +| `Sources/RepoPrompt/Infrastructure/MCP/ViewModels/MCPReadFileAutoSelectionCoordinator.swift` | `RepoPromptCore` | move | Runtime-neutral selection queue after session naming cleanup | +| `Sources/RepoPrompt/Infrastructure/MCP/ViewModels/HeadlessMode+MCP.swift` | `RepoPromptCore` | move | Neutral mode description helper | +| `Sources/RepoPrompt/Infrastructure/MCP/MCPBindingResolver.swift` | `RepoPromptCore` | move | Logical context routing priority | +| `Sources/RepoPrompt/Infrastructure/MCP/MCPToolArgsNormalizer.swift` | `RepoPromptCore` | move | Hidden selector normalization and compatibility fields | +| `Sources/RepoPrompt/Infrastructure/MCP/MCPConnectionManager.swift` | `RepoPromptCore` + `RepoPromptCoreMacOS` | split | Separate connection/routing policy and dispatch from listener lifecycle, transferred-FD ledger, socket health, and parent-PID inspection | +| `Sources/RepoPrompt/Infrastructure/MCP/ServerController.swift` | `RepoPromptCore` + `RepoPromptCoreMacOS` + app shell/adapter | split | Separate neutral coordination, macOS helper verification, and app approval/wake/power policy | +| `Sources/RepoPrompt/Infrastructure/MCP/WindowRoutingService.swift` | `RepoPromptCore` + app shell/adapter | split/rename | Future `MCPContextRoutingService`; inject session lifecycle rather than app globals | +| `Sources/RepoPrompt/Infrastructure/MCP/ServiceRegistry.swift`, `Service.swift`, `WindowScopedService.swift` | `RepoPromptCore` | move/rename | Replace static and window-scoped concepts with instance-owned session registries | + +### Window-tool providers + +All current files under `Sources/RepoPrompt/Infrastructure/MCP/WindowTools/` are inventoried. Existing `Window` naming is compatibility-era naming; later items rename core concepts to session-scoped types. + +| Current path | Reserved owner | Disposition | +| --- | --- | --- | +| `MCPFileToolProvider.swift` | `RepoPromptCore` | move | +| `MCPSelectionToolProvider.swift` | `RepoPromptCore` | move | +| `MCPPromptContextToolProvider.swift` | `RepoPromptCore` | move | +| `MCPGitToolProvider.swift` | `RepoPromptCore` | move | +| `MCPWorktreeToolProvider.swift` | `RepoPromptCore` | move | +| `MCPWorktreeToolProvider+Merge.swift` | `RepoPromptCore` | move | +| `MCPApplyEditsToolProvider.swift` | `RepoPromptCore` | move | +| `MCPContextBuilderToolProvider.swift` | `RepoPromptCore` | move | +| `MCPOracleToolProvider.swift` | `RepoPromptCore` | move | +| `MCPAskUserToolProvider.swift` | `RepoPromptCore` | move | +| `MCPAgentControlToolProvider.swift` | `RepoPromptCore` | move | +| `MCPAgentSessionControlToolProvider.swift` | `RepoPromptCore` | move | +| `MCPWindowToolCatalogService.swift` | `RepoPromptCore` | move/rename | +| `MCPWindowToolContext.swift` | `RepoPromptCore` | move/rename | +| `MCPWindowToolGroup.swift` | `RepoPromptCore` | move/rename | +| `MCPWindowToolNames.swift` | `RepoPromptCore` | move/rename | +| `MCPWindowToolRuntime.swift` | `RepoPromptCore` | move/rename | +| `MCPWindowWorkspaceToolHelpers.swift` | `RepoPromptCore` | move/rename | +| `MCPWindowToolDependencies.swift` | `RepoPromptCore` + app shell/adapter | split into capability-specific ports | + +Mutation, VCS-write, oracle, Context Builder, ask-user, and agent providers remain capability-gated. Inventorying them as reusable implementations does not expose them in the first standalone safe profile. + +### App-proxy socket and shared-contract inventory + +| Current path | Reserved owner | Disposition | Notes | +| --- | --- | --- | --- | +| `Sources/RepoPrompt/Infrastructure/MCP/BootstrapSocketServer.swift` | `RepoPromptCoreMacOS` | move | Darwin listener, bind/listen/accept, peer PID lookup, handshake I/O, and ownership transfer | +| `Sources/RepoPrompt/Infrastructure/MCP/BootstrapSocketConnectionManager.swift` | `RepoPromptCoreMacOS` | move | Accepted-FD app-proxy adapter; keep bundle metadata and app keepalive policy adapter-owned | +| `Sources/RepoPrompt/Infrastructure/MCP/UnixSocketMCPTransport.swift` | `RepoPromptCoreMacOS` | move | Unix-socket lifecycle and read/write pumps | +| `Sources/RepoPrompt/Infrastructure/MCP/AppShared/NewlineDelimitedSocketReader.swift` | `RepoPromptCoreMacOS` | audit/move | App transport loop; share only verified-equivalent framing logic | +| `Sources/RepoPromptMCP/Shared/NewlineDelimitedSocketReader.swift` | `RepoPromptMCP` | audit/stay | Proxy transport loop; currently near-duplicate but not byte-identical to app copy | +| `Sources/RepoPrompt/Infrastructure/MCP/AppShared/MCPBootstrapMessages.swift` and `Sources/RepoPromptMCP/Shared/MCPBootstrapMessages.swift` | `RepoPromptShared` | centralize in Item 1 | Preserve DTO fields, protocol version `2`, timing, and raw error codes | +| `Sources/RepoPrompt/Infrastructure/MCP/AppShared/MCPFilesystemConstants.swift` and `Sources/RepoPromptMCP/Shared/MCPFilesystemConstants.swift` | `RepoPromptShared` + local adapters | split in Item 1 | Centralize flavor-aware filesystem identity and endpoint derivation in `MCPFilesystemIdentity`; keep `getuid()`, logging, directory creation, and app event-directory policy local | +| `Sources/RepoPromptShared/MCP/MCPControlMessages.swift` | `RepoPromptShared` | stay | Already correctly single-sourced | +| `Sources/RepoPromptShared/MCP/POSIXDescriptorSupport.swift` | `RepoPromptShared` | stay | Already shared descriptor hardening | +| `Sources/RepoPromptMCP/main.swift`, `Interactive/InteractiveMCPClientSession.swift`, `Transports/BootstrapSocketMCPTransport.swift` | `RepoPromptMCP` | stay | Existing app proxy/client roles remain bundled and independently maintained | +| `Sources/RepoPrompt/Infrastructure/MCP/MCPExternalClientEvent.swift`, `MCPExternalEventsMonitor.swift` | app shell/adapter | stay | App-facing diagnostics from CLI event files | + +### Platform adapter inventory + +| Current path or family | Reserved owner | Disposition | Notes | +| --- | --- | --- | --- | +| `Sources/RepoPrompt/Infrastructure/FileSystem/FileSystemService.swift` and FSEvents lifecycle helpers | `RepoPromptCore` + `RepoPromptCoreMacOS` | split | Keep coalescing, ignore evaluation, and deltas neutral; move CoreServices lifecycle and flag mapping | +| `Sources/RepoPrompt/Infrastructure/Process/ProcessLauncher.swift` | `RepoPromptCoreMacOS` | move behind core contract | Preserve pipes, `FD_CLOEXEC`, no-SIGPIPE handling, child signal restoration, and working-directory behavior | +| `Sources/RepoPrompt/Infrastructure/Process/CLIProcessRunner.swift` | app capability adapter; promotion deferred | stay/audit | Two direct `ProcessLauncher.spawn` calls | +| `Sources/RepoPrompt/Infrastructure/AI/ACP/ACPAgentSessionController.swift` | app capability adapter; promotion deferred | stay/audit | One direct `ProcessLauncher.spawn` call | +| `Sources/RepoPrompt/Infrastructure/AI/Providers/ClaudeCode/SDK/ClaudeNativeProcessSessionController.swift` | app capability adapter; promotion deferred | stay/audit | One direct `ProcessLauncher.spawn` call | +| `Sources/RepoPrompt/Infrastructure/AI/Providers/Codex/AppServer/CodexAppServerClient.swift` | app capability adapter; promotion deferred | stay/audit | One direct `ProcessLauncher.spawn` call | +| `Sources/RepoPrompt/Infrastructure/Process/ProcessRegistry.swift` and `SpawnedProcess` consumers | app capability adapter; promotion deferred | stay/audit | Review with Item 4 process isolation | + +### Secure-store inventory + +| Current path | Reserved owner | Disposition | Notes | +| --- | --- | --- | --- | +| `Sources/RepoPrompt/Infrastructure/Security/SecureKeyValueStorageBackend.swift` | `RepoPromptCore` + `RepoPromptCoreMacOS` + app composition | split | Move neutral protocol core-facing; keep factory, bundle marker, code-signing policy, and debug/release backend selection adapter-owned | +| `Sources/RepoPrompt/Infrastructure/Security/KeychainService.swift` | `RepoPromptCore` + `RepoPromptCoreMacOS` | split | Keep neutral access mode/reason and error semantics; move `SecItem*` implementation behind macOS adapter | +| `Sources/RepoPrompt/Infrastructure/Security/EphemeralSecureKeyValueStore.swift` | `RepoPromptCore` | move after decoupling | Neutral in-memory backend after decoupling from concrete Keychain error type | +| `Sources/RepoPrompt/Infrastructure/Security/SecureKeyService.swift` | `RepoPromptCore` | move | Neutral secure-string facade | +| `Sources/RepoPrompt/Infrastructure/Security/KeyManager.swift` | `RepoPromptCore` | move | Neutral cached provider-key facade | +| `Sources/RepoPrompt/Infrastructure/Security/RuntimeCodeSigningDetector.swift` | `RepoPromptCoreMacOS` | move | Apple Security code-signing inspection | +| `Sources/RepoPrompt/Infrastructure/Security/SecurityObfuscation.swift` | deferred | audit | Not part of the secure-store backend seam | + +Existing app consumers remain adapter-owned until later review: `App/WindowStateComposition.swift`, `Features/Settings/ViewModels/APISettingsViewModel.swift`, `Features/AgentMode/Runtime/ProviderBindings/AgentPermissionSecureStore.swift`, `Infrastructure/AI/Providers/ClaudeCode/ClaudeCodeCompatibleBackendStore.swift`, and `ClaudeCodeLaunchEnvironmentResolver.swift`. + +### Bridging-header dependency inventory + +Item 4 narrowed `Sources/RepoPrompt/Support/RepoPrompt-Bridging-Header.h` to a syntax-only transitional residual without editing `Package.swift`. Accidental target-wide declarations are gone: Apple APIs are source-local or adapter-owned, RepoPrompt C consumers import `RepoPromptC` directly, PCRE wrappers import `CSwiftPCRE2` directly, and `PathSearchIndex` consumes the published `RepoPromptC` ABI instead of local `@_silgen_name` shadows. + +| Current Swift consumer | Item 4 disposition | Item 5 reserved owner | +| --- | --- | --- | +| `Sources/RepoPrompt/App/ApplicationSecurity.swift` | imports `Darwin` locally and owns the `PT_DENY_ATTACH` fallback value | app shell | +| `Sources/RepoPrompt/Infrastructure/FileSystem/FileSystemService+DirectoryEnumeration.swift` | removed unused `sysctlbyname("hw.ncpu", ...)` helper | n/a | +| `Sources/RepoPrompt/Infrastructure/MCP/MCPConnectionManager.swift` | consumes injected `ProcessAncestryInspecting`; `MacOSProcessAncestryInspector` owns `sysctl` / `kinfo_proc` | split core/macOS adapter | +| `Sources/RepoPrompt/Infrastructure/SyntaxParsing/SyntaxManager.swift` | still receives `tree_sitter_*` declarations from the narrowed header | `RepoPromptCore` via `RepoPromptSyntaxCBridge` | +| `Sources/RepoPrompt/Features/Search/SearchMatch.swift`, `SearchPathFiltering.swift`, `Infrastructure/FileSystem/GitignoreCompiler.swift`, `Infrastructure/Utilities/StringExtensions.swift`, `Infrastructure/WorkspaceContext/Search/RepoSearchBatchScorer.swift`, `Infrastructure/WorkspaceContext/Search/PathSearchIndex.swift` | import `RepoPromptC` directly; string allocation helpers use narrow `repo_strdup` / `repo_free` wrappers | `RepoPromptCore` via direct `RepoPromptC` dependency | +| `Sources/RepoPrompt/ThirdParty/SwiftPCRE2/PCRE2Error.swift`, `PCRE2JIT.swift`, `PCRE2Options.swift`, `PCRE2Regex.swift` | import `CSwiftPCRE2` directly | `RepoPromptCore` via direct `CSwiftPCRE2` dependency | + +Syntax declaration strategy: keep the remaining grammar declarations in the current target-wide header through Item 4 so scanner/linker behavior is unchanged. Item 5 creates `RepoPromptSyntaxCBridge`, wires the grammar products to that narrow target, moves the declarations there, and removes the app bridging-header setting atomically with the SwiftPM split. `TreeSitterScannerSupport` remains unchanged. + +### Test ownership inventory + +| Current path | Reserved disposition | +| --- | --- | +| `Tests/RepoPromptTests/MCP/` | Split app-adapter tests from future reusable core MCP tests when physical targets land | +| `Tests/RepoPromptTests/WorkspaceContext/` | Move neutral workspace-context coverage with the core library test owner | +| `Tests/RepoPromptTests/Services/FileSystem/` | Split neutral filesystem behavior from macOS watcher-adapter coverage | +| `Tests/RepoPromptTests/Security/` | Split neutral secure-store facade coverage from Keychain and runtime-policy adapter coverage | +| `Scripts/test_release_tooling.py` and embedded-helper validators | Keep app-proxy packaging characterization with release tooling | + +## Item 4 staged adapter isolation + +Item 4 stages neutral contracts and macOS-owned implementations inside the existing monolithic `RepoPrompt` target. No future Item 5 target roots or `Package.swift` changes land yet. + +| Boundary | Neutral staged contract | macOS-owned staged implementation | Preserved behavior | +| --- | --- | --- | --- | +| Filesystem watching | `Infrastructure/Core/Platform/FileSystemWatching.swift` | `Infrastructure/FileSystem/MacOS/MacOSFSEventsWatcher.swift` | Dedicated callback queue deep-copies native payloads and maps FSEvents flags before the existing mailbox, overflow recovery, ignore evaluation, scan coalescing, and delta generation run. | +| Process launching | `Infrastructure/Core/Platform/ProcessLaunching.swift` | `Infrastructure/Process/MacOS/POSIXProcessLauncher.swift` | Existing POSIX pipe/spawn semantics remain behind a compatibility facade while the injected adapter is available to staged host composition. | +| Secure storage | `Infrastructure/Core/Platform/SecureKeyValueStorageBackend.swift` | `Infrastructure/Security/MacOS/KeychainService.swift`, `AppSecureKeyValueStorageFactory.swift`, `RuntimeCodeSigningDetector.swift` | Neutral access modes and errors are separated from embedded-app Keychain/signing selection policy. | +| App-proxy socket boundary | `Infrastructure/MCP/Platform/MCPAppProxyTransportBoundary.swift` | `Infrastructure/MCP/AppProxy/` | Accepted sockets produce a normalized peer identity with trusted-socket versus handshake-fallback provenance. Only `LOCAL_PEERPID` is authorization input; the range-checked handshake PID is diagnostic metadata and admission fails closed when trusted socket credentials are unavailable. | +| Bundled-helper verification | `Infrastructure/MCP/Platform/BundledHelperPeerVerifying.swift` | `Infrastructure/MCP/PeerVerification/MacOSBundledHelperPeerVerifier.swift` | Bundle helper URL and peer PID are explicit verifier inputs; canonical symlink-aware executable matching is preserved. | +| Process ancestry | `Infrastructure/MCP/Platform/ProcessAncestryInspecting.swift` | `Infrastructure/MCP/PeerVerification/MacOSProcessAncestryInspector.swift` | Admission policy retains its ancestor walk while `sysctl` / `kinfo_proc` lookup moves to the adapter. | +| Syntax declarations | syntax-only residual removed during Item 5 | `Sources/RepoPromptSyntaxCBridge` | Existing grammar entry points and scanner fallback remain unchanged behind the narrow declaration shim. | + +## Closed Item 4 portability ledger + +| Topic | Classification | Exact disposition | +| --- | --- | --- | +| Combine | mixed: shell-only plus explicitly deferred runtime seam | Keep SwiftUI `ObservableObject` / `@Published` publications shell-owned. The filesystem publisher and workspace ingress subscription remain transitional inside the monolithic target; Item 5 replaces movable runtime multi-observer channels with bounded per-subscriber async streams before placing them in `RepoPromptCore`. | +| CryptoKit | core-safe hashing, with standalone toolchain verification deferred | Current movable uses are deterministic SHA-256 hashing rather than secret persistence or UI policy. Keep them in the reusable inventory; if standalone Swift tooling rejects `CryptoKit`, Item 5 introduces a narrow digest helper backed by the package toolchain rather than an Apple adapter leak. | +| OSLog / `os` | shell-only diagnostics or injected logging facade | Keep signposts and app diagnostics outside reusable runtime ownership. Movable logging sites must consume a neutral logging port during Item 5; `RepoPromptCore` must not import `OSLog` or `os`. | +| `FoundationNetworking` | explicitly deferred capability seam | The first safe headless profile does not promote AI/network capability ownership. When networking is promoted, reusable Foundation HTTP code gains conditional `FoundationNetworking` imports where standalone Swift toolchains require them; no macOS adapter is implied. | +| Application Support defaults | adapter-owned embedded-app policy | Reusable stores receive state-directory URLs. Existing embedded-app Application Support defaults stay shell/adapter-owned; the standalone host later receives its separate `Headless/` profile URL explicitly. | +| `UserDefaults` | shell-only preferences or injected configuration | Reusable runtime code must not read `.standard`. Existing preference reads remain transitional in the monolithic target and move to shell-owned configuration snapshots or standalone JSON profile persistence as the Item 5 boundary is enforced. | + +## Phase 1 dependency boundary landed + +The enforceable package boundary now includes: + +- Package-internal `RepoPromptCore`, `RepoPromptCoreMacOS`, `RepoPromptPOSIXSupport`, and `RepoPromptSyntaxCBridge` targets; SwiftPM exposes only executable products. +- No `RepoPromptCore` dependency on `RepoPromptShared`, POSIX support, or native C/syntax targets until a real Core source imports them. Current app importers retain their direct native dependencies. +- A declaration-only Tree-sitter C shim with all fourteen entry points used by syntax parsing. Grammar products and `TreeSitterScannerSupport` now link through that shim; the app target-wide bridging header and unsafe flags are removed. +- Platform-neutral filesystem watching, process launching, secure-storage, workspace access/root policy, codec/repository/migration, session/capability, and opaque MCP admission contracts under `Sources/RepoPromptCore`. +- macOS FSEvents, POSIX launcher/descriptor-write support, Keychain, runtime signing, bundled-helper verification, and process-ancestry adapters under `Sources/RepoPromptCoreMacOS`. +- Enforced core-boundary guardrails that fail on forbidden platform/UI imports, embedded-app policy references, missing roots, or accidental standalone packaging references. + +## Explicitly deferred seams after Phase 2 Slice 3 + +The canonical app workspace/file-context/prompt-projection runtime move is complete, but product convergence is not. These owners remain intentionally app-local until Phase 3 or later: + +- App preset/conversation/VCS/clipboard policy, live view-model conversion, token-fact materialization, and prompt projection adaptation remain app-owned around the canonical Core services. +- MCP safe-tool providers, catalog, descriptor vocabulary, argument normalization, DTOs, text formatting, capability composition, and dispatch remain app-owned; Slice 2 does not establish headless parity or move product protocol ownership. +- App-proxy `MacOSBootstrapSocketServer`, accepted-FD connection management, Unix transport, app filesystem constants, admission/approval, routing, and lifecycle policy remain app-owned. Existing `repoprompt-mcp` behavior is unchanged. +- App mutation authorization, diagnostics/telemetry, readiness, Combine publication, UI/view-model conversion, Application Support/UserDefaults policy, and visible-app lifecycle remain adapters in `Sources/RepoPrompt`. +- The static `ProcessLauncher` facade remains in `RepoPromptCoreMacOS` while deferred app capabilities call it directly; promote call-site injection only with those capability owners. +- The independent headless safe profile remains locked to the reviewed hardened source/test manifest under `Sources/RepoPromptHeadless` and `Tests/RepoPromptHeadlessTests`; future adoption must be a separately characterized Phase 3 change and must not route through the app proxy or app bundle. + +## Enforced boundary guardrail + +`Scripts/core_boundary_guardrails.sh` requires Core, CoreMacOS, POSIXSupport, Shared, and SyntaxBridge roots; permits `CryptoKit` only in `Sources/RepoPromptShared/MCP/JSONRPCBridgeLedger.swift`, rejects every other non-Foundation Shared import plus all Darwin/POSIX ownership there, rejects forbidden platform/UI/app policy imports, all `os`/`OSLog` ownership, and Apple signpost tokens in Core, and continues to reject app-packaging references to standalone command names. `Scripts/source_layout_guardrails.sh` and `Scripts/test_shared_runtime_phase2_boundaries.py` lock canonical Core owner filenames, reject restored app-side readable-file/session-binding implementations, and require the explicit observation and prompt-projection adapters. The Phase 1 boundary retains the immutable Phase 0 artifacts at `48a335e`; the active Phase 2 boundary separately verifies the product/dependency graph, sole app factory, importer-backed native edges, and every path and byte in the reviewed hardened headless source/test manifest. Findings fail `make guardrails`. + +The two baseline contracts are intentionally independent and have no path-level exemptions. `python3 Scripts/shared_runtime_headless_baseline.py --check` verifies the reviewed headless manifest. After a future complete-tree headless change is explicitly reviewed, `python3 Scripts/shared_runtime_headless_baseline.py --write` reproducibly advances only that manifest before commit; it does not rewrite or recharacterize Phase 0 artifacts. + +`Scripts/source_layout_guardrails.sh` remains responsible for single-sourcing `MCPControlMessages.swift`, `MCPFilesystemIdentity.swift`, and `MCPBootstrapMessages.swift` under `RepoPromptShared`, plus the narrow `TreeSitterScannerSupport` compatibility target. It requires `Sources/RepoPromptHeadless`/`RepoPromptHeadless`/`repoprompt-headless` and rejects app UI, app bundle policy, or app-proxy socket references from the standalone source root. + +## Item 0 characterization coverage + +| Compatibility surface | Characterization owner | +| --- | --- | +| Bootstrap JSON, versions, exact v7 debug/release socket paths, and shared app/CLI wire contract | `Tests/RepoPromptTests/MCP/Control/MCPBootstrapContractCharacterizationTests.swift` | +| Logical-context priority and tools/call source-order markers | `TabContextRoutingTests.swift`, `MCPResolvedToolDispatchSourceGuardTests.swift` | +| Bundled CLI path verification and MCP allow-list sanitization | `ServerControllerAdmissionTests.swift` | +| Embedded app helper path and compatibility symlinks | `Scripts/validate_embedded_mcp_helper_layout.sh`, `Scripts/test_release_tooling.py` | +| Enforced boundary wiring | `make guardrails`, `make dev-guardrails`, and `./conductor guardrails` | + +## Remaining deferred work + +Phase 3 may converge catalog/provider/DTO/formatter/dispatch behavior and explicitly adopt Core from headless. Phase 3+ must also retire the reviewed-baseline-locked parallel headless v1 implementations through separately characterized migrations. Neither step may change app-proxy routing or standalone security/profile boundaries without new characterization. Readable-root expansion (including any future authorization of `~/.codex/prompts`) requires an explicit product/security policy decision; the install location alone is not read authorization. diff --git a/docs/architecture/source-layout.md b/docs/architecture/source-layout.md index 32d247aed..4397fa2be 100644 --- a/docs/architecture/source-layout.md +++ b/docs/architecture/source-layout.md @@ -1,13 +1,12 @@ # Source Layout Ownership Map -Current as of 2026-05-30 after the source-layout refactor, Context Builder discovery cleanup, provider extraction, post-native-tree cleanup guardrail pass, provider-neutral workflow prompt catalog cleanup, and upstream Tree-sitter grammar migration. This document is contributor-facing: use it to decide where new source, tests, fixtures, diagnostics, shared protocol code, and guardrail checks belong. +Current as of 2026-06-08 after the Phase 2 Slice 3 ownership checkpoint. `RepoPromptCore` owns the canonical app-v1 workspace/session authority plus the neutral filesystem, catalog, path, search, selection, slices, token-accounting, codemap, syntax, factual prompt rendering/assembly, and workspace projection closure. `RepoPromptCoreMacOS` owns macOS directory listing and FSEvents watching. The app remains the only production constructor/consumer through app-owned composition, mutation, diagnostics, readiness, observation, UI, prompt, and policy adapters. The complete headless source/test trees are independently locked by the reviewed hardened manifest and do not construct this runtime. ## Current source tree shape ```text Sources/ RepoPrompt/ - Support/ # Obj-C bridging header / bridging-header-sensitive support path used by Package.swift App/ # lifecycle, launch/configuration, commands, composition wiring, app notifications, root app views/view models Notifications/ Sparkle/ @@ -17,7 +16,7 @@ Sources/ AgentMode/ # Agent Mode UI, models, view models, onboarding, recommendations, and shared agent runtime ownership Runtime/Providers/ # provider/runtime enum and provider factory shared by Context Builder, Agent Mode, MCP, and recommendations Chat/ # chat/oracle models, services, diff state, view models, and views - CodeMap/ # code-map extraction feature code and FileAPI model + CodeMap/ # app codemap extraction/view-model adapters; neutral generation/models live in RepoPromptCore ContextBuilder/ # Context Builder product UI/runtime, view models, settings, prompts, budget defaults, and response-type mapping Diagnostics/ # app-integrated benchmark/debug/stress/diagnostic surfaces Prompt/ # prompt UI, copy/prompt models, packaging, accounting, compact selected-files components, and view models @@ -30,29 +29,53 @@ Sources/ Prompts/Workflows/ # provider-neutral RepoPrompt workflow prompt catalog and renderers Concurrency/ # cross-cutting async primitives Diffing/ # diff parsing/application/generation substrate - FileSystem/ # filesystem seams/services + FileSystem/ # app-only filesystem policy/adapters; neutral services live in RepoPromptCore and macOS listing/watching in RepoPromptCoreMacOS MCP/ # app-side MCP infrastructure, app-local MCP helpers, and MCP view model adapters Networking/ # HTTP and decoding substrate Persistence/ # shared persistence helpers such as preset file storage Process/ # process/CLI launch substrate Regex/ # reusable regex adapters/toolkit Security/ # keychain, signing, and secure storage - SyntaxParsing/ # syntax parsing and tree-sitter query infrastructure + SyntaxParsing/ # app-only syntax consumers/adapters; neutral parser/query ownership lives in RepoPromptCore UI/ # reusable UI components, text/markdown/tooltip/mention substrate, UI services Utilities/ # narrow generic utilities/extensions VCS/ # git/VCS substrate - WorkspaceContext/ # context store, indexing, path lookup, slices, search, token accounting + WorkspaceContext/ # app observation, diagnostics, mutation, readiness, and view-model adapters over RepoPromptCore ThirdParty/ # vendored SwiftPCRE2 wrapper + RepoPromptCore/ # canonical workspace/session authority plus neutral filesystem, catalog, path, search, selection, slices, token, codemap, and syntax runtime + RepoPromptCoreMacOS/ # macOS directory listing/FSEvents plus POSIX process, Keychain/signing, and peer-verification adapters + RepoPromptPOSIXSupport/ # package-internal shared descriptor/socket helpers for MCP and CoreMacOS + RepoPromptSyntaxCBridge/ # narrow Tree-sitter declaration/linkage shim; owns grammar/scanner dependencies RepoPromptShared/ - MCP/ # shared app/CLI MCP control protocol definitions - RepoPromptMCP/ # MCP CLI implementation + MCP/ # platform-neutral app/CLI MCP wire contracts; one documented CryptoKit hashing exception + RepoPromptHeadless/ # independent direct-stdio host, v1 profile/configuration, workspace runtime, and safe tool implementations + RepoPromptMCP/ # app-proxy MCP CLI implementation RepoPromptC/ # C support target CSwiftPCRE2/ # C PCRE2 target TreeSitterScannerSupport/ # narrow exact-snapshot JavaScript/Python scanner ABI fallback Tests/ - RepoPromptTests/ # XCTest tests, support, and fixtures + RepoPromptTests/ # app/runtime XCTest tests, support, and fixtures + RepoPromptHeadlessTests/ # standalone configuration, runtime, JSON-RPC, stdio-adapter, and safe-profile tests + SharedRuntimeConvergenceFixtures/ # cross-target Phase 0 frozen fixtures and manifests ``` +## Physical headless-core roots + +[`headless-core.md`](headless-core.md) locks the library-first split. Phase 2 Slice 3 now enforces the reusable runtime, prompt/projection, and adapter substrate: + +```text +Sources/ + RepoPromptCore/ # canonical workspace/session and neutral file/context/search/selection/codemap/syntax runtime + RepoPromptCoreMacOS/ # Apple/Darwin directory listing, watcher, process, security, and peer adapters + RepoPromptPOSIXSupport/ # shared POSIX descriptor/socket implementation support + RepoPromptSyntaxCBridge/ # narrow Tree-sitter declaration shim + RepoPromptHeadless/ # landed independent direct-stdio runtime; not yet a shared-Core consumer +``` + +SwiftPM advertises only the `RepoPrompt`, `repoprompt-mcp`, and `repoprompt-headless` executable products. `RepoPromptCore`, `RepoPromptCoreMacOS`, `RepoPromptPOSIXSupport`, and `RepoPromptSyntaxCBridge` are package-internal targets. `RepoPromptHeadless` still has a separate v1 workspace/tool stack. Guardrails enforce Core ownership of the current runtime/prompt/projection closure, app-only construction, immutable Phase 0 artifacts, the complete reviewed hardened headless manifest, importer-backed native dependencies, and executable-only products. + +Phase 2 Slice 2 moves the complete neutral filesystem/catalog/path/search/selection/slices/token/codemap/syntax closure to Core and deletes the temporary Slice 1 selection forwarder. `RepoPromptEmbeddedWorkspaceRuntimeFactory` is the sole production factory. The Slice 3 checkpoint adds neutral factual prompt rendering/assembly plus workspace selection, token, code-structure, and context projections in Core. App adapters retain Combine publication, UI/view-model conversion, app mutation policy, diagnostics and readiness integration, storage-root discovery, cache-root policy, artifact classification, display-path and codemap mapping, live token-fact materialization, Git fallback, prompt/chat/clipboard policy, and Context Builder/MCP envelopes. MCP provider/catalog/DTO/formatter/dispatch ownership, standalone-headless adoption, and canonical-v2 persistence remain deferred. + The legacy top-level layer buckets under `Sources/RepoPrompt` have been pruned and must not be recreated: - `Models` @@ -77,17 +100,22 @@ The old IDE-era Prompt selected-files panel is also removed. Do not add back `Pr - New product-flow code goes under `Sources/RepoPrompt/Features/<FeatureName>`. - New app lifecycle, launch/configuration, command, root view/view-model, notification-name, and composition-root wiring goes under `Sources/RepoPrompt/App`. -- Keep bridging-header-sensitive support under `Sources/RepoPrompt/Support` unless `Package.swift` is updated in the same change. +- Keep Tree-sitter C declarations in the narrow `Sources/RepoPromptSyntaxCBridge` shim. Do not restore target-wide app bridging-header flags. +- Put canonical neutral workspace values, codecs, repository/persistence behavior, session authority, filesystem/catalog/path/search/selection/slices/token/codemap/syntax behavior, factual prompt rendering from already-classified neutral values, reusable platform contracts, and workspace policy helpers in `Sources/RepoPromptCore`. Keep app storage-root discovery, Combine observation, diagnostics/tracing adapters, mutation policy, readiness integration, UI behavior, file/workspace/codemap projection, prompt/chat/clipboard policy, Git artifact fallback, and MCP product ownership app-owned. +- Put Apple/Darwin adapter implementations in `Sources/RepoPromptCoreMacOS`; core must never import that module. +- Put descriptor/socket helpers shared by the app proxy, proxy CLI, and CoreMacOS in `Sources/RepoPromptPOSIXSupport`; never place them in `RepoPromptShared` or expose them from Core contracts. - New cross-cutting service/platform code goes under `Sources/RepoPrompt/Infrastructure/<Area>`. - Provider-neutral workflow prompt catalog metadata and renderers go under `Sources/RepoPrompt/Infrastructure/AI/Prompts/Workflows/`; do not add new workflow prompts under provider-specific command names or bundled `AppResources/Services/AI/Prompts` mirrors. - New reusable SwiftUI components, text/markdown helpers, and UI services should prefer a narrow feature owner first; otherwise use `Sources/RepoPrompt/Infrastructure/UI/<Area>`. - New generic extensions/helpers should prefer a narrow feature or infrastructure owner first; otherwise use `Sources/RepoPrompt/Infrastructure/Utilities`. - New app-visible diagnostic surfaces go under `Sources/RepoPrompt/Features/Diagnostics` and must have a documented purpose and entry point. - New app/CLI protocol definitions shared by both executables go under `Sources/RepoPromptShared`. -- MCP filesystem/product/build-flavor identity and external-client event wire DTOs are single-sourced under `Sources/RepoPromptShared/MCP`; app/helper targets may keep only local compile-flavor selection and app-only presentation behavior. +- MCP filesystem/product/build-flavor identity and external-client event wire DTOs are single-sourced under `Sources/RepoPromptShared/MCP`; app/helper targets keep local compile-flavor selection and resolve process-local platform values such as `getuid()` before calling the shared API. - New app-local MCP/socket/routing helpers go under `Sources/RepoPrompt/Infrastructure/MCP`, not `Sources/RepoPrompt/Shared`. -- New CLI-only implementation code goes under `Sources/RepoPromptMCP`. -- New test doubles, fixtures, parser inputs, sample projects, benchmark-only fixture data, and XCTest-only helpers go under `Tests/RepoPromptTests`, not the app target. +- New app-proxy CLI-only implementation code goes under `Sources/RepoPromptMCP`. +- New standalone direct-stdio/profile adapter code goes under `Sources/RepoPromptHeadless`; do not add a second implementation of canonical workspace/search/codemap/selection/prompt behavior while convergence is in progress. +- New test doubles, parser inputs, sample projects, benchmark-only fixture data, and XCTest-only helpers go under the matching test target. Cross-target convergence fixtures belong under `Tests/SharedRuntimeConvergenceFixtures`, never under production sources. +- Intentionally promoted durable characterization records belong under `docs/characterization`. This directory is not a general home for agent working notes. Current records are the frozen Phase 0 baseline, `shared-runtime-phase1-2026-06-05.md`, `shared-runtime-phase2-slice1-2026-06-05.md`, `shared-runtime-phase2-slice2-2026-06-05.md`, and the narrow Slice 3 factual-rendering checkpoint record. - Do not create directories named `Tests`, `TestSupport`, or `Fixtures` under `Sources/RepoPrompt`. - Do not put parser fixtures or sample parser inputs under `Sources/RepoPrompt/Infrastructure/SyntaxParsing`; keep only production parser/query code there. - Keep `App/WindowState.swift` in `App` until there is a separate composition-root refactor; physical moves must preserve initialization order. @@ -96,6 +124,10 @@ The old IDE-era Prompt selected-files panel is also removed. Do not add back `Pr Exceptions must be explicit, narrow, and documented here before they become precedent. +### RepoPromptShared CryptoKit exception + +- `Sources/RepoPromptShared/MCP/JSONRPCBridgeLedger.swift` may import `CryptoKit` solely for deterministic SHA-256 frame correlation. No other `RepoPromptShared` source may import `CryptoKit`, and Darwin/POSIX imports remain forbidden throughout the target. + ### App-visible diagnostics retained in the app target These files are intentionally compiled as app-integrated diagnostics and live under `Sources/RepoPrompt/Features/Diagnostics`: @@ -112,11 +144,11 @@ These files are intentionally compiled as app-integrated diagnostics and live un - `App/AppLaunchConfiguration.swift` remains in `App` because it owns process arguments/environment interpretation for launch behavior. It still routes DEBUG-only Agent chat stress settings, but harness-specific configuration lives under `Features/Diagnostics/AgentMode/Stress`. - `App/WindowState.swift` remains the composition root and continues to instantiate/pause the DEBUG-only `AgentChatStressHarness`. This is wiring only; harness implementation lives under Diagnostics. -- `Infrastructure/Security/EphemeralSecureKeyValueStore.swift` remains with security storage code, not Diagnostics, because it is a required debug-app secure-storage backend rather than a fixture or visible diagnostic harness. It is `#if DEBUG`, in-memory only, and preserves existing debug behavior for ad-hoc/ephemeral secure storage. +- `Sources/RepoPromptCore/Security/EphemeralSecureKeyValueStore.swift` remains with reusable security storage code, not Diagnostics, because it is a required debug-app secure-storage backend rather than a fixture or visible diagnostic harness. It is `#if DEBUG`, in-memory only, and preserves existing debug behavior for ad-hoc/ephemeral secure storage. ### Tree-sitter scanner linker compatibility target -- `Sources/TreeSitterScannerSupport` is an internal C linker compatibility target, not a restored local grammar target. It contains byte-for-byte exact-snapshot copies of the upstream JavaScript and Python `scanner.c` implementations plus their required `tree_sitter` helper headers. It does not contain parser copies, grammar definitions, queries, or CE-authored scanner code. +- `Sources/TreeSitterScannerSupport` is an internal C linker compatibility target consumed narrowly through `RepoPromptSyntaxCBridge`, not a restored local grammar target. It contains byte-for-byte exact-snapshot copies of the upstream JavaScript and Python `scanner.c` implementations plus their required `tree_sitter` helper headers. It does not contain parser copies, grammar definitions, queries, or CE-authored scanner code. - Clean coordinated SwiftPM root graphs compile the exact-pinned upstream JavaScript and Python parser objects but omit their scanner objects, leaving unresolved external-scanner ABI symbols. `TreeSitterScannerSupport` supplies only those missing symbols while CE continues linking the upstream package products. - The tracked checksum manifest at [`ThirdPartyLicenses/tree-sitter/scanner-support.sha256`](../../ThirdPartyLicenses/tree-sitter/scanner-support.sha256) protects the copied snapshots from drift. Do not expand this target, restore the seven retired local grammar directories, or replace the target with transient `.build/checkouts` mutation. Remove the target, guardrails, checksums, and this exception together only after validated upstream revisions or SwiftPM behavior compile the scanners directly from the dependency products in a clean graph. @@ -124,21 +156,26 @@ No top-level `Sources/RepoPrompt/Notifications` exception remains; app-wide noti ## Guardrails -Run the source-layout guardrails before or after source-layout-sensitive changes: +Run the repository guardrails before or after source-layout-sensitive changes: ```bash make guardrails -# or -./Scripts/source_layout_guardrails.sh +# coordinated entrypoint: +make dev-guardrails ``` -The guardrail script verifies: +For the source-layout check alone, run `./Scripts/source_layout_guardrails.sh`. For the enforced core-boundary scan alone, run `bash ./Scripts/core_boundary_guardrails.sh`. The active `python3 Scripts/test_shared_runtime_phase2_boundaries.py` check enforces current workspace/runtime/prompt/projection ownership, sole app construction, importer-backed dependencies, immutable Phase 0 artifacts, and the complete reviewed hardened headless manifest; `Scripts/test_shared_runtime_phase2_slice1_boundaries.py` remains a historical checkpoint. The focused manifest behavior is covered by `python3 Scripts/test_shared_runtime_headless_baseline.py`. The independently reviewable headless source/test manifest is checked or reproducibly regenerated with `python3 Scripts/shared_runtime_headless_baseline.py --check|--write`; it does not alter the Phase 0 baseline. + +The source-layout guardrail verifies: - old top-level layer buckets are absent or contain no files; - no `Tests`, `TestSupport`, or `Fixtures` directories exist under `Sources/RepoPrompt`; -- `MCPControlMessages.swift` and `MCPFilesystemIdentity.swift` exist only under `Sources/RepoPromptShared/MCP`, and the `MCPExternalClientEvent` wire DTO is declared only there; +- `MCPControlMessages.swift`, `MCPFilesystemIdentity.swift`, and `MCPBootstrapMessages.swift` exist only under `Sources/RepoPromptShared/MCP`, and the `MCPExternalClientEvent` wire DTO is declared only there; - parser fixtures/sample inputs do not live under app syntax parsing source; -- the narrow `TreeSitterScannerSupport` compatibility target has exactly its approved JavaScript/Python scanner snapshots and helper headers, matches curated checksums, remains wired in `Package.swift`, preserves the seven migrated grammar pins/products in `Package.swift` and `Package.resolved`, and keeps the seven retired local grammar directories absent; +- tracked contributor-facing documentation remains within the explicit file allowlist, including individually promoted durable characterization records; +- each moved contract/runtime/adapter file is single-sourced under its narrow `RepoPromptCore`, `RepoPromptCoreMacOS`, or `RepoPromptPOSIXSupport` owner; +- the narrow `RepoPromptSyntaxCBridge` target contains exactly its declaration header and anchor C file, exposes exactly the curated fourteen Tree-sitter declarations, owns the exact grammar/scanner linkage set, and replaces the retired app-wide bridging header; +- the narrow `TreeSitterScannerSupport` compatibility target has exactly its approved JavaScript/Python scanner snapshots and helper headers, matches curated checksums, remains wired only through `RepoPromptSyntaxCBridge`, preserves the pinned grammar products in `Package.swift` and `Package.resolved`, keeps grammar products off the app target, and keeps the retired local grammar directories absent; - Agent/MCP runtime code does not depend on `WorkspaceFilesViewModel`, `FileViewModel`, or `FolderViewModel`; - removed native-tree/search artifact paths are not tracked again; - removed native-tree/search/eager-loading symbols such as `AgentFileTreeBottomPanelView`, `FileTreeViewWrapper`, `FileTreeViewController`, `NativeFileTree`, `SearchFileTreeViewModel`, `RootDescendantMaterialization`, `legacyMaterializedRootKeys`, `legacyMaterializeDescendantsRecursively`, and `legacyEager` are not referenced from app source; @@ -146,9 +183,22 @@ The guardrail script verifies: - `App/WindowState.swift` does not reintroduce scoped `searchViewModel` wiring; - `WorkspaceFilesViewModel.swift` does not reintroduce the removed `loadContentsRecursively` eager-loading seam. +The enforced core-boundary guardrail rejects: + +- missing `Sources/RepoPromptCore`, `Sources/RepoPromptCoreMacOS`, `Sources/RepoPromptPOSIXSupport`, `Sources/RepoPromptShared`, or `Sources/RepoPromptSyntaxCBridge` roots; +- forbidden Apple UI/platform imports under `Sources/RepoPromptCore`; +- imports other than Foundation under `Sources/RepoPromptShared`, except the documented `CryptoKit` import in `MCP/JSONRPCBridgeLedger.swift`, plus all Darwin/POSIX imports and descriptor/socket ownership; +- Darwin/POSIX-backed types, raw accepted descriptors, or POSIX support imports in Core contracts; +- app-owned runtime and embedded-app policy references under `Sources/RepoPromptCore`; +- missing Slice 2 Core/CoreMacOS owners, retired app runtime paths, obsolete selection forwarding, multiple production runtime factories, speculative native dependencies, or headless runtime construction; +- premature MCP catalog/provider/DTO/formatter/dispatch ownership in Core; +- any accidental app-packaging reference to standalone `repoprompt-headless` / `rpce-headless` command names. + +Shared MCP single-sourcing, syntax-shim ownership, and scanner compatibility remain enforced by the source-layout guardrail. + ## Historical resolved items -- `MCPControlMessages.swift`, `MCPFilesystemIdentity.swift`, and the `MCPExternalClientEvent` wire DTO now have one source of truth in `Sources/RepoPromptShared/MCP`; the app and CLI targets depend on `RepoPromptShared`. +- `MCPControlMessages.swift`, `MCPFilesystemIdentity.swift`, `MCPBootstrapMessages.swift`, and the `MCPExternalClientEvent` wire DTO now have one source of truth in `Sources/RepoPromptShared/MCP`; the app and CLI targets depend on `RepoPromptShared`. - The old production dependency on a test-named filesystem seam was renamed to `FileSystemProviding`; test doubles/support live under tests. - The dead Dart parser fixture under app source was removed rather than retained as production code. - Workspace, Agent Mode, MCP infrastructure, workspace context/files, Prompt, Context Builder, Chat, Search, Settings, Code Map, and syntax parsing were moved toward the hybrid feature/infrastructure layout. @@ -163,6 +213,7 @@ The guardrail script verifies: Run the smallest focused validation that covers your change, then broaden as needed: ```bash +make dev-guardrails make dev-swift-build PRODUCT=RepoPrompt make dev-swift-build PRODUCT=repoprompt-mcp make dev-test FILTER=CodexIntegrationConfigurationTests diff --git a/docs/characterization/shared-runtime-phase0-2026-06-05.md b/docs/characterization/shared-runtime-phase0-2026-06-05.md new file mode 100644 index 000000000..9f94ca33a --- /dev/null +++ b/docs/characterization/shared-runtime-phase0-2026-06-05.md @@ -0,0 +1,113 @@ +# Shared Runtime Phase 0 Characterization + +**Branch:** `core_split` +**Freeze HEAD:** `487cd71d892dbc3104689cc42fdb39f6c038e8fb` +**Scope:** characterization only; no production ownership moves or Phase 1 target changes + +## Frozen sibling baselines + +| Lane | Commit | +| --- | --- | +| Packaging | `2b350916d52809dd036331a746d888132019ce75` | +| App/MCP | `042a500b03b39d04237ec5544811696cf6b2f2f9` | +| Headless | `487cd71d892dbc3104689cc42fdb39f6c038e8fb` | + +The commits are consecutive in branch history in the order packaging → app/MCP → headless. + +## Characterization artifacts + +- `Tests/SharedRuntimeConvergenceFixtures/Phase0/manifest.json`: baselines, exact nine-tool overlap, allowed product differences, blockers, and invariant gate owners. +- `App/app-characterization.json`: app descriptors in current published order plus normalized argument and representative structured/text formatter-boundary snapshots. Real provider behavior remains pinned by the focused existing tool suites listed below. +- `Headless/headless-characterization.json`: direct JSON-RPC initialize, descriptors, argument-coercion observations, and tool-call response snapshots. +- `differential-ledger.json`: per-tool classification of descriptor, argument, and structured/text response differences; no current safe-tool divergence is mislabeled as an allowed product difference. +- `App/WorkspaceV1/**`: current app workspace index/directory/document fixture. +- `Headless/ProfileV1/**`: current headless configuration and workspace-document fixture. +- `Scripts/test_shared_runtime_phase0_characterization.py`: ancestry, coverage, allowed-difference, and package-separation validation. + +## Normalization rules + +- JSON object keys are sorted; array order is preserved. +- Temporary repository/state paths are replaced by `$ROOT` and `$STATE`. +- Fixed UUIDs and timestamps are used in persistence fixtures. +- App and headless fields are not renamed or removed merely to create equality. +- Descriptor/parser/DTO/text/error differences are recorded as blockers, not allowed product differences. + +## Current differential result + +Only these categories are allowed to remain different after convergence: + +1. initialize and product/profile metadata; +2. profile/state-root paths; +3. capability omissions; +4. standalone initialization/configuration instructions. + +The frozen v1 implementations currently differ more broadly: descriptor descriptions/schemas/annotations, wrapper normalization versus per-field coercion, DTO/envelope shapes, text/error formatting, workspace schemas/layouts, and implementation ownership. Those are Phase 1-or-later blockers. Phase 0 preserves and exposes them. + +## Pinned invariant suites + +- Routing: `TabContextRoutingTests`, `BindContextRoutingRecoveryTests`, `MCPResolvedToolDispatchSourceGuardTests`. +- Watcher freshness: `FileSystemAcceptedIngressBarrierTests`, `WorkspaceFileContextStoreTests`. +- Bootstrap/socket ownership and ordering: `MCPBootstrapContractCharacterizationTests`, `MCPSocketDescriptorHardeningTests`. +- Process descriptors/SIGPIPE/spawn behavior: `ProcessLauncherDescriptorInheritanceTests`. +- Packaging separation: release-tooling static tests, embedded-helper layout/version checks, and direct-stdio headless smoke. + +## Validation evidence + +All commands ran on `core_split` without launching `RepoPrompt.app`. + +| Gate | Evidence | +| --- | --- | +| Phase 0 manifest/baselines/package separation | `python3 Scripts/test_shared_runtime_phase0_characterization.py` — passed | +| Release/package static self-tests | `python3 Scripts/test_release_tooling.py` — 37 tests passed | +| New app/headless snapshots and v1 no-rewrite fixtures | `make dev-test FILTER=SharedRuntimePhase0` — 4 tests passed | +| App catalog | `make dev-test FILTER=ToolCatalogSnapshotTests` — 3 tests passed after adding a bounded wait for socket publication | +| Routing | `TabContextRoutingTests` 17 passed; `BindContextRoutingRecoveryTests` 7 passed; `MCPResolvedToolDispatchSourceGuardTests` 3 passed | +| Bootstrap/socket/process | `MCPBootstrapContractCharacterizationTests` 7 passed; `MCPSocketDescriptorHardeningTests` 21 passed; `ProcessLauncherDescriptorInheritanceTests` 6 passed | +| Watcher/file context | `FileSystemAcceptedIngressBarrierTests` 8 passed; `WorkspaceFileContextStoreTests` 102 passed | +| Headless runtime/store | `HeadlessMCPServerLifecycleTests` 7 passed; `HeadlessSelectionToolsTests` 5 passed; `HeadlessWorkspaceStoreTests` 3 passed | +| Codemap/workspace/runtime | `CodeMapGoldenTests` 4 passed; `WorkspaceSelectionPersistenceTests` 4 passed; `RepoPromptCoreHostLifecycleTests` 3 passed; `MCPRuntimeSessionRegistryTests` 2 passed | +| Target builds | coordinated Swift builds passed for `RepoPrompt`, `repoprompt-mcp`, and `repoprompt-headless` | +| Guardrails | `make dev-guardrails` — passed | +| Standalone smoke | `make dev-headless-smoke` — passed over direct stdio | +| App packaging/helper smoke | `make dev-build` — packaged and signed the debug app; embedded `repoprompt-mcp --version` and helper-layout checks passed; app was not launched | +| Style | `make dev-format` and `make dev-lint` — passed; 0 SwiftFormat or SwiftLint violations | + +A true live app-proxy `make dev-smoke` was intentionally not run because the user prohibited visible app launch and the command requires an already-running app. The bootstrap contract, routing, socket ownership/rollback suites, non-launching debug package, embedded-helper layout/version smoke, and direct-stdio headless smoke are the Phase 0 substitutes. + +The first `ToolCatalogSnapshotTests` attempts exposed an existing publication race: the test inspected the isolated socket path before listener publication completed. Phase 0 adds a test-only bounded wait for the socket file; production behavior is unchanged, and the suite then passed. + +## Exact changed files + +- `docs/designs/shared-runtime-convergence-2026-06-05.md` +- `docs/plans/headless-core-isolation-2026-06-03.md` +- `docs/architecture/headless-core.md` +- `docs/architecture/source-layout.md` +- `docs/characterization/shared-runtime-phase0-2026-06-05.md` +- `Scripts/test_shared_runtime_phase0_characterization.py` +- `Tests/RepoPromptTests/MCP/ToolCatalogSnapshotTests.swift` +- `Tests/RepoPromptTests/MCP/SharedRuntimePhase0CharacterizationTests.swift` +- `Tests/RepoPromptHeadlessTests/Helpers/RepoRoot.swift` +- `Tests/RepoPromptHeadlessTests/SharedRuntimePhase0HeadlessCharacterizationTests.swift` +- `Tests/SharedRuntimeConvergenceFixtures/Phase0/manifest.json` +- `Tests/SharedRuntimeConvergenceFixtures/Phase0/differential-ledger.json` +- `Tests/SharedRuntimeConvergenceFixtures/Phase0/App/app-characterization.json` +- `Tests/SharedRuntimeConvergenceFixtures/Phase0/App/WorkspaceV1/workspacesIndex.json` +- `Tests/SharedRuntimeConvergenceFixtures/Phase0/App/WorkspaceV1/Workspace-Phase 0 App V1-AAAAAAAA-AAAA-AAAA-AAAA-AAAAAAAAAAAA/workspace.json` +- `Tests/SharedRuntimeConvergenceFixtures/Phase0/Headless/headless-characterization.json` +- `Tests/SharedRuntimeConvergenceFixtures/Phase0/Headless/ProfileV1/config.json` +- `Tests/SharedRuntimeConvergenceFixtures/Phase0/Headless/ProfileV1/Workspaces/22222222-2222-2222-2222-222222222222.json` + +No production Swift source, `Package.swift`, packaging script, or runtime ownership changed. + +## Phase 1 blockers + +1. Nine safe-tool descriptors and providers are independently owned. +2. App normalization and headless coercion differ. +3. Structured DTOs, formatted text, and errors differ. +4. Headless retains duplicate workspace/search/codemap/selection/prompt implementations. +5. No canonical workspace codec/repository or v1 migration exists. +6. Mature host/session/runtime ownership remains app-local. +7. Workspace file-context publication still retains the deferred Combine seam. +8. `RepoPromptShared` still contains POSIX descriptor support. +9. Core implementation targets remain public library products. +10. App-proxy socket mechanics and production process injection remain incomplete CoreMacOS work. diff --git a/docs/characterization/shared-runtime-phase1-2026-06-05.md b/docs/characterization/shared-runtime-phase1-2026-06-05.md new file mode 100644 index 000000000..263d3acf0 --- /dev/null +++ b/docs/characterization/shared-runtime-phase1-2026-06-05.md @@ -0,0 +1,57 @@ +# Shared Runtime Phase 1 Boundary Characterization + +Date: 2026-06-05 +Starting checkpoint: `48a335e0f65655b6ffe39018ea7e899c02108a5a` (`Freeze shared runtime parity baseline`) + +## Scope + +Phase 1 establishes dependency and contract boundaries only. It does not switch the app or headless production runtime to shared Core implementations. + +## Landed boundaries + +- `RepoPromptShared` contains only Foundation bootstrap/control wire contracts. +- `RepoPromptPOSIXSupport` owns the existing descriptor close-on-exec and socket shutdown helpers without changing their implementation or callers' error text. +- `RepoPromptCore` no longer depends on Shared or the native C/PCRE/syntax targets; the app retains those dependencies because it still has the real importers. +- Core process descriptor failures carry neutral operation, label, descriptor number, and errno fields. +- Core contains generic workspace codec, repository, layout, diagnostics, and legacy-migration contracts. No concrete app/headless workspace model or codec moved. +- Core contains immutable tool capability policy, final session tool-name/group vocabulary, and neutral session identifiers. Current app tool catalogs remain unchanged; app-local typealiases preserve existing session call sites. +- The app-proxy admission boundary carries an opaque accepted-transport lease. The app-owned implementation preserves listener ownership, admission reservation, accepted-response-first transfer, synchronous lifecycle-ledger publication, deferred startup, rollback, close-once, and full-stop draining. +- SwiftPM exposes only the three executable products. Core, CoreMacOS, POSIXSupport, and SyntaxBridge remain package-internal implementation targets. + +## Frozen behavior evidence + +`Scripts/test_shared_runtime_phase1_boundaries.py` byte-compares every `Tests/SharedRuntimeConvergenceFixtures/Phase0/**` file, the Phase 0 characterization, and its script against checkpoint `48a335e`. Phase 0 fixtures and expectations are not rewritten. + +## Deferred constraints for Phase 2 + +- Do not move or replace `WorkspaceModel`, the app repository/controller, workspace-context store, search, selection, slices, token accounting, codemap, syntax, or prompt implementations before their injected roots/watchers/diagnostics and publication barriers are ready. +- Do not implement `EmbeddedWorkspaceCodecV1`, `HeadlessWorkspaceCodecV1`, `CanonicalWorkspaceCodecV2`, canonical v2 writes, or legacy migration execution as part of the boundary work. +- Do not switch app/headless tool catalogs, descriptors, normalization, DTOs, formatting, routing, or capability composition to the new vocabulary yet. +- Keep `RepoPromptCorePlatformDependencies` and the static process facade until the later call-site injection phase; only its watcher factory is currently consumed by the app-owned session host. +- Keep app-proxy listener/Unix transport mechanics app-owned until the dedicated CoreMacOS move. The Core contract must remain opaque and direct stdio must remain separate. +- Temporary app aliases for Core session identifiers may be removed only when the host/session implementation moves to Core. + +## Validation + +All validation completed without launching the app: + +- Phase 0 artifact script: `python3 Scripts/test_shared_runtime_phase0_characterization.py` +- Phase 1 boundary script: `python3 Scripts/test_shared_runtime_phase1_boundaries.py` +- Coordinated guardrails: ticket `729cf16a-99b2-4fcd-ae56-191490513ecb` +- Coordinated builds: + - `RepoPrompt`: ticket `8658d922-4a71-408d-b449-a81adbf83772` + - `repoprompt-mcp`: ticket `2bdf45a6-17a7-4406-b213-164bf1c2b0cf` + - `repoprompt-headless`: ticket `7e610304-e3c6-4c24-8196-ad2db8340fb6` +- Focused tests: + - Core Phase 1 contracts: `b439de4f-6bca-4e22-9595-b93993aaaf0c` + - POSIX descriptor support: `2132ea51-b3ed-41f7-bf09-30cdb51a2b8f` + - opaque accepted-transport lease, including reentrant publication rollback: `667cc5c9-18c1-4cbd-b158-ab3d5ffb18bd` + - socket descriptor/rollback lifecycle: `c8d92dd2-cb20-407d-b217-33b2e5864890` + - process descriptor inheritance/SIGPIPE: `3545e945-933d-4a63-b196-67eda5629f90` + - bootstrap wire contract: `3088e122-9740-4add-b10b-cb77fe29c50f` + - app Phase 0 no-rewrite characterization: `ce529cb8-abed-49ea-aa93-a5f8026d8a11` + - headless Phase 0 no-rewrite characterization: `5621ed2e-011b-45d4-85e6-e77c252e8769` +- Coordinated lint: ticket `634f885d-e05e-4cc2-a5e4-b30f043ed63a` (0 violations) +- Coordinated headless direct-stdio smoke: ticket `c59ea1dd-9a83-46d6-a1ba-ffc4033e039e` + +The required final Context Builder review ran in chat `phase-1-review-708F5C`. Its publication reentrancy and guardrail findings were resolved before the final validation tickets above. diff --git a/docs/characterization/shared-runtime-phase2-slice1-2026-06-05.md b/docs/characterization/shared-runtime-phase2-slice1-2026-06-05.md new file mode 100644 index 000000000..a7230e274 --- /dev/null +++ b/docs/characterization/shared-runtime-phase2-slice1-2026-06-05.md @@ -0,0 +1,85 @@ +# Shared Runtime Phase 2 Slice 1 Characterization + +Date: 2026-06-05 +Starting checkpoint: `7e686cf4df882826ece64c994fb834e1334a10c1` (`Establish shared runtime dependency boundaries`) +Scope: Slice 1 only; no commit, headless adoption, canonical-v2 writes, or Slice 2/3 ownership + +## Delivered authority + +- `RepoPromptCore` now owns the canonical persisted app workspace graph: workspace, compose/stashed tab, selection, preset, context-builder, copy, file-tree, codemap, git, files-tab, and line-range values. +- `EmbeddedWorkspaceCodecV1` preserves current app-v1 coding keys, including the legacy `discover` key, and reports normalization as decode metadata without writing. +- `WorkspaceRepository` owns index-order inventory, custom storage paths, decode caching, explicit saves/deletes, injected root/layout policy, diagnostics, and the Phase 1 migration seam. +- `WorkspacePersistenceWriter` is a process-shared actor with per-URL serialization, enqueue durability, flush-through-receipt cuts, stale-date suppression, newest-selection arbitration/merge, atomic replacement, neutral completion diagnostics, and recovery when a successful replacement supersedes an earlier failed write. +- `WorkspaceSessionController` is the authoritative `@MainActor` owner of ordered workspaces, active workspace ID, index projection, immutable snapshots, mutation transactions, dirty/save generations, selection revisions, repository baselines, and binding candidates. Hydration does not mint authoritative selection revisions, and stale save completions cannot advance either dirty state or repo-path baselines. + +## App adaptation + +- `RepoPromptAppCoreContainer` constructs one writer/repository graph and shares it with every app session controller through the app-owned `RepoPromptCoreHost`. +- `WorkspaceManagerViewModel` has read-only controller projections and routes load, create, rename, reorder, duplicate cleanup, delete, switching, root/preset/metadata updates, compose-tab lifecycle, selection persistence, and save completion through controller mutations or bounded transactions. Its save pipeline returns and flushes the exact receipt for the exact captured generation/payload before recording completion. +- `WorkspaceSessionObservationBridge` adapts immutable Core snapshots to Combine without exposing writable canonical storage. +- `WorkspaceSessionSelectionForwarder` temporarily satisfies the existing app selection-host seam and is explicitly marked for deletion in Slice 2. `WindowState` retains both the observation bridge and forwarder for the full window lifetime. +- App storage-root discovery, UserDefaults/Application Support policy, durability tracing, duplicate-cleanup result models, UI behavior, file/context runtime, MCP adapters, and prompt behavior remain app-owned. + +## Frozen and deferred boundaries + +- Phase 0 fixtures and characterization remain unchanged. +- `Sources/RepoPromptHeadless/**` and `Tests/RepoPromptHeadlessTests/**` remain byte-for-byte unchanged from `7e686cf`. +- The app is the only production constructor/consumer of `WorkspacePersistenceWriter`, `WorkspaceRepository`, and `WorkspaceSessionController`. +- No `CanonicalWorkspaceCodecV2` production selection or v2 write path is present. +- No Slice 2 file/context/search/selection/codemap/syntax ownership or Slice 3 rendering/MCP/prompt ownership moved. + +## Supporting files outside the primary move table + +- `WorkspaceIndexEntry` was added to the existing Core repository contracts because app-v1 index inventory is required by the concrete repository/controller. +- `MCPConnectionManager+DebugDiagnosticsWorkspace.swift` was retargeted from the removed nested app writer to the process-shared Core writer so existing diagnostics continue to compile and observe the same durability boundary. +- Persistent MCP integration fixtures that directly mutated the old writable manager array were converted to the public manager transaction API; no compatibility setter was added. +- The Makefile, source-layout/headless architecture locks, Phase 2 design, and a new Slice 1 boundary script were updated to make the ownership constraints permanent. + +## Test coverage + +Core coverage now includes: + +- app-v1 key/round-trip/normalization warnings; +- side-effect-free repository loads and explicit-save-only persistence; +- index order, missing documents, custom storage paths, durable index completion, and concurrent merging index saves; +- every controller mutation family, generation ordering, dirty/save baselines, hydration revision neutrality, stale-controller selection arbitration, shared selection revisions, and binding candidates; +- serialized writes, flush cuts, cancellation durability, stale-date suppression, stale selection rejection, merging newer selection into newer disk state, and fail-first/succeed-second recovery. + +App coverage now includes: + +- immutable snapshot-to-Combine observation; +- app-only/shared process composition, retained bridge lifetimes, and no-v2 selection source assertions; +- no second writable manager authority and a gated manager save race proving stale generations cannot advance repo-path baselines; +- durability tracer attribution over neutral Core events; +- unchanged Phase 0 app/headless fixture behavior and root hydration/normalization characterization. + +## Validation evidence + +The complete documented Slice 1 gate passed in order: + +- guardrails: `39761dc1-d28a-405d-873f-4402c2de1a47` +- repository: `c73a961b-f8cd-4811-8241-d200afa7d1e4` +- app-v1 codec: `2f41b1b2-ca6b-41bc-a581-9858bc9e4661` +- session controller: `788e593c-a355-4376-a28c-057c632d70a2` +- selection persistence: `20f86469-850a-4439-b56f-661472f31db4` +- root sync: `213e2dc3-3349-4aaf-810b-d3c88009655d` +- Phase 0 app/headless characterization: `c4b3f10f-083d-4f9d-b615-a857e2534f29` +- `RepoPrompt` build: `c96b57bc-05fc-4c77-bcdd-9b7ad996e217` +- `repoprompt-mcp` build: `151db1de-fb8b-4228-9b0e-220912522d94` +- `repoprompt-headless` build: `41aecf86-e12e-4663-ad54-718212c5e0ee` +- headless smoke: `f04bf2ac-3ea7-417f-964e-339bd7b81a0a` +- lint/format check: `1286387b-d21f-4ba2-9f3a-d79279af15c4` + +Additional post-review regressions passed: + +- persistence writer recovery: `c3bffcdb-6285-4b2b-a478-1f54269a242b` +- observation bridge: `dcc2233c-43f8-4705-818d-62af1f4d5434` +- app composition, lifetime, and gated save race: `06b0c47d-9cc6-435a-bb43-f802795bde09` +- app durability diagnostics: `f6f786fc-f8e5-438e-a560-d4cbde44893b` +- mutating Swift format: `716b4618-74ff-4e62-b3ef-6ad9e8b7436a` + +The first blocking Context Builder review (`slice-1-review-604A48`) identified five issues, all fixed before the gate: exact receipt/generation save completion, serialized durable index merging, hydration-neutral selection revisions, retained window bridge lifetimes, and superseded writer-failure cleanup. + +A broader full-suite run (`5ad83952-5a35-4c2d-b9ce-f215e1a45e96`) reproduced a pre-existing source-guard failure and later stopped making progress, so it was canceled. The isolated blocker is `8cb8ae55-c514-45fc-9332-cb6884e4b10e`: `MCPReadSearchLatencyDiagnosticsGuardTests/testExactReadAndBootstrapAttributionHooksRemainOwnedCoarseAndDirect` expects `handshakeSocket.transferOwnershipIfOpen(`, but that hook is already absent at checkpoint `7e686cf`; neither the test nor `MacOSBootstrapSocketServer.swift` is in the Slice 1 diff. + +The final blocking Context Builder review (`slice-1-review-33883C`) identified additional persistence edge cases. Before the final gate, Slice 1 was updated to suppress selection revisions for hydration mutations, reserve every receipt for its exact payload, propagate direct document/index write failures before side effects, arbitrate revisions for all compose-tab selections (including inactive tabs), make observer cancellation publication-safe, route app index writes through the canonical repository, and use collision-resistant opaque per-URL diagnostics correlation. The focused post-review tickets were controller `2562df50-c471-4e61-8d9c-7a9bff1baa6a`, selection `b9490dae-cc21-4c45-a1be-df9ddb4428e9`, writer `7820c418-2ed6-4697-bb58-552fc0fcbe49`, repository `6d76db35-0fea-438c-878c-0b9c1bae7124`, and app composition `df9df5d3-1f7c-4236-b781-42e4a72b7c9e`. diff --git a/docs/characterization/shared-runtime-phase2-slice2-2026-06-05.md b/docs/characterization/shared-runtime-phase2-slice2-2026-06-05.md new file mode 100644 index 000000000..978f46fa3 --- /dev/null +++ b/docs/characterization/shared-runtime-phase2-slice2-2026-06-05.md @@ -0,0 +1,89 @@ +# Shared Runtime Phase 2 Slice 2 Characterization + +Date: 2026-06-05 +Starting checkpoint: `8750dc4` (`Update transport lease source guard`) +Ownership checkpoint: `de21a1e` (`Move file context runtime into RepoPromptCore`) +Scope: Slice 2 only; no Slice 3 prompt/rendering/MCP ownership and no headless adoption + +## Delivered ownership + +- `RepoPromptCore` owns the complete neutral filesystem/catalog/path/search/selection/slices/token-accounting/codemap/syntax closure on top of the Slice 1 workspace/session authority. +- `RepoPromptCoreMacOS` owns workspace directory listing and FSEvents watching behind injected Core contracts. +- Native Core dependencies exist only for real moved importers: RepoPrompt C search/path helpers, PCRE2, the syntax bridge, SwiftTreeSitter, UniversalCharsetDetection, and Cuchardet. +- `RepoPromptEmbeddedWorkspaceRuntimeFactory` is the sole production factory. `RepoPromptCoreHost` receives the constructed dependency bundle rather than selecting platform defaults. +- The temporary Slice 1 selection forwarder and obsolete app runtime source paths are deleted. + +## App adaptation + +The app retains only product policy and adaptation: + +- CoreMacOS directory-listing and watcher construction; +- file mutation authorization/backend behavior; +- diagnostics, latency attribution, readiness, and partition-event adaptation; +- Combine publication and app observation; +- Application Support/cache-root selection; +- `FileViewModel`/`FolderViewModel`, root-binding, and search-result conversion; +- prompt/rendering and MCP provider/catalog/DTO/formatter/dispatch ownership reserved for Slice 3 or Phase 3. + +## Preserved behavioral barriers + +- FSEvents callbacks retain accepted-sequence/watermark freshness and generation-scoped start/stop ownership. +- Root unload detaches catalog/search state before awaited watcher teardown and drains accepted publisher ingress without permitting post-detach mutation. +- A stale stop reconciliation cannot tear down a newly restarted watcher or its replacement ingress generation. +- Store-backed search retains bounded admission and content-fetch backpressure, catalog snapshot invalidation, path alias/wildcard behavior, and telemetry adaptation. +- Selection persistence, slice rebase behavior, token accounting, codemap extraction/goldens, and app acceptance state remain unchanged. + +Validation exposed two teardown-order regressions after the physical ownership move. The validation tranche fixes them by detaching root/search state before awaited watcher shutdown and by making detached watcher cleanup generation-aware. The diagnostics source guard now locks the new Core ordering and owner paths. + +## Frozen and deferred boundaries + +- Every byte under `Sources/RepoPromptHeadless/**` and `Tests/RepoPromptHeadlessTests/**` remains identical to `7e686cf`; headless does not construct the Slice 1/2 runtime. +- Every Phase 0 fixture byte remains frozen. App-v1 decode/load stays side-effect free and canonical-v2 writes remain inactive. +- Prompt assembly/rendering, workspace-context projection, MCP safe-tool providers/catalog/descriptors/normalization/DTOs/formatting/dispatch, app-proxy transport, and standalone adoption did not move. +- App mutation policy, diagnostics/telemetry, readiness, UI state, Application Support/UserDefaults policy, and visible-app lifecycle remain app-owned. + +## Focused validation evidence + +- Guardrails before validation hardening: `831d9be0-c54e-4a57-b1f3-19c06b421b0e`. +- `WorkspaceSelectionControllerTests`: 6 passed (`22a13b07-8e5b-4320-9720-ae2c6dfa14e1`). +- `WorkspaceFileContextStoreTests`: all test groups passed through bounded filters. The monolithic class filter repeatedly stopped progressing after 56 passing tests, so the same class was completed by deterministic method-prefix groups; no test was omitted. Key regression reruns include immediate search-snapshot detach (`8572788b-8892-4205-8f3f-c886ae800357`), unload ingress drain (`b6c66884-a072-4806-8808-18295d341bec`), and stop ingress drain (`171329bd-7ce9-436a-90c5-1e2e3d4e876e`). +- `WorkspaceSearchServiceTests`: 12 passed (`5960160e-fc34-45da-9cbc-95e2811929d1`). +- `StoreBackedWorkspaceSearchTests`: 33 passed (`ed78ec43-61bd-46a5-8b77-d69796071f63`). +- `SelectionSlicePersistenceAndRebaseTests`: 3 passed (`5a85ac44-e8f0-4c08-9b26-dd0cd378172d`). +- `TokenCalculationServiceTests`: 4 passed (`ca05cdd4-1022-4683-8593-50f14b64e265`). +- `CodeMapGoldenTests`: 4 passed (`3c5c2e7b-e392-4136-8578-9c1e64ac76cf`). +- `FileViewModelAcceptedCodeMapTests`: 2 passed (`4f95fba3-18f0-4afd-8a63-467919d88809`). +- `MCPReadSearchLatencyDiagnosticsGuardTests`: 50 passed (`c7307237-a3d1-418a-a52f-73cc173ec1db`). +- `FileSystemAcceptedIngressBarrierTests`, `MacOSFSEventsWatcherTests`, and the watcher restart/stop/unload race regressions passed in the coordinated validation sequence. + +## Broad validation evidence + +- Refreshed guardrails: `e6b7ad16-4a59-4f57-b071-0e05c8d8550e`. +- Product builds: `RepoPrompt` `d52a3bda-9ade-4ba4-8b36-f546808227bb`; `repoprompt-mcp` `2ba8c1c8-22c5-43d4-a315-4982014a7a61`; `repoprompt-headless` `0e25442a-d046-4c8b-9a30-e8cb2dfb94ec`. +- Debug app package/helper validation: `79a4412e-1cf0-461e-8fc7-18231b8f48bd`; the app was not launched. +- Direct-stdio headless smoke: `4bed73a3-5171-4a32-a06b-bce36e6e71d6`. +- SwiftFormat normalization of the moved Slice 2 files: `3a935fec-bffc-4e35-8e79-8d6c753fda7e`; lint then passed with 0 violations: `1110d28d-d4c8-4993-8ecb-08f8f406f4ad`. +- Target-wide broad shards passed: all `RepoPromptCoreTests` (`c2a8c038-d4c5-4230-89d4-ddb1aa5f470d`), all `RepoPromptCoreMacOSTests` (`196c02ee-645b-49fa-bd11-c0ecf8e3d691`), all frozen `RepoPromptHeadlessTests` (`5928dfe5-425f-45e2-b82c-98aeb82c3862`), and all `RepoPromptPOSIXSupportTests` (`1ebbfae5-7b3e-4fe2-ac25-a3aa678d0dab`). +- App adaptation/lifecycle coverage passed: Core host lifecycle `ec0cc04d-002b-4d01-b36e-a869105c9fac`; workspace composition `6c5ff2ea-c56a-4b1e-8175-464c7206bfd1`; content loading concurrency `d6d05577-3d3a-4328-b208-5045202f4227`; selection coordinator `d0c4ffaf-d5a0-4a11-990b-afcad326a477`; workspace loading diagnostics `f9921f80-ebf3-4808-a726-1005f54cbef1`; path/search/ignore recovery `f3a60fb5-afd6-4574-bad0-80686bb3dd2a`, `41becc9c-7947-458d-9b0a-53e92aa0309a`, `2555f8cb-2cd9-484e-953d-6ccb75fb05bd`, and `b2e66f97-0bc7-4d59-b7ef-907b78669aed`. +- Provider package broad tests passed: `a67cb2fe-1617-43b8-8d82-f02bc5a6b3e2`. + +The first broad run exposed two moved Core-test fixture defects rather than production failures: the neutral test runtime supplied no mutation backend and classified directory symlinks without preserving the followed-directory bit. `TestWorkspaceRuntime` now supplies a FileManager-backed mutation adapter and stable symlink metadata; the focused recovery suites pass (`be866d93-fe4f-4a68-a49c-b50ff2c11630`, `4fc27eeb-253c-4aeb-9eec-dcd37df1e8d0`). It also exposed one stale diagnostics source-owner path, now updated and locked by `f9921f80-ebf3-4808-a726-1005f54cbef1`. + +A single-process unfiltered `make dev-test` was attempted three times. After the fixture failures were fixed, it stopped advancing at different app test-class transitions while the same classes passed immediately in isolation (for example `AgentPermissionSecureStoreTests`, `4aacb45f-d7ba-4b5b-a688-bd08744abc03`). Those runs were canceled only after their logs and inactive processes confirmed harness deadlock; target-wide Core/CoreMacOS/headless/POSIX shards and the Slice 2 app-adapter suites above provide the broad validation evidence. + +## Final review + +The blocking Context Builder review first completed discovery but exceeded the provider character limit with the broad branch package. It was rerun without cancellation using a focused 10-file merge-base artifact and completed in chat `phase-2-review-3EE516`. + +The review found one P0 watcher lifecycle race: an older detached stop and a later restarted-then-stopped watcher could share the same ingress generation, allowing the older stop to reset the later lifecycle after its watcher had become nil. The first coherent fix set adds a distinct watcher lifecycle epoch, increments it for each real start attempt, captures it in `DetachedWatcherStop`, and requires it to match before destructive cleanup. A regression test proves a stale stop cannot discard accepted work from a later restarted-and-stopped lifecycle. + +Post-review focused validation passed: + +- accepted-ingress barrier suite, including the new lifecycle-epoch regression: `c1f7e79d-8f39-4680-8ecb-c66d6f620caa` (9 tests); +- store restart-vs-stale-stop regression: `04607acf-892b-4c3a-8fb0-c1e290e78818`; +- `RepoPrompt` build: `6e952bf5-ca3e-44c3-8fed-a820851d3ad2`; +- guardrails: `0267f9f0-aeff-4393-8a58-f45294c4cb90`; +- lint: `5e2129f9-b087-4acd-85c6-30ef7793271c` (0 violations); +- `git diff --check`: passed. + +Per the tranche checkpoint rule, no second review or broader follow-on fix set was started after this P0 correction. diff --git a/docs/characterization/shared-runtime-phase2-slice3-rendering-2026-06-06.md b/docs/characterization/shared-runtime-phase2-slice3-rendering-2026-06-06.md new file mode 100644 index 000000000..40b4673d1 --- /dev/null +++ b/docs/characterization/shared-runtime-phase2-slice3-rendering-2026-06-06.md @@ -0,0 +1,50 @@ +# Shared Runtime Phase 2 — Slice 3 factual rendering checkpoint + +Date: 2026-06-06 +Base: `77635d5` (`Characterize Slice 3 Context Builder parity`) + +## Scope + +This checkpoint moves only deterministic factual prompt rendering into `RepoPromptCore/Prompt`: + +- already-classified full-file and sliced-file blocks; +- codemap text supplied as a neutral value, with missing-codemap fallback to source content; +- code fences, range labels, ordering, omission, separators, and trailing whitespace; +- already-classified selected-diff slicing, ordering, two-newline joining, and non-duplication; +- factual `<file_map>`, `<file_contents>`, and `<git_diff>` wrappers. + +`PromptPackagingService` remains the app facade. It owns `PromptFileEntry`/`FileViewModel` and `ResolvedPromptFileEntry` conversion, `_git_data` diff-artifact classification, multi-root/display-path projection, `FileAPI` codemap projection, selected-artifact precedence and generated Git fallback, diagnostics, presets, title/date/meta/user/chat/clipboard policy, and Context Builder/MCP envelopes. + +## Deferred boundaries + +This checkpoint does not move local-definition or codemap projection algorithms, workspace-context projections, token or code-structure projections, MCP DTO/formatter/catalog/dispatch, app-proxy transport, or standalone headless code. Existing Prompt, MCP, and Context Builder call sites remain behind the app facade. + +## Frozen behavior + +The renderer preserves: + +- exact full-file and slice fixtures, including source trailing newlines before closing fences; +- normalized range ordering, descriptions, and blank-line separators; +- custom display-path resolver precedence and multi-root relative labels; +- codemap-only placement in `<file_map>` and one-time missing-codemap content fallback; +- stable selected-diff ordering, selected-artifact precedence, and no generated-diff duplication; +- file tree before codemaps with exactly two newlines; +- nil-content omission while non-nil empty files still render. + +## Validation + +Passed on the checkpoint worktree: + +- `make dev-test FILTER=PromptRenderingServiceTests` +- `make dev-test FILTER=PromptRenderingParityCharacterizationTests` +- `make dev-test FILTER=MCPRenderingParityCharacterizationTests` +- `make dev-test FILTER=ContextBuilderRenderingParityCharacterizationTests` +- `make dev-test FILTER=PromptMigrationRemovalTests` +- `make dev-test FILTER=WorkspaceFileContextStoreTests/testResolvedClipboardPackagingRendersStoreCodemaps` +- `make dev-swift-build PRODUCT=RepoPrompt` +- `make dev-swift-build PRODUCT=repoprompt-mcp` +- `make dev-guardrails` +- targeted SwiftFormat on the five touched Swift files +- `git diff --check` + +`make dev-lint` was also run. Its repository-wide SwiftFormat phase is currently blocked by pre-existing drift in unrelated files (for example `WindowStateComposition.swift`, `FileSystemService+Metadata.swift`, and `PCRE2LiteralEscaping.swift`); it reported no touched checkpoint file as a finding. Staged contribution preflight and the commit check run after this record is staged. diff --git a/minimalXcodeFreeSetup.md b/minimalXcodeFreeSetup.md deleted file mode 100644 index 6f6284347..000000000 --- a/minimalXcodeFreeSetup.md +++ /dev/null @@ -1,411 +0,0 @@ -## The viable path - -Build it as a **Swift Package Manager macOS app**, then have a tiny shell script turn the SwiftPM executable into a real `.app` bundle. No `.xcodeproj`, no opening Xcode, no Xcode UI. This is basically the steipete pattern. - -The catch: you can avoid an **Xcode project**, but you cannot avoid an **Apple SDK/toolchain**. SwiftUI and Liquid Glass are Apple SDK APIs. Apple’s current developer release page lists **Xcode 26.5 RC** as of May 4, 2026, and the Xcode 26.5 notes say it includes **Swift 6.3** and SDKs for iOS/iPadOS/tvOS/watchOS/macOS/visionOS 26.5. ([Apple Developer][1]) Apple’s macOS 26 SDK notes also say the macOS SDK comes bundled with Xcode. ([Apple Developer][2]) - -So the honest target should be: - -> **No Xcode project. No Xcode UI. Clone → `./Scripts/run.sh`. Requires either Xcode 26.x or matching Command Line Tools with the macOS 26 SDK installed.** - -That is the sweet spot. Trying to make Liquid Glass compile on a machine with only an older macOS 14/15 SDK is not realistic, because `.glassEffect`, `GlassEffectContainer`, and related SwiftUI symbols are SDK symbols. Apple’s docs describe `glassEffect(_:in:)`, `GlassEffectContainer`, and Liquid Glass custom-view adoption in SwiftUI. ([Apple Developer][3]) - -## What steipete’s repos point to - -The best reference is **RepoBar**. Its own repo guidelines say it is a macOS menubar app using **SwiftUI + AppKit**, built with **SwiftPM**, with app bundling/signing handled by `Scripts/*.sh`; the dev commands are wrapped by `pnpm`, but the core build is `swift build`. ([GitHub][4]) RepoBar’s spec explicitly says **Swift 6.2, Xcode 26**, and its `Package.swift` uses `swift-tools-version: 6.2`, platform declarations, SPM dependencies, and an executable target rather than an Xcode project. ([GitHub][5]) - -**Trimmy** is an even cleaner reference for the packaging shape: `Package.swift` declares a SwiftPM executable app target and dependencies like Sparkle, KeyboardShortcuts, and MenuBarExtraAccess. ([GitHub][6]) Its `package_app.sh` does the important stuff: resolve/build via SwiftPM, create `MyApp.app/Contents/...`, write `Info.plist`, copy the executable, set `LSMinimumSystemVersion`, set `LSUIElement`, and sign/package. ([GitHub][7]) - -**CodexBar** is useful as a warning: once you add Widgets/AppIntents-style things, packaging can drift back into `xcodebuild` territory; its script invokes `xcrun`, `appintentsmetadataprocessor`, and `xcodebuild` for widget metadata. ([GitHub][8]) For your “clone to build, no Xcode dependency” goal, avoid widgets, app extensions, asset catalog weirdness, and anything requiring Xcode’s build system until the base app is solid. - -## Recommended repo shape - -```text -YourApp/ - Package.swift - Package.resolved - Sources/ - YourApp/ - YourApp.swift - ContentView.swift - GlassCompat.swift - Resources/ - Scripts/ - doctor.sh - package_app.sh - run.sh - Makefile - version.env -``` - -Use **SwiftPM as the only project file**: - -```swift -// swift-tools-version: 6.2 -import PackageDescription - -let package = Package( - name: "YourApp", - platforms: [ - .macOS(.v14), - ], - products: [ - .executable(name: "YourApp", targets: ["YourApp"]), - ], - dependencies: [ - // Keep this lean. Add Sparkle/MenuBarExtraAccess/etc. later. - ], - targets: [ - .executableTarget( - name: "YourApp", - resources: [ - .process("Resources"), - ], - swiftSettings: [ - .enableUpcomingFeature("StrictConcurrency"), - ] - ), - .testTarget( - name: "YourAppTests", - dependencies: ["YourApp"], - swiftSettings: [ - .enableUpcomingFeature("StrictConcurrency"), - .enableExperimentalFeature("SwiftTesting"), - ] - ), - ] -) -``` - -Use a normal SwiftUI entry point: - -```swift -import SwiftUI - -@main -struct YourApp: App { - var body: some Scene { - WindowGroup { - ContentView() - .frame(minWidth: 900, minHeight: 600) - } - - Settings { - SettingsView() - } - } -} -``` - -## Liquid Glass while still targeting macOS 14+ - -This is the important compatibility pattern. Compile with the macOS 26 SDK, but keep your deployment target at macOS 14 and gate Liquid Glass at runtime. - -```swift -import SwiftUI - -struct GlassPanel<Content: View>: View { - private let content: Content - - init(@ViewBuilder content: () -> Content) { - self.content = content() - } - - var body: some View { - if #available(macOS 26.0, *) { - content - .padding(14) - .glassEffect(.regular, in: RoundedRectangle(cornerRadius: 18)) - } else { - content - .padding(14) - .background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 18)) - .overlay { - RoundedRectangle(cornerRadius: 18) - .strokeBorder(.separator.opacity(0.35)) - } - } - } -} -``` - -Then use standard SwiftUI/AppKit-native controls wherever possible. Apple’s guidance says standard SwiftUI/UIKit/AppKit components pick up Liquid Glass with minimal code, while custom components can adopt it using the SwiftUI glass APIs. ([Apple Developer][9]) - -This gives you: - -```swift -struct ContentView: View { - var body: some View { - NavigationSplitView { - List { - Text("Repos") - Text("Settings") - } - } detail: { - VStack(spacing: 16) { - Text("Native SwiftUI app") - .font(.title) - - GlassPanel { - VStack(alignment: .leading) { - Text("Liquid Glass on macOS 26+") - .font(.headline) - Text("Material fallback on macOS 14–25.") - .foregroundStyle(.secondary) - } - } - } - .padding() - .toolbar { - Button("Refresh", systemImage: "arrow.clockwise") { - // refresh - } - } - } - } -} -``` - -## The build scripts - -### `Scripts/doctor.sh` - -This is the script that makes the dependency boundary explicit. It does not require an Xcode project, but it checks that the active Apple developer tools expose a macOS 26 SDK. Apple’s command-line tools package is a self-contained package with the macOS SDK and command-line tools in `/Library/Developer/CommandLineTools`; Apple also says Xcode bundles command-line tools and that `xcode-select --install` installs CLT. ([Apple Developer][10]) - -```bash -#!/usr/bin/env bash -set -euo pipefail - -quiet="${1:-}" - -log() { - if [[ "$quiet" != "--quiet" ]]; then - printf '%s\n' "$*" - fi -} - -require() { - command -v "$1" >/dev/null 2>&1 || { - echo "ERROR: Missing required tool: $1" >&2 - exit 1 - } -} - -require swift -require xcrun -require codesign -require plutil - -log "==> Swift" -swift --version - -SDK_PATH="$(xcrun --sdk macosx --show-sdk-path 2>/dev/null || true)" -if [[ -z "$SDK_PATH" ]]; then - echo "ERROR: No macOS SDK found via xcrun." >&2 - echo "Install Xcode 26.x or matching Command Line Tools, then run:" >&2 - echo " sudo xcode-select -s /Applications/Xcode.app/Contents/Developer" >&2 - exit 1 -fi - -log "==> macOS SDK" -log "$SDK_PATH" - -if [[ "$SDK_PATH" != *"MacOSX26"* && "$SDK_PATH" != *"MacOSX27"* ]]; then - echo "ERROR: Liquid Glass SwiftUI APIs require the macOS 26 SDK or newer." >&2 - echo "Current SDK: $SDK_PATH" >&2 - echo "Install/select Xcode 26.x or Command Line Tools that include MacOSX26.sdk." >&2 - exit 1 -fi - -TMP="$(mktemp -d)" -trap 'rm -rf "$TMP"' EXIT - -cat > "$TMP/GlassProbe.swift" <<'SWIFT' -import SwiftUI - -@available(macOS 26.0, *) -private struct GlassProbe: View { - var body: some View { - Text("OK").glassEffect() - } -} -SWIFT - -ARCH="$(uname -m)" -xcrun swiftc \ - -typecheck \ - -parse-as-library \ - -target "${ARCH}-apple-macos14.0" \ - "$TMP/GlassProbe.swift" - -log "OK: toolchain can compile SwiftUI Liquid Glass symbols." -``` - -### `Scripts/package_app.sh` - -This follows the Trimmy/RepoBar pattern: `swift build`, create bundle, copy executable/resources/frameworks, write `Info.plist`, ad-hoc sign for dev. RepoBar’s packaging script builds with `swift build`, creates the `.app` bundle, copies the executable and resources, installs frameworks like Sparkle if present, and writes a packaged `Info.plist`. ([GitHub][11]) - -```bash -#!/usr/bin/env bash -set -euo pipefail - -APP_NAME="YourApp" -BUNDLE_ID="${BUNDLE_ID:-com.yourcompany.yourapp}" -CONF="${1:-debug}" - -ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -cd "$ROOT_DIR" - -source "$ROOT_DIR/version.env" - -./Scripts/doctor.sh --quiet - -echo "==> Building $APP_NAME ($CONF)" -swift build -c "$CONF" - -BUILD_DIR="$ROOT_DIR/.build/$CONF" -APP_BUNDLE="$ROOT_DIR/.build/$CONF/$APP_NAME.app" - -if [[ ! -f "$BUILD_DIR/$APP_NAME" ]]; then - echo "ERROR: Missing built executable: $BUILD_DIR/$APP_NAME" >&2 - exit 1 -fi - -echo "==> Creating app bundle" -rm -rf "$APP_BUNDLE" -mkdir -p \ - "$APP_BUNDLE/Contents/MacOS" \ - "$APP_BUNDLE/Contents/Resources" \ - "$APP_BUNDLE/Contents/Frameworks" - -cp "$BUILD_DIR/$APP_NAME" "$APP_BUNDLE/Contents/MacOS/$APP_NAME" -chmod +x "$APP_BUNDLE/Contents/MacOS/$APP_NAME" - -# Copy SwiftPM resource bundles, if any. -shopt -s nullglob -for bundle in "$BUILD_DIR"/*.bundle; do - echo "==> Copying resource bundle: $(basename "$bundle")" - cp -R "$bundle" "$APP_BUNDLE/Contents/Resources/" -done -shopt -u nullglob - -# Optional icon. -if [[ -f "$ROOT_DIR/Icon.icns" ]]; then - cp "$ROOT_DIR/Icon.icns" "$APP_BUNDLE/Contents/Resources/Icon.icns" - ICON_PLIST='<key>CFBundleIconFile</key><string>Icon</string>' -else - ICON_PLIST='' -fi - -cat > "$APP_BUNDLE/Contents/Info.plist" <<PLIST -<?xml version="1.0" encoding="UTF-8"?> -<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> -<plist version="1.0"> -<dict> - <key>CFBundleName</key><string>${APP_NAME}</string> - <key>CFBundleDisplayName</key><string>${APP_NAME}</string> - <key>CFBundleIdentifier</key><string>${BUNDLE_ID}</string> - <key>CFBundleExecutable</key><string>${APP_NAME}</string> - <key>CFBundlePackageType</key><string>APPL</string> - <key>CFBundleShortVersionString</key><string>${MARKETING_VERSION}</string> - <key>CFBundleVersion</key><string>${BUILD_NUMBER}</string> - <key>LSMinimumSystemVersion</key><string>14.0</string> - <key>LSMultipleInstancesProhibited</key><true/> - <key>NSHighResolutionCapable</key><true/> - ${ICON_PLIST} -</dict> -</plist> -PLIST - -plutil -lint "$APP_BUNDLE/Contents/Info.plist" - -echo "==> Ad-hoc signing" -codesign --force --sign - "$APP_BUNDLE" - -echo "Created: $APP_BUNDLE" -``` - -### `Scripts/run.sh` - -RepoBar launches via LaunchServices so the process has the right bundle identity; that is exactly what you want for permissions, menus, URL handlers, and “why is this old binary running?” sanity. ([GitHub][12]) - -```bash -#!/usr/bin/env bash -set -euo pipefail - -APP_NAME="YourApp" -ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" - -"$ROOT_DIR/Scripts/package_app.sh" debug - -pkill -x "$APP_NAME" 2>/dev/null || true -open -n "$ROOT_DIR/.build/debug/$APP_NAME.app" -``` - -### `version.env` - -```bash -MARKETING_VERSION=0.1.0 -BUILD_NUMBER=1 -``` - -### `Makefile` - -```make -.PHONY: doctor build run test clean - -doctor: - ./Scripts/doctor.sh - -build: - ./Scripts/package_app.sh debug - -run: - ./Scripts/run.sh - -test: - swift test - -clean: - rm -rf .build -``` - -Then clone-to-build is: - -```bash -git clone <repo> -cd YourApp -make run -``` - -## What I would avoid - -Avoid generating an `.xcodeproj` with XcodeGen/Tuist unless you actually want Xcode in the loop. Avoid Widgets/AppIntents/App Extensions at first, because they can require Xcode-specific metadata steps; CodexBar is a good example of that trap. ([GitHub][8]) Avoid making `pnpm` mandatory unless you already want JS tooling; steipete uses it in RepoBar as a script wrapper, but the underlying build is still SwiftPM. ([GitHub][4]) - -I would also avoid “SwiftPM app product” experiments for now. The steipete repos are doing the boring, reliable thing: executable target + manual `.app` bundling. Boring wins here, very annoyingly. - -## Bottom line - -The cleanest setup is: - -1. **SwiftPM-only project**, no `.xcodeproj`. -2. **macOS 14 deployment target** in `Package.swift` and `Info.plist`. -3. **macOS 26 SDK required to build** because Liquid Glass symbols live there. -4. **Runtime availability gates** for all Liquid Glass calls. -5. **Manual `.app` packaging script** copied from the RepoBar/Trimmy style. -6. **`make run` or `./Scripts/run.sh` as the only local dev command**. - -That gives you the practical version of “no Xcode dependency”: no Xcode project, no Xcode UI, no Xcode build system for the base app. But for Xcode 26 / Liquid Glass APIs, the builder still needs Apple’s matching SDK/toolchain installed. - -[1]: https://developer.apple.com/news/releases/?utm_source=chatgpt.com "Releases" -[2]: https://developer.apple.com/documentation/macos-release-notes/macos-26_5-release-notes?utm_source=chatgpt.com "macOS Tahoe 26.5 RC Release Notes" -[3]: https://developer.apple.com/documentation/swiftui/glasseffectcontainer?utm_source=chatgpt.com "GlassEffectContainer | Apple Developer Documentation" -[4]: https://github.com/steipete/RepoBar/blob/main/AGENTS.md "RepoBar/AGENTS.md at main · steipete/RepoBar · GitHub" -[5]: https://github.com/steipete/RepoBar/blob/main/docs/spec.md "RepoBar/docs/spec.md at main · steipete/RepoBar · GitHub" -[6]: https://github.com/steipete/Trimmy/blob/main/Package.swift "Trimmy/Package.swift at main · steipete/Trimmy · GitHub" -[7]: https://github.com/steipete/Trimmy/blob/main/Scripts/package_app.sh "Trimmy/Scripts/package_app.sh at main · steipete/Trimmy · GitHub" -[8]: https://raw.githubusercontent.com/steipete/CodexBar/main/Scripts/package_app.sh "raw.githubusercontent.com" -[9]: https://developer.apple.com/documentation/TechnologyOverviews/adopting-liquid-glass?utm_source=chatgpt.com "Adopting Liquid Glass | Apple Developer Documentation" -[10]: https://developer.apple.com/library/archive/technotes/tn2339/_index.html "Technical Note TN2339: Building from the Command Line with Xcode FAQ" -[11]: https://github.com/steipete/RepoBar/blob/main/Scripts/package_app.sh "RepoBar/Scripts/package_app.sh at main · steipete/RepoBar · GitHub" -[12]: https://github.com/steipete/RepoBar/blob/main/Scripts/compile_and_run.sh "RepoBar/Scripts/compile_and_run.sh at main · steipete/RepoBar · GitHub" From 3dd01a3efe1326e4774cd677e1f6773323ad65ae Mon Sep 17 00:00:00 2001 From: Eric Provencher <eprovencher92@gmail.com> Date: Tue, 9 Jun 2026 08:07:21 -0400 Subject: [PATCH 2/2] Ensure CI guardrails fetch history --- .github/workflows/ci.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cb7f6078c..fda6729ff 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,6 +13,8 @@ jobs: steps: - name: Check out repository uses: actions/checkout@v6 + with: + fetch-depth: 0 - name: Run repository guardrails run: make guardrails