801 lines
29 KiB
Swift
801 lines
29 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
|
||
/// What a working thread is doing right now. Nil when nothing is in progress, and from older
|
||
/// gateways.
|
||
var activity: Activity? = nil
|
||
/// What the latest run changed, in total. Nil when it changed no files, and from older gateways.
|
||
var changes: ChangeTotals? = 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)
|
||
}
|
||
}
|
||
|
||
/// One step a thread is taking now, for the working card's activity line.
|
||
struct Activity: Codable, Hashable, Sendable {
|
||
struct Kind: RawRepresentable, Codable, Hashable, Sendable {
|
||
let rawValue: String
|
||
init(rawValue: String) { self.rawValue = rawValue }
|
||
|
||
static let command = Kind(rawValue: "command")
|
||
static let edit = Kind(rawValue: "edit")
|
||
static let search = Kind(rawValue: "search")
|
||
static let web = Kind(rawValue: "web")
|
||
static let subagent = Kind(rawValue: "subagent")
|
||
static let thinking = Kind(rawValue: "thinking")
|
||
static let tool = Kind(rawValue: "tool")
|
||
}
|
||
|
||
var kind: Kind
|
||
/// The command line, file name, search, subagent title or tool name, trimmed by the gateway.
|
||
var text: String
|
||
/// When the step started, for its elapsed time.
|
||
var startedAt: Date?
|
||
}
|
||
|
||
/// What a thread's latest run changed, in total, for the line on a Recent card.
|
||
struct ChangeTotals: Codable, Hashable, Sendable {
|
||
var files: Int
|
||
var additions: Int
|
||
var deletions: Int
|
||
|
||
/// "+42", "−8" and "3 files", the pieces of the card's "+42 −8 · 3 files".
|
||
var parts: (additions: String, deletions: String, files: String) {
|
||
("+\(additions)", "\u{2212}\(deletions)", files == 1 ? "1 file" : "\(files) files")
|
||
}
|
||
}
|
||
|
||
/// A file the latest run changed. `name` is the last part of `path`.
|
||
struct ChangedFile: Codable, Hashable, Sendable, Identifiable {
|
||
var path: String
|
||
var name: String
|
||
var additions: Int
|
||
var deletions: Int
|
||
|
||
var id: String { path }
|
||
}
|
||
|
||
/// A thread search hit: the thread, and the text around the match when it was inside a message.
|
||
struct SearchHit: Codable, Hashable, Sendable, Identifiable {
|
||
var thread: ThreadRow
|
||
var projectTitle: String
|
||
var hostLabel: String
|
||
/// Nil when only the title matched.
|
||
var snippet: String?
|
||
|
||
var id: String { thread.id }
|
||
}
|
||
|
||
/// `GET /v1/search?q=`: hits from every online host, newest match first.
|
||
struct SearchResponse: Codable, Hashable, Sendable {
|
||
var hits: [SearchHit]
|
||
/// Labels of hosts that were offline or failed, so their threads were not searched.
|
||
var unsearched: [String]
|
||
|
||
/// The line under the results naming the hosts left out, such as "Laptop is offline, so its
|
||
/// threads were not searched." `offline` holds the labels of hosts the watch knows are offline;
|
||
/// any other host failed or was too slow, which the gateway does not tell apart.
|
||
static func unsearchedNote(_ labels: [String], offline: Set<String>) -> String? {
|
||
guard !labels.isEmpty else { return nil }
|
||
let names: String = switch labels.count {
|
||
case 1: labels[0]
|
||
case 2: "\(labels[0]) and \(labels[1])"
|
||
default: labels.dropLast().joined(separator: ", ") + " and " + labels[labels.count - 1]
|
||
}
|
||
guard labels.allSatisfy(offline.contains) else { return "\(names) could not be searched." }
|
||
return labels.count == 1
|
||
? "\(names) is offline, so its threads were not searched."
|
||
: "\(names) are offline, so their threads were not searched."
|
||
}
|
||
}
|
||
|
||
/// A thread in a list: a project's threads, or a thread's subagents.
|
||
struct ThreadRow: Codable, Hashable, Sendable, Identifiable {
|
||
var hostId: String
|
||
var threadId: String
|
||
var title: String
|
||
/// Nil for a thread that never ran or was stopped.
|
||
var phase: Phase?
|
||
var settled: Bool
|
||
var updatedAt: Date
|
||
|
||
var id: String { "\(hostId)/\(threadId)" }
|
||
var route: Route { .thread(hostId: hostId, threadId: threadId) }
|
||
|
||
/// "working", "waiting", "settled", "failed" or "done", for a subagent's row.
|
||
var statusWord: String {
|
||
if phase?.isWorking == true { return "working" }
|
||
if phase?.needsYou == true { return "waiting" }
|
||
if settled { return "settled" }
|
||
return phase == .failed ? "failed" : "done"
|
||
}
|
||
|
||
/// False once it finished or settled, which dims its title.
|
||
var isActive: Bool { !settled && (phase?.isWorking == true || phase?.needsYou == true) }
|
||
}
|
||
|
||
/// `GET /v1/projects/:hostId/:projectId/threads`: a project's newest 50 threads, newest first.
|
||
struct ThreadListResponse: Codable, Hashable, Sendable {
|
||
var items: [ThreadRow]
|
||
}
|
||
|
||
/// `GET /v1/threads/:hostId/:threadId/history`: the page before the messages the watch has.
|
||
struct HistoryResponse: Codable, Hashable, Sendable {
|
||
/// Oldest first.
|
||
var messages: [Message]
|
||
/// The cursor for the page before these. Nil at the start of the thread.
|
||
var earlierCursor: String?
|
||
}
|
||
|
||
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`: what the complication and the Smart Stack widget show.
|
||
struct Summary: Codable, Hashable, Sendable {
|
||
struct Next: Codable, Hashable, Sendable {
|
||
var hostId: String
|
||
var threadId: String
|
||
var title: String
|
||
var hostLabel: String
|
||
/// The command it wants to run or its first question, trimmed. Nil until the gateway has read
|
||
/// it, and from older gateways.
|
||
var request: String? = nil
|
||
|
||
var route: Route { .thread(hostId: hostId, threadId: threadId) }
|
||
}
|
||
|
||
/// The working thread updated most recently, with what it is doing.
|
||
struct Current: Codable, Hashable, Sendable {
|
||
var hostId: String
|
||
var threadId: String
|
||
var title: String
|
||
var activity: Activity?
|
||
|
||
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?
|
||
/// Nil when nothing is working, and from older gateways.
|
||
var current: Current? = nil
|
||
var hostsOffline: Int
|
||
}
|
||
|
||
extension Summary {
|
||
/// The same summary worked out from a feed. The feed lists needs-you oldest request first, so its
|
||
/// first item is the one that has waited longest, and working newest first.
|
||
init(_ feed: Feed) {
|
||
let next = feed.needsYou.first
|
||
let current = feed.working.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, request: $0.request?.summary)
|
||
},
|
||
current: current.map { Current(hostId: $0.hostId, threadId: $0.threadId, title: $0.title, activity: $0.activity) },
|
||
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
|
||
/// Pass to the history route for older messages. Nil when `messages` starts the thread, and from
|
||
/// older gateways, which hides Load earlier.
|
||
var earlierCursor: String? = nil
|
||
/// Subagents and delegated threads this thread started, newest first.
|
||
var subagents: [ThreadRow] = []
|
||
/// The files the latest run changed, most lines changed first.
|
||
var changedFiles: [ChangedFile] = []
|
||
}
|
||
|
||
extension ThreadDetail {
|
||
private enum CodingKeys: String, CodingKey {
|
||
case item, messages, canStop, askFirst, earlierCursor, subagents, changedFiles
|
||
}
|
||
|
||
/// `subagents` and `changedFiles` are newer than the rest, so a gateway that leaves them out lists none.
|
||
init(from decoder: any Decoder) throws {
|
||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||
self.init(
|
||
item: try container.decode(FeedItem.self, forKey: .item),
|
||
messages: try container.decode([Message].self, forKey: .messages),
|
||
canStop: try container.decode(Bool.self, forKey: .canStop),
|
||
askFirst: try container.decodeIfPresent(Bool.self, forKey: .askFirst),
|
||
earlierCursor: try container.decodeIfPresent(String.self, forKey: .earlierCursor),
|
||
subagents: try container.decodeIfPresent([ThreadRow].self, forKey: .subagents) ?? [],
|
||
changedFiles: try container.decodeIfPresent([ChangedFile].self, forKey: .changedFiles) ?? [])
|
||
}
|
||
}
|
||
|
||
/// `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, Equatable, 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
|
||
}
|
||
|
||
/// `question` on an `input` push that asks one single-choice question: its options, so the
|
||
/// notification can offer each as a button. Answer with `[id: value]` on the respond route.
|
||
struct PushQuestion: Codable, Hashable, Sendable {
|
||
var id: String
|
||
/// At most 4.
|
||
var options: [PendingRequest.Question.Choice]
|
||
/// Adds a spoken or typed answer.
|
||
var allowCustomAnswer: Bool
|
||
|
||
/// The question in a push's `userInfo`, or nil when the push has none or it does not decode.
|
||
init?(userInfo: [AnyHashable: Any]) {
|
||
guard let object = userInfo["question"], JSONSerialization.isValidJSONObject(object),
|
||
let data = try? JSONSerialization.data(withJSONObject: object),
|
||
let question = try? JSON.decoder().decode(Self.self, from: data)
|
||
else { return nil }
|
||
self = question
|
||
}
|
||
|
||
init(id: String, options: [PendingRequest.Question.Choice], allowCustomAnswer: Bool) {
|
||
self.id = id
|
||
self.options = options
|
||
self.allowCustomAnswer = allowCustomAnswer
|
||
}
|
||
}
|
||
|
||
/// 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)
|
||
}
|
||
}
|