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
This commit is contained in:
zhukang
2026-03-13 00:49:32 +08:00
parent 11405a4b55
commit 1d4dbeb370
15 changed files with 303 additions and 37 deletions

View File

@@ -234,12 +234,11 @@ final class AtlasAppModel: ObservableObject {
} }
var currentSmartCleanPlanHasExecutableTargets: Bool { var currentSmartCleanPlanHasExecutableTargets: Bool {
let selectedIDs = Set(currentPlan.items.map(\.id)) let executableItems = currentPlan.items.filter { $0.kind != .inspectPermission }
let executableFindings = snapshot.findings.filter { selectedIDs.contains($0.id) && !$0.targetPathsDescriptionIsInspectionOnly } guard !executableItems.isEmpty else {
guard !executableFindings.isEmpty else {
return false return false
} }
return executableFindings.allSatisfy { !($0.targetPaths ?? []).isEmpty } return executableItems.allSatisfy { !resolvedTargetPaths(for: $0).isEmpty }
} }
func refreshHealthSnapshotIfNeeded() async { func refreshHealthSnapshotIfNeeded() async {
@@ -620,9 +619,17 @@ final class AtlasAppModel: ObservableObject {
} }
} }
private extension Finding { private extension AtlasAppModel {
var targetPathsDescriptionIsInspectionOnly: Bool { func resolvedTargetPaths(for item: ActionItem) -> [String] {
risk == .advanced || !AtlasSmartCleanExecutionSupport.isFindingExecutionSupported(self) 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 ?? []
} }
} }

View File

