Files
lanework/Kanban/LiveStore/BoardStore.swift
T
rzen 747dea552d Surface write failures — banners, locks, one modal
The banner surface as one vocabulary (Kanban/LiveStore/BannerCenter,
Kanban/UI/BannerStripView): a pure precedence rule — in-progress pinned
above the collapse (ratified mid-build), lock > breakage > one-shot
write failures > commit+attachment, signposts last — with all
user-facing phrasing owned here via exhaustive switches over the closed
WriteOperation enum; free-form English survives only in diagnostics.
performWrite posts its failures before rethrowing, so no one-shot can
bypass the strip; refusals under lock post nothing.

The lock vocabulary completes: vanishedRoot and unwritableLocation join
bracketedReloadFailed, each with its own clearing rule (unwritable
clears only on a reconciling reload's writability re-probe). The
registry now owns root recovery: bookmark re-resolution absorbs renames
transparently, a dead root locks read-only and re-arms FSEvents on the
gone path so the root's return round-trips back through rootChanged,
re-minting and re-keying on the way. DirtyBufferGuard is the one modal
moment, retry / save a copy / discard, no fourth button.

36 new tests; full suite 333 tests in 62 suites green. Five findings
filed on the Redesign board.

Claude-Session: https://claude.ai/code/session_018BjQRYBR6jQja3jCRi5S3A
2026-07-26 20:41:48 -04:00

718 lines
39 KiB
Swift

import Foundation
import Observation
import os
// MARK: - Vocabulary
/// Why a board is refusing writes — the read-only lock's cause, and now the whole of the
/// vocabulary 02-architecture.md names.
///
/// The three cases share one *scope* (§ "The lock's scope") — every mutating command disabled
/// across every window sharing the store, drops refused, ⌘-drag moves degraded to copies, editor
/// buffers kept but their debounced saves suspended — and differ only in cause and in **what
/// clears them**, which is the one thing this enum's cases are actually asked about (see
/// `BoardStore.land(_:generation:origin:)`). 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").
///
/// **Clears on the next successful reload, whatever its origin** — typically once the offending
/// file is fixed.
case bracketedReloadFailed
/// The board's root is gone and its bookmark re-resolution found nothing: the volume unmounted,
/// or the folder was deleted in Finder while the board was open (02-architecture.md §
/// Write-failure surfacing, "A vanished board root locks the board read-only"). Every write
/// would land nowhere, so the last-good snapshot stays on screen, read-only.
///
/// **Clears on the next successful reload, whatever its origin**: a reload can only succeed if
/// the root is back, so success *is* the return signal. Pending dirty buffers then save
/// normally.
case vanishedRoot
/// The board opened somewhere it cannot be written: a read-only volume (a DMG, a snapshot, a
/// read-only share) or a permission-denied folder (02-architecture.md § Write-failure
/// surfacing, "An unwritable board location enters the read-only lock at open"). Failing
/// loudly, specifically, *once* beats letting every gesture fail one at a time.
///
/// **Clears only on a successful *reconciling* reload whose writability re-probe passes** —
/// unlike its two siblings, whose cause a successful reload disproves by itself. A board on a
/// read-only DMG reloads perfectly all day long; only the probe (§ "Writability re-probes on
/// every reconciling reload" — wake, activation) can tell that the permission or the mount
/// actually changed.
case unwritableLocation
}
/// 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<ItemID>
public var liveness: Liveness
public init(ids: Set<ItemID> = [], 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 liveness that is matched is **effective — ancestor-walked** (settled): a card counts as
/// trashed if its own flag *or its lane's* says so. Tombstoning a lane therefore ejects its
/// cards from a live selection even though their own flags never changed — the card renders
/// nowhere once 03-board-ui.md collapses the lane to a single trash entry, and nothing
/// invisible may stay selected, drag-included, or pending-cut.
public func resolved(against snapshot: BoardModel) -> Selection {
guard !ids.isEmpty else { return self }
var survivors: Set<ItemID> = []
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: lane.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
/// Where the board is **now**. Follows the folder: a rename or a move absorbed through
/// `relocate(to:)` updates it, so every URL derived from it — the Writer's paths, card-window
/// keys, Reveal in Finder — re-derives at the new location (02-architecture.md §
/// Write-failure surfacing, "A renamed or moved board root follows its file identity").
///
/// Observed, deliberately: the window title's folder-name fallback reads this, and a rename in
/// Finder should be visible in the title bar without anything else being told.
///
/// `snapshot.rootURL` is the root the *last successful reload* walked, and therefore lags this
/// by exactly one reload during an absorption. That is not a second source of truth: the
/// relocation is always followed by the watcher reattach whose reconciling reload rebuilds the
/// snapshot at the new root, after which the two agree again.
public private(set) var rootURL: URL
// MARK: Banners
/// The board window's banner strip, as a model (02-architecture.md § The banner surface).
///
/// **Owned, not injected**, and the reason is the hosting rule: the strip is "hosted by the
/// window of origin", and a board window's strip has exactly one lifetime — this store's. A
/// card window (m6) gets its *own* center for its own save, attachment, and raw-source Apply
/// failures, and re-homes those rows here when it closes; injecting a shared center would
/// erase precisely that distinction.
///
/// It holds only what nothing else does — one-shot write failures, the history suspension,
/// in-progress operations, passive signposts. The lock and the reload breakage stay this
/// store's own state and are composed in at render time by `bannerRows`.
public let banners = BannerCenter()
/// The rows the board window's strip renders, in precedence order.
///
/// Composed rather than stored: `readOnlyLock` and `reloadFailure` are the store's truths and
/// `banners` holds the rest, so a stored array would be a third copy waiting to go stale. The
/// ordering rule itself lives in `BannerCenter.rows(...)`, which is pure and tested on its own.
public var bannerRows: [BannerRow] {
BannerCenter.rows(
lock: readOnlyLock,
breakage: reloadFailure,
oneShots: banners.oneShots,
suspension: banners.historySuspension,
operations: banners.operations,
signposts: banners.signposts
)
}
// 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)?
/// What to do when the watched root changes identity — injected for the same reason the
/// brackets are: the response needs the board's **security-scoped bookmark**, and this store
/// does not own one (the registry does, along with the watcher that must be re-attached and the
/// last-known path that arms the return detection). A store that reached for a bookmark it did
/// not hold could not be built or tested without a registry.
///
/// `nil` keeps the documented no-op: the last-good snapshot stays on screen, which is what
/// every other failure path here does, and which is exactly the shape unit tests and any
/// storeless use want. `BoardStoreRegistry` wires it to its own recovery loop.
@ObservationIgnored
public var rootChangeDelegate: (@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<Void, Never>] = []
/// 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:
// Delegated, never guessed at. 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 a bookmark this
// store does not own, and a guess here would be a *wrong* guess: treating a rename as a
// vanish would lock a board that is merely somewhere else.
//
// No reload is started either way. The delegate's two outcomes both end in one —
// `reattach(to:)`'s reconciling reload at the re-resolved root, or the vanished-root
// lock's eventual clearance when the root returns — and a reload fired from here would
// walk a path that just stopped being the board.
guard let rootChangeDelegate else {
Self.logger.debug("rootChanged ignored — no delegate is wired (storeless use)")
return
}
rootChangeDelegate()
}
}
/// 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<LoadResult, BoardLoadError>
do throws(BoardLoadError) {
outcome = .success(try BoardLoader.load(boardRoot: root))
} catch {
outcome = .failure(error)
}
await barrier?()
await self?.apply(outcome, generation: generation, origin: origin)
}
}
/// Lands one walk's result and starts whatever it uncovered.
private func apply(_ outcome: Result<LoadResult, BoardLoadError>, generation: Int, origin: WatchOrigin) {
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, origin: origin)
}
startPendingReload()
resumeQuiescenceWaitersIfQuiet()
}
private func land(_ outcome: Result<LoadResult, BoardLoadError>, generation: Int, origin: WatchOrigin) {
// 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
// Breakage always heals on a success — it *is* the claim "the last reload failed", and
// this one did not.
reloadFailure = nil
clearLockIfDisproved(by: origin)
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
// `readOnlyLock == nil` rather than an unconditional assignment: a root that vanished
// mid-bracket already raised its own, truer lock, and 02-architecture.md is explicit
// that the bracket's final reload becomes a no-op there rather than a redundant
// failure. Overwriting `.vanishedRoot` with `.bracketedReloadFailed` would also break
// the clearing rules — the vanished root's lock must not clear on a reload that never
// proves the root came back.
if endsWholesaleOperation, readOnlyLock == nil {
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: - The lock's clearing rules
/// Clears the read-only lock if this successful reload actually disproved its cause.
///
/// **Reason-specific, because the causes are not alike** (02-architecture.md § Write-failure
/// surfacing):
///
/// - `.bracketedReloadFailed` and `.vanishedRoot` are *disproved by the success itself*. The
/// first says "the tree could not be re-read after a wholesale change" and the second says
/// "the root is gone" — a completed tree walk at the root contradicts both, whatever origin
/// asked for it, so any success clears them.
/// - `.unwritableLocation` is not. A board on a read-only DMG reloads flawlessly forever;
/// loading proves nothing about writing. It clears only when a **reconciling** reload — wake,
/// activation, a stream re-creation — re-probes writability and finds it changed ("Writability
/// re-probes on every reconciling reload, so a fixed permission or rewritable remount clears
/// the lock without ceremony").
///
/// `FileManager.isWritableFile(atPath:)` is `access(2)` on the root directory: a real-uid
/// permission question asked of the filesystem, which is what makes it answer correctly for
/// both halves of the case — a read-only *mount* and a permission-denied *folder*.
///
/// Deliberately **one-way**: a reconciling reload that finds the root unwritable does not
/// *raise* the lock. Arming it is the open flow's job (`enterUnwritableLock()`), and inferring
/// a lock from a probe here would be a policy decision this milestone was not asked to make.
private func clearLockIfDisproved(by origin: WatchOrigin) {
switch readOnlyLock {
case nil:
break
case .bracketedReloadFailed, .vanishedRoot:
readOnlyLock = nil
case .unwritableLocation:
guard origin == .reconciling, FileManager.default.isWritableFile(atPath: rootURL.path) else { return }
Self.logger.debug("writability re-probe passed — the unwritable-location lock clears")
readOnlyLock = nil
}
}
// MARK: - Root identity and explicit locks
/// Points this store at the board's new location, absorbing a rename or a move.
///
/// Called by the registry when a `.rootChanged` re-resolved the board's bookmark somewhere else
/// (02-architecture.md § Write-failure surfacing, "A renamed or moved board root follows its
/// file identity"): the board the app has open is the *file*, not the path string, so this is
/// bookkeeping rather than an event — **no banner, no lock, nothing was ever wrong**.
///
/// It deliberately starts **no reload**. The caller follows this with the watcher's
/// `reattach(to:)`, whose reconciling reload is the one that rebuilds the snapshot at the new
/// root; a reload fired from here would be a second walk racing that one for no gain. Until it
/// lands, `rootURL` is the new location and `snapshot.rootURL` is still the old — see
/// `rootURL`'s note.
public func relocate(to newRoot: URL) {
guard newRoot != rootURL else { return }
Self.logger.debug("board root relocated; Writer URLs now derive from the new location")
rootURL = newRoot
}
/// Raises the vanished-root read-only lock — the registry's call, after bookmark re-resolution
/// found nothing and the last-known path is not there either.
///
/// Overwrites whatever lock was standing: a root that is gone is the most current and most
/// specific truth about why writes are refused, and its clearing rule (a successful reload,
/// which can only happen if the root came back) is strictly the safer one to be holding.
public func enterVanishedRootLock() {
Self.logger.error("board root vanished — entering the read-only lock")
readOnlyLock = .vanishedRoot
}
/// Raises the unwritable-location read-only lock — the open flow's call, after probing the
/// root's writability (02 § "An unwritable board location enters the read-only lock at open").
/// Public now so the vocabulary and its clearing rule ship together; m4's open flow is the
/// producer.
///
/// Does **not** overwrite a standing lock: a board that is already locked for a vanished root
/// or a failed bracketed reload has a cause that outranks "and it is also read-only", and both
/// of those clear on a success that would then re-probe anyway.
public func enterUnwritableLock() {
guard readOnlyLock == nil else { return }
Self.logger.error("board location is not writable — entering the read-only lock")
readOnlyLock = .unwritableLocation
}
// 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.
///
/// **A failure posts to the banner before it is rethrown** (02-architecture.md § Write-failure
/// surfacing): the strip is how the one-way flow keeps its honesty — the action visibly did not
/// happen, and the banner is the only thing that says why — so no call site is trusted to
/// remember, and a `try?` at some future call site cannot make a failure silent. The refusal
/// below is deliberately *not* posted: the lock row is already standing, and a second row per
/// refused gesture would bury it under echoes of itself.
///
/// - 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<T>(_ 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() }
// `do throws(BoardWriteError)`: without the annotation the `catch` widens to `any Error` and
// the Writer's typed error is lost on the way to the banner.
do throws(BoardWriteError) {
return try operation()
} catch {
banners.post(error)
throw error
}
}
/// 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()
}
do {
try operation()
} catch let error as BoardWriteError {
// Same honesty rule as `performWrite`, applied to the one error type the banner has
// phrasing for. A wholesale operation is usually git's (m7), whose own failure
// vocabulary is not `BoardWriteError` and whose surfacing — the suspended-history
// condition, the in-progress row swapping for an error — is the committer's to drive;
// but a `BoardWriteError` escaping here is an ordinary failed write and may no more
// bypass the strip than one from `performWrite`.
banners.post(error)
throw error
}
}
// 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<ItemID>, 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()
}
}
}