A WidgetKit extension (accessoryCircular, Rectangular, Inline, Corner) shows GET /v1/summary. The device token moves to the App Group's Keychain access group on launch so the extension can read it without re-pairing; the app reloads the timeline when its feed counts change and on notifications. Multi-select questions toggle options and send an array of values. Co-Authored-By: Claude Opus 5.5 <[email protected]>
340 lines
11 KiB
Swift
340 lines
11 KiB
Swift
// Codable mirror of gateway/src/contract.ts. docs/api.md describes the same JSON for humans.
|
|
// Change both sides together.
|
|
//
|
|
// String enums from the gateway (phase, host status, approval decision) are modeled as
|
|
// RawRepresentable structs rather than Swift enums, so a value the watch does not know yet still
|
|
// decodes instead of failing the whole feed.
|
|
|
|
import Foundation
|
|
|
|
struct Phase: RawRepresentable, Codable, Hashable, Sendable {
|
|
let rawValue: String
|
|
init(rawValue: String) { self.rawValue = rawValue }
|
|
|
|
static let starting = Phase(rawValue: "starting")
|
|
static let running = Phase(rawValue: "running")
|
|
static let waitingForApproval = Phase(rawValue: "waiting_for_approval")
|
|
static let waitingForInput = Phase(rawValue: "waiting_for_input")
|
|
static let completed = Phase(rawValue: "completed")
|
|
static let failed = Phase(rawValue: "failed")
|
|
|
|
var needsYou: Bool { self == .waitingForApproval || self == .waitingForInput }
|
|
var isWorking: Bool { self == .starting || self == .running }
|
|
}
|
|
|
|
struct HostStatus: RawRepresentable, Codable, Hashable, Sendable {
|
|
let rawValue: String
|
|
init(rawValue: String) { self.rawValue = rawValue }
|
|
|
|
static let connecting = HostStatus(rawValue: "connecting")
|
|
static let online = HostStatus(rawValue: "online")
|
|
static let offline = HostStatus(rawValue: "offline")
|
|
static let authInvalid = HostStatus(rawValue: "auth_invalid")
|
|
static let incompatible = HostStatus(rawValue: "incompatible")
|
|
|
|
/// A short label for a host that is not online, such as "offline" or "needs a new token".
|
|
var problem: String? {
|
|
switch self {
|
|
case .online: nil
|
|
case .connecting: "connecting"
|
|
case .authInvalid: "needs a new token"
|
|
case .incompatible: "incompatible"
|
|
default: "offline"
|
|
}
|
|
}
|
|
}
|
|
|
|
struct ApprovalDecision: RawRepresentable, Codable, Hashable, Sendable {
|
|
let rawValue: String
|
|
init(rawValue: String) { self.rawValue = rawValue }
|
|
|
|
static let accept = ApprovalDecision(rawValue: "accept")
|
|
static let acceptForSession = ApprovalDecision(rawValue: "acceptForSession")
|
|
static let acceptAlways = ApprovalDecision(rawValue: "acceptAlways")
|
|
static let decline = ApprovalDecision(rawValue: "decline")
|
|
static let cancel = ApprovalDecision(rawValue: "cancel")
|
|
}
|
|
|
|
struct HostSummary: Codable, Hashable, Sendable, Identifiable {
|
|
var id: String
|
|
var label: String
|
|
var status: HostStatus
|
|
var error: String?
|
|
}
|
|
|
|
/// Something a thread is waiting on: an approval, or questions for the user.
|
|
struct PendingRequest: Codable, Hashable, Sendable, Identifiable {
|
|
struct Option: Codable, Hashable, Sendable {
|
|
var decision: ApprovalDecision
|
|
var label: String
|
|
}
|
|
|
|
struct Question: Codable, Hashable, Sendable, Identifiable {
|
|
struct Choice: Codable, Hashable, Sendable {
|
|
var label: String
|
|
var value: String
|
|
}
|
|
|
|
var id: String
|
|
var header: String
|
|
var question: String
|
|
var options: [Choice]
|
|
var allowCustomAnswer: Bool
|
|
/// When true, the answer is an array of option values.
|
|
var multiSelect = false
|
|
}
|
|
|
|
var id: String
|
|
/// `command`, `file-change`, `permission`, `user_input`, ...
|
|
var kind: String
|
|
var prompt: String?
|
|
var reason: String?
|
|
var options: [Option]
|
|
var questions: [Question]
|
|
|
|
var isQuestion: Bool { !questions.isEmpty }
|
|
|
|
/// The Allow and Deny pair shown on a feed card, when the server offers both.
|
|
var quickOptions: (allow: Option, deny: Option)? {
|
|
guard let allow = options.first(where: { $0.decision == .accept }),
|
|
let deny = options.first(where: { $0.decision == .decline })
|
|
else { return nil }
|
|
return (allow, deny)
|
|
}
|
|
|
|
/// One line saying what the agent wants, for a feed card.
|
|
var summary: String {
|
|
if let question = questions.first { return question.question }
|
|
if let prompt, !prompt.isEmpty { return prompt }
|
|
if let reason, !reason.isEmpty { return reason }
|
|
return switch kind {
|
|
case "command": "Run a command"
|
|
case "file-change", "file_change": "Change files"
|
|
default: "Approve a request"
|
|
}
|
|
}
|
|
}
|
|
|
|
extension PendingRequest.Question {
|
|
private enum CodingKeys: String, CodingKey {
|
|
case id, header, question, options, allowCustomAnswer, multiSelect
|
|
}
|
|
|
|
/// `multiSelect` is newer than the rest, so a gateway that leaves it out means single-select.
|
|
init(from decoder: any Decoder) throws {
|
|
let container = try decoder.container(keyedBy: CodingKeys.self)
|
|
self.init(
|
|
id: try container.decode(String.self, forKey: .id),
|
|
header: try container.decode(String.self, forKey: .header),
|
|
question: try container.decode(String.self, forKey: .question),
|
|
options: try container.decode([Choice].self, forKey: .options),
|
|
allowCustomAnswer: try container.decode(Bool.self, forKey: .allowCustomAnswer),
|
|
multiSelect: try container.decodeIfPresent(Bool.self, forKey: .multiSelect) ?? false)
|
|
}
|
|
}
|
|
|
|
struct FeedItem: Codable, Hashable, Sendable, Identifiable {
|
|
var hostId: String
|
|
var hostLabel: String
|
|
var threadId: String
|
|
var projectId: String
|
|
var projectTitle: String
|
|
var title: String
|
|
var phase: Phase
|
|
var updatedAt: Date
|
|
var lastMessage: String?
|
|
var request: PendingRequest?
|
|
|
|
var id: String { "\(hostId)/\(threadId)" }
|
|
var route: Route { .thread(hostId: hostId, threadId: threadId) }
|
|
}
|
|
|
|
struct Feed: Codable, Hashable, Sendable {
|
|
var needsYou: [FeedItem]
|
|
var working: [FeedItem]
|
|
var recent: [FeedItem]
|
|
var hosts: [HostSummary]
|
|
}
|
|
|
|
/// `GET /v1/summary`: the counts behind the complication.
|
|
struct Summary: Codable, Hashable, Sendable {
|
|
struct Next: Codable, Hashable, Sendable {
|
|
var hostId: String
|
|
var threadId: String
|
|
var title: String
|
|
var hostLabel: String
|
|
|
|
var route: Route { .thread(hostId: hostId, threadId: threadId) }
|
|
}
|
|
|
|
var needsYou: Int
|
|
var working: Int
|
|
/// The thread that has waited longest for you.
|
|
var next: Next?
|
|
var hostsOffline: Int
|
|
}
|
|
|
|
extension Summary {
|
|
/// The same counts worked out from a feed. The feed lists needs-you oldest request first, so its
|
|
/// first item is the one that has waited longest.
|
|
init(_ feed: Feed) {
|
|
let next = feed.needsYou.first
|
|
self.init(
|
|
needsYou: feed.needsYou.count,
|
|
working: feed.working.count,
|
|
next: next.map { Next(hostId: $0.hostId, threadId: $0.threadId, title: $0.title, hostLabel: $0.hostLabel) },
|
|
hostsOffline: feed.hosts.filter { $0.status != .online }.count)
|
|
}
|
|
}
|
|
|
|
struct Message: Codable, Hashable, Sendable, Identifiable {
|
|
struct Role: RawRepresentable, Codable, Hashable, Sendable {
|
|
let rawValue: String
|
|
init(rawValue: String) { self.rawValue = rawValue }
|
|
static let user = Role(rawValue: "user")
|
|
static let assistant = Role(rawValue: "assistant")
|
|
static let system = Role(rawValue: "system")
|
|
}
|
|
|
|
var id: String
|
|
var role: Role
|
|
var text: String
|
|
var createdAt: Date
|
|
}
|
|
|
|
struct ThreadDetail: Codable, Hashable, Sendable {
|
|
var item: FeedItem
|
|
/// Oldest first, at most 20.
|
|
var messages: [Message]
|
|
var canStop: Bool
|
|
}
|
|
|
|
struct ProjectsResponse: Codable, Hashable, Sendable {
|
|
struct Host: Codable, Hashable, Sendable, Identifiable {
|
|
var id: String
|
|
var label: String
|
|
var status: HostStatus
|
|
var error: String?
|
|
var projects: [Project]
|
|
|
|
/// Projects a new thread can start in. A project without a default model is not offered.
|
|
var launchable: [Project] { projects.filter { $0.model != nil } }
|
|
}
|
|
|
|
struct Project: Codable, Hashable, Sendable, Identifiable {
|
|
var id: String
|
|
var title: String
|
|
var model: String?
|
|
}
|
|
|
|
var hosts: [Host]
|
|
}
|
|
|
|
struct PairRequest: Codable, Sendable {
|
|
var code: String
|
|
var deviceName: String
|
|
}
|
|
|
|
struct PairResponse: Codable, Sendable {
|
|
var deviceId: String
|
|
var deviceToken: String
|
|
}
|
|
|
|
struct PushRegistration: Codable, Sendable {
|
|
enum Environment: String, Codable, Sendable {
|
|
case sandbox, production
|
|
}
|
|
|
|
var apnsToken: String
|
|
var apnsEnvironment: Environment
|
|
}
|
|
|
|
struct SendMessageRequest: Codable, Sendable {
|
|
var text: String
|
|
}
|
|
|
|
enum RespondRequest: Encodable, Sendable {
|
|
/// One question's answer: an option value or typed text, or option values for a multi-select question.
|
|
enum Answer: Encodable, Hashable, Sendable {
|
|
case text(String)
|
|
case values([String])
|
|
|
|
func encode(to encoder: any Encoder) throws {
|
|
var container = encoder.singleValueContainer()
|
|
switch self {
|
|
case let .text(text): try container.encode(text)
|
|
case let .values(values): try container.encode(values)
|
|
}
|
|
}
|
|
}
|
|
|
|
case decision(requestId: String, decision: ApprovalDecision)
|
|
case answers(requestId: String, answers: [String: Answer])
|
|
|
|
private enum CodingKeys: String, CodingKey { case requestId, decision, answers }
|
|
|
|
func encode(to encoder: any Encoder) throws {
|
|
var container = encoder.container(keyedBy: CodingKeys.self)
|
|
switch self {
|
|
case let .decision(requestId, decision):
|
|
try container.encode(requestId, forKey: .requestId)
|
|
try container.encode(decision, forKey: .decision)
|
|
case let .answers(requestId, answers):
|
|
try container.encode(requestId, forKey: .requestId)
|
|
try container.encode(answers, forKey: .answers)
|
|
}
|
|
}
|
|
}
|
|
|
|
struct NewThreadRequest: Codable, Sendable {
|
|
var hostId: String
|
|
var projectId: String
|
|
var text: String
|
|
}
|
|
|
|
struct NewThreadResponse: Codable, Sendable {
|
|
var hostId: String
|
|
var threadId: String
|
|
}
|
|
|
|
struct ErrorResponse: Codable, Sendable {
|
|
var error: String
|
|
}
|
|
|
|
/// Notification categories the watch registers actions for.
|
|
enum PushCategory: String, CaseIterable, Sendable {
|
|
case approval, finished, failed, input, host
|
|
}
|
|
|
|
enum JSON {
|
|
static func decoder() -> JSONDecoder {
|
|
let decoder = JSONDecoder()
|
|
decoder.dateDecodingStrategy = .custom { decoder in
|
|
let container = try decoder.singleValueContainer()
|
|
let string = try container.decode(String.self)
|
|
guard let date = parseDate(string) else {
|
|
throw DecodingError.dataCorruptedError(
|
|
in: container, debugDescription: "Not an ISO 8601 date: \(string)")
|
|
}
|
|
return date
|
|
}
|
|
return decoder
|
|
}
|
|
|
|
static func encoder() -> JSONEncoder {
|
|
let encoder = JSONEncoder()
|
|
encoder.dateEncodingStrategy = .custom { date, encoder in
|
|
var container = encoder.singleValueContainer()
|
|
try container.encode(Date.ISO8601FormatStyle(includingFractionalSeconds: true).format(date))
|
|
}
|
|
return encoder
|
|
}
|
|
|
|
/// Parses ISO 8601 timestamps with or without fractional seconds.
|
|
static func parseDate(_ string: String) -> Date? {
|
|
let withFraction = Date.ISO8601FormatStyle(includingFractionalSeconds: true)
|
|
if let date = try? withFraction.parse(string) { return date }
|
|
return try? Date.ISO8601FormatStyle().parse(string)
|
|
}
|
|
}
|