import Foundation import Observation import os /// One open board: its snapshot, and the bracket every write to it goes through. /// /// **The phone's answer to `BoardStore`.** It keeps that type's discipline — walks run off the main /// actor, one walk at a time, a stale result never lands, and a failed reload keeps the last good /// snapshot on screen — and none of its machinery: no FSEvents, no echo verdicts, no heal scheduler, /// no git. The change signal here is the container's metadata query, relayed by `BoardIndexStore`. /// /// Minted and cached by `BoardIndexStore.session(forBoardAt:)`, never constructed directly by a /// screen: two screens looking at one board must share one snapshot. @MainActor @Observable final class BoardSession { /// What the board screen renders. enum Phase: Sendable, Equatable { /// `open()` has not run. case idle /// The package is not fully here yet. Carries the sweep's own numbers so the screen can show /// progress rather than an indefinite spinner. Not a failure state: it retries itself. case materializing(PackageMaterialization.Progress) /// First walk in flight, nothing to show yet. A *re*-walk over an existing snapshot does not /// enter this phase — the snapshot stays on screen instead. case loading /// `snapshot` is populated. `lastError` may still be set: that combination means the snapshot /// on screen is the last one that loaded and a later walk failed (see `lastError`). case ready /// The first walk failed and there is nothing to fall back to. case failed(BoardSessionError) } /// The package root. Identity, and the argument every loader and writer call is anchored on. let rootURL: URL private(set) var phase: Phase = .idle /// The last snapshot that loaded. **Deliberately not cleared on a failed reload** — a board that /// momentarily will not walk (a file mid-sync, a coordination refusal) must not blank the screen /// the user is working in. private(set) var snapshot: BoardModel? /// The tolerated anomalies of the walk that produced `snapshot` — missing indexes, non-UUID /// folders, ignored keys. Never blocks anything; carried so a future notice surface has it. private(set) var warnings: [LoadWarning] = [] /// The most recent failure, load or write. Non-`nil` alongside `phase == .ready` is the /// stale-snapshot signal: what is drawn is real but is not the newest state of the disk. /// Cleared by the next walk that succeeds. private(set) var lastError: BoardSessionError? /// The previous walk's parsed documents, offered to the next one (`BoardLoader.ParseMemo`). Pure /// optimization — it cannot change what a walk answers, only how many files it opens — and it is /// what keeps the reload after every single write from re-parsing the whole board on a phone. private var parseMemo: BoardLoader.ParseMemo? /// One walk at a time, with a depth-one bank behind it: any number of requests arriving during a /// walk collapse into exactly one follow-up. Awaiting the returned task therefore awaits the /// banked walk too, which is what lets `perform` promise a snapshot that includes its own write. private var walk: Task? private var banked = false /// Only the newest walk's result applies. Serialization makes a stale landing unreachable today; /// the guard is written down anyway because "the newest result wins" is the rule every future /// overlapping-load change has to hold. private var loadGeneration = 0 private var retry: Task? /// How often a package that is still downloading re-checks itself. The metadata query usually /// beats the timer; the timer exists because a download that finishes without moving the /// package's own content-change date produces no notification at all. private static let retryInterval: Duration = .seconds(2) private static let logger = Logger(subsystem: "dev.rzen.indie.KanbanMobile", category: "cloud") init(rootURL: URL) { self.rootURL = rootURL } // MARK: - Loading /// Starts the first walk. Idempotent — a screen may call it on every appearance. func open() { guard case .idle = phase else { return } reload() } /// Re-walks the board. Returns the task that will have landed the newest result, so a caller who /// needs the fresh snapshot can `await` it and one who does not can ignore it. @discardableResult func reload() -> Task { if let walk { banked = true return walk } let task = Task { [self] in repeat { banked = false await runLoad() } while banked walk = nil } walk = task return task } /// Stops the retry timer. The board screen calls this when it is finished with the session; the /// snapshot survives, so a session that is reopened redraws immediately and re-walks behind that. func close() { cancelRetry() } private func cancelRetry() { retry?.cancel() retry = nil } /// The container changed — relayed by `BoardIndexStore` on every metadata notification. /// /// Always a reload rather than a filtered one: the query reports a *package*, and the only thing /// that can be said from the outside about a package that changed is that something inside it /// did. The memo makes the resulting walk cheap, and the coalescing bank makes a burst of /// notifications one walk. func containerDidUpdate() { if case .idle = phase { return } reload() } private func runLoad() async { loadGeneration += 1 let generation = loadGeneration let root = rootURL let memo = parseMemo // Only announce loading when there is nothing to show. A reload over a live board is // invisible by design, and a package still downloading keeps its own phase until the sweep // says otherwise. if snapshot == nil { switch phase { case .idle, .failed: phase = .loading case .loading, .materializing, .ready: break } } let outcome = await Task.detached(priority: .userInitiated) { () -> LoadOutcome in // Materialization first, always. A package with a dataless `index.md` anywhere in it would // otherwise reach the loader, which is fail-fast — and would report ordinary sync latency // as a broken board. let progress = PackageMaterialization.sweep(packageAt: root) guard progress.isComplete else { return .incomplete(progress) } let coordinated = CoordinatedFileAccess.read(itemAt: root) { resolved -> Result in do throws(BoardLoadFailure) { return .success(try BoardLoader.load(boardRoot: resolved, memo: memo)) } catch { return .failure(error) } } switch coordinated { case let .failure(failure): return .failed(.coordination(failure)) case let .success(.success(result)): return .loaded(result) case let .success(.failure(failure)): return .failed(.load(failure)) } }.value guard generation == loadGeneration else { return } apply(outcome) } private func apply(_ outcome: LoadOutcome) { switch outcome { case let .incomplete(progress): phase = .materializing(progress) scheduleRetry() case let .loaded(result): snapshot = result.model warnings = result.warnings parseMemo = result.memo lastError = nil phase = .ready cancelRetry() case let .failed(error): lastError = error // The whole point of keeping the last snapshot: a board that is on screen stays on screen, // and the error rides alongside it instead of replacing it. phase = snapshot == nil ? .failed(error) : .ready cancelRetry() Self.logger.error("board walk failed: \(error.description, privacy: .public)") } } /// One pending re-check while the package downloads. Replaces itself rather than stacking: a /// second timer would double the sweep rate for no extra news. private func scheduleRetry() { retry?.cancel() retry = Task { [weak self] in try? await Task.sleep(for: Self.retryInterval) guard !Task.isCancelled, let self else { return } self.reload() } } // MARK: - Writing /// Runs a closure of `BoardWriter` calls against this board and reloads. /// /// **The one door for every mutation a screen makes.** It supplies the three things a phone write /// needs and a bare `BoardWriter` call does not: a background executor (writes are synchronous /// filesystem work and must never run on the main actor), an `NSFileCoordinator` write intent over /// the whole package (so the daemon does not push a remote version into a folder mid-write, and so /// a two-folder operation like a move lands as one unit), and the reload that follows — views /// render only what is on disk, exactly as on the Mac, so a write that is not followed by a walk /// changes nothing on screen. /// /// `work` is handed the coordinator-resolved root and should derive every URL from it. It may /// make any number of writer calls; they all land inside the one bracket, and the first one to /// throw abandons the rest — which is the writer's own contract, not a policy added here. /// /// Awaiting the returned result means the reload has already landed, so `snapshot` reflects the /// write. A failure is returned *and* recorded in `lastError`; the reload runs either way, because /// a partially-applied multi-call write leaves disk in a state the screen must be shown. @discardableResult func perform( _ work: @escaping @Sendable (URL) throws(BoardWriteError) -> T ) async -> Result { let root = rootURL let outcome: Result = await Task.detached(priority: .userInitiated) { let coordinated = CoordinatedFileAccess.write(itemAt: root) { resolved -> Result in do throws(BoardWriteError) { return .success(try work(resolved)) } catch { return .failure(error) } } switch coordinated { case let .failure(failure): return .failure(.coordination(failure)) case let .success(inner): return inner.mapError(BoardSessionError.write) } }.value if case let .failure(error) = outcome { lastError = error Self.logger.error("board write failed: \(error.description, privacy: .public)") } await reload().value return outcome } } /// What one walk produced — the `Sendable` currency between the detached load and the main actor. private enum LoadOutcome: Sendable { case incomplete(PackageMaterialization.Progress) case loaded(LoadResult) case failed(BoardSessionError) } /// Everything that can go wrong for one board, in one vocabulary: the coordinator refused, the walk /// found a fail-fast defect, or a write did not land. The two storage cases carry the storage layer's /// own typed errors verbatim — a re-worded copy would be a second, worse taxonomy. enum BoardSessionError: Error, Sendable, Equatable, CustomStringConvertible { case coordination(CoordinationFailure) case load(BoardLoadFailure) case write(BoardWriteError) var description: String { switch self { case let .coordination(failure): failure.description case let .load(failure): failure.description case let .write(error): error.description } } }