// // AgentsView.swift // AT-IRC // // Agents section: the app's agent VMs from the embedded socialweb market, // with launch and release. See AgentsAPI for the flow. // import SwiftUI struct AgentsView: View { let kcp: KCPHost let requesterConfig: () async -> String @State private var store = AgentsStore() @State private var safari: IdentifiableURL? var body: some View { NavigationStack { List { statusSection agentsSection if let errorText = store.errorText { Section { Text(errorText) .font(.footnote) .foregroundStyle(.red) } } } .navigationTitle("Agents") .refreshable { await store.refresh() } .sheet(item: $safari) { target in SafariView(url: target.url) .onDisappear { Task { await store.refresh() } } } .task { // kcp boots only when the Agents section is opened; the chat // never waits on it. The requester starts on top of kcp. await kcp.start() let config = await requesterConfig() let configReady = config != "{}" && !config.isEmpty if configReady { await kcp.startRequester(configJSON: config) } await store.refresh() if !configReady { store.errorText = "The OAuth redirect listener did not start, so the market requester cannot sign in. Relaunch the app and try again." } #if DEBUG if ProcessInfo.processInfo.environment["ATIRC_TEST_AGENT_LAUNCH"] == "1" { // Launch, wait for Held/mint, send guest.agentStart like the // page's Agent button, hold for the join, release in-process. var waited = 0 while store.identityPhase != "Ready" && waited < 90 { try? await Task.sleep(for: .seconds(2)) await store.refresh() waited += 1 } if store.identityPhase == "Ready", let contract = await store.launchAndWait() { try? await Task.sleep(for: .seconds(40)) KCPHost.persistSnapshot() await store.agentStart(contractID: contract) try? await Task.sleep(for: .seconds(240)) await store.releaseAllHeld() } } if ProcessInfo.processInfo.environment["ATIRC_DUMP_WORKSPACE"] == "1" { // Stall diagnosis: the requester compose's Workspace app can // stay Initializing; log why every 5 s. for _ in 0..<24 { await KCPHost.logWorkspaceState() await KCPHost.logPolicyRuns() try? await Task.sleep(for: .seconds(5)) } } if ProcessInfo.processInfo.environment["ATIRC_RELEASE_ALL"] == "1" { // Test hook: release every contract the session infra still // holds, so launch retries do not leave VMs running. The // requester can finish composing after the bounded start, // so wait for its API before releasing. for _ in 0..<30 { await store.refresh() if store.availability == .ready { await store.releaseAllHeld() break } try? await Task.sleep(for: .seconds(10)) } } if ProcessInfo.processInfo.environment["ATIRC_MARKET_SIGNIN"] == "1", store.identityPhase != "Ready" { await store.beginSignIn(requesterConfigJSON: config) } if ProcessInfo.processInfo.environment["ATIRC_TEST_LAUNCH"] == "1" { // The bounded requester start can return before the // MarketIdentity finishes composing; wait for Ready so the // test launch does not race it. var waited = 0 while store.identityPhase != "Ready" && waited < 90 { try? await Task.sleep(for: .seconds(2)) await store.refresh() waited += 1 } if store.identityPhase == "Ready" { await store.launch() // The mint lands a few seconds after the contract; give // it time, then persist vault+infra so a restarted app // can revoke before terminating the VM. try? await Task.sleep(for: .seconds(40)) KCPHost.persistSnapshot() for _ in 0..<24 { try? await Task.sleep(for: .seconds(5)) await KCPHost.logPolicyRuns() } } } #endif } .toolbar { ToolbarItem(placement: .topBarTrailing) { Button { Task { await store.launch() } } label: { if store.launching { ProgressView().controlSize(.small) } else { Label("Launch", systemImage: "plus") } } .disabled(store.launching || store.availability != .ready) } } } } private var statusSection: some View { Section("Market") { switch kcp.state { case .idle, .starting: HStack(spacing: 8) { ProgressView().controlSize(.small) Text("Starting the embedded kcp…").foregroundStyle(.secondary) } case .failed(let message): Text("kcp failed: \(message)") .font(.footnote) .foregroundStyle(.red) case .ready: switch store.availability { case .unknown: Text("Checking the embedded kcp…").foregroundStyle(.secondary) case .ready: if !store.identityPhase.isEmpty && store.identityPhase != "Ready" { Button("Sign in to market") { if let url = store.signInURL { safari = IdentifiableURL(url: url) } else { Task { await store.beginSignIn( requesterConfigJSON: await requesterConfig() ) if let url = store.signInURL { safari = IdentifiableURL(url: url) } } } } Text("Sign in to let the requester act as you. Only needed once.") .font(.footnote) .foregroundStyle(.secondary) } else if !store.identityPhase.isEmpty { Text("market identity: \(store.identityPhase)") .font(.footnote) .foregroundStyle(.secondary) } Text("socialweb requester ready").foregroundStyle(.green) case .unavailable(let reason): Text(reason) .font(.footnote) .foregroundStyle(.secondary) } Button("Stop embedded kcp", role: .destructive) { kcp.stop() store.refreshAfterStop() } .font(.footnote) Text("Frees the embedded control plane; Agents restarts it on demand.") .font(.footnote) .foregroundStyle(.secondary) } } } private var agentsSection: some View { Section("Agent VMs") { if store.agentContracts.isEmpty { Text("No agent launches yet.") .foregroundStyle(.secondary) } else { ForEach(store.agentContracts) { contract in AgentRow(contract: contract, store: store) } } } } } private struct AgentRow: View { let contract: AgentContract let store: AgentsStore var body: some View { VStack(alignment: .leading, spacing: 4) { HStack { Text(contract.id) .font(.callout.monospaced()) .lineLimit(1) .truncationMode(.middle) Spacer() Text(contract.phase) .font(.caption) .foregroundStyle(phaseColor) } if let vm = contract.spec?.vmName { Text(vm) .font(.caption2) .foregroundStyle(.secondary) } if contract.phase != "Released" { HStack(spacing: 12) { if contract.phase == "Held" { Button("Start agent") { Task { await store.agentStart(contractID: contract.id) } } .buttonStyle(.borderedProminent) .controlSize(.small) } Button("Release") { Task { await store.release(contract) } } .buttonStyle(.bordered) .controlSize(.small) } } } .padding(.vertical, 2) } private var phaseColor: Color { switch contract.phase { case "Held": return .green case "Released": return .secondary case "Failed": return .red default: return .orange } } }