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

johnandersen777.bsky.social/atirc-ios-client

0
1
main
263 lines · 10.6 KB Raw History
//
//  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
        }
    }
}