The loader collects every fail-fast defect and honors per-open skips
Phase 1 of the decision surface (01 ▸ Malformed input, settled
2026-07-31): BoardLoadFailure aggregates the walk's defects in walk
order — stop-at-first retires. Environmental failures (unreadable root,
not-a-directory) stay immediate single-defect throws: there is no walk
to collect from. A defective root index is recorded and the walk
continues into the children (nothing in the walk consults the parsed
root document — verified); a defective lane, card, or trash-entry index
records and skips its subtree, Re-check's whole-walk re-aggregation
being the designed loop for what hides beneath. load(skipping:) is the
per-open skip channel: a skipped path's item is omitted from the model
and surfaces as LoadWarning.userSkipped; root paths are unskippable by
construction. The reload-breakage banner carries the aggregate ("…and
N more"), single-defect sentences byte-identical to before. Two new
multi-defect fixture boards; suite 2591 green.
Claude-Session: https://claude.ai/code/session_01CqjXB7ASoWtbyoGod68k97
This commit is contained in:
@@ -244,8 +244,10 @@ public enum BannerRow: Identifiable, Sendable {
|
||||
/// vanished root, and the writability probe — at open and, symmetrically, on every reconciling
|
||||
/// reload thereafter.
|
||||
case readOnlyLock(ReadOnlyLockReason)
|
||||
/// A reload failed and the last good snapshot is still on screen. Condition, error tone.
|
||||
case reloadBreakage(BoardLoadError)
|
||||
/// A reload failed and the last good snapshot is still on screen. Condition, error tone. Carries
|
||||
/// the **whole** aggregate — one row either way, but its headline names the first defect and
|
||||
/// counts the rest rather than pretending the walk found only one.
|
||||
case reloadBreakage(BoardLoadFailure)
|
||||
/// A write that did not happen. Dismissable, error tone.
|
||||
case oneShot(OneShotBanner)
|
||||
/// A git operation that did not happen — an undo restore, a branch switch, and (pro-m2) a pull
|
||||
@@ -813,7 +815,7 @@ public final class BannerCenter {
|
||||
/// Finder drop that skipped folders already posts one (`postSkippedFolders`).
|
||||
public nonisolated static func rows(
|
||||
lock: ReadOnlyLockReason?,
|
||||
breakage: BoardLoadError?,
|
||||
breakage: BoardLoadFailure?,
|
||||
oneShots: [OneShotBanner],
|
||||
losses: [LossBanner],
|
||||
suspension: HistorySuspension?,
|
||||
@@ -1154,12 +1156,31 @@ public final class BannerCenter {
|
||||
///
|
||||
/// The path is root-relative as `BoardLoadError` reports it, and `"."` — the root's own
|
||||
/// `index.md` — is spelled as "This board" rather than shown as a lone dot.
|
||||
public nonisolated static func headline(for breakage: BoardLoadError) -> String {
|
||||
let reason = trimmed(breakage.reason.description)
|
||||
let subject = breakage.path == "." || breakage.path.isEmpty
|
||||
///
|
||||
/// **One defect is named, the rest are counted** (01-storage-format.md § Malformed input: the
|
||||
/// loader collects every fail-fast defect in a walk). A banner is one line and a list of paths
|
||||
/// is the first thing that would truncate, so the sentence stays the sentence it always was —
|
||||
/// the walk's first defect, said in full — with ", and N more" between the reason and the
|
||||
/// reassurance. The full list is not lost: it is the decision surface's to show on the next
|
||||
/// attended open, which is where a repair is actually made.
|
||||
///
|
||||
/// A single-defect failure reads **exactly** as it did before the aggregate existed.
|
||||
public nonisolated static func headline(for breakage: BoardLoadFailure) -> String {
|
||||
breakageHeadline(breakage.primary, others: breakage.defects.count - 1)
|
||||
}
|
||||
|
||||
/// One defect's own sentence — the same rule, for the surfaces that hold exactly one and know
|
||||
/// it: the template chooser's unloadable row, whose folder is picked from rather than opened.
|
||||
public nonisolated static func headline(for defect: BoardLoadError) -> String {
|
||||
breakageHeadline(defect, others: 0)
|
||||
}
|
||||
|
||||
private nonisolated static func breakageHeadline(_ defect: BoardLoadError, others: Int) -> String {
|
||||
let subject = defect.path == "." || defect.path.isEmpty
|
||||
? "This board isn't loading"
|
||||
: "'\(breakage.path)' isn't loading"
|
||||
return "\(subject): \(reason) — showing the last good view"
|
||||
: "'\(defect.path)' isn't loading"
|
||||
let more = others > 0 ? ", and \(others) more" : ""
|
||||
return "\(subject): \(trimmed(defect.reason.description))\(more) — showing the last good view"
|
||||
}
|
||||
|
||||
/// The skipped-folders line — 04-interactions.md's own example, "Folders can't be attached — 2
|
||||
|
||||
@@ -238,9 +238,11 @@ public enum BoardAnnouncer {
|
||||
public var lockAfter: ReadOnlyLockReason?
|
||||
|
||||
/// The reload-breakage condition before and after, same rule: a different file failing to
|
||||
/// load is a different sentence and is worth saying.
|
||||
public var breakageBefore: BoardLoadError?
|
||||
public var breakageAfter: BoardLoadError?
|
||||
/// load is a different sentence and is worth saying. **The whole aggregate**, not just its
|
||||
/// first defect — a second broken lane appearing under an already-broken one changes the
|
||||
/// sentence the strip is showing ("and 2 more"), so it is news by the same test.
|
||||
public var breakageBefore: BoardLoadFailure?
|
||||
public var breakageAfter: BoardLoadFailure?
|
||||
|
||||
public init() {}
|
||||
}
|
||||
|
||||
@@ -279,12 +279,13 @@ public final class BoardStore: HealHost {
|
||||
defects.compactMap { if case let .duplicateIdentity(work) = $0 { work } else { nil } }
|
||||
}
|
||||
|
||||
/// 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?
|
||||
/// The standing read-side condition: the failure from the last reload that failed, `nil` when the
|
||||
/// board is healthy. `BoardLoadFailure` carries every fail-fast defect that walk found — each one
|
||||
/// 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; the banner shows `primary` and counts the
|
||||
/// rest). 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: BoardLoadFailure?
|
||||
|
||||
/// 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".
|
||||
@@ -582,8 +583,9 @@ public final class BoardStore: HealHost {
|
||||
/// 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
|
||||
/// no last-good snapshot to keep on screen yet, so a broken board throws its `BoardLoadFailure`
|
||||
/// — every fail-fast defect the walk found, aggregated — 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.
|
||||
///
|
||||
@@ -596,7 +598,7 @@ public final class BoardStore: HealHost {
|
||||
/// with a reload behind it rather than a write into a board nothing is watching yet. A store
|
||||
/// built directly (a test, a storeless consumer) heals when it is asked to, and on every reload
|
||||
/// thereafter.
|
||||
public init(rootURL: URL) throws(BoardLoadError) {
|
||||
public init(rootURL: URL) throws(BoardLoadFailure) {
|
||||
let result = try BoardLoader.load(boardRoot: rootURL)
|
||||
self.rootURL = rootURL
|
||||
self.snapshot = result.model
|
||||
@@ -676,10 +678,10 @@ public final class BoardStore: HealHost {
|
||||
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) {
|
||||
// `do throws(BoardLoadFailure)`: without the annotation the `catch` widens to `any Error`
|
||||
// and the loader's typed failure is lost on the way into `Result`.
|
||||
let outcome: Result<LoadResult, BoardLoadFailure>
|
||||
do throws(BoardLoadFailure) {
|
||||
outcome = .success(try BoardLoader.load(boardRoot: root, historyRanker: historyRanker))
|
||||
} catch {
|
||||
outcome = .failure(error)
|
||||
@@ -690,7 +692,7 @@ public final class BoardStore: HealHost {
|
||||
}
|
||||
|
||||
/// Lands one walk's result and starts whatever it uncovered.
|
||||
private func apply(_ outcome: Result<LoadResult, BoardLoadError>, generation: Int, origin: WatchOrigin) {
|
||||
private func apply(_ outcome: Result<LoadResult, BoardLoadFailure>, generation: Int, origin: WatchOrigin) {
|
||||
reloadInFlight = false
|
||||
|
||||
// The stale-apply guard. Serialization means this should not trigger today, but "only the
|
||||
@@ -704,7 +706,7 @@ public final class BoardStore: HealHost {
|
||||
resumeQuiescenceWaitersIfQuiet()
|
||||
}
|
||||
|
||||
private func land(_ outcome: Result<LoadResult, BoardLoadError>, generation: Int, origin: WatchOrigin) {
|
||||
private func land(_ outcome: Result<LoadResult, BoardLoadFailure>, 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.
|
||||
|
||||
@@ -119,7 +119,8 @@ public final class BoardStoreRegistry {
|
||||
|
||||
/// The store for `rootURL`, opening the board if this is the first window to ask for it.
|
||||
///
|
||||
/// **First acquire**: loads the board (fail-fast — the `BoardLoadError` is rethrown untouched,
|
||||
/// **First acquire**: loads the board (fail-fast — the `BoardLoadFailure` is rethrown untouched,
|
||||
/// every collected defect included, because the decision surface is the caller's to host —
|
||||
/// because there is nothing to render and nothing to fall back on), then creates and starts the
|
||||
/// watcher and ties the two together in both directions — watcher events into
|
||||
/// `BoardStore.handleWatcherEvent(_:)`, the store's write brackets out to
|
||||
@@ -136,7 +137,7 @@ public final class BoardStoreRegistry {
|
||||
///
|
||||
/// A failed load leaves **nothing behind**: no entry, no watcher, no count. A board that failed
|
||||
/// to open is not open.
|
||||
public func acquire(_ rootURL: URL) throws(BoardLoadError) -> BoardStore {
|
||||
public func acquire(_ rootURL: URL) throws(BoardLoadFailure) -> BoardStore {
|
||||
if let identity = FileIdentity(of: rootURL), var entry = entries[identity] {
|
||||
entry.referenceCount += 1
|
||||
entries[identity] = entry
|
||||
@@ -167,7 +168,8 @@ public final class BoardStoreRegistry {
|
||||
// out. The loader's own vocabulary says it; no new error path is invented for a case that
|
||||
// means exactly what `unreadableRoot` already means.
|
||||
guard let identity = FileIdentity(of: rootURL) else {
|
||||
throw BoardLoadError(path: ".", reason: .unreadableRoot(message: "the board root has no file identity"))
|
||||
throw BoardLoadFailure(
|
||||
BoardLoadError(path: ".", reason: .unreadableRoot(message: "the board root has no file identity")))
|
||||
}
|
||||
|
||||
// Both directions of the wiring capture weakly, and the registry's entry is what keeps the
|
||||
|
||||
Reference in New Issue
Block a user