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

johnandersen777.bsky.social/atirc-ios-client

0
1
main
393 lines · 16.5 KB Raw History
//
//  AgentsAPI.swift
//  AT-IRC
//
//  The socialweb market request flow from the app, following the page
//  (scripts/browser/request_vm.js and vmshell.js): one EphemeralInfra per app
//  session, one ComputeContract entry per launch with spec.agent, requester
//  RFP/accept/receipt, leased atirc token, Release through the infra entry.
//
//  Everything goes through the embedded kcp (AtircgoKCPRequest); the app
//  never talks to bidders directly and never writes a ComputeContract.
//

import AtircGo
import Foundation

/// One ComputeContract from kcp, reduced to what the Agents list shows.
struct AgentContract: Identifiable, Decodable, Sendable {
    struct Metadata: Decodable, Sendable {
        let name: String
        let labels: [String: String]?
    }

    struct Spec: Decodable, Sendable {
        struct Agent: Decodable, Sendable {}
        struct SecretsRef: Decodable, Sendable {
            let mount: String?
            let path: String?
        }

        let vmName: String?
        let agent: Agent?
        let secretsRef: SecretsRef?
        let releaseRequestedAt: String?
    }

    struct Status: Decodable, Sendable {
        let phase: String?
    }

    let metadata: Metadata
    let spec: Spec?
    let status: Status?

    var id: String { metadata.name }
    var phase: String { status?.phase ?? "Pending" }
    var isAgent: Bool { spec?.agent != nil }
    /// The EphemeralInfra entry name (`short`) the infra controller maps to id.
    var shortName: String {
        metadata.labels?["infra.socialweb.computer/contract"] ?? metadata.name
    }
}

private struct AgentContractList: Decodable {
    let items: [AgentContract]
}

/// Coarse MarketIdentity state for the Agents UI. The sign-in URL is a
/// short-lived authorization URL; never log it.
struct MarketIdentityState: Sendable {
    let phase: String
    let pendingURL: URL?
    let pendingReason: String?
}

struct AgentsAPI: Sendable {
    private static let apiPath = "/apis/socialweb.computer/v1alpha1"
    private static let sessionInfraName = "atirc-ios"
    private static let agentEnvFile = "/etc/atirc/atirc.env"
    /// Same field manager as the page. The slash is percent-encoded in the
    /// query; kcp parses the encoded form.
    private static let fieldManager = "socialweb.computer%2Fpage"
    /// The page's spec fields, applied whole; other composers keep theirs.
    private static let pageSpecFields = ["description", "ttlSeconds", "endWhen", "record", "contracts", "secrets"]

    private let kcp = KCPClient()

    // MARK: - Reads

    /// Whether this kcp serves the socialweb API at all. False until the
    /// socialweb requester is embedded in the app (plan 0038 I4).
    func socialwebAvailable() async throws -> Bool {
        do {
            // The requester applies its provider manifests during Start; the
            // APIBinding and CRDs can take a few seconds to be served.
            _ = try await kcp.getRetryingNotFound("/apis/socialweb.computer", attempts: 8, delay: .milliseconds(250))
            return true
        } catch let error as KCPError where error.isNotFound {
            return false
        }
    }

    func contracts() async throws -> [AgentContract] {
        let response = try await kcp.getRetryingNotFound("\(Self.apiPath)/computecontracts", attempts: 20, delay: .milliseconds(500))
        guard let list = try? JSONDecoder().decode(AgentContractList.self, from: response.data) else {
            throw KCPError.malformedResponse
        }
        return list.items
    }

    /// Bump the requester's identity generation so the controller issues a
    /// fresh MarketIdentity authorization URL (the page does the same merge
    /// patch before opening its sign-in popup).
    func patchRequesterIdentity(_ patch: Data) async throws {
        _ = try await kcp.request(
            "PATCH",
            "\(Self.apiPath)/marketrequesters/app",
            body: patch,
            contentType: "application/merge-patch+json"
        )
    }

    /// The embedded requester's MarketIdentity state. It is composed a few
    /// seconds after Start, so transient 404s are retried.
    func marketIdentity() async throws -> MarketIdentityState {
        let response = try await kcp.getRetryingNotFound(
            "\(Self.apiPath)/marketidentities/requester",
            attempts: 20,
            delay: .milliseconds(500)
        )
        guard let object = response.json as? [String: Any] else {
            throw KCPError.malformedResponse
        }
        let status = object["status"] as? [String: Any] ?? [:]
        let phase = status["phase"] as? String ?? "Unknown"
        var pendingURL: URL?
        var pendingReason: String?
        if let pending = status["pendingAction"] as? [String: Any] {
            pendingReason = pending["reason"] as? String
            if let raw = pending["url"] as? String, !raw.isEmpty {
                pendingURL = URL(string: raw)
            }
        }
        return MarketIdentityState(phase: phase, pendingURL: pendingURL, pendingReason: pendingReason)
    }

    func sessionInfra() async throws -> [String: Any] {
        let response = try await kcp.get("\(Self.apiPath)/ephemeralinfras/\(Self.sessionInfraName)")
        guard let object = response.json as? [String: Any] else {
            throw KCPError.malformedResponse
        }
        return object
    }

