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:
@@ -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 ?? []
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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.
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user