The crash-class gap the integrity design pass found (DESIGN/01 - Fractal layout rules; 02 - Live-reload resilience): the loader had no board-wide dedupe at all, so two hand-copied folders sharing a UUID put two equal ItemIDs into one snapshot - which SwiftUI's ForEach does not tolerate. Built to the day's re-rulings, both landing mid-flight: the user-gated Repair banner retired (176c852- the heal runs silently) and the container boundary became the first tie-break (f153e79- the visible card never loses to its own trash ghost). IntegrityRules.dedupe (pure, occurrence list in, verdict out): group by canonical identity, collapse case-spelled twins first - spellings with a live occurrence outrank trash-only spellings, then canonical lowercase, then lexicographically first; losers are silent strays (LoadWarning.caseTwinIgnored - spelling artifacts, never reminted) - then earlier-occurrence-wins across the surviving spelling's folders on a four-rung ladder: live-before-trashed, git path history rank, FS birth date (nil is no comparison, never .distantPast), traversal order. Occurrences are exactly the identity-bearing folders: lanes, cards, .trash entries - a UUID-shaped folder under a card is content. BoardLoader walks lanes as WalkedLane and builds Lane values only on the far side of the verdict, so a withheld card can never reach a snapshot; a name-only gate keeps the healthy-board cost at one dictionary pass, no disk reads. Withheld subtrees are still walked - a hand-copied lane's nested cards are their own withheld occurrences, reminted at the finest grain like the import boundary would have. A withheld trash entry's trashKinds reading leaves with it. The git rung is a seam (BoardLoader.IdentityHistoryRanker, one closure keyed by root-relative path) because base links no git machinery - base injects nothing and falls through; pro-m1 owns the ranker (card annotated). The heal: Defect.duplicateIdentity (signature duplicate:<path>:<id>) rides HealScheduler as the fourth scheduled heal, ordered last among the content heals because a remint renames folders and would stale the paths the same load handed the relocation and migration. BoardWriter.remintDuplicateIdentity re-verifies twice at write time - the folder still carries the losing identity AND something else still does (the vanished-duplicate race no-ops from either side) - then renames to a fresh v4 minted against the whole board's identity bag. A rename and nothing else: no index.md opened, no modified stamp, no modified-by clear; the receipt is heal-marked (pro-m1's committer splits it out, named by 06's kept Repair verb); no undo step - heals are not gestures. The notice is the design's own sentence ("Repaired duplicate id - 'Fix login'"; several fold to a count), a loss row on the relocation's reasoning; WriteOperation.repairDuplicateID carries the failure mirror. Fixture repair rode along: duplicate-order-tie-break.kanban had a lane and its own card sharing a UUID - a genuine duplicate the new pass correctly withholds; the folder rename landed infeae6d0, the matching test constant lands here. 66 tests added (DuplicateIdentityTests: the ladder rung by rung, the straddles, withheld-lane subtrees, remint idempotence and races, the one-heal-cycle window, all phrasing). 1804 green on both schemes. Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
232 lines
13 KiB
Swift
232 lines
13 KiB
Swift
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<T>(_ 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<String>] = [:]
|
|
|
|
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<String>? {
|
|
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<String>,
|
|
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])
|
|
/// Loss row, warning tone: withheld duplicate ids were reminted, named by title.
|
|
///
|
|
/// **Announced rather than silent, and that is the ruling's own word** (01-storage-format.md
|
|
/// § Validation and healing, re-ruled 2026-07-29): a heal that changes user content — and an
|
|
/// identity is content — "announces itself with a warning-tone notice … never a consent gate".
|
|
/// This row is what replaced the retired user-gated Repair banner: the same information, none
|
|
/// of the waiting.
|
|
case remintedDuplicateIDs(titles: [String?])
|
|
}
|
|
|
|
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)
|
|
case let .remintedDuplicateIDs(titles):
|
|
banners.postRemintedDuplicateIDs(titles)
|
|
}
|
|
}
|
|
|
|
// 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<Placement>(
|
|
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)
|
|
}
|
|
}
|