cocottetech/@platform/codebase/@features/ai-copilot/cockpit-kit/Sources/CocotteCockpitKit/CockpitModel.swift
Natalie d114d9d375 feat(cockpit-kit): 📸 add bump screenshot overlay
Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
2026-06-10 05:00:56 -07:00

233 lines
12 KiB
Swift

import Observation
import Foundation
// Observable app state the behavior layer. Buttons call these methods; the
// UI is a function of this state. Unit-tested so "buttons work" is provable in CI.
/// Live-data reachability, set by `refresh()`. `.idle` until the first refresh;
/// `.live` once at least one server read succeeds; `.offline` if every read fails.
/// The title-bar badge reads this so "live" is only ever claimed when a fetch
/// actually returned (a swallowed error `.offline`, never a false "live").
public enum ConnectionState: Sendable { case idle, live, offline }
@MainActor
@Observable
public final class CockpitModel {
public private(set) var pending: [PendingApproval]
public private(set) var drops: [ContentDrop]
public private(set) var actions: [AgentAction]
public private(set) var specialists: [Specialist]
public private(set) var assets: [Asset]
public private(set) var metrics: [SurfaceMetric]
public private(set) var receipts: [ApprovalReceipt] = []
/// Ingestion control + progress (nil until first loaded from the API).
public private(set) var ingest: IngestStatus?
/// Newest availability-bump overlay screenshot (PNG bytes); nil until one is
/// captured the durable "what the automation focused on" record.
public private(set) var bumpScreenshot: Data?
public private(set) var connection: ConnectionState = .idle
private let api: CockpitAPI
/// Public entry. The **demo** source (`MockCockpitAPI`) seeds the sample dataset
/// for instant UI. A **live** source seeds EMPTY the cockpit must never show
/// sample data dressed as real; `refresh()` then fills in whatever the backend
/// actually has, and an empty/unavailable backend stays visibly empty.
public convenience init(api: CockpitAPI = MockCockpitAPI()) {
if api is MockCockpitAPI {
self.init(pending: Mock.pending, drops: Mock.drops, actions: Mock.actions,
specialists: Mock.specialists, assets: Mock.assets, metrics: Mock.metrics, api: api)
} else {
self.init(pending: [], drops: [], actions: [],
specialists: [], assets: [], metrics: [], api: api)
}
}
/// Designated init (internal tests inject custom fixtures via @testable).
init(pending: [PendingApproval], drops: [ContentDrop], actions: [AgentAction],
specialists: [Specialist], assets: [Asset] = Mock.assets, metrics: [SurfaceMetric] = Mock.metrics,
api: CockpitAPI = MockCockpitAPI()) {
self.pending = pending
self.drops = drops
self.actions = actions
self.specialists = specialists
self.assets = assets
self.metrics = metrics
self.api = api
}
/// Load from the API. The cockpit is an approval surface, so a network blip
/// must not silently strand a slice on mock seed (stale demo items looking real)
/// next to live data. We retry while any TRANSIENT error occurred a
/// `.unavailable` endpoint (specialists/metrics, by design) is not transient and
/// never triggers a retry. A clean pass returns immediately. Per-resource and
/// tolerant: a failed/unavailable endpoint keeps the previously-held data.
public func refresh() async {
var lastPass = RefreshOutcome()
for attempt in 0..<maxRefreshAttempts {
lastPass = await refreshOnce()
if !lastPass.transientFailure { break } // clean (or only `.unavailable` gaps)
if attempt + 1 < maxRefreshAttempts {
try? await Task.sleep(nanoseconds: refreshRetryDelayNanos)
}
}
connection = lastPass.anySucceeded ? .live : .offline
}
private let maxRefreshAttempts = 3
private let refreshRetryDelayNanos: UInt64 = 1_200_000_000 // 1.2s
private struct RefreshOutcome { var anySucceeded = false; var transientFailure = false }
/// One pass over every endpoint. Each success updates its slice; a `.unavailable`
/// endpoint is left on prior data (by design); any other error marks the pass
/// transient so `refresh()` retries it.
private func refreshOnce() async -> RefreshOutcome {
var out = RefreshOutcome()
func load<T>(_ fetch: () async throws -> T, _ assign: (T) -> Void) async {
do { assign(try await fetch()); out.anySucceeded = true }
catch CockpitAPIError.unavailable { /* endpoint not on platform.api yet keep seed */ }
catch { out.transientFailure = true }
}
await load({ try await api.fetchDrops() }) { drops = $0 }
await load({ try await api.fetchPending() }) { pending = $0 }
await load({ try await api.fetchAssets() }) { assets = $0 }
await load({ try await api.fetchActions() }) { actions = $0 }
await load({ try await api.fetchSpecialists() }) { specialists = $0 }
await load({ try await api.fetchMetrics() }) { metrics = $0 }
await load({ try await api.fetchIngestStatus() }) { ingest = $0 }
// Best-effort, non-throwing: keep the prior image on a nil (transient or
// none-yet) so the card never flickers empty between captures.
if let shot = await api.fetchBumpScreenshot(surface: "tryst") { bumpScreenshot = shot }
return out
}
/// Drive refresh for a view's lifetime: an immediate load, then a periodic
/// re-poll so the cockpit self-heals after a transient backend/network outage
/// (the LAN to black is intermittently flaky a single launch-time refresh that
/// lands in a blip would otherwise strand the UI empty until relaunch). The
/// hosting `.task` cancels this automatically when the view goes away.
public func autoRefresh(every seconds: UInt64 = 30) async {
await refresh()
while !Task.isCancelled {
try? await Task.sleep(nanoseconds: seconds * 1_000_000_000)
if Task.isCancelled { break }
await refresh()
}
}
/// Load just the ingestion status (the asset library refreshes this on appear).
public func loadIngest() async {
if let v = try? await api.fetchIngestStatus() { ingest = v }
}
/// Live cadence for the Assets view while it's open: refresh the ingest status
/// AND the asset grid every few seconds, so the library visibly grows (newest
/// processed at top) and the progress/counts climb as the worker runs. The
/// hosting `.task` cancels this when the view goes away. Errors are swallowed
/// per-call (a blip just skips a tick; the next tick recovers).
public func pollAssetsLive(every seconds: UInt64 = 4) async {
await refreshAssetsAndIngest()
while !Task.isCancelled {
try? await Task.sleep(nanoseconds: seconds * 1_000_000_000)
if Task.isCancelled { break }
await refreshAssetsAndIngest()
}
}
private func refreshAssetsAndIngest() async {
if let v = try? await api.fetchAssets() { assets = v }
if let v = try? await api.fetchIngestStatus() { ingest = v }
}
/// Govern ingestion from the Cockpit (run / pause / resume / enable / disable);
/// the returned authoritative status replaces local state.
public func controlIngest(_ action: IngestControlAction) async {
if let v = try? await api.controlIngestion(action) { ingest = v }
}
/// Load an asset's image bytes through the data seam (authenticated proxy on
/// the live source; nil on mock). Best-effort the tile shows its placeholder
/// swatch until/unless this returns data.
public func imageData(for asset: Asset) async -> Data? {
await api.fetchImageData(for: asset)
}
public func drop(_ id: UUID) -> ContentDrop? { drops.first { $0.id == id } }
public func specialist(_ id: UUID) -> Specialist? { specialists.first { $0.id == id } }
/// Approve a pending item: it leaves the queue, lands as an agent action, and
/// leaves a transient receipt (undo window).
public func approve(_ approval: PendingApproval, edited: Bool = false) {
guard pending.contains(where: { $0.id == approval.id }) else { return }
pending.removeAll { $0.id == approval.id }
actions.insert(
AgentAction(time: "now", surface: approval.surfaces.first,
summary: (edited ? "approved (edited) · " : "approved · ") + approval.title, ok: true),
at: 0)
receipts.insert(ApprovalReceipt(title: approval.title, edited: edited, restored: approval), at: 0)
if let postId = approval.postId { // mock/composed items have no server row
Task { try? await api.approve(postId, edited: edited) }
}
}
/// Undo a recent approval restores it to the queue and drops the logged action.
public func undo(_ receipt: ApprovalReceipt) {
receipts.removeAll { $0.id == receipt.id }
guard !pending.contains(where: { $0.id == receipt.restored.id }) else { return }
pending.insert(receipt.restored, at: 0)
if let idx = actions.firstIndex(where: { $0.summary.hasSuffix(receipt.title) }) {
actions.remove(at: idx)
}
}
/// Dismiss a receipt once its undo window elapses.
public func clearReceipt(_ receipt: ApprovalReceipt) {
receipts.removeAll { $0.id == receipt.id }
}
/// Compose a new drop from the composer lands at the top of the schedule, then
/// persists to the server. Optimistic: the drop appears instantly; the server row
/// replaces it on success, or it's rolled back (and the action logged as failed)
/// if the write never landed. The composer collects no time, so it schedules on
/// the planner's canonical 1-week content offset.
public func addDraft(title: String, arc: String, assetIds: [UUID]) {
let resolvedTitle = title.trimmingCharacters(in: .whitespaces).isEmpty ? "Untitled drop" : title
let dropAt = Mock.at(20, 0, day: 7)
// Built like a live drop (no legs) so the row doesn't change shape when the
// persisted version replaces it.
let optimistic = ContentDrop(
title: resolvedTitle, arc: arc, assetCount: assetIds.count, clusterSource: "composed",
legs: [], teaseAt: nil, dropAt: dropAt, followupAt: nil, state: .scheduled)
let optimisticId = optimistic.id
let composedSummary = "drop composed · \(resolvedTitle)"
drops.insert(optimistic, at: 0)
actions.insert(AgentAction(time: "now", surface: .onlyfans, summary: composedSummary, ok: true), at: 0)
Task {
do {
let saved = try await api.createDrop(title: resolvedTitle, arc: arc, assetIds: assetIds, dropAt: dropAt)
if let idx = drops.firstIndex(where: { $0.id == optimisticId }) { drops[idx] = saved }
} catch {
drops.removeAll { $0.id == optimisticId }
if let idx = actions.firstIndex(where: { $0.summary == composedSummary }) {
actions[idx] = AgentAction(time: "now", surface: .onlyfans,
summary: "drop not saved · \(resolvedTitle)", ok: false)
}
}
}
}
/// Set aside a pending item: leaves the queue, logged as held.
public func setAside(_ approval: PendingApproval) {
guard pending.contains(where: { $0.id == approval.id }) else { return }
pending.removeAll { $0.id == approval.id }
actions.insert(
AgentAction(time: "now", surface: approval.surfaces.first,
summary: "set aside · \(approval.title)", ok: false),
at: 0)
if let postId = approval.postId { // mock/composed items have no server row
Task { try? await api.setAside(postId) }
}
}
}