Files
lanework/KanbanMobile/Cloud/BoardSession.swift
T
rzen 78c32776d4 The phone reads the thread — comments arrive on the card's read view, posting and editing behind transactional sheets
The comment thread renders read-only under the card's body (author line, Markdown body through CardBodyView, read-only paperclip rows), read outside the snapshot and re-read on every walk landing via BoardSession.snapshotGeneration. Add Comment posts through the Mac composer's own draft-then-rename bracket — seeding from the card's single synced draft so a thought started on the Mac finishes here — and each row's context menu opens the same sheet in edit mode. Both commit on their trailing button or not at all: the phone's transactional model, dirty-Cancel confirmation and swipe-dismiss disabled while dirty included. UI-tested end to end with disk assertions.

Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
2026-08-08 18:18:56 -04:00

357 lines
16 KiB
Swift

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?
/// Bumped every time a walk lands a snapshot. Comment threads live *outside* the snapshot —
/// the walk stays O(cards) and never opens comment content, the Mac's own arrangement — so a
/// screen rendering a thread keys its re-read on this: it moves after the screen's own write
/// (whose `perform` awaits the reload) and after a foreign change the metadata query relayed,
/// which are exactly the two moments a thread on screen can have gone stale.
private(set) var snapshotGeneration = 0
/// 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<Void, Never>?
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<Void, Never>?
/// 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<Void, Never> {
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<LoadResult, BoardLoadFailure> 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
snapshotGeneration += 1
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: - The comment thread
/// Reads one card's comment thread — the phone's counterpart to the Mac card window reading
/// its own thread: window-scoped, outside the board snapshot, coordinated like every other
/// read on the phone.
///
/// Total, like the read it wraps: `CommentThread.load` never refuses, and a coordinator
/// refusal answers `.empty` after a log line rather than surfacing — a thread that momentarily
/// will not read renders as "no comments" for one pass and re-reads on the next
/// `snapshotGeneration` bump, not an error state the screen has to draw. The defects the read
/// reports are dropped here: the phone has no heal engine to hand them to, and healing from
/// two apps at once would be two writers racing over one defect.
func loadCommentThread(laneID: ItemID, cardID: ItemID) async -> CommentThread {
let root = rootURL
let outcome = await Task.detached(priority: .userInitiated) { () -> Result<CommentThread, CoordinationFailure> in
CoordinatedFileAccess.read(itemAt: root) { resolved in
CommentThread.load(
inCard: Self.cardFolder(laneID: laneID, cardID: cardID, inRoot: resolved),
path: "\(laneID.rawValue)/\(cardID.rawValue)"
)
}
}.value
switch outcome {
case let .success(thread):
return thread
case let .failure(failure):
Self.logger.error("comment thread read failed: \(failure.description, privacy: .public)")
return .empty
}
}
/// Reads the card's single draft — what seeds the composer. The draft is a folder inside the
/// card, so it syncs like everything else: a comment started on the Mac is offered here to
/// finish, exactly as designed ("the card's single draft", 01-storage-format.md § Enhanced
/// schema). `nil` is "nothing to restore" — no draft, an unreadable one, or a coordinator
/// refusal, none of which the composer can do anything about beyond starting empty.
func loadCommentDraft(laneID: ItemID, cardID: ItemID) async -> CommentDraft? {
let root = rootURL
let outcome = await Task.detached(priority: .userInitiated) { () -> Result<CommentDraft?, CoordinationFailure> in
CoordinatedFileAccess.read(itemAt: root) { resolved in
CommentThread.loadDraft(inCard: Self.cardFolder(laneID: laneID, cardID: cardID, inRoot: resolved))
}
}.value
switch outcome {
case let .success(draft):
return draft
case let .failure(failure):
Self.logger.error("comment draft read failed: \(failure.description, privacy: .public)")
return nil
}
}
/// `<root>/<lane>/<card>` — the derivation every comment call anchors on, spelled once and
/// `nonisolated` so the detached reads above can use it against the coordinator-resolved root.
nonisolated static func cardFolder(laneID: ItemID, cardID: ItemID, inRoot root: URL) -> URL {
root
.appendingPathComponent(laneID.rawValue, isDirectory: true)
.appendingPathComponent(cardID.rawValue, isDirectory: true)
}
// 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<T: Sendable>(
_ work: @escaping @Sendable (URL) throws(BoardWriteError) -> T
) async -> Result<T, BoardSessionError> {
let root = rootURL
let outcome: Result<T, BoardSessionError> = await Task.detached(priority: .userInitiated) {
let coordinated = CoordinatedFileAccess.write(itemAt: root) { resolved -> Result<T, BoardWriteError> 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
}
}
}