    // MARK: - Writes

    /// Create the app session's EphemeralInfra when it is absent. Like the
    /// page, it holds every launch so the requester tracks them together.
    /// Without a MarketIdentity record binding it stays local to kcp.
    func ensureSessionInfra() async throws {
        do {
            _ = try await sessionInfra()
            return
        } catch let error as KCPError where error.isNotFound {
            // Create below.
        }
        let manifest: [String: Any] = [
            "apiVersion": "socialweb.computer/v1alpha1",
            "kind": "EphemeralInfra",
            "metadata": ["name": Self.sessionInfraName],
            "spec": [
                "description": "AT-IRC app session",
                "ttlSeconds": 30 * 24 * 3600,
                "endWhen": "Deadline",
                "contracts": [],
            ],
        ]
        let body = try JSONSerialization.data(withJSONObject: manifest)
        do {
            _ = try await kcp.request(
                "POST",
                "\(Self.apiPath)/ephemeralinfras?fieldManager=\(Self.fieldManager)",
                body: body
            )
        } catch let error as KCPError {
            if case .http(409, _) = error { return } // a concurrent launch won
            throw error
        }
    }

    /// One launch: a ComputeContract entry with spec.agent in the session
    /// EphemeralInfra. The requester runs the RFP/accept/receipt flow and
    /// mints a leased atirc token after the verified receipt.
    ///
    /// Remaining for a real launch (plan 0038 I4/I7): the page writes the
    /// secrets bundle (the `ATIRC_TOKEN=` placeholder) through its
    /// `socialwebVaultPut` Go export before creating the contract. There is no
    /// iOS path to that export yet, and the requester fails a contract whose
    /// secretsRef has no bundle.
    func launch() async throws {
        guard try await socialwebAvailable() else {
            throw KCPError.http(
                status: 404,
                message: "the embedded kcp has no socialweb requester yet (plan 0038 I4); agent launch is unavailable in this build"
            )
        }
        try await ensureSessionInfra()
        let short = Self.newShortName()
        let name = "einfra-\(Self.sessionInfraName)-\(short)"
        // The requester reads the bundle once and fails a contract whose
        // secretsRef is missing, so it must exist before the contract. The
        // token line is an empty placeholder the requester fills after the
        // receipt; the app never holds an agent token.
        try await putContractSecrets(
            contract: name,
            bundle: [["path": Self.agentEnvFile, "value": "ATIRC_TOKEN=\n", "mode": "0600"]]
        )
        try await updateSessionInfra { spec in
            var contracts = spec["contracts"] as? [[String: Any]] ?? []
            contracts.append([
                "name": short,
                "state": "Requested",
                "vmName": name,
                "policy": "requester-tangled-vouch",
                "tunnels": [["tag": "ssh", "port": 22, "transport": "webrtc"]],
                "agent": ["envFile": Self.agentEnvFile],
            ])
            spec["contracts"] = contracts
            // The bundle's ref is a sibling entry (spec.secrets), not a contract
            // field: the infra controller copies it onto the composed contract.
            var secrets = spec["secrets"] as? [[String: Any]] ?? []
            secrets.append([
                "contract": short,
                "secretsRef": ["mount": "requester", "path": "contracts/\(name)/secrets"],
            ])
            spec["secrets"] = secrets
            return true
        }
    }

    /// Release through the EphemeralInfra entry: state Released with a fresh
    /// releaseRequestedAt. The infra controller copies both to the contract;
    /// the requester revokes the leased atirc token and destroys the VM.
    /// Send guest.agentStart for a Held contract, exactly the page's Agent
    /// button: an outbound ContractEvent the requester publishes, after which
    /// the guest starts atirc-agent.service and the agent joins the channel.
    /// Returns the settled phase and message.
    func agentStart(contractID: String) async throws -> String {
        let alphabet = Array("abcdefghijklmnopqrstuvwxyz0123456789")
        let suffix = String((0..<6).map { _ in alphabet.randomElement()! })
        let name = "\(contractID)-agent-\(suffix)"
        let body: [String: Any] = [
            "apiVersion": "socialweb.computer/v1alpha1",
            "kind": "ContractEvent",
            "metadata": ["name": name],
            "spec": [
                "contractRef": ["name": contractID],
                "direction": "outbound",
                "nsid": "com.publicdomainrelay.temp.compute.events.guest.agentStart",
            ],
        ]
        let data = try JSONSerialization.data(withJSONObject: body)
        _ = try await kcp.request(
            "POST",
            "\(Self.apiPath)/contractevents?fieldManager=\(Self.fieldManager)",
            body: data
        )
        // Settle like `run request agent`: Pending until the requester acts.
        for _ in 0..<30 {
            if let response = try? await kcp.get("\(Self.apiPath)/contractevents/\(name)"),
               let status = response.json as? [String: Any],
               let phase = (status["status"] as? [String: Any])?["phase"] as? String,
               phase != "Pending", !phase.isEmpty {
                let message = (status["status"] as? [String: Any])?["message"] as? String ?? ""
                return "\(phase): \(message)"
            }
            try? await Task.sleep(for: .seconds(2))
        }
        return "Pending"
    }