@@ -63,6 +63,10 @@ private struct AtlasReadmeAssetExporter {
AtlasL10n.setCurrentLanguage(screenshotLanguage) AtlasL10n.setCurrentLanguage(screenshotLanguage)
let state = AtlasScaffoldWorkspace.state(language: 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 exportAppIcon()
try renderView( try renderView(
@@ -78,7 +82,7 @@ private struct AtlasReadmeAssetExporter {
isScanning: false, isScanning: false,
isExecutingPlan: false, isExecutingPlan: false,
isCurrentPlanFresh: true, isCurrentPlanFresh: true,
canExecutePlan: true, canExecutePlan: canExecuteSmartCleanPlan,
planIssue: nil planIssue: nil
), ),
fileName: "atlas-smart-clean.png" fileName: "atlas-smart-clean.png"

View File

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

View File

@@ -13,6 +13,7 @@ This document is intentionally simpler than `Docs/Execution/Execution-Chain-Audi
The current behavior is now: The current behavior is now:
- real scan when the upstream clean workflow succeeds - 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 - real execution for a safe structured subset of targets
- physical restoration for executed items when recovery mappings are present - physical restoration for executed items when recovery mappings are present
- explicit failure for unsupported or unstructured targets - 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/*`
- `~/.npm_cache/*` - `~/.npm_cache/*`
- `~/.oh-my-zsh/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: - paths containing:
- `__pycache__` - `__pycache__`
- `.next/cache` - `.next/cache`
- `Application Cache`
- `GPUCache`
- `cache2`
- `component_crx_cache` - `component_crx_cache`
- `extensions_crx_cache`
- `GoogleUpdater` - `GoogleUpdater`
- `GraphiteDawnCache`
- `GrShaderCache`
- `ShaderCache`
- `CoreSimulator.log` - `CoreSimulator.log`
- `.pyc` files under the current user home directory - `.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: The following categories remain incomplete unless they resolve to the supported structured targets above:
- broader `System` cleanup paths - 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 - 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 - 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 - 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 - disabling `Run Plan` until the plan is revalidated
- showing which plan steps can run directly and which remain review-only - 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 ### When execution succeeds

View File

@@ -83,6 +83,15 @@
- `items` - `items`
- `estimatedBytes` - `estimatedBytes`
### ActionItem
- `id`
- `title`
- `detail`
- `kind`
- `recoverable`
- `targetPaths` (optional structured execution targets carried by the current plan)
### TaskRun ### TaskRun
- `id` - `id`
@@ -129,6 +138,7 @@
- Destructive flows must end in a history record. - Destructive flows must end in a history record.
- Recoverable flows must produce structured recovery items. - Recoverable flows must produce structured recovery items.
- Helper actions must remain allowlisted structured actions, never arbitrary command strings. - 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 ## 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. - 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. - 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 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. - 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. - `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.

View File

@@ -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/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/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/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-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-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 - `Execution/Smart-Clean-Manual-Verification-2026-03-09.md` — local-machine fixture workflow for validating real Smart Clean execution and restore

View File

@@ -62,7 +62,7 @@
## Current MVP Notes ## 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. - `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. - `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. - `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. - User-visible task summaries and settings-driven text should reflect the persisted app-language preference when generated.

View File

@@ -67,14 +67,27 @@ public enum AtlasScaffoldWorkspace {
private static func makeInitialPlan(from findings: [Finding]) -> ActionPlan { private static func makeInitialPlan(from findings: [Finding]) -> ActionPlan {
let items = findings.map { finding in 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, id: finding.id,
title: finding.risk == .advanced title: finding.risk == .advanced
? AtlasL10n.string("application.plan.inspectPrivileged", finding.title) ? AtlasL10n.string("application.plan.inspectPrivileged", finding.title)
: AtlasL10n.string("application.plan.reviewFinding", finding.title), : AtlasL10n.string("application.plan.reviewFinding", finding.title),
detail: finding.detail, detail: finding.detail,
kind: finding.category == "Apps" ? .removeApp : (finding.risk == .advanced ? .inspectPermission : .removeCache), kind: kind,
recoverable: finding.risk != .advanced recoverable: finding.risk != .advanced,
targetPaths: finding.targetPaths
) )
} }

View File

@@ -55,6 +55,7 @@ final class AtlasApplicationTests: XCTestCase {
XCTAssertEqual(output.actionPlan.title, plan.title) XCTAssertEqual(output.actionPlan.title, plan.title)
XCTAssertEqual(output.actionPlan.estimatedBytes, plan.estimatedBytes) XCTAssertEqual(output.actionPlan.estimatedBytes, plan.estimatedBytes)
XCTAssertEqual(output.actionPlan.items.first?.targetPaths, plan.items.first?.targetPaths)
} }
func testExecutePlanUsesWorkerEventsToBuildSummary() async throws { func testExecutePlanUsesWorkerEventsToBuildSummary() async throws {

View File

@@ -178,19 +178,22 @@ public struct ActionItem: Identifiable, Codable, Hashable, Sendable {
public var detail: String public var detail: String
public var kind: Kind public var kind: Kind
public var recoverable: Bool public var recoverable: Bool
public var targetPaths: [String]?
public init( public init(
id: UUID = UUID(), id: UUID = UUID(),
title: String, title: String,
detail: String, detail: String,
kind: Kind, kind: Kind,
recoverable: Bool recoverable: Bool,
targetPaths: [String]? = nil
) { ) {
self.id = id self.id = id
self.title = title self.title = title
self.detail = detail self.detail = detail
self.kind = kind self.kind = kind
self.recoverable = recoverable self.recoverable = recoverable
self.targetPaths = targetPaths
} }
} }
@@ -597,21 +600,24 @@ public enum AtlasScaffoldFixtures {
title: AtlasL10n.string("fixture.plan.item.moveDerivedData.title", language: language), title: AtlasL10n.string("fixture.plan.item.moveDerivedData.title", language: language),
detail: AtlasL10n.string("fixture.plan.item.moveDerivedData.detail", language: language), detail: AtlasL10n.string("fixture.plan.item.moveDerivedData.detail", language: language),
kind: .removeCache, kind: .removeCache,
recoverable: true recoverable: true,
targetPaths: ["~/Library/Developer/Xcode/DerivedData/AtlasFixture"]
), ),
ActionItem( ActionItem(
id: uuid("00000000-0000-0000-0000-000000000012"), id: uuid("00000000-0000-0000-0000-000000000012"),
title: AtlasL10n.string("fixture.plan.item.reviewRuntimes.title", language: language), title: AtlasL10n.string("fixture.plan.item.reviewRuntimes.title", language: language),
detail: AtlasL10n.string("fixture.plan.item.reviewRuntimes.detail", language: language), detail: AtlasL10n.string("fixture.plan.item.reviewRuntimes.detail", language: language),
kind: .archiveFile, kind: .archiveFile,
recoverable: true recoverable: true,
targetPaths: ["~/Library/Developer/Xcode/iOS DeviceSupport/AtlasFixtureRuntime"]
), ),
ActionItem( ActionItem(
id: uuid("00000000-0000-0000-0000-000000000013"), id: uuid("00000000-0000-0000-0000-000000000013"),
title: AtlasL10n.string("fixture.plan.item.inspectAgents.title", language: language), title: AtlasL10n.string("fixture.plan.item.inspectAgents.title", language: language),
detail: AtlasL10n.string("fixture.plan.item.inspectAgents.detail", language: language), detail: AtlasL10n.string("fixture.plan.item.inspectAgents.detail", language: language),
kind: .inspectPermission, kind: .inspectPermission,
recoverable: false recoverable: false,
targetPaths: ["~/Library/LaunchAgents/com.example.atlas-fixture.plist"]
), ),
], ],
estimatedBytes: 23_200_000_000 estimatedBytes: 23_200_000_000

