// // 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) } }