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:
+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)
|
||||
|
||||
Reference in New Issue
Block a user