Keep schedules attached to routines across seed clone-on-edit forks

Editing a starter-seed routine forks it to a fresh ULID, but only workouts
were durably repointed — schedules kept the dead seed id, and after a
relaunch (empty in-memory redirect map) the Today board and edit form saw
the routine as gone.

- cloneSeedOnEdit now repoints schedules too (routineID + routineName
  track the clone; a schedule's name is a live-pointer fallback, unlike a
  workout's frozen run-as name)
- A launch-time repair pass (pure ScheduleRepairPlanner + tests) heals
  already-broken references by re-attaching a dead-pointer schedule to
  the unique live routine matching its remembered name
This commit is contained in:
2026-07-11 13:02:55 -04:00
parent 76150070f8
commit e509d0e7c5
3 changed files with 239 additions and 0 deletions
+73
View File
@@ -386,6 +386,7 @@ final class SyncEngine {
cloneRedirects[doc.id] = clone.id
lastSyncError = nil
await repointWorkouts(from: doc.id, to: clone.id)
await repointSchedules(from: doc.id, to: clone.id, newName: clone.name)
onCacheChanged?()
return clone.id
} catch {
@@ -421,6 +422,34 @@ final class SyncEngine {
do { try context.save() } catch { report("Cache save failed", error) }
}
/// Rewrite `routineID` on every schedule that references `oldID`, so the plan layer
/// (the Today board, edit form, adherence tracks) keeps resolving after a seed fork
/// durably, across relaunches, which the in-memory `cloneRedirects` map can't cover.
/// Unlike a workout's frozen "what it was run as" `routineName`, a schedule's
/// `routineName` is a display *fallback* for a live pointer, so it tracks the clone's
/// current name. Best effort per schedule: a failed rewrite is reported and the
/// redirect map still covers it for this session.
private func repointSchedules(from oldID: String, to newID: String, newName: String) async {
guard let store else { return }
let referencing = (try? context.fetch(
FetchDescriptor<Schedule>(predicate: #Predicate { $0.routineID == oldID })
)) ?? []
guard !referencing.isEmpty else { return }
for schedule in referencing {
var sDoc = ScheduleDocument(from: schedule)
sDoc.routineID = newID
sDoc.routineName = newName
sDoc.updatedAt = Date()
do {
try await store.write(sDoc, to: sDoc.relativePath)
CacheMapper.upsertSchedule(sDoc, relativePath: sDoc.relativePath, into: context)
} catch {
report("Failed to repoint schedule \(sDoc.id) at edited routine", error)
}
}
do { try context.save() } catch { report("Cache save failed", error) }
}
/// Push edited machine settings onto the originating routine's exercise (matched
/// by name logs reference exercises by name only), following the seed
/// clone-on-edit redirect so the live clone is updated. Skips silently when the
@@ -898,6 +927,50 @@ final class SyncEngine {
} else {
await reconcileSeeds()
}
// After the seed set has settled, heal schedules whose routine reference died
// before fork-time repointing existed (cheap, idempotent every launch).
await repairScheduleReferences()
}
/// Repair schedules whose `routineID` matches no live routine, re-attaching each to
/// the unique live routine carrying the schedule's `routineName` (the pure
/// `ScheduleRepairPlanner` decides; ambiguous or unmatched ones are left alone).
/// Batched: one cache save + one change notification when anything was repaired
/// deliberately NOT routed through `save(schedule:)`, which would fire the watch
/// push and reminder resync once per schedule.
private func repairScheduleReferences() async {
guard let store else { return }
let schedules = (try? context.fetch(FetchDescriptor<Schedule>())) ?? []
let routines = (try? context.fetch(FetchDescriptor<Routine>())) ?? []
let repairs = ScheduleRepairPlanner.plans(
schedules: schedules.map {
ScheduleRepairRef(id: $0.id, routineID: $0.routineID, routineName: $0.routineName)
},
liveRoutines: routines.map { RoutineRepairRef(id: $0.id, name: $0.name) }
)
guard !repairs.isEmpty else { return }
var repaired = 0
for repair in repairs {
guard let schedule = CacheMapper.fetchSchedule(id: repair.scheduleID, in: context) else { continue }
var doc = ScheduleDocument(from: schedule)
doc.routineID = repair.newRoutineID
doc.updatedAt = Date()
do {
try await store.write(doc, to: doc.relativePath)
CacheMapper.upsertSchedule(doc, relativePath: doc.relativePath, into: context)
repaired += 1
} catch {
report("Failed to repair schedule \(doc.id) routine reference", error)
}
}
if repaired > 0 {
log.info("schedule repair: re-attached \(repaired) schedule(s) to live routines")
do { try context.save() } catch { report("Cache save failed", error) }
onCacheChanged?()
}
}
/// Bring the on-disk seed set in line with the current bundle on a NON-empty