ai-dispatch 10.40.0

Multi-AI CLI team orchestrator
// Codable DTOs and mapping from GET /api/fleet into FleetSnapshot models.
// Exports: FleetAPIDecoder.

import Foundation

enum FleetAPIDecoder {
    static func decodeSnapshot(from data: Data, connection: ConnectionState) throws -> FleetSnapshot {
        let dto = try JSONDecoder().decode(FleetDTO.self, from: data)
        return map(dto, connection: connection)
    }

    static func decodeAction(from data: Data) throws -> MissionActionResult {
        let dto = try JSONDecoder().decode(ActionDTO.self, from: data)
        return MissionActionResult(ok: dto.ok, message: dto.error ?? (dto.ok ? "ok" : "failed"))
    }

    static func decodeDiff(from data: Data) throws -> String {
        try JSONDecoder().decode(DiffDTO.self, from: data).diff
    }

    static func decodeResult(from data: Data) throws -> String {
        try JSONDecoder().decode(ResultDTO.self, from: data).result
    }

    static func decodeTaskDetail(from data: Data) throws -> (mission: Mission, prompt: String, events: [MissionEvent]) {
        let dto = try JSONDecoder().decode(TaskDTO.self, from: data)
        let mission = mapTask(dto, medians: [:])
        let events = dto.latest_events?.map {
            MissionEvent(id: "\($0.timestamp)-\($0.event_kind)", message: $0.detail)
        } ?? []
        return (mission, dto.prompt ?? dto.prompt_excerpt ?? "—", events)
    }

    static func map(_ dto: FleetDTO, connection: ConnectionState) -> FleetSnapshot {
        var sectors: [Sector] = []
        for (index, sector) in dto.sectors.enumerated() {
            let tag = "SEC-\(String(format: "%02d", index + 1))"
            let missions = sector.tasks.map { mapTask($0, medians: [:]) }
            let wg = sector.workgroup_id.map(stripWG) ?? ""
            sectors.append(Sector(
                id: sector.id,
                tag: tag,
                name: sector.name,
                workgroupID: wg,
                missions: missions
            ))
        }
        let medians = ProgressDeriver.medianDurations(from: sectors)
        for s in sectors.indices {
            sectors[s].missions = sectors[s].missions.map { remapped($0, medians: medians) }
        }
        let summary = FleetSummary(
            window: dto.summary.window,
            running: dto.summary.running,
            done: dto.summary.done,
            failed: dto.summary.failed,
            spendUSD: formatSpend(dto.summary.spend_usd),
            memoryMB: Int(dto.summary.memory_mb ?? 0),
            sectorCount: sectors.count
        )
        let agents = dto.agents.map {
            AgentInfo(
                id: $0.name,
                busy: $0.busy,
                quotaOK: ($0.quota?.state ?? "unknown") == "ok",
                taskCount: measuredTaskCount($0.task_count)
            )
        }
        return FleetSnapshot(
            sectors: sectors,
            summary: summary,
            serverVersion: dto.server.version,
            connection: connection,
            agents: agents,
            tick: 0
        )
    }

    static func mapTask(_ dto: TaskDTO, medians: [String: [Int]]) -> Mission {
        let state = StatusMapper.displayState(from: dto.status)
        let elapsed = elapsedSeconds(dto)
        let progress: Double
        switch state {
        case .done: progress = 1
        case .run: progress = ProgressDeriver.progress(
            elapsedSeconds: elapsed, agent: dto.agent, completedDurations: medians
        )
        case .fail, .stop: progress = 0
        }
        let startedAt = dto.duration_ms == nil ? parseDate(dto.started_at) : nil
        return Mission(
            id: dto.id,
            title: title(for: dto),
            agent: dto.agent,
            model: dto.observed_model ?? dto.requested_model,
            state: state,
            threat: threat(from: dto.difficulty),
            progress: progress,
            elapsedSeconds: elapsed,
            startedAt: state == .run ? startedAt : nil,
            tokens: formatTokens(dto.tokens),
            cost: formatCost(dto.cost_usd),
            memoryMB: dto.memory_mb.map { "\($0)M" },
            verifyTag: StatusMapper.verifyTag(from: dto.outcome),
            awaitingReason: dto.awaiting_reason
        )
    }

