From 1d4dbeb370d275086bd148db5d16f2f3c8cec5f2 Mon Sep 17 00:00:00 2001 From: zhukang <274546966@qq.com> Date: Fri, 13 Mar 2026 00:49:32 +0800 Subject: [PATCH] feat(smart-clean): add structured targetPaths to ActionItem for execution ActionItem now carries optional targetPaths so plan.execute can use plan-carried targets instead of reconstructing execution intent from findings. This improves execution reliability and enables proper restore mappings for recovery items. - Add targetPaths field to ActionItem domain model - Update plan execution to prefer plan-carried targets with finding fallback - Expand safe cache path fragments for direct-trash execution - Add gate review documentation for ATL-211/212/215 - Bump protocol version to 0.3.0 --- .../Sources/AtlasApp/AtlasAppModel.swift | 21 +++-- .../AtlasApp/ReadmeAssetExporter.swift | 6 +- ...tion-Credibility-Gate-Review-2026-03-12.md | 78 ++++++++++++++++++ ...art-Clean-Execution-Coverage-2026-03-09.md | 17 ++++ Docs/Protocol.md | 11 +++ Docs/README.md | 1 + Docs/TaskStateMachine.md | 2 +- .../AtlasApplication/AtlasApplication.swift | 19 ++++- .../AtlasApplicationTests.swift | 1 + .../Sources/AtlasDomain/AtlasDomain.swift | 14 +++- .../SmartCleanFeatureView.swift | 3 + .../AtlasInfrastructure.swift | 82 ++++++++++++++----- .../AtlasInfrastructureTests.swift | 58 +++++++++++++ .../Sources/AtlasProtocol/AtlasProtocol.swift | 2 +- .../AtlasProtocolTests.swift | 25 ++++++ 15 files changed, 303 insertions(+), 37 deletions(-) create mode 100644 Docs/Execution/Execution-Credibility-Gate-Review-2026-03-12.md diff --git a/Apps/AtlasApp/Sources/AtlasApp/AtlasAppModel.swift b/Apps/AtlasApp/Sources/AtlasApp/AtlasAppModel.swift index 816b552..b588f48 100644 --- a/Apps/AtlasApp/Sources/AtlasApp/AtlasAppModel.swift +++ b/Apps/AtlasApp/Sources/AtlasApp/AtlasAppModel.swift @@ -234,12 +234,11 @@ final class AtlasAppModel: ObservableObject { } var currentSmartCleanPlanHasExecutableTargets: Bool { - let selectedIDs = Set(currentPlan.items.map(\.id)) - let executableFindings = snapshot.findings.filter { selectedIDs.contains($0.id) && !$0.targetPathsDescriptionIsInspectionOnly } - guard !executableFindings.isEmpty else { + let executableItems = currentPlan.items.filter { $0.kind != .inspectPermission } + guard !executableItems.isEmpty else { return false } - return executableFindings.allSatisfy { !($0.targetPaths ?? []).isEmpty } + return executableItems.allSatisfy { !resolvedTargetPaths(for: $0).isEmpty } } func refreshHealthSnapshotIfNeeded() async { @@ -620,9 +619,17 @@ final class AtlasAppModel: ObservableObject { } } -private extension Finding { - var targetPathsDescriptionIsInspectionOnly: Bool { - risk == .advanced || !AtlasSmartCleanExecutionSupport.isFindingExecutionSupported(self) +private extension AtlasAppModel { + func resolvedTargetPaths(for item: ActionItem) -> [String] { + if let targetPaths = item.targetPaths, !targetPaths.isEmpty { + return targetPaths + } + + guard let finding = snapshot.findings.first(where: { $0.id == item.id }) else { + return [] + } + + return finding.targetPaths ?? [] } } diff --git a/Apps/AtlasApp/Sources/AtlasApp/ReadmeAssetExporter.swift b/Apps/AtlasApp/Sources/AtlasApp/ReadmeAssetExporter.swift index 30e638b..ccff0f9 100644 --- a/Apps/AtlasApp/Sources/AtlasApp/ReadmeAssetExporter.swift +++ b/Apps/AtlasApp/Sources/AtlasApp/ReadmeAssetExporter.swift @@ -63,6 +63,10 @@ private struct AtlasReadmeAssetExporter { AtlasL10n.setCurrentLanguage(screenshotLanguage) let state = AtlasScaffoldWorkspace.state(language: screenshotLanguage) + let canExecuteSmartCleanPlan = state.currentPlan.items.contains(where: { $0.kind != .inspectPermission }) + && state.currentPlan.items + .filter { $0.kind != .inspectPermission } + .allSatisfy { !($0.targetPaths ?? []).isEmpty } try exportAppIcon() try renderView( @@ -78,7 +82,7 @@ private struct AtlasReadmeAssetExporter { isScanning: false, isExecutingPlan: false, isCurrentPlanFresh: true, - canExecutePlan: true, + canExecutePlan: canExecuteSmartCleanPlan, planIssue: nil ), fileName: "atlas-smart-clean.png" diff --git a/Docs/Execution/Execution-Credibility-Gate-Review-2026-03-12.md b/Docs/Execution/Execution-Credibility-Gate-Review-2026-03-12.md new file mode 100644 index 0000000..9494873 --- /dev/null +++ b/Docs/Execution/Execution-Credibility-Gate-Review-2026-03-12.md @@ -0,0 +1,78 @@ +# Execution Credibility Gate Review + +## Gate + +- `Smart Clean Execution Credibility` + +## Review Date + +- `2026-03-12` + +## Scope Reviewed + +- `ATL-211` additional safe-target execution coverage +- `ATL-212` structured executable targets through the worker path +- `ATL-215` execution credibility gate review + +## Readiness Checklist + +- [x] Required P0 tasks complete +- [x] Docs updated +- [x] Risks reviewed +- [x] Open questions below threshold +- [x] Next-stage inputs available + +## Evidence Reviewed + +- `Docs/Execution/Execution-Chain-Audit-2026-03-09.md` +- `Docs/Execution/Smart-Clean-Execution-Coverage-2026-03-09.md` +- `Packages/AtlasInfrastructure/Tests/AtlasInfrastructureTests/AtlasInfrastructureTests.swift` +- `Packages/AtlasProtocol/Tests/AtlasProtocolTests/AtlasProtocolTests.swift` +- `Packages/AtlasApplication/Tests/AtlasApplicationTests/AtlasApplicationTests.swift` + +## Automated Validation Summary + +- `swift test --package-path Packages` — pass +- `swift test --package-path Apps` — pass + +## Gate Assessment + +### ATL-212 Structured Target Contract + +- Smart Clean `ActionItem` payloads now carry structured `targetPaths`. +- `plan.execute` now prefers plan-carried targets instead of reconstructing destructive intent from the snapshot alone. +- Recovery items can now use restore mappings to preserve the real original path even when execution was driven by plan-carried targets. + +### ATL-211 Coverage Increment + +- This slice keeps the previously shipped safe direct-trash subset and does not expand execution into `Library/Containers`. +- File-backed contract tests prove `scan -> execute -> rescan` improvement for: + - `~/Library/Caches/*` + - `~/Library/pnpm/store/*` + +### Truthfulness Check + +- History summaries still derive from the actual worker task result. +- Smart Clean only records recovery entries when a real Trash move happened or a restore mapping exists. +- Unsupported or review-only targets remain fail-closed or skipped as review-only instead of being claimed as cleaned. + +## Remaining Limits + +- `Library/Containers` cleanup is still unsupported in the direct-trash path because the worker does not yet mirror the upstream protected-container filters. +- `Group Containers` cleanup is still unsupported in the direct-trash path. +- Broader `System` cleanup and aggregated dry-run-only findings still fail closed unless they resolve to the supported structured targets. +- This change did not add a new packaged-app manual verification pass; the evidence here is test-backed. + +## Decision + +- `Pass with Conditions` + +## Conditions + +- Release-facing copy must continue to distinguish supported vs unsupported Smart Clean paths. +- External release validation should still rerun manual packaged `scan -> execute -> rescan` flows for the supported target classes. + +## Follow-up Actions + +- Only add container cleanup support after the worker can enforce the same protected-container rules as the upstream cleanup runtime. +- Evaluate the next safe Smart Clean increment only if it preserves explicit restore semantics and fail-closed behavior. diff --git a/Docs/Execution/Smart-Clean-Execution-Coverage-2026-03-09.md b/Docs/Execution/Smart-Clean-Execution-Coverage-2026-03-09.md index 8997106..a04328f 100644 --- a/Docs/Execution/Smart-Clean-Execution-Coverage-2026-03-09.md +++ b/Docs/Execution/Smart-Clean-Execution-Coverage-2026-03-09.md @@ -13,6 +13,7 @@ This document is intentionally simpler than `Docs/Execution/Execution-Chain-Audi The current behavior is now: - real scan when the upstream clean workflow succeeds +- current-session preview plans carry structured `targetPaths` for executable items - real execution for a safe structured subset of targets - physical restoration for executed items when recovery mappings are present - explicit failure for unsupported or unstructured targets @@ -35,11 +36,19 @@ These user-owned targets can be moved to Trash directly by the worker when they - `~/.npm/*` - `~/.npm_cache/*` - `~/.oh-my-zsh/cache/*` +- selected developer cache roots under the current user home, including `~/.yarn/cache/*`, `~/.bun/install/cache/*`, `~/.cargo/registry/cache/*`, `~/.cargo/git/*`, `~/.docker/buildx/cache/*`, `~/.turbo/cache/*`, `~/.vite/cache/*`, `~/.parcel-cache/*`, and `~/.node-gyp/*` - paths containing: - `__pycache__` - `.next/cache` + - `Application Cache` + - `GPUCache` + - `cache2` - `component_crx_cache` + - `extensions_crx_cache` - `GoogleUpdater` + - `GraphiteDawnCache` + - `GrShaderCache` + - `ShaderCache` - `CoreSimulator.log` - `.pyc` files under the current user home directory @@ -58,6 +67,8 @@ Targets under these allowlisted roots can run through the helper boundary: The following categories remain incomplete unless they resolve to the supported structured targets above: - broader `System` cleanup paths +- `Library/Containers` cleanup paths +- `Group Containers` cleanup paths - partially aggregated dry-run results that do not yet carry executable sub-paths - categories that only expose a summary concept rather than concrete target paths - any Smart Clean item that requires a more privileged or more specific restore model than the current Trash-backed flow supports @@ -76,6 +87,12 @@ The UI now makes this explicit by: - disabling `Run Plan` until the plan is revalidated - showing which plan steps can run directly and which remain review-only +The worker contract now also makes this explicit: + +- `plan.preview` carries structured `targetPaths` on executable plan items +- `plan.execute` prefers those plan-carried targets instead of reconstructing execution intent from transient UI state +- older cached plans can still fall back to finding-carried targets, but fresh release-facing execution should rely on the current plan + ### When execution succeeds diff --git a/Docs/Protocol.md b/Docs/Protocol.md index 094f2de..c88d67c 100644 --- a/Docs/Protocol.md +++ b/Docs/Protocol.md @@ -83,6 +83,15 @@ - `items` - `estimatedBytes` +### ActionItem + +- `id` +- `title` +- `detail` +- `kind` +- `recoverable` +- `targetPaths` (optional structured execution targets carried by the current plan) + ### TaskRun - `id` @@ -129,6 +138,7 @@ - Destructive flows must end in a history record. - Recoverable flows must produce structured recovery items. - Helper actions must remain allowlisted structured actions, never arbitrary command strings. +- Fresh Smart Clean preview plans should carry `ActionItem.targetPaths` for executable items so execution does not have to reconstruct destructive intent from UI state. ## Current Implementation Note @@ -139,6 +149,7 @@ - Atlas localizes user-facing shell copy through a package-scoped resource bundle and uses the persisted language to keep summaries and settings text aligned. - App uninstall can invoke the packaged or development helper executable through structured JSON actions. - Structured Smart Clean findings can now carry executable target paths, and a safe subset of those targets can be moved to Trash and physically restored later. +- Structured Smart Clean action items now also carry `targetPaths`, and `plan.execute` prefers those plan-carried targets. Older cached plans can still fall back to finding-carried targets for backward compatibility. - The app shell communicates with the worker over structured XPC `Data` payloads that encode Atlas request and result envelopes. - `executePlan` is fail-closed for unsupported targets, but now supports a real Trash-based execution path for a safe structured subset of Smart Clean items. diff --git a/Docs/README.md b/Docs/README.md index 536925b..73fd2bf 100644 --- a/Docs/README.md +++ b/Docs/README.md @@ -29,6 +29,7 @@ This directory contains the working product, design, engineering, and compliance - `Execution/UI-Copy-Walkthrough-2026-03-09.md` — page-by-page UI copy glossary, consistency checklist, and acceptance guide - `Execution/Execution-Chain-Audit-2026-03-09.md` — end-to-end review of real vs scaffold execution paths and release-facing trust gaps - `Execution/Implementation-Plan-ATL-201-202-205-2026-03-12.md` — implementation plan for internal-beta hardening tasks ATL-201, ATL-202, and ATL-205 +- `Execution/Execution-Credibility-Gate-Review-2026-03-12.md` — gate review for ATL-211, ATL-212, and ATL-215 Smart Clean execution credibility work - `Execution/Smart-Clean-Execution-Coverage-2026-03-09.md` — user-facing summary of what Smart Clean can execute for real today - `Execution/Smart-Clean-QA-Checklist-2026-03-09.md` — QA checklist for scan, execute, rescan, and physical restore validation - `Execution/Smart-Clean-Manual-Verification-2026-03-09.md` — local-machine fixture workflow for validating real Smart Clean execution and restore diff --git a/Docs/TaskStateMachine.md b/Docs/TaskStateMachine.md index 91827b4..c3026e9 100644 --- a/Docs/TaskStateMachine.md +++ b/Docs/TaskStateMachine.md @@ -62,7 +62,7 @@ ## Current MVP Notes - `scan` emits monotonic progress and finishes with a preview-ready plan when the upstream scan adapter succeeds; otherwise the request should fail rather than silently fabricate findings. -- `execute_clean` must not report completion in release-facing flows unless real cleanup side effects have been applied. Unsupported or unstructured targets should fail closed. +- `execute_clean` must not report completion in release-facing flows unless real cleanup side effects have been applied. Fresh preview plans now carry structured execution targets, and unsupported or unstructured targets should fail closed. - `execute_uninstall` removes an app from the current workspace view and creates a recovery entry. - `restore` can physically restore items when structured recovery mappings are present, and can still rehydrate a `Finding` or an `AppFootprint` into Atlas state from the recovery payload. - User-visible task summaries and settings-driven text should reflect the persisted app-language preference when generated. diff --git a/Packages/AtlasApplication/Sources/AtlasApplication/AtlasApplication.swift b/Packages/AtlasApplication/Sources/AtlasApplication/AtlasApplication.swift index eb97551..3faee3f 100644 --- a/Packages/AtlasApplication/Sources/AtlasApplication/AtlasApplication.swift +++ b/Packages/AtlasApplication/Sources/AtlasApplication/AtlasApplication.swift @@ -67,14 +67,27 @@ public enum AtlasScaffoldWorkspace { private static func makeInitialPlan(from findings: [Finding]) -> ActionPlan { let items = findings.map { finding in - ActionItem( + let hasExecutableTargets = !((finding.targetPaths ?? []).isEmpty) + let kind: ActionItem.Kind + if !hasExecutableTargets || finding.risk == .advanced { + kind = .inspectPermission + } else if finding.category == "Apps" { + kind = .removeApp + } else if finding.risk == .review { + kind = .archiveFile + } else { + kind = .removeCache + } + + return ActionItem( id: finding.id, title: finding.risk == .advanced ? AtlasL10n.string("application.plan.inspectPrivileged", finding.title) : AtlasL10n.string("application.plan.reviewFinding", finding.title), detail: finding.detail, - kind: finding.category == "Apps" ? .removeApp : (finding.risk == .advanced ? .inspectPermission : .removeCache), - recoverable: finding.risk != .advanced + kind: kind, + recoverable: finding.risk != .advanced, + targetPaths: finding.targetPaths ) } diff --git a/Packages/AtlasApplication/Tests/AtlasApplicationTests/AtlasApplicationTests.swift b/Packages/AtlasApplication/Tests/AtlasApplicationTests/AtlasApplicationTests.swift index 4668a70..d261d85 100644 --- a/Packages/AtlasApplication/Tests/AtlasApplicationTests/AtlasApplicationTests.swift +++ b/Packages/AtlasApplication/Tests/AtlasApplicationTests/AtlasApplicationTests.swift @@ -55,6 +55,7 @@ final class AtlasApplicationTests: XCTestCase { XCTAssertEqual(output.actionPlan.title, plan.title) XCTAssertEqual(output.actionPlan.estimatedBytes, plan.estimatedBytes) + XCTAssertEqual(output.actionPlan.items.first?.targetPaths, plan.items.first?.targetPaths) } func testExecutePlanUsesWorkerEventsToBuildSummary() async throws { diff --git a/Packages/AtlasDomain/Sources/AtlasDomain/AtlasDomain.swift b/Packages/AtlasDomain/Sources/AtlasDomain/AtlasDomain.swift index bd03f89..2957ba7 100644 --- a/Packages/AtlasDomain/Sources/AtlasDomain/AtlasDomain.swift +++ b/Packages/AtlasDomain/Sources/AtlasDomain/AtlasDomain.swift @@ -178,19 +178,22 @@ public struct ActionItem: Identifiable, Codable, Hashable, Sendable { public var detail: String public var kind: Kind public var recoverable: Bool + public var targetPaths: [String]? public init( id: UUID = UUID(), title: String, detail: String, kind: Kind, - recoverable: Bool + recoverable: Bool, + targetPaths: [String]? = nil ) { self.id = id self.title = title self.detail = detail self.kind = kind self.recoverable = recoverable + self.targetPaths = targetPaths } } @@ -597,21 +600,24 @@ public enum AtlasScaffoldFixtures { title: AtlasL10n.string("fixture.plan.item.moveDerivedData.title", language: language), detail: AtlasL10n.string("fixture.plan.item.moveDerivedData.detail", language: language), kind: .removeCache, - recoverable: true + recoverable: true, + targetPaths: ["~/Library/Developer/Xcode/DerivedData/AtlasFixture"] ), ActionItem( id: uuid("00000000-0000-0000-0000-000000000012"), title: AtlasL10n.string("fixture.plan.item.reviewRuntimes.title", language: language), detail: AtlasL10n.string("fixture.plan.item.reviewRuntimes.detail", language: language), kind: .archiveFile, - recoverable: true + recoverable: true, + targetPaths: ["~/Library/Developer/Xcode/iOS DeviceSupport/AtlasFixtureRuntime"] ), ActionItem( id: uuid("00000000-0000-0000-0000-000000000013"), title: AtlasL10n.string("fixture.plan.item.inspectAgents.title", language: language), detail: AtlasL10n.string("fixture.plan.item.inspectAgents.detail", language: language), kind: .inspectPermission, - recoverable: false + recoverable: false, + targetPaths: ["~/Library/LaunchAgents/com.example.atlas-fixture.plist"] ), ], estimatedBytes: 23_200_000_000 diff --git a/Packages/AtlasFeaturesSmartClean/Sources/AtlasFeaturesSmartClean/SmartCleanFeatureView.swift b/Packages/AtlasFeaturesSmartClean/Sources/AtlasFeaturesSmartClean/SmartCleanFeatureView.swift index f7564f0..75b9cdf 100644 --- a/Packages/AtlasFeaturesSmartClean/Sources/AtlasFeaturesSmartClean/SmartCleanFeatureView.swift +++ b/Packages/AtlasFeaturesSmartClean/Sources/AtlasFeaturesSmartClean/SmartCleanFeatureView.swift @@ -306,6 +306,9 @@ public struct SmartCleanFeatureView: View { guard item.kind != .inspectPermission else { return false } + if let targetPaths = item.targetPaths, !targetPaths.isEmpty { + return true + } guard let finding = findings.first(where: { $0.id == item.id }) else { return false } diff --git a/Packages/AtlasInfrastructure/Sources/AtlasInfrastructure/AtlasInfrastructure.swift b/Packages/AtlasInfrastructure/Sources/AtlasInfrastructure/AtlasInfrastructure.swift index f891aa3..dae523f 100644 --- a/Packages/AtlasInfrastructure/Sources/AtlasInfrastructure/AtlasInfrastructure.swift +++ b/Packages/AtlasInfrastructure/Sources/AtlasInfrastructure/AtlasInfrastructure.swift @@ -255,6 +255,13 @@ public enum AtlasSmartCleanExecutionSupport { "/component_crx_cache", "/GoogleUpdater", "/CoreSimulator.log", + "/Application Cache", + "/GPUCache", + "/cache2", + "/extensions_crx_cache", + "/GraphiteDawnCache", + "/GrShaderCache", + "/ShaderCache", ] if safeFragments.contains(where: { path.contains($0) }) { return true @@ -570,8 +577,23 @@ public actor AtlasScaffoldWorkerService: AtlasWorkerServing { ) } - let selectedIDs = Set(state.currentPlan.items.map(\.id)) - let selectedFindings = state.snapshot.findings.filter { selectedIDs.contains($0.id) } + let selectedItems = state.currentPlan.items + let findingsByID = Dictionary(uniqueKeysWithValues: state.snapshot.findings.map { ($0.id, $0) }) + let missingFindingIDs = selectedItems.compactMap { item in + findingsByID[item.id] == nil ? item.id : nil + } + + if !missingFindingIDs.isEmpty { + return rejectedResult( + for: request, + code: .invalidSelection, + reason: "The requested Smart Clean items are no longer available. Refresh the preview and try again." + ) + } + + let selectedFindings = selectedItems.compactMap { item in + findingsByID[item.id] + } guard !selectedFindings.isEmpty else { return rejectedResult( @@ -581,8 +603,16 @@ public actor AtlasScaffoldWorkerService: AtlasWorkerServing { ) } - let executableFindings = selectedFindings.filter { actionKind(for: $0) != .inspectPermission } - let missingExecutableTargets = executableFindings.filter { ($0.targetPaths ?? []).isEmpty } + let executableSelections = selectedItems.compactMap { item -> SmartCleanExecutableSelection? in + guard item.kind != .inspectPermission, let finding = findingsByID[item.id] else { + return nil + } + return SmartCleanExecutableSelection( + finding: finding, + targetPaths: resolvedTargetPaths(for: item, finding: finding) + ) + } + let missingExecutableTargets = executableSelections.filter { $0.targetPaths.isEmpty } if !missingExecutableTargets.isEmpty && !allowStateOnlyCleanExecution { return rejectedResult( @@ -591,7 +621,7 @@ public actor AtlasScaffoldWorkerService: AtlasWorkerServing { reason: "Smart Clean execution is unavailable because one or more plan items do not include executable cleanup targets in this build." ) } - let skippedCount = selectedFindings.count - executableFindings.count + let skippedCount = selectedItems.count - executableSelections.count let taskID = UUID() let response = AtlasResponseEnvelope( @@ -600,9 +630,9 @@ public actor AtlasScaffoldWorkerService: AtlasWorkerServing { ) var executionResult = SmartCleanExecutionResult() - if !executableFindings.isEmpty { + if !executableSelections.isEmpty { do { - executionResult = try await executeSmartCleanFindings(executableFindings) + executionResult = try await executeSmartCleanSelections(executableSelections) } catch let failure as SmartCleanExecutionFailure { executionResult = failure.result if !allowStateOnlyCleanExecution && !executionResult.hasRecordedOutcome { @@ -623,11 +653,12 @@ public actor AtlasScaffoldWorkerService: AtlasWorkerServing { } } - let physicallyExecutedFindings = executableFindings.filter { + let executedFindings = executableSelections.map(\.finding) + let physicallyExecutedFindings = executedFindings.filter { !(executionResult.restoreMappingsByFindingID[$0.id] ?? []).isEmpty } - let staleFindings = executableFindings.filter { executionResult.staleFindingIDs.contains($0.id) } - let failedFindings = executableFindings.filter { executionResult.failedFindingIDs.contains($0.id) } + let staleFindings = executedFindings.filter { executionResult.staleFindingIDs.contains($0.id) } + let failedFindings = executedFindings.filter { executionResult.failedFindingIDs.contains($0.id) } let recoveryItems = physicallyExecutedFindings.map { makeRecoveryItem(for: $0, deletedAt: Date(), restoreMappings: executionResult.restoreMappingsByFindingID[$0.id]) } @@ -876,12 +907,12 @@ public actor AtlasScaffoldWorkerService: AtlasWorkerServing { } } - private func executeSmartCleanFindings(_ findings: [Finding]) async throws -> SmartCleanExecutionResult { + private func executeSmartCleanSelections(_ selections: [SmartCleanExecutableSelection]) async throws -> SmartCleanExecutionResult { var result = SmartCleanExecutionResult() - for finding in findings { - let targetPaths = Array(Set(finding.targetPaths ?? [])).sorted() + for selection in selections { + let targetPaths = Array(Set(selection.targetPaths)).sorted() guard !targetPaths.isEmpty else { - result.failedFindingIDs.insert(finding.id) + result.failedFindingIDs.insert(selection.finding.id) result.failureReason = result.failureReason ?? "Smart Clean finding is missing executable targets." throw SmartCleanExecutionFailure(result: result) } @@ -894,12 +925,12 @@ public actor AtlasScaffoldWorkerService: AtlasWorkerServing { } } if mappings.isEmpty { - result.staleFindingIDs.insert(finding.id) + result.staleFindingIDs.insert(selection.finding.id) } else { - result.restoreMappingsByFindingID[finding.id] = mappings + result.restoreMappingsByFindingID[selection.finding.id] = mappings } } catch { - result.failedFindingIDs.insert(finding.id) + result.failedFindingIDs.insert(selection.finding.id) result.failureReason = result.failureReason ?? error.localizedDescription throw SmartCleanExecutionFailure(result: result) } @@ -907,6 +938,10 @@ public actor AtlasScaffoldWorkerService: AtlasWorkerServing { return result } + private func resolvedTargetPaths(for item: ActionItem, finding: Finding) -> [String] { + Array(Set(item.targetPaths ?? finding.targetPaths ?? [])).sorted() + } + private func prepareSmartCleanTargetPaths(_ targetPaths: [String]) throws -> [String] { var preparedTargetPaths: [String] = [] for targetPath in targetPaths { @@ -1087,7 +1122,7 @@ public actor AtlasScaffoldWorkerService: AtlasWorkerServing { RecoveryItem( title: finding.title, detail: finding.detail, - originalPath: inferredPath(for: finding), + originalPath: restoreMappings?.first?.originalPath ?? inferredPath(for: finding), bytes: finding.bytes, deletedAt: deletedAt, expiresAt: recoveryExpiryDate(from: deletedAt), @@ -1143,7 +1178,8 @@ public actor AtlasScaffoldWorkerService: AtlasWorkerServing { title: actionTitle(for: finding), detail: finding.detail, kind: actionKind(for: finding), - recoverable: finding.risk != .advanced + recoverable: finding.risk != .advanced, + targetPaths: Array(Set(finding.targetPaths ?? [])).sorted() ) } @@ -1165,7 +1201,8 @@ public actor AtlasScaffoldWorkerService: AtlasWorkerServing { title: AtlasL10n.string("infrastructure.plan.uninstall.moveBundle.title", language: state.settings.language, app.name), detail: AtlasL10n.string("infrastructure.plan.uninstall.moveBundle.detail", language: state.settings.language, app.bundlePath), kind: .removeApp, - recoverable: true + recoverable: true, + targetPaths: [app.bundlePath] ), ActionItem( title: AtlasL10n.string(app.leftoverItems == 1 ? "infrastructure.plan.uninstall.archive.one" : "infrastructure.plan.uninstall.archive.other", language: state.settings.language, app.leftoverItems), @@ -1234,6 +1271,11 @@ private struct SmartCleanExecutionResult { } } +private struct SmartCleanExecutableSelection { + let finding: Finding + let targetPaths: [String] +} + private struct SmartCleanExecutionFailure: LocalizedError { let result: SmartCleanExecutionResult diff --git a/Packages/AtlasInfrastructure/Tests/AtlasInfrastructureTests/AtlasInfrastructureTests.swift b/Packages/AtlasInfrastructure/Tests/AtlasInfrastructureTests/AtlasInfrastructureTests.swift index 6b11e2b..747764f 100644 --- a/Packages/AtlasInfrastructure/Tests/AtlasInfrastructureTests/AtlasInfrastructureTests.swift +++ b/Packages/AtlasInfrastructure/Tests/AtlasInfrastructureTests/AtlasInfrastructureTests.swift @@ -434,6 +434,64 @@ final class AtlasInfrastructureTests: XCTestCase { XCTAssertEqual(result.snapshot.findings.count, 0) } + func testExecutePlanUsesStructuredTargetPathsCarriedByCurrentPlan() async throws { + let repository = AtlasWorkspaceRepository(stateFileURL: temporaryStateFileURL()) + let home = FileManager.default.homeDirectoryForCurrentUser + let targetDirectory = home.appendingPathComponent("Library/Caches/AtlasExecutionTests/" + UUID().uuidString, isDirectory: true) + try FileManager.default.createDirectory(at: targetDirectory, withIntermediateDirectories: true) + let targetFile = targetDirectory.appendingPathComponent("plan-target.cache") + try Data("cache".utf8).write(to: targetFile) + + let finding = Finding( + id: UUID(), + title: "Plan-backed cache", + detail: targetFile.path, + bytes: 5, + risk: .safe, + category: "Developer tools", + targetPaths: nil + ) + let state = AtlasWorkspaceState( + snapshot: AtlasWorkspaceSnapshot( + reclaimableSpaceBytes: 5, + findings: [finding], + apps: [], + taskRuns: [], + recoveryItems: [], + permissions: [], + healthSnapshot: nil + ), + currentPlan: ActionPlan( + title: "Review 1 selected finding", + items: [ + ActionItem( + id: finding.id, + title: "Move Plan-backed cache to Trash", + detail: finding.detail, + kind: .removeCache, + recoverable: true, + targetPaths: [targetFile.path] + ) + ], + estimatedBytes: 5 + ), + settings: AtlasScaffoldWorkspace.state().settings + ) + _ = try repository.saveState(state) + + let worker = AtlasScaffoldWorkerService(repository: repository, allowStateOnlyCleanExecution: false) + let result = try await worker.submit(AtlasRequestEnvelope(command: .executePlan(planID: state.currentPlan.id))) + + if case let .accepted(task) = result.response.response { + XCTAssertEqual(task.kind, .executePlan) + } else { + XCTFail("Expected accepted execute-plan response") + } + XCTAssertFalse(FileManager.default.fileExists(atPath: targetFile.path)) + XCTAssertEqual(result.snapshot.findings.count, 0) + XCTAssertEqual(result.snapshot.recoveryItems.first?.originalPath, targetFile.path) + } + func testScanExecuteRescanRemovesExecutedTargetFromRealResults() async throws { let repository = AtlasWorkspaceRepository(stateFileURL: temporaryStateFileURL()) let home = FileManager.default.homeDirectoryForCurrentUser diff --git a/Packages/AtlasProtocol/Sources/AtlasProtocol/AtlasProtocol.swift b/Packages/AtlasProtocol/Sources/AtlasProtocol/AtlasProtocol.swift index 03beeed..b4f1101 100644 --- a/Packages/AtlasProtocol/Sources/AtlasProtocol/AtlasProtocol.swift +++ b/Packages/AtlasProtocol/Sources/AtlasProtocol/AtlasProtocol.swift @@ -2,7 +2,7 @@ import AtlasDomain import Foundation public enum AtlasProtocolVersion { - public static let current = "0.2.0" + public static let current = "0.3.0" } public enum AtlasCommand: Codable, Hashable, Sendable { diff --git a/Packages/AtlasProtocol/Tests/AtlasProtocolTests/AtlasProtocolTests.swift b/Packages/AtlasProtocol/Tests/AtlasProtocolTests/AtlasProtocolTests.swift index 72b158f..9511f0b 100644 --- a/Packages/AtlasProtocol/Tests/AtlasProtocolTests/AtlasProtocolTests.swift +++ b/Packages/AtlasProtocol/Tests/AtlasProtocolTests/AtlasProtocolTests.swift @@ -31,4 +31,29 @@ final class AtlasProtocolTests: XCTestCase { XCTAssertEqual(decoded.response, envelope.response) } + + func testPreviewResponseRoundTripsStructuredPlanTargets() throws { + let plan = ActionPlan( + title: "Review 1 selected finding", + items: [ + ActionItem( + id: UUID(uuidString: "10000000-0000-0000-0000-000000000099") ?? UUID(), + title: "Move container cache to Trash", + detail: "Sandboxed cache path", + kind: .removeCache, + recoverable: true, + targetPaths: ["/Users/test/Library/Containers/com.example.sample/Data/Library/Caches/cache.db"] + ) + ], + estimatedBytes: 1_024 + ) + let envelope = AtlasResponseEnvelope( + requestID: UUID(), + response: .preview(plan) + ) + let data = try JSONEncoder().encode(envelope) + let decoded = try JSONDecoder().decode(AtlasResponseEnvelope.self, from: data) + + XCTAssertEqual(decoded.response, envelope.response) + } }