View File

@@ -306,6 +306,9 @@ public struct SmartCleanFeatureView: View {
guard item.kind != .inspectPermission else { guard item.kind != .inspectPermission else {
return false return false
} }
if let targetPaths = item.targetPaths, !targetPaths.isEmpty {
return true
}
guard let finding = findings.first(where: { $0.id == item.id }) else { guard let finding = findings.first(where: { $0.id == item.id }) else {
return false return false
} }

View File

@@ -255,6 +255,13 @@ public enum AtlasSmartCleanExecutionSupport {
"/component_crx_cache", "/component_crx_cache",
"/GoogleUpdater", "/GoogleUpdater",
"/CoreSimulator.log", "/CoreSimulator.log",
"/Application Cache",
"/GPUCache",
"/cache2",
"/extensions_crx_cache",
"/GraphiteDawnCache",
"/GrShaderCache",
"/ShaderCache",
] ]
if safeFragments.contains(where: { path.contains($0) }) { if safeFragments.contains(where: { path.contains($0) }) {
return true return true
@@ -570,8 +577,23 @@ public actor AtlasScaffoldWorkerService: AtlasWorkerServing {
) )
} }
let selectedIDs = Set(state.currentPlan.items.map(\.id)) let selectedItems = state.currentPlan.items
let selectedFindings = state.snapshot.findings.filter { selectedIDs.contains($0.id) } 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 { guard !selectedFindings.isEmpty else {
return rejectedResult( return rejectedResult(
@@ -581,8 +603,16 @@ public actor AtlasScaffoldWorkerService: AtlasWorkerServing {
) )
} }
let executableFindings = selectedFindings.filter { actionKind(for: $0) != .inspectPermission } let executableSelections = selectedItems.compactMap { item -> SmartCleanExecutableSelection? in
let missingExecutableTargets = executableFindings.filter { ($0.targetPaths ?? []).isEmpty } 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 { if !missingExecutableTargets.isEmpty && !allowStateOnlyCleanExecution {
return rejectedResult( 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." 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 taskID = UUID()
let response = AtlasResponseEnvelope( let response = AtlasResponseEnvelope(
@@ -600,9 +630,9 @@ public actor AtlasScaffoldWorkerService: AtlasWorkerServing {
) )
var executionResult = SmartCleanExecutionResult() var executionResult = SmartCleanExecutionResult()
if !executableFindings.isEmpty { if !executableSelections.isEmpty {
do { do {
executionResult = try await executeSmartCleanFindings(executableFindings) executionResult = try await executeSmartCleanSelections(executableSelections)
} catch let failure as SmartCleanExecutionFailure { } catch let failure as SmartCleanExecutionFailure {
executionResult = failure.result executionResult = failure.result
if !allowStateOnlyCleanExecution && !executionResult.hasRecordedOutcome { 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 !(executionResult.restoreMappingsByFindingID[$0.id] ?? []).isEmpty
} }
let staleFindings = executableFindings.filter { executionResult.staleFindingIDs.contains($0.id) } let staleFindings = executedFindings.filter { executionResult.staleFindingIDs.contains($0.id) }
let failedFindings = executableFindings.filter { executionResult.failedFindingIDs.contains($0.id) } let failedFindings = executedFindings.filter { executionResult.failedFindingIDs.contains($0.id) }
let recoveryItems = physicallyExecutedFindings.map { let recoveryItems = physicallyExecutedFindings.map {
makeRecoveryItem(for: $0, deletedAt: Date(), restoreMappings: executionResult.restoreMappingsByFindingID[$0.id]) 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() var result = SmartCleanExecutionResult()
for finding in findings { for selection in selections {
let targetPaths = Array(Set(finding.targetPaths ?? [])).sorted() let targetPaths = Array(Set(selection.targetPaths)).sorted()
guard !targetPaths.isEmpty else { 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." result.failureReason = result.failureReason ?? "Smart Clean finding is missing executable targets."
throw SmartCleanExecutionFailure(result: result) throw SmartCleanExecutionFailure(result: result)
} }
@@ -894,12 +925,12 @@ public actor AtlasScaffoldWorkerService: AtlasWorkerServing {
} }
} }
if mappings.isEmpty { if mappings.isEmpty {
result.staleFindingIDs.insert(finding.id) result.staleFindingIDs.insert(selection.finding.id)
} else { } else {
result.restoreMappingsByFindingID[finding.id] = mappings result.restoreMappingsByFindingID[selection.finding.id] = mappings
} }
} catch { } catch {
result.failedFindingIDs.insert(finding.id) result.failedFindingIDs.insert(selection.finding.id)
result.failureReason = result.failureReason ?? error.localizedDescription result.failureReason = result.failureReason ?? error.localizedDescription
throw SmartCleanExecutionFailure(result: result) throw SmartCleanExecutionFailure(result: result)
} }
@@ -907,6 +938,10 @@ public actor AtlasScaffoldWorkerService: AtlasWorkerServing {
return result 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] { private func prepareSmartCleanTargetPaths(_ targetPaths: [String]) throws -> [String] {
var preparedTargetPaths: [String] = [] var preparedTargetPaths: [String] = []
for targetPath in targetPaths { for targetPath in targetPaths {
@@ -1087,7 +1122,7 @@ public actor AtlasScaffoldWorkerService: AtlasWorkerServing {
RecoveryItem( RecoveryItem(
title: finding.title, title: finding.title,
detail: finding.detail, detail: finding.detail,
originalPath: inferredPath(for: finding), originalPath: restoreMappings?.first?.originalPath ?? inferredPath(for: finding),
bytes: finding.bytes, bytes: finding.bytes,
deletedAt: deletedAt, deletedAt: deletedAt,
expiresAt: recoveryExpiryDate(from: deletedAt), expiresAt: recoveryExpiryDate(from: deletedAt),
@@ -1143,7 +1178,8 @@ public actor AtlasScaffoldWorkerService: AtlasWorkerServing {
title: actionTitle(for: finding), title: actionTitle(for: finding),
detail: finding.detail, detail: finding.detail,
kind: actionKind(for: finding), 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), 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), detail: AtlasL10n.string("infrastructure.plan.uninstall.moveBundle.detail", language: state.settings.language, app.bundlePath),
kind: .removeApp, kind: .removeApp,
recoverable: true recoverable: true,
targetPaths: [app.bundlePath]
), ),
ActionItem( ActionItem(
title: AtlasL10n.string(app.leftoverItems == 1 ? "infrastructure.plan.uninstall.archive.one" : "infrastructure.plan.uninstall.archive.other", language: state.settings.language, app.leftoverItems), 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 { private struct SmartCleanExecutionFailure: LocalizedError {
let result: SmartCleanExecutionResult let result: SmartCleanExecutionResult

View File

@@ -434,6 +434,64 @@ final class AtlasInfrastructureTests: XCTestCase {
XCTAssertEqual(result.snapshot.findings.count, 0) 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 { func testScanExecuteRescanRemovesExecutedTargetFromRealResults() async throws {
let repository = AtlasWorkspaceRepository(stateFileURL: temporaryStateFileURL()) let repository = AtlasWorkspaceRepository(stateFileURL: temporaryStateFileURL())
let home = FileManager.default.homeDirectoryForCurrentUser let home = FileManager.default.homeDirectoryForCurrentUser

View File

@@ -2,7 +2,7 @@ import AtlasDomain
import Foundation import Foundation
public enum AtlasProtocolVersion { public enum AtlasProtocolVersion {
public static let current = "0.2.0" public static let current = "0.3.0"
} }
public enum AtlasCommand: Codable, Hashable, Sendable { public enum AtlasCommand: Codable, Hashable, Sendable {

View File

@@ -31,4 +31,29 @@ final class AtlasProtocolTests: XCTestCase {
XCTAssertEqual(decoded.response, envelope.response) 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)
}
} }