    /// Release every EphemeralInfra entry still holding a contract. The debug
    /// release-all hook uses this because the ComputeContract list can be
    /// empty even while entries hold VMs.
    func releaseAllHeld() async throws -> [String] {
        var released: [String] = []
        try await updateSessionInfra { spec in
            guard var contracts = spec["contracts"] as? [[String: Any]] else {
                throw KCPError.malformedResponse
            }
            released = []
            for index in contracts.indices {
                var entry = contracts[index]
                if (entry["state"] as? String) == "Released" { continue }
                let name = entry["name"] as? String ?? "?"
                entry["state"] = "Released"
                entry["releaseRequestedAt"] = Self.nextStamp(entry["releaseRequestedAt"] as? String)
                contracts[index] = entry
                released.append(name)
            }
            if released.isEmpty { return false }
            spec["contracts"] = contracts
            return true
        }
        return released
    }

    func release(_ contract: AgentContract) async throws {
        try await updateSessionInfra { spec in
            guard var contracts = spec["contracts"] as? [[String: Any]] else {
                throw KCPError.malformedResponse
            }
            guard let index = contracts.firstIndex(where: { entry in
                guard let name = entry["name"] as? String else { return false }
                return name == contract.shortName || "einfra-\(Self.sessionInfraName)-\(name)" == contract.id
            }) else {
                throw KCPError.http(status: 404, message: "no EphemeralInfra entry for \(contract.id)")
            }
            var entry = contracts[index]
            entry["state"] = "Released"
            entry["releaseRequestedAt"] = Self.nextStamp(entry["releaseRequestedAt"] as? String)
            contracts[index] = entry
            spec["contracts"] = contracts
            return true
        }
    }

    // MARK: - Infra apply

    /// Read the session EphemeralInfra, change its spec and apply it at the
    /// read resourceVersion. The infra controller writes the object's status
    /// right after the requester starts (and on every contract change), so a
    /// 409 between the read and the apply is expected: read again and retry.
    /// Before this, the first launch after a start could fail with 409 and
    /// nothing was requested (2026-11-10, 1 launch in ~8).
    private func updateSessionInfra(_ change: (inout [String: Any]) throws -> Bool) async throws {
        for attempt in 1...5 {
            let infra = try await sessionInfra()
            guard var spec = infra["spec"] as? [String: Any] else { throw KCPError.malformedResponse }
            guard try change(&spec) else { return }
            do {
                try await apply(spec: spec, resourceVersion: Self.resourceVersion(of: infra))
                return
            } catch KCPError.http(status: 409, message: _) where attempt < 5 {
                try? await Task.sleep(for: .milliseconds(200 * attempt))
            }
        }
    }

    /// Write a contract's secrets bundle through the embedded requester's
    /// vault (the page's socialwebVaultPut equivalent).
    private func putContractSecrets(contract: String, bundle: [[String: Any]]) async throws {
        let data = try JSONSerialization.data(withJSONObject: bundle)
        try await Task.detached(priority: .userInitiated) {
            guard let secrets = AtircgoKCPSecrets() else {
                throw KCPError.malformedResponse
            }
            try secrets.put(contract, bundleJSON: data)
        }.value
    }

    private func apply(spec: [String: Any], resourceVersion: String?) async throws {
        var pageSpec: [String: Any] = [:]
        for field in Self.pageSpecFields where spec[field] != nil {
            pageSpec[field] = spec[field]
        }
        var metadata: [String: Any] = ["name": Self.sessionInfraName]
        if let resourceVersion { metadata["resourceVersion"] = resourceVersion }
        let manifest: [String: Any] = [
            "apiVersion": "socialweb.computer/v1alpha1",
            "kind": "EphemeralInfra",
            "metadata": metadata,
            "spec": pageSpec,
        ]
        let path = "\(Self.apiPath)/ephemeralinfras/\(Self.sessionInfraName)?fieldManager=\(Self.fieldManager)&force=true"
        let body = try JSONSerialization.data(withJSONObject: manifest)
        _ = try await kcp.request("PATCH", path, body: body, contentType: "application/apply-patch+yaml")
    }

    private static func resourceVersion(of object: [String: Any]) -> String? {
        (object["metadata"] as? [String: Any])?["resourceVersion"] as? String
    }

    private static func newShortName() -> String {
        let alphabet = Array("abcdefghijklmnopqrstuvwxyz0123456789")
        return String((0..<6).map { _ in alphabet.randomElement()! })
    }

    /// Strictly after `previous`, as the page's releaseRequestedAt does.
    private static func nextStamp(_ previous: String?) -> String {
        let formatter = ISO8601DateFormatter()
        formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
        let now = Date()
        if let previous {
            let parser = ISO8601DateFormatter()
            parser.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
            if let prior = parser.date(from: previous), prior >= now {
                return formatter.string(from: prior.addingTimeInterval(0.001))
            }
        }
        return formatter.string(from: now)
    }
}