~ knotless Search/
↑↓ move↵ open⇧↵ all results

johnandersen777.bsky.social/atirc-ios-client

0
1
main
232 lines · 8.9 KB Raw History
//
//  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<Void, Never>?

    /// 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..<timeout {
            await refresh()
            if let fresh = contracts.first(where: { !before.contains($0.id) }) {
                return fresh.id
            }
            try? await Task.sleep(for: .seconds(2))
        }
        return nil
    }

    /// Send guest.agentStart for a held contract and report the settled phase.
    func agentStart(contractID: String) async {
        do {
            let settled = try await api.agentStart(contractID: contractID)
            log.notice("agentStart \(contractID, privacy: .public) \(settled, privacy: .public)")
            errorText = nil
        } catch {
            log.error("agentStart failed: \(error.localizedDescription, privacy: .public)")
            errorText = error.localizedDescription
        }
    }

    /// The embedded kcp was stopped out from under us: show it as unavailable
    /// until Agents starts it again on the next visit.
    func refreshAfterStop() {
        availability = .unavailable("The embedded kcp is stopped.")
        contracts = []
        identityPhase = ""
        signInURL = nil
        errorText = nil
    }

    /// Debug: release every contract entry the session EphemeralInfra holds.
    func releaseAllHeld() async {
        do {
            let released = try await api.releaseAllHeld()
            log.notice("released contracts=\(released.joined(separator: ","), privacy: .public)")
            errorText = nil
            KCPHost.persistSnapshot()
            await refresh()
        } catch {
            log.error("release all failed: \(error.localizedDescription, privacy: .public)")
            errorText = error.localizedDescription
        }
    }
}