fix: enforce recovery retention and fail-closed restore semantics
- prune expired recovery items on load/save and reject expired restores at worker boundary - add restoreExpired and restoreConflict protocol/application error mapping - disable expired restore actions in History and reload persisted state after restore failures - add recovery expiry/conflict coverage plus sync protocol, architecture, state-machine, and recovery contract docs - wire AtlasAppTests into the shared Xcode scheme and add app-layer regression coverage for expired restore reload behavior Refs: ATL-221 ATL-222 ATL-223 ATL-224 ATL-225, vibe-kanban SID-9
This commit is contained in:
@@ -37,6 +37,7 @@ final class AtlasAppModel: ObservableObject {
|
||||
@Published private(set) var updateCheckNotice: String?
|
||||
@Published private(set) var updateCheckError: String?
|
||||
|
||||
private let repository: AtlasWorkspaceRepository
|
||||
private let workspaceController: AtlasWorkspaceController
|
||||
private let updateChecker = AtlasUpdateChecker()
|
||||
private let notificationPermissionRequester: @Sendable () async -> Bool
|
||||
@@ -53,6 +54,7 @@ final class AtlasAppModel: ObservableObject {
|
||||
notificationPermissionRequester: (@Sendable () async -> Bool)? = nil
|
||||
) {
|
||||
let state = repository.loadState()
|
||||
self.repository = repository
|
||||
self.snapshot = state.snapshot
|
||||
self.currentPlan = state.currentPlan
|
||||
self.settings = state.settings
|
||||
@@ -482,6 +484,12 @@ final class AtlasAppModel: ObservableObject {
|
||||
}
|
||||
await refreshPlanPreview()
|
||||
} catch {
|
||||
let persistedState = repository.loadState()
|
||||
withAnimation(.snappy(duration: 0.24)) {
|
||||
snapshot = persistedState.snapshot
|
||||
currentPlan = persistedState.currentPlan
|
||||
settings = persistedState.settings
|
||||
}
|
||||
latestScanSummary = error.localizedDescription
|
||||
}
|
||||
|
||||
|
||||
@@ -189,6 +189,62 @@ final class AtlasAppModelTests: XCTestCase {
|
||||
XCTAssertNil(model.smartCleanExecutionIssue)
|
||||
}
|
||||
|
||||
func testRestoreExpiredRecoveryItemReloadsPersistedState() async throws {
|
||||
let baseDate = Date(timeIntervalSince1970: 1_710_000_000)
|
||||
let clock = TestClock(now: baseDate)
|
||||
let repository = makeRepository(nowProvider: { clock.now })
|
||||
let finding = Finding(
|
||||
id: UUID(),
|
||||
title: "Expiring fixture",
|
||||
detail: "Expires soon",
|
||||
bytes: 5,
|
||||
risk: .safe,
|
||||
category: "Developer tools"
|
||||
)
|
||||
let recoveryItem = RecoveryItem(
|
||||
id: UUID(),
|
||||
title: finding.title,
|
||||
detail: finding.detail,
|
||||
originalPath: "~/Library/Caches/AtlasOnly",
|
||||
bytes: 5,
|
||||
deletedAt: baseDate,
|
||||
expiresAt: baseDate.addingTimeInterval(10),
|
||||
payload: .finding(finding),
|
||||
restoreMappings: nil
|
||||
)
|
||||
let state = AtlasWorkspaceState(
|
||||
snapshot: AtlasWorkspaceSnapshot(
|
||||
reclaimableSpaceBytes: 0,
|
||||
findings: [],
|
||||
apps: [],
|
||||
taskRuns: [],
|
||||
recoveryItems: [recoveryItem],
|
||||
permissions: [],
|
||||
healthSnapshot: nil
|
||||
),
|
||||
currentPlan: ActionPlan(title: "Review 0 selected findings", items: [], estimatedBytes: 0),
|
||||
settings: AtlasScaffoldWorkspace.state().settings
|
||||
)
|
||||
_ = try repository.saveState(state)
|
||||
|
||||
let worker = AtlasScaffoldWorkerService(
|
||||
repository: repository,
|
||||
nowProvider: { clock.now },
|
||||
allowStateOnlyCleanExecution: true
|
||||
)
|
||||
let model = AtlasAppModel(repository: repository, workerService: worker)
|
||||
XCTAssertTrue(model.snapshot.recoveryItems.contains(where: { $0.id == recoveryItem.id }))
|
||||
|
||||
clock.now = baseDate.addingTimeInterval(60)
|
||||
await model.restoreRecoveryItem(recoveryItem.id)
|
||||
|
||||
XCTAssertFalse(model.snapshot.recoveryItems.contains(where: { $0.id == recoveryItem.id }))
|
||||
XCTAssertEqual(
|
||||
model.latestScanSummary,
|
||||
AtlasL10n.string("application.error.restoreExpired", "One or more selected recovery items have expired and can no longer be restored.")
|
||||
)
|
||||
}
|
||||
|
||||
func testSettingsUpdatePersistsThroughWorker() async throws {
|
||||
let repository = makeRepository()
|
||||
let permissionInspector = AtlasPermissionInspector(
|
||||
@@ -309,11 +365,12 @@ final class AtlasAppModelTests: XCTestCase {
|
||||
XCTAssertEqual(AtlasRoute.overview.title, "Overview")
|
||||
}
|
||||
|
||||
private func makeRepository() -> AtlasWorkspaceRepository {
|
||||
private func makeRepository(nowProvider: @escaping @Sendable () -> Date = { Date() }) -> AtlasWorkspaceRepository {
|
||||
AtlasWorkspaceRepository(
|
||||
stateFileURL: FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent(UUID().uuidString, isDirectory: true)
|
||||
.appendingPathComponent("workspace-state.json")
|
||||
.appendingPathComponent("workspace-state.json"),
|
||||
nowProvider: nowProvider
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -415,3 +472,11 @@ private actor ExecuteRejectingRestoreDelegatingWorker: AtlasWorkerServing {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private final class TestClock: @unchecked Sendable {
|
||||
var now: Date
|
||||
|
||||
init(now: Date) {
|
||||
self.now = now
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user