import Foundation import os // MARK: - The host /// What a scheduled heal needs from the board it is healing — the seam that keeps the engine /// independent of `BoardStore` (which is the only conformer, and a test's fake is the second). /// /// Deliberately four members and no more: the engine decides *whether and when*, never *what*. The /// work itself is a closure the store hands over, because only the store knows which Writer call a /// defect class maps to. @MainActor public protocol HealHost: AnyObject { /// Where the board is **now** — re-read per heal, because a rename absorbed mid-session moves it. var rootURL: URL { get } /// The standing read-only condition, `nil` when the board accepts writes. var readOnlyLock: ReadOnlyLockReason? { get } /// The board window's banner strip — the engine posts through the posture table below, and /// `BannerCenter` keeps every word. var banners: BannerCenter { get } /// The write gate: one watcher bracket, one app-mediated reload, the failure already banner-ed. @discardableResult func performWrite(_ operation: () throws(BoardWriteError) -> T) throws -> T } // MARK: - HealScheduler /// **The scheduled-heal engine** — the six-step pattern expressed once (02-architecture.md ▸ /// Components ▸ HealScheduler; 01-storage-format.md § Validation and healing, settled 2026-07-29). /// /// Before this existed, three healers in `BoardStore` re-derived the same pattern by hand — the /// loose-file relocation, the legacy-tombstone migration, and the agent-guide refresh — and the /// copies had already drifted: only the guide defended itself against an unwritable location, only /// the guide cleared its memo on success, and only two of the three fired at board open. The engine /// is the pattern with the drift taken out. /// /// ### The six steps, in order /// /// 1. **Compute the work from the latest defects**, and **rest** when there is none: an empty /// signature clears the memo, which is what makes a board with nothing to heal forget it ever /// tried. /// 2. **The lock-and-writability gate.** A read-only board **defers, never abandons** — it returns /// having written nothing *and having remembered nothing*, so the reload that lifts the lock is /// the reload that heals. The `access(2)` check beside it was the guide's private defense and is /// now everyone's: it covers the window *between* writability probes, where a volume remounted /// read-only would otherwise let a heal reach the Writer, fail, and banner about work the user /// never asked for. /// 3. **Signature compare.** An attempt is made only when the defect picture **differs from the one /// last acted on** — one failure, one banner row, then silence until something on disk actually /// changes. /// 4. **Arm the memo *before* attempting**, so a write that throws leaves it set: the memo's whole /// job is to remember pictures this store has already failed on. /// 5. **One write bracket**, whose write half re-verifies each defect against disk and no-ops when /// it is gone — losing the race to a foreign fix is success, never an error. /// 6. **Post per the banner-posture table**, then **clear the memo explicitly on success** — the /// guide's rule, generalized. Clearing is load-bearing rather than tidy: the memo keys on the /// picture that provoked the write, and a foreign undo restores that exact picture, which a /// standing memo would make the one thing the self-heal could not heal. /// /// ### Where it runs /// /// **At the reload tail and at registry acquire, uniformly** — closing the asymmetry where the /// tombstone migration never fired at open. Both seams call `BoardStore.runScheduledHeals()`. /// /// ### What it deliberately is not /// /// Not the home of *inline* heals, which stay gesture-scoped (`placingRanks` below is a helper for /// one of them, not a scheduling of it), and not the home of *on-touch* heals, which live at the /// Writer's `updateIndex` seam. The three timings are different by design (01-storage-format.md /// § Validation and healing), and only this one needs a scheduler. @MainActor public final class HealScheduler { /// The picture each defect class was last *acted on* against — armed before an attempt, cleared /// on success, and reset to nothing when the class has no work at all. /// /// **In-memory, per-store, dies with the session**, like every other loop guard here: closing /// the board is the reset. private var memos: [IntegrityRules.Defect.Class: Set] = [:] private static let logger = Logger(subsystem: "dev.rzen.indie.Kanban", category: "heal") public init() {} /// The picture a class was last acted on, for tests and diagnostics. `nil` means "nothing has /// been acted on against the current picture" — both the opening state and the resting one. public func memo(for healClass: IntegrityRules.Defect.Class) -> Set? { memos[healClass] } // MARK: - The engine /// Runs one scheduled heal against `host`, per the six steps above. /// /// - Parameters: /// - healClass: the memo key and the banner-posture row. /// - signature: the defect picture, as comparable values. **Empty means resting** — nothing to /// heal — and is the one input that clears the memo without writing. /// - work: the write half. Runs inside the host's write bracket, and re-verifies each defect /// against disk itself (the Writer's heal operations do it per call). /// - notice: what to say afterwards, read **whether or not `work` threw** — a batch that moved /// three files and failed on the fourth still names the three, which is what the relocation /// has always done and what a partial heal owes the user. public func run( _ healClass: IntegrityRules.Defect.Class, signature: Set, on host: some HealHost, performing work: () throws(BoardWriteError) -> Void, posting notice: () -> HealNotice = { .none } ) { // 1. Resting-clear. guard !signature.isEmpty else { memos[healClass] = nil return } // 2. The lock-and-writability gate — deferral, deliberately *before* the memo is written, so // the attempt a lock refused is not the attempt the guard below remembers. guard host.readOnlyLock == nil else { Self.logger.debug("\(String(describing: healClass), privacy: .public) heal deferred — the board is read-only") return } guard FileManager.default.isWritableFile(atPath: host.rootURL.path) else { Self.logger.debug("\(String(describing: healClass), privacy: .public) heal skipped — the board's location is not writable") return } // 3. Signature compare, and 4. arm before attempting. guard memos[healClass] != signature else { return } memos[healClass] = signature // 5. One bracket. `performWrite` posts every `BoardWriteError` before it rethrows, and the // rethrow is swallowed here like every other app-initiated write with nothing else to do // about a failure — the armed memo is what keeps it from being posted again every reload. let landed: Void? = try? host.performWrite(work) // 6. Post, then clear on success. Self.post(notice(), to: host.banners) if landed != nil { memos[healClass] = nil } } // MARK: - The banner-posture table /// What a heal has to say for itself — **one row per defect class, declared once** /// (02-architecture.md ▸ Components: "post per one banner-posture table … BannerCenter still /// owns all phrasing"). /// /// Every case here is a *loss row* or silence; there is no failure row, because failures are /// `performWrite`'s and reach the strip as one-shots without anyone here deciding. The payloads /// are values, never strings: the phrasing lives in `BannerCenter` and nowhere else, so a /// sentence cannot be spelled two ways. public enum HealNotice: Sendable, Equatable { /// Silent — nothing was healed, or the heal is one the user has no business hearing about /// (the guide's ordinary refresh: a courtesy file the user did not create and may not know /// exists). case none /// Loss row, warning tone: files were moved into a card's `attachments/`. case relocatedLooseFiles([BannerCenter.Relocation]) /// Loss row, warning tone: legacy `deleted:` keys were resolved. case migratedTombstones(cards: [String?], lanes: [String?]) /// Loss row, warning tone: a claimed name's squatter was moved aside, named old and new. case displacedClaimedNames([BannerCenter.Displacement]) } private static func post(_ notice: HealNotice, to banners: BannerCenter) { switch notice { case .none: break case let .relocatedLooseFiles(relocations): banners.postRelocatedLooseFiles(relocations) case let .migratedTombstones(cards, lanes): banners.postMigratedTombstones(cards: cards, lanes: lanes) case let .displacedClaimedNames(displacements): banners.postDisplacedClaimedNames(displacements) } } // MARK: - The inline renumber-and-retry /// **The midpoint-exhaustion two-step, once** — ask, renumber, ask again (01-storage-format.md /// § Ordering; 02-architecture.md ▸ Components: "the renumber's ask-renumber-ask-again two-step /// as one shared helper instead of today's nine hand-rolled copies"). /// /// An **inline** heal, and it stays gesture-scoped: this runs inside the caller's own /// `performWrite` bracket, inside the gesture that triggered it, and composes no event of its /// own (the renumber is bookkeeping — 06-history-undo.md ▸ Commit messages). It is a helper here /// rather than a method on the engine because inline heals are not scheduled; what they share /// with the scheduled ones is the vocabulary, not the machinery. /// /// - Parameters: /// - ladder: the ranks to place among, in display order. /// - parentFolder: whose visible children compact when the first ask fails — a lane /// (renumbering its cards) or the board root (renumbering its lanes). /// - ask: computes the placement from a ladder. **`nil` means the gap is exhausted, not that /// the insertion is illegal** (`Ranks`' own convention). /// - Returns: what `ask` answered, plus the ladder it answered against — the *renumbered* one /// when a compaction happened, which callers need because a renumber moves ranks they had /// already read (a dragged item's own prior rank, the undo step's inverse). `nil` when even /// the compacted ladder had no answer, which every call site treats as "write nothing". /// /// `nonisolated` because it is not the engine's state, only its vocabulary: an inline heal has /// no memo, no gate and no bracket of its own, and tying it to the main actor would claim /// otherwise. public nonisolated static func placingRanks( amongVisible ladder: [Double], compacting parentFolder: URL, _ ask: ([Double]) -> Placement? ) throws(BoardWriteError) -> (placement: Placement, ladder: [Double], renumbered: Bool)? { if let placement = ask(ladder) { return (placement: placement, ladder: ladder, renumbered: false) } // Compact, then place against the fresh ranks. The renumber assigns in display order, so the // compacted ladder lines up one-for-one with the one that was passed in. try BoardWriter.renumberVisibleChildren(of: parentFolder) let compacted = Ranks.renumbered(count: ladder.count) guard let placement = ask(compacted) else { return nil } return (placement: placement, ladder: compacted, renumbered: true) } }