// 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) } }