233 lines
12 KiB
Swift
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) }
|
|
}
|
|
}
|
|
}
|