Files
wristcode/watch/WristCode/API/Models.swift
T

610 lines
21 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 {
/// The subagent a request came from, when a thread delegated work and that work waits on you.
struct Via: Codable, Hashable, Sendable {
var hostId: String
var threadId: String
var title: String
}
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?
/// Set when `request` and `phase` belong to a subagent rather than this thread. Older gateways
/// leave it out, which decodes as nil.
var via: Via? = nil
var id: String { "\(hostId)/\(threadId)" }
var route: Route { .thread(hostId: hostId, threadId: threadId) }
/// The thread that answers `request`: the subagent named in `via`, or this thread.
var requestThread: (hostId: String, threadId: String) {
via.map { ($0.hostId, $0.threadId) } ?? (hostId, threadId)
}
}
struct Feed: Codable, Hashable, Sendable {
var needsYou: [FeedItem]
var working: [FeedItem]
/// Finished or failed threads not settled yet, newest first and at most 10.
var recent: [FeedItem]
/// How many threads Recent holds in all. `GET /v1/recent` lists the ones past `recent`.
var recentTotal: Int
var hosts: [HostSummary]
/// `recentTotal` defaults to the threads in `recent`, as when nothing is left out.
init(needsYou: [FeedItem], working: [FeedItem], recent: [FeedItem], recentTotal: Int? = nil, hosts: [HostSummary]) {
self.needsYou = needsYou
self.working = working
self.recent = recent
self.recentTotal = recentTotal ?? recent.count
self.hosts = hosts
}
/// Recent threads the feed leaves out, which the "N more" row counts.
var moreRecent: Int { max(0, recentTotal - recent.count) }
}
extension Feed {
private enum CodingKeys: String, CodingKey {
case needsYou, working, recent, recentTotal, hosts
}
/// `recentTotal` is newer than the rest, so a gateway that leaves it out has nothing past `recent`.
init(from decoder: any Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
self.init(
needsYou: try container.decode([FeedItem].self, forKey: .needsYou),
working: try container.decode([FeedItem].self, forKey: .working),
recent: try container.decode([FeedItem].self, forKey: .recent),
recentTotal: try container.decodeIfPresent(Int.self, forKey: .recentTotal),
hosts: try container.decode([HostSummary].self, forKey: .hosts))
}
}
/// `GET /v1/recent`: every unsettled finished thread, newest first. Older ones may lack `lastMessage`.
struct RecentResponse: Codable, Hashable, Sendable {
var items: [FeedItem]
}
/// `POST /v1/recent/settle`. `failed` counts threads T3 refused or whose host was offline.
struct SettleRecentResponse: Codable, Hashable, Sendable {
var settled: Int
var failed: Int
}
/// `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
/// True when the thread asks before each action (T3's approval-required mode). Older gateways
/// leave it out, which decodes as nil, and the screen hides the switch.
var askFirst: Bool? = nil
}
/// `POST /v1/threads/:hostId/:threadId/mode`.
struct ThreadModeRequest: Codable, Sendable {
var askFirst: 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]
/// The providers that can start a thread now, each with its models. Empty from older
/// gateways, which only start threads on the project's default model.
var providers: [AgentProvider] = []
/// Projects a new thread can start in. A project without a default model is not offered.
var launchable: [Project] { projects.filter { $0.model != nil } }
func provider(_ instanceId: String) -> AgentProvider? {
providers.first { $0.instanceId == instanceId }
}
/// True when this host still offers the model.
func offers(_ model: ModelRef) -> Bool {
provider(model.instanceId)?.models.contains { $0.slug == model.slug } ?? false
}
}
struct Project: Codable, Hashable, Sendable, Identifiable {
var id: String
var title: String
/// The model a new thread uses by default, such as `claude-opus-5-5`.
var model: String?
/// The provider `model` belongs to. Older gateways leave it out.
var instanceId: String? = nil
/// The default model as a pick, when the gateway names its provider.
var defaultModel: ModelRef? {
guard let model, let instanceId else { return nil }
return ModelRef(instanceId: instanceId, slug: model)
}
}
var hosts: [Host]
}
extension ProjectsResponse.Host {
private enum CodingKeys: String, CodingKey {
case id, label, status, error, projects, providers
}
/// `providers` is newer than the rest, so a gateway that leaves it out offers no model list.
init(from decoder: any Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
self.init(
id: try container.decode(String.self, forKey: .id),
label: try container.decode(String.self, forKey: .label),
status: try container.decode(HostStatus.self, forKey: .status),
error: try container.decodeIfPresent(String.self, forKey: .error),
projects: try container.decode([ProjectsResponse.Project].self, forKey: .projects),
providers: try container.decodeIfPresent([AgentProvider].self, forKey: .providers) ?? [])
}
}
/// An agent provider on a host, such as Claude or Codex, with the models a new thread can use.
struct AgentProvider: Codable, Hashable, Sendable, Identifiable {
struct Model: Codable, Hashable, Sendable, Identifiable {
var slug: String
/// Such as "Claude Opus 5.5".
var name: String
var id: String { slug }
}
var instanceId: String
/// Such as "Claude" or "Codex".
var name: String
/// False when the provider cannot run in approval-required mode, so Ask before acting is hidden.
var canAskFirst: Bool
var models: [Model]
var id: String { instanceId }
}
/// A model picked for a new thread: a slug and the provider instance it runs on.
struct ModelRef: Codable, Hashable, Sendable {
var instanceId: String
var slug: String
}
/// How a route reaches its host, guessed by the gateway from the URL.
struct RouteKind: RawRepresentable, Codable, Hashable, Sendable {
let rawValue: String
init(rawValue: String) { self.rawValue = rawValue }
static let direct = RouteKind(rawValue: "direct")
static let vpn = RouteKind(rawValue: "vpn")
static let `public` = RouteKind(rawValue: "public")
/// "direct", "VPN" or "public", for sentences like "Laptop is on its VPN route".
var name: String { self == .vpn ? "VPN" : rawValue }
var symbol: String {
switch self {
case .direct: "house.fill"
case .vpn: "lock.shield.fill"
default: "cloud.fill"
}
}
}
/// One way to reach a host. `Route` in the contract, renamed here because `Route` is the app's
/// navigation.
struct HostRoute: Codable, Hashable, Sendable {
struct Result: RawRepresentable, Codable, Hashable, Sendable {
let rawValue: String
init(rawValue: String) { self.rawValue = rawValue }
static let ok = Result(rawValue: "ok")
static let failed = Result(rawValue: "failed")
static let untried = Result(rawValue: "untried")
}
var url: String
var kind: RouteKind
/// From the latest connect. `untried` when an earlier route answered first.
var result: Result
var error: String?
var checkedAt: Date?
/// The URL without its scheme, such as `laptop.gib:3773`.
var hostPort: String {
guard let components = URLComponents(string: url), let host = components.host else { return url }
return components.port.map { "\(host):\($0)" } ?? host
}
}
/// One host as the status screens show it.
struct HostStatusDetail: Codable, Hashable, Sendable, Identifiable {
var id: String
var label: String
var status: HostStatus
var error: String?
/// The route in use. Null unless online.
var activeUrl: String?
/// Preferred first, the order the gateway tries them.
var routes: [HostRoute]
var onlineSince: Date?
var lastOnlineAt: Date?
/// When the gateway tries again, while it waits to retry.
var nextRetryAt: Date?
var tokenExpiresAt: Date?
var serverVersion: String?
var activeRoute: HostRoute? { routes.first { $0.url == activeUrl } }
/// Online, but not on the first route.
var onFallback: Bool {
status == .online && activeUrl != nil && activeUrl != routes.first?.url
}
}
/// What Apple answered for one push.
struct PushResult: Codable, Hashable, Sendable {
var ok: Bool
var at: Date
/// Apple's HTTP status, or 0 when the push never reached Apple.
var status: Int
/// Apple's reason, such as `BadDeviceToken`, or why the gateway could not send.
var reason: String?
}
/// `GET /v1/status`: everything the settings screens show.
struct StatusResponse: Codable, Hashable, Sendable {
struct Gateway: Codable, Hashable, Sendable {
var startedAt: Date
/// False when the gateway has no APNs key, so nothing can be pushed.
var pushConfigured: Bool
var lastPush: PushResult?
}
/// The watch asking.
struct Device: Codable, Hashable, Sendable {
var name: String
var pairedAt: Date
/// True once the watch has sent its APNs token.
var pushRegistered: Bool
var apnsEnvironment: PushRegistration.Environment?
/// Which pushes this watch gets. Older gateways leave it out, and the screen hides the switches.
var notifications: NotificationPrefs? = nil
}
var hosts: [HostStatusDetail]
var gateway: Gateway
var device: Device
}
/// Which pushes a watch wants. `needsMe` covers approvals and questions on your own threads, and
/// `subagents` covers the same from subagents. Host problems and test pushes always go out.
struct NotificationPrefs: Codable, Hashable, Sendable {
/// One switch, named as `PUT /v1/device/notifications` names it.
enum Key: String, CaseIterable, Sendable {
case needsMe, finished, failed, subagents
var keyPath: WritableKeyPath<NotificationPrefs, Bool> {
switch self {
case .needsMe: \.needsMe
case .finished: \.finished
case .failed: \.failed
case .subagents: \.subagents
}
}
}
var needsMe: Bool
var finished: Bool
var failed: Bool
var subagents: Bool
subscript(key: Key) -> Bool {
get { self[keyPath: key.keyPath] }
set { self[keyPath: key.keyPath] = newValue }
}
}
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
/// A model from the host's `providers`. Nil uses the project's default.
var model: ModelRef? = nil
/// Starts the thread in approval-required mode. Nil or false uses the host's default mode.
var askFirst: Bool? = nil
}
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, test
}
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)
}
}