416 lines
21 KiB
Swift
416 lines
21 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, settling
|
|
// hides a thread from the feed, 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
|
|
/// Ids of settled threads, which the feed leaves out.
|
|
private var settled: Set<String> = []
|
|
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 {
|
|
DemoFixtures.feed(items.filter { !settled.contains($0.id) }, 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
|
|
settled.remove(item.id)
|
|
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."
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Answers go to the thread that asked. For a card showing a subagent's request, that is the
|
|
/// subagent in `via`, and the parent itself has nothing to answer.
|
|
func respond(hostId: String, threadId: String, request: RespondRequest) async throws {
|
|
let index = items.firstIndex { $0.requestThread == (hostId, threadId) }
|
|
?? items.firstIndex { $0.hostId == hostId && $0.threadId == threadId }
|
|
guard let index else { throw GatewayError.http(status: 404, message: "Unknown thread.") }
|
|
try update(items[index].hostId, items[index].threadId) { item in
|
|
guard item.request != nil, item.requestThread == (hostId, threadId) else {
|
|
throw GatewayError.http(status: 409, message: "Already answered.")
|
|
}
|
|
item.request = nil
|
|
item.via = 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 settle(hostId: String, threadId: String) async throws {
|
|
guard let item = item(hostId, threadId) else { throw GatewayError.http(status: 404, message: "Unknown thread.") }
|
|
settled.insert(item.id)
|
|
}
|
|
|
|
func recent() async throws -> RecentResponse {
|
|
RecentResponse(items: DemoFixtures.recent(items.filter { !settled.contains($0.id) }))
|
|
}
|
|
|
|
func settleRecent() async throws -> SettleRecentResponse {
|
|
let completed = DemoFixtures.recent(items.filter { !settled.contains($0.id) }).filter { $0.phase == .completed }
|
|
settled.formUnion(completed.map(\.id))
|
|
return SettleRecentResponse(settled: completed.count, failed: 0)
|
|
}
|
|
|
|
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),
|
|
]))
|
|
|
|
// A subagent's request, shown on the card of the thread that started it.
|
|
let homelab = FeedItem(
|
|
hostId: "mac-mini", hostLabel: "Mac mini", threadId: "t-homelab", projectId: "p-wristlab",
|
|
projectTitle: "WristLab", title: "HomeLab Stats Apple Watch App", phase: .waitingForApproval,
|
|
updatedAt: ago(4), lastMessage: "Delegated a PATH check to the Mac mini. Waiting on it before I wire up the build.",
|
|
request: PendingRequest(
|
|
id: "r-path", kind: "command", prompt: "brew install jq", reason: nil,
|
|
options: [.init(decision: .accept, label: "Allow"), .init(decision: .decline, label: "Deny")],
|
|
questions: []),
|
|
via: .init(hostId: "mac-mini", threadId: "t-path", title: "Check Panama PATH"))
|
|
|
|
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)
|
|
|
|
let statusScreens = FeedItem(
|
|
hostId: "laptop", hostLabel: "Laptop", threadId: "t-status", projectId: "p-wc-laptop",
|
|
projectTitle: "wristcode", title: "Status screens", phase: .completed, updatedAt: ago(35),
|
|
lastMessage: "Done. Status, environment and notification screens are in TestFlight build 202611011516.",
|
|
request: nil)
|
|
|
|
let hyprlock = FeedItem(
|
|
hostId: "server-gib", hostLabel: "server-gib", threadId: "t-hyprlock", projectId: "p-panama",
|
|
projectTitle: "Panama", title: "Fix hyprlock crash", phase: .completed, updatedAt: ago(95),
|
|
lastMessage: "Fixed. hyprlock no longer crashes when a monitor sleeps.", request: nil)
|
|
|
|
// Older finished threads, past the feed's first 10 in Recent. The gateway has not read most of
|
|
// them, so they have no last message.
|
|
func older(_ host: HostSummary, _ id: String, _ project: String, _ title: String, hours: Double, failed: Bool = false) -> FeedItem {
|
|
FeedItem(
|
|
hostId: host.id, hostLabel: host.label, threadId: id, projectId: "p-\(project)", projectTitle: project,
|
|
title: title, phase: failed ? .failed : .completed, updatedAt: ago(hours * 60), lastMessage: nil, request: nil)
|
|
}
|
|
let (mac, server, laptop) = (hosts[0], hosts[1], hosts[2])
|
|
let olderRecent = [
|
|
older(laptop, "t-icons", "wristcode", "App icon variants", hours: 4),
|
|
older(mac, "t-receipts", "bill-tracker", "Receipt upload form", hours: 6),
|
|
older(server, "t-backup", "homelab", "Nightly restic backup", hours: 9, failed: true),
|
|
older(mac, "t-tailwind", "gbrown.org", "Tailwind 5 migration", hours: 20),
|
|
older(server, "t-caddy", "homelab", "Caddy wildcard certs", hours: 27),
|
|
older(laptop, "t-readme", "wristcode", "README pass", hours: 30),
|
|
older(mac, "t-csv", "bill-tracker", "CSV export", hours: 50),
|
|
older(server, "t-immich", "homelab", "Immich upgrade", hours: 72),
|
|
older(mac, "t-lint", "cdn", "Clippy warnings", hours: 96),
|
|
older(laptop, "t-pair", "wristcode", "Pairing code expiry", hours: 120),
|
|
]
|
|
|
|
items = [approval, question, release, homelab, scaffold, containerfile, statusScreens, nextjs, hyprlock, cdn] + olderRecent
|
|
|
|
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?"),
|
|
],
|
|
homelab.id: [
|
|
m("h1", .user, 30, "Build a HomeLab stats app for the watch."),
|
|
m("h2", .assistant, 4, "Delegated a PATH check to the Mac mini. Waiting on it before I wire up the build."),
|
|
],
|
|
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?"),
|
|
],
|
|
statusScreens.id: [
|
|
m("s1", .user, 120, "Build the status, environment and notification screens."),
|
|
m("s2", .assistant, 35, "Done. Status, environment and notification screens are in TestFlight build 202611011516."),
|
|
],
|
|
hyprlock.id: [
|
|
m("k1", .user, 110, "hyprlock crashes when a monitor sleeps. Fix it."),
|
|
m("k2", .assistant, 95, "Fixed. hyprlock no longer crashes when a monitor sleeps."),
|
|
],
|
|
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))
|
|
}
|
|
|
|
/// The feed the gateway builds from these threads: needs-you oldest first, working and Recent
|
|
/// newest first, and only the first 10 of Recent.
|
|
static func feed(_ items: [FeedItem], hosts: [HostSummary]) -> Feed {
|
|
let recent = recent(items)
|
|
return Feed(
|
|
needsYou: items.filter(\.phase.needsYou).sorted { $0.updatedAt < $1.updatedAt },
|
|
working: items.filter(\.phase.isWorking).sorted { $0.updatedAt > $1.updatedAt },
|
|
recent: Array(recent.prefix(10)),
|
|
recentTotal: recent.count,
|
|
hosts: hosts)
|
|
}
|
|
|
|
/// Every finished or failed thread, newest first, as `GET /v1/recent` lists them.
|
|
static func recent(_ items: [FeedItem]) -> [FeedItem] {
|
|
items.filter { $0.phase == .completed || $0.phase == .failed }.sorted { $0.updatedAt > $1.updatedAt }
|
|
}
|
|
|
|
/// A feed for previews, without going through the actor.
|
|
static func previewFeed(now: Date = .now) -> Feed {
|
|
let fixtures = DemoFixtures(now: now)
|
|
return feed(fixtures.items, hosts: fixtures.hosts)
|
|
}
|
|
|
|
/// The full Recent list for previews.
|
|
static func previewRecent(now: Date = .now) -> [FeedItem] {
|
|
recent(DemoFixtures(now: now).items)
|
|
}
|
|
}
|