diff --git a/AGENTS.md b/AGENTS.md index 6baea0a1..4c745993 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -164,6 +164,18 @@ cargo check --workspace cargo test --workspace ``` +### Swift ↔ Rust Bridges — landed on `develop` (Slice 2) + +Each bridge `dlopen`s `libskilly_core_ffi.dylib` (built from `core/ffi`) when present and falls back to the existing Swift logic when absent — so the app keeps working with or without the Rust dylib. All wiring is additive + `// MARK: - Skilly`. + +| File | Lines | Purpose | +|------|-------|---------| +| `RustPolicyBridge.swift` | ~200 | Dynamic FFI loader for policy. `EntitlementManager.canStartTurn()` + `TrialTracker`/`UsageTracker` call Rust first, Swift fallback otherwise. | +| `RustSkillsBridge.swift` | ~220 | Dynamic FFI loader for skill prompt composition; falls back to `SkillPromptComposer`. | +| `RustRealtimeBridge.swift` | ~180 | Dynamic FFI loader for realtime replay/lifecycle; Swift fallback otherwise. | + +> ⚠ The new `leanring-buddy/*.swift` files auto-compile via the project's `PBXFileSystemSynchronizedRootGroup` (Xcode 16, objectVersion 77) — no `project.pbxproj` edits needed. Validate with an Xcode build (trial/active/capped/admin turn-start + Rust-absent fallback); agents cannot run `xcodebuild` (TCC). + ### Mobile SDK Bindings (`sdk/`) — landed on `develop` (Slice 3) UniFFI-generated iOS (Swift) and Android (Kotlin) bindings over `core/mobile-sdk`, plus sample consumers and packaging scripts. The `sdk/*/generated/**` files are machine-generated — **regenerate, never hand-edit** (`scripts/generate-mobile-sdk-bindings.sh`; output is byte-reproducible from the crate). diff --git a/docs/architecture/rust-dylib-packaging-strategy.md b/docs/architecture/rust-dylib-packaging-strategy.md new file mode 100644 index 00000000..de196c2d --- /dev/null +++ b/docs/architecture/rust-dylib-packaging-strategy.md @@ -0,0 +1,76 @@ +# Rust Dylib Packaging Strategy (macOS Shell) + +## Purpose +Define a deterministic strategy for building and loading `libskilly_core_ffi.dylib` in development and release workflows. + +## Current State +- Bridges dynamically try env-var paths first, then local workspace build outputs: + - `target/debug/libskilly_core_ffi.dylib` + - `target/release/libskilly_core_ffi.dylib` +- Fallback to Swift logic is intentional when dylib is unavailable. + +## Goals +1. Keep macOS app functional even when Rust library is missing. +2. Make Rust-enabled runs deterministic in Xcode and CI. +3. Avoid ad-hoc dylib path drift across developer machines. + +## Development Strategy + +### Local Build Command +From repo root: +```bash +cargo build -p skilly-core-ffi +``` + +### Xcode Scheme Environment +Prefer one canonical variable: +- `SKILLY_RUST_CORE_DYLIB_PATH=/absolute/path/to/target/debug/libskilly_core_ffi.dylib` + +Backward-compatible vars remain supported: +- `SKILLY_RUST_POLICY_DYLIB_PATH` +- `SKILLY_RUST_SKILLS_DYLIB_PATH` +- `SKILLY_RUST_REALTIME_DYLIB_PATH` + +### Expected Behavior +- If dylib exists at configured path: Rust bridge path is active. +- If dylib missing: bridge logs fallback and Swift path remains active. + +## CI Strategy +CI should always validate Rust build artifacts separately from Xcode runtime: +1. `cargo build -p skilly-core-ffi` +2. `cargo test -p skilly-core-ffi` +3. `cargo check --workspace` + +This guarantees ABI surfaces compile while keeping macOS GUI runtime tests outside terminal `xcodebuild`. + +## Release Strategy (Current) +For release builds, keep Swift fallback as hard safety net. +Do not block app release solely on Rust dylib packaging until runtime parity is fully proven. + +Implemented packaging automation: +1. `scripts/package-rust-ffi-dylib.sh` builds `skilly-core-ffi --release` and publishes host-specific tarballs in `dist/rust-ffi/`. +2. `.github/workflows/mobile-sdk-artifacts.yml` builds and uploads Rust FFI artifacts for macOS and Linux and attaches them to GitHub release assets. + +## Release Strategy (Target) +After parity is proven: +1. Add pre-release check that builds `skilly-core-ffi`. +2. Bundle dylib in app resources (or deterministic sidecar location). +3. Set runtime lookup path to bundled location first. +4. Keep fallback path behind runtime flag for rollback. + +## Proposed Build-Phase Hook (Future) +Add optional Xcode "Run Script" phase for debug builds: +1. Run `cargo build -p skilly-core-ffi`. +2. Copy dylib to derived-data deterministic location. +3. Export `SKILLY_RUST_CORE_DYLIB_PATH` in scheme. + +This is not mandatory yet; current env-var path approach is adequate during migration. + +## Risk Controls +1. Never remove Swift fallback while migration phases remain incomplete. +2. Keep bridge symbols additive to avoid breaking older dylibs in local caches. +3. Version ABI via `skilly_policy_ffi_version()` and add equivalent version entrypoints for new surfaces as needed. + +## Decision +Adopt env-var-first deterministic loading for development and maintain Swift fallback. +Use packaged Rust FFI artifacts as release sidecars while host-app runtime parity validation continues. diff --git a/docs/architecture/swift-rust-fallback-parity-harness.md b/docs/architecture/swift-rust-fallback-parity-harness.md new file mode 100644 index 00000000..ff02c280 --- /dev/null +++ b/docs/architecture/swift-rust-fallback-parity-harness.md @@ -0,0 +1,88 @@ +# Swift-Rust Fallback Parity Harness + +## Purpose +Define how we validate behavior parity when a Rust bridge is available versus when Swift fallback logic is used. + +This harness applies to: +- policy gating (`RustPolicyBridge`) +- skill prompt composition (`RustSkillsBridge`) +- realtime transition replay (`RustRealtimeBridge`) + +## Core Principle +For each bridge-backed decision path, we verify two lanes: +1. Rust lane: bridge loaded and Rust result used. +2. Swift lane: bridge unavailable and Swift fallback used. + +The expected outcome must match for equivalent inputs unless a migration ADR explicitly declares a behavior change. + +## Runtime Toggle Strategy +Bridge load behavior is controlled by environment variables and dylib availability. + +Recommended local toggles in Xcode scheme: +- Rust lane: + - set `SKILLY_RUST_CORE_DYLIB_PATH` to a valid `libskilly_core_ffi.dylib` +- Swift lane: + - unset all `SKILLY_RUST_*_DYLIB_PATH` vars (or point to a non-existent path) + +## Parity Scenarios + +### Policy Scenarios +1. Trial user under cap -> allowed. +2. Trial user exhausted -> blocked (`trialExhausted`). +3. Active user under cap -> allowed. +4. Active user over cap -> blocked (`capReached`). +5. Admin user over cap/expired -> allowed. +6. Canceled user with valid access under cap -> allowed. +7. Canceled user with valid access over cap -> blocked (`capReached`). + +### Skill Prompt Scenarios +1. Active skill with full vocabulary budget. +2. Active skill where vocabulary trimming applies. +3. Active skill stage with completed-stage history. +4. Missing current stage fallback behavior. +5. Pointing mode variants (`always`, `when-relevant`, `minimal`). + +### Realtime Transition Scenarios +1. Happy path turn: + - `turn_started -> audio_capture_committed -> audio_playback_started -> response_completed` +2. Error path: + - `turn_started -> audio_capture_committed -> session_error` +3. Reset path: + - `completed -> session_reset` +4. Invalid-order rejection: + - `turn_started -> response_completed` (without commit) + +## Verification Procedure +1. Run Rust fixture/unit checks: + - `cargo test --workspace` +2. Run shell smoke checks: + - `cargo run -p skilly-linux-shell -- --smoke` + - `cargo run -p skilly-windows-shell -- --smoke` +3. Run manual macOS parity in Xcode: + - lane A (Rust enabled): perform each scenario and capture observed outcomes. + - lane B (Rust disabled): repeat scenarios. +4. Compare: + - decision values + - block reasons + - prompt output text shape + - lifecycle phase progression + +## Evidence Capture +For each scenario, record: +- lane (`rust` or `swift`) +- input snapshot +- output snapshot +- parity result (`match` / `mismatch`) +- notes + +Store evidence in: +- `docs/architecture/runtime-validation-report-YYYY-MM-DD.md` generated via: + - `./scripts/create-runtime-validation-report.sh` +- or PR body under "Parity Evidence" when a dedicated report is not required. + +## Failure Policy +If a mismatch is found: +1. Treat it as a regression by default. +2. Add a focused fixture test reproducing mismatch. +3. Fix Rust or Swift path to restore parity. +4. If change is intentional, document it in an ADR and update expected fixtures. diff --git a/leanring-buddy/AdminAllowlist.swift b/leanring-buddy/AdminAllowlist.swift index f8b50074..d8a1ce93 100644 --- a/leanring-buddy/AdminAllowlist.swift +++ b/leanring-buddy/AdminAllowlist.swift @@ -40,7 +40,7 @@ enum AdminAllowlist { ) } - private static var allAdminWorkOSUserIDs: Set { + static var allConfiguredAdminWorkOSUserIDs: Set { adminWorkOSUserIDs.union(infoPlistAdminWorkOSUserIDs) } @@ -48,6 +48,6 @@ enum AdminAllowlist { @MainActor static var isCurrentUserAdmin: Bool { guard let userID = AuthManager.shared.currentUser?.id else { return false } - return allAdminWorkOSUserIDs.contains(userID) + return allConfiguredAdminWorkOSUserIDs.contains(userID) } } diff --git a/leanring-buddy/CompanionManager.swift b/leanring-buddy/CompanionManager.swift index 9f88828d..36ad903a 100644 --- a/leanring-buddy/CompanionManager.swift +++ b/leanring-buddy/CompanionManager.swift @@ -133,6 +133,9 @@ final class CompanionManager: ObservableObject { private var didReceiveAnyAudioChunkForCurrentTurn = false private var pendingToolCallIdForCurrentTurn: String? private var isAwaitingForcedSpokenFollowUp = false + private var rustRealtimeEventLog: [RustRealtimeBridge.RealtimeEventPayload] = [] + private var currentRustRealtimeTurnID: String? + private var latestRustRealtimePhaseName: String = "idle" // MARK: - Skilly — Live Tutor mode state private var isLiveTutorModeActive = false @@ -626,6 +629,7 @@ final class CompanionManager: ObservableObject { isWaitingForRealtimeAudioQueueDrain = false voiceState = .idle clearRealtimeResponseBubble() + resetRustRealtimeTracking() return } @@ -644,6 +648,7 @@ final class CompanionManager: ObservableObject { isWaitingForRealtimeAudioQueueDrain = false voiceState = .idle clearRealtimeResponseBubble() + resetRustRealtimeTracking() case .processing: // MARK: - Skilly — Debug logging (stripped in release) @@ -654,6 +659,7 @@ final class CompanionManager: ObservableObject { isWaitingForRealtimeAudioQueueDrain = false voiceState = .idle clearRealtimeResponseBubble() + resetRustRealtimeTracking() case .responding: // MARK: - Skilly — Debug logging (stripped in release) @@ -665,6 +671,7 @@ final class CompanionManager: ObservableObject { isWaitingForRealtimeAudioQueueDrain = false voiceState = .idle clearRealtimeResponseBubble() + resetRustRealtimeTracking() case .idle: break @@ -1036,6 +1043,70 @@ final class CompanionManager: ObservableObject { return CGPoint(x: globalX, y: globalY) } + // MARK: - Skilly — Rust realtime transition tracking + + private func beginRustRealtimeTurnTracking(turnPrefix: String) { + rustRealtimeEventLog = [] + let newTurnID = "\(turnPrefix)-\(UUID().uuidString)" + currentRustRealtimeTurnID = newTurnID + appendRustRealtimeEvent( + type: .turnStarted, + turnID: newTurnID + ) + } + + private func resetRustRealtimeTracking() { + if !rustRealtimeEventLog.isEmpty { + appendRustRealtimeEvent(type: .sessionReset, turnID: nil, message: nil) + } + rustRealtimeEventLog = [] + currentRustRealtimeTurnID = nil + latestRustRealtimePhaseName = "idle" + } + + private func appendRustRealtimeEvent( + type: RustRealtimeBridge.RealtimeEventType, + turnID: String? = nil, + message: String? = nil + ) { + let resolvedTurnID = turnID ?? currentRustRealtimeTurnID + if type != .sessionReset && resolvedTurnID == nil { + return + } + + let realtimeEventPayload = RustRealtimeBridge.shared.makeEvent( + type: type, + turnID: resolvedTurnID, + message: message + ) + rustRealtimeEventLog.append(realtimeEventPayload) + + guard let replaySummary = RustRealtimeBridge.shared.replaySummary(events: rustRealtimeEventLog) else { + return + } + latestRustRealtimePhaseName = replaySummary.phaseName + applyVoiceStateFromRustPhaseNameIfNeeded(replaySummary.phaseName) + } + + private func applyVoiceStateFromRustPhaseNameIfNeeded(_ phaseName: String) { + switch phaseName { + case "capturing": + if voiceState != .listening { + voiceState = .listening + } + case "awaiting_response": + if voiceState != .processing { + voiceState = .processing + } + case "speaking": + if voiceState != .responding { + voiceState = .responding + } + default: + break + } + } + // MARK: - Skilly — OpenAI Realtime Push-to-Talk Pipeline private func startOpenAIRealtimePushToTalk() { @@ -1052,6 +1123,7 @@ final class CompanionManager: ObservableObject { isWaitingForRealtimeAudioQueueDrain = false // MARK: - Skilly — Record turn start for usage tracking (key press → response.done) currentTurnStartTime = Date() + beginRustRealtimeTurnTracking(turnPrefix: "ptt") clearRealtimeResponseBubble() realtimePushToTalkTask = Task { @@ -1121,8 +1193,13 @@ final class CompanionManager: ObservableObject { #if DEBUG print("⚠️ OpenAI Realtime: failed to start: \(error)") #endif + appendRustRealtimeEvent( + type: .sessionError, + message: error.localizedDescription + ) voiceState = .idle clearRealtimeResponseBubble() + resetRustRealtimeTracking() // MARK: - Skilly — Auth recovery: if the Worker rejected // /openai/token because our Keychain session token is stale, @@ -1149,6 +1226,7 @@ final class CompanionManager: ObservableObject { // Only commit if we've actually sent audio if realtimeAudioChunksSent >= minimumAudioChunksRequiredToCommit { RealtimeTelemetry.shared.endUserSpeech() + appendRustRealtimeEvent(type: .audioCaptureCommitted) openAIRealtimeClient.commitAudioAndRespond() voiceState = .processing // MARK: - Skilly — Debug logging (stripped in release) @@ -1163,6 +1241,7 @@ final class CompanionManager: ObservableObject { isWaitingForRealtimeAudioQueueDrain = false voiceState = .idle clearRealtimeResponseBubble() + resetRustRealtimeTracking() } realtimeAudioChunksSent = 0 } @@ -1239,6 +1318,7 @@ final class CompanionManager: ObservableObject { voiceState = .idle clearRealtimeResponseBubble() + resetRustRealtimeTracking() } private func resetLiveTutorAutoSleepTimer() { @@ -1264,6 +1344,7 @@ final class CompanionManager: ObservableObject { case .audioChunk(let pcm16Data): if voiceState != .responding { + appendRustRealtimeEvent(type: .audioPlaybackStarted) voiceState = .responding isWaitingForRealtimeAudioQueueDrain = false showRealtimeResponseBubble() @@ -1333,6 +1414,7 @@ final class CompanionManager: ObservableObject { return } + appendRustRealtimeEvent(type: .responseCompleted) RealtimeTelemetry.shared.endTurn(usage: usage) if !hasEndedAssistantSpeechForCurrentTurn { RealtimeTelemetry.shared.endAssistantSpeech() @@ -1408,6 +1490,7 @@ final class CompanionManager: ObservableObject { pendingToolCallIdForCurrentTurn = nil isAwaitingForcedSpokenFollowUp = false currentTurnStartTime = Date() + beginRustRealtimeTurnTracking(turnPrefix: "vad") clearDetectedElementLocation() clearRealtimeResponseBubble() @@ -1447,12 +1530,14 @@ final class CompanionManager: ObservableObject { case .speechStopped: // MARK: - Skilly — Live Tutor: server detected end of speech guard isLiveTutorModeActive else { break } + appendRustRealtimeEvent(type: .audioCaptureCommitted) voiceState = .processing #if DEBUG print("🎓 Live Tutor: speech ended, server auto-committed") #endif case .error(let message): + appendRustRealtimeEvent(type: .sessionError, message: message) // MARK: - Skilly — Debug logging (stripped in release) #if DEBUG print("⚠️ OpenAI Realtime error: \(message)") @@ -1466,6 +1551,7 @@ final class CompanionManager: ObservableObject { isWaitingForRealtimeAudioQueueDrain = false voiceState = .idle clearRealtimeResponseBubble() + resetRustRealtimeTracking() } } @@ -1475,6 +1561,7 @@ final class CompanionManager: ObservableObject { voiceState = .idle clearRealtimeResponseBubble() scheduleTransientHideIfNeeded() + resetRustRealtimeTracking() if !hasEndedAssistantSpeechForCurrentTurn { RealtimeTelemetry.shared.endAssistantSpeech() hasEndedAssistantSpeechForCurrentTurn = true diff --git a/leanring-buddy/EntitlementManager.swift b/leanring-buddy/EntitlementManager.swift index 0ffeb483..e3c26f7c 100644 --- a/leanring-buddy/EntitlementManager.swift +++ b/leanring-buddy/EntitlementManager.swift @@ -167,6 +167,17 @@ final class EntitlementManager: ObservableObject { /// Returns (allowed, reason). Call before starting any billable turn. func canStartTurn() -> (allowed: Bool, reason: BlockReason?) { + // MARK: - Skilly — Prefer shared Rust policy when available. + if let rustDecision = RustPolicyBridge.shared.canStartTurn( + userID: AuthManager.shared.currentUser?.id, + entitlementStatus: status, + trialSecondsUsed: TrialTracker.shared.totalSecondsUsed, + usageSecondsUsed: UsageTracker.shared.secondsUsed, + adminWorkOSUserIDs: AdminAllowlist.allConfiguredAdminWorkOSUserIDs + ) { + return (rustDecision.allowed, rustDecision.reason) + } + // MARK: - Skilly — Admin bypass: allowlisted users pass every gate. if AdminAllowlist.isCurrentUserAdmin { return (true, nil) diff --git a/leanring-buddy/RustPolicyBridge.swift b/leanring-buddy/RustPolicyBridge.swift new file mode 100644 index 00000000..c7c1bfe2 --- /dev/null +++ b/leanring-buddy/RustPolicyBridge.swift @@ -0,0 +1,271 @@ +// MARK: - Skilly +// +// RustPolicyBridge.swift +// leanring-buddy +// +// Dynamic bridge for shared Rust policy checks. +// If the Rust dylib is not present, callers fall back to Swift policy logic. +// + +import Darwin +import Foundation + +@MainActor +final class RustPolicyBridge { + static let shared = RustPolicyBridge() + + struct RustPolicyDecision { + let allowed: Bool + let reason: BlockReason? + let isAdminUser: Bool + } + + private enum RustPolicyEntitlementState: UInt8 { + case none = 0 + case trial = 1 + case active = 2 + case canceledAccessStillValid = 3 + case canceledExpired = 4 + case expired = 5 + } + + private enum RustPolicyBlockReason: Int32 { + case none = 255 + case trialExhausted = 0 + case capReached = 1 + case subscriptionInactive = 2 + case expired = 3 + } + + private typealias RustCanStartTurnFunction = @convention(c) ( + UnsafePointer?, // user_id + UInt8, // entitlement_state + UInt64, // trial_seconds_used + UInt64, // usage_seconds_used + UnsafePointer? // admin_workos_user_ids_csv + ) -> UInt64 + + private typealias RustTrialIsExhaustedFunction = @convention(c) ( + UnsafePointer?, // user_id + UInt64, // trial_seconds_used + UnsafePointer? // admin_workos_user_ids_csv + ) -> UInt8 + + private typealias RustUsageIsOverCapFunction = @convention(c) ( + UnsafePointer?, // user_id + UInt64, // usage_seconds_used + UnsafePointer? // admin_workos_user_ids_csv + ) -> UInt8 + + private var dynamicLibraryHandle: UnsafeMutableRawPointer? + private var canStartTurnFunction: RustCanStartTurnFunction? + private var trialIsExhaustedFunction: RustTrialIsExhaustedFunction? + private var usageIsOverCapFunction: RustUsageIsOverCapFunction? + private var hasAttemptedLibraryLoad = false + + private init() { + loadRustPolicyLibraryIfNeeded() + } + + func canStartTurn( + userID: String?, + entitlementStatus: EntitlementStatus, + trialSecondsUsed: TimeInterval, + usageSecondsUsed: TimeInterval, + adminWorkOSUserIDs: Set + ) -> RustPolicyDecision? { + loadRustPolicyLibraryIfNeeded() + guard let canStartTurnFunction else { return nil } + + let entitlementState = mapEntitlementState(entitlementStatus) + let trialSecondsUsedInt = UInt64(max(0, trialSecondsUsed.rounded())) + let usageSecondsUsedInt = UInt64(max(0, usageSecondsUsed.rounded())) + let adminUserIDsCSV = adminWorkOSUserIDs.sorted().joined(separator: ",") + + return withOptionalCString(userID) { userIDPointer in + if adminUserIDsCSV.isEmpty { + let ffiDecision = canStartTurnFunction( + userIDPointer, + entitlementState.rawValue, + trialSecondsUsedInt, + usageSecondsUsedInt, + nil + ) + return decodePolicyDecision(ffiDecision) + } else { + return adminUserIDsCSV.withCString { adminUserIDsPointer in + let ffiDecision = canStartTurnFunction( + userIDPointer, + entitlementState.rawValue, + trialSecondsUsedInt, + usageSecondsUsedInt, + adminUserIDsPointer + ) + return decodePolicyDecision(ffiDecision) + } + } + } + } + + func trialIsExhausted( + userID: String?, + trialSecondsUsed: TimeInterval, + adminWorkOSUserIDs: Set + ) -> Bool? { + loadRustPolicyLibraryIfNeeded() + guard let trialIsExhaustedFunction else { return nil } + + let trialSecondsUsedInt = UInt64(max(0, trialSecondsUsed.rounded())) + let adminUserIDsCSV = adminWorkOSUserIDs.sorted().joined(separator: ",") + + return withOptionalCString(userID) { userIDPointer in + if adminUserIDsCSV.isEmpty { + return trialIsExhaustedFunction(userIDPointer, trialSecondsUsedInt, nil) != 0 + } else { + return adminUserIDsCSV.withCString { adminUserIDsPointer in + trialIsExhaustedFunction(userIDPointer, trialSecondsUsedInt, adminUserIDsPointer) != 0 + } + } + } + } + + func usageIsOverCap( + userID: String?, + usageSecondsUsed: TimeInterval, + adminWorkOSUserIDs: Set + ) -> Bool? { + loadRustPolicyLibraryIfNeeded() + guard let usageIsOverCapFunction else { return nil } + + let usageSecondsUsedInt = UInt64(max(0, usageSecondsUsed.rounded())) + let adminUserIDsCSV = adminWorkOSUserIDs.sorted().joined(separator: ",") + + return withOptionalCString(userID) { userIDPointer in + if adminUserIDsCSV.isEmpty { + return usageIsOverCapFunction(userIDPointer, usageSecondsUsedInt, nil) != 0 + } else { + return adminUserIDsCSV.withCString { adminUserIDsPointer in + usageIsOverCapFunction(userIDPointer, usageSecondsUsedInt, adminUserIDsPointer) != 0 + } + } + } + } + + // MARK: - Library Loading + + private func loadRustPolicyLibraryIfNeeded() { + guard canStartTurnFunction == nil else { return } + guard !hasAttemptedLibraryLoad else { return } + hasAttemptedLibraryLoad = true + + for dylibPath in candidateDynamicLibraryPaths() { + guard FileManager.default.fileExists(atPath: dylibPath) else { continue } + guard let dynamicLibraryHandle = dlopen(dylibPath, RTLD_NOW) else { continue } + guard let canStartTurnSymbol = dlsym(dynamicLibraryHandle, "skilly_policy_can_start_turn") else { + dlclose(dynamicLibraryHandle) + continue + } + + self.dynamicLibraryHandle = dynamicLibraryHandle + self.canStartTurnFunction = unsafeBitCast(canStartTurnSymbol, to: RustCanStartTurnFunction.self) + if let trialIsExhaustedSymbol = dlsym(dynamicLibraryHandle, "skilly_policy_trial_is_exhausted") { + self.trialIsExhaustedFunction = unsafeBitCast( + trialIsExhaustedSymbol, + to: RustTrialIsExhaustedFunction.self + ) + } + if let usageIsOverCapSymbol = dlsym(dynamicLibraryHandle, "skilly_policy_usage_is_over_cap") { + self.usageIsOverCapFunction = unsafeBitCast( + usageIsOverCapSymbol, + to: RustUsageIsOverCapFunction.self + ) + } + #if DEBUG + print("🦀 Skilly: Rust policy bridge loaded from \(dylibPath)") + #endif + return + } + + #if DEBUG + print("🦀 Skilly: Rust policy bridge unavailable, using Swift fallback") + #endif + } + + private func candidateDynamicLibraryPaths() -> [String] { + let processEnvironment = ProcessInfo.processInfo.environment + let envPath = processEnvironment["SKILLY_RUST_POLICY_DYLIB_PATH"] + let infoPlistPath = AppBundleConfiguration.stringValue(forKey: "SKILLY_RUST_POLICY_DYLIB_PATH") + let currentDirectoryPath = FileManager.default.currentDirectoryPath + + return [ + envPath, + infoPlistPath, + "\(currentDirectoryPath)/target/debug/libskilly_core_ffi.dylib", + "\(currentDirectoryPath)/target/release/libskilly_core_ffi.dylib", + ].compactMap { $0 } + } + + // MARK: - Mapping + + private func mapEntitlementState(_ entitlementStatus: EntitlementStatus) -> RustPolicyEntitlementState { + switch entitlementStatus { + case .none: + return .none + case .trial: + return .trial + case .active: + return .active + case .canceled(let accessUntil): + if accessUntil > Date() { + return .canceledAccessStillValid + } + return .canceledExpired + case .expired: + return .expired + } + } + + private func decodePolicyDecision(_ encodedDecision: UInt64) -> RustPolicyDecision { + let allowed = (encodedDecision & 0b1) != 0 + let isAdminUser = (encodedDecision & 0b10) != 0 + let reasonRaw = Int32((encodedDecision >> 8) & 0xFF) + let blockReason = mapBlockReason(reasonRaw) + return RustPolicyDecision( + allowed: allowed, + reason: blockReason, + isAdminUser: isAdminUser + ) + } + + private func mapBlockReason(_ rawReason: Int32) -> BlockReason? { + guard let rustPolicyBlockReason = RustPolicyBlockReason(rawValue: rawReason) else { + return nil + } + + switch rustPolicyBlockReason { + case .none: + return nil + case .trialExhausted: + return .trialExhausted + case .capReached: + return .capReached + case .subscriptionInactive: + return .subscriptionInactive + case .expired: + return .expired + } + } + + private func withOptionalCString( + _ optionalString: String?, + execute: (UnsafePointer?) -> T + ) -> T { + guard let optionalString, !optionalString.isEmpty else { + return execute(nil) + } + + return optionalString.withCString { pointer in + execute(pointer) + } + } +} diff --git a/leanring-buddy/RustRealtimeBridge.swift b/leanring-buddy/RustRealtimeBridge.swift new file mode 100644 index 00000000..b118d7e1 --- /dev/null +++ b/leanring-buddy/RustRealtimeBridge.swift @@ -0,0 +1,152 @@ +// MARK: - Skilly +// +// RustRealtimeBridge.swift +// leanring-buddy +// +// Dynamic bridge for shared Rust realtime turn/session transitions. +// If the Rust dylib is unavailable, callers fall back to Swift lifecycle behavior. +// + +import Darwin +import Foundation + +@MainActor +final class RustRealtimeBridge { + static let shared = RustRealtimeBridge() + + enum RealtimeEventType: String, Codable { + case turnStarted = "turn_started" + case audioCaptureCommitted = "audio_capture_committed" + case responseStarted = "response_started" + case audioPlaybackStarted = "audio_playback_started" + case responseCompleted = "response_completed" + case sessionError = "session_error" + case sessionReset = "session_reset" + } + + struct RealtimeEventPayload: Codable { + let type: String + let turnID: String? + let message: String? + + enum CodingKeys: String, CodingKey { + case type + case turnID = "turn_id" + case message + } + } + + struct RealtimeReplaySummary: Decodable { + let phaseName: String + let turnsCompleted: Int + + enum CodingKeys: String, CodingKey { + case phaseName = "phase_name" + case turnsCompleted = "turns_completed" + } + } + + private typealias RustReplayEventsFunction = @convention(c) ( + UnsafePointer? // events_json + ) -> UnsafeMutablePointer? + + private typealias RustFreeStringFunction = @convention(c) ( + UnsafeMutablePointer? // raw_string + ) -> Void + + private var dynamicLibraryHandle: UnsafeMutableRawPointer? + private var replayEventsFunction: RustReplayEventsFunction? + private var freeStringFunction: RustFreeStringFunction? + private var hasAttemptedLibraryLoad = false + + private init() { + loadRustRealtimeLibraryIfNeeded() + } + + func makeEvent( + type: RealtimeEventType, + turnID: String? = nil, + message: String? = nil + ) -> RealtimeEventPayload { + RealtimeEventPayload( + type: type.rawValue, + turnID: turnID, + message: message + ) + } + + func replaySummary(events: [RealtimeEventPayload]) -> RealtimeReplaySummary? { + loadRustRealtimeLibraryIfNeeded() + guard let replayEventsFunction, let freeStringFunction else { return nil } + + let jsonEncoder = JSONEncoder() + guard let encodedEventsData = try? jsonEncoder.encode(events), + let encodedEventsJSON = String(data: encodedEventsData, encoding: .utf8) else { + return nil + } + + return encodedEventsJSON.withCString { encodedEventsPointer in + guard let rawSummaryJSON = replayEventsFunction(encodedEventsPointer) else { + return nil + } + + defer { + freeStringFunction(rawSummaryJSON) + } + + let summaryJSON = String(cString: rawSummaryJSON) + guard let summaryData = summaryJSON.data(using: .utf8) else { + return nil + } + return try? JSONDecoder().decode(RealtimeReplaySummary.self, from: summaryData) + } + } + + // MARK: - Library Loading + + private func loadRustRealtimeLibraryIfNeeded() { + guard replayEventsFunction == nil else { return } + guard !hasAttemptedLibraryLoad else { return } + hasAttemptedLibraryLoad = true + + for dylibPath in candidateDynamicLibraryPaths() { + guard FileManager.default.fileExists(atPath: dylibPath) else { continue } + guard let dynamicLibraryHandle = dlopen(dylibPath, RTLD_NOW) else { continue } + guard let replayEventsSymbol = dlsym(dynamicLibraryHandle, "skilly_realtime_replay_events_json"), + let freeStringSymbol = dlsym(dynamicLibraryHandle, "skilly_string_free") else { + dlclose(dynamicLibraryHandle) + continue + } + + self.dynamicLibraryHandle = dynamicLibraryHandle + self.replayEventsFunction = unsafeBitCast(replayEventsSymbol, to: RustReplayEventsFunction.self) + self.freeStringFunction = unsafeBitCast(freeStringSymbol, to: RustFreeStringFunction.self) + #if DEBUG + print("Skilly: Rust realtime bridge loaded from \(dylibPath)") + #endif + return + } + + #if DEBUG + print("Skilly: Rust realtime bridge unavailable, using Swift fallback") + #endif + } + + private func candidateDynamicLibraryPaths() -> [String] { + let processEnvironment = ProcessInfo.processInfo.environment + let envRealtimePath = processEnvironment["SKILLY_RUST_REALTIME_DYLIB_PATH"] + let envCorePath = processEnvironment["SKILLY_RUST_CORE_DYLIB_PATH"] + let envPolicyPath = processEnvironment["SKILLY_RUST_POLICY_DYLIB_PATH"] + let infoPlistPath = AppBundleConfiguration.stringValue(forKey: "SKILLY_RUST_POLICY_DYLIB_PATH") + let currentDirectoryPath = FileManager.default.currentDirectoryPath + + return [ + envRealtimePath, + envCorePath, + envPolicyPath, + infoPlistPath, + "\(currentDirectoryPath)/target/debug/libskilly_core_ffi.dylib", + "\(currentDirectoryPath)/target/release/libskilly_core_ffi.dylib", + ].compactMap { $0 } + } +} diff --git a/leanring-buddy/RustSkillsBridge.swift b/leanring-buddy/RustSkillsBridge.swift new file mode 100644 index 00000000..3333f280 --- /dev/null +++ b/leanring-buddy/RustSkillsBridge.swift @@ -0,0 +1,222 @@ +// MARK: - Skilly +// +// RustSkillsBridge.swift +// leanring-buddy +// +// Dynamic bridge for shared Rust skill prompt composition. +// If the Rust dylib is not present, callers fall back to Swift composition logic. +// + +import Darwin +import Foundation + +@MainActor +final class RustSkillsBridge { + static let shared = RustSkillsBridge() + + private struct RustSkillMetadataPayload: Encodable { + let id: String + let name: String + let targetApp: String + let pointingMode: String + + enum CodingKeys: String, CodingKey { + case id + case name + case targetApp = "target_app" + case pointingMode = "pointing_mode" + } + } + + private struct RustCurriculumStagePayload: Encodable { + let id: String + let name: String + let goals: [String] + let nextStageName: String? + + enum CodingKeys: String, CodingKey { + case id + case name + case goals + case nextStageName = "next_stage_name" + } + } + + private struct RustVocabularyEntryPayload: Encodable { + let name: String + let description: String + } + + private struct RustSkillDefinitionPayload: Encodable { + let metadata: RustSkillMetadataPayload + let teachingInstructions: String + let curriculumStages: [RustCurriculumStagePayload] + let vocabularyEntries: [RustVocabularyEntryPayload] + + enum CodingKeys: String, CodingKey { + case metadata + case teachingInstructions = "teaching_instructions" + case curriculumStages = "curriculum_stages" + case vocabularyEntries = "vocabulary_entries" + } + } + + private struct RustSkillProgressPayload: Encodable { + let currentStageID: String + let completedStageIDs: [String] + + enum CodingKeys: String, CodingKey { + case currentStageID = "current_stage_id" + case completedStageIDs = "completed_stage_ids" + } + } + + private typealias RustComposePromptFunction = @convention(c) ( + UnsafePointer?, // base_prompt + UnsafePointer?, // skill_definition_json + UnsafePointer? // skill_progress_json + ) -> UnsafeMutablePointer? + + private typealias RustFreeStringFunction = @convention(c) ( + UnsafeMutablePointer? // raw_string + ) -> Void + + private var dynamicLibraryHandle: UnsafeMutableRawPointer? + private var composePromptFunction: RustComposePromptFunction? + private var freeStringFunction: RustFreeStringFunction? + private var hasAttemptedLibraryLoad = false + + private init() { + loadRustSkillsLibraryIfNeeded() + } + + func composePrompt(basePrompt: String, skill: SkillDefinition, progress: SkillProgress) -> String? { + loadRustSkillsLibraryIfNeeded() + guard let composePromptFunction, let freeStringFunction else { return nil } + + guard let skillDefinitionJSON = encodeSkillDefinitionJSON(skill: skill), + let skillProgressJSON = encodeSkillProgressJSON(progress: progress) else { + return nil + } + + return basePrompt.withCString { basePromptPointer in + skillDefinitionJSON.withCString { skillDefinitionJSONPointer in + skillProgressJSON.withCString { skillProgressJSONPointer in + guard let rawComposedPrompt = composePromptFunction( + basePromptPointer, + skillDefinitionJSONPointer, + skillProgressJSONPointer + ) else { + return nil + } + + defer { + freeStringFunction(rawComposedPrompt) + } + return String(cString: rawComposedPrompt) + } + } + } + } + + // MARK: - Library Loading + + private func loadRustSkillsLibraryIfNeeded() { + guard composePromptFunction == nil else { return } + guard !hasAttemptedLibraryLoad else { return } + hasAttemptedLibraryLoad = true + + for dylibPath in candidateDynamicLibraryPaths() { + guard FileManager.default.fileExists(atPath: dylibPath) else { continue } + guard let dynamicLibraryHandle = dlopen(dylibPath, RTLD_NOW) else { continue } + guard let composePromptSymbol = dlsym(dynamicLibraryHandle, "skilly_skills_compose_prompt_json"), + let freeStringSymbol = dlsym(dynamicLibraryHandle, "skilly_string_free") else { + dlclose(dynamicLibraryHandle) + continue + } + + self.dynamicLibraryHandle = dynamicLibraryHandle + self.composePromptFunction = unsafeBitCast(composePromptSymbol, to: RustComposePromptFunction.self) + self.freeStringFunction = unsafeBitCast(freeStringSymbol, to: RustFreeStringFunction.self) + #if DEBUG + print("Skilly: Rust skills bridge loaded from \(dylibPath)") + #endif + return + } + + #if DEBUG + print("Skilly: Rust skills bridge unavailable, using Swift fallback") + #endif + } + + private func candidateDynamicLibraryPaths() -> [String] { + let processEnvironment = ProcessInfo.processInfo.environment + let envPolicyPath = processEnvironment["SKILLY_RUST_POLICY_DYLIB_PATH"] + let envSkillsPath = processEnvironment["SKILLY_RUST_SKILLS_DYLIB_PATH"] + let envCorePath = processEnvironment["SKILLY_RUST_CORE_DYLIB_PATH"] + let infoPlistPath = AppBundleConfiguration.stringValue(forKey: "SKILLY_RUST_POLICY_DYLIB_PATH") + let currentDirectoryPath = FileManager.default.currentDirectoryPath + + return [ + envSkillsPath, + envCorePath, + envPolicyPath, + infoPlistPath, + "\(currentDirectoryPath)/target/debug/libskilly_core_ffi.dylib", + "\(currentDirectoryPath)/target/release/libskilly_core_ffi.dylib", + ].compactMap { $0 } + } + + // MARK: - JSON Encoding + + private func encodeSkillDefinitionJSON(skill: SkillDefinition) -> String? { + let metadataPayload = RustSkillMetadataPayload( + id: skill.metadata.id, + name: skill.metadata.name, + targetApp: skill.metadata.targetApp, + pointingMode: skill.metadata.pointingMode.rawValue + ) + + let curriculumStagePayloads = skill.curriculumStages.map { curriculumStage in + RustCurriculumStagePayload( + id: curriculumStage.id, + name: curriculumStage.name, + goals: curriculumStage.goals, + nextStageName: curriculumStage.nextStageName + ) + } + + let vocabularyEntryPayloads = skill.vocabularyEntries.map { vocabularyEntry in + RustVocabularyEntryPayload( + name: vocabularyEntry.name, + description: vocabularyEntry.description + ) + } + + let skillDefinitionPayload = RustSkillDefinitionPayload( + metadata: metadataPayload, + teachingInstructions: skill.teachingInstructions, + curriculumStages: curriculumStagePayloads, + vocabularyEntries: vocabularyEntryPayloads + ) + + let jsonEncoder = JSONEncoder() + guard let encodedData = try? jsonEncoder.encode(skillDefinitionPayload) else { + return nil + } + return String(data: encodedData, encoding: .utf8) + } + + private func encodeSkillProgressJSON(progress: SkillProgress) -> String? { + let skillProgressPayload = RustSkillProgressPayload( + currentStageID: progress.currentStageId, + completedStageIDs: progress.completedStageIds + ) + + let jsonEncoder = JSONEncoder() + guard let encodedData = try? jsonEncoder.encode(skillProgressPayload) else { + return nil + } + return String(data: encodedData, encoding: .utf8) + } +} diff --git a/leanring-buddy/SkillPromptComposer.swift b/leanring-buddy/SkillPromptComposer.swift index 40159a99..be6cf1f1 100644 --- a/leanring-buddy/SkillPromptComposer.swift +++ b/leanring-buddy/SkillPromptComposer.swift @@ -67,6 +67,27 @@ enum SkillPromptComposer { basePrompt: String, skill: SkillDefinition, progress: SkillProgress + ) -> String { + if let rustComposedPrompt = RustSkillsBridge.shared.composePrompt( + basePrompt: basePrompt, + skill: skill, + progress: progress + ) { + return rustComposedPrompt + } + + return composeWithSwift( + basePrompt: basePrompt, + skill: skill, + progress: progress + ) + } + + /// Swift fallback prompt composer used when the Rust bridge is unavailable. + private static func composeWithSwift( + basePrompt: String, + skill: SkillDefinition, + progress: SkillProgress ) -> String { var sections: [String] = [] diff --git a/leanring-buddy/TrialTracker.swift b/leanring-buddy/TrialTracker.swift index af2f1d02..31b4a4b0 100644 --- a/leanring-buddy/TrialTracker.swift +++ b/leanring-buddy/TrialTracker.swift @@ -38,6 +38,15 @@ final class TrialTracker: ObservableObject { } var isExhausted: Bool { + // MARK: - Skilly — Prefer shared Rust policy when available. + if let rustTrialIsExhausted = RustPolicyBridge.shared.trialIsExhausted( + userID: userId, + trialSecondsUsed: totalSecondsUsed, + adminWorkOSUserIDs: AdminAllowlist.allConfiguredAdminWorkOSUserIDs + ) { + return rustTrialIsExhausted + } + // MARK: - Skilly — Admin bypass: allowlisted users never run out of trial time. if AdminAllowlist.isCurrentUserAdmin { return false } // MARK: - Skilly — BYOK bypass: when the user pays OpenAI directly with diff --git a/leanring-buddy/UsageTracker.swift b/leanring-buddy/UsageTracker.swift index b20df8b2..9a1ddef7 100644 --- a/leanring-buddy/UsageTracker.swift +++ b/leanring-buddy/UsageTracker.swift @@ -54,6 +54,15 @@ final class UsageTracker: ObservableObject { } var isOverCap: Bool { + // MARK: - Skilly — Prefer shared Rust policy when available. + if let rustUsageIsOverCap = RustPolicyBridge.shared.usageIsOverCap( + userID: userId, + usageSecondsUsed: secondsUsed, + adminWorkOSUserIDs: AdminAllowlist.allConfiguredAdminWorkOSUserIDs + ) { + return rustUsageIsOverCap + } + // MARK: - Skilly — Admin bypass: allowlisted users never hit the monthly cap. if AdminAllowlist.isCurrentUserAdmin { return false } return secondsUsed >= Self.maxSecondsPerPeriod diff --git a/scripts/package-rust-ffi-dylib.sh b/scripts/package-rust-ffi-dylib.sh new file mode 100755 index 00000000..6fc64e36 --- /dev/null +++ b/scripts/package-rust-ffi-dylib.sh @@ -0,0 +1,66 @@ +#!/usr/bin/env bash +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$REPO_ROOT" + +FFI_CRATE_MANIFEST="$REPO_ROOT/core/ffi/Cargo.toml" +FFI_VERSION="$(sed -n 's/^version = "\([0-9.]*\)"/\1/p' "$FFI_CRATE_MANIFEST" | head -1)" +if [[ -z "$FFI_VERSION" ]]; then + echo "Could not determine FFI crate version from $FFI_CRATE_MANIFEST" >&2 + exit 1 +fi + +DIST_ROOT="$REPO_ROOT/dist/rust-ffi" +DIST_VERSION_DIR="$DIST_ROOT/v${FFI_VERSION}" +DIST_PLATFORM_NAME="$(uname -s | tr '[:upper:]' '[:lower:]')" +DIST_OUTPUT_FILE="$DIST_ROOT/skilly-core-ffi-v${FFI_VERSION}-${DIST_PLATFORM_NAME}.tar.gz" +RELEASE_LIBRARY_DIR="$REPO_ROOT/target/release" + +cargo build -p skilly-core-ffi --release + +case "$(uname -s)" in + Darwin) + FFI_LIBRARY_PATH="$RELEASE_LIBRARY_DIR/libskilly_core_ffi.dylib" + ;; + Linux) + FFI_LIBRARY_PATH="$RELEASE_LIBRARY_DIR/libskilly_core_ffi.so" + ;; + MINGW*|MSYS*|CYGWIN*) + FFI_LIBRARY_PATH="$RELEASE_LIBRARY_DIR/skilly_core_ffi.dll" + ;; + *) + echo "Unsupported host OS for Rust FFI packaging." >&2 + exit 1 + ;; +esac + +if [[ ! -f "$FFI_LIBRARY_PATH" ]]; then + echo "Expected release FFI library at $FFI_LIBRARY_PATH" >&2 + exit 1 +fi + +rm -rf "$DIST_VERSION_DIR" +mkdir -p "$DIST_VERSION_DIR" + +cp "$FFI_LIBRARY_PATH" "$DIST_VERSION_DIR/" +cat > "$DIST_VERSION_DIR/MANIFEST.txt" </dev/null 2>&1; then + shasum -a 256 "$DIST_OUTPUT_FILE" > "${DIST_OUTPUT_FILE}.sha256" +elif command -v sha256sum >/dev/null 2>&1; then + sha256sum "$DIST_OUTPUT_FILE" > "${DIST_OUTPUT_FILE}.sha256" +fi + +echo "Packaged Rust FFI artifact: $DIST_OUTPUT_FILE" +if [[ -f "${DIST_OUTPUT_FILE}.sha256" ]]; then + echo "Checksum file: ${DIST_OUTPUT_FILE}.sha256" +fi