From 531da8a6b7914700707dc46a6e919a62d579c615 Mon Sep 17 00:00:00 2001 From: Cameron Cooke Date: Mon, 29 Jun 2026 21:48:55 +0100 Subject: [PATCH 01/12] build(telemetry): link Sentry conditionally and document privacy model Add an opt-in REPOPROMPT_ENABLE_SENTRY build gate that links sentry-cocoa and defines REPOPROMPT_SENTRY_ENABLED, register the sentry-cocoa license, add the DSN Info.plist slot, and document the telemetry privacy model. Refs GH-183 --- AppBundle/Info.plist.template | 1 + Package.resolved | 2 +- Package.swift | 162 ++++++++++-------- Scripts/source_layout_guardrails.sh | 1 + Scripts/swiftpm_notice_guardrails.sh | 8 +- ThirdPartyLicenses/swiftpm/SHA256SUMS | 1 + ThirdPartyLicenses/swiftpm/inventory.tsv | 1 + .../swiftpm/sentry-cocoa/LICENSE.md | 21 +++ docs/privacy/telemetry.md | 75 ++++++++ 9 files changed, 198 insertions(+), 74 deletions(-) create mode 100644 ThirdPartyLicenses/swiftpm/sentry-cocoa/LICENSE.md create mode 100644 docs/privacy/telemetry.md diff --git a/AppBundle/Info.plist.template b/AppBundle/Info.plist.template index a99b4463d..7ecebbe05 100644 --- a/AppBundle/Info.plist.template +++ b/AppBundle/Info.plist.template @@ -18,6 +18,7 @@ RepoPromptSigningMode__SIGNING_MODE__ RepoPromptLocalSigningCertificateSHA256__LOCAL_SIGNING_CERTIFICATE_SHA256__ RepoPromptLocalSecureStorageGeneration__LOCAL_SECURE_STORAGE_GENERATION__ + RepoPromptSentryDSN LSEnvironmentREPOPROMPT_LAUNCH_SOURCElaunchservices CFBundleURLTypesCFBundleTypeRoleViewerCFBundleURLNamecom.pvncher.repoprompt.ceCFBundleURLSchemesrepoprompt-ceCFBundleURLIconFileAppIcon NSAppTransportSecurityNSAllowsArbitraryLoads diff --git a/Package.resolved b/Package.resolved index 66ba7a266..3abd08bac 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "3f8b765387a6b49bd558b0680976f08ac2a7ddf5b7304a2b50797106c1d1f5f4", + "originHash" : "ea3108afaabf4e97ce8567da0d84569101da7d4767884265a9d3b816c24fce86", "pins" : [ { "identity" : "async-http-client", diff --git a/Package.swift b/Package.swift index 643daf128..b6e98e80f 100644 --- a/Package.swift +++ b/Package.swift @@ -4,6 +4,93 @@ import PackageDescription let packageRoot = URL(fileURLWithPath: #filePath).deletingLastPathComponent().path +// Telemetry (Sentry) is linked only when explicitly requested. The official +// Developer ID release pipeline sets REPOPROMPT_ENABLE_SENTRY=1; local builds use +// the same gate for intentional Sentry testing. +let environment = ProcessInfo.processInfo.environment +let sentryEnabled = environment["REPOPROMPT_ENABLE_SENTRY"] == "1" + +var packageDependencies: [Package.Dependency] = [ + .package(url: "https://github.com/apple/swift-log.git", exact: "1.6.3"), + .package(url: "https://github.com/sindresorhus/KeyboardShortcuts.git", exact: "2.3.0"), + .package(url: "https://github.com/gonzalezreal/swift-markdown-ui", exact: "2.4.1"), + .package(url: "https://github.com/swiftlang/swift-markdown", exact: "0.6.0"), + .package(url: "https://github.com/SwiftyJSON/SwiftyJSON", exact: "5.0.2"), + .package(url: "https://github.com/swift-server/swift-service-lifecycle.git", exact: "2.8.0"), + .package(url: "https://github.com/apple/swift-system.git", exact: "1.6.4"), + .package(url: "https://github.com/provencher/swift-sdk.git", revision: "85dec2fc7a27252bc33dc7728be6af6b3bd398c0"), + .package(url: "https://github.com/ChimeHQ/SwiftTreeSitter.git", exact: "0.8.0"), + .package(url: "https://github.com/tree-sitter/tree-sitter-c", revision: "3efee11f784605d44623d7dadd6cd12a0f73ea92"), + .package(url: "https://github.com/UserNobody14/tree-sitter-dart", revision: "80e23c07b64494f7e21090bb3450223ef0b192f4"), + .package(url: "https://github.com/tree-sitter/tree-sitter-go", revision: "c350fa54d38af725c40d061a602ee3205ef1e072"), + .package(url: "https://github.com/tree-sitter/tree-sitter-java", revision: "e10607b45ff745f5f876bfa3e94fbcc6b44bdc11"), + .package(url: "https://github.com/tree-sitter/tree-sitter-javascript", revision: "39798e26b6d4dbcee8e522b8db83f8b2df33a5ea"), + .package(url: "https://github.com/tree-sitter/tree-sitter-python", revision: "c5fca1a186e8e528115196178c28eefa8d86b0b0"), + .package(url: "https://github.com/tree-sitter/tree-sitter-rust", revision: "2eaf126458a4d6a69401089b6ba78c5e5d6c1ced"), + .package(url: "https://github.com/tree-sitter/tree-sitter-typescript", revision: "75b3874edb2dc714fb1fd77a32013d0f8699989f"), + .package(url: "https://github.com/tree-sitter/tree-sitter-ruby", revision: "7a010836b74351855148818d5cb8170dc4df8e6a"), + .package(url: "https://github.com/alex-pinkus/tree-sitter-swift", revision: "9253825dd2570430b53fa128cbb40cb62498e75d"), + .package(url: "https://github.com/tree-sitter/tree-sitter-c-sharp.git", revision: "b27b091bfdc5f16d0ef76421ea5609c82a57dff0"), + .package(url: "https://github.com/tree-sitter/tree-sitter-cpp", revision: "e5cea0ec884c5c3d2d1e41a741a66ce13da4d945"), + .package(url: "https://github.com/provencher/tree-sitter-php", revision: "0a99deca13c4af1fb9adcb03c958bfc9f4c740a9"), + .package(url: "https://github.com/jamesrochabrun/SwiftAnthropic", revision: "b7d030cd7453f314c780f5492385f73d704cbd5d"), + .package(url: "https://github.com/provencher/SwiftOpenAI", revision: "1211782eb337e7968124448a20d9260df1952012"), + .package(url: "https://github.com/ChimeHQ/Neon.git", exact: "0.6.0"), + .package(path: "Vendor/UniversalCharsetDetection"), + .package(url: "https://github.com/loopwork-ai/JSONSchema.git", exact: "1.3.0"), + .package(url: "https://github.com/loopwork-ai/ontology.git", exact: "0.6.0"), + .package(path: "Packages/RepoPromptAgentProviders") +] + +var repoPromptDependencies: [Target.Dependency] = [ + "RepoPromptShared", + "RepoPromptC", "CSwiftPCRE2", "TreeSitterScannerSupport", + "Sparkle", + .product(name: "Logging", package: "swift-log"), + .product(name: "KeyboardShortcuts", package: "KeyboardShortcuts"), + .product(name: "MarkdownUI", package: "swift-markdown-ui"), + .product(name: "Markdown", package: "swift-markdown"), + .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"), + .product(name: "UniversalCharsetDetection", package: "UniversalCharsetDetection"), + .product(name: "Cuchardet", package: "UniversalCharsetDetection"), + .product(name: "JSONSchema", package: "JSONSchema"), + .product(name: "Ontology", package: "ontology"), + .product(name: "RepoPromptClaudeCompatibleProvider", package: "RepoPromptAgentProviders") +] + +var repoPromptSwiftSettings: [SwiftSetting] = [ + .define("DEBUG", .when(configuration: .debug)), + .enableUpcomingFeature("BareSlashRegexLiterals"), + .unsafeFlags([ + "-import-objc-header", "\(packageRoot)/Sources/RepoPrompt/Support/RepoPrompt-Bridging-Header.h", + "-disable-bridging-pch" + ]) +] + +if sentryEnabled { + packageDependencies.append(.package(url: "https://github.com/getsentry/sentry-cocoa", exact: "9.17.1")) + repoPromptDependencies.append(.product(name: "Sentry", package: "sentry-cocoa")) + repoPromptSwiftSettings.append(.define("REPOPROMPT_SENTRY_ENABLED")) +} + let package = Package( name: "RepoPromptCE", platforms: [.macOS(.v14)], @@ -11,82 +98,13 @@ let package = Package( .executable(name: "RepoPrompt", targets: ["RepoPrompt"]), .executable(name: "repoprompt-mcp", targets: ["RepoPromptMCP"]) ], - dependencies: [ - .package(url: "https://github.com/apple/swift-log.git", exact: "1.6.3"), - .package(url: "https://github.com/sindresorhus/KeyboardShortcuts.git", exact: "2.3.0"), - .package(url: "https://github.com/gonzalezreal/swift-markdown-ui", exact: "2.4.1"), - .package(url: "https://github.com/swiftlang/swift-markdown", exact: "0.6.0"), - .package(url: "https://github.com/SwiftyJSON/SwiftyJSON", exact: "5.0.2"), - .package(url: "https://github.com/swift-server/swift-service-lifecycle.git", exact: "2.8.0"), - .package(url: "https://github.com/apple/swift-system.git", exact: "1.6.4"), - .package(url: "https://github.com/provencher/swift-sdk.git", revision: "85dec2fc7a27252bc33dc7728be6af6b3bd398c0"), - .package(url: "https://github.com/ChimeHQ/SwiftTreeSitter.git", exact: "0.8.0"), - .package(url: "https://github.com/tree-sitter/tree-sitter-c", revision: "3efee11f784605d44623d7dadd6cd12a0f73ea92"), - .package(url: "https://github.com/UserNobody14/tree-sitter-dart", revision: "80e23c07b64494f7e21090bb3450223ef0b192f4"), - .package(url: "https://github.com/tree-sitter/tree-sitter-go", revision: "c350fa54d38af725c40d061a602ee3205ef1e072"), - .package(url: "https://github.com/tree-sitter/tree-sitter-java", revision: "e10607b45ff745f5f876bfa3e94fbcc6b44bdc11"), - .package(url: "https://github.com/tree-sitter/tree-sitter-javascript", revision: "39798e26b6d4dbcee8e522b8db83f8b2df33a5ea"), - .package(url: "https://github.com/tree-sitter/tree-sitter-python", revision: "c5fca1a186e8e528115196178c28eefa8d86b0b0"), - .package(url: "https://github.com/tree-sitter/tree-sitter-rust", revision: "2eaf126458a4d6a69401089b6ba78c5e5d6c1ced"), - .package(url: "https://github.com/tree-sitter/tree-sitter-typescript", revision: "75b3874edb2dc714fb1fd77a32013d0f8699989f"), - .package(url: "https://github.com/tree-sitter/tree-sitter-ruby", revision: "7a010836b74351855148818d5cb8170dc4df8e6a"), - .package(url: "https://github.com/alex-pinkus/tree-sitter-swift", revision: "9253825dd2570430b53fa128cbb40cb62498e75d"), - .package(url: "https://github.com/tree-sitter/tree-sitter-c-sharp.git", revision: "b27b091bfdc5f16d0ef76421ea5609c82a57dff0"), - .package(url: "https://github.com/tree-sitter/tree-sitter-cpp", revision: "e5cea0ec884c5c3d2d1e41a741a66ce13da4d945"), - .package(url: "https://github.com/provencher/tree-sitter-php", revision: "0a99deca13c4af1fb9adcb03c958bfc9f4c740a9"), - .package(url: "https://github.com/jamesrochabrun/SwiftAnthropic", revision: "b7d030cd7453f314c780f5492385f73d704cbd5d"), - .package(url: "https://github.com/provencher/SwiftOpenAI", revision: "1211782eb337e7968124448a20d9260df1952012"), - .package(url: "https://github.com/ChimeHQ/Neon.git", exact: "0.6.0"), - .package(path: "Vendor/UniversalCharsetDetection"), - .package(url: "https://github.com/loopwork-ai/JSONSchema.git", exact: "1.3.0"), - .package(url: "https://github.com/loopwork-ai/ontology.git", exact: "0.6.0"), - .package(path: "Packages/RepoPromptAgentProviders") - ], + dependencies: packageDependencies, targets: [ .executableTarget( name: "RepoPrompt", - dependencies: [ - "RepoPromptShared", - "RepoPromptC", "CSwiftPCRE2", "TreeSitterScannerSupport", - "Sparkle", - .product(name: "Logging", package: "swift-log"), - .product(name: "KeyboardShortcuts", package: "KeyboardShortcuts"), - .product(name: "MarkdownUI", package: "swift-markdown-ui"), - .product(name: "Markdown", package: "swift-markdown"), - .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"), - .product(name: "UniversalCharsetDetection", package: "UniversalCharsetDetection"), - .product(name: "Cuchardet", package: "UniversalCharsetDetection"), - .product(name: "JSONSchema", package: "JSONSchema"), - .product(name: "Ontology", package: "ontology"), - .product(name: "RepoPromptClaudeCompatibleProvider", package: "RepoPromptAgentProviders") - ], + dependencies: repoPromptDependencies, path: "Sources/RepoPrompt", - swiftSettings: [ - .define("DEBUG", .when(configuration: .debug)), - .enableUpcomingFeature("BareSlashRegexLiterals"), - .unsafeFlags([ - "-import-objc-header", "\(packageRoot)/Sources/RepoPrompt/Support/RepoPrompt-Bridging-Header.h", - "-disable-bridging-pch" - ]) - ] + swiftSettings: repoPromptSwiftSettings ), .executableTarget( name: "RepoPromptMCP", diff --git a/Scripts/source_layout_guardrails.sh b/Scripts/source_layout_guardrails.sh index a61ed7428..d0334e721 100755 --- a/Scripts/source_layout_guardrails.sh +++ b/Scripts/source_layout_guardrails.sh @@ -293,6 +293,7 @@ allowed_tracked_docs=( "docs/architecture/source-layout.md" "docs/architecture/xcode-workspace.md" "docs/open-source-readiness.md" + "docs/privacy/telemetry.md" "docs/releasing.md" "docs/testing.md" "docs/worktrees.md" diff --git a/Scripts/swiftpm_notice_guardrails.sh b/Scripts/swiftpm_notice_guardrails.sh index 5fb85e074..a8565f24a 100755 --- a/Scripts/swiftpm_notice_guardrails.sh +++ b/Scripts/swiftpm_notice_guardrails.sh @@ -35,7 +35,13 @@ jq -r ' ' Package.resolved | sort > "$TMP_DIR/resolved.tsv" cut -f1-3 "$INVENTORY" | sort > "$TMP_DIR/inventory.tsv" -diff -u "$TMP_DIR/resolved.tsv" "$TMP_DIR/inventory.tsv" || +# SwiftPM does not keep the env-gated Sentry package in Package.resolved once +# REPOPROMPT_ENABLE_SENTRY is restored to its default-off state, but official +# release builds still link it via Package.swift's exact dependency. Keep its +# copied notice under guardrail coverage without treating it as lockfile drift. +grep -v $'^sentry-cocoa\t' "$TMP_DIR/inventory.tsv" > "$TMP_DIR/inventory-for-lockfile.tsv" + +diff -u "$TMP_DIR/resolved.tsv" "$TMP_DIR/inventory-for-lockfile.tsv" || fail "SwiftPM notice inventory does not match Package.resolved" while IFS=$'\t' read -r identity _resolved _location bundle; do diff --git a/ThirdPartyLicenses/swiftpm/SHA256SUMS b/ThirdPartyLicenses/swiftpm/SHA256SUMS index 0c0b896fe..5bf83f341 100644 --- a/ThirdPartyLicenses/swiftpm/SHA256SUMS +++ b/ThirdPartyLicenses/swiftpm/SHA256SUMS @@ -7,6 +7,7 @@ d11b5eea8e9ae2f6dd06545633e8daf0dfcbc7dbb911a20d1504c905e5808775 neon/LICENSE 4438d8a78e985d37ba3c4db356557423b3739b2b2a0abb2ecefcafda4009c16b networkimage/LICENSE 5a79e5efc0acd18c5db2b9079445f87942b4f342eebbf7f792ea296825563d82 ontology/LICENSE 72512faf072d3a4c0d9ae21552e0a3367e87b719ac511d06d2b5a0e427e8796d rearrange/LICENSE +1784335a04bbe8014b9bf0f6a373f7b3804855792545702199dbdd6e2c5b6ad1 sentry-cocoa/LICENSE.md 770af8291f708538d8ff885a0bbc4e045cd700531741c4f99528d435c14d7f55 swift-algorithms/LICENSE.txt cfc7749b96f63bd31c3c42b5c471bf756814053e847c10f3eb003417bc523d30 swift-asn1/LICENSE.txt 570ef2dc97e91403cb8a9b322a66cdd4107821d225a20e7df892700d7ad9586b swift-asn1/NOTICE.txt diff --git a/ThirdPartyLicenses/swiftpm/inventory.tsv b/ThirdPartyLicenses/swiftpm/inventory.tsv index 6b36ace6d..80f7cd2d6 100644 --- a/ThirdPartyLicenses/swiftpm/inventory.tsv +++ b/ThirdPartyLicenses/swiftpm/inventory.tsv @@ -6,6 +6,7 @@ neon 0.6.0 https://github.com/ChimeHQ/Neon.git neon/ networkimage 6.0.1 https://github.com/gonzalezreal/NetworkImage networkimage/ ontology 0.6.0 https://github.com/loopwork-ai/ontology.git ontology/ rearrange 1.8.1 https://github.com/ChimeHQ/Rearrange rearrange/ +sentry-cocoa 9.17.1 https://github.com/getsentry/sentry-cocoa sentry-cocoa/ swift-algorithms 1.2.1 https://github.com/apple/swift-algorithms.git swift-algorithms/ swift-asn1 1.7.0 https://github.com/apple/swift-asn1.git swift-asn1/ swift-async-algorithms 1.1.3 https://github.com/apple/swift-async-algorithms.git swift-async-algorithms/ diff --git a/ThirdPartyLicenses/swiftpm/sentry-cocoa/LICENSE.md b/ThirdPartyLicenses/swiftpm/sentry-cocoa/LICENSE.md new file mode 100644 index 000000000..6b2b8e3e4 --- /dev/null +++ b/ThirdPartyLicenses/swiftpm/sentry-cocoa/LICENSE.md @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2015 Sentry + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/docs/privacy/telemetry.md b/docs/privacy/telemetry.md new file mode 100644 index 000000000..939e24e5e --- /dev/null +++ b/docs/privacy/telemetry.md @@ -0,0 +1,75 @@ +# Telemetry + +RepoPrompt CE can report crashes, errors, app hangs, and diagnostic signals that do not include +user content to [Sentry](https://sentry.io) to help us find and fix problems that affect people +running the app. +This document describes when telemetry is active, what is and is not collected, and how it is +configured. + +## When telemetry is active + +Telemetry is active only in official, notarized Developer ID builds that are configured with the +RepoPrompt CE Sentry project. + +Telemetry is **not active by default** for: + +- DEBUG builds, +- self-compiled / locally built CE, +- locally self-signed production builds, +- release-candidate (ad-hoc) builds, +- UI-test and stress-test launches. + +This is enforced at build time and runtime. Builds without the telemetry-enabled app binary and a +Sentry DSN do not initialize telemetry. If you build CE yourself, telemetry is off unless you +explicitly configure a local build for Sentry testing. + +## What is collected + +When active, telemetry can capture: + +- crash reports and unhandled errors, with stack traces, +- app-hang reports, +- the app version/build and OS version, +- a bounded set of explicitly instrumented breadcrumbs leading up to an event, +- explicitly instrumented traces/spans for coarse app workflow timing, without user content, +- explicitly instrumented metrics for aggregate usage shape, without user content. + +The configuration is deliberately conservative: + +- no personally identifying information (`sendDefaultPii = false`), +- no session replay, MetricKit, structured logs, automatic network breadcrumbs, or automatic file I/O tracing, +- only explicitly instrumented breadcrumbs, spans, and metrics that do not include user content, +- breadcrumb and on-disk event caches are bounded. + +## What is not sent + +RepoPrompt CE's app-owned telemetry is data-minimized by construction: call sites emit typed +lifecycle events instead of raw app data. The telemetry model uses closed enums, +booleans, counts, and buckets; it does not accept arbitrary `String` values for event attributes. +The following are not collected by the manual telemetry instrumentation: + +- prompts and conversation transcripts, +- selected file contents, +- absolute file system paths and workspace names, +- tool arguments and results, and MCP payloads, +- AI provider request/response bodies, +- command output, +- run IDs and tool invocation IDs, +- custom or free-form external tool names, +- raw model names, +- environment variables, +- API keys, tokens, bearer credentials, and passwords, +- screenshots, view hierarchy, and session replay. + +Sentry native crash reports include SDK-provided crash context such as stack traces, exception +messages, app version/build, OS version/build, device model, locale, memory values, a generated user +identifier, and coarse geo country. + +## Processor + +Telemetry data is processed by Sentry (a third-party SaaS provider) acting as a data processor on +behalf of the RepoPrompt CE project. + +Sentry project-side data scrubbing is also configured for the project as defense-in-depth. +It is not the primary privacy boundary: RepoPrompt CE avoids sending sensitive data from the +app in the first place. \ No newline at end of file From c6ba6721b60b68f2225989c382069b4df71f0997 Mon Sep 17 00:00:00 2001 From: Cameron Cooke Date: Mon, 29 Jun 2026 21:50:04 +0100 Subject: [PATCH 02/12] feat(telemetry): add typed Sentry telemetry model and bootstrap Refs GH-183 --- .../Telemetry/SentryTelemetryBootstrap.swift | 303 ++++++++++++ .../Telemetry/SentryTelemetryModel.swift | 438 ++++++++++++++++++ 2 files changed, 741 insertions(+) create mode 100644 Sources/RepoPrompt/Infrastructure/Telemetry/SentryTelemetryBootstrap.swift create mode 100644 Sources/RepoPrompt/Infrastructure/Telemetry/SentryTelemetryModel.swift diff --git a/Sources/RepoPrompt/Infrastructure/Telemetry/SentryTelemetryBootstrap.swift b/Sources/RepoPrompt/Infrastructure/Telemetry/SentryTelemetryBootstrap.swift new file mode 100644 index 000000000..ba184f92f --- /dev/null +++ b/Sources/RepoPrompt/Infrastructure/Telemetry/SentryTelemetryBootstrap.swift @@ -0,0 +1,303 @@ +import Foundation + +#if REPOPROMPT_SENTRY_ENABLED + import Sentry +#endif + +enum SentryTelemetryBootstrap { + /// Starts telemetry when the build is telemetry-enabled and a DSN is present. + /// Must be called on the main thread (the SDK requires it). + @MainActor + static func start() { + #if REPOPROMPT_SENTRY_ENABLED + let dsn: String + if let value = ProcessInfo.processInfo.environment["REPOPROMPT_SENTRY_DSN"], !value.isEmpty { + dsn = value + } else if let value = Bundle.main.object(forInfoDictionaryKey: "RepoPromptSentryDSN") as? String, !value.isEmpty { + dsn = value + } else { + return + } + + let dist = Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as? String + + SentrySDK.start { options in + options.dsn = dsn + #if DEBUG + options.environment = "debug" + options.debug = true + options.tracesSampleRate = 1 + #else + options.environment = "production" + options.debug = false + options.tracesSampleRate = 0.3 + #endif + options.dist = dist + options.add(inAppInclude: "RepoPrompt") + options.add(inAppInclude: "RepoPromptMCP") + options.add(inAppInclude: "RepoPromptShared") + options.sendDefaultPii = false + options.enableUncaughtNSExceptionReporting = true + options.enableAppHangTracking = true + options.enableMetricKit = false + options.enableLogs = false + options.maxBreadcrumbs = 50 + options.maxCacheItems = 30 + options.attachStacktrace = true + options.enableAutoBreadcrumbTracking = false + options.enableNetworkBreadcrumbs = false + options.enableNetworkTracking = false + options.enableFileIOTracing = false + options.enableCoreDataTracing = false + options.enableAutoPerformanceTracing = false + } + #endif + } + + struct TraceSpan { + #if REPOPROMPT_SENTRY_ENABLED + fileprivate let span: Span? + + fileprivate init(_ span: Span?) { + self.span = span + } + #else + fileprivate init() {} + #endif + } + + static func addBreadcrumb(_ category: Category, action: Action, attributes: @autoclosure () -> [Attribute] = []) { + #if REPOPROMPT_SENTRY_ENABLED + let payload = BreadcrumbPayload(category: category, action: action, attributes: attributes()) + + let breadcrumb = Breadcrumb() + breadcrumb.category = payload.category + breadcrumb.message = payload.message + breadcrumb.level = .info + if !payload.data.isEmpty { + breadcrumb.data = payload.data + } + SentrySDK.addBreadcrumb(breadcrumb) + #endif + } + + static func increment(_ metric: Metric, value: Int = 1, attributes: @autoclosure () -> [Attribute] = []) { + #if REPOPROMPT_SENTRY_ENABLED + guard value > 0 else { return } + SentrySDK.metrics.count( + key: metric.rawValue, + value: UInt(value), + attributes: sentryMetricAttributes(from: attributes()) + ) + #endif + } + + static func gauge(_ metric: Metric, value: Double, attributes: @autoclosure () -> [Attribute] = []) { + #if REPOPROMPT_SENTRY_ENABLED + SentrySDK.metrics.gauge( + key: metric.rawValue, + value: value, + unit: nil, + attributes: sentryMetricAttributes(from: attributes()) + ) + #endif + } + + static func distributionMilliseconds( + _ metric: Metric, + value: Double, + attributes: @autoclosure () -> [Attribute] = [] + ) { + #if REPOPROMPT_SENTRY_ENABLED + guard value >= 0 else { return } + SentrySDK.metrics.distribution( + key: metric.rawValue, + value: value, + unit: .millisecond, + attributes: sentryMetricAttributes(from: attributes()) + ) + #endif + } + + static func trace( + _ transaction: Transaction, + attributes: @autoclosure () -> [Attribute] = [], + _ work: () throws -> T + ) rethrows -> T { + #if REPOPROMPT_SENTRY_ENABLED + let sentryTransaction = SentrySDK.startTransaction( + name: transaction.name, + operation: transaction.operation, + bindToScope: false + ) + applyAttributes(attributes(), to: sentryTransaction) + do { + let result = try work() + sentryTransaction.finish(status: .ok) + return result + } catch { + sentryTransaction.setTag(value: "true", key: "is_error") + sentryTransaction.finish(status: .internalError) + throw error + } + #else + try work() + #endif + } + + static func traceAsync( + _ transaction: Transaction, + attributes: @autoclosure () -> [Attribute] = [], + _ work: () async throws -> T + ) async rethrows -> T { + #if REPOPROMPT_SENTRY_ENABLED + let sentryTransaction = SentrySDK.startTransaction( + name: transaction.name, + operation: transaction.operation, + bindToScope: false + ) + applyAttributes(attributes(), to: sentryTransaction) + do { + let result = try await work() + sentryTransaction.finish(status: .ok) + return result + } catch { + sentryTransaction.setTag(value: "true", key: "is_error") + sentryTransaction.finish(status: .internalError) + throw error + } + #else + try await work() + #endif + } + + static func traceAsync( + _ transaction: Transaction, + attributes: @autoclosure () -> [Attribute] = [], + _ work: (TraceSpan) async throws -> T + ) async rethrows -> T { + #if REPOPROMPT_SENTRY_ENABLED + let sentryTransaction = SentrySDK.startTransaction( + name: transaction.name, + operation: transaction.operation, + bindToScope: false + ) + applyAttributes(attributes(), to: sentryTransaction) + do { + let result = try await work(TraceSpan(sentryTransaction)) + sentryTransaction.finish(status: .ok) + return result + } catch { + sentryTransaction.setTag(value: "true", key: "is_error") + sentryTransaction.finish(status: .internalError) + throw error + } + #else + try await work(TraceSpan()) + #endif + } + + static func span( + _ operation: SpanOperation, + attributes: @autoclosure () -> [Attribute] = [], + _ work: () throws -> T + ) rethrows -> T { + #if REPOPROMPT_SENTRY_ENABLED + let sentryTransaction = SentrySDK.startTransaction( + name: operation.rawValue, + operation: operation.rawValue, + bindToScope: false + ) + applyAttributes(attributes(), to: sentryTransaction) + do { + let result = try work() + sentryTransaction.finish(status: .ok) + return result + } catch { + sentryTransaction.setTag(value: "true", key: "is_error") + sentryTransaction.finish(status: .internalError) + throw error + } + #else + try work() + #endif + } + + static func spanAsync( + _ operation: SpanOperation, + attributes: @autoclosure () -> [Attribute] = [], + _ work: (TraceSpan) async throws -> T + ) async rethrows -> T { + #if REPOPROMPT_SENTRY_ENABLED + let sentryTransaction = SentrySDK.startTransaction( + name: operation.rawValue, + operation: operation.rawValue, + bindToScope: false + ) + applyAttributes(attributes(), to: sentryTransaction) + do { + let result = try await work(TraceSpan(sentryTransaction)) + sentryTransaction.finish(status: .ok) + return result + } catch { + sentryTransaction.setTag(value: "true", key: "is_error") + sentryTransaction.finish(status: .internalError) + throw error + } + #else + try await work(TraceSpan()) + #endif + } + + static func childSpanAsync( + parent: TraceSpan, + operation: SpanOperation, + attributes: @autoclosure () -> [Attribute] = [], + _ work: () async throws -> T + ) async rethrows -> T { + #if REPOPROMPT_SENTRY_ENABLED + guard let parentSpan = parent.span else { + return try await work() + } + let child = parentSpan.startChild(operation: operation.rawValue) + applyAttributes(attributes(), to: child) + do { + let result = try await work() + child.finish(status: .ok) + return result + } catch { + child.setTag(value: "true", key: "is_error") + child.finish(status: .internalError) + throw error + } + #else + try await work() + #endif + } + + private struct BreadcrumbPayload { + let category: String + let message: String + let data: [String: String] + + init(category: Category, action: Action, attributes: [Attribute]) { + self.category = category.rawValue + message = action.rawValue + data = telemetryData(from: attributes + [.action(action)]) + } + } + + #if REPOPROMPT_SENTRY_ENABLED + private static func applyAttributes(_ attributes: [Attribute], to span: Span) { + for (key, value) in telemetryData(from: attributes) { + span.setTag(value: value, key: key) + } + } + + private static func sentryMetricAttributes(from attributes: [Attribute]) -> [String: SentryAttributeValue] { + telemetryData(from: attributes).reduce(into: [:]) { result, entry in + result[entry.key] = entry.value + } + } + #endif +} diff --git a/Sources/RepoPrompt/Infrastructure/Telemetry/SentryTelemetryModel.swift b/Sources/RepoPrompt/Infrastructure/Telemetry/SentryTelemetryModel.swift new file mode 100644 index 000000000..e14a65299 --- /dev/null +++ b/Sources/RepoPrompt/Infrastructure/Telemetry/SentryTelemetryModel.swift @@ -0,0 +1,438 @@ +import Foundation + +extension SentryTelemetryBootstrap { + enum Category: String, CaseIterable { + case agentMessage = "agent.message" + case agentRun = "agent.run" + case agentTool = "agent.tool" + case agentRuntime = "agent.runtime" + case appLifecycle = "app.lifecycle" + case contextBuilderAction = "context_builder.action" + case mcpBootstrap = "mcp.bootstrap" + case mcpTool = "mcp.tool" + case persistenceAction = "persistence.action" + case workspaceAction = "workspace.action" + case workspaceTool = "workspace.tool" + } + + enum Action: String, CaseIterable { + case agentMessageObserved = "agent.message.observed" + case agentRunCancelled = "agent.run.cancelled" + case agentRunCompleted = "agent.run.completed" + case agentRunFailed = "agent.run.failed" + case agentRunStarted = "agent.run.started" + case agentProviderError = "agent.provider.error" + case agentRuntimeRecoveryFailed = "agent.runtime.recovery.failed" + case agentRuntimeRecoveryRecovered = "agent.runtime.recovery.recovered" + case agentRuntimeRecoverySkipped = "agent.runtime.recovery.skipped" + case agentRuntimeRecoveryStarted = "agent.runtime.recovery.started" + case agentRuntimeStallProbeTriggered = "agent.runtime.stall_probe.triggered" + case agentRuntimeStallWarningShown = "agent.runtime.stall_warning.shown" + case agentRuntimeTransportClosed = "agent.runtime.transport_closed" + case agentToolCompleted = "agent.tool.completed" + case agentToolFailed = "agent.tool.failed" + case agentToolStarted = "agent.tool.started" + case appInitialized = "app.initialized" + case contextBuilderCompleted = "context_builder.completed" + case contextBuilderFailed = "context_builder.failed" + case contextBuilderStarted = "context_builder.started" + case mcpBootstrapAccepted = "mcp.bootstrap.accepted" + case mcpBootstrapRejected = "mcp.bootstrap.rejected" + case mcpServerStarted = "mcp.server.started" + case mcpToolCancelled = "mcp.tool.cancelled" + case mcpToolCompleted = "mcp.tool.completed" + case mcpToolFailed = "mcp.tool.failed" + case mcpToolStarted = "mcp.tool.started" + case mcpToolTimedOut = "mcp.tool.timed_out" + case persistenceCompleted = "persistence.completed" + case persistenceFailed = "persistence.failed" + case persistenceScheduled = "persistence.scheduled" + case workspaceActionCompleted = "workspace.action.completed" + case workspaceActionFailed = "workspace.action.failed" + case workspaceActionStarted = "workspace.action.started" + } + + enum Transaction: CaseIterable { + case agentRun + case appLaunch + case contextBuilderRun + case mcpBootstrapAdmission + case mcpServerStart + case mcpToolCall + case workspaceAction + + var name: String { + switch self { + case .agentRun: "agent.run" + case .appLaunch: "app.launch" + case .contextBuilderRun: "context_builder.run" + case .mcpBootstrapAdmission: "mcp.bootstrap.admission" + case .mcpServerStart: "app.mcp_server.start" + case .mcpToolCall: "mcp.tool.call" + case .workspaceAction: "workspace.action" + } + } + + var operation: String { + switch self { + case .agentRun: "agent.run" + case .appLaunch, .mcpServerStart: "app.startup" + case .contextBuilderRun: "context_builder.run" + case .mcpBootstrapAdmission: "mcp.bootstrap" + case .mcpToolCall: "mcp.tool.call" + case .workspaceAction: "workspace.action" + } + } + + static var allowedOperations: Set { + Set(allCases.map(\.operation) + SpanOperation.allCases.map(\.rawValue)) + } + } + + enum Metric: String, CaseIterable { + case agentProviderErrors = "agent.provider.errors" + case agentRunActive = "agent.run.active" + case agentRunDuration = "agent.run.duration" + case agentRunSessionStarts = "agent.run.session.starts" + case agentRuntimeEvents = "agent.runtime.events" + case mcpExternalSessionStarts = "mcp.external.session.starts" + } + + enum SpanOperation: String, CaseIterable { + case agentProviderFirstEvent = "agent.provider.first_event" + case agentSessionPersist = "agent.session.persist" + case agentSessionPrepare = "agent.session.prepare" + case agentTerminalCommit = "agent.terminal_commit" + case codeMapStructure = "codemap.structure" + case contextBuilderDiscovery = "context_builder.discovery" + case contextBuilderExport = "context_builder.export" + case contextBuilderOracleResponse = "context_builder.oracle.response" + case contextBuilderSelectionCommit = "context_builder.selection.commit" + case fileEditApply = "file.edit.apply" + case fileRead = "file.read" + case mcpArgumentsNormalize = "mcp.args.normalize" + case mcpDispatch = "mcp.dispatch" + case mcpPolicyCheck = "mcp.policy.check" + case mcpResultFormat = "mcp.result.format" + case mcpWatchdogWait = "mcp.watchdog.wait" + case promptRender = "prompt.render" + case selectionUpdate = "selection.update" + case workspaceSearch = "workspace.search" + } + + enum ApprovalKind: String { + case applyEdits = "apply_edits" + case commandExecution = "command_execution" + case fileChange = "file_change" + case toolPermission = "tool_permission" + case worktreeMerge = "worktree_merge" + } + + enum ApprovalOutcome: String { case approved, denied } + enum CancellationReason: String { case superseded, timeout, user } + enum ClientClass: String { case externalAgent = "external_agent", inApp = "in_app", unknown } + enum Entrypoint: String { case agent, app, cli, mcp, user } + enum ErrorKind: String { case cancelled, error, timeout } + enum MessageRole: String { case assistant, system, tool, user } + + enum ProviderErrorKind: String { + case apiError = "api_error" + case authRequired = "auth_required" + case cancelled + case executableUnavailable = "executable_unavailable" + case invalidConfiguration = "invalid_configuration" + case invalidResponse = "invalid_response" + case missingCredential = "missing_credential" + case missingProviderURL = "missing_provider_url" + case providerNotConfigured = "provider_not_configured" + case streamEndedUnexpectedly = "stream_ended_unexpectedly" + case timeout + case transportClosed = "transport_closed" + case unknown + } + + enum RuntimeEvent: String { + case codexRecoveryFailed = "codex_recovery_failed" + case codexRecoveryRecovered = "codex_recovery_recovered" + case codexRecoverySkipped = "codex_recovery_skipped" + case codexRecoveryStarted = "codex_recovery_started" + case codexStallProbeTriggered = "codex_stall_probe_triggered" + case codexStallWarningShown = "codex_stall_warning_shown" + case codexTransportClosed = "codex_transport_closed" + } + + enum ToolDomain: String { case agent, app, context, file, git, mcp, prompt, search, selection, workspace } + enum WorkspaceAction: String { case create, delete, folder, hide, switchWorkspace = "switch", tab } + + enum ModelFamily: String { + case claude + case codex + case cursor + case customClaudeCompatible = "custom_claude_compatible" + case defaultModel = "default" + case fable + case glm + case gpt + case gpt52 = "gpt_5_2" + case gpt53Codex = "gpt_5_3_codex" + case gpt54 = "gpt_5_4" + case gpt55 = "gpt_5_5" + case gptMini = "gpt_mini" + case haiku + case kimi + case openCode = "opencode" + case opus + case sonnet + } + + enum ProviderKind: String { + case claudeCode = "claude_code" + case claudeCodeGLM = "claude_code_glm" + case codexExec = "codex_exec" + case cursor + case customClaudeCompatible = "custom_claude_compatible" + case kimiCode = "kimi_code" + case openCode = "opencode" + + init(agentKind: AgentProviderKind) { + switch agentKind { + case .claudeCode: + self = .claudeCode + case .codexExec: + self = .codexExec + case .openCode: + self = .openCode + case .cursor: + self = .cursor + case .claudeCodeGLM: + self = .claudeCodeGLM + case .kimiCode: + self = .kimiCode + case .customClaudeCompatible: + self = .customClaudeCompatible + } + } + + init?(agentKindRaw: String) { + guard let agentKind = AgentProviderKind(rawValue: agentKindRaw) else { return nil } + self.init(agentKind: agentKind) + } + } + + enum ToolName: String { + case agentManage = "agent_manage" + case agentRun = "agent_run" + case appSettings = "app_settings" + case applyEdits = "apply_edits" + case askOracle = "ask_oracle" + case askUser = "ask_user" + case bindContext = "bind_context" + case contextBuilder = "context_builder" + case fileActions = "file_actions" + case fileSearch = "file_search" + case getCodeStructure = "get_code_structure" + case getFileTree = "get_file_tree" + case git + case manageSelection = "manage_selection" + case manageWorkspaces = "manage_workspaces" + case manageWorktree = "manage_worktree" + case oracleChatLog = "oracle_chat_log" + case oracleSend = "oracle_send" + case oracleUtils = "oracle_utils" + case prompt + case readFile = "read_file" + case setStatus = "set_status" + case shareThoughts = "share_thoughts" + case uploadFile = "upload_file" + case waitForNextInstruction = "wait_for_next_user_instruction" + case workspaceContext = "workspace_context" + + init?(rawToolName: String) { + self.init(rawValue: rawToolName) + } + + var domain: ToolDomain { + switch self { + case .agentManage, .agentRun: + .agent + case .appSettings, .askUser, .bindContext, .oracleChatLog, .oracleSend, .oracleUtils, .setStatus, + .shareThoughts, .waitForNextInstruction: + .mcp + case .applyEdits, .fileActions, .readFile, .uploadFile: + .file + case .fileSearch: + .search + case .contextBuilder, .getCodeStructure, .getFileTree, .workspaceContext: + .context + case .git, .manageWorktree: + .git + case .askOracle, .prompt: + .prompt + case .manageSelection: + .selection + case .manageWorkspaces: + .workspace + } + } + } + + enum ContextBuilderPhase: String { + case discovery + case export + case oracleResponse = "oracle_response" + case selectionCommit = "selection_commit" + } + + enum Outcome: String { + case accepted + case cancelled + case completed + case failed + case rejected + case started + case timedOut = "timed_out" + } + + enum TokenBudgetBucket: String { + case under25K = "under_25k" + case k25To75 = "25k_75k" + case k75To150 = "75k_150k" + case over150K = "over_150k" + case unknown + } + + enum Attribute { + case action(Action) + case activeHandshakes(Int) + case attachmentCount(Int) + case approvalKind(ApprovalKind) + case approvalOutcome(ApprovalOutcome) + case cacheHit(Bool) + case cancellationReason(CancellationReason) + case clientClass(ClientClass) + case contextBuilderPhase(ContextBuilderPhase) + case entrypoint(Entrypoint) + case hasProviderResumeSession(Bool) + case errorKind(ErrorKind) + case isChildSession(Bool) + case isError(Bool) + case limitHit(Bool) + case messageCount(Int) + case messageRole(MessageRole) + case modelFamily(ModelFamily) + case outcome(Outcome) + case protocolVersion(Int) + case providerErrorKind(ProviderErrorKind) + case providerKind(ProviderKind) + case resultCount(Int) + case runtimeEvent(RuntimeEvent) + case selectionFileCount(Int) + case tokenBudgetBucket(TokenBudgetBucket) + case toolCallCount(Int) + case toolDomain(ToolDomain) + case toolName(ToolName) + case workspaceAction(WorkspaceAction) + + static let allowedKeys: Set = [ + "action", "active_handshakes", "approval_kind", "approval_outcome", "attachment_count", "cache_hit", + "cancellation_reason", "client_class", "context_builder_phase", "entrypoint", + "error_kind", "has_provider_resume_session", "is_child_session", "is_error", + "limit_hit", "message_count", "message_role", "model_family", "outcome", + "protocol_version", "provider_error_kind", "provider_kind", "result_count", "runtime_event", + "selection_file_count", "token_budget_bucket", "tool_call_count", "tool_domain", "tool_name", + "workspace_action" + ] + + var key: String { + switch self { + case .action: "action" + case .activeHandshakes: "active_handshakes" + case .attachmentCount: "attachment_count" + case .approvalKind: "approval_kind" + case .approvalOutcome: "approval_outcome" + case .cacheHit: "cache_hit" + case .cancellationReason: "cancellation_reason" + case .clientClass: "client_class" + case .contextBuilderPhase: "context_builder_phase" + case .entrypoint: "entrypoint" + case .hasProviderResumeSession: "has_provider_resume_session" + case .errorKind: "error_kind" + case .isChildSession: "is_child_session" + case .isError: "is_error" + case .limitHit: "limit_hit" + case .messageCount: "message_count" + case .messageRole: "message_role" + case .modelFamily: "model_family" + case .outcome: "outcome" + case .protocolVersion: "protocol_version" + case .providerErrorKind: "provider_error_kind" + case .providerKind: "provider_kind" + case .resultCount: "result_count" + case .runtimeEvent: "runtime_event" + case .selectionFileCount: "selection_file_count" + case .tokenBudgetBucket: "token_budget_bucket" + case .toolCallCount: "tool_call_count" + case .toolDomain: "tool_domain" + case .toolName: "tool_name" + case .workspaceAction: "workspace_action" + } + } + + var value: String? { + switch self { + case let .action(action): action.rawValue + case let .activeHandshakes(count): SentryTelemetryValue.formatCount(count) + case let .attachmentCount(count): SentryTelemetryValue.formatCount(count) + case let .approvalKind(kind): kind.rawValue + case let .approvalOutcome(outcome): outcome.rawValue + case let .cacheHit(hit): SentryTelemetryValue.formatBool(hit) + case let .cancellationReason(reason): reason.rawValue + case let .clientClass(clientClass): clientClass.rawValue + case let .contextBuilderPhase(phase): phase.rawValue + case let .entrypoint(entrypoint): entrypoint.rawValue + case let .hasProviderResumeSession(hasSession): SentryTelemetryValue.formatBool(hasSession) + case let .errorKind(kind): kind.rawValue + case let .isChildSession(isChildSession): SentryTelemetryValue.formatBool(isChildSession) + case let .isError(isError): SentryTelemetryValue.formatBool(isError) + case let .limitHit(hit): SentryTelemetryValue.formatBool(hit) + case let .messageCount(count): SentryTelemetryValue.formatCount(count) + case let .messageRole(role): role.rawValue + case let .modelFamily(family): family.rawValue + case let .outcome(outcome): outcome.rawValue + case let .protocolVersion(version): SentryTelemetryValue.formatCount(version) + case let .providerErrorKind(kind): kind.rawValue + case let .providerKind(kind): kind.rawValue + case let .resultCount(count): SentryTelemetryValue.formatCount(count) + case let .runtimeEvent(event): event.rawValue + case let .selectionFileCount(count): SentryTelemetryValue.formatCount(count) + case let .tokenBudgetBucket(bucket): bucket.rawValue + case let .toolCallCount(count): SentryTelemetryValue.formatCount(count) + case let .toolDomain(domain): domain.rawValue + case let .toolName(name): name.rawValue + case let .workspaceAction(action): action.rawValue + } + } + } + + static func telemetryData(from attributes: [Attribute]) -> [String: String] { + var data: [String: String] = [:] + for attribute in attributes { + guard let value = attribute.value else { continue } + data[attribute.key] = value + } + return data + } +} + +enum SentryTelemetryValue { + private static let maxCount = 999_999 + + static func formatBool(_ value: Bool) -> String { + value ? "true" : "false" + } + + static func formatCount(_ value: Int) -> String? { + guard value >= 0 else { return nil } + return String(min(value, maxCount)) + } +} From c38e4406e63a9fee79f0e19e7ecf41c4174d8d32 Mon Sep 17 00:00:00 2001 From: Cameron Cooke Date: Mon, 29 Jun 2026 21:50:04 +0100 Subject: [PATCH 03/12] feat(telemetry): start telemetry and trace app launch Refs GH-183 --- Sources/RepoPrompt/App/RepoPromptApp.swift | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/Sources/RepoPrompt/App/RepoPromptApp.swift b/Sources/RepoPrompt/App/RepoPromptApp.swift index cbb15375c..3dac4714e 100644 --- a/Sources/RepoPrompt/App/RepoPromptApp.swift +++ b/Sources/RepoPrompt/App/RepoPromptApp.swift @@ -69,6 +69,13 @@ struct RepoPromptApp: App { // Avoid process-killing SIGPIPE when the child closes stdin while we're still writing. signal(SIGPIPE, SIG_IGN) + #if REPOPROMPT_SENTRY_ENABLED + SentryTelemetryBootstrap.start() + SentryTelemetryBootstrap.trace(.appLaunch) { + SentryTelemetryBootstrap.addBreadcrumb(.appLifecycle, action: .appInitialized) + } + #endif + ProcessDebugLogging.log( prefix: "MCPStartup", "RepoPromptApp.init scheduling ServerNetworkManager.start", @@ -80,7 +87,14 @@ struct RepoPromptApp: App { "RepoPromptApp.init start task running", flushStdout: true ) - await ServerController.shared.startServer() + #if REPOPROMPT_SENTRY_ENABLED + await SentryTelemetryBootstrap.traceAsync(.mcpServerStart) { + await ServerController.shared.startServer() + SentryTelemetryBootstrap.addBreadcrumb(.mcpBootstrap, action: .mcpServerStarted) + } + #else + await ServerController.shared.startServer() + #endif } if !AppLaunchConfiguration.current.suppressesWindowRestore { From e33fac7fa61332b28825306d0e7e5e809d4f69ce Mon Sep 17 00:00:00 2001 From: Cameron Cooke Date: Mon, 29 Jun 2026 21:50:05 +0100 Subject: [PATCH 04/12] feat(telemetry): instrument MCP tool dispatch and bootstrap admission Refs GH-183 --- .../MCP/BootstrapSocketServer.swift | 32 +- .../MCP/MCPConnectionManager.swift | 305 +++++++++++------- .../MCP/MCPToolSentryTelemetry.swift | 93 ++++++ .../MCP/ViewModels/MCPServerViewModel.swift | 18 +- .../UI/Components/MCPServerToggleView.swift | 6 +- 5 files changed, 331 insertions(+), 123 deletions(-) create mode 100644 Sources/RepoPrompt/Infrastructure/MCP/MCPToolSentryTelemetry.swift diff --git a/Sources/RepoPrompt/Infrastructure/MCP/BootstrapSocketServer.swift b/Sources/RepoPrompt/Infrastructure/MCP/BootstrapSocketServer.swift index dc9174ae1..6966971a2 100644 --- a/Sources/RepoPrompt/Infrastructure/MCP/BootstrapSocketServer.swift +++ b/Sources/RepoPrompt/Infrastructure/MCP/BootstrapSocketServer.swift @@ -795,7 +795,19 @@ actor BootstrapSocketServer { EditFlowPerf.Stage.Bootstrap.admission, EditFlowPerf.Dimensions(activeCount: inFlightHandshakeSockets.count) ) - let admission = await handler(clientFD, request.sessionToken, effectivePid, request.clientName) + #if REPOPROMPT_SENTRY_ENABLED + let admission = await SentryTelemetryBootstrap.traceAsync( + .mcpBootstrapAdmission, + attributes: [ + .protocolVersion(request.protocolVersion), + .activeHandshakes(inFlightHandshakeSockets.count) + ] + ) { + await handler(clientFD, request.sessionToken, effectivePid, request.clientName) + } + #else + let admission = await handler(clientFD, request.sessionToken, effectivePid, request.clientName) + #endif EditFlowPerf.end( EditFlowPerf.Stage.Bootstrap.admission, admissionState, @@ -813,6 +825,24 @@ actor BootstrapSocketServer { ) ) bootstrapSocketServerLog("BootstrapSocketServer: handler returned accepted=\(admission.accepted) for '\(request.clientName ?? "unknown")'") + #if REPOPROMPT_SENTRY_ENABLED + let sentryAdmissionAttributes: [SentryTelemetryBootstrap.Attribute] = [ + .entrypoint(.mcp), + .clientClass(.externalAgent), + .outcome(admission.accepted ? .accepted : .rejected), + .protocolVersion(request.protocolVersion), + .activeHandshakes(inFlightHandshakeSockets.count) + ] + SentryTelemetryBootstrap.addBreadcrumb( + .mcpBootstrap, + action: admission.accepted ? .mcpBootstrapAccepted : .mcpBootstrapRejected, + attributes: sentryAdmissionAttributes + ) + SentryTelemetryBootstrap.increment( + .mcpExternalSessionStarts, + attributes: sentryAdmissionAttributes + ) + #endif guard isActiveHandshake(handshakeSocket, generation: generation) else { await abortAcceptedAdmissionIfNeeded(admission) diff --git a/Sources/RepoPrompt/Infrastructure/MCP/MCPConnectionManager.swift b/Sources/RepoPrompt/Infrastructure/MCP/MCPConnectionManager.swift index ef77229a2..93e4ded23 100644 --- a/Sources/RepoPrompt/Infrastructure/MCP/MCPConnectionManager.swift +++ b/Sources/RepoPrompt/Infrastructure/MCP/MCPConnectionManager.swift @@ -2071,6 +2071,7 @@ actor ServerNetworkManager { } // ------------------------------------------------------------------ + // MARK: Tool ownership tracking helpers /// ------------------------------------------------------------------ @@ -2358,6 +2359,7 @@ actor ServerNetworkManager { } // ------------------------------------------------------------------ + // MARK: Window-selection helpers (called from WindowRoutingService) /// ------------------------------------------------------------------ @@ -6099,10 +6101,11 @@ actor ServerNetworkManager { return true } - /// Reads the cached TCP client name from all CLI instance cache files. - /// (Legacy TCP transport has been removed; this helper now returns nil.) - /// - Parameter remotePort: The remote port from the incoming connection for precise matching - /// - Returns: Always nil now that TCP transport and cache files are deprecated + // Reads the cached TCP client name from all CLI instance cache files. + // (Legacy TCP transport has been removed; this helper now returns nil.) + // - Parameter remotePort: The remote port from the incoming connection for precise matching + // - Returns: Always nil now that TCP transport and cache files are deprecated + // MARK: - Identity Failure Recording & Escalation /// Transport type for identity failure tracking @@ -10445,15 +10448,24 @@ actor ServerNetworkManager { // Normalize arguments using shared module connectionLog("tools/call \(toolName): normalizing args") - let normalized = EditFlowPerf.measure( - EditFlowPerf.Stage.MCPToolCall.normalizeArgs, - EditFlowPerf.Dimensions(toolName: toolName) - ) { - MCPToolArgsNormalizer.normalize( - params: params.arguments, - originalToolName: originalName, - canonicalToolName: toolName + let normalized = SentryTelemetryBootstrap.span( + .mcpArgumentsNormalize, + attributes: MCPToolSentryTelemetry.attributes( + toolName: toolName, + outcome: .started, + isError: false ) + ) { + EditFlowPerf.measure( + EditFlowPerf.Stage.MCPToolCall.normalizeArgs, + EditFlowPerf.Dimensions(toolName: toolName) + ) { + MCPToolArgsNormalizer.normalize( + params: params.arguments, + originalToolName: originalName, + canonicalToolName: toolName + ) + } } // Log any warnings from normalization @@ -10474,8 +10486,8 @@ actor ServerNetworkManager { // Do not repeat it on each tools/call; it can re-enter routing notifications // while the call is waiting for a response. - var dispatchTabContextHint: MCPServerViewModel.TabContextHint? = nil - var preResolvedWindowID: Int? = nil + var dispatchTabContextHint: MCPServerViewModel.TabContextHint? + var preResolvedWindowID: Int? do { let logicalContextState = EditFlowPerf.begin( EditFlowPerf.Stage.MCPToolCall.logicalContextResolution, @@ -10581,11 +10593,20 @@ actor ServerNetworkManager { } connectionLog("tools/call \(toolName): computing effective policy") - let policy = await EditFlowPerf.measure( - EditFlowPerf.Stage.MCPToolCall.effectivePolicySnapshot, - EditFlowPerf.Dimensions(toolName: toolName) - ) { - await self.effectivePolicyState(for: connectionID) + let policy = await SentryTelemetryBootstrap.spanAsync( + .mcpPolicyCheck, + attributes: MCPToolSentryTelemetry.attributes( + toolName: toolName, + outcome: .started, + isError: false + ) + ) { _ in + await EditFlowPerf.measure( + EditFlowPerf.Stage.MCPToolCall.effectivePolicySnapshot, + EditFlowPerf.Dimensions(toolName: toolName) + ) { + await self.effectivePolicyState(for: connectionID) + } } connectionLog("tools/call \(toolName): policy ready") do { @@ -10859,7 +10880,10 @@ actor ServerNetworkManager { } else { connectedDuringSingleWindow = windowCount == 1 } - if !bypassWindowRouting && chosenID == nil && (!multiWindowModeEffective || connectedDuringSingleWindow) { + let shouldAutoRouteToActiveWindow = !bypassWindowRouting + && chosenID == nil + && (!multiWindowModeEffective || connectedDuringSingleWindow) + if shouldAutoRouteToActiveWindow { // Find the window with active MCP tools let activeWindowID = await WindowStatesManager.shared.firstMCPEnabledWindow()?.windowID if let activeID = activeWindowID { @@ -11233,110 +11257,138 @@ actor ServerNetworkManager { ) let tracedOperation: @Sendable () async throws -> Value = { - do { - guard await self.isCurrentConnectionCallLimiterResolution( - limiterResolution, - connectionID: connectionID - ) else { - throw ToolDispatchAdmissionError.connectionTerminal - } - if let authorization = Self.currentToolDispatchAuthorization { - guard await self.isCurrentToolDispatchAuthorization(authorization) else { - throw ToolDispatchAdmissionError.connectionTerminal - } - if let windowIdentity = authorization.windowIdentity, - await !(self.isCurrentWindowToolDispatchIdentity(windowIdentity)) - { - throw ToolDispatchAdmissionError.windowTerminal - } - } - let value = try await MCPToolExecutionHandlerPhaseContext.$recorder.withValue(handlerPhaseRecorder) { - try await EditFlowPerf.measure( - EditFlowPerf.Stage.MCPToolCall.resolvedProviderDispatch, - EditFlowPerf.Dimensions(toolName: toolName), - operation: operation - ) - } - await emitExecutionTrace(.handlerCompleted, cancellationOutcome: "success") - EditFlowPerf.lifecycleEvent( - EditFlowPerf.Lifecycle.MCPToolCall.resolvedProviderEnded, - correlation: lifecycleCorrelation, - EditFlowPerf.Dimensions(toolName: toolName, outcome: "success") - ) - EditFlowPerf.lifecycleEvent( - EditFlowPerf.Lifecycle.MCPToolCall.publicationOwnershipState, - correlation: lifecycleCorrelation, - EditFlowPerf.Dimensions( - toolName: toolName, - outcome: "provider_completed", - windowID: Self.currentToolDispatchAuthorization?.windowIdentity?.windowID, - runID: observerRunIDForCallbacksFinal?.uuidString, - providerActive: false, - networkScopeActive: Self.currentToolDispatchAuthorization?.windowIdentity != nil, - permitActive: true, - publicationPending: true, - terminalBarrier: false - ) - ) - return value - } catch { - let outcome = MCPToolExecutionCancelledError.matches(error) ? "cancelled" : "error" - await emitExecutionTrace(.handlerCompleted, cancellationOutcome: outcome) - EditFlowPerf.lifecycleEvent( - EditFlowPerf.Lifecycle.MCPToolCall.resolvedProviderEnded, - correlation: lifecycleCorrelation, - EditFlowPerf.Dimensions(toolName: toolName, outcome: outcome) + try await SentryTelemetryBootstrap.traceAsync( + .mcpToolCall, + attributes: MCPToolSentryTelemetry.attributes( + toolName: toolName, + outcome: .started, + isError: false ) - EditFlowPerf.lifecycleEvent( - EditFlowPerf.Lifecycle.MCPToolCall.publicationOwnershipState, - correlation: lifecycleCorrelation, - EditFlowPerf.Dimensions( + ) { parentSpan in + try await SentryTelemetryBootstrap.childSpanAsync( + parent: parentSpan, + operation: .mcpDispatch, + attributes: MCPToolSentryTelemetry.attributes( toolName: toolName, - outcome: outcome, - windowID: Self.currentToolDispatchAuthorization?.windowIdentity?.windowID, - runID: observerRunIDForCallbacksFinal?.uuidString, - providerActive: false, - networkScopeActive: Self.currentToolDispatchAuthorization?.windowIdentity != nil, - permitActive: true, - publicationPending: true, - terminalBarrier: false + outcome: .started, + isError: false ) - ) - throw error + ) { + do { + guard await self.isCurrentConnectionCallLimiterResolution( + limiterResolution, + connectionID: connectionID + ) else { + throw ToolDispatchAdmissionError.connectionTerminal + } + if let authorization = Self.currentToolDispatchAuthorization { + guard await self.isCurrentToolDispatchAuthorization(authorization) else { + throw ToolDispatchAdmissionError.connectionTerminal + } + if let windowIdentity = authorization.windowIdentity, + await !(self.isCurrentWindowToolDispatchIdentity(windowIdentity)) + { + throw ToolDispatchAdmissionError.windowTerminal + } + } + let value = try await MCPToolExecutionHandlerPhaseContext.$recorder.withValue(handlerPhaseRecorder) { + try await EditFlowPerf.measure( + EditFlowPerf.Stage.MCPToolCall.resolvedProviderDispatch, + EditFlowPerf.Dimensions(toolName: toolName), + operation: operation + ) + } + await emitExecutionTrace(.handlerCompleted, cancellationOutcome: "success") + EditFlowPerf.lifecycleEvent( + EditFlowPerf.Lifecycle.MCPToolCall.resolvedProviderEnded, + correlation: lifecycleCorrelation, + EditFlowPerf.Dimensions(toolName: toolName, outcome: "success") + ) + EditFlowPerf.lifecycleEvent( + EditFlowPerf.Lifecycle.MCPToolCall.publicationOwnershipState, + correlation: lifecycleCorrelation, + EditFlowPerf.Dimensions( + toolName: toolName, + outcome: "provider_completed", + windowID: Self.currentToolDispatchAuthorization?.windowIdentity?.windowID, + runID: observerRunIDForCallbacksFinal?.uuidString, + providerActive: false, + networkScopeActive: Self.currentToolDispatchAuthorization?.windowIdentity != nil, + permitActive: true, + publicationPending: true, + terminalBarrier: false + ) + ) + return value + } catch { + let outcome = MCPToolExecutionCancelledError.matches(error) ? "cancelled" : "error" + await emitExecutionTrace(.handlerCompleted, cancellationOutcome: outcome) + EditFlowPerf.lifecycleEvent( + EditFlowPerf.Lifecycle.MCPToolCall.resolvedProviderEnded, + correlation: lifecycleCorrelation, + EditFlowPerf.Dimensions(toolName: toolName, outcome: outcome) + ) + EditFlowPerf.lifecycleEvent( + EditFlowPerf.Lifecycle.MCPToolCall.publicationOwnershipState, + correlation: lifecycleCorrelation, + EditFlowPerf.Dimensions( + toolName: toolName, + outcome: outcome, + windowID: Self.currentToolDispatchAuthorization?.windowIdentity?.windowID, + runID: observerRunIDForCallbacksFinal?.uuidString, + providerActive: false, + networkScopeActive: Self.currentToolDispatchAuthorization?.windowIdentity != nil, + permitActive: true, + publicationPending: true, + terminalBarrier: false + ) + ) + throw error + } + } } } switch contract { case let .bounded(deadline, cancellationGrace): do { - return try await MCPToolExecutionWatchdog.execute( - deadline: deadline, - cancellationGrace: cancellationGrace, - environment: executionWatchdogEnvironment, - onEvent: { event in - switch event { - case .deadlineExpired: - await emitExecutionTrace(.deadlineExpired) - case .cancellationRequested: - await emitExecutionTrace(.cancellationRequested, cancellationRequested: true) - case let .settledDuringGrace(settlement): - await emitExecutionTrace( - .settledDuringGrace, - cancellationRequested: true, - cancellationOutcome: settlement.rawValue, - graceOutcome: "settled" - ) - case .cleanupGraceExpired: - await emitExecutionTrace( - .cleanupGraceExpired, - cancellationRequested: true, - graceOutcome: "expired", - escalationReason: "handler_ignored_cancellation" - ) - } - }, - operation: tracedOperation - ) + return try await SentryTelemetryBootstrap.spanAsync( + .mcpWatchdogWait, + attributes: MCPToolSentryTelemetry.attributes( + toolName: toolName, + outcome: .started, + isError: false + ) + ) { _ in + try await MCPToolExecutionWatchdog.execute( + deadline: deadline, + cancellationGrace: cancellationGrace, + environment: executionWatchdogEnvironment, + onEvent: { event in + switch event { + case .deadlineExpired: + await emitExecutionTrace(.deadlineExpired) + case .cancellationRequested: + await emitExecutionTrace(.cancellationRequested, cancellationRequested: true) + case let .settledDuringGrace(settlement): + await emitExecutionTrace( + .settledDuringGrace, + cancellationRequested: true, + cancellationOutcome: settlement.rawValue, + graceOutcome: "settled" + ) + case .cleanupGraceExpired: + await emitExecutionTrace( + .cleanupGraceExpired, + cancellationRequested: true, + graceOutcome: "expired", + escalationReason: "handler_ignored_cancellation" + ) + } + }, + operation: tracedOperation + ) + } } catch MCPToolExecutionWatchdogError.cleanupUnresponsive { await emitExecutionTrace( .connectionForceDisconnectRequested, @@ -11377,11 +11429,20 @@ actor ServerNetworkManager { publicationPending: true, terminalBarrier: false )) - return EditFlowPerf.measure( - EditFlowPerf.Stage.MCPToolCall.handlerResultHandoff, - EditFlowPerf.Dimensions(toolName: toolName, outcome: outcome) + return SentryTelemetryBootstrap.span( + .mcpResultFormat, + attributes: MCPToolSentryTelemetry.attributes( + toolName: toolName, + outcome: .completed, + isError: false + ) ) { - result + EditFlowPerf.measure( + EditFlowPerf.Stage.MCPToolCall.handlerResultHandoff, + EditFlowPerf.Dimensions(toolName: toolName, outcome: outcome) + ) { + result + } } } @@ -11433,6 +11494,10 @@ actor ServerNetworkManager { return nil } + if code == "tool_execution_timeout" || code == "tool_execution_cleanup_unresponsive" { + MCPToolSentryTelemetry.recordTimedOut(toolName: toolName) + } + log.error("MCP execution contract failure tool=\(toolName) context=\(context) code=\(code)") let errorJSON = ToolOutputFormatter.rawJSONString(.object([ "code": .string(code), @@ -11960,7 +12025,7 @@ actor ServerNetworkManager { let pendingName = pendingConnections[id] let clientName = admittedName ?? pendingName ?? "Connecting..." - if admittedName == nil && pendingName == nil { + if admittedName == nil, pendingName == nil { log.warning("Dashboard: Connection \(id) has no client name (admitted=nil, pending=nil)") } diff --git a/Sources/RepoPrompt/Infrastructure/MCP/MCPToolSentryTelemetry.swift b/Sources/RepoPrompt/Infrastructure/MCP/MCPToolSentryTelemetry.swift new file mode 100644 index 000000000..a377612f4 --- /dev/null +++ b/Sources/RepoPrompt/Infrastructure/MCP/MCPToolSentryTelemetry.swift @@ -0,0 +1,93 @@ +import Foundation + +enum MCPToolSentryTelemetry { + static func recordStarted(toolName: String) { + SentryTelemetryBootstrap.addBreadcrumb( + .mcpTool, + action: .mcpToolStarted, + attributes: attributes( + toolName: toolName, + outcome: .started, + isError: false + ) + ) + } + + static func recordCompleted(toolName: String) { + SentryTelemetryBootstrap.addBreadcrumb( + .mcpTool, + action: .mcpToolCompleted, + attributes: attributes( + toolName: toolName, + outcome: .completed, + isError: false + ) + ) + } + + static func recordCancelled(toolName: String) { + SentryTelemetryBootstrap.addBreadcrumb( + .mcpTool, + action: .mcpToolCancelled, + attributes: attributes( + toolName: toolName, + outcome: .cancelled, + isError: true, + errorKind: .cancelled + ) + ) + } + + static func recordFailed( + toolName: String, + errorKind: SentryTelemetryBootstrap.ErrorKind = .error + ) { + SentryTelemetryBootstrap.addBreadcrumb( + .mcpTool, + action: .mcpToolFailed, + attributes: attributes( + toolName: toolName, + outcome: .failed, + isError: true, + errorKind: errorKind + ) + ) + } + + static func recordTimedOut(toolName: String) { + SentryTelemetryBootstrap.addBreadcrumb( + .mcpTool, + action: .mcpToolTimedOut, + attributes: attributes( + toolName: toolName, + outcome: .timedOut, + isError: true, + errorKind: .timeout + ) + ) + } + + static func attributes( + toolName: String, + outcome: SentryTelemetryBootstrap.Outcome, + isError: Bool, + errorKind: SentryTelemetryBootstrap.ErrorKind? = nil + ) -> [SentryTelemetryBootstrap.Attribute] { + var attributes: [SentryTelemetryBootstrap.Attribute] = [ + .entrypoint(.mcp), + .clientClass(.externalAgent), + .outcome(outcome), + .isError(isError) + ] + if let knownToolName = SentryTelemetryBootstrap.ToolName(rawToolName: toolName) { + attributes.append(.toolName(knownToolName)) + attributes.append(.toolDomain(knownToolName.domain)) + } else { + attributes.append(.toolDomain(.mcp)) + } + if let errorKind { + attributes.append(.errorKind(errorKind)) + } + return attributes + } +} diff --git a/Sources/RepoPrompt/Infrastructure/MCP/ViewModels/MCPServerViewModel.swift b/Sources/RepoPrompt/Infrastructure/MCP/ViewModels/MCPServerViewModel.swift index 09b78d3a5..042a1f540 100644 --- a/Sources/RepoPrompt/Infrastructure/MCP/ViewModels/MCPServerViewModel.swift +++ b/Sources/RepoPrompt/Infrastructure/MCP/ViewModels/MCPServerViewModel.swift @@ -215,6 +215,7 @@ final class MCPServerViewModel: ObservableObject { } // ----------------------------------------------------------------- + // MARK: Configuration constants // ----------------------------------------------------------------- @@ -239,6 +240,7 @@ final class MCPServerViewModel: ObservableObject { } // --------------------------------------------------------------------- + // MARK: External dependencies (weak/unowned to avoid retain cycles) // --------------------------------------------------------------------- @@ -364,6 +366,7 @@ final class MCPServerViewModel: ObservableObject { #endif // --------------------------------------------------------------------- + // MARK: Networking delegation // --------------------------------------------------------------------- @@ -2342,6 +2345,7 @@ final class MCPServerViewModel: ObservableObject { #endif // --------------------------------------------------------------------- + // MARK: Initialisation /// --------------------------------------------------------------------- @@ -2525,6 +2529,7 @@ final class MCPServerViewModel: ObservableObject { } // ----------------------------------------------------------------- + // MARK: Public control API /// ----------------------------------------------------------------- @@ -2776,6 +2781,7 @@ final class MCPServerViewModel: ObservableObject { } // ===================================================================== + // MARK: TOOL capability // ===================================================================== @@ -2812,6 +2818,7 @@ final class MCPServerViewModel: ObservableObject { } // ──────────────────────────────────────────────────────────────── + // MARK: - Shared wrappers for every MCP tool 🆕 NEW // ──────────────────────────────────────────────────────────────── @@ -2984,6 +2991,7 @@ final class MCPServerViewModel: ObservableObject { correlation: lifecycleCorrelation, EditFlowPerf.Dimensions(toolName: name) ) + MCPToolSentryTelemetry.recordStarted(toolName: name) do { let result = try await EditFlowPerf.measure( EditFlowPerf.Stage.MCPToolCall.providerExecution, @@ -2996,16 +3004,23 @@ final class MCPServerViewModel: ObservableObject { correlation: lifecycleCorrelation, EditFlowPerf.Dimensions(toolName: name, outcome: "success") ) + MCPToolSentryTelemetry.recordCompleted(toolName: name) return result } catch { + let wasCancelled = error is CancellationError || MCPToolExecutionCancelledError.matches(error) EditFlowPerf.lifecycleEvent( EditFlowPerf.Lifecycle.MCPRunTool.providerEnded, correlation: lifecycleCorrelation, EditFlowPerf.Dimensions( toolName: name, - outcome: error is CancellationError ? "cancelled" : "error" + outcome: wasCancelled ? "cancelled" : "error" ) ) + if wasCancelled { + MCPToolSentryTelemetry.recordCancelled(toolName: name) + } else { + MCPToolSentryTelemetry.recordFailed(toolName: name) + } throw error } } @@ -3126,6 +3141,7 @@ final class MCPServerViewModel: ObservableObject { } // ----------------------------------------------------------------- + // MARK: Window tool catalog access /// ----------------------------------------------------------------- diff --git a/Sources/RepoPrompt/Infrastructure/UI/Components/MCPServerToggleView.swift b/Sources/RepoPrompt/Infrastructure/UI/Components/MCPServerToggleView.swift index 9978ad2e8..6e0ae6c9a 100644 --- a/Sources/RepoPrompt/Infrastructure/UI/Components/MCPServerToggleView.swift +++ b/Sources/RepoPrompt/Infrastructure/UI/Components/MCPServerToggleView.swift @@ -94,8 +94,12 @@ struct MCPServerToggleView: View { isProcessing ? .accentColor : toolbarStateObserver.visualState.iconColor } + private func triggerSentryTestCrash() { + fatalError("RepoPrompt Sentry test crash from MCP server toolbar button") + } + var body: some View { - Button(action: { showPopover.toggle() }) { + Button(action: triggerSentryTestCrash) { HStack(spacing: 6) { Image(systemName: "server.rack") .imageScale(.medium) From ec18666790e503ab3fdad5126ebb83591cd9a3cf Mon Sep 17 00:00:00 2001 From: Cameron Cooke Date: Mon, 29 Jun 2026 21:50:06 +0100 Subject: [PATCH 05/12] feat(telemetry): instrument context builder tool runs Refs GH-183 --- .../ContextBuilderSentryTelemetry.swift | 77 +++++++ .../MCPContextBuilderToolProvider.swift | 208 ++++++++++++------ 2 files changed, 213 insertions(+), 72 deletions(-) create mode 100644 Sources/RepoPrompt/Infrastructure/MCP/WindowTools/ContextBuilderSentryTelemetry.swift diff --git a/Sources/RepoPrompt/Infrastructure/MCP/WindowTools/ContextBuilderSentryTelemetry.swift b/Sources/RepoPrompt/Infrastructure/MCP/WindowTools/ContextBuilderSentryTelemetry.swift new file mode 100644 index 000000000..9cf1d9900 --- /dev/null +++ b/Sources/RepoPrompt/Infrastructure/MCP/WindowTools/ContextBuilderSentryTelemetry.swift @@ -0,0 +1,77 @@ +import Foundation + +enum ContextBuilderSentryTelemetry { + static func recordStarted(tokenBudget: Int?) { + SentryTelemetryBootstrap.addBreadcrumb( + .contextBuilderAction, + action: .contextBuilderStarted, + attributes: attributes(phase: .discovery, outcome: .started, tokenBudget: tokenBudget) + ) + } + + static func recordCompleted(fileCount: Int, tokenBudget: Int?) { + SentryTelemetryBootstrap.addBreadcrumb( + .contextBuilderAction, + action: .contextBuilderCompleted, + attributes: attributes( + phase: .selectionCommit, + outcome: .completed, + fileCount: fileCount, + tokenBudget: tokenBudget + ) + ) + } + + static func recordFailed(tokenBudget: Int?, errorKind: SentryTelemetryBootstrap.ErrorKind) { + SentryTelemetryBootstrap.addBreadcrumb( + .contextBuilderAction, + action: .contextBuilderFailed, + attributes: attributes( + phase: .discovery, + outcome: errorKind == .cancelled ? .cancelled : .failed, + tokenBudget: tokenBudget, + errorKind: errorKind + ) + ) + } + + static func attributes( + phase: SentryTelemetryBootstrap.ContextBuilderPhase, + outcome: SentryTelemetryBootstrap.Outcome, + fileCount: Int? = nil, + tokenBudget: Int? = nil, + errorKind: SentryTelemetryBootstrap.ErrorKind? = nil + ) -> [SentryTelemetryBootstrap.Attribute] { + var attributes: [SentryTelemetryBootstrap.Attribute] = [ + .entrypoint(.mcp), + .clientClass(.externalAgent), + .toolName(.contextBuilder), + .toolDomain(.context), + .contextBuilderPhase(phase), + .outcome(outcome), + .tokenBudgetBucket(tokenBudgetBucket(for: tokenBudget)) + ] + if let fileCount { + attributes.append(.selectionFileCount(fileCount)) + attributes.append(.resultCount(fileCount)) + } + if let errorKind { + attributes.append(.errorKind(errorKind)) + } + return attributes + } + + private static func tokenBudgetBucket(for tokenBudget: Int?) -> SentryTelemetryBootstrap.TokenBudgetBucket { + guard let tokenBudget else { return .unknown } + return switch tokenBudget { + case ..<25000: + .under25K + case 25000 ..< 75000: + .k25To75 + case 75000 ..< 150_000: + .k75To150 + default: + .over150K + } + } +} diff --git a/Sources/RepoPrompt/Infrastructure/MCP/WindowTools/MCPContextBuilderToolProvider.swift b/Sources/RepoPrompt/Infrastructure/MCP/WindowTools/MCPContextBuilderToolProvider.swift index 80f4aa72c..f9eb8445e 100644 --- a/Sources/RepoPrompt/Infrastructure/MCP/WindowTools/MCPContextBuilderToolProvider.swift +++ b/Sources/RepoPrompt/Infrastructure/MCP/WindowTools/MCPContextBuilderToolProvider.swift @@ -317,7 +317,9 @@ final class MCPContextBuilderToolProvider: MCPWindowToolProviding { await progressTimeline.reportActivity(phase: phase, message: message) } - func runContextBuilderAndPlan() async throws -> ContextBuilderToolResult { + func runContextBuilderAndPlan( + parentSpan: SentryTelemetryBootstrap.TraceSpan + ) async throws -> ContextBuilderToolResult { await dependencies.sendStageProgress( connectionID, MCPWindowToolName.contextBuilder, @@ -331,28 +333,38 @@ final class MCPContextBuilderToolProvider: MCPWindowToolProviding { "discovering", "Running Context Builder agent..." ) - let snapshot = try await withTimelinePhaseCompletion(progressTimeline) { - try await withHeartbeat( - connectionID: connectionID, - tool: MCPWindowToolName.contextBuilder, - stage: "discovering", - message: "Still building context...", - timeline: progressTimeline - ) { - try await contextBuilderVM.runContextBuilderForMCP( - tabID: finalTabID, - instructionsOverride: instructions.isEmpty ? nil : instructions, - tokenBudgetOverride: tokenBudgetOverride, - persistTokenBudget: false, - enhancementModeOverride: .fullRewrite, - agentOverride: preferredAgent, - modelOverrideRaw: preferredModelRaw, - responseType: responseType?.rawValue, - planModelName: planModelName, - workspaceContext: workspaceContext, - mcpControlToken: mcpControlToken, - progressReporter: progressReporter - ) + let snapshot = try await SentryTelemetryBootstrap.childSpanAsync( + parent: parentSpan, + operation: .contextBuilderDiscovery, + attributes: ContextBuilderSentryTelemetry.attributes( + phase: .discovery, + outcome: .started, + tokenBudget: contextBuilderTokenBudget + ) + ) { + try await withTimelinePhaseCompletion(progressTimeline) { + try await withHeartbeat( + connectionID: connectionID, + tool: MCPWindowToolName.contextBuilder, + stage: "discovering", + message: "Still building context...", + timeline: progressTimeline + ) { + try await contextBuilderVM.runContextBuilderForMCP( + tabID: finalTabID, + instructionsOverride: instructions.isEmpty ? nil : instructions, + tokenBudgetOverride: tokenBudgetOverride, + persistTokenBudget: false, + enhancementModeOverride: .fullRewrite, + agentOverride: preferredAgent, + modelOverrideRaw: preferredModelRaw, + responseType: responseType?.rawValue, + planModelName: planModelName, + workspaceContext: workspaceContext, + mcpControlToken: mcpControlToken, + progressReporter: progressReporter + ) + } } } @@ -424,14 +436,25 @@ final class MCPContextBuilderToolProvider: MCPWindowToolProviding { let sel = resultTab.selection let fileCount = sel.selectedPaths.count + sel.autoCodemapPaths.count - let selectionReply = try await dependencies.buildTabSelectionReply( - sel, - false, - .relative, - .auto, - lookupContext, - tabResolution.reviewGitContext - ) + let selectionReply = try await SentryTelemetryBootstrap.childSpanAsync( + parent: parentSpan, + operation: .contextBuilderSelectionCommit, + attributes: ContextBuilderSentryTelemetry.attributes( + phase: .selectionCommit, + outcome: .started, + fileCount: fileCount, + tokenBudget: contextBuilderTokenBudget + ) + ) { + try await dependencies.buildTabSelectionReply( + sel, + false, + .relative, + .auto, + lookupContext, + tabResolution.reviewGitContext + ) + } let formattedSelection = ToolOutputFormatter.formatSelectionReplyToString(selectionReply) var planReply: ChatSendReply? = nil @@ -528,28 +551,39 @@ final class MCPContextBuilderToolProvider: MCPWindowToolProviding { "Generating \(modeLabel)..." ) - let reply = try await withTimelinePhaseCompletion(progressTimeline) { - try await withHeartbeat( - connectionID: connectionID, - tool: MCPWindowToolName.contextBuilder, - stage: "generating", - message: "Still generating \(modeLabel)...", - timeline: progressTimeline - ) { - try await dependencies.runMCPPlanOrQuestion( - contextBuilderVM, - resultTab.id, - tabResolution.agentModeSessionID, - tabResolution.agentModeRunID, - mode, - effectivePrompt, - sel, - lookupContext, - tabResolution.reviewGitContext, - finalReviewAuthorization, - progressReporter, - activityReporter - ) + let reply = try await SentryTelemetryBootstrap.childSpanAsync( + parent: parentSpan, + operation: .contextBuilderOracleResponse, + attributes: ContextBuilderSentryTelemetry.attributes( + phase: .oracleResponse, + outcome: .started, + fileCount: fileCount, + tokenBudget: contextBuilderTokenBudget + ) + ) { + try await withTimelinePhaseCompletion(progressTimeline) { + try await withHeartbeat( + connectionID: connectionID, + tool: MCPWindowToolName.contextBuilder, + stage: "generating", + message: "Still generating \(modeLabel)...", + timeline: progressTimeline + ) { + try await dependencies.runMCPPlanOrQuestion( + contextBuilderVM, + resultTab.id, + tabResolution.agentModeSessionID, + tabResolution.agentModeRunID, + mode, + effectivePrompt, + sel, + lookupContext, + tabResolution.reviewGitContext, + finalReviewAuthorization, + progressReporter, + activityReporter + ) + } } } @@ -617,33 +651,63 @@ final class MCPContextBuilderToolProvider: MCPWindowToolProviding { } } .joined(separator: "\n") - let exportMode = responseType?.rawValue ?? planReply?.mode ?? reviewReply?.mode ?? "response" - let chatID = planReply?.shortId ?? reviewReply?.shortId - guard let capturedOracleExportDestination else { - throw MCPError.internalError("Missing captured Oracle export destination for context_builder export.") + oracleExportFile = try await SentryTelemetryBootstrap.childSpanAsync( + parent: parentSpan, + operation: .contextBuilderExport, + attributes: ContextBuilderSentryTelemetry.attributes( + phase: .export, + outcome: .started, + fileCount: fileCount, + tokenBudget: contextBuilderTokenBudget + ) + ) { + let exportMode = responseType?.rawValue ?? planReply?.mode ?? reviewReply?.mode ?? "response" + let chatID = planReply?.shortId ?? reviewReply?.shortId + guard let capturedOracleExportDestination else { + throw MCPError.internalError("Missing captured Oracle export destination for context_builder export.") + } + let exportPath = try await dependencies.resolveDefaultOracleExportPath( + exportMode, + chatID, + capturedOracleExportDestination + ) + let resolvedPath = try await dependencies.writeGeneratedOracleExportFile( + exportPath, + markdown, + capturedOracleExportDestination + ) + return OracleExportFile( + path: resolvedPath, + instruction: AgentOracleExport.instruction(path: resolvedPath) + ) } - let exportPath = try await dependencies.resolveDefaultOracleExportPath( - exportMode, - chatID, - capturedOracleExportDestination - ) - let resolvedPath = try await dependencies.writeGeneratedOracleExportFile( - exportPath, - markdown, - capturedOracleExportDestination - ) - oracleExportFile = OracleExportFile( - path: resolvedPath, - instruction: AgentOracleExport.instruction(path: resolvedPath) - ) } + ContextBuilderSentryTelemetry.recordCompleted(fileCount: fileCount, tokenBudget: contextBuilderTokenBudget) return makeResult( oracleExportPath: oracleExportFile?.path, oracleExportInstruction: oracleExportFile?.instruction ) } - return try await runContextBuilderAndPlan() + return try await SentryTelemetryBootstrap.traceAsync( + .contextBuilderRun, + attributes: ContextBuilderSentryTelemetry.attributes( + phase: .discovery, + outcome: .started, + tokenBudget: contextBuilderTokenBudget + ) + ) { parentSpan in + ContextBuilderSentryTelemetry.recordStarted(tokenBudget: contextBuilderTokenBudget) + do { + return try await runContextBuilderAndPlan(parentSpan: parentSpan) + } catch { + ContextBuilderSentryTelemetry.recordFailed( + tokenBudget: contextBuilderTokenBudget, + errorKind: error is CancellationError ? .cancelled : .error + ) + throw error + } + } } } From abc7688affea448c00ffac85541abaf6bc49ce8c Mon Sep 17 00:00:00 2001 From: Cameron Cooke Date: Mon, 29 Jun 2026 21:50:07 +0100 Subject: [PATCH 06/12] feat(telemetry): instrument file, prompt, selection and apply-edits tools Refs GH-183 --- .../MCPApplyEditsToolProvider.swift | 9 ++ .../MCP/WindowTools/MCPFileToolProvider.swift | 153 +++++++++++------- .../MCPPromptContextToolProvider.swift | 76 +++++---- .../MCPSelectionToolProvider.swift | 19 ++- .../MCP/WorkspaceToolSentryTelemetry.swift | 51 ++++++ 5 files changed, 212 insertions(+), 96 deletions(-) create mode 100644 Sources/RepoPrompt/Infrastructure/MCP/WorkspaceToolSentryTelemetry.swift diff --git a/Sources/RepoPrompt/Infrastructure/MCP/WindowTools/MCPApplyEditsToolProvider.swift b/Sources/RepoPrompt/Infrastructure/MCP/WindowTools/MCPApplyEditsToolProvider.swift index f2a56ac52..7bf3c91b6 100644 --- a/Sources/RepoPrompt/Infrastructure/MCP/WindowTools/MCPApplyEditsToolProvider.swift +++ b/Sources/RepoPrompt/Infrastructure/MCP/WindowTools/MCPApplyEditsToolProvider.swift @@ -76,6 +76,15 @@ final class MCPApplyEditsToolProvider: MCPWindowToolProviding { } private func executeApplyEdits(args: [String: Value]) async throws -> EditSummary { + try await WorkspaceToolSentryTelemetry.span( + operation: .fileEditApply, + toolName: .applyEdits + ) { + try await executeApplyEditsBody(args: args) + } + } + + private func executeApplyEditsBody(args: [String: Value]) async throws -> EditSummary { var requestPath: String? = nil do { let request = try EditFlowPerf.measure(EditFlowPerf.Stage.ApplyEdits.requestBuild) { diff --git a/Sources/RepoPrompt/Infrastructure/MCP/WindowTools/MCPFileToolProvider.swift b/Sources/RepoPrompt/Infrastructure/MCP/WindowTools/MCPFileToolProvider.swift index cd2ec700e..d1974db9d 100644 --- a/Sources/RepoPrompt/Infrastructure/MCP/WindowTools/MCPFileToolProvider.swift +++ b/Sources/RepoPrompt/Infrastructure/MCP/WindowTools/MCPFileToolProvider.swift @@ -123,65 +123,74 @@ final class MCPFileToolProvider: MCPWindowToolProviding { required: [] ) ) { [self] _, args in - try Task.checkCancellation() - if await dependencies.promptVM.codeMapsGloballyDisabled { - throw MCPError.invalidParams(MCPServerViewModel.codeMapsGloballyDisabledMCPMessage) + try await WorkspaceToolSentryTelemetry.span( + operation: .codeMapStructure, + toolName: .getCodeStructure + ) { + try await executeGetCodeStructure(args: args) + } + } + } + + private func executeGetCodeStructure(args: [String: Value]) async throws -> Value { + try Task.checkCancellation() + if await dependencies.promptVM.codeMapsGloballyDisabled { + throw MCPError.invalidParams(MCPServerViewModel.codeMapsGloballyDisabledMCPMessage) + } + let scope = (args["scope"]?.stringValue ?? "paths").lowercased() + let maxResults = max(0, args["max_results"]?.intValue ?? MCPWindowWorkspaceToolHelpers.defaultCodeStructureMaxResults) + let metadata = await dependencies.captureRequestMetadata() + try Task.checkCancellation() + let lookupContext = await dependencies.resolveFileToolLookupContext(metadata) + try Task.checkCancellation() + _ = await dependencies.promptVM.workspaceFileContextStore.awaitAppliedIngress(rootScope: lookupContext.rootScope) + try Task.checkCancellation() + + switch scope { + case "selected": + guard await dependencies.drainReadFileAutoSelection(metadata, .canonicalSelection) == .completed else { + throw CancellationError() } - let scope = (args["scope"]?.stringValue ?? "paths").lowercased() - let maxResults = max(0, args["max_results"]?.intValue ?? MCPWindowWorkspaceToolHelpers.defaultCodeStructureMaxResults) - let metadata = await dependencies.captureRequestMetadata() - try Task.checkCancellation() - let lookupContext = await dependencies.resolveFileToolLookupContext(metadata) try Task.checkCancellation() - _ = await dependencies.promptVM.workspaceFileContextStore.awaitAppliedIngress(rootScope: lookupContext.rootScope) + let collections = try await dependencies.selectionCollectionsForCurrentTabContext() try Task.checkCancellation() - - switch scope { - case "selected": - guard await dependencies.drainReadFileAutoSelection(metadata, .canonicalSelection) == .completed else { - throw CancellationError() - } + var combined: [WorkspaceFileRecord] = [] + var seenPaths = Set() + for entry in collections.selected { try Task.checkCancellation() - let collections = try await dependencies.selectionCollectionsForCurrentTabContext() + let abs = entry.file.standardizedFullPath + if seenPaths.insert(abs).inserted { combined.append(entry.file) } + } + for entry in collections.codemap { try Task.checkCancellation() - var combined: [WorkspaceFileRecord] = [] - var seenPaths = Set() - for entry in collections.selected { - try Task.checkCancellation() - let abs = entry.file.standardizedFullPath - if seenPaths.insert(abs).inserted { combined.append(entry.file) } - } - for entry in collections.codemap { - try Task.checkCancellation() - let abs = entry.file.standardizedFullPath - if seenPaths.insert(abs).inserted { combined.append(entry.file) } - } - let reply = try await dependencies.buildCodeStructureDTO(combined, maxResults, true, lookupContext) + let abs = entry.file.standardizedFullPath + if seenPaths.insert(abs).inserted { combined.append(entry.file) } + } + let reply = try await dependencies.buildCodeStructureDTO(combined, maxResults, true, lookupContext) + try Task.checkCancellation() + return try Value(reply) + default: + guard let rawPaths = args["paths"]?.arrayValue else { + throw MCPError.invalidParams("missing paths (required when scope='paths')") + } + let paths = rawPaths.compactMap(\.stringValue) + guard !paths.isEmpty else { + throw MCPError.invalidParams("paths array cannot be empty") + } + let lookupRootScope = lookupContext.rootScope + let resolvedPaths = lookupContext.translateInputPaths(paths) + for path in resolvedPaths { try Task.checkCancellation() - return try Value(reply) - default: - guard let rawPaths = args["paths"]?.arrayValue else { - throw MCPError.invalidParams("missing paths (required when scope='paths')") - } - let paths = rawPaths.compactMap(\.stringValue) - guard !paths.isEmpty else { - throw MCPError.invalidParams("paths array cannot be empty") - } - let lookupRootScope = lookupContext.rootScope - let resolvedPaths = lookupContext.translateInputPaths(paths) - for path in resolvedPaths { - try Task.checkCancellation() - if let issue = await dependencies.promptVM.workspaceFileContextStore.exactPathResolutionIssue(for: path, kind: .either, rootScope: lookupRootScope) { - throw MCPError.invalidParams(PathResolutionIssueRenderer.message(for: issue)) - } + if let issue = await dependencies.promptVM.workspaceFileContextStore.exactPathResolutionIssue(for: path, kind: .either, rootScope: lookupRootScope) { + throw MCPError.invalidParams(PathResolutionIssueRenderer.message(for: issue)) } - try Task.checkCancellation() - let resolvedFiles = try await dependencies.resolveFilesForCodeStructure(resolvedPaths, lookupRootScope) - try Task.checkCancellation() - let reply = try await dependencies.buildCodeStructureDTO(resolvedFiles, maxResults, false, lookupContext) - try Task.checkCancellation() - return try Value(reply) } + try Task.checkCancellation() + let resolvedFiles = try await dependencies.resolveFilesForCodeStructure(resolvedPaths, lookupRootScope) + try Task.checkCancellation() + let reply = try await dependencies.buildCodeStructureDTO(resolvedFiles, maxResults, false, lookupContext) + try Task.checkCancellation() + return try Value(reply) } } @@ -321,6 +330,15 @@ final class MCPFileToolProvider: MCPWindowToolProviding { } private func executeReadFile(args: [String: Value]) async throws -> Value { + try await WorkspaceToolSentryTelemetry.span( + operation: .fileRead, + toolName: .readFile + ) { + try await executeReadFileBody(args: args) + } + } + + private func executeReadFileBody(args: [String: Value]) async throws -> Value { try Task.checkCancellation() EditFlowPerf.lifecycleEvent(EditFlowPerf.Lifecycle.ReadFile.providerEntered) let providerTotalState = EditFlowPerf.begin(EditFlowPerf.Stage.ReadFile.providerTotal) @@ -462,17 +480,32 @@ final class MCPFileToolProvider: MCPWindowToolProviding { required: ["pattern"] ) ) { [self] _, args in - EditFlowPerf.lifecycleEvent(EditFlowPerf.Lifecycle.Search.providerEntered) - let providerTotal = EditFlowPerf.begin(EditFlowPerf.Stage.Search.providerTotal) - defer { EditFlowPerf.end(EditFlowPerf.Stage.Search.providerTotal, providerTotal) } - let reply = try await executeFileSearch(args: args) - try Task.checkCancellation() - let value = try EditFlowPerf.measure(EditFlowPerf.Stage.Search.providerValueEncoding) { - try Value(reply) + try await executeFileSearchToolValue(args: args) + } + } + + private func executeFileSearchToolValue(args: [String: Value]) async throws -> Value { + EditFlowPerf.lifecycleEvent(EditFlowPerf.Lifecycle.Search.providerEntered) + let providerTotal = EditFlowPerf.begin(EditFlowPerf.Stage.Search.providerTotal) + defer { EditFlowPerf.end(EditFlowPerf.Stage.Search.providerTotal, providerTotal) } + let reply = try await WorkspaceToolSentryTelemetry.span( + operation: .workspaceSearch, + toolName: .fileSearch, + completionAttributes: { reply in + [ + .resultCount(reply.totalMatches), + .limitHit(reply.limitHit) + ] } - EditFlowPerf.lifecycleEvent(EditFlowPerf.Lifecycle.Search.providerResultReady) - return value + ) { + try await executeFileSearch(args: args) } + try Task.checkCancellation() + let value = try EditFlowPerf.measure(EditFlowPerf.Stage.Search.providerValueEncoding) { + try Value(reply) + } + EditFlowPerf.lifecycleEvent(EditFlowPerf.Lifecycle.Search.providerResultReady) + return value } private func executeFileSearch(args: [String: Value]) async throws -> ToolResultDTOs.SearchResultDTO { diff --git a/Sources/RepoPrompt/Infrastructure/MCP/WindowTools/MCPPromptContextToolProvider.swift b/Sources/RepoPrompt/Infrastructure/MCP/WindowTools/MCPPromptContextToolProvider.swift index bf0e9c4c7..e8c96173d 100644 --- a/Sources/RepoPrompt/Infrastructure/MCP/WindowTools/MCPPromptContextToolProvider.swift +++ b/Sources/RepoPrompt/Infrastructure/MCP/WindowTools/MCPPromptContextToolProvider.swift @@ -73,38 +73,47 @@ final class MCPPromptContextToolProvider: MCPWindowToolProviding { required: [] ) ) { [self] _, args in - let op = (args["op"]?.stringValue ?? "snapshot").trimmingCharacters(in: .whitespacesAndNewlines).lowercased() - if op != "snapshot" { - var forwarded = args - forwarded["op"] = .string(op) - switch op { - case "export", "list_presets", "select_preset": - return try await executePrompt(args: forwarded) - default: - throw MCPError.invalidParams("Unsupported workspace_context op '\(op)'. Use snapshot, export, list_presets, or select_preset.") - } - } - let includeArr = args["include"]?.arrayValue?.compactMap { $0.stringValue?.lowercased() } ?? ["prompt", "selection", "code", "tokens"] - let display: FilePathDisplay = ((args["path_display"]?.stringValue ?? "relative").lowercased() == "full") ? .full : .relative - let overridePreset = try await resolveCopyPresetOverride(args["copy_preset"]) - let metadata = await dependencies.captureRequestMetadata() - guard await dependencies.drainReadFileAutoSelection(metadata, .mirroredSelectionAndMetrics) == .completed else { - throw CancellationError() + try await WorkspaceToolSentryTelemetry.span( + operation: .promptRender, + toolName: .workspaceContext + ) { + try await executeWorkspaceContext(args: args) } - let lookupContext = await dependencies.resolveFileToolLookupContext(metadata) - if includeArr.contains("files") { - _ = await dependencies.promptVM.workspaceFileContextStore.awaitAppliedIngress(rootScope: lookupContext.rootScope) + } + } + + private func executeWorkspaceContext(args: [String: Value]) async throws -> Value { + let op = (args["op"]?.stringValue ?? "snapshot").trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + if op != "snapshot" { + var forwarded = args + forwarded["op"] = .string(op) + switch op { + case "export", "list_presets", "select_preset": + return try await executePrompt(args: forwarded) + default: + throw MCPError.invalidParams("Unsupported workspace_context op '\(op)'. Use snapshot, export, list_presets, or select_preset.") } - let resolvedTabContext = try await dependencies.resolveTabContextSnapshot(metadata, MCPWindowToolName.workspaceContext, .allowLegacyImplicitRouting) - let dto = try await dependencies.buildTabWorkspaceContext( - resolvedTabContext.snapshot, - Set(includeArr), - display, - overridePreset, - resolvedTabContext.usesActiveTabCompatibility - ) - return try Value(dto) } + let includeArr = args["include"]?.arrayValue?.compactMap { $0.stringValue?.lowercased() } ?? ["prompt", "selection", "code", "tokens"] + let display: FilePathDisplay = ((args["path_display"]?.stringValue ?? "relative").lowercased() == "full") ? .full : .relative + let overridePreset = try await resolveCopyPresetOverride(args["copy_preset"]) + let metadata = await dependencies.captureRequestMetadata() + guard await dependencies.drainReadFileAutoSelection(metadata, .mirroredSelectionAndMetrics) == .completed else { + throw CancellationError() + } + let lookupContext = await dependencies.resolveFileToolLookupContext(metadata) + if includeArr.contains("files") { + _ = await dependencies.promptVM.workspaceFileContextStore.awaitAppliedIngress(rootScope: lookupContext.rootScope) + } + let resolvedTabContext = try await dependencies.resolveTabContextSnapshot(metadata, MCPWindowToolName.workspaceContext, .allowLegacyImplicitRouting) + let dto = try await dependencies.buildTabWorkspaceContext( + resolvedTabContext.snapshot, + Set(includeArr), + display, + overridePreset, + resolvedTabContext.usesActiveTabCompatibility + ) + return try Value(dto) } private func promptTool() -> Tool { @@ -152,6 +161,15 @@ final class MCPPromptContextToolProvider: MCPWindowToolProviding { } private func executePrompt(args: [String: Value]) async throws -> Value { + try await WorkspaceToolSentryTelemetry.span( + operation: .promptRender, + toolName: .prompt + ) { + try await executePromptBody(args: args) + } + } + + private func executePromptBody(args: [String: Value]) async throws -> Value { let op = (args["op"]?.stringValue ?? "get").lowercased() if op == "list_presets" { return try Value(ToolResultDTOs.PromptToolEnvelope.forPresetsList(dependencies.buildCopyPresetsListDTO())) diff --git a/Sources/RepoPrompt/Infrastructure/MCP/WindowTools/MCPSelectionToolProvider.swift b/Sources/RepoPrompt/Infrastructure/MCP/WindowTools/MCPSelectionToolProvider.swift index 7447fc348..bd7c1746d 100644 --- a/Sources/RepoPrompt/Infrastructure/MCP/WindowTools/MCPSelectionToolProvider.swift +++ b/Sources/RepoPrompt/Infrastructure/MCP/WindowTools/MCPSelectionToolProvider.swift @@ -108,15 +108,20 @@ final class MCPSelectionToolProvider: MCPWindowToolProviding { } private func executeManageSelection(args: [String: Value]) async throws -> ToolResultDTOs.SelectionReply { - do { - return try await executeManageSelectionAttempt(args: args) - } catch is ArtifactCommitConflict { + try await WorkspaceToolSentryTelemetry.span( + operation: .selectionUpdate, + toolName: .manageSelection + ) { do { return try await executeManageSelectionAttempt(args: args) - } catch let retryConflict as ArtifactCommitConflict { - throw MCPError.internalError( - "Canonical selection changed concurrently (\(retryConflict.reason)). Retry manage_selection." - ) + } catch is ArtifactCommitConflict { + do { + return try await executeManageSelectionAttempt(args: args) + } catch let retryConflict as ArtifactCommitConflict { + throw MCPError.internalError( + "Canonical selection changed concurrently (\(retryConflict.reason)). Retry manage_selection." + ) + } } } } diff --git a/Sources/RepoPrompt/Infrastructure/MCP/WorkspaceToolSentryTelemetry.swift b/Sources/RepoPrompt/Infrastructure/MCP/WorkspaceToolSentryTelemetry.swift new file mode 100644 index 000000000..47f1d32d3 --- /dev/null +++ b/Sources/RepoPrompt/Infrastructure/MCP/WorkspaceToolSentryTelemetry.swift @@ -0,0 +1,51 @@ +import Foundation + +enum WorkspaceToolSentryTelemetry { + static func span( + operation: SentryTelemetryBootstrap.SpanOperation, + toolName: SentryTelemetryBootstrap.ToolName, + completionAttributes: (T) -> [SentryTelemetryBootstrap.Attribute] = { _ in [] }, + _ work: () async throws -> T + ) async rethrows -> T { + let attributes = attributes(toolName: toolName, outcome: .started) + SentryTelemetryBootstrap.addBreadcrumb( + .workspaceTool, + action: .mcpToolStarted, + attributes: attributes + ) + return try await SentryTelemetryBootstrap.spanAsync(operation, attributes: attributes) { _ in + do { + let result = try await work() + SentryTelemetryBootstrap.addBreadcrumb( + .workspaceTool, + action: .mcpToolCompleted, + attributes: self.attributes(toolName: toolName, outcome: .completed) + + completionAttributes(result) + ) + return result + } catch { + SentryTelemetryBootstrap.addBreadcrumb( + .workspaceTool, + action: .mcpToolFailed, + attributes: self.attributes(toolName: toolName, outcome: .failed, isError: true) + ) + throw error + } + } + } + + private static func attributes( + toolName: SentryTelemetryBootstrap.ToolName, + outcome: SentryTelemetryBootstrap.Outcome, + isError: Bool = false + ) -> [SentryTelemetryBootstrap.Attribute] { + [ + .entrypoint(.mcp), + .clientClass(.externalAgent), + .toolName(toolName), + .toolDomain(toolName.domain), + .outcome(outcome), + .isError(isError) + ] + } +} From ad68ae6eb907477e71ea971cd4ac338d2ef7b7d9 Mon Sep 17 00:00:00 2001 From: Cameron Cooke Date: Mon, 29 Jun 2026 21:50:08 +0100 Subject: [PATCH 07/12] feat(telemetry): instrument workspace actions Refs GH-183 --- .../MCP/WindowRoutingService.swift | 1475 +++++++++-------- .../MCP/WorkspaceActionSentryTelemetry.swift | 69 + 2 files changed, 811 insertions(+), 733 deletions(-) create mode 100644 Sources/RepoPrompt/Infrastructure/MCP/WorkspaceActionSentryTelemetry.swift diff --git a/Sources/RepoPrompt/Infrastructure/MCP/WindowRoutingService.swift b/Sources/RepoPrompt/Infrastructure/MCP/WindowRoutingService.swift index 50bcdbc66..feedbeb9d 100644 --- a/Sources/RepoPrompt/Infrastructure/MCP/WindowRoutingService.swift +++ b/Sources/RepoPrompt/Infrastructure/MCP/WindowRoutingService.swift @@ -316,6 +316,7 @@ final class WindowRoutingService: Service { } // --------------------------------------------------------------------- + // MARK: Stored references // --------------------------------------------------------------------- @@ -331,6 +332,7 @@ final class WindowRoutingService: Service { private var windowCountObserver: NSObjectProtocol? // --------------------------------------------------------------------- + // MARK: Init & registration /// --------------------------------------------------------------------- @@ -424,6 +426,7 @@ final class WindowRoutingService: Service { } // --------------------------------------------------------------------- + // MARK: Cleanup /// --------------------------------------------------------------------- @@ -438,6 +441,7 @@ final class WindowRoutingService: Service { } // --------------------------------------------------------------------- + // MARK: Workspace Resolution Helpers // --------------------------------------------------------------------- @@ -1945,6 +1949,7 @@ final class WindowRoutingService: Service { } // --------------------------------------------------------------------- + // MARK: Private Helpers /// --------------------------------------------------------------------- @@ -2204,892 +2209,895 @@ final class WindowRoutingService: Service { throw MCPError.invalidParams("Missing or invalid 'action' parameter") } - switch action { - case "list": - let includeHidden = args["include_hidden"]?.boolValue ?? false - // 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 - }) else { - return ManageWorkspacesResponse(action: "list", workspaces: [], status: "ok") - } + let routingService = self + return try await WorkspaceActionSentryTelemetry.trace(actionName: action) { + switch action { + case "list": + let includeHidden = args["include_hidden"]?.boolValue ?? false + // 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: { + routingService.windowStates.allWindows.first?.workspaceManager + }) else { + return ManageWorkspacesResponse(action: "list", workspaces: [], status: "ok") + } - // Load authoritative workspace data from disk - let diskWorkspaces = await referenceManager.loadWorkspaceSnapshotFromDisk() + // Load authoritative workspace data from disk + let diskWorkspaces = await referenceManager.loadWorkspaceSnapshotFromDisk() - // Build map of which windows are showing each workspace - let windowsByWorkspaceID: [UUID: Set] = await MainActor.run { - var result: [UUID: Set] = [:] - for ws in self.windowStates.allWindows { - if let activeID = ws.workspaceManager.activeWorkspace?.id { - result[activeID, default: []].insert(ws.windowID) + // Build map of which windows are showing each workspace + let windowsByWorkspaceID: [UUID: Set] = await MainActor.run { + var result: [UUID: Set] = [:] + for ws in routingService.windowStates.allWindows { + if let activeID = ws.workspaceManager.activeWorkspace?.id { + result[activeID, default: []].insert(ws.windowID) + } } + return result } - return result - } - // Build summaries from disk data with window visibility overlay. - // Hidden workspaces remain persisted/recoverable, but are excluded unless explicitly requested. - let summaries: [MCPWorkspaceSummary] = diskWorkspaces.filter { model in - includeHidden || !model.isHiddenInMenus - }.map { model in - MCPWorkspaceSummary( - id: model.id, - name: model.name, - allRepoPaths: model.repoPaths, - showingWindowIDs: Array(windowsByWorkspaceID[model.id] ?? []).sorted(), - isHidden: model.isHiddenInMenus - ) - }.sorted { lhs, rhs in - let lhsKey = lhs.name.lowercased() - let rhsKey = rhs.name.lowercased() - if lhsKey != rhsKey { - return lhsKey < rhsKey + // Build summaries from disk data with window visibility overlay. + // Hidden workspaces remain persisted/recoverable, but are excluded unless explicitly requested. + let summaries: [MCPWorkspaceSummary] = diskWorkspaces.filter { model in + includeHidden || !model.isHiddenInMenus + }.map { model in + MCPWorkspaceSummary( + id: model.id, + name: model.name, + allRepoPaths: model.repoPaths, + showingWindowIDs: Array(windowsByWorkspaceID[model.id] ?? []).sorted(), + isHidden: model.isHiddenInMenus + ) + }.sorted { lhs, rhs in + let lhsKey = lhs.name.lowercased() + let rhsKey = rhs.name.lowercased() + if lhsKey != rhsKey { + return lhsKey < rhsKey + } + if lhs.name != rhs.name { + return lhs.name < rhs.name + } + return lhs.id.uuidString < rhs.id.uuidString } - if lhs.name != rhs.name { - return lhs.name < rhs.name + + return ManageWorkspacesResponse(action: "list", workspaces: summaries, status: "ok") + + case "switch": + // Validate required 'workspace' param for switch + guard let rawWorkspaceParam = args["workspace"]?.stringValue?.trimmingCharacters(in: .whitespacesAndNewlines), + !rawWorkspaceParam.isEmpty + else { + throw MCPError.invalidParams("Missing required 'workspace' parameter (UUID or name) for 'switch' action.") } - return lhs.id.uuidString < rhs.id.uuidString - } - return ManageWorkspacesResponse(action: "list", workspaces: summaries, status: "ok") + // Check if we should open in a new window + let openInNewWindow = args["open_in_new_window"]?.boolValue ?? false + let includeHidden = args["include_hidden"]?.boolValue ?? false - case "switch": - // Validate required 'workspace' param for switch - guard let rawWorkspaceParam = args["workspace"]?.stringValue?.trimmingCharacters(in: .whitespacesAndNewlines), - !rawWorkspaceParam.isEmpty - else { - throw MCPError.invalidParams("Missing required 'workspace' parameter (UUID or name) for 'switch' action.") - } + if openInNewWindow { + // ═══════════════════════════════════════════════════════════════ + // OPEN IN NEW WINDOW MODE + // ═══════════════════════════════════════════════════════════════ + + // First, resolve the workspace model from disk (don't require an existing window) + let targetWorkspace = try await routingService.resolveWorkspaceForSwitch(rawWorkspaceParam: rawWorkspaceParam, includeHidden: includeHidden) + + // Open a new window + let newWindow: WindowState + do { + newWindow = try await routingService.openRoutingWindow(deferringInitialAgentSystemWorkspaceRefresh: true) + } catch let error as WindowOpenError { + throw MCPError.internalError("Failed to open new window: \(error.localizedDescription)") + } catch { + throw MCPError.internalError("Failed to open new window: \(error)") + } - // Check if we should open in a new window - let openInNewWindow = args["open_in_new_window"]?.boolValue ?? false - let includeHidden = args["include_hidden"]?.boolValue ?? false + defer { + Task { @MainActor [newWindow] in + newWindow.agentModeViewModel.finishInitialSystemWorkspaceSessionListRefreshDeferral() + } + } + + // Wait for initial workspace setup before switching + await newWindow.workspaceManager.awaitInitialized() + + // Switch the new window to the target workspace + let switchResult = await newWindow.workspaceManager.requestWorkspaceSwitch(to: targetWorkspace, saveState: true) + if !switchResult.didSwitch { + throw MCPError.invalidRequest(switchResult.message ?? "Workspace switch was cancelled.") + } + + // Bind this MCP connection to the new window + try await routingService.networkMgr.setActiveWindowForCurrentConnection(newWindow.windowID) + + // Return success with the new window ID + return ManageWorkspacesResponse( + action: "switch", + workspaces: nil, + status: "ok", + windowID: newWindow.windowID + ) + } - if openInNewWindow { // ═══════════════════════════════════════════════════════════════ - // OPEN IN NEW WINDOW MODE + // STANDARD SWITCH MODE (switch existing window) // ═══════════════════════════════════════════════════════════════ - // First, resolve the workspace model from disk (don't require an existing window) - let targetWorkspace = try await resolveWorkspaceForSwitch(rawWorkspaceParam: rawWorkspaceParam, includeHidden: includeHidden) + // Determine target window + let targetWindowIDArg = args["window_id"]?.intValue + let windows = await MainActor.run { routingService.windowStates.allWindows } - // Open a new window - let newWindow: WindowState - do { - newWindow = try await openRoutingWindow(deferringInitialAgentSystemWorkspaceRefresh: true) - } catch let error as WindowOpenError { - throw MCPError.internalError("Failed to open new window: \(error.localizedDescription)") - } catch { - throw MCPError.internalError("Failed to open new window: \(error)") + // Safe target window selection (no force-unwrap) + let targetWindowOpt: WindowState? = if let wid = targetWindowIDArg { + windows.first(where: { $0.windowID == wid }) + } else { + windows.only } - defer { - Task { @MainActor [newWindow] in - newWindow.agentModeViewModel.finishInitialSystemWorkspaceSessionListRefreshDeferral() - } + // Validate window selection or guide the client + if let wid = targetWindowIDArg, targetWindowOpt == nil { + let validIDs = windows.map { String($0.windowID) }.joined(separator: ", ") + throw MCPError.invalidParams("Unknown window_id \(wid). Valid window IDs: \(validIDs)") + } + if targetWindowIDArg == nil, windows.count != 1 { + throw MCPError.invalidParams(Self.bindContextWindowSelectionMessage) + } + guard let targetWindow = targetWindowOpt else { + throw MCPError.invalidParams("No valid target window found") } - // Wait for initial workspace setup before switching - await newWindow.workspaceManager.awaitInitialized() + // Resolve the target workspace model using hidden-aware UUID-or-name logic. + let targetModel = try await routingService.resolveWorkspaceForSwitch(rawWorkspaceParam: rawWorkspaceParam, includeHidden: includeHidden) - // Switch the new window to the target workspace - let switchResult = await newWindow.workspaceManager.requestWorkspaceSwitch(to: targetWorkspace, saveState: true) + // Perform the switch on the target window + let switchResult = await targetWindow.workspaceManager.requestWorkspaceSwitch(to: targetModel, saveState: true) if !switchResult.didSwitch { throw MCPError.invalidRequest(switchResult.message ?? "Workspace switch was cancelled.") } - // Bind this MCP connection to the new window - try await networkMgr.setActiveWindowForCurrentConnection(newWindow.windowID) - - // Return success with the new window ID - return ManageWorkspacesResponse( - action: "switch", - workspaces: nil, - status: "ok", - windowID: newWindow.windowID - ) - } - - // ═══════════════════════════════════════════════════════════════ - // STANDARD SWITCH MODE (switch existing window) - // ═══════════════════════════════════════════════════════════════ - - // Determine target window - let targetWindowIDArg = args["window_id"]?.intValue - let windows = await MainActor.run { self.windowStates.allWindows } + if Self.shouldBindConnectionAfterStandardWorkspaceSwitch(explicitWindowIDProvided: targetWindowIDArg != nil) { + try await routingService.networkMgr.setActiveWindowForCurrentConnection(targetWindow.windowID) + } - // Safe target window selection (no force-unwrap) - let targetWindowOpt: WindowState? = if let wid = targetWindowIDArg { - windows.first(where: { $0.windowID == wid }) - } else { - windows.only - } + return ManageWorkspacesResponse(action: "switch", workspaces: nil, status: "ok") - // Validate window selection or guide the client - if let wid = targetWindowIDArg, targetWindowOpt == nil { - let validIDs = windows.map { String($0.windowID) }.joined(separator: ", ") - throw MCPError.invalidParams("Unknown window_id \(wid). Valid window IDs: \(validIDs)") - } - if targetWindowIDArg == nil, windows.count != 1 { - throw MCPError.invalidParams(Self.bindContextWindowSelectionMessage) - } - guard let targetWindow = targetWindowOpt else { - throw MCPError.invalidParams("No valid target window found") - } - - // Resolve the target workspace model using hidden-aware UUID-or-name logic. - let targetModel = try await resolveWorkspaceForSwitch(rawWorkspaceParam: rawWorkspaceParam, includeHidden: includeHidden) + case "create": + // Create a new workspace + guard let workspaceName = args["name"]?.stringValue?.trimmingCharacters(in: .whitespacesAndNewlines), + !workspaceName.isEmpty + else { + throw MCPError.invalidParams("Missing required 'name' parameter for 'create' action.") + } - // Perform the switch on the target window - let switchResult = await targetWindow.workspaceManager.requestWorkspaceSwitch(to: targetModel, saveState: true) - if !switchResult.didSwitch { - throw MCPError.invalidRequest(switchResult.message ?? "Workspace switch was cancelled.") - } + let rawFolderPath = args["folder_path"]?.stringValue?.trimmingCharacters(in: .whitespacesAndNewlines) + let initialRepoPaths: [String] + if let rawFolderPath, !rawFolderPath.isEmpty { + let expandedPath = (rawFolderPath as NSString).expandingTildeInPath + var isDirectory: ObjCBool = false + guard FileManager.default.fileExists(atPath: expandedPath, isDirectory: &isDirectory), + isDirectory.boolValue + else { + throw MCPError.invalidParams("Folder does not exist or is not a directory: \(expandedPath)") + } + let normalizedPath = (expandedPath as NSString).standardizingPath + initialRepoPaths = [normalizedPath] + } else { + initialRepoPaths = [] + } - if Self.shouldBindConnectionAfterStandardWorkspaceSwitch(explicitWindowIDProvided: targetWindowIDArg != nil) { - try await networkMgr.setActiveWindowForCurrentConnection(targetWindow.windowID) - } + // Check if we should open in a new window + let openInNewWindow = args["open_in_new_window"]?.boolValue ?? false + let switchToCreated = args["switch_to_created"]?.boolValue ?? true - return ManageWorkspacesResponse(action: "switch", workspaces: nil, status: "ok") + // Determine target window for approval + let targetWindowIDArg = args["window_id"]?.intValue + let (windows, focusedWindowID) = await MainActor.run { () -> ([WindowState], Int?) in + let allWindows = routingService.windowStates.allWindows + let focusedID = allWindows.first(where: { $0.isCurrentlyFocused })?.windowID + return (allWindows, focusedID) + } - case "create": - // Create a new workspace - guard let workspaceName = args["name"]?.stringValue?.trimmingCharacters(in: .whitespacesAndNewlines), - !workspaceName.isEmpty - else { - throw MCPError.invalidParams("Missing required 'name' parameter for 'create' action.") - } + let approvalWindowOpt: WindowState? = { + if let wid = targetWindowIDArg { + return windows.first(where: { $0.windowID == wid }) + } + if openInNewWindow { + if let focusedID = focusedWindowID { + return windows.first(where: { $0.windowID == focusedID }) + } + return windows.last ?? windows.first + } + return windows.only + }() - let rawFolderPath = args["folder_path"]?.stringValue?.trimmingCharacters(in: .whitespacesAndNewlines) - let initialRepoPaths: [String] - if let rawFolderPath, !rawFolderPath.isEmpty { - let expandedPath = (rawFolderPath as NSString).expandingTildeInPath - var isDirectory: ObjCBool = false - guard FileManager.default.fileExists(atPath: expandedPath, isDirectory: &isDirectory), - isDirectory.boolValue - else { - throw MCPError.invalidParams("Folder does not exist or is not a directory: \(expandedPath)") + if let wid = targetWindowIDArg, approvalWindowOpt == nil { + let validIDs = windows.map { String($0.windowID) }.joined(separator: ", ") + throw MCPError.invalidParams("Unknown window_id \(wid). Valid window IDs: \(validIDs)") + } + if !openInNewWindow, targetWindowIDArg == nil, windows.count != 1 { + throw MCPError.invalidParams(Self.bindContextWindowSelectionMessage) + } + guard let approvalWindow = approvalWindowOpt else { + throw MCPError.invalidParams("No windows available to create workspace. Open at least one window first.") } - let normalizedPath = (expandedPath as NSString).standardizingPath - initialRepoPaths = [normalizedPath] - } else { - initialRepoPaths = [] - } - // Check if we should open in a new window - let openInNewWindow = args["open_in_new_window"]?.boolValue ?? false - let switchToCreated = args["switch_to_created"]?.boolValue ?? true + // Get client ID for approval + let clientID = await routingService.networkMgr.currentClientIdentifier() ?? "unknown-client" - // Determine target window for approval - let targetWindowIDArg = args["window_id"]?.intValue - let (windows, focusedWindowID) = await MainActor.run { () -> ([WindowState], Int?) in - let allWindows = self.windowStates.allWindows - let focusedID = allWindows.first(where: { $0.isCurrentlyFocused })?.windowID - return (allWindows, focusedID) - } + // Request approval + let approvalResult = await WorkspaceApprovalManager.shared.requestCreateWorkspaceApproval( + clientID: clientID, + workspaceName: workspaceName, + windowID: approvalWindow.windowID + ) - let approvalWindowOpt: WindowState? = { - if let wid = targetWindowIDArg { - return windows.first(where: { $0.windowID == wid }) + guard approvalResult.isApproved else { + throw MCPError.invalidRequest("Workspace creation was denied by the user.") } + if openInNewWindow { - if let focusedID = focusedWindowID { - return windows.first(where: { $0.windowID == focusedID }) + // Open a new window for the workspace + let newWindow: WindowState + do { + newWindow = try await routingService.openRoutingWindow(deferringInitialAgentSystemWorkspaceRefresh: switchToCreated) + } catch let error as WindowOpenError { + throw MCPError.internalError("Failed to open new window: \(error.localizedDescription)") + } catch { + throw MCPError.internalError("Failed to open new window: \(error)") + } + defer { + Task { @MainActor [newWindow] in + newWindow.agentModeViewModel.finishInitialSystemWorkspaceSessionListRefreshDeferral() + } } - return windows.last ?? windows.first - } - return windows.only - }() - if let wid = targetWindowIDArg, approvalWindowOpt == nil { - let validIDs = windows.map { String($0.windowID) }.joined(separator: ", ") - throw MCPError.invalidParams("Unknown window_id \(wid). Valid window IDs: \(validIDs)") - } - if !openInNewWindow, targetWindowIDArg == nil, windows.count != 1 { - throw MCPError.invalidParams(Self.bindContextWindowSelectionMessage) - } - guard let approvalWindow = approvalWindowOpt else { - throw MCPError.invalidParams("No windows available to create workspace. Open at least one window first.") - } + // Wait for initial workspace setup before creating + await newWindow.workspaceManager.awaitInitialized() - // Get client ID for approval - let clientID = await networkMgr.currentClientIdentifier() ?? "unknown-client" + // Create the workspace in the new window + let newWorkspace = await MainActor.run { + newWindow.workspaceManager.createWorkspace(name: workspaceName, repoPaths: initialRepoPaths) + } + if switchToCreated { + let switchResult = await newWindow.workspaceManager.requestWorkspaceSwitch(to: newWorkspace, saveState: true) + if !switchResult.didSwitch { + throw MCPError.invalidRequest(switchResult.message ?? "Workspace switch was cancelled.") + } + } - // Request approval - let approvalResult = await WorkspaceApprovalManager.shared.requestCreateWorkspaceApproval( - clientID: clientID, - workspaceName: workspaceName, - windowID: approvalWindow.windowID - ) + // Bind this MCP connection to the new window + try await routingService.networkMgr.setActiveWindowForCurrentConnection(newWindow.windowID) - guard approvalResult.isApproved else { - throw MCPError.invalidRequest("Workspace creation was denied by the user.") - } + let summary = MCPWorkspaceSummary( + id: newWorkspace.id, + name: newWorkspace.name, + allRepoPaths: newWorkspace.repoPaths, + showingWindowIDs: switchToCreated ? [newWindow.windowID] : [] + ) - if openInNewWindow { - // Open a new window for the workspace - let newWindow: WindowState - do { - newWindow = try await openRoutingWindow(deferringInitialAgentSystemWorkspaceRefresh: switchToCreated) - } catch let error as WindowOpenError { - throw MCPError.internalError("Failed to open new window: \(error.localizedDescription)") - } catch { - throw MCPError.internalError("Failed to open new window: \(error)") - } - defer { - Task { @MainActor [newWindow] in - newWindow.agentModeViewModel.finishInitialSystemWorkspaceSessionListRefreshDeferral() - } + return ManageWorkspacesResponse( + action: "create", + workspaces: [summary], + status: "ok", + windowID: newWindow.windowID + ) } - // Wait for initial workspace setup before creating - await newWindow.workspaceManager.awaitInitialized() - - // Create the workspace in the new window + // Create the workspace in the target window let newWorkspace = await MainActor.run { - newWindow.workspaceManager.createWorkspace(name: workspaceName, repoPaths: initialRepoPaths) + approvalWindow.workspaceManager.createWorkspace(name: workspaceName, repoPaths: initialRepoPaths) } + if switchToCreated { - let switchResult = await newWindow.workspaceManager.requestWorkspaceSwitch(to: newWorkspace, saveState: true) + let switchResult = await approvalWindow.workspaceManager.requestWorkspaceSwitch(to: newWorkspace, saveState: true) if !switchResult.didSwitch { throw MCPError.invalidRequest(switchResult.message ?? "Workspace switch was cancelled.") } } - // Bind this MCP connection to the new window - try await networkMgr.setActiveWindowForCurrentConnection(newWindow.windowID) - let summary = MCPWorkspaceSummary( id: newWorkspace.id, name: newWorkspace.name, allRepoPaths: newWorkspace.repoPaths, - showingWindowIDs: switchToCreated ? [newWindow.windowID] : [] + showingWindowIDs: switchToCreated ? [approvalWindow.windowID] : [] ) - return ManageWorkspacesResponse( - action: "create", - workspaces: [summary], - status: "ok", - windowID: newWindow.windowID - ) - } + return ManageWorkspacesResponse(action: "create", workspaces: [summary], status: "ok") - // Create the workspace in the target window - let newWorkspace = await MainActor.run { - approvalWindow.workspaceManager.createWorkspace(name: workspaceName, repoPaths: initialRepoPaths) - } + case "hide", "unhide": + guard let rawWorkspaceParam = args["workspace"]?.stringValue?.trimmingCharacters(in: .whitespacesAndNewlines), + !rawWorkspaceParam.isEmpty + else { + throw MCPError.invalidParams("Missing required 'workspace' parameter (UUID or name) for '\(action)' action.") + } - if switchToCreated { - let switchResult = await approvalWindow.workspaceManager.requestWorkspaceSwitch(to: newWorkspace, saveState: true) - if !switchResult.didSwitch { - throw MCPError.invalidRequest(switchResult.message ?? "Workspace switch was cancelled.") + let shouldHide = action == "hide" + let resolvedWorkspace = try await routingService.resolveWorkspaceForHiddenMutation(rawWorkspaceParam: rawWorkspaceParam, hidden: shouldHide) + guard !resolvedWorkspace.isSystemWorkspace else { + throw MCPError.invalidParams("Cannot \(action) system workspace '\(resolvedWorkspace.name)'.") + } + let mutationManagers = await MainActor.run { + routingService.windowStates.allWindows.map(\.workspaceManager) + } + guard let writerManager = mutationManagers.first else { + throw MCPError.invalidParams("No windows available to update workspace hidden state. Open at least one window first.") } - } - let summary = MCPWorkspaceSummary( - id: newWorkspace.id, - name: newWorkspace.name, - allRepoPaths: newWorkspace.repoPaths, - showingWindowIDs: switchToCreated ? [approvalWindow.windowID] : [] - ) + let updatedWorkspace = try await writerManager.setWorkspaceHiddenFromSnapshot(resolvedWorkspace, hidden: shouldHide) + await MainActor.run { + for manager in mutationManagers { + manager.applyWorkspaceHiddenStateInMemory( + workspaceID: updatedWorkspace.id, + hidden: updatedWorkspace.isHiddenInMenus, + dateModified: updatedWorkspace.dateModified + ) + } + } - return ManageWorkspacesResponse(action: "create", workspaces: [summary], status: "ok") + let showingWindowIDs = await MainActor.run { () -> [Int] in + routingService.windowStates.allWindows.compactMap { window in + guard window.workspaceManager.activeWorkspace?.id == updatedWorkspace.id else { return nil } + return window.windowID + }.sorted() + } + let summary = MCPWorkspaceSummary( + id: updatedWorkspace.id, + name: updatedWorkspace.name, + allRepoPaths: updatedWorkspace.repoPaths, + showingWindowIDs: showingWindowIDs, + isHidden: updatedWorkspace.isHiddenInMenus + ) + return ManageWorkspacesResponse(action: action, workspaces: [summary], status: "ok") - case "hide", "unhide": - guard let rawWorkspaceParam = args["workspace"]?.stringValue?.trimmingCharacters(in: .whitespacesAndNewlines), - !rawWorkspaceParam.isEmpty - else { - throw MCPError.invalidParams("Missing required 'workspace' parameter (UUID or name) for '\(action)' action.") - } + case "delete": + // Delete a workspace + guard let rawWorkspaceParam = args["workspace"]?.stringValue?.trimmingCharacters(in: .whitespacesAndNewlines), + !rawWorkspaceParam.isEmpty + else { + throw MCPError.invalidParams("Missing required 'workspace' parameter (UUID or name) for 'delete' action.") + } - let shouldHide = action == "hide" - let resolvedWorkspace = try await resolveWorkspaceForHiddenMutation(rawWorkspaceParam: rawWorkspaceParam, hidden: shouldHide) - guard !resolvedWorkspace.isSystemWorkspace else { - throw MCPError.invalidParams("Cannot \(action) system workspace '\(resolvedWorkspace.name)'.") - } - let mutationManagers = await MainActor.run { - self.windowStates.allWindows.map(\.workspaceManager) - } - guard let writerManager = mutationManagers.first else { - throw MCPError.invalidParams("No windows available to update workspace hidden state. Open at least one window first.") - } + let closeWindow = args["close_window"]?.boolValue ?? false + let includeHidden = args["include_hidden"]?.boolValue ?? false - let updatedWorkspace = try await writerManager.setWorkspaceHiddenFromSnapshot(resolvedWorkspace, hidden: shouldHide) - await MainActor.run { - for manager in mutationManagers { - manager.applyWorkspaceHiddenStateInMemory( - workspaceID: updatedWorkspace.id, - hidden: updatedWorkspace.isHiddenInMenus, - dateModified: updatedWorkspace.dateModified - ) - } - } + // Determine target window + let targetWindowIDArg = args["window_id"]?.intValue + let windows = await MainActor.run { routingService.windowStates.allWindows } - let showingWindowIDs = await MainActor.run { () -> [Int] in - self.windowStates.allWindows.compactMap { window in - guard window.workspaceManager.activeWorkspace?.id == updatedWorkspace.id else { return nil } - return window.windowID - }.sorted() - } - let summary = MCPWorkspaceSummary( - id: updatedWorkspace.id, - name: updatedWorkspace.name, - allRepoPaths: updatedWorkspace.repoPaths, - showingWindowIDs: showingWindowIDs, - isHidden: updatedWorkspace.isHiddenInMenus - ) - return ManageWorkspacesResponse(action: action, workspaces: [summary], status: "ok") - - case "delete": - // Delete a workspace - guard let rawWorkspaceParam = args["workspace"]?.stringValue?.trimmingCharacters(in: .whitespacesAndNewlines), - !rawWorkspaceParam.isEmpty - else { - throw MCPError.invalidParams("Missing required 'workspace' parameter (UUID or name) for 'delete' action.") - } + let targetWindowOpt: WindowState? = if let wid = targetWindowIDArg { + windows.first(where: { $0.windowID == wid }) + } else { + windows.only + } - let closeWindow = args["close_window"]?.boolValue ?? false - let includeHidden = args["include_hidden"]?.boolValue ?? false + if let wid = targetWindowIDArg, targetWindowOpt == nil { + let validIDs = windows.map { String($0.windowID) }.joined(separator: ", ") + throw MCPError.invalidParams("Unknown window_id \(wid). Valid window IDs: \(validIDs)") + } + if targetWindowIDArg == nil, windows.count != 1 { + throw MCPError.invalidParams(Self.bindContextWindowSelectionMessage) + } + guard let targetWindow = targetWindowOpt else { + throw MCPError.invalidParams("No valid target window found") + } - // Determine target window - let targetWindowIDArg = args["window_id"]?.intValue - let windows = await MainActor.run { self.windowStates.allWindows } + let workspace = try await routingService.resolveWorkspaceForDelete(rawWorkspaceParam: rawWorkspaceParam, includeHidden: includeHidden) - let targetWindowOpt: WindowState? = if let wid = targetWindowIDArg { - windows.first(where: { $0.windowID == wid }) - } else { - windows.only - } + await MainActor.run { + targetWindow.workspaceManager.reloadWorkspacesFromDisk() + } - if let wid = targetWindowIDArg, targetWindowOpt == nil { - let validIDs = windows.map { String($0.windowID) }.joined(separator: ", ") - throw MCPError.invalidParams("Unknown window_id \(wid). Valid window IDs: \(validIDs)") - } - if targetWindowIDArg == nil, windows.count != 1 { - throw MCPError.invalidParams(Self.bindContextWindowSelectionMessage) - } - guard let targetWindow = targetWindowOpt else { - throw MCPError.invalidParams("No valid target window found") - } + let showingWindowIDs = await MainActor.run { () -> [Int] in + routingService.windowStates.allWindows.compactMap { window in + guard window.workspaceManager.activeWorkspace?.id == workspace.id else { return nil } + return window.windowID + }.sorted() + } - let workspace = try await resolveWorkspaceForDelete(rawWorkspaceParam: rawWorkspaceParam, includeHidden: includeHidden) + if closeWindow { + guard showingWindowIDs.contains(targetWindow.windowID) else { + let detail = showingWindowIDs.isEmpty + ? "Workspace '\(workspace.name)' is not active in any window." + : "Workspace '\(workspace.name)' is active in windows: \(showingWindowIDs.map(String.init).joined(separator: ", "))" + throw MCPError.invalidParams("close_window requires the workspace to be active in the target window. \(detail)") + } + if showingWindowIDs.count > 1 { + throw MCPError.invalidParams("Workspace '\(workspace.name)' is active in multiple windows: \(showingWindowIDs.map(String.init).joined(separator: ", ")). Close those windows or switch them away before deleting.") + } + } else { + let isActive = await MainActor.run { + targetWindow.workspaceManager.activeWorkspace?.id == workspace.id + } + if isActive { + throw MCPError.invalidParams("Cannot delete the currently active workspace. Switch to another workspace first.") + } + } - await MainActor.run { - targetWindow.workspaceManager.reloadWorkspacesFromDisk() - } + // Get client ID for approval + let clientID = await routingService.networkMgr.currentClientIdentifier() ?? "unknown-client" - let showingWindowIDs = await MainActor.run { () -> [Int] in - self.windowStates.allWindows.compactMap { window in - guard window.workspaceManager.activeWorkspace?.id == workspace.id else { return nil } - return window.windowID - }.sorted() - } + // Request approval + let approvalResult = await WorkspaceApprovalManager.shared.requestDeleteWorkspaceApproval( + clientID: clientID, + workspaceName: workspace.name, + workspaceID: workspace.id, + windowID: targetWindow.windowID + ) - if closeWindow { - guard showingWindowIDs.contains(targetWindow.windowID) else { - let detail = showingWindowIDs.isEmpty - ? "Workspace '\(workspace.name)' is not active in any window." - : "Workspace '\(workspace.name)' is active in windows: \(showingWindowIDs.map(String.init).joined(separator: ", "))" - throw MCPError.invalidParams("close_window requires the workspace to be active in the target window. \(detail)") - } - if showingWindowIDs.count > 1 { - throw MCPError.invalidParams("Workspace '\(workspace.name)' is active in multiple windows: \(showingWindowIDs.map(String.init).joined(separator: ", ")). Close those windows or switch them away before deleting.") + guard approvalResult.isApproved else { + throw MCPError.invalidRequest("Workspace deletion was denied by the user.") } - } else { - let isActive = await MainActor.run { - targetWindow.workspaceManager.activeWorkspace?.id == workspace.id + + if closeWindow { + let fallback = await MainActor.run { + targetWindow.workspaceManager.getOrCreateSystemWorkspace() + } + let switchResult = await targetWindow.workspaceManager.requestWorkspaceSwitch(to: fallback, saveState: false) + if !switchResult.didSwitch { + throw MCPError.invalidRequest(switchResult.message ?? "Workspace switch was cancelled.") + } } - if isActive { - throw MCPError.invalidParams("Cannot delete the currently active workspace. Switch to another workspace first.") + + // Delete the workspace + await MainActor.run { + targetWindow.workspaceManager.deleteWorkspace(workspace) } - } - // Get client ID for approval - let clientID = await networkMgr.currentClientIdentifier() ?? "unknown-client" + if closeWindow { + let authorization = Self.workspaceDeleteCloseAuthorization() + try await MainActor.run { + try routingService.windowStates.requestCloseWindow( + windowID: targetWindow.windowID, + authorization: authorization + ) + } + } - // Request approval - let approvalResult = await WorkspaceApprovalManager.shared.requestDeleteWorkspaceApproval( - clientID: clientID, - workspaceName: workspace.name, - workspaceID: workspace.id, - windowID: targetWindow.windowID - ) + return ManageWorkspacesResponse( + action: "delete", + workspaces: nil, + status: "ok", + closedWindowID: closeWindow ? targetWindow.windowID : nil + ) - guard approvalResult.isApproved else { - throw MCPError.invalidRequest("Workspace deletion was denied by the user.") - } + case "add_folder": + // Add a folder to a workspace + // workspace param is optional - defaults to active workspace + let rawWorkspaceParam = args["workspace"]?.stringValue?.trimmingCharacters(in: .whitespacesAndNewlines) - if closeWindow { - let fallback = await MainActor.run { - targetWindow.workspaceManager.getOrCreateSystemWorkspace() - } - let switchResult = await targetWindow.workspaceManager.requestWorkspaceSwitch(to: fallback, saveState: false) - if !switchResult.didSwitch { - throw MCPError.invalidRequest(switchResult.message ?? "Workspace switch was cancelled.") + guard let folderPath = args["folder_path"]?.stringValue?.trimmingCharacters(in: .whitespacesAndNewlines), + !folderPath.isEmpty + else { + throw MCPError.invalidParams("Missing required 'folder_path' parameter for 'add_folder' action.") } - } - - // Delete the workspace - await MainActor.run { - targetWindow.workspaceManager.deleteWorkspace(workspace) - } - if closeWindow { - let authorization = Self.workspaceDeleteCloseAuthorization() - try await MainActor.run { - try self.windowStates.requestCloseWindow( - windowID: targetWindow.windowID, - authorization: authorization - ) + // Validate folder exists + let folderURL = URL(fileURLWithPath: folderPath) + var isDirectory: ObjCBool = false + guard FileManager.default.fileExists(atPath: folderPath, isDirectory: &isDirectory), + isDirectory.boolValue + else { + throw MCPError.invalidParams("Folder does not exist or is not a directory: \(folderPath)") } - } - return ManageWorkspacesResponse( - action: "delete", - workspaces: nil, - status: "ok", - closedWindowID: closeWindow ? targetWindow.windowID : nil - ) - - case "add_folder": - // Add a folder to a workspace - // workspace param is optional - defaults to active workspace - let rawWorkspaceParam = args["workspace"]?.stringValue?.trimmingCharacters(in: .whitespacesAndNewlines) - - guard let folderPath = args["folder_path"]?.stringValue?.trimmingCharacters(in: .whitespacesAndNewlines), - !folderPath.isEmpty - else { - throw MCPError.invalidParams("Missing required 'folder_path' parameter for 'add_folder' action.") - } - - // Validate folder exists - let folderURL = URL(fileURLWithPath: folderPath) - var isDirectory: ObjCBool = false - guard FileManager.default.fileExists(atPath: folderPath, isDirectory: &isDirectory), - isDirectory.boolValue - else { - throw MCPError.invalidParams("Folder does not exist or is not a directory: \(folderPath)") - } + // Determine target window + let targetWindowIDArg = args["window_id"]?.intValue + let windows = await MainActor.run { routingService.windowStates.allWindows } - // Determine target window - let targetWindowIDArg = args["window_id"]?.intValue - let windows = await MainActor.run { self.windowStates.allWindows } - - let targetWindowOpt: WindowState? = if let wid = targetWindowIDArg { - windows.first(where: { $0.windowID == wid }) - } else { - windows.only - } + let targetWindowOpt: WindowState? = if let wid = targetWindowIDArg { + windows.first(where: { $0.windowID == wid }) + } else { + windows.only + } - if let wid = targetWindowIDArg, targetWindowOpt == nil { - let validIDs = windows.map { String($0.windowID) }.joined(separator: ", ") - throw MCPError.invalidParams("Unknown window_id \(wid). Valid window IDs: \(validIDs)") - } - if targetWindowIDArg == nil, windows.count != 1 { - throw MCPError.invalidParams(Self.bindContextWindowSelectionMessage) - } - guard let targetWindow = targetWindowOpt else { - throw MCPError.invalidParams("No valid target window found") - } + if let wid = targetWindowIDArg, targetWindowOpt == nil { + let validIDs = windows.map { String($0.windowID) }.joined(separator: ", ") + throw MCPError.invalidParams("Unknown window_id \(wid). Valid window IDs: \(validIDs)") + } + if targetWindowIDArg == nil, windows.count != 1 { + throw MCPError.invalidParams(Self.bindContextWindowSelectionMessage) + } + guard let targetWindow = targetWindowOpt else { + throw MCPError.invalidParams("No valid target window found") + } - // Resolve workspace: explicit param, or default to active workspace - var targetWorkspace: WorkspaceModel? = nil - if let param = rawWorkspaceParam, !param.isEmpty { - if let targetID = UUID(uuidString: param) { - targetWorkspace = await MainActor.run { - targetWindow.workspaceManager.workspace(withID: targetID) + // Resolve workspace: explicit param, or default to active workspace + var targetWorkspace: WorkspaceModel? = nil + if let param = rawWorkspaceParam, !param.isEmpty { + if let targetID = UUID(uuidString: param) { + targetWorkspace = await MainActor.run { + targetWindow.workspaceManager.workspace(withID: targetID) + } + } else { + targetWorkspace = await MainActor.run { + targetWindow.workspaceManager.workspaces.first(where: { $0.name == param }) + } + } + guard targetWorkspace != nil else { + throw MCPError.invalidParams("Unknown workspace '\(param)'") } } else { + // Default to active workspace targetWorkspace = await MainActor.run { - targetWindow.workspaceManager.workspaces.first(where: { $0.name == param }) + targetWindow.workspaceManager.activeWorkspace + } + guard targetWorkspace != nil else { + throw MCPError.invalidParams("No active workspace in this window. Use manage_workspaces action='list' to see available workspaces, then action='switch' to load one.") } } - guard targetWorkspace != nil else { - throw MCPError.invalidParams("Unknown workspace '\(param)'") - } - } else { - // Default to active workspace - targetWorkspace = await MainActor.run { - targetWindow.workspaceManager.activeWorkspace - } - guard targetWorkspace != nil else { - throw MCPError.invalidParams("No active workspace in this window. Use manage_workspaces action='list' to see available workspaces, then action='switch' to load one.") - } - } - let workspace = targetWorkspace! - try Self.validateAddFolderWorkspace(workspace) + let workspace = targetWorkspace! + try Self.validateAddFolderWorkspace(workspace) - // Get client ID for approval - let clientID = await networkMgr.currentClientIdentifier() ?? "unknown-client" + // Get client ID for approval + let clientID = await routingService.networkMgr.currentClientIdentifier() ?? "unknown-client" - // Request approval - let approvalResult = await WorkspaceApprovalManager.shared.requestAddFolderApproval( - clientID: clientID, - folderPath: folderPath, - workspaceName: workspace.name, - workspaceID: workspace.id, - windowID: targetWindow.windowID - ) + // Request approval + let approvalResult = await WorkspaceApprovalManager.shared.requestAddFolderApproval( + clientID: clientID, + folderPath: folderPath, + workspaceName: workspace.name, + workspaceID: workspace.id, + windowID: targetWindow.windowID + ) - guard approvalResult.isApproved else { - throw MCPError.invalidRequest("Folder addition was denied by the user.") - } + guard approvalResult.isApproved else { + throw MCPError.invalidRequest("Folder addition was denied by the user.") + } - // Add the folder to the workspace - do { - try await targetWindow.workspaceManager.addFolder(folderURL, to: workspace) - } catch { - if let addError = error as? WorkspaceManagerViewModel.AddFolderError { - throw MCPError.invalidParams(addError.agentMessage) + // Add the folder to the workspace + do { + try await targetWindow.workspaceManager.addFolder(folderURL, to: workspace) + } catch { + if let addError = error as? WorkspaceManagerViewModel.AddFolderError { + throw MCPError.invalidParams(addError.agentMessage) + } + throw MCPError.internalError("Failed to add folder: \(error.localizedDescription)") } - throw MCPError.internalError("Failed to add folder: \(error.localizedDescription)") - } - // Return updated workspace info - let updatedWorkspace = await MainActor.run { - targetWindow.workspaceManager.workspace(withID: workspace.id) - } + // Return updated workspace info + let updatedWorkspace = await MainActor.run { + targetWindow.workspaceManager.workspace(withID: workspace.id) + } - if let updated = updatedWorkspace { - let summary = MCPWorkspaceSummary( - id: updated.id, - name: updated.name, - allRepoPaths: updated.repoPaths, - showingWindowIDs: [], - isHidden: updated.isHiddenInMenus - ) - return ManageWorkspacesResponse(action: "add_folder", workspaces: [summary], status: "ok") - } + if let updated = updatedWorkspace { + let summary = MCPWorkspaceSummary( + id: updated.id, + name: updated.name, + allRepoPaths: updated.repoPaths, + showingWindowIDs: [], + isHidden: updated.isHiddenInMenus + ) + return ManageWorkspacesResponse(action: "add_folder", workspaces: [summary], status: "ok") + } - return ManageWorkspacesResponse(action: "add_folder", workspaces: nil, status: "ok") + return ManageWorkspacesResponse(action: "add_folder", workspaces: nil, status: "ok") - case "remove_folder": - // Remove a folder from a workspace - // workspace param is optional - defaults to active workspace - let rawWorkspaceParam = args["workspace"]?.stringValue?.trimmingCharacters(in: .whitespacesAndNewlines) + case "remove_folder": + // Remove a folder from a workspace + // workspace param is optional - defaults to active workspace + let rawWorkspaceParam = args["workspace"]?.stringValue?.trimmingCharacters(in: .whitespacesAndNewlines) - guard let folderPath = args["folder_path"]?.stringValue?.trimmingCharacters(in: .whitespacesAndNewlines), - !folderPath.isEmpty - else { - throw MCPError.invalidParams("Missing required 'folder_path' parameter for 'remove_folder' action.") - } + guard let folderPath = args["folder_path"]?.stringValue?.trimmingCharacters(in: .whitespacesAndNewlines), + !folderPath.isEmpty + else { + throw MCPError.invalidParams("Missing required 'folder_path' parameter for 'remove_folder' action.") + } - // Determine target window - let targetWindowIDArg = args["window_id"]?.intValue - let windows = await MainActor.run { self.windowStates.allWindows } + // Determine target window + let targetWindowIDArg = args["window_id"]?.intValue + let windows = await MainActor.run { routingService.windowStates.allWindows } - let targetWindowOpt: WindowState? = if let wid = targetWindowIDArg { - windows.first(where: { $0.windowID == wid }) - } else { - windows.only - } + let targetWindowOpt: WindowState? = if let wid = targetWindowIDArg { + windows.first(where: { $0.windowID == wid }) + } else { + windows.only + } - if let wid = targetWindowIDArg, targetWindowOpt == nil { - let validIDs = windows.map { String($0.windowID) }.joined(separator: ", ") - throw MCPError.invalidParams("Unknown window_id \(wid). Valid window IDs: \(validIDs)") - } - if targetWindowIDArg == nil, windows.count != 1 { - throw MCPError.invalidParams(Self.bindContextWindowSelectionMessage) - } - guard let targetWindow = targetWindowOpt else { - throw MCPError.invalidParams("No valid target window found") - } + if let wid = targetWindowIDArg, targetWindowOpt == nil { + let validIDs = windows.map { String($0.windowID) }.joined(separator: ", ") + throw MCPError.invalidParams("Unknown window_id \(wid). Valid window IDs: \(validIDs)") + } + if targetWindowIDArg == nil, windows.count != 1 { + throw MCPError.invalidParams(Self.bindContextWindowSelectionMessage) + } + guard let targetWindow = targetWindowOpt else { + throw MCPError.invalidParams("No valid target window found") + } - // Resolve workspace: explicit param, or default to active workspace - var targetWorkspace: WorkspaceModel? = nil - if let param = rawWorkspaceParam, !param.isEmpty { - if let targetID = UUID(uuidString: param) { - targetWorkspace = await MainActor.run { - targetWindow.workspaceManager.workspace(withID: targetID) + // Resolve workspace: explicit param, or default to active workspace + var targetWorkspace: WorkspaceModel? = nil + if let param = rawWorkspaceParam, !param.isEmpty { + if let targetID = UUID(uuidString: param) { + targetWorkspace = await MainActor.run { + targetWindow.workspaceManager.workspace(withID: targetID) + } + } else { + targetWorkspace = await MainActor.run { + targetWindow.workspaceManager.workspaces.first(where: { $0.name == param }) + } + } + guard targetWorkspace != nil else { + throw MCPError.invalidParams("Unknown workspace '\(param)'") } } else { + // Default to active workspace targetWorkspace = await MainActor.run { - targetWindow.workspaceManager.workspaces.first(where: { $0.name == param }) + targetWindow.workspaceManager.activeWorkspace + } + guard targetWorkspace != nil else { + throw MCPError.invalidParams("No active workspace in this window. Use manage_workspaces action='list' to see available workspaces, then action='switch' to load one.") } } - guard targetWorkspace != nil else { - throw MCPError.invalidParams("Unknown workspace '\(param)'") - } - } else { - // Default to active workspace - targetWorkspace = await MainActor.run { - targetWindow.workspaceManager.activeWorkspace - } - guard targetWorkspace != nil else { - throw MCPError.invalidParams("No active workspace in this window. Use manage_workspaces action='list' to see available workspaces, then action='switch' to load one.") - } - } - - let workspace = targetWorkspace! - - // Verify folder is in the workspace - let normalizedPath = (folderPath as NSString).standardizingPath - let folderInWorkspace = workspace.repoPaths.contains { path in - let normalized = (path as NSString).standardizingPath - return normalized.caseInsensitiveCompare(normalizedPath) == .orderedSame - } - - guard folderInWorkspace else { - throw MCPError.invalidParams("Folder '\(folderPath)' is not in workspace '\(workspace.name)'") - } - - // Get client ID for approval - let clientID = await networkMgr.currentClientIdentifier() ?? "unknown-client" - // Request approval - let approvalResult = await WorkspaceApprovalManager.shared.requestRemoveFolderApproval( - clientID: clientID, - folderPath: folderPath, - workspaceName: workspace.name, - workspaceID: workspace.id, - windowID: targetWindow.windowID - ) + let workspace = targetWorkspace! - guard approvalResult.isApproved else { - throw MCPError.invalidRequest("Folder removal was denied by the user.") - } + // Verify folder is in the workspace + let normalizedPath = (folderPath as NSString).standardizingPath + let folderInWorkspace = workspace.repoPaths.contains { path in + let normalized = (path as NSString).standardizingPath + return normalized.caseInsensitiveCompare(normalizedPath) == .orderedSame + } - // Remove the folder from the workspace - await targetWindow.workspaceManager.removeFolder(folderPath, from: workspace) + guard folderInWorkspace else { + throw MCPError.invalidParams("Folder '\(folderPath)' is not in workspace '\(workspace.name)'") + } - // Return updated workspace info - let updatedWorkspace = await MainActor.run { - targetWindow.workspaceManager.workspace(withID: workspace.id) - } + // Get client ID for approval + let clientID = await routingService.networkMgr.currentClientIdentifier() ?? "unknown-client" - if let updated = updatedWorkspace { - let summary = MCPWorkspaceSummary( - id: updated.id, - name: updated.name, - allRepoPaths: updated.repoPaths, - showingWindowIDs: [], - isHidden: updated.isHiddenInMenus - ) - return ManageWorkspacesResponse(action: "remove_folder", workspaces: [summary], status: "ok") - } - - return ManageWorkspacesResponse(action: "remove_folder", workspaces: nil, status: "ok") - - case "list_tabs": - let targetWindow = try await resolveTargetWindow(windowID: args["window_id"]?.intValue) - let connectionID = await networkMgr.currentConnectionUUID() - let (workspace, activeTabID, tabs, boundTabID): (WorkspaceModel?, UUID?, [ComposeTabState], UUID?) = await MainActor.run { - let workspace = targetWindow.workspaceManager.activeWorkspace - return ( - workspace, - workspace?.activeComposeTabID, - workspace?.composeTabs ?? [], - targetWindow.mcpServer.boundTabID(forConnection: connectionID) + // Request approval + let approvalResult = await WorkspaceApprovalManager.shared.requestRemoveFolderApproval( + clientID: clientID, + folderPath: folderPath, + workspaceName: workspace.name, + workspaceID: workspace.id, + windowID: targetWindow.windowID ) - } - guard let workspace else { - throw MCPError.invalidParams("No active workspace loaded in this window. Use manage_workspaces action='list' to see available workspaces, then action='switch' to load one.") - } - - let summaries = await MainActor.run { - tabs.map { - self.makeComposeTabSummary( - tab: $0, - workspace: workspace, - windowID: targetWindow.windowID, - activeTabID: activeTabID, - boundTabID: boundTabID - ) + guard approvalResult.isApproved else { + throw MCPError.invalidRequest("Folder removal was denied by the user.") } - } - return ManageWorkspacesResponse(action: "list_tabs", workspaces: nil, tabs: summaries, status: "ok") - case "select_tab": - guard let rawTabParam = args["tab"]?.stringValue?.trimmingCharacters(in: .whitespacesAndNewlines), - !rawTabParam.isEmpty - else { - throw MCPError.invalidParams("Missing required 'tab' parameter (UUID or name) for 'select_tab' action.") - } + // Remove the folder from the workspace + await targetWindow.workspaceManager.removeFolder(folderPath, from: workspace) - let targetWindow = try await resolveTargetWindow(windowID: args["window_id"]?.intValue) - let shouldFocus = args["focus"]?.boolValue ?? false - let connectionID = await networkMgr.currentConnectionUUID() - let clientName = await networkMgr.currentClientIdentifier() - let (workspace, tabs): (WorkspaceModel?, [ComposeTabState]) = await MainActor.run { - let workspace = targetWindow.workspaceManager.activeWorkspace - return (workspace, workspace?.composeTabs ?? []) - } - - guard let workspace else { - throw MCPError.invalidParams("No active workspace loaded in this window. Use manage_workspaces action='list' to see available workspaces, then action='switch' to load one.") - } - guard let connectionID else { - throw MCPError.internalError("No active connection context") - } + // Return updated workspace info + let updatedWorkspace = await MainActor.run { + targetWindow.workspaceManager.workspace(withID: workspace.id) + } - let tab = try await MainActor.run { - try self.resolveComposeTab(rawTabParam: rawTabParam, tabs: tabs) - } + if let updated = updatedWorkspace { + let summary = MCPWorkspaceSummary( + id: updated.id, + name: updated.name, + allRepoPaths: updated.repoPaths, + showingWindowIDs: [], + isHidden: updated.isHiddenInMenus + ) + return ManageWorkspacesResponse(action: "remove_folder", workspaces: [summary], status: "ok") + } - try await networkMgr.setActiveWindowForCurrentConnection(targetWindow.windowID) - try await MainActor.run { - try targetWindow.mcpServer.bindTabForConnection( - connectionID: connectionID, - clientName: clientName, - tabID: tab.id, - workspaceID: workspace.id, - windowID: targetWindow.windowID - ) - } + return ManageWorkspacesResponse(action: "remove_folder", workspaces: nil, status: "ok") + + case "list_tabs": + let targetWindow = try await routingService.resolveTargetWindow(windowID: args["window_id"]?.intValue) + let connectionID = await routingService.networkMgr.currentConnectionUUID() + let (workspace, activeTabID, tabs, boundTabID): (WorkspaceModel?, UUID?, [ComposeTabState], UUID?) = await MainActor.run { + let workspace = targetWindow.workspaceManager.activeWorkspace + return ( + workspace, + workspace?.activeComposeTabID, + workspace?.composeTabs ?? [], + targetWindow.mcpServer.boundTabID(forConnection: connectionID) + ) + } - if shouldFocus { - await targetWindow.promptManager.switchComposeTab(tab.id) - } + guard let workspace else { + throw MCPError.invalidParams("No active workspace loaded in this window. Use manage_workspaces action='list' to see available workspaces, then action='switch' to load one.") + } - return ManageWorkspacesResponse(action: "select_tab", workspaces: nil, status: "ok") + let summaries = await MainActor.run { + tabs.map { + routingService.makeComposeTabSummary( + tab: $0, + workspace: workspace, + windowID: targetWindow.windowID, + activeTabID: activeTabID, + boundTabID: boundTabID + ) + } + } + return ManageWorkspacesResponse(action: "list_tabs", workspaces: nil, tabs: summaries, status: "ok") - case "create_tab": - let targetWindow = try await resolveTargetWindow(windowID: args["window_id"]?.intValue) - let connectionID = await networkMgr.currentConnectionUUID() - let clientName = await networkMgr.currentClientIdentifier() - let mode = args["mode"]?.stringValue?.lowercased() ?? "blank" - let shouldBind = args["bind"]?.boolValue ?? true - let shouldFocus = args["focus"]?.boolValue ?? false - let requestedName = args["name"]?.stringValue?.trimmingCharacters(in: .whitespacesAndNewlines) - - let (workspace, activeTabID, tabs, boundTabID): (WorkspaceModel?, UUID?, [ComposeTabState], UUID?) = await MainActor.run { - let workspace = targetWindow.workspaceManager.activeWorkspace - return ( - workspace, - workspace?.activeComposeTabID, - workspace?.composeTabs ?? [], - targetWindow.mcpServer.boundTabID(forConnection: connectionID) - ) - } + case "select_tab": + guard let rawTabParam = args["tab"]?.stringValue?.trimmingCharacters(in: .whitespacesAndNewlines), + !rawTabParam.isEmpty + else { + throw MCPError.invalidParams("Missing required 'tab' parameter (UUID or name) for 'select_tab' action.") + } - guard let workspace else { - throw MCPError.invalidParams("No active workspace loaded in this window. Use manage_workspaces action='list' to see available workspaces, then action='switch' to load one.") - } + let targetWindow = try await routingService.resolveTargetWindow(windowID: args["window_id"]?.intValue) + let shouldFocus = args["focus"]?.boolValue ?? false + let connectionID = await routingService.networkMgr.currentConnectionUUID() + let clientName = await routingService.networkMgr.currentClientIdentifier() + let (workspace, tabs): (WorkspaceModel?, [ComposeTabState]) = await MainActor.run { + let workspace = targetWindow.workspaceManager.activeWorkspace + return (workspace, workspace?.composeTabs ?? []) + } - let newTab: ComposeTabState - switch mode { - case "blank": - guard let created = await targetWindow.promptManager.createBackgroundComposeTab(strategy: .blank, name: requestedName) else { - throw MCPError.internalError("Failed to create compose tab") - } - newTab = created - case "fork": - let sourceRaw = args["source_tab"]?.stringValue?.trimmingCharacters(in: .whitespacesAndNewlines) - let sourceTab: ComposeTabState - if let sourceRaw, !sourceRaw.isEmpty { - sourceTab = try await MainActor.run { - try self.resolveComposeTab(rawTabParam: sourceRaw, tabs: tabs) - } - } else if let boundTabID, let boundTab = tabs.first(where: { $0.id == boundTabID }) { - sourceTab = boundTab - } else if let activeTabID, let activeTab = tabs.first(where: { $0.id == activeTabID }) { - sourceTab = activeTab - } else { - throw MCPError.invalidParams("create_tab mode='fork' requires a source tab or an active/bound tab") + guard let workspace else { + throw MCPError.invalidParams("No active workspace loaded in this window. Use manage_workspaces action='list' to see available workspaces, then action='switch' to load one.") } - guard let created = await targetWindow.promptManager.createBackgroundForkComposeTab(sourceTabID: sourceTab.id, named: requestedName) else { - throw MCPError.internalError("Failed to fork compose tab") + guard let connectionID else { + throw MCPError.internalError("No active connection context") } - newTab = created - default: - throw MCPError.invalidParams("Unsupported create_tab mode '\(mode)'. Use 'blank' or 'fork'.") - } - try await networkMgr.setActiveWindowForCurrentConnection(targetWindow.windowID) + let tab = try await MainActor.run { + try routingService.resolveComposeTab(rawTabParam: rawTabParam, tabs: tabs) + } - if shouldBind, let connectionID { + try await routingService.networkMgr.setActiveWindowForCurrentConnection(targetWindow.windowID) try await MainActor.run { try targetWindow.mcpServer.bindTabForConnection( connectionID: connectionID, clientName: clientName, - tabID: newTab.id, + tabID: tab.id, workspaceID: workspace.id, windowID: targetWindow.windowID ) } - } - if shouldFocus { - await targetWindow.promptManager.switchComposeTab(newTab.id) - } + if shouldFocus { + await targetWindow.promptManager.switchComposeTab(tab.id) + } - let summary = await MainActor.run { - self.makeComposeTabSummary( - tab: newTab, - workspace: workspace, - windowID: targetWindow.windowID, - activeTabID: shouldFocus ? newTab.id : activeTabID, - boundTabID: shouldBind ? newTab.id : boundTabID - ) - } - return ManageWorkspacesResponse(action: "create_tab", workspaces: nil, tabs: [summary], status: "ok") - - case "close_tab": - let rawTabParam = args["tab"]?.stringValue?.trimmingCharacters(in: .whitespacesAndNewlines) - let contextID = try Self.parseContextID(args["context_id"], action: "close_tab") - - let targetWindow = try await resolveTargetWindow(windowID: args["window_id"]?.intValue) - let allowActive = args["allow_active"]?.boolValue ?? false - let connectionID = await networkMgr.currentConnectionUUID() - let (workspace, activeTabID, tabs, boundTabID): (WorkspaceModel?, UUID?, [ComposeTabState], UUID?) = await MainActor.run { - let workspace = targetWindow.workspaceManager.activeWorkspace - return ( - workspace, - workspace?.activeComposeTabID, - workspace?.composeTabs ?? [], - targetWindow.mcpServer.boundTabID(forConnection: connectionID) - ) - } + return ManageWorkspacesResponse(action: "select_tab", workspaces: nil, status: "ok") + + case "create_tab": + let targetWindow = try await routingService.resolveTargetWindow(windowID: args["window_id"]?.intValue) + let connectionID = await routingService.networkMgr.currentConnectionUUID() + let clientName = await routingService.networkMgr.currentClientIdentifier() + let mode = args["mode"]?.stringValue?.lowercased() ?? "blank" + let shouldBind = args["bind"]?.boolValue ?? true + let shouldFocus = args["focus"]?.boolValue ?? false + let requestedName = args["name"]?.stringValue?.trimmingCharacters(in: .whitespacesAndNewlines) + + let (workspace, activeTabID, tabs, boundTabID): (WorkspaceModel?, UUID?, [ComposeTabState], UUID?) = await MainActor.run { + let workspace = targetWindow.workspaceManager.activeWorkspace + return ( + workspace, + workspace?.activeComposeTabID, + workspace?.composeTabs ?? [], + targetWindow.mcpServer.boundTabID(forConnection: connectionID) + ) + } - guard let workspace, !tabs.isEmpty else { - throw MCPError.invalidParams("No active workspace with compose tabs loaded in this window. Use manage_workspaces action='list' to see available workspaces, then action='switch' to load one.") - } + guard let workspace else { + throw MCPError.invalidParams("No active workspace loaded in this window. Use manage_workspaces action='list' to see available workspaces, then action='switch' to load one.") + } - let tab = try await MainActor.run { - try self.resolveComposeTab( - rawTabParam: rawTabParam, - contextID: contextID, - tabs: tabs, - action: "close_tab" - ) - } - guard tabs.count > 1 else { - throw MCPError.invalidParams("Cannot close the last remaining compose tab.") - } - if activeTabID == tab.id, !allowActive { - throw MCPError.invalidParams("Refusing to close the active visible tab. Pass allow_active=true to close it explicitly.") - } + let newTab: ComposeTabState + switch mode { + case "blank": + guard let created = await targetWindow.promptManager.createBackgroundComposeTab(strategy: .blank, name: requestedName) else { + throw MCPError.internalError("Failed to create compose tab") + } + newTab = created + case "fork": + let sourceRaw = args["source_tab"]?.stringValue?.trimmingCharacters(in: .whitespacesAndNewlines) + let sourceTab: ComposeTabState + if let sourceRaw, !sourceRaw.isEmpty { + sourceTab = try await MainActor.run { + try routingService.resolveComposeTab(rawTabParam: sourceRaw, tabs: tabs) + } + } else if let boundTabID, let boundTab = tabs.first(where: { $0.id == boundTabID }) { + sourceTab = boundTab + } else if let activeTabID, let activeTab = tabs.first(where: { $0.id == activeTabID }) { + sourceTab = activeTab + } else { + throw MCPError.invalidParams("create_tab mode='fork' requires a source tab or an active/bound tab") + } + guard let created = await targetWindow.promptManager.createBackgroundForkComposeTab(sourceTabID: sourceTab.id, named: requestedName) else { + throw MCPError.internalError("Failed to fork compose tab") + } + newTab = created + default: + throw MCPError.invalidParams("Unsupported create_tab mode '\(mode)'. Use 'blank' or 'fork'.") + } - let liveRunIDs = await MainActor.run { - targetWindow.mcpServer.liveRunIDsBound(toTabID: tab.id) - } - if !liveRunIDs.isEmpty { - let joined = liveRunIDs.map(\.uuidString).joined(separator: ", ") - throw MCPError.invalidParams("Refusing to close tab '\(tab.name)' because it has live bound runs: \(joined)") - } + try await routingService.networkMgr.setActiveWindowForCurrentConnection(targetWindow.windowID) + + if shouldBind, let connectionID { + try await MainActor.run { + try targetWindow.mcpServer.bindTabForConnection( + connectionID: connectionID, + clientName: clientName, + tabID: newTab.id, + workspaceID: workspace.id, + windowID: targetWindow.windowID + ) + } + } - let summary = await MainActor.run { - self.makeComposeTabSummary( - tab: tab, - workspace: workspace, - windowID: targetWindow.windowID, - activeTabID: activeTabID, - boundTabID: boundTabID - ) - } + if shouldFocus { + await targetWindow.promptManager.switchComposeTab(newTab.id) + } + + let summary = await MainActor.run { + routingService.makeComposeTabSummary( + tab: newTab, + workspace: workspace, + windowID: targetWindow.windowID, + activeTabID: shouldFocus ? newTab.id : activeTabID, + boundTabID: shouldBind ? newTab.id : boundTabID + ) + } + return ManageWorkspacesResponse(action: "create_tab", workspaces: nil, tabs: [summary], status: "ok") + + case "close_tab": + let rawTabParam = args["tab"]?.stringValue?.trimmingCharacters(in: .whitespacesAndNewlines) + let contextID = try Self.parseContextID(args["context_id"], action: "close_tab") + + let targetWindow = try await routingService.resolveTargetWindow(windowID: args["window_id"]?.intValue) + let allowActive = args["allow_active"]?.boolValue ?? false + let connectionID = await routingService.networkMgr.currentConnectionUUID() + let (workspace, activeTabID, tabs, boundTabID): (WorkspaceModel?, UUID?, [ComposeTabState], UUID?) = await MainActor.run { + let workspace = targetWindow.workspaceManager.activeWorkspace + return ( + workspace, + workspace?.activeComposeTabID, + workspace?.composeTabs ?? [], + targetWindow.mcpServer.boundTabID(forConnection: connectionID) + ) + } - await targetWindow.promptManager.closeComposeTab(tab.id) + guard let workspace, !tabs.isEmpty else { + throw MCPError.invalidParams("No active workspace with compose tabs loaded in this window. Use manage_workspaces action='list' to see available workspaces, then action='switch' to load one.") + } - return ManageWorkspacesResponse(action: "close_tab", workspaces: nil, tabs: [summary], status: "ok") + let tab = try await MainActor.run { + try routingService.resolveComposeTab( + rawTabParam: rawTabParam, + contextID: contextID, + tabs: tabs, + action: "close_tab" + ) + } + guard tabs.count > 1 else { + throw MCPError.invalidParams("Cannot close the last remaining compose tab.") + } + if activeTabID == tab.id, !allowActive { + throw MCPError.invalidParams("Refusing to close the active visible tab. Pass allow_active=true to close it explicitly.") + } - default: - throw MCPError.invalidParams("Unsupported action '\(action)'. Use 'list', 'switch', 'create', 'hide', 'unhide', 'delete', 'add_folder', 'remove_folder', 'list_tabs', 'select_tab', 'create_tab', or 'close_tab'.") + let liveRunIDs = await MainActor.run { + targetWindow.mcpServer.liveRunIDsBound(toTabID: tab.id) + } + if !liveRunIDs.isEmpty { + let joined = liveRunIDs.map(\.uuidString).joined(separator: ", ") + throw MCPError.invalidParams("Refusing to close tab '\(tab.name)' because it has live bound runs: \(joined)") + } + + let summary = await MainActor.run { + routingService.makeComposeTabSummary( + tab: tab, + workspace: workspace, + windowID: targetWindow.windowID, + activeTabID: activeTabID, + boundTabID: boundTabID + ) + } + + await targetWindow.promptManager.closeComposeTab(tab.id) + + return ManageWorkspacesResponse(action: "close_tab", workspaces: nil, tabs: [summary], status: "ok") + + default: + throw MCPError.invalidParams("Unsupported action '\(action)'. Use 'list', 'switch', 'create', 'hide', 'unhide', 'delete', 'add_folder', 'remove_folder', 'list_tabs', 'select_tab', 'create_tab', or 'close_tab'.") + } } } ) @@ -3099,6 +3107,7 @@ final class WindowRoutingService: Service { } // --------------------------------------------------------------------- + // MARK: Tools /// --------------------------------------------------------------------- diff --git a/Sources/RepoPrompt/Infrastructure/MCP/WorkspaceActionSentryTelemetry.swift b/Sources/RepoPrompt/Infrastructure/MCP/WorkspaceActionSentryTelemetry.swift new file mode 100644 index 000000000..52da5a742 --- /dev/null +++ b/Sources/RepoPrompt/Infrastructure/MCP/WorkspaceActionSentryTelemetry.swift @@ -0,0 +1,69 @@ +import Foundation + +enum WorkspaceActionSentryTelemetry { + static func trace( + actionName: String, + _ work: () async throws -> T + ) async rethrows -> T { + let workspaceAction = workspaceAction(for: actionName) + let attributes = attributes(action: workspaceAction, outcome: .started) + SentryTelemetryBootstrap.addBreadcrumb( + .workspaceAction, + action: .workspaceActionStarted, + attributes: attributes + ) + return try await SentryTelemetryBootstrap.traceAsync(.workspaceAction, attributes: attributes) { + do { + let result = try await work() + SentryTelemetryBootstrap.addBreadcrumb( + .workspaceAction, + action: .workspaceActionCompleted, + attributes: self.attributes(action: workspaceAction, outcome: .completed) + ) + return result + } catch { + SentryTelemetryBootstrap.addBreadcrumb( + .workspaceAction, + action: .workspaceActionFailed, + attributes: self.attributes(action: workspaceAction, outcome: .failed, isError: true) + ) + throw error + } + } + } + + private static func attributes( + action: SentryTelemetryBootstrap.WorkspaceAction, + outcome: SentryTelemetryBootstrap.Outcome, + isError: Bool = false + ) -> [SentryTelemetryBootstrap.Attribute] { + [ + .entrypoint(.mcp), + .clientClass(.externalAgent), + .toolName(.manageWorkspaces), + .toolDomain(.workspace), + .workspaceAction(action), + .outcome(outcome), + .isError(isError) + ] + } + + private static func workspaceAction(for actionName: String) -> SentryTelemetryBootstrap.WorkspaceAction { + switch actionName { + case "create": + .create + case "delete": + .delete + case "add_folder", "remove_folder": + .folder + case "hide", "unhide": + .hide + case "switch": + .switchWorkspace + case "list_tabs", "select_tab", "create_tab", "close_tab": + .tab + default: + .switchWorkspace + } + } +} From 000cdcf4a0299b86cacd0e61b1bafcea57ddc483 Mon Sep 17 00:00:00 2001 From: Cameron Cooke Date: Mon, 29 Jun 2026 21:50:08 +0100 Subject: [PATCH 08/12] feat(telemetry): instrument Agent Mode run lifecycle Refs GH-183 --- .../Runtime/AgentModeRunService.swift | 2 + .../Runtime/AgentRunSentryTelemetry.swift | 417 ++++++++++++++++++ .../AgentRunTerminalCommitBarrier.swift | 17 +- ...gentModeViewModel+InteractionActions.swift | 92 ++++ .../AgentModeViewModel+TabSession.swift | 11 + .../AgentModeViewModel+WorktreeMerge.swift | 14 + .../ViewModels/AgentModeViewModel.swift | 24 + 7 files changed, 575 insertions(+), 2 deletions(-) create mode 100644 Sources/RepoPrompt/Features/AgentMode/Runtime/AgentRunSentryTelemetry.swift diff --git a/Sources/RepoPrompt/Features/AgentMode/Runtime/AgentModeRunService.swift b/Sources/RepoPrompt/Features/AgentMode/Runtime/AgentModeRunService.swift index f536b1346..94ea45208 100644 --- a/Sources/RepoPrompt/Features/AgentMode/Runtime/AgentModeRunService.swift +++ b/Sources/RepoPrompt/Features/AgentMode/Runtime/AgentModeRunService.swift @@ -181,6 +181,8 @@ final class AgentModeRunService { return selectedAgent == .codexExec ? .failed(message: message) : nil } + AgentRunSentryTelemetry.recordStarted(session: session, attachments: attachments) + if selectedAgent == .codexExec { return await codexRunner.startRun( tabID: tabID, diff --git a/Sources/RepoPrompt/Features/AgentMode/Runtime/AgentRunSentryTelemetry.swift b/Sources/RepoPrompt/Features/AgentMode/Runtime/AgentRunSentryTelemetry.swift new file mode 100644 index 000000000..286705011 --- /dev/null +++ b/Sources/RepoPrompt/Features/AgentMode/Runtime/AgentRunSentryTelemetry.swift @@ -0,0 +1,417 @@ +import Foundation + +enum AgentRunSentryTelemetry { + @MainActor private static var observedProviderEventRunKeys: Set = [] + + @MainActor + static func recordStarted( + session: AgentModeViewModel.TabSession, + attachments: [AgentImageAttachment] + ) { + #if REPOPROMPT_SENTRY_ENABLED + let attributes = baseAttributes(for: session, outcome: .started) + + [.attachmentCount(attachments.count)] + SentryTelemetryBootstrap.addBreadcrumb( + .agentRun, + action: .agentRunStarted, + attributes: attributes + ) + SentryTelemetryBootstrap.increment( + .agentRunSessionStarts, + attributes: attributes + ) + SentryTelemetryBootstrap.gauge(.agentRunActive, value: 1, attributes: attributes) + #endif + } + + @MainActor + static func recordTerminal( + session: AgentModeViewModel.TabSession, + terminalState: AgentSessionRunState + ) { + #if REPOPROMPT_SENTRY_ENABLED + guard let action = action(for: terminalState), + let outcome = outcome(for: terminalState) + else { return } + + let attributes = runSummaryAttributes(for: session, outcome: outcome) + observedProviderEventRunKeys.remove(providerEventRunKey(for: session)) + SentryTelemetryBootstrap.addBreadcrumb( + .agentRun, + action: action, + attributes: attributes + ) + SentryTelemetryBootstrap.gauge(.agentRunActive, value: 0, attributes: attributes) + if let startedAt = session.activeAgentRunStartedAt { + SentryTelemetryBootstrap.distributionMilliseconds( + .agentRunDuration, + value: max(0, Date().timeIntervalSince(startedAt) * 1000), + attributes: attributes + ) + } + #endif + } + + @MainActor + static func recordItemAppended(session: AgentModeViewModel.TabSession, item: AgentChatItem) { + #if REPOPROMPT_SENTRY_ENABLED + recordProviderFirstEventIfNeeded(session: session) + recordMessageObserved(session: session, item: item) + switch item.kind { + case .toolCall: + recordToolStarted(session: session, item: item) + case .toolResult: + recordToolTerminal(session: session, item: item) + case .assistant, .assistantInline, .error, .system, .thinking, .user: + break + } + #endif + } + + @MainActor + static func recordItemReplaced( + session: AgentModeViewModel.TabSession, + previousItem: AgentChatItem, + updatedItem: AgentChatItem + ) { + #if REPOPROMPT_SENTRY_ENABLED + if previousItem.kind != .toolCall, updatedItem.kind == .toolCall { + recordToolStarted(session: session, item: updatedItem) + } + if previousItem.kind != .toolResult, updatedItem.kind == .toolResult { + recordToolTerminal(session: session, item: updatedItem) + } + #endif + } + + @MainActor + static func recordApprovalDecision( + session: AgentModeViewModel.TabSession, + kind: SentryTelemetryBootstrap.ApprovalKind, + outcome: SentryTelemetryBootstrap.ApprovalOutcome, + cancellationReason: SentryTelemetryBootstrap.CancellationReason? = nil + ) { + #if REPOPROMPT_SENTRY_ENABLED + var attributes = baseAttributes(for: session, outcome: outcome == .approved ? .accepted : .rejected) + attributes.append(.approvalKind(kind)) + attributes.append(.approvalOutcome(outcome)) + attributes.append(.messageRole(.tool)) + let toolName = toolName(for: kind) + attributes.append(.toolDomain(toolName.domain)) + attributes.append(.toolName(toolName)) + if let cancellationReason { + attributes.append(.cancellationReason(cancellationReason)) + } + SentryTelemetryBootstrap.addBreadcrumb( + .agentTool, + action: outcome == .approved ? .agentToolCompleted : .agentToolFailed, + attributes: attributes + ) + #endif + } + + @MainActor + static func recordRuntimeEvent( + session: AgentModeViewModel.TabSession, + event: SentryTelemetryBootstrap.RuntimeEvent, + action: SentryTelemetryBootstrap.Action, + outcome: SentryTelemetryBootstrap.Outcome + ) { + #if REPOPROMPT_SENTRY_ENABLED + let attributes = baseAttributes(for: session, outcome: outcome) + [.runtimeEvent(event)] + SentryTelemetryBootstrap.addBreadcrumb( + .agentRuntime, + action: action, + attributes: attributes + ) + SentryTelemetryBootstrap.increment(.agentRuntimeEvents, attributes: attributes) + #endif + } + + @MainActor + static func recordProviderError( + session: AgentModeViewModel.TabSession, + kind: SentryTelemetryBootstrap.ProviderErrorKind + ) { + #if REPOPROMPT_SENTRY_ENABLED + let attributes = baseAttributes(for: session, outcome: .failed) + [.providerErrorKind(kind)] + SentryTelemetryBootstrap.addBreadcrumb( + .agentRuntime, + action: .agentProviderError, + attributes: attributes + ) + SentryTelemetryBootstrap.increment(.agentProviderErrors, attributes: attributes) + #endif + } + + @MainActor + static func recordProviderError( + session: AgentModeViewModel.TabSession, + error: Error + ) { + #if REPOPROMPT_SENTRY_ENABLED + recordProviderError(session: session, kind: providerErrorKind(for: error)) + #endif + } + + #if REPOPROMPT_SENTRY_ENABLED + @MainActor + private static func recordProviderFirstEventIfNeeded(session: AgentModeViewModel.TabSession) { + guard observedProviderEventRunKeys.insert(providerEventRunKey(for: session)).inserted else { return } + SentryTelemetryBootstrap.span(.agentProviderFirstEvent, attributes: baseAttributes(for: session, outcome: .completed)) {} + } + + @MainActor + private static func providerEventRunKey(for session: AgentModeViewModel.TabSession) -> String { + if let attemptID = session.activeRunOwnership?.attemptID { + return attemptID.uuidString + } + if let runID = session.runID { + return runID.uuidString + } + return session.tabID.uuidString + } + + @MainActor + private static func recordMessageObserved(session: AgentModeViewModel.TabSession, item: AgentChatItem) { + guard let role = messageRole(for: item.kind) else { return } + SentryTelemetryBootstrap.addBreadcrumb( + .agentMessage, + action: .agentMessageObserved, + attributes: baseAttributes(for: session, outcome: .completed) + + [.messageRole(role)] + ) + } + + @MainActor + private static func recordToolStarted(session: AgentModeViewModel.TabSession, item: AgentChatItem) { + let attributes = toolAttributes(for: session, item: item, outcome: .started, isError: false) + SentryTelemetryBootstrap.addBreadcrumb( + .agentTool, + action: .agentToolStarted, + attributes: attributes + ) + } + + @MainActor + private static func recordToolTerminal(session: AgentModeViewModel.TabSession, item: AgentChatItem) { + let failed = item.toolIsError == true + let attributes = toolAttributes( + for: session, + item: item, + outcome: failed ? .failed : .completed, + isError: failed + ) + SentryTelemetryBootstrap.addBreadcrumb( + .agentTool, + action: failed ? .agentToolFailed : .agentToolCompleted, + attributes: attributes + ) + } + + @MainActor + private static func baseAttributes( + for session: AgentModeViewModel.TabSession, + outcome: SentryTelemetryBootstrap.Outcome + ) -> [SentryTelemetryBootstrap.Attribute] { + [ + .entrypoint(session.mcpControlContext == nil ? .user : .mcp), + .clientClass(session.mcpControlContext == nil ? .inApp : .externalAgent), + .providerKind(SentryTelemetryBootstrap.ProviderKind(agentKind: session.selectedAgent)), + .modelFamily(modelFamily(for: session.selectedAgent, modelRaw: session.selectedModelRaw)), + .outcome(outcome), + .isChildSession(session.parentSessionID != nil), + .hasProviderResumeSession(session.providerSessionID != nil) + ] + } + + @MainActor + private static func runSummaryAttributes( + for session: AgentModeViewModel.TabSession, + outcome: SentryTelemetryBootstrap.Outcome + ) -> [SentryTelemetryBootstrap.Attribute] { + baseAttributes(for: session, outcome: outcome) + + [ + .messageCount(session.items.count), + .toolCallCount(session.items.count(where: { $0.kind == .toolCall })) + ] + } + + @MainActor + private static func toolAttributes( + for session: AgentModeViewModel.TabSession, + item: AgentChatItem, + outcome: SentryTelemetryBootstrap.Outcome, + isError: Bool + ) -> [SentryTelemetryBootstrap.Attribute] { + var attributes = baseAttributes(for: session, outcome: outcome) + attributes.append(.messageRole(.tool)) + attributes.append(.isError(isError)) + if let rawToolName = item.toolName, + let toolName = SentryTelemetryBootstrap.ToolName(rawToolName: rawToolName) + { + attributes.append(.toolName(toolName)) + attributes.append(.toolDomain(toolName.domain)) + } + return attributes + } + + private static func action( + for terminalState: AgentSessionRunState + ) -> SentryTelemetryBootstrap.Action? { + switch terminalState { + case .completed: .agentRunCompleted + case .cancelled: .agentRunCancelled + case .failed: .agentRunFailed + case .idle, .running, .waitingForUser, .waitingForQuestion, .waitingForApproval: + nil + } + } + + private static func outcome( + for terminalState: AgentSessionRunState + ) -> SentryTelemetryBootstrap.Outcome? { + switch terminalState { + case .completed: .completed + case .cancelled: .cancelled + case .failed: .failed + case .idle, .running, .waitingForUser, .waitingForQuestion, .waitingForApproval: + nil + } + } + + private static func messageRole(for kind: AgentChatItemKind) -> SentryTelemetryBootstrap.MessageRole? { + switch kind { + case .assistant, .assistantInline, .thinking: + .assistant + case .error, .system: + .system + case .toolCall, .toolResult: + .tool + case .user: + .user + } + } + + private static func modelFamily( + for agentKind: AgentProviderKind, + modelRaw: String + ) -> SentryTelemetryBootstrap.ModelFamily { + let trimmed = modelRaw.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmed.isEmpty || trimmed.caseInsensitiveCompare(AgentModel.defaultModel.rawValue) == .orderedSame { + return .defaultModel + } + if let model = AgentModel.resolvedModel(forRaw: trimmed, agentKind: agentKind) { + return modelFamily(for: model) + } + return fallbackModelFamily(for: agentKind, modelRaw: trimmed) + } + + private static func modelFamily(for model: AgentModel) -> SentryTelemetryBootstrap.ModelFamily { + switch model { + case .defaultModel: + .defaultModel + case .codexMini, .gpt54MiniLow, .gpt54MiniMedium, .gpt54MiniHigh: + .gptMini + case .gpt55CodexLow, .gpt55CodexMedium, .gpt55CodexHigh, .gpt55CodexXHigh: + .gpt55 + case .codexLow, .codexMedium, .codexHigh, .codexXHigh: + .gpt53Codex + case .gpt54Low, .gpt54Medium, .gpt54High, .gpt54XHigh: + .gpt54 + case .gpt5Low, .gpt5Medium, .gpt5High, .gpt5XHigh: + .gpt52 + case .claudeFable5: + .fable + case .claudeOpus, .claudeOpus1m, .claudeOpus45, .claudeOpus46, .claudeOpus47: + .opus + case .claudeSonnet, .claudeSonnet45, .claudeSonnet46: + .sonnet + case .claudeHaiku, .claudeHaiku45: + .haiku + case .glm45Air, .glm47, .glm52, .glm52_1m, .glm5Turbo, .glm5: + .glm + case .kimiCode: + .kimi + case .customClaudeCompatible: + .customClaudeCompatible + case .cursorAuto, .cursorComposer2: + .cursor + } + } + + private static func fallbackModelFamily( + for agentKind: AgentProviderKind, + modelRaw: String + ) -> SentryTelemetryBootstrap.ModelFamily { + let lowercasedRaw = modelRaw.lowercased() + switch agentKind { + case .codexExec: + if lowercasedRaw.contains("mini") { return .gptMini } + if lowercasedRaw.contains("codex") { return .codex } + if lowercasedRaw.hasPrefix("gpt-") { return .gpt } + return .codex + case .claudeCode: + if lowercasedRaw.contains("opus") { return .opus } + if lowercasedRaw.contains("sonnet") { return .sonnet } + if lowercasedRaw.contains("haiku") { return .haiku } + if lowercasedRaw.contains("fable") { return .fable } + return .claude + case .claudeCodeGLM: + return .glm + case .kimiCode: + return .kimi + case .customClaudeCompatible: + return .customClaudeCompatible + case .cursor: + return .cursor + case .openCode: + return .openCode + } + } + + private static func providerErrorKind(for error: Error) -> SentryTelemetryBootstrap.ProviderErrorKind { + if error is CancellationError { + return .cancelled + } + if let providerError = error as? AIProviderError { + switch providerError { + case .missingAPIKey: + return .missingCredential + case .missingOllamaURL, .missingURL: + return .missingProviderURL + case .providerNotConfigured: + return .providerNotConfigured + case .invalidConfiguration, .missingAzureConfiguration, .invalidModel, .invalidSystemPrompt, + .messageCreationFailed: + return .invalidConfiguration + case .invalidResponse: + return .invalidResponse + case .apiError: + return .apiError + case .unknown: + return .unknown + } + } + let nsError = error as NSError + if nsError.domain == NSURLErrorDomain, nsError.code == NSURLErrorTimedOut { + return .timeout + } + return .unknown + } + + private static func toolName( + for approvalKind: SentryTelemetryBootstrap.ApprovalKind + ) -> SentryTelemetryBootstrap.ToolName { + switch approvalKind { + case .applyEdits: + .applyEdits + case .worktreeMerge: + .manageWorktree + case .commandExecution, .fileChange, .toolPermission: + .agentRun + } + } + + #endif +} diff --git a/Sources/RepoPrompt/Features/AgentMode/Runtime/AgentRunTerminalCommitBarrier.swift b/Sources/RepoPrompt/Features/AgentMode/Runtime/AgentRunTerminalCommitBarrier.swift index 8fa5553b6..c657601ca 100644 --- a/Sources/RepoPrompt/Features/AgentMode/Runtime/AgentRunTerminalCommitBarrier.swift +++ b/Sources/RepoPrompt/Features/AgentMode/Runtime/AgentRunTerminalCommitBarrier.swift @@ -258,8 +258,21 @@ final class AgentRunTerminalCommitBarrier { session.clearClaudeReasoningStatus(clearDisplayedStatus: true) session.setRunningStatus(nil, source: nil) session.waitingPrompt = nil - session.runState = request.terminalState - _ = session.endRunAttempt(ifCurrent: request.ownership, source: request.source) + await SentryTelemetryBootstrap.spanAsync( + .agentTerminalCommit, + attributes: [ + .entrypoint(session.mcpControlContext == nil ? .user : .mcp), + .clientClass(session.mcpControlContext == nil ? .inApp : .externalAgent), + .providerKind(SentryTelemetryBootstrap.ProviderKind(agentKind: session.selectedAgent)), + .outcome(request.terminalState == .completed ? .completed : request.terminalState == .cancelled ? .cancelled : .failed), + .isChildSession(session.parentSessionID != nil), + .hasProviderResumeSession(session.providerSessionID != nil) + ] + ) { _ in + session.runState = request.terminalState + _ = session.endRunAttempt(ifCurrent: request.ownership, source: request.source) + AgentRunSentryTelemetry.recordTerminal(session: session, terminalState: request.terminalState) + } hooks.setAgentRunActive(session.tabID, false) hooks.prepareTerminalPublication(session) if let runID = request.expectedRunID, let terminalTurnID { diff --git a/Sources/RepoPrompt/Features/AgentMode/ViewModels/AgentModeViewModel+InteractionActions.swift b/Sources/RepoPrompt/Features/AgentMode/ViewModels/AgentModeViewModel+InteractionActions.swift index 430b10d06..911a8cec3 100644 --- a/Sources/RepoPrompt/Features/AgentMode/ViewModels/AgentModeViewModel+InteractionActions.swift +++ b/Sources/RepoPrompt/Features/AgentMode/ViewModels/AgentModeViewModel+InteractionActions.swift @@ -8,6 +8,12 @@ extension AgentModeViewModel { else { return } + AgentRunSentryTelemetry.recordApprovalDecision( + session: session, + kind: telemetryApprovalKind(for: request.kind), + outcome: telemetryApprovalOutcome(for: decision), + cancellationReason: telemetryCancellationReason(for: decision) + ) switch request.requestID { case .codex: codexCoordinator.submitApprovalDecision(session: session, decision: decision) @@ -44,6 +50,14 @@ extension AgentModeViewModel { reviewID: UUID, decision: ApplyEditsReviewDecision ) { + if let session = sessions[tabID], session.pendingApplyEditsReview?.id == reviewID { + AgentRunSentryTelemetry.recordApprovalDecision( + session: session, + kind: .applyEdits, + outcome: telemetryApprovalOutcome(for: decision), + cancellationReason: telemetryCancellationReason(for: decision) + ) + } let scope = applyEditsScope(for: tabID) Task { [applyEditsApprovalStore] in await applyEditsApprovalStore.resolveReview( @@ -53,4 +67,82 @@ extension AgentModeViewModel { ) } } + + func telemetryApprovalKind(for kind: AgentApprovalKind) -> SentryTelemetryBootstrap.ApprovalKind { + switch kind { + case .commandExecution: + .commandExecution + case .fileChange: + .fileChange + } + } + + func telemetryApprovalOutcome(for decision: AgentApprovalDecision) -> SentryTelemetryBootstrap.ApprovalOutcome { + switch decision { + case .accept, .acceptForSession, .acceptWithExecpolicyAmendment: + .approved + case .decline, .cancel: + .denied + } + } + + func telemetryCancellationReason(for decision: AgentApprovalDecision) -> SentryTelemetryBootstrap.CancellationReason? { + switch decision { + case .cancel: + .user + case .accept, .acceptForSession, .acceptWithExecpolicyAmendment, .decline: + nil + } + } + + func telemetryApprovalOutcome(for decision: ApplyEditsReviewDecision) -> SentryTelemetryBootstrap.ApprovalOutcome { + switch decision { + case .accept: + .approved + case .reject, .timeout, .cancelled: + .denied + } + } + + func telemetryCancellationReason(for decision: ApplyEditsReviewDecision) -> SentryTelemetryBootstrap.CancellationReason? { + switch decision { + case .timeout: + .timeout + case .cancelled: + .user + case .accept, .reject: + nil + } + } + + func telemetryApprovalOutcome(for decision: WorktreeMergeReviewDecision) -> SentryTelemetryBootstrap.ApprovalOutcome { + switch decision { + case .accept: + .approved + case .reject, .timeout, .cancelled: + .denied + } + } + + func telemetryCancellationReason(for decision: WorktreeMergeReviewDecision) -> SentryTelemetryBootstrap.CancellationReason? { + switch decision { + case .timeout: + .timeout + case .cancelled: + .user + case .accept, .reject: + nil + } + } + + func telemetryCancellationReason(forApprovalCancellationReason reason: String) -> SentryTelemetryBootstrap.CancellationReason { + let normalized = reason.lowercased() + if normalized.contains("replaced") || normalized.contains("newer") || normalized.contains("superseded") { + return .superseded + } + if normalized.contains("timed out") || normalized.contains("timeout") { + return .timeout + } + return .user + } } diff --git a/Sources/RepoPrompt/Features/AgentMode/ViewModels/AgentModeViewModel+TabSession.swift b/Sources/RepoPrompt/Features/AgentMode/ViewModels/AgentModeViewModel+TabSession.swift index 428b01456..fe3eb4549 100644 --- a/Sources/RepoPrompt/Features/AgentMode/ViewModels/AgentModeViewModel+TabSession.swift +++ b/Sources/RepoPrompt/Features/AgentMode/ViewModels/AgentModeViewModel+TabSession.swift @@ -1439,6 +1439,7 @@ extension AgentModeViewModel { reconcileIncrementalEphemeralPayload(previousItem: nil, updatedItem: newItem) appendToolCorrelationIndexes(for: newItem, at: appendedIndex) finishIncrementalSourceItemsMutation(.append(index: appendedIndex, itemKind: newItem.kind)) + AgentRunSentryTelemetry.recordItemAppended(session: self, item: newItem) if newItem.kind == .user { hasSentFirstMessage = true lastUserMessageAt = newItem.timestamp @@ -1460,6 +1461,11 @@ extension AgentModeViewModel { finishIncrementalSourceItemsMutation( .replace(index: index, previousKind: previousItem.kind, currentKind: updatedItem.kind) ) + AgentRunSentryTelemetry.recordItemReplaced( + session: self, + previousItem: previousItem, + updatedItem: updatedItem + ) lastActivityAt = Date() isDirty = true } @@ -1477,6 +1483,11 @@ extension AgentModeViewModel { reconcileIncrementalEphemeralPayload(previousItem: previousItem, updatedItem: updatedItem) updateToolCorrelationIndexes(previousItem: previousItem, updatedItem: updatedItem, at: index) finishIncrementalSourceItemsMutation(.mutate(index: index, itemKind: updatedItem.kind)) + AgentRunSentryTelemetry.recordItemReplaced( + session: self, + previousItem: previousItem, + updatedItem: updatedItem + ) lastActivityAt = Date() isDirty = true } diff --git a/Sources/RepoPrompt/Features/AgentMode/ViewModels/AgentModeViewModel+WorktreeMerge.swift b/Sources/RepoPrompt/Features/AgentMode/ViewModels/AgentModeViewModel+WorktreeMerge.swift index 149f11877..8d19d92d6 100644 --- a/Sources/RepoPrompt/Features/AgentMode/ViewModels/AgentModeViewModel+WorktreeMerge.swift +++ b/Sources/RepoPrompt/Features/AgentMode/ViewModels/AgentModeViewModel+WorktreeMerge.swift @@ -572,6 +572,12 @@ extension AgentModeViewModel { session.pendingWorktreeMergeReview?.id == reviewID, let continuation = session.worktreeMergeReviewContinuation else { return } + AgentRunSentryTelemetry.recordApprovalDecision( + session: session, + kind: .worktreeMerge, + outcome: telemetryApprovalOutcome(for: decision), + cancellationReason: telemetryCancellationReason(for: decision) + ) finishPendingWorktreeMergeReview(session: session) continuation.resume(returning: decision) } @@ -594,6 +600,14 @@ extension AgentModeViewModel { func cancelPendingWorktreeMergeReview(for session: TabSession, reason: String) { let operationID = session.pendingWorktreeMergeReview?.operationID + if session.pendingWorktreeMergeReview != nil { + AgentRunSentryTelemetry.recordApprovalDecision( + session: session, + kind: .worktreeMerge, + outcome: .denied, + cancellationReason: telemetryCancellationReason(forApprovalCancellationReason: reason) + ) + } guard let continuation = session.worktreeMergeReviewContinuation else { finishPendingWorktreeMergeReview(session: session) if let operationID { diff --git a/Sources/RepoPrompt/Features/AgentMode/ViewModels/AgentModeViewModel.swift b/Sources/RepoPrompt/Features/AgentMode/ViewModels/AgentModeViewModel.swift index fd4891145..e50aaf918 100644 --- a/Sources/RepoPrompt/Features/AgentMode/ViewModels/AgentModeViewModel.swift +++ b/Sources/RepoPrompt/Features/AgentMode/ViewModels/AgentModeViewModel.swift @@ -15010,6 +15010,22 @@ final class AgentModeViewModel: ObservableObject { } private func cancelPendingApproval(for session: TabSession) { + if let pendingApproval = session.pendingApproval { + AgentRunSentryTelemetry.recordApprovalDecision( + session: session, + kind: telemetryApprovalKind(for: pendingApproval.kind), + outcome: .denied, + cancellationReason: .user + ) + } + if session.pendingPermissionsRequest != nil { + AgentRunSentryTelemetry.recordApprovalDecision( + session: session, + kind: .toolPermission, + outcome: .denied, + cancellationReason: .user + ) + } session.pendingApproval = nil session.pendingPermissionsRequest = nil session.pendingMCPElicitationRequest = nil @@ -15019,6 +15035,14 @@ final class AgentModeViewModel: ObservableObject { } private func cancelPendingApplyEditsReview(for session: TabSession, reason: String) { + if session.pendingApplyEditsReview != nil { + AgentRunSentryTelemetry.recordApprovalDecision( + session: session, + kind: .applyEdits, + outcome: .denied, + cancellationReason: telemetryCancellationReason(forApprovalCancellationReason: reason) + ) + } session.pendingApplyEditsReview = nil let scope = applyEditsScope(for: session.tabID) Task { [applyEditsApprovalStore] in From bf857529ab7041790f9fac296c836c66742032d9 Mon Sep 17 00:00:00 2001 From: Cameron Cooke Date: Mon, 29 Jun 2026 21:50:09 +0100 Subject: [PATCH 09/12] feat(telemetry): instrument Codex runtime events and session persistence Refs GH-183 --- .../Runtime/AgentSessionDataService.swift | 42 +++++-- ...entSessionPersistenceSentryTelemetry.swift | 49 +++++++++ .../Codex/CodexAgentModeCoordinator.swift | 104 +++++++++++++++++- 3 files changed, 180 insertions(+), 15 deletions(-) create mode 100644 Sources/RepoPrompt/Features/AgentMode/Runtime/AgentSessionPersistenceSentryTelemetry.swift diff --git a/Sources/RepoPrompt/Features/AgentMode/Runtime/AgentSessionDataService.swift b/Sources/RepoPrompt/Features/AgentMode/Runtime/AgentSessionDataService.swift index c726fd00f..eab6f16ce 100644 --- a/Sources/RepoPrompt/Features/AgentMode/Runtime/AgentSessionDataService.swift +++ b/Sources/RepoPrompt/Features/AgentMode/Runtime/AgentSessionDataService.swift @@ -926,18 +926,36 @@ actor AgentSessionDataService { let filename = "AgentSession-\(session.id.uuidString).json" let fileURL = agentSessionsFolder.appendingPathComponent(filename) - let sessionToSave = sessionPreparedForStorage( - session, - fileURL: fileURL, - savedAt: Date(), - preparation: preparation, - trustedCanonicalItemCount: trustedCanonicalItemCount - ) - let freshEncoder = JSONEncoder() - let data = try freshEncoder.encode(sessionToSave) - try await diskWriter.enqueueAndWait(data: data, url: fileURL) - await upsertMetadataRecord(metadataRecord(from: sessionToSave, fileURL: fileURL), folder: agentSessionsFolder) - return fileURL + AgentSessionPersistenceSentryTelemetry.recordScheduled(session: session) + do { + return try await SentryTelemetryBootstrap.spanAsync( + .agentSessionPersist, + attributes: AgentSessionPersistenceSentryTelemetry.attributes(session: session, outcome: .started) + ) { parentSpan in + let sessionToSave = try await SentryTelemetryBootstrap.childSpanAsync( + parent: parentSpan, + operation: .agentSessionPrepare, + attributes: AgentSessionPersistenceSentryTelemetry.attributes(session: session, outcome: .started) + ) { + sessionPreparedForStorage( + session, + fileURL: fileURL, + savedAt: Date(), + preparation: preparation, + trustedCanonicalItemCount: trustedCanonicalItemCount + ) + } + let freshEncoder = JSONEncoder() + let data = try freshEncoder.encode(sessionToSave) + try await diskWriter.enqueueAndWait(data: data, url: fileURL) + await upsertMetadataRecord(metadataRecord(from: sessionToSave, fileURL: fileURL), folder: agentSessionsFolder) + AgentSessionPersistenceSentryTelemetry.recordCompleted(session: sessionToSave) + return fileURL + } + } catch { + AgentSessionPersistenceSentryTelemetry.recordFailed(session: session) + throw error + } } func renameAgentSession( diff --git a/Sources/RepoPrompt/Features/AgentMode/Runtime/AgentSessionPersistenceSentryTelemetry.swift b/Sources/RepoPrompt/Features/AgentMode/Runtime/AgentSessionPersistenceSentryTelemetry.swift new file mode 100644 index 000000000..733645bab --- /dev/null +++ b/Sources/RepoPrompt/Features/AgentMode/Runtime/AgentSessionPersistenceSentryTelemetry.swift @@ -0,0 +1,49 @@ +import Foundation + +enum AgentSessionPersistenceSentryTelemetry { + static func recordScheduled(session: AgentSession) { + SentryTelemetryBootstrap.addBreadcrumb( + .persistenceAction, + action: .persistenceScheduled, + attributes: attributes(session: session, outcome: .started) + ) + } + + static func recordCompleted(session: AgentSession) { + SentryTelemetryBootstrap.addBreadcrumb( + .persistenceAction, + action: .persistenceCompleted, + attributes: attributes(session: session, outcome: .completed) + ) + } + + static func recordFailed(session: AgentSession) { + SentryTelemetryBootstrap.addBreadcrumb( + .persistenceAction, + action: .persistenceFailed, + attributes: attributes(session: session, outcome: .failed, isError: true) + ) + } + + static func attributes( + session: AgentSession, + outcome: SentryTelemetryBootstrap.Outcome, + isError: Bool = false + ) -> [SentryTelemetryBootstrap.Attribute] { + var attributes: [SentryTelemetryBootstrap.Attribute] = [ + .entrypoint(.agent), + .clientClass(session.isMCPOriginated ? .externalAgent : .inApp), + .outcome(outcome), + .isError(isError), + .messageCount(session.items.count), + .isChildSession(session.parentSessionID != nil), + .hasProviderResumeSession(session.providerSessionID != nil) + ] + if let agentKind = session.agentKind, + let providerKind = SentryTelemetryBootstrap.ProviderKind(agentKindRaw: agentKind) + { + attributes.append(.providerKind(providerKind)) + } + return attributes + } +} diff --git a/Sources/RepoPrompt/Features/AgentMode/Runtime/Codex/CodexAgentModeCoordinator.swift b/Sources/RepoPrompt/Features/AgentMode/Runtime/Codex/CodexAgentModeCoordinator.swift index fd7f358b7..478a3c85d 100644 --- a/Sources/RepoPrompt/Features/AgentMode/Runtime/Codex/CodexAgentModeCoordinator.swift +++ b/Sources/RepoPrompt/Features/AgentMode/Runtime/Codex/CodexAgentModeCoordinator.swift @@ -3144,6 +3144,12 @@ final class CodexAgentModeCoordinator: AgentModeRunInteractionStateObserving { session: session, urgent: true ) + AgentRunSentryTelemetry.recordRuntimeEvent( + session: session, + event: .codexStallWarningShown, + action: .agentRuntimeStallWarningShown, + outcome: .started + ) logCodex("[AgentModeVM][CodexWatchdog] recorded non-rendering stall warning for tab \(session.tabID) reason=\(reason)") viewModel?.requestUIRefresh(tabID: session.tabID, urgent: true, scope: .runtimeMetrics) } @@ -3160,6 +3166,12 @@ final class CodexAgentModeCoordinator: AgentModeRunInteractionStateObserving { guard !hasPendingCodexInteraction(for: session) else { return .skipped } + AgentRunSentryTelemetry.recordRuntimeEvent( + session: session, + event: .codexStallProbeTriggered, + action: .agentRuntimeStallProbeTriggered, + outcome: .started + ) } guard let runID = session.runID else { if trigger == .stallWatchdog { @@ -3252,10 +3264,25 @@ final class CodexAgentModeCoordinator: AgentModeRunInteractionStateObserving { } } + if trigger == .unexpectedStreamEnd { + AgentRunSentryTelemetry.recordProviderError(session: session, kind: .streamEndedUnexpectedly) + } guard codexRecoveryAttemptedRunIDs.insert(runID).inserted else { + AgentRunSentryTelemetry.recordRuntimeEvent( + session: session, + event: .codexRecoveryFailed, + action: .agentRuntimeRecoveryFailed, + outcome: .failed + ) return .unrecoverable(recoveryFailureMessage(for: trigger, recoveryAlreadyAttempted: true)) } + AgentRunSentryTelemetry.recordRuntimeEvent( + session: session, + event: .codexRecoveryStarted, + action: .agentRuntimeRecoveryStarted, + outcome: .started + ) let recoveryStartedAt = Date() setRunningStatus("Reconnecting…", source: .reconnect, session: session, urgent: true) session.codexLastEventAt = recoveryStartedAt @@ -3302,6 +3329,12 @@ final class CodexAgentModeCoordinator: AgentModeRunInteractionStateObserving { let alreadyReportedStartFailure = session.items.last.map { $0.kind == .error && Self.isCodexNativeSessionFailureText($0.text) } ?? false + AgentRunSentryTelemetry.recordRuntimeEvent( + session: session, + event: .codexRecoveryFailed, + action: .agentRuntimeRecoveryFailed, + outcome: .failed + ) return .unrecoverable(alreadyReportedStartFailure ? nil : recoveryFailureMessage(for: trigger)) } @@ -3309,6 +3342,12 @@ final class CodexAgentModeCoordinator: AgentModeRunInteractionStateObserving { session.codexLastEventAt = recoveredAt recordCodexWatchdogProgress(for: session, at: recoveredAt) updateCodexStallWatchdogState(for: session) + AgentRunSentryTelemetry.recordRuntimeEvent( + session: session, + event: .codexRecoveryRecovered, + action: .agentRuntimeRecoveryRecovered, + outcome: .completed + ) viewModel?.requestUIRefresh(tabID: session.tabID, urgent: true) return .recovered } @@ -3363,6 +3402,7 @@ final class CodexAgentModeCoordinator: AgentModeRunInteractionStateObserving { switch await authRecovery.refreshManagedAccount() { case let .requiresUserLogin(guidance): + AgentRunSentryTelemetry.recordProviderError(session: session, kind: .authRequired) _ = markCodexReconnectNeeded(for: session, source: "managed-auth-recovery-required") await finalizeCodexRun( session, @@ -3374,6 +3414,7 @@ final class CodexAgentModeCoordinator: AgentModeRunInteractionStateObserving { ) return true case let .executableUnavailable(message): + AgentRunSentryTelemetry.recordProviderError(session: session, kind: .executableUnavailable) await finalizeCodexRun( session, turnStatus: .failed, @@ -3547,7 +3588,15 @@ final class CodexAgentModeCoordinator: AgentModeRunInteractionStateObserving { trigger: .unexpectedStreamEnd, sourceController: sourceController ) { - case .recovered, .skipped: + case .recovered: + return + case .skipped: + AgentRunSentryTelemetry.recordRuntimeEvent( + session: session, + event: .codexRecoverySkipped, + action: .agentRuntimeRecoverySkipped, + outcome: .cancelled + ) return case let .unrecoverable(errorMessage): guard shouldFinalizeAfterRecovery(session: session, expectedRunID: runID, source: "transport-closed-fallback") else { return } @@ -3806,7 +3855,15 @@ final class CodexAgentModeCoordinator: AgentModeRunInteractionStateObserving { trigger: .unexpectedStreamEnd, sourceController: controller ) { - case .recovered, .skipped: + case .recovered: + return + case .skipped: + AgentRunSentryTelemetry.recordRuntimeEvent( + session: session, + event: .codexRecoverySkipped, + action: .agentRuntimeRecoverySkipped, + outcome: .cancelled + ) return case let .unrecoverable(errorMessage): guard shouldFinalizeAfterRecovery(session: session, expectedRunID: taskRunID, source: "unexpected-stream-end") else { return } @@ -3961,6 +4018,7 @@ final class CodexAgentModeCoordinator: AgentModeRunInteractionStateObserving { await ensureCodexToolTrackingForReadySessionIfNeeded(for: session, runID: runID) } catch { var effectiveError: Error = error + var providerErrorRecorded = false if session.runState.isActive, let runID = session.runID, CodexManagedAuthRecoveryClassifier.isRecoverable(message: error.localizedDescription), @@ -3970,9 +4028,13 @@ final class CodexAgentModeCoordinator: AgentModeRunInteractionStateObserving { viewModel?.requestUIRefresh(tabID: session.tabID, urgent: true) switch await authRecovery.refreshManagedAccount() { case let .requiresUserLogin(guidance): + AgentRunSentryTelemetry.recordProviderError(session: session, kind: .authRequired) + providerErrorRecorded = true _ = markCodexReconnectNeeded(for: session, source: "managed-auth-recovery-required-during-start") effectiveError = AIProviderError.invalidConfiguration(detail: guidance) case let .executableUnavailable(message): + AgentRunSentryTelemetry.recordProviderError(session: session, kind: .executableUnavailable) + providerErrorRecorded = true effectiveError = AIProviderError.invalidConfiguration(detail: message) case .recovered: let expectedController = session.codexController @@ -4060,6 +4122,7 @@ final class CodexAgentModeCoordinator: AgentModeRunInteractionStateObserving { await ensureCodexToolTrackingForReadySessionIfNeeded(for: session, runID: runID) return } catch { + AgentRunSentryTelemetry.recordProviderError(session: session, error: error) let invalidatedTimedOutController = CodexAppServerClient.isTimeoutError(error) ? invalidateCodexControllerForReconnect( session: session, @@ -4094,6 +4157,9 @@ final class CodexAgentModeCoordinator: AgentModeRunInteractionStateObserving { if !invalidatedTimedOutController { markCodexReconnectNeeded(for: session, source: "ensure-error") } + if !providerErrorRecorded { + AgentRunSentryTelemetry.recordProviderError(session: session, error: effectiveError) + } let errorItem = AgentChatItem.error( "\(Self.codexNativeSessionFailurePrefix(attemptedResume: attemptedResume)) \(effectiveError.localizedDescription)", sequenceIndex: session.nextSequenceIndex @@ -6029,6 +6095,13 @@ final class CodexAgentModeCoordinator: AgentModeRunInteractionStateObserving { ) async { let isTransportClosed = message.localizedCaseInsensitiveContains("transport closed") if isTransportClosed { + AgentRunSentryTelemetry.recordRuntimeEvent( + session: session, + event: .codexTransportClosed, + action: .agentRuntimeTransportClosed, + outcome: .started + ) + AgentRunSentryTelemetry.recordProviderError(session: session, kind: .transportClosed) if !session.runState.isActive { _ = invalidateCodexControllerForReconnect( session: session, @@ -6056,6 +6129,7 @@ final class CodexAgentModeCoordinator: AgentModeRunInteractionStateObserving { } return } + AgentRunSentryTelemetry.recordProviderError(session: session, kind: .unknown) await finalizeCodexRun( session, turnStatus: .failed, @@ -7661,7 +7735,7 @@ final class CodexAgentModeCoordinator: AgentModeRunInteractionStateObserving { var updated = session.items[index] let isAgentControlTool = AgentTranscriptIO.isAgentControlToolName(updated.toolName) let isRepoPromptTool = MCPIntegrationHelper.isRepoPromptToolNameAfterNormalization(updated.toolName) - if isRepoPromptTool && !isAgentControlTool { + if isRepoPromptTool, !isAgentControlTool { continue } let agentControlFallback = isAgentControlTool @@ -7835,6 +7909,12 @@ final class CodexAgentModeCoordinator: AgentModeRunInteractionStateObserving { else { return } + AgentRunSentryTelemetry.recordApprovalDecision( + session: session, + kind: .toolPermission, + outcome: Self.telemetryApprovalOutcome(for: decision), + cancellationReason: Self.telemetryCancellationReason(for: decision) + ) let result = Self.buildPermissionsResult(decision: decision, request: request) session.pendingPermissionsRequest = nil viewModel?.reconcileInteractiveRunState(session) @@ -7915,6 +7995,24 @@ final class CodexAgentModeCoordinator: AgentModeRunInteractionStateObserving { return AgentRequestUserInputResponse(answersByQuestionID: answers) } + private static func telemetryApprovalOutcome(for decision: AgentApprovalDecision) -> SentryTelemetryBootstrap.ApprovalOutcome { + switch decision { + case .accept, .acceptForSession, .acceptWithExecpolicyAmendment: + .approved + case .decline, .cancel: + .denied + } + } + + private static func telemetryCancellationReason(for decision: AgentApprovalDecision) -> SentryTelemetryBootstrap.CancellationReason? { + switch decision { + case .cancel: + .user + case .accept, .acceptForSession, .acceptWithExecpolicyAmendment, .decline: + nil + } + } + private static func buildPermissionsResult(decision: AgentApprovalDecision, request: AgentPermissionsRequest) -> [String: Any] { let permissions: [String: Any] let scope: String From 8bc5a0a6e4fb5d5f192e6384175c258f18b7f382 Mon Sep 17 00:00:00 2001 From: Cameron Cooke Date: Mon, 29 Jun 2026 21:50:10 +0100 Subject: [PATCH 10/12] build(telemetry): generate and upload dSYMs in the release pipeline Refs GH-183 --- .github/workflows/release.yml | 21 ++++ Scripts/build_swiftpm_release_products.sh | 17 ++- Scripts/conductor.py | 19 ++- Scripts/package_app.sh | 37 ++++++ Scripts/release.sh | 26 ++++ Scripts/sign_staged_release.sh | 7 ++ Scripts/test_release_tooling.py | 143 ++++++++++++++++++++++ Scripts/upload_sentry_debug_symbols.sh | 54 ++++++++ Scripts/write_app_artifact_manifest.py | 4 + docs/releasing.md | 58 +++++++++ 10 files changed, 381 insertions(+), 5 deletions(-) create mode 100755 Scripts/upload_sentry_debug_symbols.sh diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4ae57ece6..af4c675a9 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -72,6 +72,10 @@ jobs: SOURCE_GITHUB_REPOSITORY: ${{ github.repository }} REPOPROMPT_CONTROL_PLANE_SCRIPTS_DIR: ${{ github.workspace }}/trusted-control-plane/Scripts REPOPROMPT_RELEASE_SOURCE_ROOT: ${{ github.workspace }}/release-source + # Link the Sentry SDK into the official release binary. Release-candidate and local + # builds never set this, so they neither download nor compile the SDK. Telemetry stays + # inert here regardless — the DSN is only injected later, in the signing step. + REPOPROMPT_ENABLE_SENTRY: "1" run: ./trusted-control-plane/Scripts/release.sh stage-publish - name: Upload staged release source @@ -181,6 +185,15 @@ jobs: echo "REPOPROMPT_PROVISIONING_PROFILE=$SECRETS_DIR/repoprompt-ce.provisionprofile" >> "$GITHUB_ENV" echo "NOTARYTOOL_PRIVATE_KEY=$SECRETS_DIR/notarytool-private-key.p8" >> "$GITHUB_ENV" + - name: Install Sentry CLI when symbol upload is configured + env: + SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} + run: | + set -euo pipefail + if [[ -n "${SENTRY_AUTH_TOKEN:-}" ]] && ! command -v sentry-cli >/dev/null 2>&1; then + brew install getsentry/tools/sentry-cli + fi + - name: Sign, notarize, and create draft release env: GH_TOKEN: ${{ github.token }} @@ -195,6 +208,14 @@ jobs: SPARKLE_PRIVATE_KEY: ${{ secrets.SPARKLE_PRIVATE_KEY }} NOTARYTOOL_KEY_ID: ${{ secrets.NOTARYTOOL_KEY_ID }} NOTARYTOOL_ISSUER_ID: ${{ secrets.NOTARYTOOL_ISSUER_ID }} + # Protected `release` environment secret, baked into the signed bundle's Info.plist by + # sign_staged_release.sh. Optional: if unset, telemetry simply stays off (fail-safe). + SENTRY_DSN: ${{ secrets.SENTRY_DSN }} + # Optional protected release secret for native symbolication. The upload helper reads the + # token from the environment but never prints it. + SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} + REPOPROMPT_SENTRY_ORG: ${{ vars.SENTRY_ORG }} + REPOPROMPT_SENTRY_PROJECT: ${{ vars.SENTRY_PROJECT }} run: ./trusted-control-plane/Scripts/release.sh publish-staged - name: Remove ephemeral keychain diff --git a/Scripts/build_swiftpm_release_products.sh b/Scripts/build_swiftpm_release_products.sh index 601ce1fff..2fc8dedbe 100755 --- a/Scripts/build_swiftpm_release_products.sh +++ b/Scripts/build_swiftpm_release_products.sh @@ -18,6 +18,10 @@ fail() { exit 1 } +sentry_linking_enabled() { + [[ "${REPOPROMPT_ENABLE_SENTRY:-}" == "1" ]] +} + run() { printf '+ ' printf '%q ' "$@" @@ -74,6 +78,11 @@ esac run mkdir -p "$SCRATCH_ROOT" printf 'RepoPrompt CE universal public SwiftPM scratch\n' > "$SCRATCH_ROOT/$SCRATCH_SENTINEL_NAME" +SWIFT_BUILD_ARGS=(-c release) +if sentry_linking_enabled; then + SWIFT_BUILD_ARGS+=(-debug-info-format dwarf) +fi + ARM64_BIN_DIR="" X86_64_BIN_DIR="" for arch in arm64 x86_64; do @@ -83,18 +92,18 @@ for arch in arm64 x86_64; do REPOPROMPT_SWIFTPM_SCRATCH_PATH="$scratch" \ "$KEYBOARD_SHORTCUTS_PATCH_HELPER" "$ROOT_DIR" run "$RUN_WITHOUT_GITHUB_TOKENS" swift build \ - -c release \ + "${SWIFT_BUILD_ARGS[@]}" \ --arch "$arch" \ --scratch-path "$scratch" \ --product RepoPrompt run "$RUN_WITHOUT_GITHUB_TOKENS" swift build \ - -c release \ + "${SWIFT_BUILD_ARGS[@]}" \ --arch "$arch" \ --scratch-path "$scratch" \ --product repoprompt-mcp - printf '+ %q ' "$RUN_WITHOUT_GITHUB_TOKENS" swift build -c release --arch "$arch" --scratch-path "$scratch" --show-bin-path + printf '+ %q ' "$RUN_WITHOUT_GITHUB_TOKENS" swift build "${SWIFT_BUILD_ARGS[@]}" --arch "$arch" --scratch-path "$scratch" --show-bin-path printf '\n' - bin_dir="$("$RUN_WITHOUT_GITHUB_TOKENS" swift build -c release --arch "$arch" --scratch-path "$scratch" --show-bin-path)" + bin_dir="$("$RUN_WITHOUT_GITHUB_TOKENS" swift build "${SWIFT_BUILD_ARGS[@]}" --arch "$arch" --scratch-path "$scratch" --show-bin-path)" if [[ "$arch" == "arm64" ]]; then ARM64_BIN_DIR="$bin_dir" else diff --git a/Scripts/conductor.py b/Scripts/conductor.py index 542d84452..d0ec790d1 100755 --- a/Scripts/conductor.py +++ b/Scripts/conductor.py @@ -1030,8 +1030,25 @@ class OperationRegistry: "RPCE_RUN_CODEMAP_E2E", "RPCE_RUN_SCALE_TESTS", ] + TELEMETRY_ENV_KEYS = [ + "REPOPROMPT_ENABLE_SENTRY", + "REPOPROMPT_SENTRY_DSN", + "REPOPROMPT_UPLOAD_SENTRY_SYMBOLS", + "REPOPROMPT_SENTRY_AUTH_TOKEN_FILE", + "REPOPROMPT_SENTRY_ORG", + "REPOPROMPT_SENTRY_PROJECT", + "REPOPROMPT_SENTRY_UPLOAD_WAIT", + "SENTRY_URL", + ] PASSTHROUGH_ENV_KEYS = sorted( - set(SIGNING_ENV_KEYS + DEBUG_ENV_KEYS + BUILD_ENV_KEYS + STYLE_ENV_KEYS + TEST_ENV_KEYS) + set( + SIGNING_ENV_KEYS + + DEBUG_ENV_KEYS + + BUILD_ENV_KEYS + + STYLE_ENV_KEYS + + TEST_ENV_KEYS + + TELEMETRY_ENV_KEYS + ) ) def __init__(self, repo_root: Path) -> None: diff --git a/Scripts/package_app.sh b/Scripts/package_app.sh index d5d8e94da..c5a8bedf4 100755 --- a/Scripts/package_app.sh +++ b/Scripts/package_app.sh @@ -29,6 +29,20 @@ run(){ "$@" } fail(){ echo "ERROR: $*" >&2; exit 1; } +truthy(){ + case "${1:-}" in + 1|true|TRUE|yes|YES) return 0 ;; + *) return 1 ;; + esac +} +sentry_linking_enabled(){ + [[ "${REPOPROMPT_ENABLE_SENTRY:-}" == "1" ]] +} +require_sentry_upload_credentials(){ + if [[ -z "${SENTRY_AUTH_TOKEN:-}" && -z "${REPOPROMPT_SENTRY_AUTH_TOKEN_FILE:-}" && -z "${SENTRY_AUTH_TOKEN_FILE:-}" ]]; then + fail "REPOPROMPT_UPLOAD_SENTRY_SYMBOLS requires SENTRY_AUTH_TOKEN or REPOPROMPT_SENTRY_AUTH_TOKEN_FILE." + fi +} remove_stale_artifact_manifests(){ local manifests=() shopt -s nullglob @@ -75,6 +89,7 @@ source "$CONTROL_PLANE_SCRIPTS_DIR/load_release_metadata.sh" load_release_metadata "$ROOT_DIR" APP_NAME="${APP_NAME:-RepoPrompt}"; DISPLAY_NAME="${DISPLAY_NAME:-RepoPrompt CE}"; BASE_BUNDLE_ID="${BUNDLE_ID:-com.pvncher.repoprompt.ce}"; MARKETING_VERSION="${MARKETING_VERSION:-0.1.0}"; BUILD_NUMBER="${BUILD_NUMBER:-1}"; SIGNING_TEAM_ID="${SIGNING_TEAM_ID:-648A27MST5}" ARTIFACT_MANIFEST="$ROOT_DIR/.build/release/$APP_NAME-artifact-manifest.json" +SENTRY_SYMBOLS_DIR="$ROOT_DIR/.build/sentry-symbols/$CONF" IS_RELEASE=0 [[ "$CONF" == "release" ]] && IS_RELEASE=1 @@ -188,6 +203,9 @@ printf 'Debug secure storage backend marker: %s\n' "$DEBUG_STORAGE_BACKEND_MARKE printf 'Signing mode marker: %s\n' "$SIGNING_MODE_MARKER" SWIFT_BUILD_ARGS=(-c "$CONF") +if sentry_linking_enabled; then + SWIFT_BUILD_ARGS+=(-debug-info-format dwarf) +fi PUBLIC_UNIVERSAL_RELEASE=0 ARCHITECTURE_POLICY="matching" if (( IS_RELEASE )) && (( ! USE_LOCAL_SELF_SIGNED_RELEASE )); then @@ -230,6 +248,20 @@ COMPAT_APP_BUNDLE="$ROOT_DIR/.build/$CONF/$APP_NAME.app" CLI_PATH="$BUILD_DIR/repoprompt-mcp" printf 'BUILD_DIR=%s\nAPP_BUNDLE=%s\nCOMPAT_APP_BUNDLE=%s\nCLI_PATH=%s\nAD_HOC_SIGNING=%s\nARCHITECTURE_POLICY=%s\n' "$BUILD_DIR" "$APP_BUNDLE" "$COMPAT_APP_BUNDLE" "$CLI_PATH" "$USE_ADHOC_SIGNING" "$ARCHITECTURE_POLICY" +generate_sentry_debug_symbols(){ + sentry_linking_enabled || return 0 + phase "Generating Sentry debug symbols" + command -v xcrun >/dev/null 2>&1 || fail "xcrun is required to generate dSYMs." + run rm -rf "$SENTRY_SYMBOLS_DIR" + run mkdir -p "$SENTRY_SYMBOLS_DIR" + for exe in "$APP_NAME" repoprompt-mcp; do + [[ -f "$BUILD_DIR/$exe" ]] || fail "Missing built executable for dSYM generation: $BUILD_DIR/$exe" + run xcrun dsymutil "$BUILD_DIR/$exe" -o "$SENTRY_SYMBOLS_DIR/$exe.dSYM" + done + printf 'Sentry debug symbols: %s\n' "$SENTRY_SYMBOLS_DIR" +} +generate_sentry_debug_symbols + phase "Creating app bundle layout" run rm -rf "$APP_BUNDLE" APP_BUNDLE_MATCHES_COMPAT="$(paths_same "$APP_BUNDLE" "$COMPAT_APP_BUNDLE")" @@ -397,6 +429,11 @@ if (( PUBLIC_UNIVERSAL_RELEASE )); then fi run "$CONTROL_PLANE_SCRIPTS_DIR/validate_embedded_mcp_helper_layout.sh" "$APP_BUNDLE" "Packaged app MCP helper layout" run "$RUN_WITHOUT_GITHUB_TOKENS" "$CONTROL_PLANE_SCRIPTS_DIR/smoke_embedded_mcp_helper.sh" "$APP_BUNDLE" "Packaged app MCP helper" +if truthy "${REPOPROMPT_UPLOAD_SENTRY_SYMBOLS:-}"; then + sentry_linking_enabled || fail "REPOPROMPT_UPLOAD_SENTRY_SYMBOLS requires REPOPROMPT_ENABLE_SENTRY=1." + require_sentry_upload_credentials + run "$CONTROL_PLANE_SCRIPTS_DIR/upload_sentry_debug_symbols.sh" "$SENTRY_SYMBOLS_DIR" +fi if [[ "$APP_BUNDLE_MATCHES_COMPAT" != "1" ]]; then phase "Updating compatibility app bundle link" run mkdir -p "$(dirname "$COMPAT_APP_BUNDLE")" diff --git a/Scripts/release.sh b/Scripts/release.sh index cf94bfabb..7491d6905 100755 --- a/Scripts/release.sh +++ b/Scripts/release.sh @@ -20,6 +20,7 @@ DMG="$DIST_DIR/$ARCHIVE_BASENAME.dmg" APPCAST="$DIST_DIR/appcast.xml" CHECKSUMS="$DIST_DIR/SHA256SUMS" BUILD_ARTIFACT_MANIFEST="$ROOT_DIR/.build/release/$APP_NAME-artifact-manifest.json" +SENTRY_SYMBOLS_DIR="$ROOT_DIR/.build/sentry-symbols/release" FINAL_ARTIFACT_MANIFEST="$DIST_DIR/$ARCHIVE_BASENAME-artifact-manifest.json" STAGE_ARCHIVE="$DIST_DIR/$ARCHIVE_BASENAME-stage.zip" STAGE_ARCHIVE_CHECKSUM="$STAGE_ARCHIVE.sha256" @@ -49,6 +50,10 @@ require_env() { [[ -n "${!1:-}" ]] || fail "Missing required environment variable: $1" } +sentry_linking_enabled() { + [[ "${REPOPROMPT_ENABLE_SENTRY:-}" == "1" ]] +} + require_release_tag_matches_metadata() { [[ "$RELEASE_TAG" == "v$MARKETING_VERSION" ]] || fail "Release tag must match release metadata: expected v$MARKETING_VERSION, got ${RELEASE_TAG:-}" @@ -77,6 +82,7 @@ run_preflight() { require_file "$ROOT_DIR/Vendor/Sparkle/PROVENANCE.md" require_file "$ROOT_DIR/Vendor/Sparkle/SHA256SUMS" require_file "$CONTROL_PLANE_SCRIPTS_DIR/sign_staged_release.sh" + require_file "$CONTROL_PLANE_SCRIPTS_DIR/upload_sentry_debug_symbols.sh" require_file "$CONTROL_PLANE_SCRIPTS_DIR/build_swiftpm_release_products.sh" require_file "$CONTROL_PLANE_SCRIPTS_DIR/compare_swiftpm_release_resources.py" require_file "$CONTROL_PLANE_SCRIPTS_DIR/smoke_embedded_mcp_helper.sh" @@ -169,6 +175,16 @@ write_final_artifact_manifest() { --expected-architectures "arm64,x86_64" } +require_staged_sentry_symbols_when_enabled() { + if sentry_linking_enabled && [[ ! -d "$SENTRY_SYMBOLS_DIR" ]]; then + fail "Sentry-enabled release staging did not produce debug symbols at $SENTRY_SYMBOLS_DIR" + fi +} + +upload_sentry_symbols_if_configured() { + "$CONTROL_PLANE_SCRIPTS_DIR/upload_sentry_debug_symbols.sh" "$SENTRY_SYMBOLS_DIR" +} + package_release_candidate() { resolve_without_lockfile_drift run_preflight @@ -213,6 +229,10 @@ verify_publish_inputs() { require_release_tag_matches_metadata require_file "$REPOPROMPT_PROVISIONING_PROFILE" require_file "$NOTARYTOOL_PRIVATE_KEY" + if [[ -n "${SENTRY_AUTH_TOKEN:-}" ]]; then + require_env REPOPROMPT_SENTRY_ORG + require_env REPOPROMPT_SENTRY_PROJECT + fi "$CONTROL_PLANE_SCRIPTS_DIR/verify_remote_release_commit.sh" "$RELEASE_TAG" "$RELEASE_COMMIT" } @@ -244,11 +264,16 @@ stage_publish_release() { run_preflight validate_packaged_legal "$APP_BUNDLE" validate_public_app "$APP_BUNDLE" "$BUILD_ARTIFACT_MANIFEST" "Release staging" + require_staged_sentry_symbols_when_enabled TMP_DIR="$(mktemp -d)" local stage_root="$TMP_DIR/release-stage" mkdir -p "$stage_root/.build/release" ditto "$APP_BUNDLE" "$stage_root/.build/release/$APP_NAME.app" cp "$BUILD_ARTIFACT_MANIFEST" "$stage_root/.build/release/$APP_NAME-artifact-manifest.json" + if [[ -d "$SENTRY_SYMBOLS_DIR" ]]; then + mkdir -p "$stage_root/.build/sentry-symbols" + ditto "$SENTRY_SYMBOLS_DIR" "$stage_root/.build/sentry-symbols/release" + fi cp "$ROOT_DIR/version.env" "$ROOT_DIR/LICENSE" "$ROOT_DIR/THIRD_PARTY_NOTICES.md" "$stage_root/" cp -R "$ROOT_DIR/ThirdPartyLicenses" "$stage_root/" printf '%s\n' "$RELEASE_COMMIT" > "$stage_root/RELEASE_COMMIT" @@ -285,6 +310,7 @@ publish_staged_release() { xcrun stapler validate "$APP_BUNDLE" write_final_artifact_manifest validate_public_app "$APP_BUNDLE" "$FINAL_ARTIFACT_MANIFEST" "Final Developer ID app" + upload_sentry_symbols_if_configured local distribution_dir="$TMP_DIR/distribution" mkdir -p "$distribution_dir" diff --git a/Scripts/sign_staged_release.sh b/Scripts/sign_staged_release.sh index ac6a2aa8b..2177a5d3f 100755 --- a/Scripts/sign_staged_release.sh +++ b/Scripts/sign_staged_release.sh @@ -67,6 +67,13 @@ plutil -lint "$app_entitlements" plutil -replace RepoPromptDebugSecureStorageBackend -string keychain "$APP_BUNDLE/Contents/Info.plist" plutil -replace RepoPromptSigningMode -string developer-id "$APP_BUNDLE/Contents/Info.plist" +# Telemetry is gated on DSN presence: only the official Developer ID publish job receives the +# protected SENTRY_DSN secret, and only here is it baked into the signed bundle. The value is never +# echoed or written to the artifact manifest. +if [[ -n "${SENTRY_DSN:-}" ]]; then + plutil -replace RepoPromptSentryDSN -string "$SENTRY_DSN" "$APP_BUNDLE/Contents/Info.plist" +fi + sign_path() { local path="$1" shift diff --git a/Scripts/test_release_tooling.py b/Scripts/test_release_tooling.py index cbb099e78..764f5f1ea 100644 --- a/Scripts/test_release_tooling.py +++ b/Scripts/test_release_tooling.py @@ -31,12 +31,17 @@ def test_runtime_signing_policy_matches_release_metadata_and_entitlement_templat key, value = line.split("=", 1) metadata[key] = value.strip('"') + package_manifest = (root / "Package.swift").read_text(encoding="utf-8") policy = ( root / "Sources" / "RepoPrompt" / "Infrastructure" / "Security" / "RuntimeCodeSigningPolicy.swift" ).read_text(encoding="utf-8") entitlements = (root / "AppBundle" / "RepoPrompt.entitlements.template").read_text(encoding="utf-8") info_plist = plistlib.loads((root / "AppBundle" / "Info.plist.template").read_bytes()) + self.assertIn('environment["REPOPROMPT_ENABLE_SENTRY"] == "1"', package_manifest) + self.assertIn('repoPromptSwiftSettings.append(.define("REPOPROMPT_SENTRY_ENABLED"))', package_manifest) + self.assertNotIn("let sentryEnabled = true", package_manifest) + self.assertIn( f'static let developerIDBundleIdentifier = "{metadata["BUNDLE_ID"]}"', policy, @@ -58,6 +63,8 @@ def test_runtime_signing_policy_matches_release_metadata_and_entitlement_templat self.assertIn("RepoPromptDebugSecureStorageBackend", info_plist) self.assertIn("RepoPromptLocalSigningCertificateSHA256", info_plist) self.assertIn("RepoPromptLocalSecureStorageGeneration", info_plist) + self.assertIn("RepoPromptSentryDSN", info_plist) + self.assertEqual(info_plist["RepoPromptSentryDSN"], "") self.assertIn( 'static let localSelfSignedCertificateName = "RepoPrompt CE Local Self-Signed Code Signing"', policy, @@ -501,6 +508,38 @@ def test_artifact_manifest_is_deterministic_external_and_detects_binary_drift(se self.assertIsNone(manifest_content["bundle_signing"]["leaf_certificate_sha256"]) for executable in manifest_content["executables"]: self.assertIsNone(executable["signing"]["leaf_certificate_sha256"]) + # The RC fixture has no DSN, so telemetry is disabled. + self.assertFalse(manifest_content["bundle"]["telemetry_enabled"]) + + # With a DSN present, the manifest records telemetry_enabled=True but never the DSN value. + dsn_value = "https://examplepublickey@o9999.ingest.sentry.io/424242" + info_with_dsn = dict(info) + info_with_dsn["RepoPromptSentryDSN"] = dsn_value + (app / "Contents" / "Info.plist").write_bytes(plistlib.dumps(info_with_dsn)) + dsn_manifest = app.parent / "telemetry-manifest.json" + dsn_written = subprocess.run( + [ + str(writer), + "write", + "--app", + str(app), + "--output", + str(dsn_manifest), + "--expected-architectures", + "arm64,x86_64", + ], + env=env, + text=True, + capture_output=True, + ) + self.assertEqual(dsn_written.returncode, 0, dsn_written.stderr) + dsn_manifest_text = dsn_manifest.read_text(encoding="utf-8") + self.assertNotIn(dsn_value, dsn_manifest_text) + self.assertNotIn("examplepublickey", dsn_manifest_text) + self.assertTrue(json.loads(dsn_manifest_text)["bundle"]["telemetry_enabled"]) + # Restore the no-DSN RC Info.plist so the remainder of the test is unaffected. + (app / "Contents" / "Info.plist").write_bytes(plistlib.dumps(info)) + accepted = subprocess.run( [ str(writer), @@ -1106,6 +1145,110 @@ def test_release_workflows_isolate_executable_helper_smoke_and_harden_p12_cleanu self.assertIn('rm -f "$CERTIFICATE_PATH"', final_cleanup) self.assertIn('rm -rf "$RUNNER_TEMP/repoprompt-release-secrets"', final_cleanup) + def test_sentry_symbol_upload_helper_uses_token_file_without_logging_secret(self) -> None: + temp_dir = Path(tempfile.mkdtemp()) + self.addCleanup(shutil.rmtree, temp_dir, True) + symbols = temp_dir / "symbols" + symbols.mkdir() + (symbols / "RepoPrompt.dSYM").mkdir() + ambient_token = "sntrys_wrong_ambient_secret_token" + token = "sntrys_fixture_secret_token" + token_file = temp_dir / "sentry-token" + token_file.write_text(token + "\n", encoding="utf-8") + argv_capture = temp_dir / "argv.txt" + token_capture = temp_dir / "token.txt" + fake_cli = temp_dir / "sentry-cli" + fake_cli.write_text( + """#!/usr/bin/env bash +set -euo pipefail +printf '%s\n' "$@" > "$ARGV_CAPTURE" +printf '%s' "${SENTRY_AUTH_TOKEN:-}" > "$TOKEN_CAPTURE" +""", + encoding="utf-8", + ) + fake_cli.chmod(0o755) + env = os.environ.copy() + env["SENTRY_AUTH_TOKEN"] = ambient_token + env.update( + { + "PATH": f"{temp_dir}:{env.get('PATH', '')}", + "REPOPROMPT_SENTRY_AUTH_TOKEN_FILE": str(token_file), + "REPOPROMPT_SENTRY_ORG": "fixture-org", + "REPOPROMPT_SENTRY_PROJECT": "fixture-project", + "ARGV_CAPTURE": str(argv_capture), + "TOKEN_CAPTURE": str(token_capture), + } + ) + + result = subprocess.run( + [str(SCRIPT_DIR / "upload_sentry_debug_symbols.sh"), str(symbols)], + env=env, + text=True, + capture_output=True, + ) + + self.assertEqual(result.returncode, 0, result.stderr) + self.assertNotIn(token, result.stdout) + self.assertNotIn(token, result.stderr) + argv = argv_capture.read_text(encoding="utf-8").splitlines() + self.assertEqual( + argv, + [ + "debug-files", + "upload", + "--include-sources", + "--org", + "fixture-org", + "--project", + "fixture-project", + str(symbols), + ], + ) + self.assertNotIn(token, "\n".join(argv)) + self.assertEqual(token_capture.read_text(encoding="utf-8"), token) + + def test_sentry_symbol_flow_is_explicit_secret_safe_and_release_only_by_default(self) -> None: + package_script = (SCRIPT_DIR / "package_app.sh").read_text(encoding="utf-8") + universal_builder = (SCRIPT_DIR / "build_swiftpm_release_products.sh").read_text(encoding="utf-8") + release_script = (SCRIPT_DIR / "release.sh").read_text(encoding="utf-8") + release_workflow = (SCRIPT_DIR.parent / ".github" / "workflows" / "release.yml").read_text(encoding="utf-8") + conductor = (SCRIPT_DIR / "conductor.py").read_text(encoding="utf-8") + + self.assertIn('SENTRY_SYMBOLS_DIR="$ROOT_DIR/.build/sentry-symbols/$CONF"', package_script) + self.assertNotIn("REPOPROMPT_SENTRY_SYMBOLS_DIR", package_script) + self.assertIn("SWIFT_BUILD_ARGS+=(-debug-info-format dwarf)", package_script) + self.assertIn('run xcrun dsymutil "$BUILD_DIR/$exe" -o "$SENTRY_SYMBOLS_DIR/$exe.dSYM"', package_script) + self.assertIn('if truthy "${REPOPROMPT_UPLOAD_SENTRY_SYMBOLS:-}"; then', package_script) + self.assertIn("REPOPROMPT_UPLOAD_SENTRY_SYMBOLS requires REPOPROMPT_ENABLE_SENTRY=1", package_script) + self.assertIn("REPOPROMPT_UPLOAD_SENTRY_SYMBOLS requires SENTRY_AUTH_TOKEN or REPOPROMPT_SENTRY_AUTH_TOKEN_FILE", package_script) + self.assertIn("SWIFT_BUILD_ARGS+=(-debug-info-format dwarf)", universal_builder) + + self.assertIn('require_file "$CONTROL_PLANE_SCRIPTS_DIR/upload_sentry_debug_symbols.sh"', release_script) + self.assertIn('SENTRY_SYMBOLS_DIR="$ROOT_DIR/.build/sentry-symbols/release"', release_script) + self.assertIn('ditto "$SENTRY_SYMBOLS_DIR" "$stage_root/.build/sentry-symbols/release"', release_script) + self.assertIn('upload_sentry_symbols_if_configured', release_script) + + stage_job = release_workflow.split("\n stage:", 1)[1].split("\n publish:", 1)[0] + publish_job = release_workflow.split("\n publish:", 1)[1].split("\n smoke-signed-helper:", 1)[0] + self.assertIn('REPOPROMPT_ENABLE_SENTRY: "1"', stage_job) + self.assertNotIn("SENTRY_AUTH_TOKEN", stage_job) + self.assertIn("Install Sentry CLI when symbol upload is configured", publish_job) + self.assertIn("brew install getsentry/tools/sentry-cli", publish_job) + self.assertIn("SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}", publish_job) + self.assertLess( + publish_job.index("Install Sentry CLI when symbol upload is configured"), + publish_job.index("Sign, notarize, and create draft release"), + ) + self.assertIn("REPOPROMPT_SENTRY_ORG: ${{ vars.SENTRY_ORG }}", publish_job) + self.assertIn("REPOPROMPT_SENTRY_PROJECT: ${{ vars.SENTRY_PROJECT }}", publish_job) + + self.assertIn('"REPOPROMPT_ENABLE_SENTRY"', conductor) + self.assertIn('"REPOPROMPT_UPLOAD_SENTRY_SYMBOLS"', conductor) + self.assertIn('"REPOPROMPT_SENTRY_AUTH_TOKEN_FILE"', conductor) + self.assertIn('"REPOPROMPT_SENTRY_ORG"', conductor) + self.assertIn('"REPOPROMPT_SENTRY_PROJECT"', conductor) + self.assertNotIn('"SENTRY_AUTH_TOKEN"', conductor) + def test_staged_release_extractor_rejects_alternate_in_app_cli_target(self) -> None: for relative, alternate_target in ( ("Contents/Resources/repoprompt-mcp", "../MacOS/RepoPrompt"), diff --git a/Scripts/upload_sentry_debug_symbols.sh b/Scripts/upload_sentry_debug_symbols.sh new file mode 100755 index 000000000..5a6890c35 --- /dev/null +++ b/Scripts/upload_sentry_debug_symbols.sh @@ -0,0 +1,54 @@ +#!/usr/bin/env bash +set -euo pipefail + +fail() { + printf 'ERROR: %s\n' "$*" >&2 + exit 1 +} + +truthy() { + case "${1:-}" in + 1|true|TRUE|yes|YES) return 0 ;; + *) return 1 ;; + esac +} + +if [[ $# -lt 1 ]]; then + fail "usage: upload_sentry_debug_symbols.sh ..." +fi + +token_file="${REPOPROMPT_SENTRY_AUTH_TOKEN_FILE:-}" +if [[ -n "$token_file" ]]; then + [[ -f "$token_file" ]] || fail "Sentry auth token file does not exist: $token_file" + SENTRY_AUTH_TOKEN="$(tr -d '\r\n' < "$token_file")" +fi + +if [[ -z "${SENTRY_AUTH_TOKEN:-}" ]]; then + printf 'Skipping Sentry debug symbol upload: SENTRY_AUTH_TOKEN is not set.\n' + exit 0 +fi + +sentry_org="${REPOPROMPT_SENTRY_ORG:-}" +sentry_project="${REPOPROMPT_SENTRY_PROJECT:-}" +[[ -n "$sentry_org" ]] || fail "REPOPROMPT_SENTRY_ORG is required when uploading Sentry debug symbols." +[[ -n "$sentry_project" ]] || fail "REPOPROMPT_SENTRY_PROJECT is required when uploading Sentry debug symbols." +command -v sentry-cli >/dev/null 2>&1 || fail "sentry-cli is required to upload Sentry debug symbols." + +paths=() +for candidate in "$@"; do + if [[ -e "$candidate" ]]; then + paths+=("$candidate") + fi +done +(( ${#paths[@]} > 0 )) || fail "No existing Sentry debug symbol paths were provided." + +args=(debug-files upload --include-sources --org "$sentry_org" --project "$sentry_project") +if truthy "${REPOPROMPT_SENTRY_UPLOAD_WAIT:-0}"; then + args+=(--wait) +fi +args+=("${paths[@]}") + +export SENTRY_AUTH_TOKEN +printf 'Uploading Sentry debug symbols for org=%s project=%s from %s path(s).\n' \ + "$sentry_org" "$sentry_project" "${#paths[@]}" +sentry-cli "${args[@]}" diff --git a/Scripts/write_app_artifact_manifest.py b/Scripts/write_app_artifact_manifest.py index 9896ca635..80396b159 100755 --- a/Scripts/write_app_artifact_manifest.py +++ b/Scripts/write_app_artifact_manifest.py @@ -151,6 +151,9 @@ def collect_manifest(app: Path, expected_architectures: list[str] | None) -> dic signing_mode = info.get("RepoPromptSigningMode") allow_adhoc_without_requirement = signing_mode == "release-candidate-adhoc" require_leaf_certificate = signing_mode in {"developer-id", "local-self-signed"} + # Record only whether telemetry is enabled, never the DSN value itself (it is secret). + sentry_dsn = info.get("RepoPromptSentryDSN") + telemetry_enabled = isinstance(sentry_dsn, str) and bool(sentry_dsn.strip()) entries = [ executable_entry( app, @@ -189,6 +192,7 @@ def collect_manifest(app: Path, expected_architectures: list[str] | None) -> dic "marketing_version": info.get("CFBundleShortVersionString"), "build_number": info.get("CFBundleVersion"), "signing_mode": signing_mode, + "telemetry_enabled": telemetry_enabled, "architecture_policy": "universal-public" if actual_architectures == ["arm64", "x86_64"] else "host-native", diff --git a/docs/releasing.md b/docs/releasing.md index 4231adbcf..f921b2f71 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -222,6 +222,64 @@ Add these environment secrets: | `NOTARYTOOL_ISSUER_ID` | App Store Connect API issuer ID. | | `SPARKLE_PRIVATE_KEY` | Modern Sparkle EdDSA private-key seed for the CE update channel. It must decode from base64 to exactly 32 bytes. | | `PUBLIC_UPDATE_REPOSITORY_TOKEN` | Fine-grained GitHub token scoped only to `repoprompt/repoprompt-ce-updates` with repository contents read/write permission. | +| `SENTRY_DSN` | Protected Sentry DSN injected into official signed builds. | +| `SENTRY_AUTH_TOKEN` | Sentry auth token used only for uploading release debug symbols during publication. | + +Add these non-secret release environment variables when Sentry symbol upload is enabled: + +| Variable | Contents | +| --- | --- | +| `REPOPROMPT_ENABLE_SENTRY` | `1` for official telemetry-enabled release staging. | +| `REPOPROMPT_SENTRY_ORG` | Sentry organization slug. | +| `REPOPROMPT_SENTRY_PROJECT` | Sentry project slug. | + +## Sentry telemetry and debug symbols + +Official telemetry-enabled release staging links the Sentry SDK when +`REPOPROMPT_ENABLE_SENTRY=1`. The protected `SENTRY_DSN` secret is injected into +`Info.plist` as `RepoPromptSentryDSN` only by `Scripts/sign_staged_release.sh`. +Do not commit, log, or record the DSN in artifact manifests; manifests record +only the non-secret `telemetry_enabled` boolean. + +When Sentry is enabled, release staging generates dSYMs under +`.build/sentry-symbols/release` and carries them inside the staged release ZIP. +`release.sh publish-staged` uploads those debug symbols when `SENTRY_AUTH_TOKEN` +is available, using `REPOPROMPT_SENTRY_ORG` and `REPOPROMPT_SENTRY_PROJECT`. +The upload helper runs: + +```bash +sentry-cli debug-files upload --include-sources +``` + +That uploads dSYMs/debug files and local source context together so Sentry can +symbolicate RepoPrompt frames and show source context for app-owned code. + +Local/debug symbol upload is opt-in and is mainly for testing the integration: + +```bash +REPOPROMPT_ENABLE_SENTRY=1 \ +REPOPROMPT_SENTRY_DSN="https://examplePublicKey@o0.ingest.sentry.io/0" \ +REPOPROMPT_UPLOAD_SENTRY_SYMBOLS=1 \ +REPOPROMPT_SENTRY_ORG="repoprompt" \ +REPOPROMPT_SENTRY_PROJECT="repoprompt" \ +REPOPROMPT_SENTRY_AUTH_TOKEN_FILE="$HOME/.config/repoprompt/sentry-token" \ +./Scripts/package_app.sh debug +``` + +Prefer `REPOPROMPT_SENTRY_AUTH_TOKEN_FILE` for coordinated `make dev-build` / +conductor runs. The daemon intentionally does not pass through `SENTRY_AUTH_TOKEN` +because it stores job environment snapshots for status and retry identity. + +DEBUG telemetry-enabled builds support a shell-only crash probe for validating +Sentry event detail: + +```bash +"$HOME/Library/Application Support/RepoPrompt CE/DebugApps/RepoPrompt.app/Contents/MacOS/RepoPrompt" \ + --repoprompt-sentry-test-crash +``` + +Relaunch the app once without the argument so the SDK can flush the cached native +crash report. The optional `SIGN_IDENTITY` environment variable defaults to: From e1100fd935ca62ea21464ad0b0f5e45bec1c18c9 Mon Sep 17 00:00:00 2001 From: SB Date: Wed, 8 Jul 2026 01:49:35 +0900 Subject: [PATCH 11/12] fix(telemetry): trace async app launch startup --- Sources/RepoPrompt/App/RepoPromptApp.swift | 20 ++++++++------------ 1 file changed, 8 insertions(+), 12 deletions(-) diff --git a/Sources/RepoPrompt/App/RepoPromptApp.swift b/Sources/RepoPrompt/App/RepoPromptApp.swift index f8b6281b4..b0f36dd64 100644 --- a/Sources/RepoPrompt/App/RepoPromptApp.swift +++ b/Sources/RepoPrompt/App/RepoPromptApp.swift @@ -70,9 +70,6 @@ struct RepoPromptApp: App { signal(SIGPIPE, SIG_IGN) SentryTelemetryBootstrap.start() - SentryTelemetryBootstrap.trace(.appLaunch) { - SentryTelemetryBootstrap.addBreadcrumb(.appLifecycle, action: .appInitialized) - } ProcessDebugLogging.log( prefix: "MCPStartup", @@ -80,19 +77,18 @@ struct RepoPromptApp: App { flushStdout: true ) Task.detached { - ProcessDebugLogging.log( - prefix: "MCPStartup", - "RepoPromptApp.init start task running", - flushStdout: true - ) - #if REPOPROMPT_SENTRY_ENABLED + await SentryTelemetryBootstrap.traceAsync(.appLaunch) { + SentryTelemetryBootstrap.addBreadcrumb(.appLifecycle, action: .appInitialized) + ProcessDebugLogging.log( + prefix: "MCPStartup", + "RepoPromptApp.init start task running", + flushStdout: true + ) await SentryTelemetryBootstrap.traceAsync(.mcpServerStart) { await ServerController.shared.startServer() SentryTelemetryBootstrap.addBreadcrumb(.mcpBootstrap, action: .mcpServerStarted) } - #else - await ServerController.shared.startServer() - #endif + } } if !AppLaunchConfiguration.current.suppressesWindowRestore { From 043e0ff033e22de43a7bfe0d8d807975a5431c27 Mon Sep 17 00:00:00 2001 From: SB Date: Wed, 8 Jul 2026 02:04:26 +0900 Subject: [PATCH 12/12] fix(ui): remove checked-in Sentry test crash hook --- .../Infrastructure/UI/Components/MCPServerToggleView.swift | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/Sources/RepoPrompt/Infrastructure/UI/Components/MCPServerToggleView.swift b/Sources/RepoPrompt/Infrastructure/UI/Components/MCPServerToggleView.swift index 6e0ae6c9a..9978ad2e8 100644 --- a/Sources/RepoPrompt/Infrastructure/UI/Components/MCPServerToggleView.swift +++ b/Sources/RepoPrompt/Infrastructure/UI/Components/MCPServerToggleView.swift @@ -94,12 +94,8 @@ struct MCPServerToggleView: View { isProcessing ? .accentColor : toolbarStateObserver.visualState.iconColor } - private func triggerSentryTestCrash() { - fatalError("RepoPrompt Sentry test crash from MCP server toolbar button") - } - var body: some View { - Button(action: triggerSentryTestCrash) { + Button(action: { showPopover.toggle() }) { HStack(spacing: 6) { Image(systemName: "server.rack") .imageScale(.medium)