A gear on the feed opens a status overview that leads with what is wrong: offline hosts, hosts on a fallback route, tokens expiring within 7 days, and push problems, with healthy hosts folded into one row. Each host opens its route ladder with Reconnect. The notifications page shows registration, the last push, Send test and Unpair. Demo mode gains a Laptop on its VPN fallback, and snapshot tests render the three screens.
320 lines
16 KiB
Swift
320 lines
16 KiB
Swift
// Fixture data shaped like the docs/api.md examples, served by an in-memory gateway. Demo mode and
|
|
// SwiftUI previews use it, so every screen renders without a live gateway. It reacts to writes:
|
|
// answering an approval moves the thread to working, a reply restarts a finished thread, and so on.
|
|
|
|
import Foundation
|
|
|
|
actor DemoGateway: GatewayAPI {
|
|
static let shared = DemoGateway()
|
|
|
|
private var items: [FeedItem]
|
|
private var messages: [String: [Message]]
|
|
private let hosts: [HostSummary]
|
|
private let projectHosts: [ProjectsResponse.Host]
|
|
private var statusResponse: StatusResponse
|
|
private var nextId = 1
|
|
|
|
init(now: Date = .now) {
|
|
let fixtures = DemoFixtures(now: now)
|
|
items = fixtures.items
|
|
messages = fixtures.messages
|
|
hosts = fixtures.hosts
|
|
projectHosts = fixtures.projectHosts
|
|
statusResponse = fixtures.status
|
|
}
|
|
|
|
func feed() async throws -> Feed {
|
|
let needsYou = items.filter(\.phase.needsYou).sorted { $0.updatedAt < $1.updatedAt }
|
|
let working = items.filter(\.phase.isWorking).sorted { $0.updatedAt > $1.updatedAt }
|
|
let recent = items.filter { $0.phase == .completed || $0.phase == .failed }
|
|
.sorted { $0.updatedAt > $1.updatedAt }
|
|
return Feed(needsYou: needsYou, working: working, recent: Array(recent.prefix(10)), hosts: hosts)
|
|
}
|
|
|
|
func summary() async throws -> Summary {
|
|
Summary(try await feed())
|
|
}
|
|
|
|
func thread(hostId: String, threadId: String) async throws -> ThreadDetail {
|
|
guard let item = item(hostId, threadId) else { throw GatewayError.http(status: 404, message: "Unknown thread.") }
|
|
return ThreadDetail(item: item, messages: messages[item.id] ?? [], canStop: item.phase.isWorking)
|
|
}
|
|
|
|
func sendMessage(hostId: String, threadId: String, text: String) async throws {
|
|
try update(hostId, threadId) { item in
|
|
append(Message(id: newId(), role: .user, text: text, createdAt: .now), to: item.id)
|
|
if !item.phase.isWorking {
|
|
item.phase = .running
|
|
item.lastMessage = "Working on it."
|
|
}
|
|
}
|
|
}
|
|
|
|
func respond(hostId: String, threadId: String, request: RespondRequest) async throws {
|
|
try update(hostId, threadId) { item in
|
|
guard item.request != nil else { throw GatewayError.http(status: 409, message: "Already answered.") }
|
|
item.request = nil
|
|
switch request {
|
|
case let .decision(_, decision) where decision == .decline || decision == .cancel:
|
|
item.phase = .completed
|
|
item.lastMessage = "OK, I won't run it. Tell me what to do instead."
|
|
default:
|
|
item.phase = .running
|
|
item.lastMessage = "Thanks. Continuing."
|
|
}
|
|
if let line = item.lastMessage {
|
|
append(Message(id: newId(), role: .assistant, text: line, createdAt: .now), to: item.id)
|
|
}
|
|
}
|
|
}
|
|
|
|
func stop(hostId: String, threadId: String) async throws {
|
|
try update(hostId, threadId) { item in
|
|
item.phase = .completed
|
|
item.lastMessage = "Stopped."
|
|
}
|
|
}
|
|
|
|
func projects() async throws -> ProjectsResponse {
|
|
ProjectsResponse(hosts: projectHosts)
|
|
}
|
|
|
|
func newThread(_ request: NewThreadRequest) async throws -> NewThreadResponse {
|
|
guard let host = projectHosts.first(where: { $0.id == request.hostId }),
|
|
let project = host.projects.first(where: { $0.id == request.projectId })
|
|
else { throw GatewayError.http(status: 404, message: "Unknown project.") }
|
|
let threadId = "demo-\(newId())"
|
|
let item = FeedItem(
|
|
hostId: host.id, hostLabel: host.label, threadId: threadId, projectId: project.id,
|
|
projectTitle: project.title, title: String(request.text.prefix(40)), phase: .starting,
|
|
updatedAt: .now, lastMessage: nil, request: nil)
|
|
items.append(item)
|
|
messages[item.id] = [Message(id: newId(), role: .user, text: request.text, createdAt: .now)]
|
|
return NewThreadResponse(hostId: host.id, threadId: threadId)
|
|
}
|
|
|
|
func registerPush(_ registration: PushRegistration) async throws {}
|
|
|
|
func status() async throws -> StatusResponse {
|
|
statusResponse
|
|
}
|
|
|
|
func reconnect(hostId: String) async throws {}
|
|
|
|
func testPush() async throws -> PushResult {
|
|
let result = PushResult(ok: true, at: .now, status: 200, reason: nil)
|
|
statusResponse.gateway.lastPush = result
|
|
return result
|
|
}
|
|
|
|
func unpair() async throws {}
|
|
|
|
// MARK: - Helpers
|
|
|
|
private func item(_ hostId: String, _ threadId: String) -> FeedItem? {
|
|
items.first { $0.hostId == hostId && $0.threadId == threadId }
|
|
}
|
|
|
|
private func update(_ hostId: String, _ threadId: String, _ change: (inout FeedItem) throws -> Void) throws {
|
|
guard let index = items.firstIndex(where: { $0.hostId == hostId && $0.threadId == threadId }) else {
|
|
throw GatewayError.http(status: 404, message: "Unknown thread.")
|
|
}
|
|
try change(&items[index])
|
|
items[index].updatedAt = .now
|
|
}
|
|
|
|
private func append(_ message: Message, to key: String) {
|
|
messages[key, default: []].append(message)
|
|
}
|
|
|
|
private func newId() -> String {
|
|
nextId += 1
|
|
return "m\(nextId)"
|
|
}
|
|
}
|
|
|
|
struct DemoFixtures {
|
|
let items: [FeedItem]
|
|
let messages: [String: [Message]]
|
|
let hosts: [HostSummary]
|
|
let projectHosts: [ProjectsResponse.Host]
|
|
let status: StatusResponse
|
|
|
|
init(now: Date) {
|
|
func ago(_ minutes: Double) -> Date { now.addingTimeInterval(-minutes * 60) }
|
|
|
|
hosts = [
|
|
HostSummary(id: "mac-mini", label: "Mac mini", status: .online, error: nil),
|
|
HostSummary(id: "server-gib", label: "server-gib", status: .online, error: nil),
|
|
HostSummary(id: "laptop", label: "Laptop", status: .online, error: nil),
|
|
HostSummary(id: "littleman", label: "LittleMan", status: .offline, error: "Connection refused"),
|
|
]
|
|
|
|
let approval = FeedItem(
|
|
hostId: "mac-mini", hostLabel: "Mac mini", threadId: "t-ocr", projectId: "p-bill",
|
|
projectTitle: "bill-tracker", title: "Receipt OCR", phase: .waitingForApproval,
|
|
updatedAt: ago(3), lastMessage: "Parser and tests are written. I want to run the suite.",
|
|
request: PendingRequest(
|
|
id: "r-ocr", kind: "command", prompt: "bun test --filter ocr", reason: nil,
|
|
options: [
|
|
.init(decision: .accept, label: "Allow"),
|
|
.init(decision: .acceptForSession, label: "Allow this session"),
|
|
.init(decision: .decline, label: "Deny"),
|
|
],
|
|
questions: []))
|
|
|
|
let question = FeedItem(
|
|
hostId: "server-gib", hostLabel: "server-gib", threadId: "t-db", projectId: "p-wc",
|
|
projectTitle: "wristcode", title: "Gateway storage", phase: .waitingForInput,
|
|
updatedAt: ago(1), lastMessage: "Two ways to store pairing codes.",
|
|
request: PendingRequest(
|
|
id: "r-db", kind: "user_input", prompt: nil, reason: nil, options: [],
|
|
questions: [
|
|
.init(
|
|
id: "q1", header: "Storage", question: "Hash pairing codes or store them plain?",
|
|
options: [.init(label: "Hash them", value: "hash"), .init(label: "Plain", value: "plain")],
|
|
allowCustomAnswer: true),
|
|
]))
|
|
|
|
let release = FeedItem(
|
|
hostId: "mac-mini", hostLabel: "Mac mini", threadId: "t-release", projectId: "p-wc-mac",
|
|
projectTitle: "wristcode", title: "Release checklist", phase: .waitingForInput,
|
|
updatedAt: ago(6), lastMessage: "The build is ready. Which checks should run first?",
|
|
request: PendingRequest(
|
|
id: "r-release", kind: "user_input", prompt: nil, reason: nil, options: [],
|
|
questions: [
|
|
.init(
|
|
id: "checks", header: "Checks", question: "Which checks should run before upload?",
|
|
options: [
|
|
.init(label: "Unit tests", value: "unit"),
|
|
.init(label: "UI tests", value: "ui"),
|
|
.init(label: "Lint", value: "lint"),
|
|
.init(label: "Archive", value: "archive"),
|
|
],
|
|
allowCustomAnswer: true, multiSelect: true),
|
|
]))
|
|
|
|
let scaffold = FeedItem(
|
|
hostId: "mac-mini", hostLabel: "Mac mini", threadId: "t-scaffold", projectId: "p-wc-mac",
|
|
projectTitle: "wristcode", title: "Watch app scaffold", phase: .running, updatedAt: ago(0.5),
|
|
lastMessage: "Writing ThreadListView.swift", request: nil)
|
|
|
|
let containerfile = FeedItem(
|
|
hostId: "server-gib", hostLabel: "server-gib", threadId: "t-container", projectId: "p-wc",
|
|
projectTitle: "wristcode", title: "Gateway Containerfile", phase: .running, updatedAt: ago(2),
|
|
lastMessage: "Running podman build", request: nil)
|
|
|
|
let nextjs = FeedItem(
|
|
hostId: "littleman", hostLabel: "LittleMan", threadId: "t-next", projectId: "p-site",
|
|
projectTitle: "gbrown.org", title: "Bump Next.js", phase: .completed, updatedAt: ago(62),
|
|
lastMessage: "Done. Next.js is on 17.1 and the build passes. Want me to commit it?", request: nil)
|
|
|
|
let cdn = FeedItem(
|
|
hostId: "mac-mini", hostLabel: "Mac mini", threadId: "t-cdn", projectId: "p-cdn",
|
|
projectTitle: "cdn", title: "Fix CDN headers", phase: .failed, updatedAt: ago(140),
|
|
lastMessage: "cargo test failed: 2 tests in headers.rs", request: nil)
|
|
|
|
items = [approval, question, release, scaffold, containerfile, nextjs, cdn]
|
|
|
|
func m(_ id: String, _ role: Message.Role, _ minutes: Double, _ text: String) -> Message {
|
|
Message(id: id, role: role, text: text, createdAt: ago(minutes))
|
|
}
|
|
|
|
messages = [
|
|
approval.id: [
|
|
m("a1", .user, 20, "Add OCR for receipts. Use the existing parser module."),
|
|
m("a2", .assistant, 12, "I'll add a receipt parser in src/ocr and tests beside it."),
|
|
m("a3", .assistant, 3, "Parser and tests are written. I want to run the suite."),
|
|
],
|
|
question.id: [
|
|
m("b1", .user, 10, "Add pairing codes to the gateway."),
|
|
m("b2", .assistant, 1, "Two ways to store pairing codes. Hashing is safer, plain is simpler to debug."),
|
|
],
|
|
release.id: [
|
|
m("r1", .user, 25, "Get the watch app ready for TestFlight."),
|
|
m("r2", .assistant, 6, "The build is ready. Which checks should run first?"),
|
|
],
|
|
scaffold.id: [
|
|
m("c1", .user, 4, "Scaffold the watch app with XcodeGen."),
|
|
m("c2", .assistant, 0.5, "Writing ThreadListView.swift"),
|
|
],
|
|
containerfile.id: [
|
|
m("d1", .user, 15, "Write a Containerfile for the gateway."),
|
|
m("d2", .assistant, 2, "Running podman build"),
|
|
],
|
|
nextjs.id: [
|
|
m("e1", .user, 90, "Bump Next.js to the latest release."),
|
|
m("e2", .assistant, 80, "Updating next and eslint-config-next, then running the build."),
|
|
m("e3", .assistant, 62, "Done. Next.js is on 17.1 and the build passes. Want me to commit it?"),
|
|
],
|
|
cdn.id: [
|
|
m("f1", .user, 150, "Fix the cache headers the CDN sends for images."),
|
|
m("f2", .assistant, 140, "cargo test failed: 2 tests in headers.rs"),
|
|
],
|
|
]
|
|
|
|
projectHosts = [
|
|
.init(
|
|
id: "mac-mini", label: "Mac mini", status: .online, error: nil,
|
|
projects: [
|
|
.init(id: "p-wc-mac", title: "wristcode", model: "claude-opus-5-5"),
|
|
.init(id: "p-bill", title: "bill-tracker", model: "claude-sonnet-5-5"),
|
|
.init(id: "p-cdn", title: "cdn", model: "gpt-5-codex"),
|
|
.init(id: "p-scratch", title: "scratch", model: nil),
|
|
]),
|
|
.init(
|
|
id: "server-gib", label: "server-gib", status: .online, error: nil,
|
|
projects: [.init(id: "p-wc", title: "wristcode", model: "claude-opus-5-5")]),
|
|
.init(
|
|
id: "littleman", label: "LittleMan", status: .offline, error: "Connection refused",
|
|
projects: [.init(id: "p-site", title: "gbrown.org", model: "claude-opus-5-5")]),
|
|
]
|
|
|
|
// Mac mini and server-gib on their first route, Laptop on its VPN fallback, LittleMan offline.
|
|
func route(_ url: String, _ kind: RouteKind, _ result: HostRoute.Result, _ error: String? = nil, _ minutes: Double? = nil) -> HostRoute {
|
|
HostRoute(url: url, kind: kind, result: result, error: error, checkedAt: minutes.map(ago))
|
|
}
|
|
func days(_ count: Double) -> Date { now.addingTimeInterval(count * 86_400) }
|
|
status = StatusResponse(
|
|
hosts: [
|
|
HostStatusDetail(
|
|
id: "mac-mini", label: "Mac mini", status: .online, error: nil, activeUrl: "http://mac.gib:3773",
|
|
routes: [route("http://mac.gib:3773", .direct, .ok, nil, 95), route("https://t3mac.gbrown.org", .public, .untried)],
|
|
onlineSince: ago(95), lastOnlineAt: nil, nextRetryAt: nil, tokenExpiresAt: days(29), serverVersion: "0.1.5"),
|
|
HostStatusDetail(
|
|
id: "server-gib", label: "server-gib", status: .online, error: nil, activeUrl: "http://server.gib:3773",
|
|
routes: [route("http://server.gib:3773", .direct, .ok, nil, 300), route("https://t3server.gbrown.org", .public, .untried)],
|
|
onlineSince: ago(300), lastOnlineAt: nil, nextRetryAt: nil, tokenExpiresAt: days(5.5), serverVersion: "0.1.5"),
|
|
HostStatusDetail(
|
|
id: "laptop", label: "Laptop", status: .online, error: nil, activeUrl: "http://laptopvpn.gib:3773",
|
|
routes: [
|
|
route("http://laptop.gib:3773", .direct, .failed, "no answer in 5 s", 29),
|
|
route("http://laptopvpn.gib:3773", .vpn, .ok, nil, 29),
|
|
route("https://t3laptop.gbrown.org", .public, .untried),
|
|
route("https://t3laptopvpn.gbrown.org", .public, .untried),
|
|
],
|
|
onlineSince: ago(29), lastOnlineAt: nil, nextRetryAt: nil, tokenExpiresAt: days(29.5), serverVersion: "0.1.5"),
|
|
HostStatusDetail(
|
|
id: "littleman", label: "LittleMan", status: .offline, error: "Connection refused", activeUrl: nil,
|
|
routes: [
|
|
route("http://littleman.gib:3773", .direct, .failed, "refused", 0.3),
|
|
route("https://t3littleman.gbrown.org", .public, .failed, "502", 0.3),
|
|
],
|
|
onlineSince: nil, lastOnlineAt: ago(4), nextRetryAt: now.addingTimeInterval(40),
|
|
tokenExpiresAt: days(29.5), serverVersion: "0.1.4"),
|
|
],
|
|
gateway: .init(startedAt: ago(600), pushConfigured: true, lastPush: PushResult(ok: true, at: ago(2), status: 200, reason: nil)),
|
|
device: .init(name: "Apple Watch", pairedAt: days(-3), pushRegistered: true, apnsEnvironment: .production))
|
|
}
|
|
|
|
/// A feed for previews, without going through the actor.
|
|
static func previewFeed(now: Date = .now) -> Feed {
|
|
let fixtures = DemoFixtures(now: now)
|
|
return Feed(
|
|
needsYou: fixtures.items.filter(\.phase.needsYou),
|
|
working: fixtures.items.filter(\.phase.isWorking),
|
|
recent: fixtures.items.filter { !$0.phase.needsYou && !$0.phase.isWorking },
|
|
hosts: fixtures.hosts)
|
|
}
|
|
}
|