diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c988e43..8eb19bf 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: 720 + 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..9f51a0e 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=720 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