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:
zhukang
2026-03-13 10:35:15 +08:00
parent 1d4dbeb370
commit 1cb9a42c7b
23 changed files with 1309 additions and 15 deletions

View File

@@ -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
}

View File

@@ -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
}
}