import Foundation import Observation import os // MARK: - Vocabulary /// Why a board is refusing writes — the read-only lock's cause, and the whole of its vocabulary /// today. /// /// 02-architecture.md names three ways a board enters the lock and only the first is built here. /// `vanishedRoot` (§ Write-failure surfacing — a root that is gone, after bookmark re-resolution /// found nothing) and `unwritableLocation` (§ — a read-only volume or a permission-denied folder, /// probed at open) arrive with the cards that implement them. The banner turns a case of this into /// user-facing phrasing (§ The banner surface); the store only owns the truth of it. public enum ReadOnlyLockReason: Sendable, Equatable { /// A reload that followed a bracketed wholesale operation failed, so the last-good snapshot on /// screen may describe a tree that no longer exists — after a branch switch, a different branch /// entirely. Writes derived from it would land nonsense, so every write is refused until a /// reload succeeds (02-architecture.md § Live-reload resilience, "A failed reload after a /// bracketed operation locks the board read-only"). case bracketedReloadFailed } /// The refusal `BoardStore.performWrite` throws when the board is locked read-only. /// /// **Deliberately temporary, and deliberately not a `BoardWriteError`.** The lock is a *store* /// condition, not a filesystem outcome: nothing was attempted, no path failed, and folding it into /// `BoardWriteError.io(message:)` would misrepresent a policy refusal as an I/O error in the one /// place — the banner — where the distinction is the whole point. Widening `BoardWriteError` to /// carry a refusal case is the banner card's job (02-architecture.md § Write-failure surfacing: /// "The operation is a closed enum, not a string"), and it will unify this vocabulary with the /// Writer's. Until then this thin error keeps `performWrite`'s honesty at the cost of an untyped /// `throws` on its signature. public enum BoardStoreWriteRefusal: Error, Sendable, Equatable, CustomStringConvertible { case readOnlyLocked(ReadOnlyLockReason) public var description: String { switch self { case let .readOnlyLocked(reason): "the board is read-only (\(reason))" } } } /// Which side of the live/tombstoned boundary something sits on. /// /// A selection is **homogeneous by liveness** (04-interactions.md § The trash): it never mixes live /// and tombstoned items, so the side is a property of the selection as a whole rather than of each /// member — which is exactly what makes re-resolution across a reload a matching rule rather than a /// partition. public enum Liveness: Sendable, Equatable { case live case trashed /// The side an item's own tombstone flag puts it on. `Lane.isDeleted`/`Card.isDeleted` are /// presence-of-the-key, not validity, so a malformed `deleted:` still reads as trashed — see /// their doc comments in `BoardModel.swift`. init(isDeleted: Bool) { self = isDeleted ? .trashed : .live } } // MARK: - Selection /// The board's selection: a set of UUIDs over the snapshot, plus the liveness side it lives on. /// /// **UUIDs, never indices or copies of items** (02-architecture.md § Live-reload resilience, /// "Selection survives reloads by UUID"): a reload swaps the whole snapshot as a value, and /// anything holding positions or item copies would be silently wrong the moment an agent files a /// card. Re-resolution against the new snapshot is `resolved(against:)`, and it is a *pure /// function* on purpose — the transient-state container (02-architecture.md § Changes from Kanban) /// will absorb this type and apply the same rule to drag membership and the pending cut, so the /// rule must be reusable rather than buried in the store's reload path. public struct Selection: Sendable, Equatable { public var ids: Set public var liveness: Liveness public init(ids: Set = [], liveness: Liveness = .live) { self.ids = ids self.liveness = liveness } /// Nothing selected, on the live side — the state a board opens in and the state /// `BoardStore.clearSelection()` returns to. public static let empty = Selection() public var isEmpty: Bool { ids.isEmpty } /// This selection re-grounded on `snapshot`: the members that are still there, **on the same /// liveness side**, and nothing else. /// /// Two rules, both settled in 02-architecture.md § Live-reload resilience: /// /// - **Vanished members leave silently.** No substitute is invented, no successor is picked — /// an empty result is a legitimate outcome. (App-mediated deletion is deliberately different: /// ⌫ selects the successor sibling, because that is an act rather than a surprise — /// 04-interactions.md ▸ The map. That belongs to the delete command, not here.) /// - **A liveness flip is a vanish.** A foreign edit that tombstones a selected live card — or /// restores a selected tombstoned one — ejects it, keeping 04-interactions.md's /// homogeneous-by-liveness invariant true across reloads so menu validation never sees a /// mixed selection. /// /// The match is on the item's *own* tombstone flag. A live card sitting under a tombstoned lane /// therefore survives re-resolution even though the board does not render it — the loader keeps /// it in the snapshot, and "still present, same side" is the rule as written. public func resolved(against snapshot: BoardModel) -> Selection { guard !ids.isEmpty else { return self } var survivors: Set = [] survivors.reserveCapacity(ids.count) for lane in snapshot.lanes { if ids.contains(lane.id), Liveness(isDeleted: lane.isDeleted) == liveness { survivors.insert(lane.id) } for card in lane.cards where ids.contains(card.id) { if Liveness(isDeleted: card.isDeleted) == liveness { survivors.insert(card.id) } } } return Selection(ids: survivors, liveness: liveness) } } // MARK: - BoardStore /// The per-board hub: one live snapshot, one reload pipeline, and the read-side conditions the /// board window renders (02-architecture.md § Layering ▸ Components). /// /// **It enforces the one-way flow — files → watcher → loader → store → views — by never mutating /// its snapshot from the write path.** A user action runs the Writer, the Writer touches disk, the /// watcher notices, and the change arrives here as a reload like any external edit. The app trusts /// its own writes no more than anyone else's; that is what makes external editors and agents /// first-class, and it is why there is no `snapshot` setter anywhere below `apply(_:generation:)`. /// /// ### What this type actually owns /// /// 1. **The reload pipeline.** At most one tree walk in flight, off the main actor; signals arriving /// during one coalesce into a single follow-up; only the newest result applies. /// 2. **The failure rules.** A failed reload never replaces a good snapshot; an ordinary failure /// raises the banner condition and leaves editing alone; a failure after a bracketed wholesale /// operation locks the board read-only; the next success clears both. /// 3. **Selection across reloads.** Re-resolved by UUID and liveness on every applied snapshot. /// /// ### What it deliberately does not own /// /// The `FolderWatcher` itself — the registry owns one watcher and one store per board and wires /// them together (`watcherBrackets`, `handleWatcherEvent(_:)`), so this type can be built and tested /// without a filesystem stream. The transient-state container is also still to come: selection lives /// here for now, and the **new-card placeholder** will live beside it — a pseudo-card with no disk /// presence and no UUID, overlaid on the snapshot rather than merged into it (02-architecture.md § /// Layering, the one named exception to the one-way flow). Nothing here precludes that: `snapshot` /// is a pure value swap with no identity assumptions, so an overlay can simply be rendered on top of /// whatever the latest reload produced. @MainActor @Observable public final class BoardStore { // MARK: Read-side state /// The last good tree walk. Replaced wholesale by a successful reload and **never** by the write /// path — see the type's doc comment for why. A failed reload leaves it exactly as it was. public private(set) var snapshot: BoardModel /// Tolerated anomalies from the load that produced `snapshot` (stray folders, an indexless /// UUID-shaped folder, a board-level `deleted:`). Replaced with the snapshot, so they always /// describe the tree currently on screen. public private(set) var loadWarnings: [LoadWarning] /// The standing read-side condition: the error from the last reload that failed, `nil` when the /// board is healthy. `BoardLoadError` already carries fail-fast's specifics — the offending path /// and what is wrong with it — which is the whole of what the banner needs to render /// (02-architecture.md § Live-reload resilience). This is a *condition*, not a one-shot: it /// stands until a reload succeeds, and it heals without ceremony when one does. public private(set) var reloadFailure: BoardLoadError? /// Non-`nil` while the board refuses writes. Cleared by the next successful reload, per "the /// next successful reload clears both the banner and the lock". public private(set) var readOnlyLock: ReadOnlyLockReason? public var isReadOnly: Bool { readOnlyLock != nil } /// The board's selection, re-resolved against every snapshot this store applies. public private(set) var selection: Selection /// The board root this store was opened on. Constant for now: absorbing a rename by /// re-resolving the security-scoped bookmark (02-architecture.md § Write-failure surfacing) is /// the registry's job and a later card's, and it will arrive together with `.rootChanged` /// handling. public let rootURL: URL // MARK: Wiring /// The watcher's bracket calls, injected rather than owned: the registry holds the watcher and /// the store together, and a store that reached into a watcher it did not own could not be /// tested without one. `nil` means "no watcher attached" — every operation below still behaves, /// it simply has nothing to suspend, which is exactly the shape unit tests want. @ObservationIgnored public var watcherBrackets: (begin: @MainActor () -> Void, end: @MainActor () -> Void)? // MARK: Reload machinery /// Monotonic id of the most recently *started* reload — and therefore also the number of tree /// walks this store has run since it opened, which is what makes the coalescing rule assertable /// from a test rather than merely plausible. /// /// Two rules are expressed as comparisons against it: **only the newest result applies** (a /// result whose generation is no longer the current one is dropped), and **the wholesale /// expectation binds to a load started after it was armed** (`wholesaleReloadFloor`). @ObservationIgnored private(set) var reloadGeneration = 0 /// Whether a tree walk is running. At most one ever is: a second concurrent walk would buy /// nothing (both would produce the same snapshot) and would make "the newest result wins" a race /// rather than a rule. @ObservationIgnored private var reloadInFlight = false /// A reload owed but not started, because one was already running when the signal arrived — a /// **flag, not a queue**: any number of signals during one walk coalesce into exactly one /// follow-up, because the follow-up is a full tree walk that covers all of them. The origin is /// merged by the watcher's own precedence rule (`reconciling > appMediated > foreign`) so a /// foreign event folding into a pending reconciling one never downgrades it. @ObservationIgnored private var pendingReload: WatchOrigin? /// The generation from which a bracketed wholesale operation's expectation applies, or `nil` /// when no operation is outstanding. /// /// **Why a floor and not a boolean.** The rule is "the reload that ends this wholesale operation /// must succeed, or the board locks" — and a boolean cannot tell that reload apart from one that /// was *already in flight* when the operation ended, which observed a tree from before the /// operation touched it and therefore proves nothing about the result. Arming stores /// `reloadGeneration + 1`: the next walk to be *started*. An in-flight walk's generation is /// below the floor and passes through without consuming it; the first walk started at or after /// it consumes it — succeeding clears everything as usual, failing engages the lock. /// /// Ordinary (non-wholesale) reload failures never set the lock, because ordinary breakage is /// per-file: the snapshot still describes the tree and editing around the broken file is safe. @ObservationIgnored private var wholesaleReloadFloor: Int? /// Consumers suspended in `awaitQuiescence()`, resumed together the moment nothing is running /// and nothing is owed. @ObservationIgnored private var quiescenceWaiters: [CheckedContinuation] = [] /// Awaited off the main actor **after** a tree walk finishes and **before** its result is /// applied — the one seam this type keeps, `nil` in production. /// /// It exists because two of the contracts above are ordering claims about work that runs /// concurrently with the main actor ("only the newest result applies"; "the wholesale /// expectation never binds to a walk already in flight"), and a test that cannot pin a finished /// walk open can only approximate them with sleeps — which would make the suite slow, flaky, and /// silent about the very race it exists to rule out. @ObservationIgnored var loadBarrier: (@Sendable () async -> Void)? private static let logger = Logger(subsystem: "dev.rzen.indie.Kanban", category: "store") // MARK: - Init /// Opens a board: one synchronous tree walk, and **no fallback if it fails**. /// /// Fail-fast is the *initial-load* contract (01-storage-format.md § Malformed input): there is /// no last-good snapshot to keep on screen yet, so a broken board throws its `BoardLoadError` /// instead of constructing a store that would have nothing to show. Every rule below — the /// banner, the lock, "a failed reload never replaces a good snapshot" — exists only *because* /// this one succeeded. /// /// The walk is synchronous because the caller has nothing to render until it lands; the /// asynchronous, off-main pipeline starts with the first reload. public init(rootURL: URL) throws(BoardLoadError) { let result = try BoardLoader.load(boardRoot: rootURL) self.rootURL = rootURL self.snapshot = result.model self.loadWarnings = result.warnings self.reloadFailure = nil self.readOnlyLock = nil self.selection = .empty } // MARK: - Inbound signals /// The single inbound signal — everything the outside world tells this store arrives here. /// /// Deliberately one door: the watcher, a test, and (later) the registry's wake/activation /// reconciliation all speak the same two-case vocabulary, so there is exactly one place where a /// filesystem change becomes a reload. public func handleWatcherEvent(_ event: WatcherEvent) { switch event { case let .treeChanged(origin): requestReload(origin) case .rootChanged: // Stubbed on purpose, and the stub is the whole of the honest answer today: the settled // response is to re-resolve the board's security-scoped bookmark and either // `reattach(to:)` the watcher at the new location — a rename absorbed with no banner and // no lock — or enter the vanished-root read-only lock (02-architecture.md § // Write-failure surfacing). Both halves need the registry's bookmark, which this store // deliberately does not own yet, and a guess here would be a *wrong* guess: treating a // rename as a vanish would lock a board that is merely somewhere else. The card that // brings the bookmark replaces this case; until then a root change simply leaves the // last-good snapshot on screen, which is the same thing every failure path does. Self.logger.debug("rootChanged ignored — bookmark re-resolution is not built yet") } } /// Starts a reload, or banks one if a walk is already running. private func requestReload(_ origin: WatchOrigin) { guard !reloadInFlight else { pendingReload = WatchOrigin.merged(pendingReload, origin) return } startReload(origin) } // MARK: - The reload pipeline /// Runs one tree walk **off the main actor** and applies its result on it. /// /// Off-main because a board of any size is a directory walk plus a YAML parse per item, and the /// whole point of the value-type snapshot is that this work can happen anywhere: `BoardLoader` /// is stateless statics and `LoadResult` is `Sendable`, so the only thing that has to be on the /// main actor is the assignment at the end. `Task.detached` rather than `Task { }`: a task /// created inside a `@MainActor` method inherits that isolation and would run the walk on the /// main actor — the exact thing this is avoiding. /// /// `origin` is not branched on: every reload is a full tree walk, so no origin is less safe than /// another. It is carried because the *policy* around a reload differs later — a `.reconciling` /// sweep re-probes writability (02-architecture.md § Write-failure surfacing) — and because it is /// the one thing that makes a reload's provenance legible in the log. private func startReload(_ origin: WatchOrigin) { reloadGeneration += 1 let generation = reloadGeneration let root = rootURL let barrier = loadBarrier reloadInFlight = true Self.logger.debug("reload \(generation, privacy: .public) started (\(origin.rawValue, privacy: .public))") Task.detached(priority: .userInitiated) { [weak self] in // `do throws(BoardLoadError)`: without the annotation the `catch` widens to `any Error` // and the loader's typed error is lost on the way into `Result`. let outcome: Result do throws(BoardLoadError) { outcome = .success(try BoardLoader.load(boardRoot: root)) } catch { outcome = .failure(error) } await barrier?() await self?.apply(outcome, generation: generation) } } /// Lands one walk's result and starts whatever it uncovered. private func apply(_ outcome: Result, generation: Int) { reloadInFlight = false // The stale-apply guard. Serialization means this should not trigger today, but "only the // newest result applies" is the rule the wholesale floor and every future overlapping-load // change are written against, so it is enforced rather than assumed. if generation == reloadGeneration { land(outcome, generation: generation) } startPendingReload() resumeQuiescenceWaitersIfQuiet() } private func land(_ outcome: Result, generation: Int) { // Consumed here, before the branch, because *both* outcomes end the expectation: a wholesale // operation gets exactly one reload to prove itself, and a second failure after it is // ordinary per-file breakage again. let endsWholesaleOperation: Bool if let floor = wholesaleReloadFloor, generation >= floor { wholesaleReloadFloor = nil endsWholesaleOperation = true } else { endsWholesaleOperation = false } switch outcome { case let .success(result): snapshot = result.model loadWarnings = result.warnings // One success clears both conditions — transient breakage self-heals and a locked board // unlocks without the user doing anything but fixing the file. reloadFailure = nil readOnlyLock = nil selection = selection.resolved(against: result.model) case let .failure(error): // `snapshot`, `loadWarnings` and `selection` are untouched: a failed reload never // replaces a good snapshot, and a selection over a snapshot that did not change has // nothing to re-resolve against. reloadFailure = error if endsWholesaleOperation { readOnlyLock = .bracketedReloadFailed } Self.logger.error("reload \(generation, privacy: .public) failed: \(error.description, privacy: .public)") } } private func startPendingReload() { guard !reloadInFlight, let origin = pendingReload else { return } pendingReload = nil startReload(origin) } // MARK: - Write gate /// Runs a synchronous Writer operation inside the watcher bracket, so the churn it produces /// rounds back as one app-mediated reload rather than a scatter of foreign ones. /// /// The store does **not** touch its snapshot here, before or after. `operation` puts bytes on /// disk; the watcher notices; the reload applies. That indirection is the one-way flow, and it is /// why this method's only jobs are the gate and the bracket. /// /// - Throws: `BoardStoreWriteRefusal.readOnlyLocked` if the board is locked read-only — checked /// *before* the bracket opens, so a refusal costs no suspended watcher and no owed reload. /// Otherwise rethrows whatever `operation` threw, which is a `BoardWriteError`. The untyped /// `throws` is the price of those being two different error types today; see /// `BoardStoreWriteRefusal` for why they are, and why they will not stay that way. /// /// One ergonomic wart, recorded so it is not rediscovered: when `operation` returns a value, /// Swift cannot infer `T` and the closure's thrown type at the same time — the thrown type /// widens to `any Error` and the call fails to compile. Such a call site spells the closure out /// (`{ () throws(BoardWriteError) -> ItemID in … }`). `Void`-returning operations, which are /// most of them, infer cleanly. @discardableResult public func performWrite(_ operation: () throws(BoardWriteError) -> T) throws -> T { if let readOnlyLock { throw BoardStoreWriteRefusal.readOnlyLocked(readOnlyLock) } watcherBrackets?.begin() // `defer`, not a trailing call: a Writer operation that fails partway has still touched disk, // and an unbalanced bracket would leave the watcher suspended for the rest of the session. defer { watcherBrackets?.end() } return try operation() } /// Runs an operation that rewrites the tree **wholesale** — pull-rebase, branch switch, undo /// restore (06-history-undo.md, 07-sync-collab.md) — under the bracket, and arms the rule that /// its closing reload must succeed. /// /// Two things distinguish this from `performWrite`: /// /// - The bracket is load-bearing rather than tidy: a reload landing mid-operation would render a /// half-checked-out tree. /// - Failure of the closing reload **locks the board** (`ReadOnlyLockReason.bracketedReloadFailed`). /// After a wholesale change the last-good snapshot may describe a different branch entirely, so /// writes derived from it would land nonsense — unlike ordinary per-file breakage, where the /// snapshot still describes the tree. /// /// The expectation is armed on **every** exit path, a thrown error included: an operation that /// died partway is precisely the case where the tree's state is unknown and the next reload had /// better be the authority on it. /// /// - Throws: `BoardStoreWriteRefusal.readOnlyLocked` if the board is already locked — a locked /// board refuses to *start* wholesale work, not just ordinary writes. Otherwise rethrows /// `operation`'s error. (Spelled `throws` rather than `rethrows` because of that refusal: a /// `rethrows` function may only throw errors its closure threw.) public func performWholesale(_ operation: () throws -> Void) throws { if let readOnlyLock { throw BoardStoreWriteRefusal.readOnlyLocked(readOnlyLock) } watcherBrackets?.begin() defer { // Ordered: arm first, then close the bracket. `endBracket()` is what schedules the // post-bracket reload, and with a `nil` watcher a consumer may signal by hand the instant // this returns — either way the floor has to be in place before any walk can start. wholesaleReloadFloor = reloadGeneration + 1 watcherBrackets?.end() } try operation() } // MARK: - Selection /// Replaces the selection. /// /// Minimal on purpose — the transient-state container will absorb this along with drag state and /// the pending cut, and give the selection its real grammar (extend, range, successor-on-delete). /// Deliberately *not* filtered against the snapshot: a caller selects what it is rendering, and /// `Selection.resolved(against:)` on the next reload is what keeps the set honest over time. public func select(_ ids: Set, liveness: Liveness) { selection = Selection(ids: ids, liveness: liveness) } /// Selects nothing — Escape's last step outward (04-interactions.md ▸ Grammar). public func clearSelection() { selection = .empty } // MARK: - Quiescence /// Suspends until no reload is running and none is owed. /// /// The store is not a request/response object — a signal in does not produce a result out — so /// this is how a consumer (and every test below) says "let the pipeline settle" without polling. /// Returns immediately when the store is already quiet. public func awaitQuiescence() async { guard !isQuiescent else { return } await withCheckedContinuation { continuation in quiescenceWaiters.append(continuation) } } private var isQuiescent: Bool { !reloadInFlight && pendingReload == nil } private func resumeQuiescenceWaitersIfQuiet() { guard isQuiescent, !quiescenceWaiters.isEmpty else { return } let waiters = quiescenceWaiters quiescenceWaiters.removeAll() for waiter in waiters { waiter.resume() } } }