From e6ce1e2a5d5fdea8b3b48713738c0f7e59fc0f4e Mon Sep 17 00:00:00 2001 From: RNT56 Date: Mon, 13 Jul 2026 14:57:17 +0200 Subject: [PATCH 1/2] Complete production UX and harden iOS CI Add durable provider transfers with real upload progress, explicit hosted-tool approvals, persisted CloudKit conflict resolution, exact OpenRouter spend reporting, and honest compatibility guidance. Bound simulator lifecycle and Xcode test phases so CI fails with diagnostics instead of hanging. Extend schema, persistence, tests, generated project wiring, and product documentation. Verified with Swift package tests, the core verifier, full Xcode unit and UI suites, public hygiene, site build, workflow lint, ShellCheck, and gitleaks. --- .github/workflows/ci.yml | 4 + Pines.xcodeproj/project.pbxproj | 16 + .../PinesAppModel+AnthropicLifecycle.swift | 4 +- Pines/App/PinesAppModel+GeminiLifecycle.swift | 4 +- Pines/App/PinesAppModel+MCP.swift | 48 ++ .../PinesAppModel+OpenAIProviderStorage.swift | 4 +- Pines/App/PinesAppModel+Presentation.swift | 92 +++- Pines/App/PinesAppModel+ProductionUX.swift | 43 ++ .../App/PinesAppModel+ProviderTransfers.swift | 320 ++++++++++++++ Pines/App/PinesAppModel.swift | 56 ++- Pines/App/PinesAppModelTypes.swift | 15 + Pines/App/PinesAppServices.swift | 6 + Pines/App/PinesAppState.swift | 12 + Pines/App/PinesRootView.swift | 83 ++++ ...nthropicProviderLifecycleCoordinator.swift | 5 +- Pines/Cloud/AnthropicProviderService.swift | 28 +- Pines/Cloud/BoundedHTTPResponse.swift | 30 +- .../GeminiProviderLifecycleCoordinator.swift | 9 +- Pines/Cloud/GeminiProviderService.swift | 13 +- .../OpenAIProviderLifecycleCoordinator.swift | 6 +- Pines/Cloud/OpenAIProviderService.swift | 23 +- .../Persistence/GRDBPinesStore+CloudKit.swift | 216 ++++++++- .../GRDBPinesStore+ProductionUX.swift | 347 +++++++++++++++ Pines/Persistence/GRDBPinesStore.swift | 3 + .../Artifacts/ArtifactsWorkspaceView.swift | 150 +++++-- Pines/Views/Models/ModelsViewComponents.swift | 32 +- Pines/Views/Settings/SettingsDetailView.swift | 127 ++++++ PinesTests/ProductionUXPersistenceTests.swift | 174 ++++++++ .../Persistence/DatabaseSchema.swift | 51 ++- Sources/PinesCore/ProductionUXTypes.swift | 413 ++++++++++++++++++ Sources/PinesCoreTestRunner/main.swift | 4 +- Tests/PinesCoreTests/CoreContractTests.swift | 8 +- .../ProductionUXTypesTests.swift | 62 +++ docs/STATUS.md | 7 +- docs/cloud-providers/anthropic.md | 18 +- docs/cloud-providers/openai-roadmap.md | 6 +- docs/cloud-providers/openrouter-roadmap.md | 6 +- docs/cloud-providers/openrouter.md | 6 +- docs/cloud-providers/ui-roadmap.md | 10 +- docs/feature_additions.md | 1 + scripts/ci/run-xcode-validation.sh | 80 +++- 41 files changed, 2438 insertions(+), 104 deletions(-) create mode 100644 Pines/App/PinesAppModel+ProductionUX.swift create mode 100644 Pines/App/PinesAppModel+ProviderTransfers.swift create mode 100644 Pines/Persistence/GRDBPinesStore+ProductionUX.swift create mode 100644 PinesTests/ProductionUXPersistenceTests.swift create mode 100644 Sources/PinesCore/ProductionUXTypes.swift create mode 100644 Tests/PinesCoreTests/ProductionUXTypesTests.swift diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c988e43..d1d75ff 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -202,6 +202,10 @@ jobs: name: Xcode project build runs-on: macos-26 timeout-minutes: 60 + env: + PINES_SIMULATOR_OPERATION_TIMEOUT_SECONDS: 180 + PINES_XCODE_TEST_TIMEOUT_SECONDS: 480 + PINES_XCODE_TEST_ATTEMPTS: 1 steps: - name: Checkout diff --git a/Pines.xcodeproj/project.pbxproj b/Pines.xcodeproj/project.pbxproj index eaf7200..fc10f18 100644 --- a/Pines.xcodeproj/project.pbxproj +++ b/Pines.xcodeproj/project.pbxproj @@ -20,6 +20,7 @@ 14619B70F3093CC492C66E1E /* MCPStreamableHTTPPayloads.swift in Sources */ = {isa = PBXBuildFile; fileRef = 453D050E47134A672156FBA7 /* MCPStreamableHTTPPayloads.swift */; }; 148670557457CDC793E77B74 /* PinesStressDiagnostics.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD7A1C3BB2F375BB75558DEF /* PinesStressDiagnostics.swift */; }; 14C935C0C8E726C774184D06 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = FFFEAD9040192EA2246EDAD7 /* PrivacyInfo.xcprivacy */; }; + 15BAE7F3C3F25C0E9A585BF3 /* PinesAppModel+ProductionUX.swift in Sources */ = {isa = PBXBuildFile; fileRef = 87962C9865B67066C3B4A065 /* PinesAppModel+ProductionUX.swift */; }; 1696C482B2BFEADAD7A1744D /* AgentRunner.swift in Sources */ = {isa = PBXBuildFile; fileRef = D71AF1F71FC9A37490CF1D04 /* AgentRunner.swift */; }; 16E0EFE03D0794F515FE4916 /* GeminiLiveSessionService.swift in Sources */ = {isa = PBXBuildFile; fileRef = A577E1122CE291EF5ECF16D2 /* GeminiLiveSessionService.swift */; }; 18C03354C01E800E9DF94E48 /* PhoneWatchSessionService.swift in Sources */ = {isa = PBXBuildFile; fileRef = F70C871149DDD6846E8A71E4 /* PhoneWatchSessionService.swift */; }; @@ -66,6 +67,7 @@ 686B47B0D035141554B0F573 /* BoundedHTTPResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 01D033C19E3D30C55472B9C0 /* BoundedHTTPResponse.swift */; }; 69BDBCFF81EA56913B793189 /* VaultIngestionService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6A46B185F9ED03D5646D6C7C /* VaultIngestionService.swift */; }; 6F2B60A874B79E3B73C27624 /* CloudProviderService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A168FBE9051A1A21DB0BC6D /* CloudProviderService.swift */; }; + 6F41226A048D04B78DD9D5EC /* ProductionUXPersistenceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA16124DB3D831EFEB164408 /* ProductionUXPersistenceTests.swift */; }; 6F9A244BE814746CDB379678 /* OpenRouterRoutingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2B52037BC526EB397D558996 /* OpenRouterRoutingTests.swift */; }; 719221FCF6C2534E106E242F /* PinesAppModel+AnthropicLifecycle.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA6B8F14EBFB0EBF412BB1E7 /* PinesAppModel+AnthropicLifecycle.swift */; }; 72E9FD20785D66F46A2E07C4 /* BoundedHTTPResponseTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E146838846B476DD76171BB2 /* BoundedHTTPResponseTests.swift */; }; @@ -99,6 +101,7 @@ B3575B225F8E3FB7D77E8244 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5A8627F9808A76C0F0A91729 /* SettingsView.swift */; }; B3597D1896C704560D0BBA0F /* PinesTurboQuantBenchmarkDiagnostics.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6EEEEF241E72FF35E46A640 /* PinesTurboQuantBenchmarkDiagnostics.swift */; }; B4A9D4F14838316236688468 /* PinesRealModelTurboQuantDiagnostics.swift in Sources */ = {isa = PBXBuildFile; fileRef = B116CBC0CC3896287D3E1057 /* PinesRealModelTurboQuantDiagnostics.swift */; }; + B7846F5FFB50FC69A71CA74D /* PinesAppModel+ProviderTransfers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 996DF39B02823192A5F70D0D /* PinesAppModel+ProviderTransfers.swift */; }; B7A642333FC08377576C9C3F /* PinesUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA70907A54EAB3A9DE13642D /* PinesUITests.swift */; }; B925E264EAB46D0C3EB6B307 /* PinesHubXetSupport in Frameworks */ = {isa = PBXBuildFile; productRef = F5BE958969B414C35418437A /* PinesHubXetSupport */; }; BAF84E40F21C59F00422283F /* MLXNN in Frameworks */ = {isa = PBXBuildFile; productRef = 97ADD97B0749021A162048FE /* MLXNN */; }; @@ -120,6 +123,7 @@ E64DF5CED9F4BECBE5446731 /* ModelDownloadLiveActivityController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2EB6A8EFD9FE940BEFE80119 /* ModelDownloadLiveActivityController.swift */; }; E84F98A01F4A541B478C8D0D /* PinesAppModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9B417F2B5DA63F1EDAE01442 /* PinesAppModel.swift */; }; E96F244C6E13B6BBAD411CE0 /* WKWebViewBrowserRuntime.swift in Sources */ = {isa = PBXBuildFile; fileRef = 48D656408D8E64E0D6C5E6E4 /* WKWebViewBrowserRuntime.swift */; }; + EA63CC61A39833D31645C2FB /* GRDBPinesStore+ProductionUX.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA4749FF84B1B36B9C1A2C5C /* GRDBPinesStore+ProductionUX.swift */; }; EF023CED82C75823EC051432 /* VaultEmbeddingService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 46107CC67D229C2D05C33A59 /* VaultEmbeddingService.swift */; }; EF4BB673BC780064BBF0DDD5 /* BraveSearchTool.swift in Sources */ = {isa = PBXBuildFile; fileRef = 948132DFF2EAF4B9D92CE805 /* BraveSearchTool.swift */; }; F00643F235FF10B3F267D0EA /* OpenAIProviderLifecycleCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5111BF39FD0BF9D28CDB406D /* OpenAIProviderLifecycleCoordinator.swift */; }; @@ -251,6 +255,7 @@ 7BD8C13D0473641034075F8C /* OpenAIProviderStorageWorkflows.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenAIProviderStorageWorkflows.swift; sourceTree = ""; }; 7C379B46E87980ED73F59DCD /* PinesWatchApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PinesWatchApp.swift; sourceTree = ""; }; 82DE8010D019802F630AAF0B /* PinesApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PinesApp.swift; sourceTree = ""; }; + 87962C9865B67066C3B4A065 /* PinesAppModel+ProductionUX.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PinesAppModel+ProductionUX.swift"; sourceTree = ""; }; 8BD9A00CA54B90E791E77F56 /* MarkdownMessageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarkdownMessageView.swift; sourceTree = ""; }; 8E832CA92705405D1734D955 /* ModelsViewComponents.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ModelsViewComponents.swift; sourceTree = ""; }; 92870571C6D3C4FD5605CB7E /* KeychainSecretStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeychainSecretStore.swift; sourceTree = ""; }; @@ -258,6 +263,7 @@ 948132DFF2EAF4B9D92CE805 /* BraveSearchTool.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BraveSearchTool.swift; sourceTree = ""; }; 95F1CE8B2C5E53DB9848C8C0 /* PinesAppState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PinesAppState.swift; sourceTree = ""; }; 989263AE706043656C31B580 /* CoreSurfaceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoreSurfaceTests.swift; sourceTree = ""; }; + 996DF39B02823192A5F70D0D /* PinesAppModel+ProviderTransfers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PinesAppModel+ProviderTransfers.swift"; sourceTree = ""; }; 99ABB45AA2309B6667A25016 /* PinesAppModel+OpenAIProviderStorage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PinesAppModel+OpenAIProviderStorage.swift"; sourceTree = ""; }; 9B417F2B5DA63F1EDAE01442 /* PinesAppModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PinesAppModel.swift; sourceTree = ""; }; 9BDA783D05156233EA9620B1 /* ChatOpenRouterReceiptView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatOpenRouterReceiptView.swift; sourceTree = ""; }; @@ -268,6 +274,8 @@ A613D7E3096A52A53E3F9BC1 /* WatchHaptics.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchHaptics.swift; sourceTree = ""; }; A790E0D7CCFB1AF34B214B13 /* PinesHuggingFaceBridge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PinesHuggingFaceBridge.swift; sourceTree = ""; }; A8B838038B4F8ACF52E8F8EB /* PinesAppModel+Artifacts.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PinesAppModel+Artifacts.swift"; sourceTree = ""; }; + AA16124DB3D831EFEB164408 /* ProductionUXPersistenceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProductionUXPersistenceTests.swift; sourceTree = ""; }; + AA4749FF84B1B36B9C1A2C5C /* GRDBPinesStore+ProductionUX.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "GRDBPinesStore+ProductionUX.swift"; sourceTree = ""; }; AA70907A54EAB3A9DE13642D /* PinesUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PinesUITests.swift; sourceTree = ""; }; ABBC1146085E67B1969B3C24 /* CloudKitRecordCipher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudKitRecordCipher.swift; sourceTree = ""; }; AE7FFC4A75A71DA8C323BB59 /* ArtifactsModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ArtifactsModels.swift; sourceTree = ""; }; @@ -422,6 +430,7 @@ 4DCE6D93751290B9E24557E6 /* MCPToolAnnotationPersistenceTests.swift */, FF525EBBEA1C9D3330E352F3 /* MLXTurboQuantRuntimeSmokeTests.swift */, 2B52037BC526EB397D558996 /* OpenRouterRoutingTests.swift */, + AA16124DB3D831EFEB164408 /* ProductionUXPersistenceTests.swift */, ); path = PinesTests; sourceTree = ""; @@ -624,6 +633,8 @@ F57763E1BB7A0DD285BE66BC /* PinesAppModel+OpenAIMediaWorkflows.swift */, 99ABB45AA2309B6667A25016 /* PinesAppModel+OpenAIProviderStorage.swift */, 27F8A15281AD531830EEBB98 /* PinesAppModel+Presentation.swift */, + 87962C9865B67066C3B4A065 /* PinesAppModel+ProductionUX.swift */, + 996DF39B02823192A5F70D0D /* PinesAppModel+ProviderTransfers.swift */, DD3E7E5D4E7D39C6BCD7C151 /* PinesAppModel+Stress.swift */, 0FA547644EE942BA65EC006C /* PinesAppModelTypes.swift */, DC8CFAC4EBF64683D47B94C8 /* PinesAppServices.swift */, @@ -680,6 +691,7 @@ 5852E9A0AF3A4AC3314E5A48 /* GRDBPinesStore.swift */, 54110781FB02FBE823BC3F9C /* GRDBPinesStore+CloudKit.swift */, AEC56EEE76C86B70FF02D9AF /* GRDBPinesStore+Mapping.swift */, + AA4749FF84B1B36B9C1A2C5C /* GRDBPinesStore+ProductionUX.swift */, ); path = Persistence; sourceTree = ""; @@ -912,6 +924,7 @@ 1B52E0E2F83496E860CFF466 /* EncryptedBlobStore.swift in Sources */, 368944A065D96B6F78A3E0E6 /* GRDBPinesStore+CloudKit.swift in Sources */, 0101B36D3B132435D7763B4A /* GRDBPinesStore+Mapping.swift in Sources */, + EA63CC61A39833D31645C2FB /* GRDBPinesStore+ProductionUX.swift in Sources */, 8C35F8BC0A4CD94236F66F46 /* GRDBPinesStore.swift in Sources */, 16E0EFE03D0794F515FE4916 /* GeminiLiveSessionService.swift in Sources */, 65F15CA6905766AC2C4C0280 /* GeminiProviderLifecycleCoordinator.swift in Sources */, @@ -950,6 +963,8 @@ F7B13175B183F946D3F5ABAB /* PinesAppModel+OpenAIMediaWorkflows.swift in Sources */, A9753F68411A3E16284D04EF /* PinesAppModel+OpenAIProviderStorage.swift in Sources */, F3D59B7E54F622FED196570C /* PinesAppModel+Presentation.swift in Sources */, + 15BAE7F3C3F25C0E9A585BF3 /* PinesAppModel+ProductionUX.swift in Sources */, + B7846F5FFB50FC69A71CA74D /* PinesAppModel+ProviderTransfers.swift in Sources */, 8BA43F9B205B9683A03F3CD4 /* PinesAppModel+Stress.swift in Sources */, E84F98A01F4A541B478C8D0D /* PinesAppModel.swift in Sources */, 2DC7F4355DC8D047D2D5460C /* PinesAppModelTypes.swift in Sources */, @@ -1001,6 +1016,7 @@ E6389B1E94D595D33725A8AC /* MCPToolAnnotationPersistenceTests.swift in Sources */, 9CAF8AA54EE5B61702B8103C /* MLXTurboQuantRuntimeSmokeTests.swift in Sources */, 6F9A244BE814746CDB379678 /* OpenRouterRoutingTests.swift in Sources */, + 6F41226A048D04B78DD9D5EC /* ProductionUXPersistenceTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/Pines/App/PinesAppModel+AnthropicLifecycle.swift b/Pines/App/PinesAppModel+AnthropicLifecycle.swift index 7f4e0ec..237e9ad 100644 --- a/Pines/App/PinesAppModel+AnthropicLifecycle.swift +++ b/Pines/App/PinesAppModel+AnthropicLifecycle.swift @@ -69,6 +69,7 @@ extension PinesAppModel { fileURL: URL, contentType: String? = nil, consent: PinesAnthropicProviderStorageConsent, + uploadProgress: ProviderUploadProgress? = nil, services: PinesAppServices ) async throws -> PinesAnthropicProviderUploadResult { do { @@ -89,7 +90,8 @@ extension PinesAppModel { fileName: fileURL.lastPathComponent, contentType: contentType ?? Self.providerStorageContentType(for: fileURL), data: data, - localURL: fileURL + localURL: fileURL, + uploadProgress: uploadProgress ) try await auditAnthropicProviderStorageConsent(consent, providerID: coordinator.providerID, services: services) await refreshProviderLifecycleState(services: services) diff --git a/Pines/App/PinesAppModel+GeminiLifecycle.swift b/Pines/App/PinesAppModel+GeminiLifecycle.swift index 3015461..a9251d5 100644 --- a/Pines/App/PinesAppModel+GeminiLifecycle.swift +++ b/Pines/App/PinesAppModel+GeminiLifecycle.swift @@ -82,6 +82,7 @@ extension PinesAppModel { contentType: String? = nil, consent: PinesGeminiProviderStorageConsent, poll: GeminiFilePolling? = GeminiFilePolling(), + uploadProgress: ProviderUploadProgress? = nil, services: PinesAppServices ) async throws -> PinesGeminiProviderUploadResult { do { @@ -108,7 +109,8 @@ extension PinesAppModel { contentType: resolvedContentType, data: data, localURL: fileURL, - poll: poll + poll: poll, + uploadProgress: uploadProgress ) try await auditGeminiProviderStorageConsent(consent, providerID: coordinator.providerID, services: services) await refreshProviderLifecycleState(services: services) diff --git a/Pines/App/PinesAppModel+MCP.swift b/Pines/App/PinesAppModel+MCP.swift index f138c42..041cd0f 100644 --- a/Pines/App/PinesAppModel+MCP.swift +++ b/Pines/App/PinesAppModel+MCP.swift @@ -3,6 +3,54 @@ import PinesCore @MainActor extension PinesAppModel { + func requestHostedToolApproval( + _ request: HostedToolApprovalRequest, + services: PinesAppServices + ) async -> Bool { + hostedToolApprovalContinuation?.resume(returning: false) + hostedToolApprovalContinuation = nil + pendingHostedToolApproval = nil + await appendAuditEvent( + AuditEvent( + category: .consent, + summary: "Requested approval for provider-hosted tools: \(request.descriptors.map(\.displayName).joined(separator: ", ")).", + redactedPayload: "Environment and data-egress details were presented before execution.", + providerID: request.providerID, + modelID: request.modelID, + networkDomains: request.descriptors.flatMap(\.networkDestinations) + ), + services: services, + component: "hosted_tool_approval_requested" + ) + return await withCheckedContinuation { continuation in + hostedToolApprovalContinuation = continuation + pendingHostedToolApproval = request + emitHaptic(.toolApprovalNeeded) + } + } + + func resolvePendingHostedToolApproval(_ approved: Bool, services: PinesAppServices?) { + guard let request = pendingHostedToolApproval else { return } + pendingHostedToolApproval = nil + emitHaptic(approved ? .primaryAction : .runCancelled) + hostedToolApprovalContinuation?.resume(returning: approved) + hostedToolApprovalContinuation = nil + guard let services else { return } + Task { + await appendAuditEvent( + AuditEvent( + category: .consent, + summary: approved ? "Approved provider-hosted tool execution." : "Denied provider-hosted tool execution.", + providerID: request.providerID, + modelID: request.modelID, + networkDomains: request.descriptors.flatMap(\.networkDestinations) + ), + services: services, + component: approved ? "hosted_tool_approval_granted" : "hosted_tool_approval_denied" + ) + } + } + func requestToolApproval(_ request: ToolApprovalRequest) async -> ToolApprovalStatus { pendingToolApproval = request emitHaptic(.toolApprovalNeeded) diff --git a/Pines/App/PinesAppModel+OpenAIProviderStorage.swift b/Pines/App/PinesAppModel+OpenAIProviderStorage.swift index f2297d6..90e84b8 100644 --- a/Pines/App/PinesAppModel+OpenAIProviderStorage.swift +++ b/Pines/App/PinesAppModel+OpenAIProviderStorage.swift @@ -59,6 +59,7 @@ extension PinesAppModel { consent: PinesOpenAIProviderStorageConsent, attachToVectorStoreID: String? = nil, vectorStoreAttributes: JSONValue? = nil, + uploadProgress: ProviderUploadProgress? = nil, services: PinesAppServices ) async throws -> PinesOpenAIProviderUploadResult { do { @@ -77,7 +78,8 @@ extension PinesAppModel { contentType: contentType ?? Self.providerStorageContentType(for: fileURL), data: data, purpose: purpose, - localURL: fileURL + localURL: fileURL, + uploadProgress: uploadProgress ) let attached: ProviderCacheRecord? if let attachToVectorStoreID { diff --git a/Pines/App/PinesAppModel+Presentation.swift b/Pines/App/PinesAppModel+Presentation.swift index 59287df..b4d4b32 100644 --- a/Pines/App/PinesAppModel+Presentation.swift +++ b/Pines/App/PinesAppModel+Presentation.swift @@ -246,6 +246,12 @@ extension PinesAppModel { case .revoked: compatibilityWarnings.append("Previous benchmark evidence was revoked and cannot support this tuple.") } + let compatibilityExplanation = runtimeCompatibilityExplanation( + state: runtimeCompatibilityState, + install: install, + profile: runtimeProfile, + evidence: matchingProfileEvidence + ) let contextWindow: String if enrichRuntime, let admittedContext = runtimeProfile.quantization.turboQuantAdmission?.admittedContextLength, @@ -271,7 +277,91 @@ extension PinesAppModel { downloadProgress: download, compatibilityWarnings: compatibilityWarnings, runtimeProfileEvidence: matchingProfileEvidence, - runtimeCompatibilityState: runtimeCompatibilityState + runtimeCompatibilityState: runtimeCompatibilityState, + compatibilityExplanation: compatibilityExplanation + ) + } + + nonisolated private static func runtimeCompatibilityExplanation( + state: RuntimeCompatibilityState, + install: ModelInstall, + profile: RuntimeProfile, + evidence: RuntimeProfileEvidence? + ) -> PinesRuntimeCompatibilityExplanation { + let admission = profile.quantization.turboQuantAdmission + let headline: String + let summary: String + let nextAction: String? + switch state { + case .verified: + headline = "Verified for this exact runtime tuple" + summary = "Trusted benchmark evidence matches the model, runtime pair, device class, mode, backend, and fallback contract shown below." + nextAction = nil + case .conservative: + headline = "Installable; running conservatively" + summary = "The model is supported by the installer, but no matching benchmark may be used as a product-level performance claim. Pines keeps conservative defaults." + nextAction = "Import or run a matching trusted device benchmark to verify this tuple." + case .unverified: + headline = "Installable metadata, unverified runtime tuple" + summary = "Repository metadata passed basic checks. That is not proof for this device, context, runtime pair, or backend." + nextAction = install.state == .remote ? "Run preflight, install the model, then capture device evidence." : "Capture matching on-device evidence before relying on performance claims." + case .degraded: + headline = "Supported with a runtime downgrade" + summary = admission?.userMessage ?? "Pines reduced context or selected a fallback path to stay within the current device budget." + nextAction = "Use the admitted context below or choose a smaller model/profile." + case .unsupported: + headline = "Unsupported by the current runtime profile" + summary = admission?.userMessage ?? "This model or requested tuple cannot be admitted safely on the current runtime profile." + nextAction = "Choose a supported model, reduce context, or change the runtime mode." + case .benchmarkRequired: + headline = "Benchmark required before support can be claimed" + summary = "The repository is experimental and needs a trusted benchmark for the exact tuple shown below." + nextAction = "Run and import the required on-device benchmark." + case .revoked: + headline = "Previous evidence was revoked" + summary = evidence?.revokedReason ?? "The prior evidence no longer supports this runtime tuple. Pines will not use it for a compatibility claim." + nextAction = "Capture replacement evidence with the current runtime and fallback contract." + } + + var facts = [PinesRuntimeCompatibilityExplanation.Fact]() + facts.append(.init(label: "Install check", value: install.verification.rawValue)) + facts.append(.init(label: "Profile", value: profile.name)) + if let evidence { + facts.append(.init(label: "Evidence", value: "\(evidence.evidenceLevel.rawValue) - \(evidence.createdAt.formatted(date: .abbreviated, time: .omitted))")) + facts.append(.init(label: "Compatibility pair", value: evidence.compatibilityPairID)) + facts.append(.init(label: "Device class", value: evidence.deviceClass.rawValue)) + facts.append(.init(label: "Mode", value: evidence.userMode.rawValue)) + if let mode = evidence.resolvedRuntimeMode { facts.append(.init(label: "Runtime mode", value: mode.rawValue)) } + if let backend = evidence.effectiveBackend { facts.append(.init(label: "Backend", value: backend.rawValue)) } + facts.append(.init(label: "Fallback contract", value: String(evidence.fallbackContractHash.prefix(16)))) + } else { + facts.append(.init(label: "Evidence", value: "No matching trusted evidence")) + if let deviceClass = profile.quantization.devicePerformanceClass { + facts.append(.init(label: "Device class", value: deviceClass.rawValue)) + } + facts.append(.init(label: "Mode", value: profile.quantization.turboQuantUserMode.rawValue)) + if let mode = profile.quantization.turboQuantResolvedRuntimeMode { + facts.append(.init(label: "Runtime mode", value: mode.rawValue)) + } + if let backend = profile.quantization.turboQuantEffectiveBackend { + facts.append(.init(label: "Backend", value: backend.rawValue)) + } + } + if let admission { + facts.append(.init( + label: "Context admission", + value: "\(admission.requestedContextLength.formatted()) requested -> \(admission.admittedContextLength.formatted()) admitted" + )) + } + if let fallback = profile.quantization.activeFallbackReason, !fallback.isEmpty { + facts.append(.init(label: "Fallback", value: fallback)) + } + return PinesRuntimeCompatibilityExplanation( + headline: headline, + summary: summary, + claimBasis: state.allowsProductClaim ? "Exact-tuple claim backed by trusted evidence" : "No verified product claim for this exact tuple", + facts: facts, + nextAction: nextAction ) } diff --git a/Pines/App/PinesAppModel+ProductionUX.swift b/Pines/App/PinesAppModel+ProductionUX.swift new file mode 100644 index 0000000..cf7b2ed --- /dev/null +++ b/Pines/App/PinesAppModel+ProductionUX.swift @@ -0,0 +1,43 @@ +import Foundation +import PinesCore + +@MainActor +extension PinesAppModel { + func refreshOpenRouterSpend(window: OpenRouterSpendWindow, services: PinesAppServices) async { + guard let repository = services.cloudSpendRepository else { return } + do { + setIfChanged(\.openRouterSpendReport, try await repository.openRouterSpendReport(window: window, now: Date())) + } catch { + setIfChanged(\.serviceError, error.localizedDescription) + } + } + + func resolveCloudKitConflict( + id: UUID, + resolution: CloudKitConflictResolution, + services: PinesAppServices + ) async { + guard resolution != .unresolved, let repository = services.cloudKitConflictRepository else { return } + do { + try await repository.resolveCloudKitConflict(id: id, resolution: resolution, at: Date()) + setIfChanged(\.cloudKitConflicts, try await repository.listCloudKitConflicts(unresolvedOnly: true)) + await appendAuditEvent( + AuditEvent( + category: .security, + summary: resolution == .keepDevice + ? "Resolved an iCloud conflict by keeping this device's version." + : "Resolved an iCloud conflict by using the iCloud version." + ), + services: services, + component: "cloudkit_conflict_resolved" + ) + if resolution == .keepDevice { + await syncCloudKitNow(services: services, reason: "conflict_keep_device") + } else { + await refreshAll(services: services) + } + } catch { + setIfChanged(\.serviceError, error.localizedDescription) + } + } +} diff --git a/Pines/App/PinesAppModel+ProviderTransfers.swift b/Pines/App/PinesAppModel+ProviderTransfers.swift new file mode 100644 index 0000000..4b63f85 --- /dev/null +++ b/Pines/App/PinesAppModel+ProviderTransfers.swift @@ -0,0 +1,320 @@ +import Foundation +import PinesCore + +@MainActor +extension PinesAppModel { + func enqueueProviderFileTransfer( + provider: CloudProviderConfiguration, + fileURL: URL, + purpose: String?, + services: PinesAppServices + ) async throws { + guard [.openAI, .anthropic, .gemini].contains(provider.kind) else { + throw InferenceError.invalidRequest("\(provider.kind.pinesLifecycleTitle) file upload is not supported.") + } + guard let repository = services.providerTransferRepository else { + throw InferenceError.invalidRequest("Transfer persistence is unavailable.") + } + + let transferID = UUID() + let stagedURL = try stageProviderTransferSource(fileURL, transferID: transferID) + let values = try stagedURL.resourceValues(forKeys: [.fileSizeKey, .contentTypeKey]) + let transfer = ProviderTransferRecord( + id: transferID, + providerID: provider.id, + providerKind: provider.kind, + source: .localFile, + sourceReference: fileURL.lastPathComponent, + stagedLocalURL: stagedURL, + fileName: fileURL.lastPathComponent, + contentType: values.contentType?.preferredMIMEType, + purpose: purpose, + totalBytes: values.fileSize.map(Int64.init) + ) + try await repository.upsertProviderTransfer(transfer) + await refreshProviderTransfers(services: services) + startProviderTransfer(transfer, services: services) + } + + func enqueueVaultProviderTransfer( + provider: CloudProviderConfiguration, + documentID: UUID, + documentTitle: String, + purpose: String?, + services: PinesAppServices + ) async throws { + guard [.openAI, .anthropic].contains(provider.kind) else { + throw InferenceError.invalidRequest("\(provider.kind.pinesLifecycleTitle) Vault upload is not supported.") + } + guard let repository = services.providerTransferRepository else { + throw InferenceError.invalidRequest("Transfer persistence is unavailable.") + } + let transfer = ProviderTransferRecord( + providerID: provider.id, + providerKind: provider.kind, + source: .vaultDocument, + sourceReference: documentID.uuidString, + fileName: documentTitle, + contentType: "text/plain", + purpose: purpose + ) + try await repository.upsertProviderTransfer(transfer) + await refreshProviderTransfers(services: services) + startProviderTransfer(transfer, services: services) + } + + func cancelProviderTransfer(id: UUID, services: PinesAppServices) async { + providerTransferTasks[id]?.cancel() + providerTransferTasks[id] = nil + guard var transfer = providerTransfers.first(where: { $0.id == id }), transfer.status.isActive else { return } + transfer.status = .cancelled + transfer.updatedAt = Date() + transfer.lastError = "Cancelled by you. The staged source is retained so this transfer can be retried." + try? await services.providerTransferRepository?.upsertProviderTransfer(transfer) + await refreshProviderTransfers(services: services) + } + + func retryProviderTransfer(id: UUID, services: PinesAppServices) async { + guard var transfer = providerTransfers.first(where: { $0.id == id }), transfer.status.canRetry else { return } + if transfer.source == .localFile, + transfer.stagedLocalURL.map({ !FileManager.default.fileExists(atPath: $0.path) }) ?? true { + transfer.lastError = "The staged source is no longer available. Choose the file again to create a new transfer." + transfer.status = .failed + transfer.updatedAt = Date() + try? await services.providerTransferRepository?.upsertProviderTransfer(transfer) + await refreshProviderTransfers(services: services) + return + } + transfer.retryCount += 1 + transfer.status = .queued + transfer.completedBytes = 0 + transfer.completedAt = nil + transfer.updatedAt = Date() + transfer.lastError = nil + try? await services.providerTransferRepository?.upsertProviderTransfer(transfer) + await refreshProviderTransfers(services: services) + startProviderTransfer(transfer, services: services) + } + + func removeProviderTransfer(id: UUID, services: PinesAppServices) async { + guard let transfer = providerTransfers.first(where: { $0.id == id }), !transfer.status.isActive else { return } + if let stagedURL = transfer.stagedLocalURL { + try? FileManager.default.removeItem(at: stagedURL.deletingLastPathComponent()) + } + try? await services.providerTransferRepository?.deleteProviderTransfer(id: id) + await refreshProviderTransfers(services: services) + } + + func refreshProviderTransfers(services: PinesAppServices) async { + guard let repository = services.providerTransferRepository else { return } + do { + setIfChanged(\.providerTransfers, try await repository.listProviderTransfers(providerID: nil)) + } catch { + setIfChanged(\.providerLifecycleError, error.localizedDescription) + } + } + + private func startProviderTransfer(_ transfer: ProviderTransferRecord, services: PinesAppServices) { + providerTransferTasks[transfer.id]?.cancel() + providerTransferTasks[transfer.id] = Task { @MainActor [weak self] in + guard let self else { return } + await self.performProviderTransfer(transfer, services: services) + self.providerTransferTasks[transfer.id] = nil + } + } + + private func performProviderTransfer(_ initialTransfer: ProviderTransferRecord, services: PinesAppServices) async { + guard let repository = services.providerTransferRepository else { return } + var transfer = initialTransfer + do { + transfer.status = .preparing + transfer.updatedAt = Date() + try await repository.upsertProviderTransfer(transfer) + await refreshProviderTransfers(services: services) + try Task.checkCancellation() + + transfer.status = .transferring + transfer.updatedAt = Date() + try await repository.upsertProviderTransfer(transfer) + await refreshProviderTransfers(services: services) + + let providerObjectID: String + switch (transfer.providerKind, transfer.source) { + case (.openAI, .localFile): + guard let url = transfer.stagedLocalURL else { throw InferenceError.invalidRequest("The staged upload source is missing.") } + let consent = PinesOpenAIProviderStorageConsent( + isGranted: true, + sourceDescription: transfer.fileName, + destinationDescription: "OpenAI Files API", + byteCount: transfer.totalBytes + ) + providerObjectID = try await uploadOpenAILocalFile( + providerID: transfer.providerID, + fileURL: url, + purpose: transfer.purpose ?? "assistants", + contentType: transfer.contentType, + consent: consent, + uploadProgress: providerTransferProgressHandler( + transferID: transfer.id, + fileByteCount: transfer.totalBytes, + services: services + ), + services: services + ).file.id + case (.anthropic, .localFile): + guard let url = transfer.stagedLocalURL else { throw InferenceError.invalidRequest("The staged upload source is missing.") } + let consent = PinesAnthropicProviderStorageConsent( + isGranted: true, + sourceDescription: transfer.fileName, + destinationDescription: "Anthropic Files API", + byteCount: transfer.totalBytes + ) + providerObjectID = try await uploadAnthropicLocalFile( + providerID: transfer.providerID, + fileURL: url, + contentType: transfer.contentType, + consent: consent, + uploadProgress: providerTransferProgressHandler( + transferID: transfer.id, + fileByteCount: transfer.totalBytes, + services: services + ), + services: services + ).file.id + case (.gemini, .localFile): + guard let url = transfer.stagedLocalURL else { throw InferenceError.invalidRequest("The staged upload source is missing.") } + let consent = PinesGeminiProviderStorageConsent( + isGranted: true, + sourceDescription: transfer.fileName, + destinationDescription: "Gemini Files API", + byteCount: transfer.totalBytes + ) + providerObjectID = try await uploadGeminiLocalFile( + providerID: transfer.providerID, + fileURL: url, + contentType: transfer.contentType, + consent: consent, + uploadProgress: providerTransferProgressHandler( + transferID: transfer.id, + fileByteCount: transfer.totalBytes, + services: services + ), + services: services + ).file.id + case (.openAI, .vaultDocument): + guard let id = UUID(uuidString: transfer.sourceReference) else { throw InferenceError.invalidRequest("The Vault source is invalid.") } + let consent = PinesOpenAIProviderStorageConsent( + isGranted: true, + sourceDescription: transfer.fileName, + destinationDescription: "OpenAI Files API" + ) + providerObjectID = try await uploadOpenAIVaultDocument( + providerID: transfer.providerID, + documentID: id, + purpose: transfer.purpose ?? "assistants", + consent: consent, + services: services + ).file.id + case (.anthropic, .vaultDocument): + guard let id = UUID(uuidString: transfer.sourceReference) else { throw InferenceError.invalidRequest("The Vault source is invalid.") } + let consent = PinesAnthropicProviderStorageConsent( + isGranted: true, + sourceDescription: transfer.fileName, + destinationDescription: "Anthropic Files API" + ) + providerObjectID = try await uploadAnthropicVaultDocument( + providerID: transfer.providerID, + documentID: id, + consent: consent, + services: services + ).file.id + default: + throw InferenceError.invalidRequest("This provider/source transfer combination is unsupported.") + } + + try Task.checkCancellation() + transfer.status = .verifying + transfer.providerObjectID = providerObjectID + transfer.completedBytes = transfer.totalBytes ?? transfer.completedBytes + transfer.updatedAt = Date() + try await repository.upsertProviderTransfer(transfer) + await refreshProviderLifecycleState(services: services) + + transfer.status = .completed + transfer.completedAt = Date() + transfer.updatedAt = transfer.completedAt ?? Date() + transfer.lastError = nil + try await repository.upsertProviderTransfer(transfer) + } catch is CancellationError { + transfer.status = .cancelled + transfer.lastError = "Cancelled by you. Retry when you are ready." + transfer.updatedAt = Date() + try? await repository.upsertProviderTransfer(transfer) + } catch { + transfer.status = .failed + transfer.lastError = error.localizedDescription + transfer.updatedAt = Date() + try? await repository.upsertProviderTransfer(transfer) + } + await refreshProviderTransfers(services: services) + } + + private func providerTransferProgressHandler( + transferID: UUID, + fileByteCount: Int64?, + services: PinesAppServices + ) -> ProviderUploadProgress { + { [weak self] sent, expected in + guard let fileByteCount, fileByteCount > 0, expected > 0 else { return } + let fraction = min(1, max(0, Double(sent) / Double(expected))) + let completed = min(fileByteCount, Int64(Double(fileByteCount) * fraction)) + Task { @MainActor [weak self] in + await self?.recordProviderTransferProgress( + transferID: transferID, + completedBytes: completed, + totalBytes: fileByteCount, + services: services + ) + } + } + } + + private func recordProviderTransferProgress( + transferID: UUID, + completedBytes: Int64, + totalBytes: Int64, + services: PinesAppServices + ) async { + guard let repository = services.providerTransferRepository, + var transfer = providerTransfers.first(where: { $0.id == transferID }), + transfer.status == .transferring, + completedBytes > transfer.completedBytes + else { return } + let minimumStep = max(Int64(64 * 1_024), totalBytes / 100) + guard completedBytes == totalBytes || completedBytes - transfer.completedBytes >= minimumStep else { return } + transfer.completedBytes = completedBytes + transfer.totalBytes = totalBytes + transfer.updatedAt = Date() + do { + try await repository.upsertProviderTransfer(transfer) + await refreshProviderTransfers(services: services) + } catch { + setIfChanged(\.providerLifecycleError, error.localizedDescription) + } + } + + private func stageProviderTransferSource(_ sourceURL: URL, transferID: UUID) throws -> URL { + let hasScope = sourceURL.startAccessingSecurityScopedResource() + defer { if hasScope { sourceURL.stopAccessingSecurityScopedResource() } } + let root = try FileManager.default.url( + for: .applicationSupportDirectory, + in: .userDomainMask, + appropriateFor: nil, + create: true + ).appending(path: "Pines/ProviderTransfers/\(transferID.uuidString)", directoryHint: .isDirectory) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + let destination = root.appending(path: sourceURL.lastPathComponent) + try FileManager.default.copyItem(at: sourceURL, to: destination) + return destination + } +} diff --git a/Pines/App/PinesAppModel.swift b/Pines/App/PinesAppModel.swift index 0710ac4..6aeec46 100644 --- a/Pines/App/PinesAppModel.swift +++ b/Pines/App/PinesAppModel.swift @@ -424,6 +424,16 @@ final class PinesAppModel: ObservableObject { set { settingsState.cloudKitSyncStatus = newValue } } + var cloudKitConflicts: [CloudKitConflictRecord] { + get { settingsState.cloudKitConflicts } + set { settingsState.cloudKitConflicts = newValue } + } + + var openRouterSpendReport: OpenRouterSpendReport { + get { settingsState.openRouterSpendReport } + set { settingsState.openRouterSpendReport = newValue } + } + var huggingFaceCredentialStatus: String { get { settingsState.huggingFaceCredentialStatus } set { settingsState.huggingFaceCredentialStatus = newValue } @@ -439,6 +449,11 @@ final class PinesAppModel: ObservableObject { set { providerLifecycleState.providerFiles = newValue } } + var providerTransfers: [ProviderTransferRecord] { + get { providerLifecycleState.providerTransfers } + set { providerLifecycleState.providerTransfers = newValue } + } + var providerFilePreviews: [PinesProviderFilePreview] { get { providerLifecycleState.providerFilePreviews } set { providerLifecycleState.providerFilePreviews = newValue } @@ -544,6 +559,11 @@ final class PinesAppModel: ObservableObject { set { workflowState.pendingToolApproval = newValue } } + var pendingHostedToolApproval: HostedToolApprovalRequest? { + get { workflowState.pendingHostedToolApproval } + set { workflowState.pendingHostedToolApproval = newValue } + } + var pendingCloudContextApproval: CloudContextApprovalRequest? { get { workflowState.pendingCloudContextApproval } set { workflowState.pendingCloudContextApproval = newValue } @@ -589,11 +609,13 @@ final class PinesAppModel: ObservableObject { private var needsCloudModelCatalogRefresh = false private var cloudModelCatalogSnapshots: [ProviderID: CloudProviderModelCatalogSnapshot] = [:] private var repositoryObservationTasks: [Task] = [] + var providerTransferTasks: [UUID: Task] = [:] private var currentRunTask: Task? private var currentRunToken: UUID? private var currentRunUsesLocalRuntime = false private var vaultReindexToken: UUID? var approvalContinuation: CheckedContinuation? + var hostedToolApprovalContinuation: CheckedContinuation? var cloudContextContinuation: CheckedContinuation? var cloudVaultEmbeddingContinuation: CheckedContinuation? var samplingContinuation: CheckedContinuation? @@ -689,7 +711,7 @@ final class PinesAppModel: ObservableObject { } } - private func setIfChanged( + func setIfChanged( _ keyPath: ReferenceWritableKeyPath, _ value: Value ) { @@ -1025,7 +1047,9 @@ final class PinesAppModel: ObservableObject { currentRunTask?.cancel() modelSearchMetadataTask?.cancel() repositoryObservationTasks.forEach { $0.cancel() } + providerTransferTasks.values.forEach { $0.cancel() } approvalContinuation?.resume(returning: .denied) + hostedToolApprovalContinuation?.resume(returning: false) cloudContextContinuation?.resume(returning: .cancel) cloudVaultEmbeddingContinuation?.resume(returning: false) samplingContinuation?.resume(returning: false) @@ -1086,9 +1110,21 @@ final class PinesAppModel: ObservableObject { if let cloudProviderRepository = services.cloudProviderRepository { setIfChanged(\.cloudProviders, try await cloudProviderRepository.listProviders()) } + + if let conflictRepository = services.cloudKitConflictRepository { + setIfChanged(\.cloudKitConflicts, try await conflictRepository.listCloudKitConflicts(unresolvedOnly: true)) + } + + if let spendRepository = services.cloudSpendRepository { + setIfChanged( + \.openRouterSpendReport, + try await spendRepository.openRouterSpendReport(window: openRouterSpendReport.window, now: Date()) + ) + } await hydrateCloudModelCatalogSnapshots(services: services) await repairInterruptedChatRuns(services: services) + try await services.providerTransferRepository?.markActiveProviderTransfersInterrupted(at: Date()) try await services.modelLifecycleService?.validateInstalledModels() await refreshProviderLifecycleState(services: services) try await refreshModelPreviews(services: services, enrichRuntime: false) @@ -1351,6 +1387,9 @@ final class PinesAppModel: ObservableObject { defer { isRefreshingProviderLifecycle = false } do { + if let repository = services.providerTransferRepository { + setIfChanged(\.providerTransfers, try await repository.listProviderTransfers(providerID: nil)) + } if let repository = services.providerFileRepository { let records = try await repository.listProviderFiles(providerID: nil) setIfChanged(\.providerFiles, records) @@ -2995,6 +3034,21 @@ final class PinesAppModel: ObservableObject { anthropicOptions: anthropicRequestOptions(for: selectedProviderID, settings: settings, services: services), openRouterOptions: openRouterRequestOptions(for: selectedProviderID, settings: settings, services: services) ) + let hostedProviderName = cloudProviders.first(where: { $0.id == selectedProviderID })?.displayName + ?? (selectedProviderID == ManagedCloudPolicy.providerID ? "Pines Pro Cloud" : selectedProviderID.rawValue) + let hostedToolDescriptors = request.hostedToolApprovalDescriptors(providerName: hostedProviderName) + if !hostedToolDescriptors.isEmpty { + let approved = await requestHostedToolApproval( + HostedToolApprovalRequest( + providerID: selectedProviderID, + providerName: hostedProviderName, + modelID: selectedModelID, + descriptors: hostedToolDescriptors + ), + services: services + ) + guard approved else { throw InferenceError.cancelled } + } if let eligibilityFailure = openRouterModelEligibilityFailure( providerID: selectedProviderID, request: request diff --git a/Pines/App/PinesAppModelTypes.swift b/Pines/App/PinesAppModelTypes.swift index e6a943b..5ded86b 100644 --- a/Pines/App/PinesAppModelTypes.swift +++ b/Pines/App/PinesAppModelTypes.swift @@ -303,6 +303,21 @@ struct PinesModelPreview: Identifiable, Hashable, Sendable { let compatibilityWarnings: [String] let runtimeProfileEvidence: RuntimeProfileEvidence? let runtimeCompatibilityState: RuntimeCompatibilityState + let compatibilityExplanation: PinesRuntimeCompatibilityExplanation +} + +struct PinesRuntimeCompatibilityExplanation: Hashable, Sendable { + struct Fact: Identifiable, Hashable, Sendable { + var id: String { label } + let label: String + let value: String + } + + let headline: String + let summary: String + let claimBasis: String + let facts: [Fact] + let nextAction: String? } extension PinesModelPreview { diff --git a/Pines/App/PinesAppServices.swift b/Pines/App/PinesAppServices.swift index cf7c191..8991fb2 100644 --- a/Pines/App/PinesAppServices.swift +++ b/Pines/App/PinesAppServices.swift @@ -22,6 +22,9 @@ typealias PinesLiveStore = any ConversationRepository & ProviderStructuredOutputRepository & ProviderModelCapabilityRepository & ProviderResearchRunRepository + & ProviderTransferRepository + & CloudKitConflictRepository + & CloudSpendRepository & MCPServerRepository & ModelDownloadRepository & AuditEventRepository @@ -128,6 +131,9 @@ final class PinesAppServices: @unchecked Sendable { var providerStructuredOutputRepository: (any ProviderStructuredOutputRepository)? { liveStore } var providerModelCapabilityRepository: (any ProviderModelCapabilityRepository)? { liveStore } var providerResearchRunRepository: (any ProviderResearchRunRepository)? { liveStore } + var providerTransferRepository: (any ProviderTransferRepository)? { liveStore } + var cloudKitConflictRepository: (any CloudKitConflictRepository)? { liveStore } + var cloudSpendRepository: (any CloudSpendRepository)? { liveStore } var mcpServerRepository: (any MCPServerRepository)? { liveStore } var modelDownloadRepository: (any ModelDownloadRepository)? { liveStore } var auditRepository: (any AuditEventRepository)? { liveStore } diff --git a/Pines/App/PinesAppState.swift b/Pines/App/PinesAppState.swift index 8e23850..93a1c25 100644 --- a/Pines/App/PinesAppState.swift +++ b/Pines/App/PinesAppState.swift @@ -223,6 +223,8 @@ final class PinesSettingsState: ObservableObject { @Published var isSavingCloudProvider: Bool @Published var validatingCloudProviderIDs: Set @Published var cloudKitSyncStatus: PinesCloudKitSyncStatus + @Published var cloudKitConflicts: [CloudKitConflictRecord] + @Published var openRouterSpendReport: OpenRouterSpendReport @Published var huggingFaceCredentialStatus: String @Published var braveSearchCredentialStatus: String @@ -264,6 +266,8 @@ final class PinesSettingsState: ObservableObject { isSavingCloudProvider: Bool = false, validatingCloudProviderIDs: Set = [], cloudKitSyncStatus: PinesCloudKitSyncStatus = .init(), + cloudKitConflicts: [CloudKitConflictRecord] = [], + openRouterSpendReport: OpenRouterSpendReport = .init(window: .month), huggingFaceCredentialStatus: String = "Not configured", braveSearchCredentialStatus: String = "Not configured" ) { @@ -304,6 +308,8 @@ final class PinesSettingsState: ObservableObject { self.isSavingCloudProvider = isSavingCloudProvider self.validatingCloudProviderIDs = validatingCloudProviderIDs self.cloudKitSyncStatus = cloudKitSyncStatus + self.cloudKitConflicts = cloudKitConflicts + self.openRouterSpendReport = openRouterSpendReport self.huggingFaceCredentialStatus = huggingFaceCredentialStatus self.braveSearchCredentialStatus = braveSearchCredentialStatus } @@ -311,6 +317,7 @@ final class PinesSettingsState: ObservableObject { @MainActor final class PinesProviderLifecycleState: ObservableObject { + @Published var providerTransfers: [ProviderTransferRecord] @Published var providerFiles: [ProviderFileRecord] @Published var providerFilePreviews: [PinesProviderFilePreview] @Published var providerArtifacts: [ProviderArtifactRecord] @@ -333,6 +340,7 @@ final class PinesProviderLifecycleState: ObservableObject { @Published var providerLifecycleError: String? init( + providerTransfers: [ProviderTransferRecord] = [], providerFiles: [ProviderFileRecord] = [], providerFilePreviews: [PinesProviderFilePreview] = [], providerArtifacts: [ProviderArtifactRecord] = [], @@ -354,6 +362,7 @@ final class PinesProviderLifecycleState: ObservableObject { isRefreshingProviderLifecycle: Bool = false, providerLifecycleError: String? = nil ) { + self.providerTransfers = providerTransfers self.providerFiles = providerFiles self.providerFilePreviews = providerFilePreviews self.providerArtifacts = providerArtifacts @@ -381,6 +390,7 @@ final class PinesProviderLifecycleState: ObservableObject { final class PinesWorkflowState: ObservableObject { @Published var serviceError: String? @Published var pendingToolApproval: ToolApprovalRequest? + @Published var pendingHostedToolApproval: HostedToolApprovalRequest? @Published var pendingCloudContextApproval: CloudContextApprovalRequest? @Published var pendingCloudVaultEmbeddingApproval: CloudVaultEmbeddingApprovalRequest? @Published var pendingMCPSamplingRequest: MCPSamplingRequest? @@ -392,6 +402,7 @@ final class PinesWorkflowState: ObservableObject { init( serviceError: String? = nil, pendingToolApproval: ToolApprovalRequest? = nil, + pendingHostedToolApproval: HostedToolApprovalRequest? = nil, pendingCloudContextApproval: CloudContextApprovalRequest? = nil, pendingCloudVaultEmbeddingApproval: CloudVaultEmbeddingApprovalRequest? = nil, pendingMCPSamplingRequest: MCPSamplingRequest? = nil, @@ -402,6 +413,7 @@ final class PinesWorkflowState: ObservableObject { ) { self.serviceError = serviceError self.pendingToolApproval = pendingToolApproval + self.pendingHostedToolApproval = pendingHostedToolApproval self.pendingCloudContextApproval = pendingCloudContextApproval self.pendingCloudVaultEmbeddingApproval = pendingCloudVaultEmbeddingApproval self.pendingMCPSamplingRequest = pendingMCPSamplingRequest diff --git a/Pines/App/PinesRootView.swift b/Pines/App/PinesRootView.swift index 7e783bd..c3f0405 100644 --- a/Pines/App/PinesRootView.swift +++ b/Pines/App/PinesRootView.swift @@ -255,6 +255,24 @@ struct PinesRootView: View { .presentationDetents([.medium, .large]) .presentationDragIndicator(.visible) } + .sheet(item: Binding( + get: { workflowState.pendingHostedToolApproval }, + set: { request in + if request == nil { + appModel.resolvePendingHostedToolApproval(false, services: services) + } + } + )) { request in + HostedToolApprovalSheet( + request: request, + deny: { appModel.resolvePendingHostedToolApproval(false, services: services) }, + approve: { appModel.resolvePendingHostedToolApproval(true, services: services) } + ) + .environmentObject(haptics) + .pinesTheme(theme) + .presentationDetents([.large]) + .presentationDragIndicator(.visible) + } .sheet(item: Binding( get: { workflowState.pendingCloudContextApproval }, set: { request in @@ -578,6 +596,71 @@ private struct ToolApprovalSheet: View { } } +private struct HostedToolApprovalSheet: View { + @Environment(\.pinesTheme) private var theme + @EnvironmentObject private var haptics: PinesHaptics + let request: HostedToolApprovalRequest + let deny: () -> Void + let approve: () -> Void + + var body: some View { + NavigationStack { + Form { + Section("Provider Environment") { + LabeledContent("Provider", value: request.providerName) + LabeledContent("Model", value: request.modelID.rawValue) + Text("These tools run outside this iPhone. Review what leaves the device and what each hosted environment can change.") + .font(theme.typography.caption) + .foregroundStyle(theme.colors.secondaryText) + } + + ForEach(request.descriptors) { descriptor in + Section(descriptor.displayName) { + LabeledContent("Runs in", value: descriptor.environment) + hostedApprovalList("Data leaving this device", values: descriptor.dataLeavingDevice) + hostedApprovalList("Possible side effects", values: descriptor.sideEffects) + hostedApprovalList("Network destinations", values: descriptor.networkDestinations) + Text(descriptor.retentionNotice) + .font(theme.typography.caption) + .foregroundStyle(theme.colors.secondaryText) + } + } + } + .pinesThemedForm() + .navigationTitle("Approve Hosted Tools") + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Deny", role: .cancel) { + haptics.play(.primaryAction) + deny() + } + } + ToolbarItem(placement: .confirmationAction) { + Button("Approve Once") { + haptics.play(.primaryAction) + approve() + } + } + } + } + } + + @ViewBuilder + private func hostedApprovalList(_ title: String, values: [String]) -> some View { + VStack(alignment: .leading, spacing: theme.spacing.xxsmall) { + Text(title) + .font(theme.typography.caption.weight(.semibold)) + .foregroundStyle(theme.colors.primaryText) + ForEach(values, id: \.self) { value in + Label(value, systemImage: "circle.fill") + .font(theme.typography.caption) + .foregroundStyle(theme.colors.secondaryText) + .labelStyle(.titleAndIcon) + } + } + } +} + private struct CloudContextApprovalSheet: View { @Environment(\.pinesTheme) private var theme let request: CloudContextApprovalRequest diff --git a/Pines/Cloud/AnthropicProviderLifecycleCoordinator.swift b/Pines/Cloud/AnthropicProviderLifecycleCoordinator.swift index aa0f1a4..6917282 100644 --- a/Pines/Cloud/AnthropicProviderLifecycleCoordinator.swift +++ b/Pines/Cloud/AnthropicProviderLifecycleCoordinator.swift @@ -28,14 +28,15 @@ struct AnthropicProviderLifecycleCoordinator: Sendable { contentType: String, data: Data, localURL: URL? = nil, - fields: [String: String] = [:] + fields: [String: String] = [:], + uploadProgress: ProviderUploadProgress? = nil ) async throws -> ProviderFileRecord { let response = try await service.uploadFile(AnthropicFileUploadRequest( fileName: fileName, contentType: contentType, data: data, fields: fields - )) + ), uploadProgress: uploadProgress) guard var record = response.json.flatMap({ AnthropicProviderRecordMapper.providerFile(from: $0, providerID: providerID) }) else { throw CloudProviderError.invalidResponse } diff --git a/Pines/Cloud/AnthropicProviderService.swift b/Pines/Cloud/AnthropicProviderService.swift index d96c5eb..c7d7866 100644 --- a/Pines/Cloud/AnthropicProviderService.swift +++ b/Pines/Cloud/AnthropicProviderService.swift @@ -32,7 +32,8 @@ struct AnthropicProviderService: Sendable { path: String, queryItems: [URLQueryItem] = [], multipart: OpenAIMultipartForm, - betaHeaders: [String] = [] + betaHeaders: [String] = [], + uploadProgress: ProviderUploadProgress? = nil ) async throws -> AnthropicProviderResponse { let boundary = "PinesAnthropic-\(UUID().uuidString)" return try await send( @@ -42,7 +43,8 @@ struct AnthropicProviderService: Sendable { body: multipart.encoded(boundary: boundary), contentType: "multipart/form-data; boundary=\(boundary)", accept: "application/json", - betaHeaders: betaHeaders + betaHeaders: betaHeaders, + uploadProgress: uploadProgress ) } @@ -50,8 +52,16 @@ struct AnthropicProviderService: Sendable { try await rawJSON(method: .get, path: "files", queryItems: request.queryItems, betaHeaders: [Self.filesAPIBeta]) } - func uploadFile(_ request: AnthropicFileUploadRequest) async throws -> AnthropicProviderResponse { - try await rawMultipart(path: "files", multipart: request.multipart, betaHeaders: [Self.filesAPIBeta]) + func uploadFile( + _ request: AnthropicFileUploadRequest, + uploadProgress: ProviderUploadProgress? = nil + ) async throws -> AnthropicProviderResponse { + try await rawMultipart( + path: "files", + multipart: request.multipart, + betaHeaders: [Self.filesAPIBeta], + uploadProgress: uploadProgress + ) } func retrieveFile(_ fileID: String) async throws -> AnthropicProviderResponse { @@ -131,7 +141,8 @@ struct AnthropicProviderService: Sendable { contentType: String? = nil, accept: String? = nil, betaHeaders: [String] = [], - maxResponseBytes: Int = BoundedHTTPResponse.jsonLimit + maxResponseBytes: Int = BoundedHTTPResponse.jsonLimit, + uploadProgress: ProviderUploadProgress? = nil ) async throws -> AnthropicProviderResponse { guard let apiKey = try await readAPIKey() else { throw CloudProviderError.missingAPIKey @@ -159,7 +170,12 @@ struct AnthropicProviderService: Sendable { var lastRetryableResponse: AnthropicProviderResponse? for attempt in 0..<3 { - let (data, http) = try await BoundedHTTPResponse.data(for: request, session: urlSession, maxBytes: maxResponseBytes) + let (data, http) = try await BoundedHTTPResponse.data( + for: request, + session: urlSession, + maxBytes: maxResponseBytes, + uploadProgress: uploadProgress + ) let providerResponse = AnthropicProviderResponse(data: data, httpResponse: http) if (200..<300).contains(http.statusCode) { return providerResponse diff --git a/Pines/Cloud/BoundedHTTPResponse.swift b/Pines/Cloud/BoundedHTTPResponse.swift index 536ac0b..1eff037 100644 --- a/Pines/Cloud/BoundedHTTPResponse.swift +++ b/Pines/Cloud/BoundedHTTPResponse.swift @@ -1,6 +1,8 @@ import Foundation import PinesCore +typealias ProviderUploadProgress = @Sendable (_ completedBytes: Int64, _ totalBytes: Int64) -> Void + enum BoundedHTTPResponse { enum RedirectScope { case sameOrigin @@ -15,10 +17,15 @@ enum BoundedHTTPResponse { for request: URLRequest, session: URLSession, maxBytes: Int, - redirectScope: RedirectScope = .sameOrigin + redirectScope: RedirectScope = .sameOrigin, + uploadProgress: ProviderUploadProgress? = nil ) async throws -> (Data, HTTPURLResponse) { guard let originURL = request.url else { throw CloudProviderError.invalidResponse } - let redirectPolicy = BoundedRedirectPolicy(originURL: originURL, scope: redirectScope) + let redirectPolicy = BoundedRedirectPolicy( + originURL: originURL, + scope: redirectScope, + uploadProgress: uploadProgress + ) let (bytes, response) = try await session.bytes(for: request, delegate: redirectPolicy) guard let http = response as? HTTPURLResponse else { throw CloudProviderError.invalidResponse @@ -57,10 +64,27 @@ enum BoundedHTTPResponse { private final class BoundedRedirectPolicy: NSObject, URLSessionTaskDelegate, @unchecked Sendable { private let originURL: URL private let scope: BoundedHTTPResponse.RedirectScope + private let uploadProgress: ProviderUploadProgress? - init(originURL: URL, scope: BoundedHTTPResponse.RedirectScope) { + init( + originURL: URL, + scope: BoundedHTTPResponse.RedirectScope, + uploadProgress: ProviderUploadProgress? + ) { self.originURL = originURL self.scope = scope + self.uploadProgress = uploadProgress + } + + func urlSession( + _ session: URLSession, + task: URLSessionTask, + didSendBodyData bytesSent: Int64, + totalBytesSent: Int64, + totalBytesExpectedToSend: Int64 + ) { + guard totalBytesExpectedToSend > 0 else { return } + uploadProgress?(totalBytesSent, totalBytesExpectedToSend) } func urlSession( diff --git a/Pines/Cloud/GeminiProviderLifecycleCoordinator.swift b/Pines/Cloud/GeminiProviderLifecycleCoordinator.swift index 06b2526..f83aeba 100644 --- a/Pines/Cloud/GeminiProviderLifecycleCoordinator.swift +++ b/Pines/Cloud/GeminiProviderLifecycleCoordinator.swift @@ -28,10 +28,15 @@ struct GeminiProviderLifecycleCoordinator: Sendable { contentType: String, data: Data, localURL: URL? = nil, - poll: GeminiFilePolling? = nil + poll: GeminiFilePolling? = nil, + uploadProgress: ProviderUploadProgress? = nil ) async throws -> ProviderFileRecord { let session = try await service.startResumableUpload(displayName: fileName, mimeType: contentType, byteCount: data.count) - let response = try await service.uploadResumableData(to: session.uploadURL, data: data) + let response = try await service.uploadResumableData( + to: session.uploadURL, + data: data, + uploadProgress: uploadProgress + ) guard var record = fileRecord(from: response.json, fallbackFileName: fileName, contentType: contentType, byteCount: data.count) else { throw CloudProviderError.invalidResponse } diff --git a/Pines/Cloud/GeminiProviderService.swift b/Pines/Cloud/GeminiProviderService.swift index ac77703..07f67d7 100644 --- a/Pines/Cloud/GeminiProviderService.swift +++ b/Pines/Cloud/GeminiProviderService.swift @@ -59,7 +59,8 @@ struct GeminiProviderService { to uploadURL: String, data: Data, offset: Int = 0, - finalize: Bool = true + finalize: Bool = true, + uploadProgress: ProviderUploadProgress? = nil ) async throws -> GeminiProviderResponse { guard let url = URL(string: uploadURL) else { throw CloudProviderError.invalidResponse @@ -70,7 +71,7 @@ struct GeminiProviderService { request.addValue(finalize ? "upload, finalize" : "upload", forHTTPHeaderField: "X-Goog-Upload-Command") request.addValue(String(offset), forHTTPHeaderField: "X-Goog-Upload-Offset") try await applyExtraHeaders(to: &request) - return try await send(request) + return try await send(request, uploadProgress: uploadProgress) } func listFiles(_ request: GeminiListRequest = GeminiListRequest()) async throws -> GeminiProviderResponse { @@ -201,11 +202,15 @@ struct GeminiProviderService { return try await send(request) } - private func send(_ request: URLRequest) async throws -> GeminiProviderResponse { + private func send( + _ request: URLRequest, + uploadProgress: ProviderUploadProgress? = nil + ) async throws -> GeminiProviderResponse { let (data, http) = try await BoundedHTTPResponse.data( for: request, session: urlSession, - maxBytes: BoundedHTTPResponse.fileLimit + maxBytes: BoundedHTTPResponse.fileLimit, + uploadProgress: uploadProgress ) let providerResponse = GeminiProviderResponse(data: data, httpResponse: http) guard (200..<300).contains(http.statusCode) else { diff --git a/Pines/Cloud/OpenAIProviderLifecycleCoordinator.swift b/Pines/Cloud/OpenAIProviderLifecycleCoordinator.swift index 1594c60..ab1633e 100644 --- a/Pines/Cloud/OpenAIProviderLifecycleCoordinator.swift +++ b/Pines/Cloud/OpenAIProviderLifecycleCoordinator.swift @@ -61,7 +61,8 @@ struct OpenAIProviderLifecycleCoordinator: Sendable { data: Data, purpose: String, localURL: URL? = nil, - fields: [String: String] = [:] + fields: [String: String] = [:], + uploadProgress: ProviderUploadProgress? = nil ) async throws -> ProviderFileRecord { let response = try await service.uploadFile( OpenAIFileUploadRequest( @@ -70,7 +71,8 @@ struct OpenAIProviderLifecycleCoordinator: Sendable { data: data, purpose: purpose, fields: fields - ) + ), + uploadProgress: uploadProgress ) guard var record = response.json.flatMap({ OpenAIProviderRecordMapper.providerFile(from: $0, providerID: providerID) }) else { throw CloudProviderError.invalidResponse diff --git a/Pines/Cloud/OpenAIProviderService.swift b/Pines/Cloud/OpenAIProviderService.swift index 3f01f39..b32679d 100644 --- a/Pines/Cloud/OpenAIProviderService.swift +++ b/Pines/Cloud/OpenAIProviderService.swift @@ -25,7 +25,8 @@ struct OpenAIProviderService { method: OpenAIHTTPMethod = .post, path: String, queryItems: [URLQueryItem] = [], - multipart: OpenAIMultipartForm + multipart: OpenAIMultipartForm, + uploadProgress: ProviderUploadProgress? = nil ) async throws -> OpenAIProviderResponse { let boundary = "PinesOpenAI-\(UUID().uuidString)" return try await send( @@ -33,7 +34,8 @@ struct OpenAIProviderService { path: path, queryItems: queryItems, body: multipart.encoded(boundary: boundary), - contentType: "multipart/form-data; boundary=\(boundary)" + contentType: "multipart/form-data; boundary=\(boundary)", + uploadProgress: uploadProgress ) } @@ -41,8 +43,11 @@ struct OpenAIProviderService { try await rawJSON(method: .get, path: "files", queryItems: request.queryItems) } - func uploadFile(_ request: OpenAIFileUploadRequest) async throws -> OpenAIProviderResponse { - try await rawMultipart(path: "files", multipart: request.multipart) + func uploadFile( + _ request: OpenAIFileUploadRequest, + uploadProgress: ProviderUploadProgress? = nil + ) async throws -> OpenAIProviderResponse { + try await rawMultipart(path: "files", multipart: request.multipart, uploadProgress: uploadProgress) } func retrieveFile(_ fileID: String) async throws -> OpenAIProviderResponse { @@ -306,7 +311,8 @@ struct OpenAIProviderService { body: Data? = nil, contentType: String? = nil, accept: String = "application/json", - maxResponseBytes: Int = BoundedHTTPResponse.jsonLimit + maxResponseBytes: Int = BoundedHTTPResponse.jsonLimit, + uploadProgress: ProviderUploadProgress? = nil ) async throws -> OpenAIProviderResponse { guard let apiKey = try await readAPIKey() else { throw CloudProviderError.missingAPIKey @@ -323,7 +329,12 @@ struct OpenAIProviderService { addOpenAIClientRequestID(to: &request) try await applyExtraHeaders(to: &request) - let (data, http) = try await BoundedHTTPResponse.data(for: request, session: urlSession, maxBytes: maxResponseBytes) + let (data, http) = try await BoundedHTTPResponse.data( + for: request, + session: urlSession, + maxBytes: maxResponseBytes, + uploadProgress: uploadProgress + ) let providerResponse = OpenAIProviderResponse(data: data, httpResponse: http) guard (200..<300).contains(http.statusCode) else { throw CloudProviderError.providerRejectedRequest( diff --git a/Pines/Persistence/GRDBPinesStore+CloudKit.swift b/Pines/Persistence/GRDBPinesStore+CloudKit.swift index 5ecf6e6..cc93107 100644 --- a/Pines/Persistence/GRDBPinesStore+CloudKit.swift +++ b/Pines/Persistence/GRDBPinesStore+CloudKit.swift @@ -374,12 +374,28 @@ extension GRDBPinesStore: CloudKitSyncRepository { private static func applyCloudKitConversation(_ conversation: CloudKitConversationSnapshot, db: Database) throws { let local = try Row.fetchOne( db, - sql: "SELECT updated_at, deleted_at FROM conversations WHERE id = ?", + sql: """ + SELECT title, updated_at, deleted_at, default_model_id, default_provider_id, project_id, + archived_at, pinned, sync_state + FROM conversations WHERE id = ? + """, arguments: [conversation.id.uuidString] ) let localUpdatedAt = (local?["updated_at"] as Double?).map(Date.init(timeIntervalSinceReferenceDate:)) ?? Date(timeIntervalSinceReferenceDate: 0) let localDeletedAt = (local?["deleted_at"] as Double?).map(Date.init(timeIntervalSinceReferenceDate:)) + let localSyncState = (local?["sync_state"] as String?).flatMap(SyncState.init(rawValue:)) + + if let local, + [.local, .pendingUpload, .conflicted].contains(localSyncState), + try cloudKitConversationDiffers(local: local, remote: conversation) { + try recordCloudKitConversationConflict(local: local, remote: conversation, db: db) + try db.execute( + sql: "UPDATE conversations SET sync_state = ? WHERE id = ?", + arguments: [SyncState.conflicted.rawValue, conversation.id.uuidString] + ) + return + } if let remoteDeletedAt = conversation.deletedAt { guard local == nil || remoteDeletedAt >= max(localUpdatedAt, localDeletedAt ?? Date(timeIntervalSinceReferenceDate: 0)) else { @@ -398,7 +414,7 @@ extension GRDBPinesStore: CloudKitSyncRepository { try upsertCloudKitConversation(conversation, deletedAt: nil, db: db) } - private static func upsertCloudKitConversation( + static func upsertCloudKitConversation( _ conversation: CloudKitConversationSnapshot, deletedAt: Date?, db: Database @@ -549,13 +565,24 @@ extension GRDBPinesStore: CloudKitSyncRepository { private static func applyCloudKitDocument(_ document: CloudKitVaultDocumentSnapshot, db: Database) throws { let local = try Row.fetchOne( db, - sql: "SELECT updated_at, sync_state FROM vault_documents WHERE id = ?", + sql: "SELECT title, source_type, project_id, updated_at, sync_state FROM vault_documents WHERE id = ?", arguments: [document.id.uuidString] ) let localUpdatedAt = (local?["updated_at"] as Double?).map(Date.init(timeIntervalSinceReferenceDate:)) ?? Date(timeIntervalSinceReferenceDate: 0) let localSyncState = (local?["sync_state"] as String?).flatMap(SyncState.init(rawValue:)) + if let local, + [.local, .pendingUpload, .conflicted].contains(localSyncState), + try cloudKitDocumentDiffers(local: local, remote: document) { + try recordCloudKitDocumentConflict(local: local, remote: document, db: db) + try db.execute( + sql: "UPDATE vault_documents SET sync_state = ? WHERE id = ?", + arguments: [SyncState.conflicted.rawValue, document.id.uuidString] + ) + return + } + if let remoteDeletedAt = document.deletedAt { guard local == nil || remoteDeletedAt >= localUpdatedAt else { return } try upsertCloudKitDocument(document, syncState: .deleted, updatedAt: remoteDeletedAt, db: db) @@ -571,7 +598,7 @@ extension GRDBPinesStore: CloudKitSyncRepository { try upsertCloudKitDocument(document, syncState: .synced, updatedAt: document.updatedAt, db: db) } - private static func upsertCloudKitDocument( + static func upsertCloudKitDocument( _ document: CloudKitVaultDocumentSnapshot, syncState: SyncState, updatedAt: Date, @@ -602,6 +629,133 @@ extension GRDBPinesStore: CloudKitSyncRepository { ) } + private static func cloudKitConversationDiffers(local: Row, remote: CloudKitConversationSnapshot) throws -> Bool { + let localDeletedAt = (local["deleted_at"] as Double?).map(Date.init(timeIntervalSinceReferenceDate:)) + let localArchivedAt = (local["archived_at"] as Double?).map(Date.init(timeIntervalSinceReferenceDate:)) + return (local["title"] as String) != remote.title + || (local["default_model_id"] as String?) != remote.defaultModelID?.rawValue + || (local["default_provider_id"] as String?) != remote.defaultProviderID?.rawValue + || (local["project_id"] as String?) != remote.projectID?.uuidString + || (local["pinned"] as Int) != (remote.pinned ? 1 : 0) + || (localArchivedAt != nil) != remote.archived + || localDeletedAt != remote.deletedAt + } + + private static func cloudKitDocumentDiffers(local: Row, remote: CloudKitVaultDocumentSnapshot) throws -> Bool { + (local["title"] as String) != remote.title + || (local["source_type"] as String) != remote.sourceType + || (local["project_id"] as String?) != remote.projectID?.uuidString + || remote.deletedAt != nil + } + + private static func recordCloudKitConversationConflict( + local: Row, + remote: CloudKitConversationSnapshot, + db: Database + ) throws { + let localPayload: [String: String] = [ + "title": local["title"], + "default_model_id": (local["default_model_id"] as String?) ?? "", + "default_provider_id": (local["default_provider_id"] as String?) ?? "", + "project_id": (local["project_id"] as String?) ?? "", + "archived": (local["archived_at"] as Double?) == nil ? "false" : "true", + "pinned": (local["pinned"] as Int) == 1 ? "true" : "false", + ] + try recordCloudKitConflict( + entity: .conversation, + entityID: remote.id, + title: local["title"], + deviceSummary: "This device has unsynced conversation changes from \(dateLabel(local["updated_at"] as Double)).", + iCloudSummary: remote.deletedAt == nil + ? "iCloud has \"\(remote.title)\" from \(dateLabel(remote.updatedAt.timeIntervalSinceReferenceDate))." + : "The conversation was deleted from iCloud at \(dateLabel(remote.deletedAt!.timeIntervalSinceReferenceDate)).", + devicePayloadJSON: String(decoding: try JSONEncoder().encode(localPayload), as: UTF8.self), + iCloudPayloadJSON: String(decoding: try JSONEncoder().encode(remote), as: UTF8.self), + deviceUpdatedAt: Date(timeIntervalSinceReferenceDate: local["updated_at"]), + iCloudUpdatedAt: remote.deletedAt ?? remote.updatedAt, + db: db + ) + } + + private static func recordCloudKitDocumentConflict( + local: Row, + remote: CloudKitVaultDocumentSnapshot, + db: Database + ) throws { + let localPayload: [String: String] = [ + "title": local["title"], + "source_type": local["source_type"], + "project_id": (local["project_id"] as String?) ?? "", + ] + try recordCloudKitConflict( + entity: .vaultDocument, + entityID: remote.id, + title: local["title"], + deviceSummary: "This device has unsynced Vault changes from \(dateLabel(local["updated_at"] as Double)).", + iCloudSummary: remote.deletedAt == nil + ? "iCloud has \"\(remote.title)\" from \(dateLabel(remote.updatedAt.timeIntervalSinceReferenceDate))." + : "The Vault document was deleted from iCloud at \(dateLabel(remote.deletedAt!.timeIntervalSinceReferenceDate)).", + devicePayloadJSON: String(decoding: try JSONEncoder().encode(localPayload), as: UTF8.self), + iCloudPayloadJSON: String(decoding: try JSONEncoder().encode(remote), as: UTF8.self), + deviceUpdatedAt: Date(timeIntervalSinceReferenceDate: local["updated_at"]), + iCloudUpdatedAt: remote.deletedAt ?? remote.updatedAt, + db: db + ) + } + + private static func recordCloudKitConflict( + entity: CloudKitConflictEntity, + entityID: UUID, + title: String, + deviceSummary: String, + iCloudSummary: String, + devicePayloadJSON: String, + iCloudPayloadJSON: String, + deviceUpdatedAt: Date, + iCloudUpdatedAt: Date, + db: Database + ) throws { + let existingID = try String.fetchOne( + db, + sql: "SELECT id FROM cloudkit_conflicts WHERE entity = ? AND entity_id = ? AND resolution = ?", + arguments: [entity.rawValue, entityID.uuidString, CloudKitConflictResolution.unresolved.rawValue] + ) + try db.execute( + sql: """ + INSERT INTO cloudkit_conflicts + (id, entity, entity_id, title, device_summary, icloud_summary, device_payload_json, + icloud_payload_json, device_updated_at, icloud_updated_at, resolution, detected_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(id) DO UPDATE SET + title = excluded.title, + device_summary = excluded.device_summary, + icloud_summary = excluded.icloud_summary, + device_payload_json = excluded.device_payload_json, + icloud_payload_json = excluded.icloud_payload_json, + device_updated_at = excluded.device_updated_at, + icloud_updated_at = excluded.icloud_updated_at + """, + arguments: [ + existingID ?? UUID().uuidString, + entity.rawValue, + entityID.uuidString, + title, + deviceSummary, + iCloudSummary, + devicePayloadJSON, + iCloudPayloadJSON, + deviceUpdatedAt.timeIntervalSinceReferenceDate, + iCloudUpdatedAt.timeIntervalSinceReferenceDate, + CloudKitConflictResolution.unresolved.rawValue, + Date().timeIntervalSinceReferenceDate, + ] + ) + } + + private static func dateLabel(_ referenceTime: Double) -> String { + Date(timeIntervalSinceReferenceDate: referenceTime).formatted(date: .abbreviated, time: .shortened) + } + private static func activeProjectID(_ projectID: UUID?, db: Database) throws -> UUID? { guard let projectID else { return nil } let exists = try Bool.fetchOne( @@ -716,6 +870,36 @@ extension GRDBPinesStore: CloudKitSyncRepository { try db.execute(sql: "UPDATE conversations SET project_id = NULL WHERE project_id = ?", arguments: [deletion.recordName]) try db.execute(sql: "UPDATE vault_documents SET project_id = NULL WHERE project_id = ?", arguments: [deletion.recordName]) case "Conversation": + if let id = UUID(uuidString: deletion.recordName), + let local = try Row.fetchOne( + db, + sql: """ + SELECT title, updated_at, deleted_at, default_model_id, default_provider_id, project_id, + archived_at, pinned, sync_state + FROM conversations WHERE id = ? + """, + arguments: [deletion.recordName] + ), + let state = (local["sync_state"] as String?).flatMap(SyncState.init(rawValue:)), + [.local, .pendingUpload, .conflicted].contains(state) { + let remote = CloudKitConversationSnapshot( + id: id, + title: local["title"], + updatedAt: deletion.deletedAt, + deletedAt: deletion.deletedAt, + defaultModelID: (local["default_model_id"] as String?).map(ModelID.init(rawValue:)), + defaultProviderID: (local["default_provider_id"] as String?).map(ProviderID.init(rawValue:)), + projectID: (local["project_id"] as String?).flatMap(UUID.init(uuidString:)), + archived: (local["archived_at"] as Double?) != nil, + pinned: (local["pinned"] as Int) == 1 + ) + try recordCloudKitConversationConflict(local: local, remote: remote, db: db) + try db.execute( + sql: "UPDATE conversations SET sync_state = ? WHERE id = ?", + arguments: [SyncState.conflicted.rawValue, deletion.recordName] + ) + return + } try db.execute( sql: "UPDATE conversations SET deleted_at = ?, updated_at = ?, sync_state = ? WHERE id = ? AND updated_at <= ?", arguments: [deletedAt, deletedAt, SyncState.deleted.rawValue, deletion.recordName, deletedAt] @@ -726,6 +910,30 @@ extension GRDBPinesStore: CloudKitSyncRepository { arguments: [deletedAt, deletedAt, SyncState.deleted.rawValue, deletion.recordName, deletedAt] ) case "VaultDocument": + if let id = UUID(uuidString: deletion.recordName), + let local = try Row.fetchOne( + db, + sql: "SELECT title, source_type, project_id, updated_at, sync_state FROM vault_documents WHERE id = ?", + arguments: [deletion.recordName] + ), + let state = (local["sync_state"] as String?).flatMap(SyncState.init(rawValue:)), + [.local, .pendingUpload, .conflicted].contains(state) { + let remote = CloudKitVaultDocumentSnapshot( + id: id, + title: local["title"], + sourceType: local["source_type"], + projectID: (local["project_id"] as String?).flatMap(UUID.init(uuidString:)), + updatedAt: deletion.deletedAt, + deletedAt: deletion.deletedAt, + chunkCount: 0 + ) + try recordCloudKitDocumentConflict(local: local, remote: remote, db: db) + try db.execute( + sql: "UPDATE vault_documents SET sync_state = ? WHERE id = ?", + arguments: [SyncState.conflicted.rawValue, deletion.recordName] + ) + return + } try db.execute( sql: "UPDATE vault_documents SET sync_state = ?, updated_at = ? WHERE id = ? AND updated_at <= ?", arguments: [SyncState.deleted.rawValue, deletedAt, deletion.recordName, deletedAt] diff --git a/Pines/Persistence/GRDBPinesStore+ProductionUX.swift b/Pines/Persistence/GRDBPinesStore+ProductionUX.swift new file mode 100644 index 0000000..3c645aa --- /dev/null +++ b/Pines/Persistence/GRDBPinesStore+ProductionUX.swift @@ -0,0 +1,347 @@ +import Foundation +import PinesCore + +#if canImport(GRDB) +import GRDB + +extension GRDBPinesStore { + func listProviderTransfers(providerID: ProviderID?) async throws -> [ProviderTransferRecord] { + try await database.read { db in + let rows: [Row] + if let providerID { + rows = try Row.fetchAll( + db, + sql: "SELECT * FROM provider_transfers WHERE provider_id = ? ORDER BY updated_at DESC", + arguments: [providerID.rawValue] + ) + } else { + rows = try Row.fetchAll(db, sql: "SELECT * FROM provider_transfers ORDER BY updated_at DESC") + } + return rows.compactMap(Self.providerTransfer(from:)) + } + } + + func upsertProviderTransfer(_ transfer: ProviderTransferRecord) async throws { + try await database.write { db in + try db.execute( + sql: """ + INSERT INTO provider_transfers + (id, provider_id, provider_kind, source, source_reference, staged_local_path, file_name, + content_type, purpose, status, completed_bytes, total_bytes, retry_count, provider_object_id, + created_at, updated_at, completed_at, last_error) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(id) DO UPDATE SET + provider_id = excluded.provider_id, + provider_kind = excluded.provider_kind, + source = excluded.source, + source_reference = excluded.source_reference, + staged_local_path = excluded.staged_local_path, + file_name = excluded.file_name, + content_type = excluded.content_type, + purpose = excluded.purpose, + status = excluded.status, + completed_bytes = excluded.completed_bytes, + total_bytes = excluded.total_bytes, + retry_count = excluded.retry_count, + provider_object_id = excluded.provider_object_id, + updated_at = excluded.updated_at, + completed_at = excluded.completed_at, + last_error = excluded.last_error + """, + arguments: [ + transfer.id.uuidString, + transfer.providerID.rawValue, + transfer.providerKind.rawValue, + transfer.source.rawValue, + transfer.sourceReference, + transfer.stagedLocalURL?.path, + transfer.fileName, + transfer.contentType, + transfer.purpose, + transfer.status.rawValue, + transfer.completedBytes, + transfer.totalBytes, + transfer.retryCount, + transfer.providerObjectID, + transfer.createdAt.timeIntervalSinceReferenceDate, + transfer.updatedAt.timeIntervalSinceReferenceDate, + transfer.completedAt?.timeIntervalSinceReferenceDate, + transfer.lastError, + ] + ) + } + } + + func deleteProviderTransfer(id: UUID) async throws { + try await database.write { db in + try db.execute(sql: "DELETE FROM provider_transfers WHERE id = ?", arguments: [id.uuidString]) + } + } + + func markActiveProviderTransfersInterrupted(at date: Date) async throws { + try await database.write { db in + try db.execute( + sql: """ + UPDATE provider_transfers + SET status = ?, updated_at = ?, last_error = ? + WHERE status IN (?, ?, ?, ?) + """, + arguments: [ + ProviderTransferStatus.interrupted.rawValue, + date.timeIntervalSinceReferenceDate, + "Pines closed before this transfer finished. Retry to resume from the staged source.", + ProviderTransferStatus.queued.rawValue, + ProviderTransferStatus.preparing.rawValue, + ProviderTransferStatus.transferring.rawValue, + ProviderTransferStatus.verifying.rawValue, + ] + ) + } + } + + func listCloudKitConflicts(unresolvedOnly: Bool) async throws -> [CloudKitConflictRecord] { + try await database.read { db in + let sql = unresolvedOnly + ? "SELECT * FROM cloudkit_conflicts WHERE resolution = ? ORDER BY detected_at DESC" + : "SELECT * FROM cloudkit_conflicts ORDER BY detected_at DESC" + let arguments: StatementArguments = unresolvedOnly ? [CloudKitConflictResolution.unresolved.rawValue] : [] + return try Row.fetchAll(db, sql: sql, arguments: arguments).compactMap(Self.cloudKitConflict(from:)) + } + } + + func upsertCloudKitConflict(_ conflict: CloudKitConflictRecord) async throws { + try await database.write { db in + if conflict.resolution == .unresolved { + try db.execute( + sql: "DELETE FROM cloudkit_conflicts WHERE entity = ? AND entity_id = ? AND resolution = ?", + arguments: [conflict.entity.rawValue, conflict.entityID.uuidString, CloudKitConflictResolution.unresolved.rawValue] + ) + } + try db.execute( + sql: """ + INSERT INTO cloudkit_conflicts + (id, entity, entity_id, title, device_summary, icloud_summary, device_payload_json, + icloud_payload_json, device_updated_at, icloud_updated_at, resolution, detected_at, resolved_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(id) DO UPDATE SET + title = excluded.title, + device_summary = excluded.device_summary, + icloud_summary = excluded.icloud_summary, + device_payload_json = excluded.device_payload_json, + icloud_payload_json = excluded.icloud_payload_json, + device_updated_at = excluded.device_updated_at, + icloud_updated_at = excluded.icloud_updated_at, + resolution = excluded.resolution, + resolved_at = excluded.resolved_at + """, + arguments: [ + conflict.id.uuidString, + conflict.entity.rawValue, + conflict.entityID.uuidString, + conflict.title, + conflict.deviceSummary, + conflict.iCloudSummary, + conflict.devicePayloadJSON, + conflict.iCloudPayloadJSON, + conflict.deviceUpdatedAt.timeIntervalSinceReferenceDate, + conflict.iCloudUpdatedAt.timeIntervalSinceReferenceDate, + conflict.resolution.rawValue, + conflict.detectedAt.timeIntervalSinceReferenceDate, + conflict.resolvedAt?.timeIntervalSinceReferenceDate, + ] + ) + } + } + + func resolveCloudKitConflict(id: UUID, resolution: CloudKitConflictResolution, at date: Date) async throws { + try await database.write { db in + guard resolution != .unresolved, + let row = try Row.fetchOne( + db, + sql: "SELECT * FROM cloudkit_conflicts WHERE id = ? AND resolution = ?", + arguments: [id.uuidString, CloudKitConflictResolution.unresolved.rawValue] + ), + let entity = CloudKitConflictEntity(rawValue: row["entity"]), + let entityID = UUID(uuidString: row["entity_id"]) + else { return } + + switch (entity, resolution) { + case (.conversation, .keepDevice): + try db.execute( + sql: "UPDATE conversations SET sync_state = ?, updated_at = MAX(updated_at, ?) WHERE id = ?", + arguments: [SyncState.local.rawValue, date.timeIntervalSinceReferenceDate, entityID.uuidString] + ) + case (.vaultDocument, .keepDevice): + try db.execute( + sql: "UPDATE vault_documents SET sync_state = ?, updated_at = MAX(updated_at, ?) WHERE id = ?", + arguments: [SyncState.local.rawValue, date.timeIntervalSinceReferenceDate, entityID.uuidString] + ) + case (.conversation, .useICloud): + let json: String = row["icloud_payload_json"] + let remote = try JSONDecoder().decode(CloudKitConversationSnapshot.self, from: Data(json.utf8)) + try Self.upsertCloudKitConversation(remote, deletedAt: remote.deletedAt, db: db) + case (.vaultDocument, .useICloud): + let json: String = row["icloud_payload_json"] + let remote = try JSONDecoder().decode(CloudKitVaultDocumentSnapshot.self, from: Data(json.utf8)) + try Self.upsertCloudKitDocument( + remote, + syncState: remote.deletedAt == nil ? .synced : .deleted, + updatedAt: remote.deletedAt ?? remote.updatedAt, + db: db + ) + case (_, .unresolved): + return + } + try db.execute( + sql: "UPDATE cloudkit_conflicts SET resolution = ?, resolved_at = ? WHERE id = ? AND resolution = ?", + arguments: [ + resolution.rawValue, + date.timeIntervalSinceReferenceDate, + id.uuidString, + CloudKitConflictResolution.unresolved.rawValue, + ] + ) + } + } + + func openRouterSpendReport(window: OpenRouterSpendWindow, now: Date) async throws -> OpenRouterSpendReport { + try await database.read { db in + let rows: [Row] + if let start = window.startDate(relativeTo: now) { + rows = try Row.fetchAll( + db, + sql: """ + SELECT provider_metadata_json + FROM messages + WHERE role = 'assistant' AND deleted_at IS NULL AND created_at >= ? + AND provider_metadata_json LIKE '%openrouter.%' + ORDER BY created_at DESC + """, + arguments: [start.timeIntervalSinceReferenceDate] + ) + } else { + rows = try Row.fetchAll( + db, + sql: """ + SELECT provider_metadata_json + FROM messages + WHERE role = 'assistant' AND deleted_at IS NULL + AND provider_metadata_json LIKE '%openrouter.%' + ORDER BY created_at DESC + """ + ) + } + + var runCount = 0 + var reportedCostRunCount = 0 + var reportedCost = 0.0 + var upstreamCost = 0.0 + var promptTokens = 0 + var completionTokens = 0 + var webSearchRuns = 0 + var providers: [String: (runs: Int, reported: Double, upstream: Double)] = [:] + + for row in rows { + guard let json: String = row["provider_metadata_json"], + let data = json.data(using: .utf8), + let metadata = try? JSONDecoder().decode([String: String].self, from: data) + else { continue } + runCount += 1 + let reported = metadata[CloudProviderMetadataKeys.openRouterCostCredits].flatMap(Double.init) + let upstream = metadata[CloudProviderMetadataKeys.openRouterUpstreamInferenceCost].flatMap(Double.init) ?? 0 + if let reported { + reportedCostRunCount += 1 + reportedCost += reported + } + upstreamCost += upstream + promptTokens += metadata[CloudProviderMetadataKeys.openRouterPromptTokens].flatMap(Int.init) ?? 0 + completionTokens += metadata[CloudProviderMetadataKeys.openRouterCompletionTokens].flatMap(Int.init) ?? 0 + if metadata[CloudProviderMetadataKeys.openRouterWebSearchRequests].flatMap(Int.init) ?? 0 > 0 { + webSearchRuns += 1 + } + let name = metadata[CloudProviderMetadataKeys.openRouterSelectedProvider] + ?? metadata[CloudProviderMetadataKeys.openRouterProvider] + ?? "Unreported provider" + var bucket = providers[name] ?? (0, 0, 0) + bucket.runs += 1 + bucket.reported += reported ?? 0 + bucket.upstream += upstream + providers[name] = bucket + } + + return OpenRouterSpendReport( + window: window, + generatedAt: now, + runCount: runCount, + reportedCostRunCount: reportedCostRunCount, + missingCostRunCount: runCount - reportedCostRunCount, + reportedCostCredits: reportedCost, + upstreamCostCredits: upstreamCost, + promptTokens: promptTokens, + completionTokens: completionTokens, + webSearchRunCount: webSearchRuns, + byUpstreamProvider: providers.map { name, value in + OpenRouterSpendProviderBreakdown( + providerName: name, + runCount: value.runs, + reportedCostCredits: value.reported, + upstreamCostCredits: value.upstream + ) + }.sorted { $0.reportedCostCredits > $1.reportedCostCredits } + ) + } + } + + private static func providerTransfer(from row: Row) -> ProviderTransferRecord? { + guard let id = UUID(uuidString: row["id"]), + let kind = CloudProviderKind(rawValue: row["provider_kind"]), + let source = ProviderTransferSource(rawValue: row["source"]), + let status = ProviderTransferStatus(rawValue: row["status"]) + else { return nil } + let stagedPath: String? = row["staged_local_path"] + return ProviderTransferRecord( + id: id, + providerID: ProviderID(rawValue: row["provider_id"]), + providerKind: kind, + source: source, + sourceReference: row["source_reference"], + stagedLocalURL: stagedPath.map { URL(fileURLWithPath: $0) }, + fileName: row["file_name"], + contentType: row["content_type"], + purpose: row["purpose"], + status: status, + completedBytes: row["completed_bytes"], + totalBytes: row["total_bytes"], + retryCount: row["retry_count"], + providerObjectID: row["provider_object_id"], + createdAt: Date(timeIntervalSinceReferenceDate: row["created_at"]), + updatedAt: Date(timeIntervalSinceReferenceDate: row["updated_at"]), + completedAt: (row["completed_at"] as Double?).map(Date.init(timeIntervalSinceReferenceDate:)), + lastError: row["last_error"] + ) + } + + private static func cloudKitConflict(from row: Row) -> CloudKitConflictRecord? { + guard let id = UUID(uuidString: row["id"]), + let entityID = UUID(uuidString: row["entity_id"]), + let entity = CloudKitConflictEntity(rawValue: row["entity"]), + let resolution = CloudKitConflictResolution(rawValue: row["resolution"]) + else { return nil } + return CloudKitConflictRecord( + id: id, + entity: entity, + entityID: entityID, + title: row["title"], + deviceSummary: row["device_summary"], + iCloudSummary: row["icloud_summary"], + devicePayloadJSON: row["device_payload_json"], + iCloudPayloadJSON: row["icloud_payload_json"], + deviceUpdatedAt: Date(timeIntervalSinceReferenceDate: row["device_updated_at"]), + iCloudUpdatedAt: Date(timeIntervalSinceReferenceDate: row["icloud_updated_at"]), + resolution: resolution, + detectedAt: Date(timeIntervalSinceReferenceDate: row["detected_at"]), + resolvedAt: (row["resolved_at"] as Double?).map(Date.init(timeIntervalSinceReferenceDate:)) + ) + } +} +#endif diff --git a/Pines/Persistence/GRDBPinesStore.swift b/Pines/Persistence/GRDBPinesStore.swift index 6911281..a0e6d19 100644 --- a/Pines/Persistence/GRDBPinesStore.swift +++ b/Pines/Persistence/GRDBPinesStore.swift @@ -23,6 +23,9 @@ actor GRDBPinesStore: ProviderStructuredOutputRepository, ProviderModelCapabilityRepository, ProviderResearchRunRepository, + ProviderTransferRepository, + CloudKitConflictRepository, + CloudSpendRepository, MCPServerRepository, ModelDownloadRepository, AuditEventRepository, diff --git a/Pines/Views/Artifacts/ArtifactsWorkspaceView.swift b/Pines/Views/Artifacts/ArtifactsWorkspaceView.swift index 54d1fc8..32b77b9 100644 --- a/Pines/Views/Artifacts/ArtifactsWorkspaceView.swift +++ b/Pines/Views/Artifacts/ArtifactsWorkspaceView.swift @@ -1469,7 +1469,6 @@ private struct ArtifactsFilesWorkspace: View { @Binding var selection: ArtifactsSelection? @Binding var pendingConfirmation: ArtifactsConfirmation? @State private var isImporterPresented = false - @State private var isUploading = false @State private var selectedVaultDocumentID: UUID? @State private var purpose = "assistants" @@ -1481,6 +1480,15 @@ private struct ArtifactsFilesWorkspace: View { ArtifactsWorkspaceDeriver.fileSummaries(files: providerState.providerFiles, filter: .init(providerScope: providerScope)) } + private var transfers: [ProviderTransferRecord] { + providerState.providerTransfers.filter { transfer in + switch providerScope { + case .all: true + case .provider(let providerID): transfer.providerID == providerID + } + } + } + var body: some View { VStack(alignment: .leading, spacing: theme.spacing.medium) { PinesCardSection("Cloud Copies", subtitle: "Reusable remote copies for large files and background work. Local Vault files stay separate.", systemImage: "doc.badge.arrow.up") { @@ -1496,9 +1504,9 @@ private struct ArtifactsFilesWorkspace: View { Button { isImporterPresented = true } label: { - Label(isUploading ? "Uploading" : "Create Cloud Copy", systemImage: isUploading ? "hourglass" : "square.and.arrow.up") + Label("Create Cloud Copy", systemImage: "square.and.arrow.up") } - .disabled(provider == nil || isUploading) + .disabled(provider == nil) .pinesButtonStyle(.primary) Button { @@ -1531,6 +1539,20 @@ private struct ArtifactsFilesWorkspace: View { Task { await handleImport(result) } } + if !transfers.isEmpty { + PinesCardSection( + "Transfer Queue", + subtitle: "Transfers survive relaunch. Cancelled, interrupted, and failed uploads keep their staged source for retry.", + systemImage: "arrow.up.arrow.down.circle" + ) { + VStack(spacing: theme.spacing.small) { + ForEach(transfers) { transfer in + transferRow(transfer) + } + } + } + } + ArtifactsResourceList(summaries: summaries, selection: $selection, emptyTitle: "No provider files", emptyDetail: "Provider-hosted files appear here after upload or provider refresh.") fileActions @@ -1579,30 +1601,85 @@ private struct ArtifactsFilesWorkspace: View { } } + @ViewBuilder + private func transferRow(_ transfer: ProviderTransferRecord) -> some View { + VStack(alignment: .leading, spacing: theme.spacing.xsmall) { + HStack(alignment: .firstTextBaseline, spacing: theme.spacing.small) { + VStack(alignment: .leading, spacing: 2) { + Text(transfer.fileName) + .font(theme.typography.callout.weight(.semibold)) + .foregroundStyle(theme.colors.primaryText) + .lineLimit(1) + Text("\(transfer.providerKind.pinesLifecycleTitle) - \(transfer.status.pinesTransferLabel)") + .font(theme.typography.caption) + .foregroundStyle(theme.colors.secondaryText) + } + Spacer(minLength: theme.spacing.small) + if transfer.status.isActive { + Button("Cancel", role: .destructive) { + Task { await appModel.cancelProviderTransfer(id: transfer.id, services: services) } + } + .font(theme.typography.caption.weight(.semibold)) + } else if transfer.status.canRetry { + Button("Retry") { + Task { await appModel.retryProviderTransfer(id: transfer.id, services: services) } + } + .font(theme.typography.caption.weight(.semibold)) + Button("Remove", role: .destructive) { + Task { await appModel.removeProviderTransfer(id: transfer.id, services: services) } + } + .font(theme.typography.caption.weight(.semibold)) + } else { + Button("Clear") { + Task { await appModel.removeProviderTransfer(id: transfer.id, services: services) } + } + .font(theme.typography.caption.weight(.semibold)) + } + } + + if transfer.status.isActive { + if let progress = transfer.progressFraction, + transfer.status == .transferring, + transfer.completedBytes > 0 { + ProgressView(value: progress) + .accessibilityLabel("Upload progress") + .accessibilityValue("\(Int(progress * 100)) percent") + } else { + ProgressView() + .controlSize(.small) + .accessibilityLabel(transfer.status.pinesTransferLabel) + } + } + if let total = transfer.totalBytes { + Text(transfer.completedBytes > 0 + ? "\(providerByteCountLabel(transfer.completedBytes)) of \(providerByteCountLabel(total)) - attempt \(transfer.retryCount + 1)" + : "\(providerByteCountLabel(total)) staged - attempt \(transfer.retryCount + 1)") + .font(theme.typography.caption) + .foregroundStyle(theme.colors.secondaryText) + } + if let error = transfer.lastError, !error.isEmpty { + Text(error) + .font(theme.typography.caption) + .foregroundStyle(theme.colors.warning) + .fixedSize(horizontal: false, vertical: true) + } + } + .pinesSurface(.inset, padding: theme.spacing.small) + } + @MainActor private func handleImport(_ result: Result<[URL], Error>) async { guard let provider else { return } do { guard let url = try result.get().first else { return } - isUploading = true - defer { isUploading = false } - let byteCount = (try? url.resourceValues(forKeys: [.fileSizeKey]).fileSize).map(Int64.init) - switch provider.kind { - case .openAI: - let consent = PinesOpenAIProviderStorageConsent(isGranted: true, sourceDescription: url.lastPathComponent, destinationDescription: "OpenAI Files API for \(provider.displayName)", byteCount: byteCount) - _ = try await appModel.uploadOpenAILocalFile(providerID: provider.id, fileURL: url, purpose: purpose, consent: consent, services: services) - case .anthropic: - let consent = PinesAnthropicProviderStorageConsent(isGranted: true, sourceDescription: url.lastPathComponent, destinationDescription: "Anthropic Files API for \(provider.displayName)", byteCount: byteCount) - _ = try await appModel.uploadAnthropicLocalFile(providerID: provider.id, fileURL: url, consent: consent, services: services) - case .gemini: - let consent = PinesGeminiProviderStorageConsent(isGranted: true, sourceDescription: url.lastPathComponent, destinationDescription: "Gemini Files API for \(provider.displayName)", byteCount: byteCount) - _ = try await appModel.uploadGeminiLocalFile(providerID: provider.id, fileURL: url, consent: consent, services: services) - default: - throw InferenceError.invalidRequest("\(provider.kind.pinesLifecycleTitle) file upload is not supported here.") - } + try await appModel.enqueueProviderFileTransfer( + provider: provider, + fileURL: url, + purpose: provider.kind == .openAI ? purpose : nil, + services: services + ) } catch { providerState.providerLifecycleError = error.localizedDescription - isUploading = false } } @@ -1610,16 +1687,14 @@ private struct ArtifactsFilesWorkspace: View { private func uploadVaultDocument() async { guard let provider, let selectedVaultDocumentID else { return } do { - switch provider.kind { - case .openAI: - let consent = PinesOpenAIProviderStorageConsent(isGranted: true, sourceDescription: "Vault document \(selectedVaultDocumentID.uuidString)", destinationDescription: "OpenAI Files API for \(provider.displayName)") - _ = try await appModel.uploadOpenAIVaultDocument(providerID: provider.id, documentID: selectedVaultDocumentID, purpose: purpose, consent: consent, services: services) - case .anthropic: - let consent = PinesAnthropicProviderStorageConsent(isGranted: true, sourceDescription: "Vault document \(selectedVaultDocumentID.uuidString)", destinationDescription: "Anthropic Files API for \(provider.displayName)") - _ = try await appModel.uploadAnthropicVaultDocument(providerID: provider.id, documentID: selectedVaultDocumentID, consent: consent, services: services) - default: - throw InferenceError.invalidRequest("\(provider.kind.pinesLifecycleTitle) Vault document upload is not supported here.") - } + let title = vaultState.vaultItems.first(where: { $0.id == selectedVaultDocumentID })?.title ?? "Vault document" + try await appModel.enqueueVaultProviderTransfer( + provider: provider, + documentID: selectedVaultDocumentID, + documentTitle: title, + purpose: provider.kind == .openAI ? purpose : nil, + services: services + ) } catch { providerState.providerLifecycleError = error.localizedDescription } @@ -1672,6 +1747,21 @@ private struct ArtifactsFilesWorkspace: View { } } +private extension ProviderTransferStatus { + var pinesTransferLabel: String { + switch self { + case .queued: "Queued" + case .preparing: "Preparing durable source" + case .transferring: "Uploading" + case .verifying: "Verifying cloud copy" + case .completed: "Completed" + case .failed: "Failed" + case .cancelled: "Cancelled" + case .interrupted: "Interrupted" + } + } +} + private struct ArtifactsContextWorkspace: View { @Environment(\.pinesTheme) private var theme @Environment(\.pinesServices) private var services diff --git a/Pines/Views/Models/ModelsViewComponents.swift b/Pines/Views/Models/ModelsViewComponents.swift index 879ba0f..eec96c3 100644 --- a/Pines/Views/Models/ModelsViewComponents.swift +++ b/Pines/Views/Models/ModelsViewComponents.swift @@ -465,9 +465,7 @@ struct ModelDetailView: View { repositoryCard capabilitiesCard - if !model.compatibilityWarnings.isEmpty { - compatibilityCard - } + compatibilityCard } .padding(theme.spacing.large) .frame(maxWidth: theme.spacing.contentMaxWidth, alignment: .leading) @@ -917,7 +915,7 @@ struct ModelDetailView: View { } private var compatibilityCard: some View { - PinesCardSection("Compatibility", subtitle: "Warnings surfaced before install or execution.", systemImage: "exclamationmark.triangle") { + PinesCardSection("Compatibility", subtitle: "Exact runtime claim, evidence tuple, admission, and fallback behavior.", systemImage: "checkmark.shield") { Label(model.runtimeCompatibilityState.title, systemImage: model.runtimeCompatibilityState.systemImage) .font(theme.typography.callout.weight(.semibold)) .foregroundStyle(model.runtimeCompatibilityState.tint(in: theme)) @@ -925,6 +923,24 @@ struct ModelDetailView: View { .minimumScaleFactor(0.86) .pinesSurface(.inset, padding: theme.spacing.small) + VStack(alignment: .leading, spacing: theme.spacing.xsmall) { + Text(model.compatibilityExplanation.headline) + .font(theme.typography.callout.weight(.semibold)) + .foregroundStyle(theme.colors.primaryText) + Text(model.compatibilityExplanation.summary) + .font(theme.typography.caption) + .foregroundStyle(theme.colors.secondaryText) + .fixedSize(horizontal: false, vertical: true) + Text(model.compatibilityExplanation.claimBasis) + .font(theme.typography.caption.weight(.semibold)) + .foregroundStyle(model.runtimeCompatibilityState.allowsProductClaim ? theme.colors.success : theme.colors.warning) + } + .pinesSurface(.inset, padding: theme.spacing.small) + + PinesKeyValueGrid(items: model.compatibilityExplanation.facts.map { fact in + .init(fact.label, fact.value, copyable: fact.label == "Compatibility pair" || fact.label == "Fallback contract") + }) + ForEach(model.compatibilityWarnings, id: \.self) { warning in Label(warning, systemImage: "exclamationmark.triangle.fill") .font(theme.typography.callout) @@ -933,6 +949,14 @@ struct ModelDetailView: View { .minimumScaleFactor(0.86) .pinesSurface(.inset, padding: theme.spacing.small) } + + if let nextAction = model.compatibilityExplanation.nextAction { + Label(nextAction, systemImage: "arrow.forward.circle") + .font(theme.typography.callout) + .foregroundStyle(theme.colors.primaryText) + .fixedSize(horizontal: false, vertical: true) + .pinesSurface(.inset, padding: theme.spacing.small) + } } } } diff --git a/Pines/Views/Settings/SettingsDetailView.swift b/Pines/Views/Settings/SettingsDetailView.swift index 33cc842..bcfc0f4 100644 --- a/Pines/Views/Settings/SettingsDetailView.swift +++ b/Pines/Views/Settings/SettingsDetailView.swift @@ -63,6 +63,7 @@ struct SettingsDetailView: View { @State private var mcpServerPendingDeletion: MCPServerConfiguration? @State private var showsAdvancedMCPOptions = false @State private var showsAdvancedSyncOptions = false + @State private var openRouterSpendWindow: OpenRouterSpendWindow = .month private var iCloudSyncAvailable: Bool { services.cloudKitSyncService != nil @@ -485,6 +486,22 @@ struct SettingsDetailView: View { .accessibilityIdentifier("pines.settings.icloud.error") } + if !settingsState.cloudKitConflicts.isEmpty { + VStack(alignment: .leading, spacing: theme.spacing.small) { + Label( + "\(settingsState.cloudKitConflicts.count) change conflict\(settingsState.cloudKitConflicts.count == 1 ? "" : "s") need review", + systemImage: "arrow.triangle.2.circlepath.icloud" + ) + .font(theme.typography.callout.weight(.semibold)) + .foregroundStyle(theme.colors.warning) + + ForEach(settingsState.cloudKitConflicts) { conflict in + cloudKitConflictRow(conflict) + } + } + .accessibilityIdentifier("pines.settings.icloud.conflicts") + } + Toggle("App lock", isOn: Binding( get: { settingsState.securityConfiguration.appLockEnabled }, set: { value in @@ -754,6 +771,7 @@ struct SettingsDetailView: View { if settingsState.cloudProviders.contains(where: { $0.kind == .openRouter }) { openRouterRoutingPolicyEditor + openRouterSpendDashboard } } } label: { @@ -875,6 +893,104 @@ struct SettingsDetailView: View { .accessibilityIdentifier("pines.settings.openrouter.routing") } + private var openRouterSpendDashboard: some View { + let report = settingsState.openRouterSpendReport + return DisclosureGroup { + VStack(alignment: .leading, spacing: theme.spacing.medium) { + Picker("Period", selection: $openRouterSpendWindow) { + ForEach(OpenRouterSpendWindow.allCases, id: \.self) { window in + Text(window.settingsTitle).tag(window) + } + } + .pickerStyle(.segmented) + .onChange(of: openRouterSpendWindow) { _, window in + Task { await appModel.refreshOpenRouterSpend(window: window, services: services) } + } + + PinesKeyValueGrid(items: [ + .init("Reported cost", String(format: "%.6f credits", report.reportedCostCredits), systemImage: "creditcard"), + .init("Runs", "\(report.runCount)", systemImage: "bolt"), + .init("Cost coverage", "\(report.reportedCostRunCount)/\(report.runCount)", systemImage: "checkmark.seal"), + .init("Tokens", "\(report.promptTokens + report.completionTokens)", systemImage: "text.word.spacing") + ]) + + if report.missingCostRunCount > 0 { + Label( + "\(report.missingCostRunCount) run\(report.missingCostRunCount == 1 ? "" : "s") did not report cost. Totals intentionally exclude those runs.", + systemImage: "exclamationmark.triangle" + ) + .font(theme.typography.caption) + .foregroundStyle(theme.colors.warning) + .fixedSize(horizontal: false, vertical: true) + } + + if report.byUpstreamProvider.isEmpty { + Text("No OpenRouter usage receipts were recorded in this period.") + .font(theme.typography.caption) + .foregroundStyle(theme.colors.secondaryText) + } else { + ForEach(report.byUpstreamProvider) { provider in + HStack(spacing: theme.spacing.small) { + VStack(alignment: .leading, spacing: 2) { + Text(provider.providerName) + .font(theme.typography.callout.weight(.semibold)) + .foregroundStyle(theme.colors.primaryText) + Text("\(provider.runCount) run\(provider.runCount == 1 ? "" : "s")") + .font(theme.typography.caption) + .foregroundStyle(theme.colors.secondaryText) + } + Spacer() + Text(String(format: "%.6f credits", provider.reportedCostCredits)) + .font(theme.typography.caption.monospacedDigit()) + .foregroundStyle(theme.colors.primaryText) + } + } + } + + Text("Pines reports provider-returned credits and never guesses a missing price. Upstream cost is tracked separately when OpenRouter supplies it.") + .font(theme.typography.caption) + .foregroundStyle(theme.colors.tertiaryText) + .fixedSize(horizontal: false, vertical: true) + } + .padding(.top, theme.spacing.small) + } label: { + Label("OpenRouter spend and reconciliation", systemImage: "chart.bar.xaxis") + .font(theme.typography.headline) + } + .task { + openRouterSpendWindow = settingsState.openRouterSpendReport.window + await appModel.refreshOpenRouterSpend(window: openRouterSpendWindow, services: services) + } + .accessibilityIdentifier("pines.settings.openrouter.spend") + } + + private func cloudKitConflictRow(_ conflict: CloudKitConflictRecord) -> some View { + VStack(alignment: .leading, spacing: theme.spacing.xsmall) { + Text(conflict.title) + .font(theme.typography.callout.weight(.semibold)) + .foregroundStyle(theme.colors.primaryText) + Label(conflict.deviceSummary, systemImage: "iphone") + .font(theme.typography.caption) + .foregroundStyle(theme.colors.secondaryText) + .fixedSize(horizontal: false, vertical: true) + Label(conflict.iCloudSummary, systemImage: "icloud") + .font(theme.typography.caption) + .foregroundStyle(theme.colors.secondaryText) + .fixedSize(horizontal: false, vertical: true) + PinesAdaptiveButtonRow { + Button("Keep This Device") { + Task { await appModel.resolveCloudKitConflict(id: conflict.id, resolution: .keepDevice, services: services) } + } + .pinesButtonStyle(.secondary, fillWidth: true) + Button("Use iCloud") { + Task { await appModel.resolveCloudKitConflict(id: conflict.id, resolution: .useICloud, services: services) } + } + .pinesButtonStyle(.secondary, fillWidth: true) + } + } + .pinesSurface(.inset, padding: theme.spacing.small) + } + private func openRouterProviderListBinding( _ keyPath: WritableKeyPath ) -> Binding { @@ -2139,6 +2255,17 @@ private extension OpenRouterWebSearchEngine { } } +private extension OpenRouterSpendWindow { + var settingsTitle: String { + switch self { + case .day: "24h" + case .week: "7d" + case .month: "30d" + case .all: "All" + } + } +} + private extension ProviderValidationStatus { var cloudStatus: PinesCloudStatus { switch self { diff --git a/PinesTests/ProductionUXPersistenceTests.swift b/PinesTests/ProductionUXPersistenceTests.swift new file mode 100644 index 0000000..91b4115 --- /dev/null +++ b/PinesTests/ProductionUXPersistenceTests.swift @@ -0,0 +1,174 @@ +import Foundation +import XCTest +import PinesCore +@testable import pines + +final class ProductionUXPersistenceTests: XCTestCase { + func testProviderTransferPersistsAndActiveTransferBecomesInterrupted() async throws { + let (store, directory) = try makeStore() + defer { try? FileManager.default.removeItem(at: directory) } + + let provider = CloudProviderConfiguration( + id: ProviderID(rawValue: "openai-test"), + kind: .openAI, + displayName: "OpenAI Test", + baseURL: try XCTUnwrap(URL(string: "https://api.openai.com/v1")), + keychainAccount: "openai-test" + ) + try await store.upsertProvider(provider) + let transfer = ProviderTransferRecord( + providerID: provider.id, + providerKind: .openAI, + source: .localFile, + sourceReference: "brief.pdf", + stagedLocalURL: directory.appending(path: "brief.pdf"), + fileName: "brief.pdf", + contentType: "application/pdf", + purpose: "assistants", + status: .transferring, + completedBytes: 128, + totalBytes: 1_024 + ) + try await store.upsertProviderTransfer(transfer) + + var transfers = try await store.listProviderTransfers(providerID: nil) + let restored = try XCTUnwrap(transfers.first) + XCTAssertEqual(restored, transfer) + + let interruptedAt = Date(timeIntervalSinceReferenceDate: 75_000) + try await store.markActiveProviderTransfersInterrupted(at: interruptedAt) + transfers = try await store.listProviderTransfers(providerID: transfer.providerID) + let interrupted = try XCTUnwrap(transfers.first) + XCTAssertEqual(interrupted.status, .interrupted) + XCTAssertTrue(interrupted.status.canRetry) + XCTAssertEqual(interrupted.updatedAt, interruptedAt) + XCTAssertTrue(interrupted.lastError?.contains("Retry") == true) + } + + func testOpenRouterSpendUsesPersistedReceiptMetadataWithoutEstimation() async throws { + let (store, directory) = try makeStore() + defer { try? FileManager.default.removeItem(at: directory) } + + let conversation = try await store.createConversation( + title: "Spend", + defaultModelID: nil, + defaultProviderID: nil + ) + let now = Date(timeIntervalSinceReferenceDate: 90_000) + try await store.appendMessage( + ChatMessage( + role: .assistant, + content: "Reported", + createdAt: now.addingTimeInterval(-60), + providerMetadata: [ + CloudProviderMetadataKeys.openRouterCostCredits: "0.004", + CloudProviderMetadataKeys.openRouterUpstreamInferenceCost: "0.003", + CloudProviderMetadataKeys.openRouterPromptTokens: "100", + CloudProviderMetadataKeys.openRouterCompletionTokens: "20", + CloudProviderMetadataKeys.openRouterWebSearchRequests: "2", + CloudProviderMetadataKeys.openRouterSelectedProvider: "Anthropic", + ] + ), + status: .complete, + conversationID: conversation.id, + modelID: ModelID(rawValue: "openrouter/auto"), + providerID: nil + ) + try await store.appendMessage( + ChatMessage( + role: .assistant, + content: "Missing provider cost", + createdAt: now.addingTimeInterval(-30), + providerMetadata: [ + CloudProviderMetadataKeys.openRouterPromptTokens: "50", + CloudProviderMetadataKeys.openRouterCompletionTokens: "10", + CloudProviderMetadataKeys.openRouterProvider: "OpenAI", + ] + ), + status: .complete, + conversationID: conversation.id, + modelID: ModelID(rawValue: "openrouter/auto"), + providerID: nil + ) + + let report = try await store.openRouterSpendReport(window: .day, now: now) + XCTAssertEqual(report.runCount, 2) + XCTAssertEqual(report.reportedCostRunCount, 1) + XCTAssertEqual(report.missingCostRunCount, 1) + XCTAssertEqual(report.reportedCostCredits, 0.004, accuracy: 0.000_001) + XCTAssertEqual(report.upstreamCostCredits, 0.003, accuracy: 0.000_001) + XCTAssertEqual(report.promptTokens, 150) + XCTAssertEqual(report.completionTokens, 30) + XCTAssertEqual(report.webSearchRunCount, 1) + XCTAssertEqual(report.byUpstreamProvider.map(\.providerName).sorted(), ["Anthropic", "OpenAI"]) + } + + func testCloudKitConversationConflictRequiresExplicitResolution() async throws { + let (store, directory) = try makeStore() + defer { try? FileManager.default.removeItem(at: directory) } + + let conversation = try await store.createConversation( + title: "This device", + defaultModelID: nil, + defaultProviderID: nil + ) + let cloudVersion = CloudKitConversationSnapshot( + id: conversation.id, + title: "From iCloud", + updatedAt: Date().addingTimeInterval(60), + deletedAt: nil, + defaultModelID: nil, + defaultProviderID: nil, + projectID: nil, + archived: false, + pinned: false + ) + + try await store.applyCloudKitSnapshot(CloudKitRemoteSnapshot(conversations: [cloudVersion])) + var unresolvedConflicts = try await store.listCloudKitConflicts(unresolvedOnly: true) + var conflict = try XCTUnwrap(unresolvedConflicts.first) + XCTAssertEqual(conflict.entityID, conversation.id) + var restoredConversation = try await store.listConversations().first + XCTAssertEqual(restoredConversation?.title, "This device") + + try await store.resolveCloudKitConflict(id: conflict.id, resolution: .keepDevice, at: Date()) + unresolvedConflicts = try await store.listCloudKitConflicts(unresolvedOnly: true) + XCTAssertTrue(unresolvedConflicts.isEmpty) + restoredConversation = try await store.listConversations().first + XCTAssertEqual(restoredConversation?.title, "This device") + + try await store.applyCloudKitSnapshot(CloudKitRemoteSnapshot(conversations: [cloudVersion])) + unresolvedConflicts = try await store.listCloudKitConflicts(unresolvedOnly: true) + conflict = try XCTUnwrap(unresolvedConflicts.first) + try await store.resolveCloudKitConflict(id: conflict.id, resolution: .useICloud, at: Date()) + unresolvedConflicts = try await store.listCloudKitConflicts(unresolvedOnly: true) + XCTAssertTrue(unresolvedConflicts.isEmpty) + restoredConversation = try await store.listConversations().first + XCTAssertEqual(restoredConversation?.title, "From iCloud") + + try await store.updateConversationTitle("Edited after sync", conversationID: conversation.id) + try await store.applyCloudKitSnapshot( + CloudKitRemoteSnapshot( + deletedRecords: [ + CloudKitDeletedRecord( + recordType: "Conversation", + recordName: conversation.id.uuidString, + deletedAt: Date().addingTimeInterval(120) + ), + ] + ) + ) + unresolvedConflicts = try await store.listCloudKitConflicts(unresolvedOnly: true) + conflict = try XCTUnwrap(unresolvedConflicts.first) + XCTAssertTrue(conflict.iCloudSummary.contains("deleted")) + restoredConversation = try await store.listConversations().first + XCTAssertEqual(restoredConversation?.title, "Edited after sync") + } + + private func makeStore() throws -> (GRDBPinesStore, URL) { + let directory = FileManager.default.temporaryDirectory + .appending(path: "pines-production-ux-tests-\(UUID().uuidString)", directoryHint: .isDirectory) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + return (try GRDBPinesStore.makeTestingStore(at: directory.appending(path: "store.sqlite")), directory) + } +} diff --git a/Sources/PinesCore/Persistence/DatabaseSchema.swift b/Sources/PinesCore/Persistence/DatabaseSchema.swift index 799a82c..6683d77 100644 --- a/Sources/PinesCore/Persistence/DatabaseSchema.swift +++ b/Sources/PinesCore/Persistence/DatabaseSchema.swift @@ -13,7 +13,7 @@ public struct DatabaseMigration: Hashable, Codable, Sendable { } public enum PinesDatabaseSchema { - public static let currentVersion = 27 + public static let currentVersion = 28 /// Durable application tables that must survive plaintext-to-SQLCipher migration and must be /// cleared by a full local-data reset. Keep this catalog aligned with every non-FTS table @@ -64,6 +64,8 @@ public enum PinesDatabaseSchema { "provider_structured_outputs", "provider_model_capabilities", "provider_research_runs", + "provider_transfers", + "cloudkit_conflicts", "projects", "turboquant_profile_evidence", "turboquant_evidence_revocations", @@ -1406,6 +1408,53 @@ public enum PinesDatabaseSchema { """, "CREATE INDEX IF NOT EXISTS idx_cloud_model_catalog_expiry ON cloud_model_catalog_snapshots(expires_at ASC);", ]), + DatabaseMigration( + version: 28, name: "production-ux-state", + sql: [ + """ + CREATE TABLE IF NOT EXISTS provider_transfers ( + id TEXT PRIMARY KEY NOT NULL, + provider_id TEXT NOT NULL REFERENCES cloud_providers(id) ON DELETE CASCADE, + provider_kind TEXT NOT NULL, + source TEXT NOT NULL, + source_reference TEXT NOT NULL, + staged_local_path TEXT, + file_name TEXT NOT NULL, + content_type TEXT, + purpose TEXT, + status TEXT NOT NULL, + completed_bytes INTEGER NOT NULL DEFAULT 0, + total_bytes INTEGER, + retry_count INTEGER NOT NULL DEFAULT 0, + provider_object_id TEXT, + created_at REAL NOT NULL, + updated_at REAL NOT NULL, + completed_at REAL, + last_error TEXT + ); + """, + "CREATE INDEX IF NOT EXISTS idx_provider_transfers_status ON provider_transfers(status, updated_at DESC);", + "CREATE INDEX IF NOT EXISTS idx_provider_transfers_provider ON provider_transfers(provider_id, updated_at DESC);", + """ + CREATE TABLE IF NOT EXISTS cloudkit_conflicts ( + id TEXT PRIMARY KEY NOT NULL, + entity TEXT NOT NULL, + entity_id TEXT NOT NULL, + title TEXT NOT NULL, + device_summary TEXT NOT NULL, + icloud_summary TEXT NOT NULL, + device_payload_json TEXT NOT NULL, + icloud_payload_json TEXT NOT NULL, + device_updated_at REAL NOT NULL, + icloud_updated_at REAL NOT NULL, + resolution TEXT NOT NULL DEFAULT 'unresolved', + detected_at REAL NOT NULL, + resolved_at REAL + ); + """, + "CREATE UNIQUE INDEX IF NOT EXISTS idx_cloudkit_conflicts_unresolved_entity ON cloudkit_conflicts(entity, entity_id) WHERE resolution = 'unresolved';", + "CREATE INDEX IF NOT EXISTS idx_cloudkit_conflicts_resolution ON cloudkit_conflicts(resolution, detected_at DESC);", + ]), ] } diff --git a/Sources/PinesCore/ProductionUXTypes.swift b/Sources/PinesCore/ProductionUXTypes.swift new file mode 100644 index 0000000..731e8b9 --- /dev/null +++ b/Sources/PinesCore/ProductionUXTypes.swift @@ -0,0 +1,413 @@ +import Foundation + +// MARK: - Provider transfers + +public enum ProviderTransferSource: String, Codable, CaseIterable, Sendable { + case localFile + case vaultDocument +} + +public enum ProviderTransferStatus: String, Codable, CaseIterable, Sendable { + case queued + case preparing + case transferring + case verifying + case completed + case failed + case cancelled + case interrupted + + public var isActive: Bool { + switch self { + case .queued, .preparing, .transferring, .verifying: true + case .completed, .failed, .cancelled, .interrupted: false + } + } + + public var canRetry: Bool { + switch self { + case .failed, .cancelled, .interrupted: true + default: false + } + } +} + +public struct ProviderTransferRecord: Identifiable, Hashable, Codable, Sendable { + public var id: UUID + public var providerID: ProviderID + public var providerKind: CloudProviderKind + public var source: ProviderTransferSource + public var sourceReference: String + public var stagedLocalURL: URL? + public var fileName: String + public var contentType: String? + public var purpose: String? + public var status: ProviderTransferStatus + public var completedBytes: Int64 + public var totalBytes: Int64? + public var retryCount: Int + public var providerObjectID: String? + public var createdAt: Date + public var updatedAt: Date + public var completedAt: Date? + public var lastError: String? + + public init( + id: UUID = UUID(), + providerID: ProviderID, + providerKind: CloudProviderKind, + source: ProviderTransferSource, + sourceReference: String, + stagedLocalURL: URL? = nil, + fileName: String, + contentType: String? = nil, + purpose: String? = nil, + status: ProviderTransferStatus = .queued, + completedBytes: Int64 = 0, + totalBytes: Int64? = nil, + retryCount: Int = 0, + providerObjectID: String? = nil, + createdAt: Date = Date(), + updatedAt: Date = Date(), + completedAt: Date? = nil, + lastError: String? = nil + ) { + self.id = id + self.providerID = providerID + self.providerKind = providerKind + self.source = source + self.sourceReference = sourceReference + self.stagedLocalURL = stagedLocalURL + self.fileName = fileName + self.contentType = contentType + self.purpose = purpose + self.status = status + self.completedBytes = completedBytes + self.totalBytes = totalBytes + self.retryCount = retryCount + self.providerObjectID = providerObjectID + self.createdAt = createdAt + self.updatedAt = updatedAt + self.completedAt = completedAt + self.lastError = lastError + } + + public var progressFraction: Double? { + guard let totalBytes, totalBytes > 0 else { return nil } + return min(1, max(0, Double(completedBytes) / Double(totalBytes))) + } +} + +public protocol ProviderTransferRepository: Sendable { + func listProviderTransfers(providerID: ProviderID?) async throws -> [ProviderTransferRecord] + func upsertProviderTransfer(_ transfer: ProviderTransferRecord) async throws + func deleteProviderTransfer(id: UUID) async throws + func markActiveProviderTransfersInterrupted(at date: Date) async throws +} + +// MARK: - Hosted-tool consent + +public struct HostedToolApprovalDescriptor: Identifiable, Hashable, Codable, Sendable { + public var id: String { + ([providerToolName, environment] + networkDestinations).joined(separator: "::") + } + public var providerToolName: String + public var displayName: String + public var environment: String + public var dataLeavingDevice: [String] + public var sideEffects: [String] + public var networkDestinations: [String] + public var retentionNotice: String + + public init( + providerToolName: String, + displayName: String, + environment: String, + dataLeavingDevice: [String], + sideEffects: [String], + networkDestinations: [String], + retentionNotice: String + ) { + self.providerToolName = providerToolName + self.displayName = displayName + self.environment = environment + self.dataLeavingDevice = dataLeavingDevice + self.sideEffects = sideEffects + self.networkDestinations = networkDestinations + self.retentionNotice = retentionNotice + } +} + +public struct HostedToolApprovalRequest: Identifiable, Hashable, Codable, Sendable { + public var id: UUID + public var providerID: ProviderID + public var providerName: String + public var modelID: ModelID + public var descriptors: [HostedToolApprovalDescriptor] + public var createdAt: Date + + public init( + id: UUID = UUID(), + providerID: ProviderID, + providerName: String, + modelID: ModelID, + descriptors: [HostedToolApprovalDescriptor], + createdAt: Date = Date() + ) { + self.id = id + self.providerID = providerID + self.providerName = providerName + self.modelID = modelID + self.descriptors = descriptors + self.createdAt = createdAt + } +} + +public extension ChatRequest { + func hostedToolApprovalDescriptors(providerName: String) -> [HostedToolApprovalDescriptor] { + let generic = (hostedTools + (anthropicOptions?.hostedTools ?? [])) + .filter(\.requiresApproval) + .map { $0.approvalDescriptor(providerName: providerName) } + let openAI = (openAIResponseOptions?.hostedTools ?? []) + .filter(\.requiresApproval) + .map { $0.approvalDescriptor(providerName: providerName) } + return (generic + openAI).reduce(into: []) { result, descriptor in + if !result.contains(where: { $0.id == descriptor.id }) { result.append(descriptor) } + } + } +} + +public extension HostedToolConfiguration { + func approvalDescriptor(providerName: String) -> HostedToolApprovalDescriptor { + switch self { + case let .computerUse(width, height): + return .init( + providerToolName: "computer_use", + displayName: "Computer use", + environment: "\(providerName) hosted computer\(width.map { " (\($0)x\(height ?? 0))" } ?? "")", + dataLeavingDevice: ["Your instructions", "screenshots and interaction state returned by the hosted computer"], + sideEffects: ["Can click, type, navigate, and change data in the hosted environment"], + networkDestinations: [providerName], + retentionNotice: "The provider may retain tool inputs and outputs under its account and API retention policy." + ) + case let .remoteMCP(label, serverURL, _): + return .init( + providerToolName: "remote_mcp", + displayName: "Remote MCP - \(label)", + environment: "Third-party MCP server called by \(providerName)", + dataLeavingDevice: ["Relevant prompt and tool arguments", "Provider-generated tool context"], + sideEffects: ["The MCP server may read or change external data according to the selected tool"], + networkDestinations: [serverURL], + retentionNotice: "Both the model provider and MCP server may process or retain the request." + ) + case .textEditor: + return .init( + providerToolName: "text_editor", + displayName: "Hosted text editor", + environment: "\(providerName) hosted container", + dataLeavingDevice: ["Text and files supplied to the model"], + sideEffects: ["Can create and edit files inside the provider-hosted container"], + networkDestinations: [providerName], + retentionNotice: "Container files are processed under the provider's API retention policy." + ) + case .bash: + return .init( + providerToolName: "bash", + displayName: "Hosted shell", + environment: "\(providerName) hosted container", + dataLeavingDevice: ["Commands, prompt context, and files supplied to the model"], + sideEffects: ["Can execute commands and create, modify, or delete container files"], + networkDestinations: [providerName], + retentionNotice: "Commands and results are processed under the provider's API retention policy." + ) + default: + return .init( + providerToolName: approvalToolName, + displayName: approvalToolName.replacingOccurrences(of: "_", with: " ").capitalized, + environment: "\(providerName) hosted service", + dataLeavingDevice: ["Relevant prompt context and tool arguments"], + sideEffects: ["Provider-defined hosted tool execution"], + networkDestinations: [providerName], + retentionNotice: "Inputs and outputs are processed under the provider's API retention policy." + ) + } + } + + private var approvalToolName: String { + switch self { + case .webSearch: "web_search" + case .webFetch: "web_fetch" + case .fileSearch: "file_search" + case .codeInterpreter: "code_interpreter" + case .imageGeneration: "image_generation" + case .computerUse: "computer_use" + case .remoteMCP: "remote_mcp" + case .textEditor: "text_editor" + case .bash: "bash" + case .toolSearch: "tool_search" + } + } +} + +public extension OpenAIHostedToolRequest { + func approvalDescriptor(providerName: String) -> HostedToolApprovalDescriptor { + let hosted: HostedToolConfiguration = switch kind { + case .computerUse: .computerUse(displayWidth: nil, displayHeight: nil) + case .mcp: + .remoteMCP( + serverLabel: name ?? "MCP server", + serverURL: configuration?.objectValue?["server_url"]?.stringValue ?? "Configured MCP server", + requireApproval: configuration?.objectValue?["require_approval"]?.stringValue ?? "always" + ) + case .textEditor: .textEditor + case .bash: .bash + default: .toolSearch + } + var descriptor = hosted.approvalDescriptor(providerName: providerName) + descriptor.providerToolName = name ?? kind.rawValue + return descriptor + } +} + +// MARK: - CloudKit conflict review + +public enum CloudKitConflictEntity: String, Codable, CaseIterable, Sendable { + case conversation + case vaultDocument +} + +public enum CloudKitConflictResolution: String, Codable, CaseIterable, Sendable { + case unresolved + case keepDevice + case useICloud +} + +public struct CloudKitConflictRecord: Identifiable, Hashable, Codable, Sendable { + public var id: UUID + public var entity: CloudKitConflictEntity + public var entityID: UUID + public var title: String + public var deviceSummary: String + public var iCloudSummary: String + public var devicePayloadJSON: String + public var iCloudPayloadJSON: String + public var deviceUpdatedAt: Date + public var iCloudUpdatedAt: Date + public var resolution: CloudKitConflictResolution + public var detectedAt: Date + public var resolvedAt: Date? + + public init( + id: UUID = UUID(), + entity: CloudKitConflictEntity, + entityID: UUID, + title: String, + deviceSummary: String, + iCloudSummary: String, + devicePayloadJSON: String, + iCloudPayloadJSON: String, + deviceUpdatedAt: Date, + iCloudUpdatedAt: Date, + resolution: CloudKitConflictResolution = .unresolved, + detectedAt: Date = Date(), + resolvedAt: Date? = nil + ) { + self.id = id + self.entity = entity + self.entityID = entityID + self.title = title + self.deviceSummary = deviceSummary + self.iCloudSummary = iCloudSummary + self.devicePayloadJSON = devicePayloadJSON + self.iCloudPayloadJSON = iCloudPayloadJSON + self.deviceUpdatedAt = deviceUpdatedAt + self.iCloudUpdatedAt = iCloudUpdatedAt + self.resolution = resolution + self.detectedAt = detectedAt + self.resolvedAt = resolvedAt + } +} + +public protocol CloudKitConflictRepository: Sendable { + func listCloudKitConflicts(unresolvedOnly: Bool) async throws -> [CloudKitConflictRecord] + func upsertCloudKitConflict(_ conflict: CloudKitConflictRecord) async throws + func resolveCloudKitConflict(id: UUID, resolution: CloudKitConflictResolution, at date: Date) async throws +} + +// MARK: - OpenRouter spend reconciliation + +public enum OpenRouterSpendWindow: String, Codable, CaseIterable, Sendable { + case day + case week + case month + case all + + public func startDate(relativeTo date: Date = Date()) -> Date? { + let seconds: TimeInterval? = switch self { + case .day: 86_400 + case .week: 604_800 + case .month: 2_592_000 + case .all: nil + } + return seconds.map { date.addingTimeInterval(-$0) } + } +} + +public struct OpenRouterSpendProviderBreakdown: Identifiable, Hashable, Codable, Sendable { + public var id: String { providerName } + public var providerName: String + public var runCount: Int + public var reportedCostCredits: Double + public var upstreamCostCredits: Double + + public init(providerName: String, runCount: Int, reportedCostCredits: Double, upstreamCostCredits: Double) { + self.providerName = providerName + self.runCount = runCount + self.reportedCostCredits = reportedCostCredits + self.upstreamCostCredits = upstreamCostCredits + } +} + +public struct OpenRouterSpendReport: Hashable, Codable, Sendable { + public var window: OpenRouterSpendWindow + public var generatedAt: Date + public var runCount: Int + public var reportedCostRunCount: Int + public var missingCostRunCount: Int + public var reportedCostCredits: Double + public var upstreamCostCredits: Double + public var promptTokens: Int + public var completionTokens: Int + public var webSearchRunCount: Int + public var byUpstreamProvider: [OpenRouterSpendProviderBreakdown] + + public init( + window: OpenRouterSpendWindow, + generatedAt: Date = Date(), + runCount: Int = 0, + reportedCostRunCount: Int = 0, + missingCostRunCount: Int = 0, + reportedCostCredits: Double = 0, + upstreamCostCredits: Double = 0, + promptTokens: Int = 0, + completionTokens: Int = 0, + webSearchRunCount: Int = 0, + byUpstreamProvider: [OpenRouterSpendProviderBreakdown] = [] + ) { + self.window = window + self.generatedAt = generatedAt + self.runCount = runCount + self.reportedCostRunCount = reportedCostRunCount + self.missingCostRunCount = missingCostRunCount + self.reportedCostCredits = reportedCostCredits + self.upstreamCostCredits = upstreamCostCredits + self.promptTokens = promptTokens + self.completionTokens = completionTokens + self.webSearchRunCount = webSearchRunCount + self.byUpstreamProvider = byUpstreamProvider + } +} + +public protocol CloudSpendRepository: Sendable { + func openRouterSpendReport(window: OpenRouterSpendWindow, now: Date) async throws -> OpenRouterSpendReport +} diff --git a/Sources/PinesCoreTestRunner/main.swift b/Sources/PinesCoreTestRunner/main.swift index 3c9ecec..21c797d 100644 --- a/Sources/PinesCoreTestRunner/main.swift +++ b/Sources/PinesCoreTestRunner/main.swift @@ -571,7 +571,9 @@ struct PinesCoreTestRunner { try expect(sql.contains("ALTER TABLE turboquant_profile_evidence ADD COLUMN speculative_auto_disable_json"), "missing speculative auto-disable column") try expect(sql.contains("CREATE TABLE IF NOT EXISTS cloud_model_catalog_snapshots"), "missing cloud model catalog snapshot table") try expect(sql.contains("expires_at REAL NOT NULL"), "cloud model catalog snapshots must expire") - try expectEqual(PinesDatabaseSchema.currentVersion, 27) + try expect(sql.contains("CREATE TABLE IF NOT EXISTS provider_transfers"), "missing durable provider transfer table") + try expect(sql.contains("CREATE TABLE IF NOT EXISTS cloudkit_conflicts"), "missing CloudKit conflict table") + try expectEqual(PinesDatabaseSchema.currentVersion, 28) let config = LocalStoreConfiguration(iCloudSyncEnabled: true) try expect(config.iCloudSyncEnabled, "iCloud should be enabled") diff --git a/Tests/PinesCoreTests/CoreContractTests.swift b/Tests/PinesCoreTests/CoreContractTests.swift index ec52cc0..4961032 100644 --- a/Tests/PinesCoreTests/CoreContractTests.swift +++ b/Tests/PinesCoreTests/CoreContractTests.swift @@ -2867,7 +2867,7 @@ struct CoreContractTests { @Test func openAIParityMigrationAddsTablesAndRunProvenance() throws { - #expect(PinesDatabaseSchema.currentVersion == 27) + #expect(PinesDatabaseSchema.currentVersion == 28) let openAIMigration = try #require(PinesDatabaseSchema.migrations.first { $0.version == 14 }) let genericProviderMigration = try #require( PinesDatabaseSchema.migrations.first { $0.version == 15 }) @@ -2875,6 +2875,8 @@ struct CoreContractTests { PinesDatabaseSchema.migrations.first { $0.version == 16 }) let runtimeMetadataMigration = try #require( PinesDatabaseSchema.migrations.first { $0.version == 17 }) + let productionUXMigration = try #require( + PinesDatabaseSchema.migrations.first { $0.version == 28 }) let sql = openAIMigration.sql.joined(separator: "\n") for table in [ @@ -2918,6 +2920,10 @@ struct CoreContractTests { ] { #expect(genericSQL.contains("CREATE TABLE IF NOT EXISTS \(table)")) } + + let productionUXSQL = productionUXMigration.sql.joined(separator: "\n") + #expect(productionUXSQL.contains("CREATE TABLE IF NOT EXISTS provider_transfers")) + #expect(productionUXSQL.contains("CREATE TABLE IF NOT EXISTS cloudkit_conflicts")) #expect(genericSQL.contains("credential_keychain_account")) #expect(!genericSQL.contains("client_secret_keychain_account")) diff --git a/Tests/PinesCoreTests/ProductionUXTypesTests.swift b/Tests/PinesCoreTests/ProductionUXTypesTests.swift new file mode 100644 index 0000000..a3b5922 --- /dev/null +++ b/Tests/PinesCoreTests/ProductionUXTypesTests.swift @@ -0,0 +1,62 @@ +import Foundation +import PinesCore +import Testing + +@Suite("Production UX contracts") +struct ProductionUXTypesTests { + @Test + func hostedToolApprovalExplainsEnvironmentEgressAndSideEffects() { + let request = ChatRequest( + modelID: ModelID(rawValue: "provider/model"), + messages: [], + hostedTools: [ + .bash, + .webSearch, + .remoteMCP( + serverLabel: "Issue tracker", + serverURL: "https://mcp.example.test", + requireApproval: "always" + ), + ], + openAIResponseOptions: OpenAIResponseRequestOptions( + hostedTools: [ + OpenAIHostedToolRequest(kind: .textEditor), + ] + ) + ) + + let descriptors = request.hostedToolApprovalDescriptors(providerName: "Example Cloud") + #expect(descriptors.count == 3) + #expect(descriptors.contains { $0.providerToolName == "bash" && $0.environment.contains("hosted container") }) + #expect(descriptors.contains { $0.providerToolName == "remote_mcp" && $0.networkDestinations == ["https://mcp.example.test"] }) + #expect(descriptors.allSatisfy { !$0.dataLeavingDevice.isEmpty && !$0.sideEffects.isEmpty && !$0.retentionNotice.isEmpty }) + #expect(!descriptors.contains { $0.providerToolName == "web_search" }) + } + + @Test + func transferProgressIsBoundedAndRetryStateIsExplicit() { + var transfer = ProviderTransferRecord( + providerID: ProviderID(rawValue: "provider"), + providerKind: .openAI, + source: .localFile, + sourceReference: "file.bin", + fileName: "file.bin", + status: .failed, + completedBytes: 1_500, + totalBytes: 1_000 + ) + #expect(transfer.progressFraction == 1) + #expect(transfer.status.canRetry) + transfer.completedBytes = -100 + #expect(transfer.progressFraction == 0) + } + + @Test + func spendWindowsHaveStableBoundaries() { + let now = Date(timeIntervalSince1970: 2_000_000) + #expect(OpenRouterSpendWindow.day.startDate(relativeTo: now) == now.addingTimeInterval(-86_400)) + #expect(OpenRouterSpendWindow.week.startDate(relativeTo: now) == now.addingTimeInterval(-604_800)) + #expect(OpenRouterSpendWindow.month.startDate(relativeTo: now) == now.addingTimeInterval(-2_592_000)) + #expect(OpenRouterSpendWindow.all.startDate(relativeTo: now) == nil) + } +} diff --git a/docs/STATUS.md b/docs/STATUS.md index 0e7e2f8..1210faf 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -33,6 +33,11 @@ This repository is a working foundation for `pines`, not a signed App Store dist - Anthropic provider lifecycle workflows for Files, generated file download/import, prompt cache metrics, citations, thinking preservation, Message Batches, token counting, hosted tool metadata, and model capability rows. - Gemini provider lifecycle workflows for Files, context caches, token counting, Deep Research, Live sessions, generated media artifacts, batches, URL context metadata, Google Search grounding, and model capability rows. - Chat provenance surfaces for provider citations, hosted tool timelines, provider file references, request/message IDs, cache metrics, thinking mode, generated artifacts, and privacy-minimized OpenRouter route/fallback/token/cost receipts. +- Durable OpenAI, Anthropic, and Gemini provider-transfer queue with real upload-byte progress, staged local sources, relaunch interruption recovery, retry, cancellation, cloud-copy verification, and explicit local/provider storage separation. +- Pre-execution provider-hosted tool consent for approval-gated code, shell/editor, remote MCP, and computer-use requests, including provider environment, data egress, side effects, network destinations, retention wording, denial, and consent audit events. +- Persisted CloudKit conversation and Vault-document conflicts with side-by-side device/iCloud summaries and explicit keep-device or use-iCloud resolution instead of last-writer-wins overwrite. +- Always-visible local-model compatibility explanations covering exact compatibility pair, evidence level/date, device class, runtime/backend, admitted context, fallback contract, warnings, and the next evidence action. +- Settings-level OpenRouter spend inspection for 24 hours, 7 days, 30 days, or all history, aggregated only from persisted provider receipts with coverage/missing-cost disclosure, tokens, web-search runs, and upstream-provider breakdown. - Built-in calculator, time/date, attachment read, vault search/read, conversation search, Brave Search BYOK, bounded public-network-only web fetch, and policy-gated WKWebView browser observe/action tools. - Vault file/PDF/image import pipeline with scoped file types, bounded source size/text extraction, OCR, chunking, and embedding invocation. - TurboQuant runtime profile defaults, requested/active backend diagnostics, Metal codec and compressed-attention availability diagnostics, compressed vault embedding storage, approximate vector search, and FP16 rerank path. @@ -62,7 +67,7 @@ This repository is a working foundation for `pines`, not a signed App Store dist ## Not Complete - Real-device TurboQuant acceptance on the A16 through A19 Pro hardware matrix, including stable multi-repeat real-model coverage and at least one imported model/device/mode evidence tuple before any `Verified` or `Certified` product claim. -- Production UX hardening for provider-hosted transfer progress/retry/cancellation, richer hosted-tool approvals, persisted CloudKit conflict resolution, detailed model compatibility messaging, and OpenRouter endpoint-level availability plus aggregate spend reporting. +- OpenRouter endpoint-level provider availability, provider-specific feature variance/rate limits/prices, cached/reasoning/media usage detail, and optional generation-endpoint cost reconciliation remain incomplete. Aggregate spend deliberately uses only persisted provider-reported receipt fields and does not estimate missing cost. - Signed App Store archive/export, TestFlight/App Store upload automation, and final App Store Connect privacy review for the submitted binary. - The platform-aware, warning-free `mlx-swift` build-tool plugin, Apple-mobile JIT compatibility fix, and SwiftPM 6.2 manifest compatibility are pinned at `bcf93af23f11428f6f01efb0bb4b9020cd2eb383`; cold iOS device and simulator builds must remain warning-free as part of release validation. - Remaining monolith candidates are semantic rather than mechanical: `PinesAppModel` still owns high-level orchestration, `SettingsDetailView` owns the full settings editor, and `ModelsViewComponents` owns model list/detail presentation. Split these further only alongside focused feature changes. diff --git a/docs/cloud-providers/anthropic.md b/docs/cloud-providers/anthropic.md index 2b5becd..8ab9d09 100644 --- a/docs/cloud-providers/anthropic.md +++ b/docs/cloud-providers/anthropic.md @@ -1,6 +1,6 @@ # Anthropic Provider Status And Gaps -Last verified: 2026-05-19. +Last verified: 2026-07-13. Primary sources: @@ -31,18 +31,19 @@ Primary sources: - Token counting for Anthropic chat/batch preflight where enabled. - Stream parser coverage for cache usage fields, request/message IDs, signed thinking, citations, `server_tool_use`, hosted tool results, web fetch/search results, code execution outputs, generated files, and Anthropic error events. - Settings capability rows, shared lifecycle dashboard previews, Anthropic file manager, batch creator, chat quick settings, provider citation panel, hosted-tool timeline rows, and run provenance metadata. +- Durable Files API transfers with real upload progress, staged-source recovery, retry/cancellation, and provider-copy verification. +- Preflight approval sheets for approval-gated hosted tools that disclose the Anthropic-hosted environment, data leaving the device, side effects, destinations, and retention before execution. ## Remaining High-Value Gaps -### 1. Hosted tool approval depth +### 1. Hosted tool policy defaults and incremental arguments -Pines maps and records Anthropic hosted tools, but production approval UX still needs more detail for code execution, remote MCP, text editor/bash, and web fetch. +Pines maps and records Anthropic hosted tools and presents detailed one-time approval before code execution, remote MCP, text editor/bash, or other approval-gated execution. Per-tool saved defaults and argument-level reapproval during fine-grained streaming remain incomplete. Needed work: -- Show exact provider-hosted environment labels before execution. -- Preview external data transfer, expected side effects, allowed domains, and generated-file retention. - Add richer denial/retry paths and per-tool policy defaults. +- Revisit approval when fine-grained streaming materially changes tool arguments after initial consent. ### 2. Source highlighting and Vault citation alignment @@ -83,13 +84,12 @@ Needed work: - Map Vault snippets into Anthropic search-result blocks only after user approval for cloud context. - Validate final structured output separately from normal markdown streaming. -### 6. Production file-transfer hardening +### 6. Provider file cleanup and suspended-transfer limits -Anthropic files are now represented as provider-hosted records, but upload/download operations still need production-grade progress and recovery. +Anthropic files are represented as provider-hosted records, and foreground uploads now have durable progress, retry, cancellation, relaunch recovery, and retained staged sources. Pines does not claim indefinite background continuation after iOS suspension. Needed work: -- Durable progress, retry, cancellation, and background-safe transfer state. - Orphan cleanup for generated files and failed imports. - Clearer retention and billing labels in every file picker and provider storage row. @@ -97,7 +97,7 @@ Needed work: 1. Harden hosted tool approval sheets and generated-file import UX. 2. Add source highlighting and Vault citation alignment. -3. Add production transfer progress/retry/cancellation. +3. Add orphan cleanup and richer per-resource retention/billing labels. 4. Complete fine-grained tool streaming and parallel tool controls. 5. Add provider-neutral structured outputs and custom RAG search-result blocks. 6. Revisit computer use only after a dedicated safety design is ready. diff --git a/docs/cloud-providers/openai-roadmap.md b/docs/cloud-providers/openai-roadmap.md index 5b327e2..75aa27f 100644 --- a/docs/cloud-providers/openai-roadmap.md +++ b/docs/cloud-providers/openai-roadmap.md @@ -128,7 +128,7 @@ Todos: - Add retention, billing, and provider-storage warnings. - Add cleanup UX for orphaned hosted files. - Build themed file/vector store management screens and source chips for File Search results. -- Add durable upload progress, retry, cancellation, and background-safe transfer state. +- [x] Add durable upload progress, retry, cancellation, relaunch recovery, retained staged sources, and provider-copy verification. In-flight continuation after iOS suspension is not claimed. Possible hiccups: @@ -153,8 +153,8 @@ Todos: - Remote MCP: store server label, URL, auth, approval policy, allowlist, and data disclosure. - Tool Search: register tool catalogs and parse dynamic tool loading events. - Add per-tool availability checks from model capabilities. -- Add audit events for every hosted tool call. -- Add approval sheets and timeline rows for each hosted tool class. +- [x] Add consent audit events for approval-gated hosted-tool requests. +- [x] Add approval sheets that disclose provider environment, egress, side effects, destinations, and retention, plus existing hosted-tool timeline rows. Possible hiccups: diff --git a/docs/cloud-providers/openrouter-roadmap.md b/docs/cloud-providers/openrouter-roadmap.md index 9693737..50429c5 100644 --- a/docs/cloud-providers/openrouter-roadmap.md +++ b/docs/cloud-providers/openrouter-roadmap.md @@ -194,9 +194,11 @@ Todos: - [ ] Parse reasoning, cached, and media usage details. - [x] Parse server web-search request usage and show it in the run receipt/timeline. - [x] Store upstream provider, model, safe fallback route, and cost in metadata after the terminal stream chunk. -- Add per-thread and per-provider spend summaries if product wants it. +- [x] Add receipt-backed aggregate spend windows with upstream-provider breakdown and explicit missing-cost coverage. +- [ ] Add per-thread spend summaries if product wants them. - [x] Add a progressively disclosed chat receipt for routed provider/cost metadata. -- [ ] Add aggregate cost inspection and optional generation-endpoint reconciliation. +- [x] Add aggregate cost inspection without estimating missing provider data. +- [ ] Add optional generation-endpoint reconciliation. Possible hiccups: diff --git a/docs/cloud-providers/openrouter.md b/docs/cloud-providers/openrouter.md index 772b3ef..b8a21e1 100644 --- a/docs/cloud-providers/openrouter.md +++ b/docs/cloud-providers/openrouter.md @@ -101,7 +101,7 @@ Implementation notes: ### 5. Detailed usage accounting and aggregate spend -Pines parses and displays prompt/completion/total tokens, reported cost, upstream inference cost, BYOK state, and server web-search request count per run. Cached/reasoning/media detail, thread/provider rollups, and reconciliation through the generation accounting endpoint remain incomplete. +Pines parses and displays prompt/completion/total tokens, reported cost, upstream inference cost, BYOK state, and server web-search request count per run. Settings also aggregates persisted receipts over 24 hours, 7 days, 30 days, or all history, with cost-coverage disclosure and upstream-provider breakdown. Cached/reasoning/media detail, per-thread rollups, and reconciliation through the generation accounting endpoint remain incomplete. Value: @@ -110,7 +110,7 @@ Value: Implementation notes: - Add cached, reasoning, and media usage fields as product surfaces consume them. -- Add optional thread/provider spend summaries and generation-endpoint reconciliation without turning Pines into an account-management client. +- Add optional per-thread spend summaries and generation-endpoint reconciliation without turning Pines into an account-management client. Never estimate cost when the provider omitted it. ### 6. Prompt caching and sticky routing @@ -184,7 +184,7 @@ Implementation notes: 2. OpenRouter reasoning controls and detailed reasoning/cache usage. 3. Response healing for eligible non-streaming structured requests. 4. Max-price/quantization and scoped routing overrides. -5. Aggregate/reconciled spend reporting. +5. Generation-endpoint spend reconciliation and per-thread summaries. 6. Prompt caching/transforms. 7. Additional output modalities and upstream BYOK routing. diff --git a/docs/cloud-providers/ui-roadmap.md b/docs/cloud-providers/ui-roadmap.md index 8386cab..8cf78c1 100644 --- a/docs/cloud-providers/ui-roadmap.md +++ b/docs/cloud-providers/ui-roadmap.md @@ -1,6 +1,6 @@ # Cloud Provider UI Roadmap -Last verified: 2026-05-19. +Last verified: 2026-07-13. This document defines the shared UI work required by the provider parity roadmaps. Every new provider feature must follow the Pines design system in [DESIGN_SYSTEM.md](../DESIGN_SYSTEM.md): no feature-local palettes, no hard-coded light/dark colors, dense operational layouts, shared list/panel/card primitives, semantic colors, and theme support through `\.pinesTheme`. @@ -10,14 +10,16 @@ Add advanced cloud-provider capabilities without turning Pines into a dashboard ## Implementation Status -Updated 2026-05-19: +Updated 2026-07-13: - Pines has a shared provider lifecycle dashboard with previews for files, artifacts, caches/vector stores, batches, research runs, live sessions, and model capabilities. - Vault/provider storage views show OpenAI, Anthropic, and Gemini provider-hosted files separately from local Vault items, with refresh/delete/export/import paths where supported. - Anthropic now has Settings capability rows, prompt/thinking quick settings, file management, batch create/count/refresh/cancel/import flows, citations/source panels, hosted-tool timeline rows, and run provenance pills. - Gemini now has file/media management, context cache management, generated media workspace, Deep Research workspace, realtime session records, batch rows, and capability previews. - OpenAI now has file/vector-store management, artifact previews, batch rows, Deep Research workspace, realtime session records, and media/audio artifact workflows. -- Remaining UI work is production hardening: durable upload progress, retry/cancellation, richer provider-hosted approval sheets, media viewers, realtime controls, source highlighting, compact-width polish, and provider-specific cost/retention detail. +- OpenAI, Anthropic, and Gemini file uploads now use a persisted transfer queue with real byte progress, retry/cancellation, relaunch recovery, retained staged sources, and provider-copy verification. +- Approval-gated hosted tools now pause before the provider request and disclose environment, data egress, side effects, network destinations, and retention before one-time approval or denial; decisions are audited. +- Remaining UI work includes media viewers, realtime controls, source highlighting, compact-width preview coverage, richer per-resource retention/billing detail, and dedicated computer-use action review. ## Design Principles @@ -82,7 +84,7 @@ Production requirements: - Provider-hosted files must never appear indistinguishable from local Vault records. - Deleting local Vault content must not imply deleting provider-hosted copies, and vice versa. -- Long uploads need progress, retry, cancellation, and background-safe state. +- Long uploads have progress, retry, cancellation, and durable relaunch state. Pines does not claim that an in-flight foreground URL-session upload continues indefinitely after iOS suspends the app. ### 4. Tool Approval And Hosted Tool Timeline diff --git a/docs/feature_additions.md b/docs/feature_additions.md index d4cb42f..a86b377 100644 --- a/docs/feature_additions.md +++ b/docs/feature_additions.md @@ -8,6 +8,7 @@ This document captures adjacent, high-value feature additions for Pines. The emp - Provider-hosted storage managers for OpenAI Files/vector stores, Anthropic Files, and Gemini Files/context caches, with local Vault separation. - Provider provenance in chat for citations, hosted tool events, file references, request/message IDs, cache metrics, thinking mode, and generated artifacts. - Long-running provider job records for OpenAI/Gemini Deep Research, OpenAI/Anthropic/Gemini batches, OpenAI/Gemini realtime/live sessions, and generated media workflows. +- Production UX state for durable provider transfers, approval-gated hosted tools, user-resolved CloudKit conflicts, exact compatibility explanations, and receipt-backed OpenRouter spend summaries. ## Priority Recommendations diff --git a/scripts/ci/run-xcode-validation.sh b/scripts/ci/run-xcode-validation.sh index c225ce5..817e5c1 100755 --- a/scripts/ci/run-xcode-validation.sh +++ b/scripts/ci/run-xcode-validation.sh @@ -140,6 +140,29 @@ first_available_iphone_simulator() { xcrun simctl list devices available | awk -F '[()]' '/iPhone/ { print $2; exit }' } +create_ephemeral_iphone_simulator() { + local timeout_seconds="${PINES_SIMULATOR_OPERATION_TIMEOUT_SECONDS:-180}" + local device_type="${PINES_SIMULATOR_DEVICE_TYPE:-}" + local runtime="${PINES_SIMULATOR_RUNTIME:-}" + + if [ -z "$device_type" ]; then + device_type="$(xcrun simctl list devicetypes | awk -F '[()]' '/iPhone/ { print $2; exit }')" + fi + if [ -z "$runtime" ]; then + runtime="$(xcrun simctl list runtimes available | awk '/^iOS / { value=$NF } END { print value }')" + fi + if [ -z "$device_type" ] || [ -z "$runtime" ]; then + echo "::error::Unable to select an available iPhone device type and iOS runtime." >&2 + xcrun simctl list devicetypes || true + xcrun simctl list runtimes available || true + return 1 + fi + + echo "Creating an ephemeral iPhone simulator for this validation run..." >&2 + run_with_timeout "$timeout_seconds" \ + xcrun simctl create "Pines-CI-$$-$(date +%s)" "$device_type" "$runtime" +} + simulator_required() { local required="${PINES_REQUIRE_SIMULATOR_TEST_RUN:-}" if [ -z "$required" ] && [ "${CI:-}" = "true" ]; then @@ -155,7 +178,11 @@ build_tests() { local destination='generic/platform=iOS Simulator' local simulator_id - simulator_id="$(first_available_iphone_simulator || true)" + if [ "${CI:-}" = "true" ]; then + simulator_id="$(create_ephemeral_iphone_simulator || true)" + else + simulator_id="$(first_available_iphone_simulator || true)" + fi if [ -n "$simulator_id" ]; then destination="id=$simulator_id" printf '%s\n' "$simulator_id" > "$simulator_id_file" @@ -202,28 +229,39 @@ run_tests() { return 0 fi + trap cleanup_test_simulator EXIT INT TERM : > "$log_dir/xcodebuild-test-run.log" + prepare_test_simulator "$simulator_id" run_xcode_test_phase "$simulator_id" "unit tests" -only-testing:PinesTests run_xcode_test_phase "$simulator_id" "UI smoke tests" -only-testing:PinesUITests + cleanup_test_simulator + trap - EXIT INT TERM } run_xcode_test_phase() { local simulator_id="$1" local label="$2" local timeout_seconds="${PINES_XCODE_TEST_TIMEOUT_SECONDS:-}" + local attempts="${PINES_XCODE_TEST_ATTEMPTS:-}" shift 2 if [ -z "$timeout_seconds" ]; then if [ "${CI:-}" = "true" ]; then - timeout_seconds=600 + timeout_seconds=480 else timeout_seconds=0 fi fi + if [ -z "$attempts" ]; then + if [ "${CI:-}" = "true" ]; then + attempts=1 + else + attempts=2 + fi + fi local attempt - for attempt in 1 2; do - prepare_test_simulator "$simulator_id" + for ((attempt = 1; attempt <= attempts; attempt++)); do echo "Running iOS runtime smoke tests ($label, attempt $attempt)..." set -o pipefail if run_with_timeout "$timeout_seconds" \ @@ -241,12 +279,13 @@ run_xcode_test_phase() { fi local status="${PIPESTATUS[0]}" - if [ "$attempt" -eq 2 ]; then - echo "::error::$label failed after a fresh simulator retry (status $status)." >&2 + if [ "$attempt" -eq "$attempts" ]; then + echo "::error::$label failed after $attempt attempt(s) (status $status)." >&2 return "$status" fi - echo "::warning::$label did not complete (status $status); retrying with a freshly booted simulator." >&2 + echo "::warning::$label did not complete (status $status); restarting the simulator before retry." >&2 + prepare_test_simulator "$simulator_id" done } @@ -258,10 +297,28 @@ prepare_test_simulator() { fi echo "Preparing simulator $simulator_id for runtime tests..." - xcrun simctl shutdown "$simulator_id" >/dev/null 2>&1 || true - xcrun simctl erase "$simulator_id" - xcrun simctl boot "$simulator_id" - xcrun simctl bootstatus "$simulator_id" -b + local timeout_seconds="${PINES_SIMULATOR_OPERATION_TIMEOUT_SECONDS:-180}" + run_with_timeout "$timeout_seconds" xcrun simctl shutdown "$simulator_id" >/dev/null 2>&1 || true + run_with_timeout "$timeout_seconds" xcrun simctl erase "$simulator_id" + run_with_timeout "$timeout_seconds" xcrun simctl boot "$simulator_id" + if ! run_with_timeout "$timeout_seconds" xcrun simctl bootstatus "$simulator_id" -b; then + echo "::error::Simulator $simulator_id did not finish booting within ${timeout_seconds}s." >&2 + xcrun simctl diagnose -b --no-archive || true + return 1 + fi +} + +cleanup_test_simulator() { + [ "${CI:-}" = "true" ] || return 0 + [ -f "$simulator_id_file" ] || return 0 + local simulator_id + simulator_id="$(cat "$simulator_id_file")" + [ -n "$simulator_id" ] || return 0 + local timeout_seconds="${PINES_SIMULATOR_OPERATION_TIMEOUT_SECONDS:-180}" + echo "Cleaning up validation simulator $simulator_id..." + run_with_timeout "$timeout_seconds" xcrun simctl shutdown "$simulator_id" >/dev/null 2>&1 || true + run_with_timeout "$timeout_seconds" xcrun simctl delete "$simulator_id" >/dev/null 2>&1 || true + rm -f "$simulator_id_file" } run_with_timeout() { @@ -281,6 +338,7 @@ run_with_timeout() { } finalize_validation() { + cleanup_test_simulator require_snapshot echo "Restoring generated Xcode project..." "${xcodegen[@]}" generate From 35091fa5608ad5148b1e1225572c08b9fa83c01d Mon Sep 17 00:00:00 2001 From: RNT56 Date: Mon, 13 Jul 2026 15:38:39 +0200 Subject: [PATCH 2/2] Calibrate bounded UI test timeout for CI Allow 12 minutes per Xcode test phase after the macOS runner completed all unit tests and six of nine UI tests before the previous eight-minute bound. Simulator lifecycle operations remain independently bounded at three minutes and the overall job remains capped at 60 minutes. --- .github/workflows/ci.yml | 2 +- scripts/ci/run-xcode-validation.sh | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d1d75ff..8eb19bf 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -204,7 +204,7 @@ jobs: timeout-minutes: 60 env: PINES_SIMULATOR_OPERATION_TIMEOUT_SECONDS: 180 - PINES_XCODE_TEST_TIMEOUT_SECONDS: 480 + PINES_XCODE_TEST_TIMEOUT_SECONDS: 720 PINES_XCODE_TEST_ATTEMPTS: 1 steps: diff --git a/scripts/ci/run-xcode-validation.sh b/scripts/ci/run-xcode-validation.sh index 817e5c1..9f51a0e 100755 --- a/scripts/ci/run-xcode-validation.sh +++ b/scripts/ci/run-xcode-validation.sh @@ -247,7 +247,7 @@ run_xcode_test_phase() { if [ -z "$timeout_seconds" ]; then if [ "${CI:-}" = "true" ]; then - timeout_seconds=480 + timeout_seconds=720 else timeout_seconds=0 fi