Build the integrity service - IntegrityRules and the HealScheduler
The 2026-07-29 integrity design pass, consolidated (DESIGN/01 -
Validation and healing; DESIGN/02 - Components): IntegrityRules
(Storage, pure) is the one home for the identity predicate and
canonical form (BoardWriter.canonicalIdentity deleted, ItemID and the
loader forward to it), the per-field rulebook, uneditable shapes,
per-kind index validation, the reserved-name tables, and the trash
kind discriminator (values trusted - kind: lane/card explicit,
unrecognized falls to shape). LoadResult's ad-hoc channels fold into
one typed Defect stream (looseCardFiles / legacyTombstone /
claimedNameSquatted, per-defect heal signatures); the old accessors
survive as computed views.
HealScheduler (LiveStore) states the six-step heal pattern once -
resting-clear, lock gate, isWritableFile gate (now covering all four
heals), signature memo armed-before-attempt with explicit
clear-on-success, disk re-verify in each write half, one banner-posture
table (BannerCenter keeps all phrasing). The three hand-rolled healers
run on it with behavior preserved - including the
relocation-notice-despite-partial-failure quirk, deliberately. Heals
run at the reload tail AND at registry acquire, closing the
migration-never-fires-at-open asymmetry. Displacement runs first: a
squatted .trash would otherwise fail the migration and arm its memo
against an unchanged picture.
Claimed-name squatters (ruled today, 62c47a2) displace by the shared
Finder-style rename ladder - preserved verbatim, symlinks moved as
links, nothing stamped; AgentGuide's untouchable-skip upgrades to
displace-then-write, the CLAUDE.user.md-taken skip stands. kind stamps
on every create and backfills on any index rewrite via the on-touch
seam (placement resolver stamps nothing when the parent is unknown -
a guessed kind is worse than an absent one; board-root writers declare
theirs). Heal writes mark their EchoLedger receipts (inert in base;
pro-m1's committer will split them into their own commits). The
renumber ask-renumber-ask-again two-step is one shared helper, adopted
at all nine call sites.
69 tests added. 1738 green on both schemes.
Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
This commit is contained in:
@@ -490,6 +490,40 @@ public final class BannerCenter {
|
||||
postLoss(message)
|
||||
}
|
||||
|
||||
/// One claimed board-root name whose squatter was moved aside — what
|
||||
/// `displacedClaimedNamesMessage(for:)` names.
|
||||
///
|
||||
/// Both names are carried because the notice owes **old and new** (01-storage-format.md
|
||||
/// § Fractal layout ▸ Rules, ruled 2026-07-29: "with the relocation-style warning-tone notice
|
||||
/// naming old and new"): the user needs to know which of their files moved *and* where to find
|
||||
/// it, and a sentence naming only one of the two would be half an answer.
|
||||
public struct Displacement: Sendable, Equatable {
|
||||
/// The claimed name the app took back — `.trash`, `CLAUDE.md`.
|
||||
public let name: String
|
||||
/// The Finder-ladder name the displaced node now has — `.trash 2`.
|
||||
public let movedTo: String
|
||||
|
||||
public init(name: String, movedTo: String) {
|
||||
self.name = name
|
||||
self.movedTo = movedTo
|
||||
}
|
||||
}
|
||||
|
||||
/// **The claimed-name displacement** (01-storage-format.md § Fractal layout ▸ Rules, ruled
|
||||
/// 2026-07-29): a folder, file or symlink was sitting on a name the app owns, the app moved it
|
||||
/// aside — preserved verbatim, never destroyed — and this is the row that says so.
|
||||
///
|
||||
/// **A loss row, on `postRelocatedLooseFiles`' exact reasoning**, which is also what the ruling
|
||||
/// asks for by name ("the relocation-style warning-tone notice"): the app moved something of the
|
||||
/// user's that they did not ask it to move, so it must be said out loud, must not evaporate
|
||||
/// unread, and must not rank as an error, because nothing failed.
|
||||
///
|
||||
/// A displacement that displaced nothing posts nothing.
|
||||
public func postDisplacedClaimedNames(_ displacements: [Displacement]) {
|
||||
guard let message = Self.displacedClaimedNamesMessage(for: displacements) else { return }
|
||||
postLoss(message)
|
||||
}
|
||||
|
||||
/// Posts the skipped-folders loss row for a Finder drop that imported its files but refused its
|
||||
/// folders (04-interactions.md ▸ Selection, drag & drop, "Folders are refused at hover"): "a
|
||||
/// mixed drag proposes for its files only, and the drop imports the files while a one-shot
|
||||
@@ -757,6 +791,13 @@ public final class BannerCenter {
|
||||
// touched, and nothing is lost: the board works exactly as well without the guide, which
|
||||
// is why every *refusal* to write it is a log line and only a real I/O failure gets here.
|
||||
"Couldn't update the agent guide"
|
||||
case let .displaceClaimedName(name):
|
||||
// **The name, quoted, and what the app wanted with it** — the failure's mirror of the
|
||||
// success row ("Renamed '.trash' to '.trash 2' — Lanework needs that name"). It names
|
||||
// the *consequence* the user can act on rather than the mechanics: while the name is
|
||||
// held, the feature that needs it does not work, and the fix is theirs (move or rename
|
||||
// the thing sitting there) because the app has just demonstrated it cannot.
|
||||
"Couldn't move '\(name)' aside — Lanework needs that name"
|
||||
case let .toggleTask(title):
|
||||
// The user's word for it, not the file's: they ticked a box. The card is named where
|
||||
// the read that preceded the flip learned its title, so a body write that refused says
|
||||
@@ -957,6 +998,27 @@ public final class BannerCenter {
|
||||
return "\(clauses.joined(separator: " and ")) — \(tail)"
|
||||
}
|
||||
|
||||
/// The claimed-name displacement's line — the relocation's own voice (the act first, the subject
|
||||
/// after an em dash), naming **old and new** as the ruling requires.
|
||||
///
|
||||
/// - **One name**: "Renamed '.trash' to '.trash 2' — Lanework needs that name". The tail is the
|
||||
/// whole explanation the row owes: the user did not rename anything, and without it the
|
||||
/// sentence would read as an act they had somehow just taken. It says *needs the name* rather
|
||||
/// than anything about what was there, because what was there is the user's business and
|
||||
/// still exists, under the name the row just gave them.
|
||||
/// - **Several**: folded to a count in the relocation's idiom — "Renamed 2 items — Lanework
|
||||
/// needs those names". Two claimed names can be squatted at once (a board somebody unpacked
|
||||
/// over an old one), and a two-clause sentence would be longer than the row.
|
||||
///
|
||||
/// `nil` when nothing moved — a displacement that displaced nothing is not news.
|
||||
public nonisolated static func displacedClaimedNamesMessage(for displacements: [Displacement]) -> String? {
|
||||
guard let only = displacements.first else { return nil }
|
||||
guard displacements.count == 1 else {
|
||||
return "Renamed \(displacements.count) items — Lanework needs those names"
|
||||
}
|
||||
return "Renamed '\(only.name)' to '\(only.movedTo)' — Lanework needs that name"
|
||||
}
|
||||
|
||||
/// A sole migrated item's name: its title in quotes, or the untitled rendering the relocation
|
||||
/// line already uses ("an untitled card" / "an untitled lane" are one phrase here, because the
|
||||
/// clause it sits in already says which level it is).
|
||||
|
||||
+341
-338
@@ -222,7 +222,7 @@ public enum TransferOperation: Sendable, Equatable {
|
||||
/// is simply rendered on top of whatever the latest reload produced.
|
||||
@MainActor
|
||||
@Observable
|
||||
public final class BoardStore {
|
||||
public final class BoardStore: HealHost {
|
||||
|
||||
// MARK: Read-side state
|
||||
|
||||
@@ -244,23 +244,30 @@ public final class BoardStore {
|
||||
/// describe the tree currently on screen.
|
||||
public private(set) var loadWarnings: [LoadWarning]
|
||||
|
||||
/// The cards the load that produced `snapshot` found holding loose files, exactly as the loader
|
||||
/// reported them — the loose-file carve-out's detection channel (01-storage-format.md § Fractal
|
||||
/// layout ▸ Rules, settled 2026-07-28). Replaced with the snapshot, like `loadWarnings`, so it
|
||||
/// always describes the tree currently on screen.
|
||||
/// **The pending work the load that produced `snapshot` found** — the typed defect stream
|
||||
/// (`IntegrityRules.Defect`, settled 2026-07-29). Replaced with the snapshot, like
|
||||
/// `loadWarnings`, so it always describes the tree currently on screen.
|
||||
///
|
||||
/// **Nothing renders it.** A loose file is not content — it reaches no view, and the card it
|
||||
/// sits in draws exactly as it would without it. Its one consumer is
|
||||
/// `relocateLooseCardFiles()`, immediately below the reload that produced it.
|
||||
public private(set) var looseCardFiles: [LooseCardFiles]
|
||||
/// **Nothing renders it.** A defect is not content — it reaches no view, and the board draws
|
||||
/// exactly as it would without it. Its one consumer is `runScheduledHeals()`, immediately below
|
||||
/// the reload that produced it.
|
||||
public private(set) var defects: [IntegrityRules.Defect]
|
||||
|
||||
/// The legacy `deleted:` keys the load that produced `snapshot` found — the retired tombstone
|
||||
/// model's migration input (01-storage-format.md § Deletion, resettled 2026-07-28), in the
|
||||
/// loose-file channel's idiom and replaced with the snapshot exactly as it is.
|
||||
///
|
||||
/// **Nothing renders it either.** Its one consumer is `migrateLegacyTombstones()`, immediately
|
||||
/// below the reload that produced it.
|
||||
public private(set) var legacyTombstones: [LegacyTombstone]
|
||||
/// The cards the last load found holding loose files — a view over `defects`, under the name it
|
||||
/// has always had.
|
||||
public var looseCardFiles: [LooseCardFiles] {
|
||||
defects.compactMap { if case let .looseCardFiles(work) = $0 { work } else { nil } }
|
||||
}
|
||||
|
||||
/// The legacy `deleted:` keys the last load found — a view over `defects`.
|
||||
public var legacyTombstones: [LegacyTombstone] {
|
||||
defects.compactMap { if case let .legacyTombstone(work) = $0 { work } else { nil } }
|
||||
}
|
||||
|
||||
/// The claimed board-root names the last load found squatted — a view over `defects`.
|
||||
public var claimedNameSquatters: [ClaimedNameSquatter] {
|
||||
defects.compactMap { if case let .claimedNameSquatted(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
|
||||
@@ -471,24 +478,14 @@ public final class BoardStore {
|
||||
@ObservationIgnored
|
||||
private var quiescenceWaiters: [CheckedContinuation<Void, Never>] = []
|
||||
|
||||
/// The loose-file set the last relocation attempt was made against — the loop guard
|
||||
/// `relocateLooseCardFiles()` documents. Empty means "nothing has been attempted against the
|
||||
/// current picture", which is both the opening state and what a clean board resets it to.
|
||||
/// **The scheduled-heal engine** (02-architecture.md ▸ Components ▸ HealScheduler): the six-step
|
||||
/// pattern the three healers below used to re-derive one by one, plus the memo each of them
|
||||
/// used to keep on its own.
|
||||
///
|
||||
/// A `let` beside `transient`, `banners` and `echoes`, and for their reason: the memos are
|
||||
/// per-open state, and closing the board is the reset.
|
||||
@ObservationIgnored
|
||||
private var attemptedRelocation: Set<String> = []
|
||||
|
||||
/// The tombstone set the last migration attempt was made against — `attemptedRelocation`'s twin,
|
||||
/// documented at `migrateLegacyTombstones()`.
|
||||
@ObservationIgnored
|
||||
private var attemptedTombstoneMigration: Set<String> = []
|
||||
|
||||
/// The board root's agent-guide picture the last refresh acted on — the third of the same loop
|
||||
/// guard, documented at `refreshAgentGuide()`. `nil` means "nothing has been acted on against
|
||||
/// the current picture", which is both the opening state and what a board whose guide is already
|
||||
/// current resets it to (so the state it holds is never a guide's own text for longer than the
|
||||
/// one reload that wrote it).
|
||||
@ObservationIgnored
|
||||
private var attemptedGuideRefresh: AgentGuide.State?
|
||||
let heals = HealScheduler()
|
||||
|
||||
/// 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.
|
||||
@@ -534,19 +531,18 @@ public final class BoardStore {
|
||||
/// The walk is synchronous because the caller has nothing to render until it lands; the
|
||||
/// asynchronous, off-main pipeline starts with the first reload.
|
||||
///
|
||||
/// **It writes nothing, the opened board's loose files included.** `looseCardFiles` is recorded
|
||||
/// here and acted on by whoever wired this store up — `BoardStoreRegistry.acquire` calls
|
||||
/// `relocateLooseCardFiles()` once the watcher and the brackets exist, so the relocation is a
|
||||
/// bracketed write 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) relocates when it is asked to, and
|
||||
/// on every reload thereafter.
|
||||
/// **It writes nothing, the opened board's defects included.** `defects` is recorded here and
|
||||
/// acted on by whoever wired this store up — `BoardStoreRegistry.acquire` calls
|
||||
/// `runScheduledHeals()` once the watcher and the brackets exist, so a heal is a bracketed write
|
||||
/// 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) {
|
||||
let result = try BoardLoader.load(boardRoot: rootURL)
|
||||
self.rootURL = rootURL
|
||||
self.snapshot = result.model
|
||||
self.loadWarnings = result.warnings
|
||||
self.looseCardFiles = result.looseCardFiles
|
||||
self.legacyTombstones = result.legacyTombstones
|
||||
self.defects = result.defects
|
||||
self.reloadFailure = nil
|
||||
self.readOnlyLock = nil
|
||||
self.transient = TransientBoardState()
|
||||
@@ -765,8 +761,7 @@ public final class BoardStore {
|
||||
// Breakage always heals on a success — it *is* the claim "the last reload failed", and
|
||||
// this one did not.
|
||||
reloadFailure = nil
|
||||
looseCardFiles = result.looseCardFiles
|
||||
legacyTombstones = result.legacyTombstones
|
||||
defects = result.defects
|
||||
reconcileLock(after: origin)
|
||||
// The registry write-through, for the same "not board structure" reason the lock
|
||||
// clearing sits out here: whether this board's row needs a new title, icon, or
|
||||
@@ -774,29 +769,14 @@ public final class BoardStore {
|
||||
// guard), not a decision this store makes by comparing against its own prior
|
||||
// snapshot.
|
||||
displayStateDelegate?()
|
||||
// Last, and after `reconcileLock` deliberately: this is the seam the two deferred
|
||||
// app-initiated writes are armed on. A board that was locked read-only tolerated its
|
||||
// loose files and its legacy tombstones for exactly as long as the lock stood, and the
|
||||
// reload that clears the lock is the reload that lets them move — see
|
||||
// `relocateLooseCardFiles()` and `migrateLegacyTombstones()`. The ordering cuts the
|
||||
// other way too now that the probe is symmetric: a reconciling reload that *raises* the
|
||||
// lock raises it before these three run, so none of them writes into a location the
|
||||
// same reload just learned is read-only.
|
||||
//
|
||||
// The migration goes second only because the relocation is the older rule; they touch
|
||||
// disjoint files (loose files beside an `index.md` vs the `deleted:` key inside one) and
|
||||
// share one bracket-per-call posture, so neither can see the other's work half-done —
|
||||
// each opens its own bracket and each is re-armed by the reload the other's write
|
||||
// produces.
|
||||
//
|
||||
// The agent guide joins them last, and is the one of the three that runs on *every*
|
||||
// board rather than only on one an older version or an outside writer left work in: it
|
||||
// re-checks a single board-root file and writes only when the version marker says to
|
||||
// (08-agent-integration.md ▸ The agent guide). Running it here rather than at open alone
|
||||
// is what makes it self-healing — see `refreshAgentGuide()`.
|
||||
relocateLooseCardFiles()
|
||||
migrateLegacyTombstones()
|
||||
refreshAgentGuide()
|
||||
// **The reload tail** — one of the two seams the heal engine runs at (the other is
|
||||
// `BoardStoreRegistry.acquire`), and after `reconcileLock` deliberately: this is where
|
||||
// the deferred app-initiated writes are armed. A board that was locked read-only
|
||||
// tolerated its defects for exactly as long as the lock stood, and the reload that
|
||||
// clears the lock is the reload that heals them. The ordering cuts the other way too:
|
||||
// a reconciling reload that *raises* the unwritable-location lock raises it before this
|
||||
// runs, so no heal writes into a location the same reload just learned is read-only.
|
||||
runScheduledHeals()
|
||||
|
||||
case let .failure(error):
|
||||
// `snapshot`, `loadWarnings` and the transient state are untouched: a failed reload
|
||||
@@ -1398,7 +1378,13 @@ public final class BoardStore {
|
||||
for edit in edits {
|
||||
// `.style(title: nil)`: `updateIndex` enriches it off the document it reads, so a
|
||||
// failure names the item by the title it still has (see `WriteOperation.style`).
|
||||
try BoardWriter.updateIndex(inItemFolder: edit.folder, operation: .style(title: nil)) { document in
|
||||
// `kind: .board` for the one subject with no identity — the board root, whose
|
||||
// position nothing can infer (`BoardWriter.updateIndex`'s on-touch backfill).
|
||||
try BoardWriter.updateIndex(
|
||||
inItemFolder: edit.folder,
|
||||
kind: edit.id == nil ? .board : nil,
|
||||
operation: .style(title: nil)
|
||||
) { document in
|
||||
Self.apply(edit.background, to: FrontmatterKeys.background, in: &document)
|
||||
Self.apply(edit.icon, to: FrontmatterKeys.icon, in: &document)
|
||||
}
|
||||
@@ -1437,14 +1423,22 @@ public final class BoardStore {
|
||||
}
|
||||
) { _ in
|
||||
for edit in edits {
|
||||
try BoardWriter.updateIndex(inItemFolder: edit.folder, operation: .style(title: nil)) { document in
|
||||
try BoardWriter.updateIndex(
|
||||
inItemFolder: edit.folder,
|
||||
kind: edit.id == nil ? .board : nil,
|
||||
operation: .style(title: nil)
|
||||
) { document in
|
||||
Self.restore(edit.priorBackground, to: FrontmatterKeys.background, in: &document)
|
||||
Self.restore(edit.priorIcon, to: FrontmatterKeys.icon, in: &document)
|
||||
}
|
||||
}
|
||||
} redo: { _ in
|
||||
for edit in edits {
|
||||
try BoardWriter.updateIndex(inItemFolder: edit.folder, operation: .style(title: nil)) { document in
|
||||
try BoardWriter.updateIndex(
|
||||
inItemFolder: edit.folder,
|
||||
kind: edit.id == nil ? .board : nil,
|
||||
operation: .style(title: nil)
|
||||
) { document in
|
||||
Self.apply(edit.background, to: FrontmatterKeys.background, in: &document)
|
||||
Self.apply(edit.icon, to: FrontmatterKeys.icon, in: &document)
|
||||
}
|
||||
@@ -1551,16 +1545,16 @@ public final class BoardStore {
|
||||
// degenerate reorder — "a move whose destination is the item's current parent degrades
|
||||
// to a plain reorder" — inside the *same* `performWrite`, so the pair rounds back as
|
||||
// one app-mediated reload rather than showing the card at the bottom for a frame.
|
||||
var rank = Ranks.insertionRank(amongVisible: visible.map(\.order), at: position)
|
||||
if rank == nil {
|
||||
// Midpoint precision exhausted between the anchor and its neighbour
|
||||
// (01-storage-format.md § Ordering). Compact, then place against the fresh ranks:
|
||||
// the new card is not among the renumbered siblings — it was appended past them —
|
||||
// so the compacted ladder lines up one-for-one with `visible`.
|
||||
try BoardWriter.renumberVisibleChildren(of: laneFolder)
|
||||
rank = Ranks.insertionRank(amongVisible: Ranks.renumbered(count: visible.count), at: position)
|
||||
}
|
||||
guard let rank else { return id }
|
||||
// Ask, and on exhausted midpoint precision (01-storage-format.md § Ordering) compact
|
||||
// and ask again — the shared two-step. The new card is not among the renumbered
|
||||
// siblings (it was appended past them), so the compacted ladder lines up one-for-one
|
||||
// with `visible` and nothing captured needs refreshing here.
|
||||
guard let placed = try HealScheduler.placingRanks(
|
||||
amongVisible: visible.map(\.order),
|
||||
compacting: laneFolder,
|
||||
{ Ranks.insertionRank(amongVisible: $0, at: position) }
|
||||
) else { return id }
|
||||
let rank = placed.placement
|
||||
|
||||
_ = try BoardWriter.moveItem(
|
||||
at: laneFolder.appendingPathComponent(id.rawValue),
|
||||
@@ -1670,8 +1664,16 @@ public final class BoardStore {
|
||||
/// The title write every rename shares — the item-level one and the board's — spelled once so
|
||||
/// the empty-title rule (a missing key, never `title: ""`) cannot differ between a gesture and
|
||||
/// its own undo.
|
||||
private static func setTitle(_ title: String?, at folder: URL) throws(BoardWriteError) {
|
||||
try BoardWriter.updateIndex(inItemFolder: folder, operation: .rename(title: nil)) { document in
|
||||
///
|
||||
/// - Parameter kind: `.board` from the board rename, `nil` from an item's — the on-touch `kind`
|
||||
/// backfill's one declared case, since a board root's folder name is a Finder document name
|
||||
/// and position cannot answer for it (`BoardWriter.updateIndex`).
|
||||
private static func setTitle(
|
||||
_ title: String?,
|
||||
at folder: URL,
|
||||
kind: IntegrityRules.ObjectKind? = nil
|
||||
) throws(BoardWriteError) {
|
||||
try BoardWriter.updateIndex(inItemFolder: folder, kind: kind, operation: .rename(title: nil)) { document in
|
||||
if let title {
|
||||
document.set(FrontmatterKeys.title, to: .string(title))
|
||||
} else {
|
||||
@@ -1988,7 +1990,7 @@ public final class BoardStore {
|
||||
let landed: Void? = try? performWrite { () throws(BoardWriteError) -> Void in
|
||||
// `.rename(title: nil)`: `updateIndex` enriches it off the document it reads, so a
|
||||
// refusal names the board by the title it still has (see `WriteOperation.rename`).
|
||||
try Self.setTitle(newTitle, at: folder)
|
||||
try Self.setTitle(newTitle, at: folder, kind: .board)
|
||||
}
|
||||
guard landed != nil else { return }
|
||||
|
||||
@@ -2001,9 +2003,9 @@ public final class BoardStore {
|
||||
undoExpects: [.present(folder, .title(newTitle))],
|
||||
redoExpects: [.present(folder, .title(priorTitle))]
|
||||
) { _ in
|
||||
try Self.setTitle(priorTitle, at: folder)
|
||||
try Self.setTitle(priorTitle, at: folder, kind: .board)
|
||||
} redo: { _ in
|
||||
try Self.setTitle(newTitle, at: folder)
|
||||
try Self.setTitle(newTitle, at: folder, kind: .board)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2040,19 +2042,21 @@ public final class BoardStore {
|
||||
var priorOrder = lanes[from].order
|
||||
var newOrder: Double?
|
||||
let landed: Void? = try? performWrite { () throws(BoardWriteError) -> Void in
|
||||
var rank = Ranks.insertionRank(amongVisible: remaining.map(\.order), at: target)
|
||||
if rank == nil {
|
||||
// Compact and place again. Unlike the card case the dragged lane *is* among the
|
||||
// renumbered children — it is a real folder on disk — so its fresh rank is dropped
|
||||
// from the ladder before the neighbours are consulted.
|
||||
try BoardWriter.renumberVisibleChildren(of: root)
|
||||
let renumbered = Ranks.renumbered(count: lanes.count)
|
||||
priorOrder = renumbered[from]
|
||||
var compacted = renumbered
|
||||
compacted.remove(at: from)
|
||||
rank = Ranks.insertionRank(amongVisible: compacted, at: target)
|
||||
}
|
||||
guard let rank else { return }
|
||||
// The shared two-step, over the *whole* strip: unlike the card-create case the dragged
|
||||
// lane **is** among the renumbered children — it is a real folder on disk — so the ask
|
||||
// drops its own rung before consulting the neighbours, and a compaction refreshes the
|
||||
// prior rank the inverse has to restore.
|
||||
guard let placed = try HealScheduler.placingRanks(
|
||||
amongVisible: lanes.map(\.order),
|
||||
compacting: root,
|
||||
{ ladder in
|
||||
var compacted = ladder
|
||||
compacted.remove(at: from)
|
||||
return Ranks.insertionRank(amongVisible: compacted, at: target)
|
||||
}
|
||||
) else { return }
|
||||
if placed.renumbered { priorOrder = placed.ladder[from] }
|
||||
let rank = placed.placement
|
||||
newOrder = rank
|
||||
|
||||
_ = try BoardWriter.moveItem(
|
||||
@@ -2119,18 +2123,25 @@ public final class BoardStore {
|
||||
var priorOrders = members.map(\.order)
|
||||
var rewrites: [(folder: URL, order: Double)] = []
|
||||
let landed: Void? = try? performWrite { () throws(BoardWriteError) -> Void in
|
||||
var ranks = Ranks.insertionRanks(amongVisible: remaining.map(\.order), at: target, count: members.count)
|
||||
if ranks == nil {
|
||||
// Compact and place again. The dragged lanes *are* among the renumbered children —
|
||||
// they are real folders on disk — so their fresh rungs are dropped from the ladder
|
||||
// before the neighbours are consulted, exactly as `moveLane` drops its one.
|
||||
try BoardWriter.renumberVisibleChildren(of: root)
|
||||
let renumbered = Array(zip(lanes, Ranks.renumbered(count: lanes.count)))
|
||||
priorOrders = renumbered.filter { ids.contains($0.0.id) }.map(\.1)
|
||||
let compacted = renumbered.filter { !ids.contains($0.0.id) }.map(\.1)
|
||||
ranks = Ranks.insertionRanks(amongVisible: compacted, at: target, count: members.count)
|
||||
// `moveLane`'s two-step, plural: the dragged lanes *are* among the renumbered children,
|
||||
// so the ask drops their rungs before consulting the neighbours, and a compaction
|
||||
// refreshes the prior ranks the inverse restores.
|
||||
guard let placed = try HealScheduler.placingRanks(
|
||||
amongVisible: lanes.map(\.order),
|
||||
compacting: root,
|
||||
{ ladder in
|
||||
let rungs = Array(zip(lanes, ladder))
|
||||
return Ranks.insertionRanks(
|
||||
amongVisible: rungs.filter { !ids.contains($0.0.id) }.map(\.1),
|
||||
at: target,
|
||||
count: members.count
|
||||
)
|
||||
}
|
||||
) else { return }
|
||||
if placed.renumbered {
|
||||
priorOrders = Array(zip(lanes, placed.ladder)).filter { ids.contains($0.0.id) }.map(\.1)
|
||||
}
|
||||
guard let ranks else { return }
|
||||
let ranks = placed.placement
|
||||
|
||||
for (member, rank) in zip(members, ranks) {
|
||||
let folder = root.appendingPathComponent(member.id.rawValue, isDirectory: true)
|
||||
@@ -2291,21 +2302,28 @@ public final class BoardStore {
|
||||
var origins = Dictionary(uniqueKeysWithValues: members.map { ($0.id, (path: $0.path, order: $0.order)) })
|
||||
var arrivals: [(id: ItemID, order: Double)] = []
|
||||
let landed: Void? = try? performWrite { () throws(BoardWriteError) -> Void in
|
||||
var ranks = Ranks.insertionRanks(amongVisible: remaining.map(\.order), at: target, count: members.count)
|
||||
if ranks == nil {
|
||||
// Compact and place again. The renumber assigns in display order over the lane's
|
||||
// cards, so the compacted ladder lines up one-for-one with `rendered`; the members
|
||||
// already in this lane are dropped from it before the neighbours are consulted,
|
||||
// exactly as `moveLane` drops the dragged lane's own rung.
|
||||
try BoardWriter.renumberVisibleChildren(of: laneFolder)
|
||||
let renumbered = Array(zip(rendered, Ranks.renumbered(count: rendered.count)))
|
||||
for (card, rank) in renumbered where ids.contains(card.id) {
|
||||
// The shared two-step. The renumber assigns in display order over the lane's cards, so
|
||||
// the compacted ladder lines up one-for-one with `rendered`; members already in this
|
||||
// lane are dropped from it before the neighbours are consulted, exactly as `moveLane`
|
||||
// drops the dragged lane's own rung — and a compaction refreshes their captured origins.
|
||||
guard let placed = try HealScheduler.placingRanks(
|
||||
amongVisible: rendered.map(\.order),
|
||||
compacting: laneFolder,
|
||||
{ ladder in
|
||||
let rungs = Array(zip(rendered, ladder))
|
||||
return Ranks.insertionRanks(
|
||||
amongVisible: rungs.filter { !ids.contains($0.0.id) }.map(\.1),
|
||||
at: target,
|
||||
count: members.count
|
||||
)
|
||||
}
|
||||
) else { return }
|
||||
if placed.renumbered {
|
||||
for (card, rank) in zip(rendered, placed.ladder) where ids.contains(card.id) {
|
||||
origins[card.id] = (path: .card(lane: laneID, id: card.id), order: rank)
|
||||
}
|
||||
let compacted = renumbered.filter { !ids.contains($0.0.id) }.map(\.1)
|
||||
ranks = Ranks.insertionRanks(amongVisible: compacted, at: target, count: members.count)
|
||||
}
|
||||
guard let ranks else { return }
|
||||
let ranks = placed.placement
|
||||
|
||||
for (member, rank) in zip(members, ranks) {
|
||||
arrivals.append((id: member.id, order: rank))
|
||||
@@ -2412,16 +2430,15 @@ public final class BoardStore {
|
||||
let laneFolder = ItemPath.lane(laneID).folder(under: root)
|
||||
|
||||
try? performWrite { () throws(BoardWriteError) -> Void in
|
||||
var ranks = Ranks.insertionRanks(amongVisible: rendered.map(\.order), at: placement, count: members.count)
|
||||
if ranks == nil {
|
||||
try BoardWriter.renumberVisibleChildren(of: laneFolder)
|
||||
ranks = Ranks.insertionRanks(
|
||||
amongVisible: Ranks.renumbered(count: rendered.count),
|
||||
at: placement,
|
||||
count: members.count
|
||||
)
|
||||
}
|
||||
guard let ranks else { return }
|
||||
// The shared two-step. The copies are not among the renumbered children — they do not
|
||||
// exist yet — so the compacted ladder lines up one-for-one with `rendered` and nothing
|
||||
// captured needs refreshing.
|
||||
guard let placed = try HealScheduler.placingRanks(
|
||||
amongVisible: rendered.map(\.order),
|
||||
compacting: laneFolder,
|
||||
{ Ranks.insertionRanks(amongVisible: $0, at: placement, count: members.count) }
|
||||
) else { return }
|
||||
let ranks = placed.placement
|
||||
|
||||
for (member, rank) in zip(members, ranks) {
|
||||
_ = try BoardWriter.copyItem(
|
||||
@@ -2562,16 +2579,13 @@ public final class BoardStore {
|
||||
let laneFolder = ItemPath.lane(laneID).folder(under: root)
|
||||
|
||||
try? performWrite { () throws(BoardWriteError) -> Void in
|
||||
var ranks = Ranks.insertionRanks(amongVisible: rendered.map(\.order), at: target, count: sources.count)
|
||||
if ranks == nil {
|
||||
try BoardWriter.renumberVisibleChildren(of: laneFolder)
|
||||
ranks = Ranks.insertionRanks(
|
||||
amongVisible: Ranks.renumbered(count: rendered.count),
|
||||
at: target,
|
||||
count: sources.count
|
||||
)
|
||||
}
|
||||
guard let ranks else { return }
|
||||
// The shared two-step; the arrivals are not among the renumbered children.
|
||||
guard let placed = try HealScheduler.placingRanks(
|
||||
amongVisible: rendered.map(\.order),
|
||||
compacting: laneFolder,
|
||||
{ Ranks.insertionRanks(amongVisible: $0, at: target, count: sources.count) }
|
||||
) else { return }
|
||||
let ranks = placed.placement
|
||||
|
||||
for (source, rank) in zip(sources, ranks) {
|
||||
guard let arrived = try Self.materialize(
|
||||
@@ -2676,16 +2690,13 @@ public final class BoardStore {
|
||||
let target = min(max(0, stripIndex), rendered.count)
|
||||
|
||||
try? performWrite { () throws(BoardWriteError) -> Void in
|
||||
var ranks = Ranks.insertionRanks(amongVisible: rendered.map(\.order), at: target, count: sources.count)
|
||||
if ranks == nil {
|
||||
try BoardWriter.renumberVisibleChildren(of: root)
|
||||
ranks = Ranks.insertionRanks(
|
||||
amongVisible: Ranks.renumbered(count: rendered.count),
|
||||
at: target,
|
||||
count: sources.count
|
||||
)
|
||||
}
|
||||
guard let ranks else { return }
|
||||
// The shared two-step; the arriving lanes are not among the renumbered children.
|
||||
guard let placed = try HealScheduler.placingRanks(
|
||||
amongVisible: rendered.map(\.order),
|
||||
compacting: root,
|
||||
{ Ranks.insertionRanks(amongVisible: $0, at: target, count: sources.count) }
|
||||
) else { return }
|
||||
let ranks = placed.placement
|
||||
|
||||
for (source, rank) in zip(sources, ranks) {
|
||||
guard let arrived = try Self.materialize(
|
||||
@@ -2792,7 +2803,12 @@ public final class BoardStore {
|
||||
/// Moves every loose file the last applied snapshot found beside a card's `index.md` into that
|
||||
/// card's `attachments/`, and posts one notice naming what moved — the **act** half of
|
||||
/// 01-storage-format.md's loose-file carve-out (§ Fractal layout ▸ Rules, settled 2026-07-28,
|
||||
/// "Lanework-owns-the-board"; the loader's `looseCardFiles` is the notice half).
|
||||
/// "Lanework-owns-the-board"; the loader's loose-file defect is the notice half).
|
||||
///
|
||||
/// **Scheduling is the engine's** (`HealScheduler`): the resting-clear, the lock-and-writability
|
||||
/// gate, the signature compare, the armed-before-attempt memo, the one bracket, the banner
|
||||
/// posture and the clear-on-success are all its six steps, and this method is now only what is
|
||||
/// genuinely this heal's — which Writer call the defect maps to, and what the notice names.
|
||||
///
|
||||
/// **It registers no undo step**, and unlike its neighbours that is not a deferral: nobody asked
|
||||
/// for it. The relocation is the app tidying its own house on a reload, not a gesture — there is
|
||||
@@ -2800,58 +2816,23 @@ public final class BoardStore {
|
||||
/// the user never did. (It is `renumberVisibleChildren`'s posture: bookkeeping composes no event,
|
||||
/// 06-history-undo.md ▸ Commit messages.)
|
||||
///
|
||||
/// **It is an ordinary app write and nothing more.** One `performWrite` bracket over the whole
|
||||
/// board's worth of relocation, so the churn rounds back as a single app-mediated reload and (on
|
||||
/// git boards) a single commit — the style batch's rule, applied to a batch the app started
|
||||
/// itself. The snapshot is not touched here any more than it is anywhere else: the files move,
|
||||
/// the watcher notices, the reload lands.
|
||||
/// **One bracket over the whole board's worth of relocation**, so the churn rounds back as a
|
||||
/// single app-mediated reload and (on git boards) a single commit — the style batch's rule,
|
||||
/// applied to a batch the app started itself. The snapshot is not touched here any more than it
|
||||
/// is anywhere else: the files move, the watcher notices, the reload lands.
|
||||
///
|
||||
/// ### The read-only lock defers it, it does not cancel it
|
||||
///
|
||||
/// "The relocation … waits out any read-only lock — strays stay tolerated until it clears." A
|
||||
/// locked board returns here having written nothing **and having remembered nothing**, so the
|
||||
/// next attempt is a fresh one. The arming seam is `land(_:generation:origin:)`: every lock
|
||||
/// clears on a successful reload and nowhere else, and this runs at the end of every successful
|
||||
/// reload, after `reconcileLock(after:)` — so the reload that lifts the lock is the reload that
|
||||
/// performs the relocation, with no timer, no queue, and no second state to keep in step. The
|
||||
/// same ordering covers the other direction, now that the probe is symmetric: a reconciling
|
||||
/// reload that *raises* the unwritable-location lock raises it before this runs.
|
||||
///
|
||||
/// ### It cannot hot-loop
|
||||
///
|
||||
/// The relocation's own reload re-walks the tree, which is the loop the guard exists for. After
|
||||
/// a success the walk finds nothing loose, `looseCardFiles` empties, and the memo below is
|
||||
/// cleared — the ordinary resting state. After a *failure* the walk finds the same files again,
|
||||
/// and an unguarded call would fail again, forever, at the speed of a directory walk. So an
|
||||
/// attempt is made only when the loose-file set **differs from the last one attempted**: one
|
||||
/// failure, one banner row, then silence until the picture on disk actually changes (a file
|
||||
/// added, removed, or partially moved by the failed attempt itself — each of which is a
|
||||
/// different set and so a fresh attempt).
|
||||
///
|
||||
/// The failure is the banner's already: `performWrite` posts every `BoardWriteError` before it
|
||||
/// rethrows, and the rethrow is swallowed here like every other gesture with nothing else to do
|
||||
/// about it. Files moved before the failure stay moved, and the notice names exactly those.
|
||||
/// **The write half re-verifies against disk**: `BoardWriter.relocateLooseFiles` re-reads each
|
||||
/// name at write time (`isRelocatable`) and skips what has gone, so a card whose loose files
|
||||
/// vanished under the write contributes no line to the notice.
|
||||
public func relocateLooseCardFiles() {
|
||||
let work = looseCardFiles
|
||||
guard !work.isEmpty else {
|
||||
// The resting state, and the memo's reset: a board with nothing loose has nothing to
|
||||
// remember having tried.
|
||||
attemptedRelocation = []
|
||||
return
|
||||
}
|
||||
// Deferred, not abandoned — and deliberately *before* the memo is written, so the attempt
|
||||
// this lock refused is not the attempt the guard below remembers.
|
||||
guard readOnlyLock == nil else {
|
||||
Self.logger.debug("loose-file relocation deferred — the board is read-only")
|
||||
return
|
||||
}
|
||||
let signature = Self.relocationSignature(of: work)
|
||||
guard signature != attemptedRelocation else { return }
|
||||
attemptedRelocation = signature
|
||||
|
||||
let root = rootURL
|
||||
var relocated: [BannerCenter.Relocation] = []
|
||||
try? performWrite { () throws(BoardWriteError) -> Void in
|
||||
heals.run(
|
||||
.looseCardFiles,
|
||||
signature: Self.signature(of: work.map(IntegrityRules.Defect.looseCardFiles)),
|
||||
on: self
|
||||
) { () throws(BoardWriteError) -> Void in
|
||||
for card in work {
|
||||
let folder = root
|
||||
.appendingPathComponent(card.laneID.rawValue, isDirectory: true)
|
||||
@@ -2865,23 +2846,18 @@ public final class BoardStore {
|
||||
fileNames: moved.map { $0.sourceURL.lastPathComponent }
|
||||
))
|
||||
}
|
||||
} posting: {
|
||||
.relocatedLooseFiles(relocated)
|
||||
}
|
||||
banners.postRelocatedLooseFiles(relocated)
|
||||
}
|
||||
|
||||
/// The loose-file picture as a comparable value: one entry per file, keyed by where it sits.
|
||||
/// A defect list as the engine's comparable picture — every defect's own signature, flattened.
|
||||
///
|
||||
/// A `Set` rather than the array itself because the *identity* of the work is what matters, not
|
||||
/// the order the walk happened to meet it in — and because two loads of an unchanged tree must
|
||||
/// compare equal even if a lane's folder-name ordering shifted underneath them.
|
||||
nonisolated static func relocationSignature(of work: [LooseCardFiles]) -> Set<String> {
|
||||
var signature: Set<String> = []
|
||||
for card in work {
|
||||
for name in card.fileNames {
|
||||
signature.insert("\(card.laneID.rawValue)/\(card.cardID.rawValue)/\(name)")
|
||||
}
|
||||
}
|
||||
return signature
|
||||
nonisolated static func signature(of defects: [IntegrityRules.Defect]) -> Set<String> {
|
||||
Set(defects.flatMap(\.signatures))
|
||||
}
|
||||
|
||||
/// Creates one card per file at `index` in `laneID`, each titled with its filename minus the
|
||||
@@ -2933,14 +2909,13 @@ public final class BoardStore {
|
||||
|
||||
var created: [(folder: URL, source: URL)] = []
|
||||
try? performWrite { () throws(BoardWriteError) -> Void in
|
||||
var ranks = Ranks.insertionRanks(
|
||||
amongVisible: rendered.map(\.order), at: target, count: urls.count)
|
||||
if ranks == nil {
|
||||
try BoardWriter.renumberVisibleChildren(of: laneFolder)
|
||||
ranks = Ranks.insertionRanks(
|
||||
amongVisible: Ranks.renumbered(count: rendered.count), at: target, count: urls.count)
|
||||
}
|
||||
guard let ranks else { return }
|
||||
// The shared two-step; the created cards are not among the renumbered children.
|
||||
guard let placed = try HealScheduler.placingRanks(
|
||||
amongVisible: rendered.map(\.order),
|
||||
compacting: laneFolder,
|
||||
{ Ranks.insertionRanks(amongVisible: $0, at: target, count: urls.count) }
|
||||
) else { return }
|
||||
let ranks = placed.placement
|
||||
|
||||
for (url, rank) in zip(urls, ranks) {
|
||||
// Create then place, `commitPlaceholder`'s pair: the Writer's create appends after the
|
||||
@@ -3050,13 +3025,17 @@ public final class BoardStore {
|
||||
// what its own rewrite overwrites and therefore what an undo has to put back.
|
||||
var rewrites: [(folder: URL, from: Double, to: Double)] = []
|
||||
let landed: Void? = try? performWrite { () throws(BoardWriteError) -> Void in
|
||||
var ladder = orders
|
||||
if !Self.isStrictlyAscending(orders) {
|
||||
try BoardWriter.renumberVisibleChildren(of: laneFolder)
|
||||
// The renumber assigns in display order, so the compacted ladder lines up one-for-one
|
||||
// with `rendered` — the same alignment `commitPlaceholder` relies on.
|
||||
ladder = Ranks.renumbered(count: rendered.count)
|
||||
}
|
||||
// The shared two-step, with the *ask* being "are these ranks usable at all?": a
|
||||
// permutation can only rewrite ranks that already separate the cards, so a ladder with
|
||||
// ties is exhausted in exactly the sense the helper means, and the compacted one — which
|
||||
// is strictly ascending by construction — always answers. It lines up one-for-one with
|
||||
// `rendered`, the same alignment `commitPlaceholder` relies on.
|
||||
guard let placed = try HealScheduler.placingRanks(
|
||||
amongVisible: orders,
|
||||
compacting: laneFolder,
|
||||
{ Self.isStrictlyAscending($0) ? $0 : nil }
|
||||
) else { return }
|
||||
let ladder = placed.placement
|
||||
for (destination, id) in plan.ordering.enumerated() {
|
||||
guard let origin = positions[id], origin != destination else { continue }
|
||||
let rank = ladder[destination]
|
||||
@@ -3500,12 +3479,13 @@ public final class BoardStore {
|
||||
|
||||
/// Migrates every legacy `deleted:` key the last applied snapshot found, and posts one notice —
|
||||
/// the **act** half of 01-storage-format.md § Deletion's migration rule ("Legacy `deleted:` keys
|
||||
/// migrate on load-and-write, never destroy"; the loader's `legacyTombstones` is the notice half).
|
||||
/// migrate on load-and-write, never destroy"; the loader's legacy-tombstone defect is the notice
|
||||
/// half).
|
||||
///
|
||||
/// **`relocateLooseCardFiles()`'s twin in every mechanical respect**, deliberately: same tail hook
|
||||
/// on a successful reload, same lock deferral, same attempted-set loop guard, same one bracket
|
||||
/// for the whole board, same warning-tone loss row. Two migrations arriving in one release with
|
||||
/// two different schedulings would be two things to keep honest.
|
||||
/// **`relocateLooseCardFiles()`'s twin in every mechanical respect**, and since 2026-07-29 that
|
||||
/// is true by construction rather than by two methods agreeing: both run on `HealScheduler`, so
|
||||
/// the tail hook, the lock deferral, the writability gate, the memo and the clear-on-success are
|
||||
/// one implementation. What is this heal's own is below.
|
||||
///
|
||||
/// ### The two acts
|
||||
///
|
||||
@@ -3524,36 +3504,17 @@ public final class BoardStore {
|
||||
/// the work. A card whose stamp is missing or unparseable sorts as **oldest** (the retired sort's
|
||||
/// own rule: "a corrupt stamp must not outrank fresh deletions"), and ties fall to the loader's
|
||||
/// walk order — lane `order`, then card `order` — which is the deterministic tie-break the whole
|
||||
/// corpus already uses. `legacyTombstones` carries no timestamp of its own, so the stamps are
|
||||
/// read out of the snapshot the same walk produced.
|
||||
/// corpus already uses.
|
||||
///
|
||||
/// ### The read-only lock defers it, it does not cancel it
|
||||
/// ### The write half re-verifies against disk
|
||||
///
|
||||
/// Exactly the relocation's posture, and stated there: a locked board returns having written
|
||||
/// nothing **and having remembered nothing**, so the reload that lifts the lock is the reload
|
||||
/// that performs the migration.
|
||||
///
|
||||
/// ### It cannot hot-loop
|
||||
///
|
||||
/// The migration's own write triggers a reload, which re-walks the tree — the loop the guard
|
||||
/// exists for. After a success the walk finds no keys, `legacyTombstones` empties, and the memo
|
||||
/// clears. After a *failure* it finds the same keys again, and an unguarded call would fail
|
||||
/// forever at the speed of a directory walk; so an attempt is made only when the tombstone set
|
||||
/// **differs from the last one attempted**.
|
||||
/// Each item's `deleted:` key is re-read at write time (`stillTombstoned(at:)`) and a key that
|
||||
/// has gone — an agent removed it, another window migrated first — skips silently: "losing the
|
||||
/// race to a foreign fix is success, never an error" (§ Validation and healing, generalized from
|
||||
/// the Repair-races-a-vanished-duplicate precedent). Without it a lane whose key vanished under
|
||||
/// the write would be rewritten for nothing, stamping `modified` on a file with no defect left.
|
||||
public func migrateLegacyTombstones() {
|
||||
let work = legacyTombstones
|
||||
guard !work.isEmpty else {
|
||||
attemptedTombstoneMigration = []
|
||||
return
|
||||
}
|
||||
guard readOnlyLock == nil else {
|
||||
Self.logger.debug("legacy tombstone migration deferred — the board is read-only")
|
||||
return
|
||||
}
|
||||
let signature = Self.migrationSignature(of: work)
|
||||
guard signature != attemptedTombstoneMigration else { return }
|
||||
attemptedTombstoneMigration = signature
|
||||
|
||||
let root = rootURL
|
||||
let cards = Self.migrationOrder(of: work, in: snapshot)
|
||||
let lanes = work.filter { $0.kind == .lane }
|
||||
@@ -3563,30 +3524,45 @@ public final class BoardStore {
|
||||
// The ranks are minted exactly as a delete's are — head of the trash, threaded forward — so a
|
||||
// migrated card is indistinguishable on disk from one the user deletes today.
|
||||
var ladder = snapshot.trash.map(\.order)
|
||||
try? performWrite { () throws(BoardWriteError) -> Void in
|
||||
heals.run(
|
||||
.legacyTombstone,
|
||||
signature: Self.signature(of: work.map(IntegrityRules.Defect.legacyTombstone)),
|
||||
on: self
|
||||
) { () throws(BoardWriteError) -> Void in
|
||||
for card in cards {
|
||||
guard let cardID = card.cardID else { continue }
|
||||
let folder = ItemPath.card(lane: card.laneID, id: cardID).folder(under: root)
|
||||
guard Self.stillTombstoned(at: folder) else { continue }
|
||||
let rank = Ranks.insertAtHead(ofVisible: ladder)
|
||||
try BoardWriter.migrateTombstonedCard(
|
||||
at: ItemPath.card(lane: card.laneID, id: cardID).folder(under: root),
|
||||
inBoard: root,
|
||||
order: rank
|
||||
)
|
||||
try BoardWriter.migrateTombstonedCard(at: folder, inBoard: root, order: rank)
|
||||
ladder.insert(rank, at: 0)
|
||||
movedCards.append(card.title)
|
||||
}
|
||||
for lane in lanes {
|
||||
try BoardWriter.migrateTombstonedLane(at: ItemPath.lane(lane.laneID).folder(under: root))
|
||||
let folder = ItemPath.lane(lane.laneID).folder(under: root)
|
||||
guard Self.stillTombstoned(at: folder) else { continue }
|
||||
try BoardWriter.migrateTombstonedLane(at: folder)
|
||||
returnedLanes.append(lane.title)
|
||||
}
|
||||
} posting: {
|
||||
.migratedTombstones(cards: movedCards, lanes: returnedLanes)
|
||||
}
|
||||
banners.postMigratedTombstones(cards: movedCards, lanes: returnedLanes)
|
||||
}
|
||||
|
||||
/// The tombstone picture as a comparable value — `relocationSignature`'s shape, for its reason:
|
||||
/// the *identity* of the work is what matters, not the order the walk happened to meet it in.
|
||||
nonisolated static func migrationSignature(of work: [LegacyTombstone]) -> Set<String> {
|
||||
Set(work.map { "\($0.laneID.rawValue)/\($0.cardID?.rawValue ?? "")" })
|
||||
/// Whether the item at `folder` still carries a `deleted:` key — the migration's disk re-verify.
|
||||
///
|
||||
/// **Presence, not validity**, exactly as `Lane`/`Card.isDeleted` reads it: a malformed timestamp
|
||||
/// still tombstones, and an explicit `deleted: null` is absence to both. A file that cannot be
|
||||
/// read or parsed answers `false` — the conservative direction, since a migration is a *move* and
|
||||
/// the one thing it must never do is move something on a guess.
|
||||
nonisolated static func stillTombstoned(at folder: URL) -> Bool {
|
||||
guard let data = try? Data(contentsOf: folder.appendingPathComponent(BoardLoader.indexFileName)),
|
||||
let text = String(validating: data, as: UTF8.self),
|
||||
let document = try? FrontmatterDocument.parse(text)
|
||||
else {
|
||||
return false
|
||||
}
|
||||
return !document.deleted.isMissing
|
||||
}
|
||||
|
||||
/// The `.card` tombstones in the order they should be filed into the trash — oldest `deleted:`
|
||||
@@ -3616,17 +3592,52 @@ public final class BoardStore {
|
||||
.map(\.element)
|
||||
}
|
||||
|
||||
// MARK: - The claimed-name displacement
|
||||
|
||||
/// Moves a squatter off a board-root name the app claims, and posts the notice naming old and
|
||||
/// new — the **act** half of the claimed-names ruling (01-storage-format.md § Fractal layout ▸
|
||||
/// Rules, ruled 2026-07-29: "Lanework owns the board, so an invalid artifact on a claimed name is
|
||||
/// a defect, not a resident").
|
||||
///
|
||||
/// Today that is `.trash` and only `.trash`: a file or symlink sitting on the name the trash
|
||||
/// needs, which breaks deletion for as long as it stands — which is exactly why the timing is
|
||||
/// *scheduled* rather than on-touch (§ Validation and healing: "proactive when the defect is
|
||||
/// load-bearing now"). `CLAUDE.md`'s squatter is displaced by the guide's own heal, which owns
|
||||
/// that file end to end.
|
||||
///
|
||||
/// **Displacement, never destruction**, and never a mint: the freed name is left empty and the
|
||||
/// *next delete* creates the real `.trash/`, exactly as it does on a board that never had one.
|
||||
public func displaceClaimedNames() {
|
||||
let work = claimedNameSquatters
|
||||
let root = rootURL
|
||||
var displaced: [BannerCenter.Displacement] = []
|
||||
heals.run(
|
||||
.claimedNameSquatted,
|
||||
signature: Self.signature(of: work.map(IntegrityRules.Defect.claimedNameSquatted)),
|
||||
on: self
|
||||
) { () throws(BoardWriteError) -> Void in
|
||||
for squatter in work {
|
||||
// The Writer re-verifies and answers `nil` when the name freed itself under us.
|
||||
guard let freed = try BoardWriter.displaceClaimedName(squatter, atBoardRoot: root) else {
|
||||
continue
|
||||
}
|
||||
displaced.append(BannerCenter.Displacement(name: squatter.name, movedTo: freed))
|
||||
}
|
||||
} posting: {
|
||||
.displacedClaimedNames(displaced)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - The agent guide
|
||||
|
||||
/// Brings the board root's `CLAUDE.md` up to the current guide version, or leaves it exactly as
|
||||
/// it is — the whole of 08-agent-integration.md ▸ The agent guide's scheduling. The rule itself
|
||||
/// is `AgentGuide.decide(_:)`, a pure function; this method is the I/O and the policy around it.
|
||||
///
|
||||
/// **Run on every successful reload**, beside the relocation and the tombstone migration, and
|
||||
/// once more at open (`BoardStoreRegistry.acquire`, which fires it after the watcher and the
|
||||
/// brackets exist, for the reason stated there). That makes the guide *self-healing* rather than
|
||||
/// merely written-once: a foreign deletion, a downgrade to an older guide, a board restored from
|
||||
/// a template carrying a stale one — each heals on the next reload, without a single new signal.
|
||||
/// **Run on every successful reload**, beside the other scheduled heals, and once more at open
|
||||
/// (`BoardStoreRegistry.acquire`). That makes the guide *self-healing* rather than merely
|
||||
/// written-once: a foreign deletion, a downgrade to an older guide, a board restored from a
|
||||
/// template carrying a stale one — each heals on the next reload, without a single new signal.
|
||||
/// It also pre-wires the Pro-era bounce 06-history-undo.md acknowledges by name, where undoing
|
||||
/// an "Update agent guide (vN)" commit restores an older guide that the app immediately
|
||||
/// re-upgrades.
|
||||
@@ -3635,85 +3646,77 @@ public final class BoardStore {
|
||||
/// first-line parse — and no write at all. Nothing here touches the snapshot: the bytes land, the
|
||||
/// watcher notices, the reload applies, exactly like every other app write.
|
||||
///
|
||||
/// ### The read-only board is skipped, never banner-ed
|
||||
/// ### The two skips, and the one displacement
|
||||
///
|
||||
/// Two gates, because two different things can be true. `performWrite` would refuse under the
|
||||
/// read-only lock on its own, but that refusal is a thrown error and this is not a gesture — so
|
||||
/// the lock is checked first, the relocation's own deferral idiom. That gate is now the one that
|
||||
/// actually fires on an unwritable board: `BoardStoreRegistry.acquire` probes writability
|
||||
/// *before* it calls this method, so 02-architecture.md's "the open-time agent-guide write is
|
||||
/// skipped-with-log, the `CLAUDE.user.md`-taken precedent" is honored by the lock being up
|
||||
/// rather than by this method noticing on its own.
|
||||
/// `.skipUserFilenameTaken` is the standing exception (a rescue destination is not itself freed
|
||||
/// by a second displacement); `.displaceSquatterThenWrite` is the 2026-07-29 upgrade of the old
|
||||
/// untouchable-skip, and it *does* post — a node of the user's moved aside owes the same
|
||||
/// warning-tone notice `.trash`'s displacement does. Both decisions are re-made inside the write
|
||||
/// half, which is this heal's disk re-verify.
|
||||
///
|
||||
/// The `access(2)` check below stays anyway, as the second line of defense: it is the only gate
|
||||
/// covering the window *between* probes — a volume remounted read-only mid-session raises the
|
||||
/// lock at the next reconciling reload, and a reload landing in that window would otherwise
|
||||
/// reach the Writer, fail, and post a banner about a file the user never asked for. Both skips
|
||||
/// are logged and neither is ever surfaced.
|
||||
/// ### The signature is the board root's picture
|
||||
///
|
||||
/// ### It cannot hot-loop
|
||||
///
|
||||
/// `performWrite`'s bracket schedules a reload whether or not the write succeeded, and this runs
|
||||
/// on every reload — so a *failing* guide write would retry forever at the speed of the debounce,
|
||||
/// posting a banner row each time. The guard is the relocation's exactly: an attempt is made only
|
||||
/// when the board root's picture **differs from the one last acted on**. One failure, one row,
|
||||
/// then silence until something on disk actually changes. The same memo is what keeps the two
|
||||
/// skip cases from repeating their log line on every reload of an unchanged board.
|
||||
/// One failure, one row, then silence until something on disk actually changes — and the memo
|
||||
/// clears on success, which is what lets a foreign deletion of the guide be healed again
|
||||
/// immediately (the picture "missing" is restored, and a standing memo would make that deletion
|
||||
/// the one thing this could not heal).
|
||||
public func refreshAgentGuide() {
|
||||
let state = AgentGuide.inspect(atBoardRoot: rootURL)
|
||||
let root = rootURL
|
||||
let state = AgentGuide.inspect(atBoardRoot: root)
|
||||
let decision = AgentGuide.decide(state)
|
||||
guard decision != .leaveAlone else {
|
||||
// The resting state, and the memo's reset: a board whose guide is current has nothing to
|
||||
// remember having tried.
|
||||
attemptedGuideRefresh = nil
|
||||
return
|
||||
}
|
||||
// Deferred, not abandoned — and deliberately *before* the memo is written, so the refresh a
|
||||
// lock refused is not the one the guard below remembers.
|
||||
guard readOnlyLock == nil else {
|
||||
Self.logger.debug("agent-guide refresh deferred — the board is read-only")
|
||||
return
|
||||
}
|
||||
guard FileManager.default.isWritableFile(atPath: rootURL.path) else {
|
||||
Self.logger.debug("agent-guide refresh skipped — the board's location is not writable")
|
||||
return
|
||||
}
|
||||
guard state != attemptedGuideRefresh else { return }
|
||||
// Armed before anything is attempted, so a write that throws leaves it set — the memo's
|
||||
// whole job is to remember pictures this store has already failed or refused to act on.
|
||||
attemptedGuideRefresh = state
|
||||
|
||||
// **A decision that writes nothing is no work at all**, and says so with an empty signature:
|
||||
// the engine's resting-clear then costs no bracket, which matters because a bracket schedules
|
||||
// a reload whether or not anything was written — a skip that opened one would tick forever.
|
||||
let signature: Set<String>
|
||||
switch decision {
|
||||
case .leaveAlone:
|
||||
break // Ruled out above; the switch stays exhaustive so a new decision is a compile error.
|
||||
case .skipUntouchable:
|
||||
Self.logger.debug("agent-guide refresh skipped — CLAUDE.md is not an ordinary file")
|
||||
signature = []
|
||||
case .skipUserFilenameTaken:
|
||||
// The ruling's own outcome (08 ▸ Ownership): a user-authored CLAUDE.md that cannot be
|
||||
// rescued keeps its name, and the guide simply does not exist on this board.
|
||||
Self.logger.debug("agent-guide refresh skipped — CLAUDE.md is not the app's and CLAUDE.user.md is taken")
|
||||
case .write, .displaceThenWrite:
|
||||
let root = rootURL
|
||||
let displace = decision == .displaceThenWrite
|
||||
do {
|
||||
// One bracket over the rescue move *and* the write: two files change, one
|
||||
// app-mediated reload lands, and (in the Pro edition) one honestly-attributed commit
|
||||
// records it.
|
||||
try performWrite { () throws(BoardWriteError) -> Void in
|
||||
try AgentGuide.install(atBoardRoot: root, displacingUserContent: displace)
|
||||
}
|
||||
// **Cleared on success, and this is load-bearing rather than tidy**: the memo keys on
|
||||
// the picture that provoked the write, and a foreign deletion restores that exact
|
||||
// picture ("missing"). A memo left standing would make the deletion the one thing the
|
||||
// self-heal could not heal.
|
||||
attemptedGuideRefresh = nil
|
||||
} catch {
|
||||
// Already the banner's — `performWrite` posts every `BoardWriteError` before it
|
||||
// rethrows — and there is nothing else a courtesy write can do about a failure. The
|
||||
// memo, left armed above, is what keeps it from being posted again every reload.
|
||||
Self.logger.error("agent-guide write failed: \(error.localizedDescription, privacy: .public)")
|
||||
}
|
||||
signature = []
|
||||
case .write, .displaceThenWrite, .displaceSquatterThenWrite:
|
||||
signature = [state.signature]
|
||||
}
|
||||
|
||||
var displaced: [BannerCenter.Displacement] = []
|
||||
heals.run(
|
||||
.staleAgentGuide,
|
||||
signature: signature,
|
||||
on: self
|
||||
) { () throws(BoardWriteError) -> Void in
|
||||
// One bracket over the displacement *and* the write: two files change, one app-mediated
|
||||
// reload lands, and (in the Pro edition) one honestly-attributed commit records it.
|
||||
guard let moved = try AgentGuide.install(atBoardRoot: root) else { return }
|
||||
// The `CLAUDE.user.md` rescue is the settled, silent ownership rule; a squatter's
|
||||
// displacement is announced.
|
||||
guard !moved.wasUserContent else { return }
|
||||
displaced.append(BannerCenter.Displacement(name: moved.name, movedTo: moved.movedTo))
|
||||
} posting: {
|
||||
.displacedClaimedNames(displaced)
|
||||
}
|
||||
}
|
||||
|
||||
/// **Every scheduled heal, in order** — the engine's two seams call exactly this
|
||||
/// (02-architecture.md ▸ Components ▸ HealScheduler: "fires uniformly at the reload tail and at
|
||||
/// registry acquire, closing today's asymmetry where tombstone migration never fires at open").
|
||||
///
|
||||
/// **The claimed-name displacement goes first, and that ordering is load-bearing**: a card's
|
||||
/// migration mints `<root>/.trash/`, which cannot be created while a file or symlink holds that
|
||||
/// name — so a migration attempted ahead of the displacement fails *and arms its memo against an
|
||||
/// unchanged tombstone picture*, which would leave the board unmigrated until something else on
|
||||
/// disk changed. Running the displacement first closes the ruling's one-reload window inside a
|
||||
/// single pass, because a heal's write lands synchronously even though its reload does not.
|
||||
///
|
||||
/// The rest of the order is immaterial: they touch disjoint files (a card's loose files, a
|
||||
/// `deleted:` key inside an `index.md`, `CLAUDE.md`), each opens its own bracket, and each is
|
||||
/// re-armed by the reload the others' writes produce, so none can see another's work half-done.
|
||||
public func runScheduledHeals() {
|
||||
displaceClaimedNames()
|
||||
relocateLooseCardFiles()
|
||||
migrateLegacyTombstones()
|
||||
refreshAgentGuide()
|
||||
}
|
||||
|
||||
// MARK: - Selection (delegated)
|
||||
|
||||
@@ -221,24 +221,25 @@ public final class BoardStoreRegistry {
|
||||
lastKnownRoot: rootURL
|
||||
)
|
||||
|
||||
// The loose-file carve-out's first firing (01-storage-format.md § Fractal layout ▸ Rules,
|
||||
// settled 2026-07-28): files an agent or a hand-editor left beside a card's `index.md`
|
||||
// while this board was closed are relocated into `attachments/` now, with the notice.
|
||||
// **The heal engine's open seam** (02-architecture.md ▸ Components ▸ HealScheduler: "fires
|
||||
// uniformly at the reload tail and at registry acquire"). Everything an agent or a
|
||||
// hand-editor left in this board while it was closed is healed now: loose files relocated
|
||||
// into `attachments/`, legacy `deleted:` keys migrated, a squatter moved off a claimed name,
|
||||
// the agent guide brought up to version — each with its own notice where it has one.
|
||||
//
|
||||
// **Here rather than in `BoardStore.init`**, and last rather than first: the store's own
|
||||
// init is one tree walk and no writes, and a relocation written before the brackets and the
|
||||
// init is one tree walk and no writes, and a heal written before the brackets and the
|
||||
// watcher exist would be a write nothing is watching — landing on disk with the snapshot
|
||||
// above it left one reload stale. By this line the pair is wired, so it is an ordinary
|
||||
// bracketed app write whose echo reload refreshes the board like any other. Every reload
|
||||
// thereafter re-fires it from `BoardStore.land`; this call is only the one the opening walk
|
||||
// would otherwise have no reload behind.
|
||||
store.relocateLooseCardFiles()
|
||||
// The agent guide's first firing (08-agent-integration.md ▸ The agent guide), here for the
|
||||
// same reason and with the same timing as the relocation above: a board opened with no
|
||||
// `CLAUDE.md`, or with one an older version wrote, gets the current guide now — as a
|
||||
// bracketed app write with a reload behind it, never as a write into a board nothing is
|
||||
// watching. Every reload thereafter re-checks it from `BoardStore.land`.
|
||||
store.refreshAgentGuide()
|
||||
// above it left one reload stale. By this line the pair is wired, so each heal is an
|
||||
// ordinary bracketed app write whose echo reload refreshes the board like any other. Every
|
||||
// reload thereafter re-fires them from `BoardStore.land`; this call is only the one the
|
||||
// opening walk would otherwise have no reload behind.
|
||||
//
|
||||
// **One call, all four** — before 2026-07-29 this seam named two of the three healers by
|
||||
// hand, which is how the legacy-tombstone migration came to be the one heal that never fired
|
||||
// at open. A board opened, migrated nothing, and waited for an unrelated filesystem event to
|
||||
// do what opening should have done.
|
||||
store.runScheduledHeals()
|
||||
return store
|
||||
}
|
||||
|
||||
|
||||
@@ -104,7 +104,23 @@ public final class EchoLedger: Sendable {
|
||||
/// is what those paths want: there is no session whose echoes they are.
|
||||
@TaskLocal public static var current: EchoLedger?
|
||||
|
||||
private let receipts = Mutex<[String: Receipt]>([:])
|
||||
/// One receipt plus its attributes. Split from `Receipt` so the public vocabulary stays the
|
||||
/// three outcomes it always was and an attribute can be added without every reader learning a
|
||||
/// new shape.
|
||||
private struct Entry: Sendable, Equatable {
|
||||
var receipt: Receipt
|
||||
/// **Whether the write that dropped this receipt was a heal** (06-history-undo.md ▸ Commit
|
||||
/// messages, ruled 2026-07-29: "the Writer's heal operations drop heal-marked receipts in
|
||||
/// the EchoLedger — attribution machinery like the author split, never message tagging").
|
||||
///
|
||||
/// **Inert in base beyond the ledger itself.** Nothing here reads it and nothing renders it;
|
||||
/// it is the flag pro-m1's committer reads to split a heal's paths into their own commit,
|
||||
/// and it is stored rather than derived because by commit time the only thing that still
|
||||
/// knows a path was healed is the receipt.
|
||||
var isHeal: Bool = false
|
||||
}
|
||||
|
||||
private let receipts = Mutex<[String: Entry]>([:])
|
||||
|
||||
public init() {}
|
||||
|
||||
@@ -145,7 +161,10 @@ public final class EchoLedger: Sendable {
|
||||
/// The path-and-hash form — **supersession** lives here, as a plain overwrite: "a newer app
|
||||
/// write to the same path supersedes the receipt", because only the final content decides.
|
||||
public func recordWrite(atPath path: String, hash: String) {
|
||||
receipts.withLock { $0[path] = .content(hash: hash) }
|
||||
// The heal mark is **not** carried over: supersession replaces the whole receipt, and a
|
||||
// later ordinary write to the same path is exactly the case where the path stops being the
|
||||
// heal's alone. `markHeal(at:)` is called after the write it describes, never before.
|
||||
receipts.withLock { $0[path] = Entry(receipt: .content(hash: hash)) }
|
||||
}
|
||||
|
||||
/// An attachment import — "attachment imports hash during the copy (the bytes stream through
|
||||
@@ -174,7 +193,7 @@ public final class EchoLedger: Sendable {
|
||||
public func recordDeletion(atPath path: String) {
|
||||
receipts.withLock { store in
|
||||
Self.forget(&store, under: path)
|
||||
store[path] = .absence
|
||||
store[path] = Entry(receipt: .absence)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -192,13 +211,42 @@ public final class EchoLedger: Sendable {
|
||||
for path in Array(store.keys) where path.hasPrefix(source + "/") {
|
||||
store[destination + path.dropFirst(source.count)] = store.removeValue(forKey: path)
|
||||
}
|
||||
let pair = Receipt.move(from: source, to: destination)
|
||||
let pair = Entry(receipt: .move(from: source, to: destination))
|
||||
store[source] = pair
|
||||
store[destination] = pair
|
||||
}
|
||||
}
|
||||
|
||||
private static func forget(_ store: inout [String: Receipt], under path: String) {
|
||||
/// **Marks the receipt at `path` as a heal** — the Writer's heal operations call this on the
|
||||
/// paths they touched, after the bytes land (06-history-undo.md ▸ Commit messages, ruled
|
||||
/// 2026-07-29).
|
||||
///
|
||||
/// A no-op where there is no receipt (a Writer call outside any bracket, a test): a mark with no
|
||||
/// receipt to attach to would describe nothing. It never *creates* a receipt for the same reason
|
||||
/// — the receipt is the record of a completed write, and this only ever adds an attribute to
|
||||
/// one that already exists.
|
||||
///
|
||||
/// Both ends of a move pair are one fact filed under two keys, so marking either marks both.
|
||||
public func markHeal(at url: URL) {
|
||||
markHeal(atPath: Self.key(url))
|
||||
}
|
||||
|
||||
public func markHeal(atPath path: String) {
|
||||
receipts.withLock { store in
|
||||
guard var entry = store[path] else { return }
|
||||
entry.isHeal = true
|
||||
store[path] = entry
|
||||
if case let .move(from, to) = entry.receipt {
|
||||
let other = path == from ? to : from
|
||||
if var twin = store[other] {
|
||||
twin.isHeal = true
|
||||
store[other] = twin
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static func forget(_ store: inout [String: Entry], under path: String) {
|
||||
let prefix = path + "/"
|
||||
for key in Array(store.keys) where key.hasPrefix(prefix) {
|
||||
store.removeValue(forKey: key)
|
||||
@@ -217,7 +265,17 @@ public final class EchoLedger: Sendable {
|
||||
}
|
||||
|
||||
public func receipt(atPath path: String) -> Receipt? {
|
||||
receipts.withLock { $0[path] }
|
||||
receipts.withLock { $0[path]?.receipt }
|
||||
}
|
||||
|
||||
/// Whether the receipt at this path is heal-marked — `false` for a path with no receipt at all,
|
||||
/// which is the same shrug every other read here gives an unknown path.
|
||||
public func isHeal(at url: URL) -> Bool {
|
||||
isHeal(atPath: Self.key(url))
|
||||
}
|
||||
|
||||
public func isHeal(atPath path: String) -> Bool {
|
||||
receipts.withLock { $0[path]?.isHeal ?? false }
|
||||
}
|
||||
|
||||
/// Every path the ledger holds a receipt for strictly *below* `folder`.
|
||||
@@ -247,7 +305,7 @@ public final class EchoLedger: Sendable {
|
||||
/// byte under an item would make every copy foreign.
|
||||
public func classify(_ observations: [String: Observation]) -> Provenance {
|
||||
let held: [(path: String, receipt: Receipt)] = receipts.withLock { store in
|
||||
observations.keys.compactMap { path in store[path].map { (path, $0) } }
|
||||
observations.keys.compactMap { path in store[path].map { (path, $0.receipt) } }
|
||||
}
|
||||
guard !held.isEmpty else { return .foreign }
|
||||
for entry in held {
|
||||
|
||||
@@ -0,0 +1,221 @@
|
||||
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])
|
||||
}
|
||||
|
||||
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<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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user