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