// // AgentsStore.swift // AT-IRC // // State for the Agents section: the embedded kcp's socialweb availability, // the agent ComputeContracts, and launch/release actions. // import Foundation import Observation import OSLog @Observable @MainActor final class AgentsStore { enum Availability: Equatable { case unknown case ready case unavailable(String) } private(set) var contracts: [AgentContract] = [] private(set) var availability: Availability = .unknown private(set) var identityPhase = "" /// Short-lived authorization URL for the requester's MarketIdentity. private(set) var signInURL: URL? private(set) var loading = false private(set) var launching = false var errorText: String? private let api = AgentsAPI() private let log = Logger(subsystem: "com.fedfork.irc.AT-IRC", category: "agents") @ObservationIgnored private var identityPollTask: Task? /// Agent contracts first, Released ones last. var agentContracts: [AgentContract] { contracts.filter { $0.isAgent }.sorted { a, b in if (a.phase == "Released") != (b.phase == "Released") { return a.phase != "Released" } return a.id < b.id } } /// Start the requester's OAuth by re-applying its identity (generation /// bump), then wait for the controller-issued authorization URL. func beginSignIn(requesterConfigJSON: String) async { guard let raw = requesterConfigJSON.data(using: .utf8), let config = try? JSONSerialization.jsonObject(with: raw) as? [String: Any] else { asksError("The requester config is not ready yet.") return } let identity: [String: Any] = [ "mode": "oauthBrowser", "handle": config["handle"] as? String ?? "", "did": config["did"] as? String ?? "", "pdsUrl": config["pdsUrl"] as? String ?? "", "oauthClientID": config["oauthClientId"] as? String ?? "", "oauthRedirectURI": config["oauthRedirectUri"] as? String ?? "", ] let patch = ["spec": ["identity": identity]] guard let body = try? JSONSerialization.data(withJSONObject: patch) else { return } do { try await api.patchRequesterIdentity(body) log.notice("signin requester generation bumped") } catch { errorText = error.localizedDescription log.error("signin patch failed: \(error.localizedDescription, privacy: .public)") return } for _ in 0..<15 { if let state = try? await api.marketIdentity() { identityPhase = state.phase if let url = state.pendingURL { signInURL = url // Never log the authorization URL itself. log.notice("signin url ready") exportSignInURLForTest(url) pollIdentityUntilReady() return } } try? await Task.sleep(for: .seconds(1)) } log.notice("signin url not issued phase=\(self.identityPhase, privacy: .public)") errorText = "The requester did not issue a sign-in URL (identity \(identityPhase.isEmpty ? "unknown" : identityPhase)). Check the embedded kcp logs, then try again." } /// The OAuth callback flips the identity to Ready asynchronously; keep the /// panel current without the user pulling to refresh. private func pollIdentityUntilReady() { identityPollTask?.cancel() identityPollTask = Task { @MainActor [weak self] in for _ in 0..<90 { guard let self else { return } if let state = try? await self.api.marketIdentity() { self.identityPhase = state.phase if state.phase == "Ready" { self.signInURL = nil self.log.notice("market identity ready") // Persist the new OAuth session now: a restart before // the next launch/release must not lose the sign-in. KCPHost.persistSnapshot() await self.refresh() return } self.signInURL = state.pendingURL } try? await Task.sleep(for: .seconds(2)) } } } private func asksError(_ message: String) { errorText = message } /// Scripted tests only: hand the authorization URL to a headless browser /// through a 0600 app-container file, never through logs or the channel. private func exportSignInURLForTest(_ url: URL) { #if DEBUG guard ProcessInfo.processInfo.environment["ATIRC_MARKET_SIGNIN_EXPORT"] == "1" else { return } guard let dir = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first else { return } let path = dir.appendingPathComponent(".market-signin-url") try? url.absoluteString.write(to: path, atomically: true, encoding: .utf8) try? FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: path.path) #endif } func refresh() async { guard !loading else { return } loading = true defer { loading = false } do { guard try await api.socialwebAvailable() else { availability = .unavailable("The embedded kcp has no socialweb requester yet (plan 0038 I4), so the market flow cannot run in this build.") contracts = [] errorText = nil return } contracts = try await api.contracts() availability = .ready errorText = nil if let identity = try? await api.marketIdentity() { identityPhase = identity.phase signInURL = identity.phase == "Ready" ? nil : identity.pendingURL } } catch { availability = .unavailable(error.localizedDescription) errorText = error.localizedDescription } } func launch() async { guard !launching else { return } launching = true defer { launching = false } do { try await api.launch() errorText = nil // Persist the session EphemeralInfra now: a Release must finish // from persisted state even if the app is killed after this. KCPHost.persistSnapshot() await refresh() } catch { errorText = error.localizedDescription log.error("launch failed: \(error.localizedDescription, privacy: .public)") } } func release(_ contract: AgentContract) async { do { try await api.release(contract) errorText = nil KCPHost.persistSnapshot() await refresh() } catch { errorText = error.localizedDescription } } /// Launch and return the new contract's id once it appears (the list can be /// empty before the first launch). func launchAndWait(timeout: Int = 60) async -> String? { let before = Set(contracts.map(\.id)) await launch() for _ in 0..