    private static func remapped(_ mission: Mission, medians: [String: [Int]]) -> Mission {
        guard mission.state == .run else { return mission }
        let progress = ProgressDeriver.progress(
            elapsedSeconds: mission.elapsedSeconds,
            agent: mission.agent,
            completedDurations: medians
        )
        return Mission(
            id: mission.id, title: mission.title, agent: mission.agent, model: mission.model,
            state: mission.state, threat: mission.threat, progress: progress,
            elapsedSeconds: mission.elapsedSeconds, startedAt: mission.startedAt,
            tokens: mission.tokens, cost: mission.cost,
            memoryMB: mission.memoryMB, verifyTag: mission.verifyTag,
            awaitingReason: mission.awaitingReason
        )
    }

    private static func title(for dto: TaskDTO) -> String {
        if let excerpt = dto.prompt_excerpt, !excerpt.isEmpty { return excerpt }
        if let milestone = dto.latest_milestone, !milestone.isEmpty { return milestone }
        return dto.id
    }

    private static func elapsedSeconds(_ dto: TaskDTO) -> Int {
        if let ms = dto.duration_ms { return max(0, Int(ms / 1000)) }
        if let started = parseDate(dto.started_at) {
            return max(0, Int(Date().timeIntervalSince(started)))
        }
        return 0
    }

    private static func parseDate(_ string: String?) -> Date? {
        guard let string, !string.isEmpty else { return nil }
        let withFraction = ISO8601DateFormatter()
        withFraction.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
        if let date = withFraction.date(from: string) { return date }
        let basic = ISO8601DateFormatter()
        basic.formatOptions = [.withInternetDateTime]
        return basic.date(from: string)
    }

    private static func threat(from difficulty: String?) -> Int? {
        guard let difficulty else { return nil }
        switch difficulty.lowercased() {
        case "trivial": return 1
        case "simple": return 2
        case "moderate": return 3
        case "complex": return 4
        default: return nil
        }
    }

    private static func formatSpend(_ value: Double?) -> String? {
        guard let value else { return nil }
        return String(format: "$%.2f", value)
    }

    private static func formatCost(_ value: Double?) -> String? {
        guard let value else { return nil }
        return String(format: "$%.2f", value)
    }

    private static func formatTokens(_ value: Int64?) -> String? {
        guard let value else { return nil }
        if value >= 1_000_000 {
            return String(format: "%.1fM", Double(value) / 1_000_000)
        }
        if value >= 1_000 {
            return String(format: "%.1fK", Double(value) / 1_000)
        }
        return "\(value)"
    }

    private static func stripWG(_ id: String) -> String {
        id.hasPrefix("wg-") ? String(id.dropFirst(3)) : id
    }

    /// Server currently sends 0 when it withholds a sample — treat as unmeasured.
    private static func measuredTaskCount(_ value: UInt64?) -> Int? {
        guard let value, value > 0 else { return nil }
        return Int(value)
    }
}

struct FleetDTO: Decodable {
    let server: ServerDTO
    let summary: SummaryDTO
    let sectors: [SectorDTO]
    let agents: [AgentDTO]
}

struct ServerDTO: Decodable {
    let version: String
    let host: String
    let port: Int
    let started_at: String
    let aid_home: String
}

struct SummaryDTO: Decodable {
    let running: Int
    let done: Int
    let failed: Int
    let stopped: Int?
    let spend_usd: Double?
    let tokens: Int64?
    let memory_mb: Int64?
    let window: String
}

struct SectorDTO: Decodable {
    let id: String
    let name: String
    let repo_path: String?
    let workgroup_id: String?
    let tasks: [TaskDTO]
}

struct TaskDTO: Decodable {
    let id: String
    let agent: String
    let status: String
    let outcome: String?
    let prompt: String?
    let prompt_excerpt: String?
    let requested_model: String?
    let observed_model: String?
    let tokens: Int64?
    let cost_usd: Double?
    let duration_ms: Int64?
    let started_at: String?
    let difficulty: String?
    let memory_mb: Int64?
    let awaiting_reason: String?
    let latest_milestone: String?
    let latest_events: [EventDTO]?
}

struct EventDTO: Decodable {
    let timestamp: String
    let event_kind: String
    let detail: String
}

struct AgentDTO: Decodable {
    let name: String
    let busy: Bool
    let task_count: UInt64?
    let quota: QuotaDTO?
}

struct QuotaDTO: Decodable {
    let state: String?
}

struct ActionDTO: Decodable {
    let ok: Bool
    let error: String?
    let new_task_id: String?
}

struct DiffDTO: Decodable { let diff: String }
struct ResultDTO: Decodable { let result: String }