diff --git a/.gitignore b/.gitignore index 0c47b1be..92a7b4e4 100644 --- a/.gitignore +++ b/.gitignore @@ -16,6 +16,7 @@ Package.resolved # Local agent workflow scratch .superpowers/ +.worktrees/ # Machine-local native UI capture ledger (portable checkpoint is tracked separately) docs/ui-review/evidence-manifest.local.json diff --git a/Makefile b/Makefile index af8b70ba..4ee146c1 100644 --- a/Makefile +++ b/Makefile @@ -18,6 +18,7 @@ RELEASE_SWIFT_PLATFORM_DIR ?= $(RELEASE_ARCHITECTURE)-apple-macosx SWIFT_TRIPLE_ARGS = $(if $(filter release,$(CONFIGURATION)),--triple "$(RELEASE_SWIFT_TRIPLE)",) SWIFT_PROVENANCE_ARGS = $(if $(SWIFT_BUILD_PROVENANCE_FILE),-Xlinker -sectcreate -Xlinker __TEXT -Xlinker __vifty_src -Xlinker "$(SWIFT_BUILD_PROVENANCE_FILE)",) SWIFT_BUILD_ARGS = $(if $(SWIFT_BUILD_PATH),--build-path "$(SWIFT_BUILD_PATH)",) $(SWIFT_TRIPLE_ARGS) $(SWIFT_PROVENANCE_ARGS) $(SWIFT_BUILD_EXTRA_ARGS) +SWIFT_TEST_WARNING_ARGS = -Xswiftc -warnings-as-errors # SwiftPM's product layout is toolchain-dependent (for example, Xcode 26 # places release products under .build/out/Products/Release). Ask SwiftPM for # the path it actually selected, while preserving explicit caller overrides. @@ -264,10 +265,10 @@ source-first-readiness: ## Check published source-first release readiness test: test-full ## Run the full XCTest suite test-fast: check-toolchain ## Run the fast local XCTest suite - swift test $(SWIFT_BUILD_ARGS) $(SLOW_TEST_SKIP_ARGS) + swift test $(SWIFT_BUILD_ARGS) $(SLOW_TEST_SKIP_ARGS) $(SWIFT_TEST_WARNING_ARGS) test-full: check-toolchain ## Run the full XCTest suite, including slow evidence/release script tests - swift test $(SWIFT_BUILD_ARGS) + swift test $(SWIFT_BUILD_ARGS) $(SWIFT_TEST_WARNING_ARGS) verify: check-toolchain ## Run fast local trust gates without installing /bin/bash -n scripts/*.sh scripts/lib/*.sh examples/viftyctl/*.sh diff --git a/Sources/Vifty/AppModel+Control.swift b/Sources/Vifty/AppModel+Control.swift index b679e344..95f225fe 100644 --- a/Sources/Vifty/AppModel+Control.swift +++ b/Sources/Vifty/AppModel+Control.swift @@ -461,6 +461,16 @@ extension AppModel { } } + var agentAuditPersistenceMessage: String? { + guard let health = agentControlStatus?.persistenceHealth, + !health.auditStatusAvailable else { return nil } + let detail = health.auditError?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + guard !detail.isEmpty else { + return "Agent audit history unavailable; cooling control is unchanged." + } + return "Agent audit history unavailable; cooling control is unchanged. " + detail + } + func setAgentCoolingEnabled(_ enabled: Bool) async { do { guard let status = try await agentPolicySetter(enabled) else { return } diff --git a/Sources/Vifty/AppModel+MenuBar.swift b/Sources/Vifty/AppModel+MenuBar.swift index 1570681b..ba261cb4 100644 --- a/Sources/Vifty/AppModel+MenuBar.swift +++ b/Sources/Vifty/AppModel+MenuBar.swift @@ -175,7 +175,7 @@ extension AppModel { } func persistAppPreferences() { - preferencesStore.save(AppPreferences( + let preferences = AppPreferences( menuBarDisplayMode: menuBarDisplayMode, menuBarCustomFields: menuBarCustomFields, startupMode: startupMode, @@ -184,7 +184,17 @@ extension AppModel { usePerFanFixedRPM: usePerFanFixedRPM, fixedFanTargets: fixedFanTargets, codexUsageDisplayPreferences: codexUsageDisplayPreferences - )) + ) + do { + try preferencesStore.saveThrowing(preferences) + appPreferencesPersistenceMessage = nil + } catch { + appPreferencesPersistenceMessage = "Settings were not saved: \(error.localizedDescription)" + } + } + + func retryAppPreferencesSave() { + persistAppPreferences() } var codexUsageDisplayPreferences: CodexUsageDisplayPreferences { diff --git a/Sources/Vifty/AppModel.swift b/Sources/Vifty/AppModel.swift index d17b0a48..6f4f802b 100644 --- a/Sources/Vifty/AppModel.swift +++ b/Sources/Vifty/AppModel.swift @@ -30,6 +30,8 @@ final class AppModel: ObservableObject { } @Published var lastError: String? @Published var curveProfilePersistenceError: String? + @Published var appPreferencesPersistenceMessage: String? + @Published var appPreferencesRecoveryMessage: String? @Published var fanAccessMessage: String? @Published var daemonResponding = false @Published var daemonReachable = false @@ -318,7 +320,15 @@ final class AppModel: ObservableObject { self.agentRestore = agentRestore self.profileStore = profileStore self.preferencesStore = preferencesStore - let appPreferences = self.preferencesStore.load() + let appPreferences: AppPreferences + do { + let result = try self.preferencesStore.loadResult() + appPreferences = result.preferences + appPreferencesRecoveryMessage = result.recoveryMessage + } catch { + appPreferences = self.preferencesStore.load() + appPreferencesPersistenceMessage = "Settings were not saved: \(error.localizedDescription)" + } menuBarDisplayMode = appPreferences.menuBarDisplayMode menuBarCustomFields = MenuBarField.normalized(appPreferences.menuBarCustomFields) startupMode = appPreferences.startupMode diff --git a/Sources/Vifty/AppPreferencesStore.swift b/Sources/Vifty/AppPreferencesStore.swift index 7885c747..8e39ac70 100644 --- a/Sources/Vifty/AppPreferencesStore.swift +++ b/Sources/Vifty/AppPreferencesStore.swift @@ -59,6 +59,11 @@ struct AppPreferences: Codable, Equatable { } } +struct AppPreferencesLoadResult: Equatable { + var preferences: AppPreferences + var recoveryMessage: String? +} + final class AppPreferencesStore: @unchecked Sendable { static let legacyMenuBarDisplayModeDefaultsKey = "menuBarDisplayMode" static let legacyNotificationHelperFailureDefaultsKey = "notification.helperFailure" @@ -76,28 +81,49 @@ final class AppPreferencesStore: @unchecked Sendable { } func load() -> AppPreferences { - if let data = try? Data(contentsOf: url), - let preferences = try? JSONDecoder().decode(AppPreferences.self, from: data) { - return preferences - } + (try? loadResult().preferences) ?? migratedPreferences() + } - // Preserve the unreadable original before any overwrite so a decode - // failure never silently destroys the last recoverable copy. - if FileManager.default.fileExists(atPath: url.path) { - let backup = url.appendingPathExtension("bak") - try? FileManager.default.removeItem(at: backup) - try? FileManager.default.copyItem(at: url, to: backup) + func loadResult() throws -> AppPreferencesLoadResult { + let primary = decodePreferences(at: url) + switch primary { + case .success(let preferences): + try restrictDirectoryPermissions() + try restrictFilePermissions(at: url) + if case .success = decodePreferences(at: backupURL) { + try restrictFilePermissions(at: backupURL) + } + return AppPreferencesLoadResult(preferences: preferences, recoveryMessage: nil) + case .missing, .failure: + break } - let migrated = migratedPreferences() - if migrated != .defaults { - try? saveThrowing(migrated) + let backup = decodePreferences(at: backupURL) + switch backup { + case .success(let preferences): + try restrictDirectoryPermissions() + try restrictFilePermissions(at: backupURL) + if case .failure = primary { + quarantinePrimaryIfPossible() + } + return AppPreferencesLoadResult( + preferences: preferences, + recoveryMessage: "Vifty loaded its private app-preferences backup." + ) + case .missing, .failure: + let migrated = migratedPreferences() + if migrated != .defaults { + try saveThrowing(migrated) + } + let recoveryMessage: String? + switch (primary, backup) { + case (.failure, _), (_, .failure): + recoveryMessage = "Vifty could not recover its private app preferences; defaults are in use." + default: + recoveryMessage = nil + } + return AppPreferencesLoadResult(preferences: migrated, recoveryMessage: recoveryMessage) } - return migrated - } - - func save(_ preferences: AppPreferences) { - try? saveThrowing(preferences) } func saveThrowing(_ preferences: AppPreferences) throws { @@ -105,9 +131,82 @@ final class AppPreferencesStore: @unchecked Sendable { try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) try FileManager.default.setAttributes([.posixPermissions: NSNumber(value: 0o700)], ofItemAtPath: directory.path) + if case .success = decodePreferences(at: url) { + let primaryData = try Data(contentsOf: url) + try replaceBackup(with: primaryData) + } + let data = try JSONEncoder().encode(preferences) try data.write(to: url, options: .atomic) try restrictFilePermissions(at: url) + + if case .success = decodePreferences(at: backupURL) { + try restrictFilePermissions(at: backupURL) + } else { + try replaceBackup(with: data) + } + } + + private var backupURL: URL { + url.appendingPathExtension("bak") + } + + private enum DecodeResult { + case success(AppPreferences) + case missing + case failure + } + + private func decodePreferences(at fileURL: URL) -> DecodeResult { + guard FileManager.default.fileExists(atPath: fileURL.path) else { return .missing } + do { + return .success(try JSONDecoder().decode(AppPreferences.self, from: Data(contentsOf: fileURL))) + } catch { + return .failure + } + } + + private func replaceBackup(with data: Data) throws { + let temporaryURL = backupURL.deletingLastPathComponent().appendingPathComponent( + ".\(backupURL.lastPathComponent).\(UUID().uuidString).tmp" + ) + do { + try data.write(to: temporaryURL, options: .withoutOverwriting) + try restrictFilePermissions(at: temporaryURL) + if FileManager.default.fileExists(atPath: backupURL.path) { + _ = try FileManager.default.replaceItemAt( + backupURL, + withItemAt: temporaryURL, + backupItemName: nil, + options: [] + ) + } else { + try FileManager.default.moveItem(at: temporaryURL, to: backupURL) + } + try restrictFilePermissions(at: backupURL) + } catch { + try? FileManager.default.removeItem(at: temporaryURL) + throw error + } + } + + private func quarantinePrimaryIfPossible() { + let quarantineURL = url.deletingLastPathComponent().appendingPathComponent( + ".\(url.lastPathComponent).corrupt.\(UUID().uuidString)" + ) + do { + try FileManager.default.moveItem(at: url, to: quarantineURL) + try restrictFilePermissions(at: quarantineURL) + } catch { + // Recovery from a valid backup must not depend on quarantine I/O. + } + } + + private func restrictDirectoryPermissions() throws { + try FileManager.default.setAttributes( + [.posixPermissions: NSNumber(value: 0o700)], + ofItemAtPath: url.deletingLastPathComponent().path + ) } private func migratedPreferences() -> AppPreferences { diff --git a/Sources/Vifty/DaemonInstallService.swift b/Sources/Vifty/DaemonInstallService.swift index e6c616d0..3089062b 100644 --- a/Sources/Vifty/DaemonInstallService.swift +++ b/Sources/Vifty/DaemonInstallService.swift @@ -24,6 +24,20 @@ struct DaemonInstallProcessOutput: Equatable, Sendable { var standardError: String } +private actor BoundedProcessOutput { + static let maximumBytesPerStream = 64 * 1_024 + private var data = Data() + + func append(_ chunk: Data) { + guard data.count < Self.maximumBytesPerStream else { return } + data.append(chunk.prefix(Self.maximumBytesPerStream - data.count)) + } + + func snapshot() -> Data { + data + } +} + struct DaemonInstallProcessRunner: Sendable { let run: @Sendable (URL, [String], Data) async throws -> DaemonInstallProcessOutput @@ -45,26 +59,68 @@ struct DaemonInstallProcessRunner: Sendable { let inputPipe = Pipe() let outputPipe = Pipe() let errorPipe = Pipe() + let inputHandle = inputPipe.fileHandleForWriting + let outputHandle = outputPipe.fileHandleForReading + let errorHandle = errorPipe.fileHandleForReading process.standardInput = inputPipe process.standardOutput = outputPipe process.standardError = errorPipe + defer { + try? inputHandle.close() + try? outputHandle.close() + try? errorHandle.close() + } try process.run() + let output = BoundedProcessOutput() + let error = BoundedProcessOutput() + let outputReader = Task.detached(priority: .userInitiated) { + do { + while let chunk = try outputHandle.read(upToCount: 64 * 1_024), !chunk.isEmpty { + await output.append(chunk) + } + } catch { + return + } + } + let errorReader = Task.detached(priority: .userInitiated) { + do { + while let chunk = try errorHandle.read(upToCount: 64 * 1_024), !chunk.isEmpty { + await error.append(chunk) + } + } catch { + return + } + } do { - try inputPipe.fileHandleForWriting.write(contentsOf: standardInput) - try inputPipe.fileHandleForWriting.close() + try inputHandle.write(contentsOf: standardInput) + try inputHandle.close() } catch { process.terminate() + try? inputHandle.close() + try? outputHandle.close() + try? errorHandle.close() + let deadline = Date().addingTimeInterval(0.25) + while process.isRunning && Date() < deadline { + usleep(10_000) + } + if process.isRunning { + kill(process.processIdentifier, SIGKILL) + } + outputReader.cancel() + errorReader.cancel() throw error } process.waitUntilExit() + _ = await outputReader.value + _ = await errorReader.value return DaemonInstallProcessOutput( terminationStatus: process.terminationStatus, standardOutput: String( - decoding: outputPipe.fileHandleForReading.readDataToEndOfFile(), + decoding: await output.snapshot(), as: UTF8.self ), standardError: String( - decoding: errorPipe.fileHandleForReading.readDataToEndOfFile(), + decoding: await error.snapshot(), as: UTF8.self ) ) diff --git a/Sources/Vifty/SettingsAgentWorkflowView.swift b/Sources/Vifty/SettingsAgentWorkflowView.swift index 004e3556..c07c3f2e 100644 --- a/Sources/Vifty/SettingsAgentWorkflowView.swift +++ b/Sources/Vifty/SettingsAgentWorkflowView.swift @@ -32,6 +32,17 @@ struct SettingsAgentWorkflowView: View { ) .disabled(model.agentCoolingEnabled == nil) .accessibilityIdentifier(ViftyAccessibilityIdentifier.agentCoolingEnabled) + + if let message = model.agentAuditPersistenceMessage { + Label { + Text(message) + } icon: { + Image(systemName: "exclamationmark.triangle") + } + .foregroundStyle(.orange) + .fixedSize(horizontal: false, vertical: true) + .accessibilityLabel("Agent audit persistence needs attention") + } } Section("Commands") { diff --git a/Sources/Vifty/SettingsGeneralView.swift b/Sources/Vifty/SettingsGeneralView.swift index fe52145e..f704f7c0 100644 --- a/Sources/Vifty/SettingsGeneralView.swift +++ b/Sources/Vifty/SettingsGeneralView.swift @@ -23,6 +23,30 @@ struct SettingsGeneralView: View { var body: some View { SettingsPane(accessibilityPane: .general) { + if let message = model.appPreferencesPersistenceMessage { + HStack(alignment: .firstTextBaseline) { + Label(message, systemImage: "exclamationmark.triangle") + .foregroundStyle(.orange) + .accessibilityLabel("Settings were not saved") + .accessibilityValue(message) + Button("Retry Save") { + model.retryAppPreferencesSave() + } + .accessibilityLabel("Retry saving settings") + } + .viftyFont(.caption) + .fixedSize(horizontal: false, vertical: true) + } + + if let message = model.appPreferencesRecoveryMessage { + Label("Settings recovered", systemImage: "arrow.counterclockwise.circle") + .foregroundStyle(.secondary) + .accessibilityLabel("Settings recovered") + .accessibilityValue(message) + .viftyFont(.caption) + .fixedSize(horizontal: false, vertical: true) + } + updatesSection Section("Startup") { @@ -87,6 +111,7 @@ struct SettingsGeneralView: View { isOn: automaticUpdateChecksBinding ) .disabled(!softwareUpdates.canCheck) + .accessibilityLabel("Automatically check for updates") .accessibilityIdentifier( ViftyAccessibilityIdentifier.settingsUpdateAutomatic ) diff --git a/Sources/ViftyAXCollector/AXReader.swift b/Sources/ViftyAXCollector/AXReader.swift index 6c40f60d..0bb58990 100644 --- a/Sources/ViftyAXCollector/AXReader.swift +++ b/Sources/ViftyAXCollector/AXReader.swift @@ -54,6 +54,7 @@ public struct AXSystemReader: AXReadAdapter { static func treatsGenericFailureAsMissing(errorCode: Int32, attribute: String) -> Bool { guard errorCode == AXError.failure.rawValue else { return false } return attribute == AXReadAttribute.identifier + || attribute == AXReadAttribute.description || attribute == AXReadAttribute.valueDescription } @@ -159,8 +160,8 @@ public struct AXSystemReader: AXReadAdapter { var value: CFTypeRef? let error = AXUIElementCopyAttributeValue(element, attribute as CFString, &value) if error == .attributeUnsupported || error == .noValue { return nil } - // SwiftUI can return the generic AX failure when optional identifier - // or value-description metadata is absent instead of returning + // SwiftUI can return the generic AX failure when optional identifier, + // description, or value-description metadata is absent instead of returning // `noValue`. Required identifiers are still enforced by exact target // matching and semantic predicates; every other failure stays closed. if Self.treatsGenericFailureAsMissing(errorCode: error.rawValue, attribute: attribute) { diff --git a/Sources/ViftyCore/AgentControlModels.swift b/Sources/ViftyCore/AgentControlModels.swift index 2422d1a3..9b607830 100644 --- a/Sources/ViftyCore/AgentControlModels.swift +++ b/Sources/ViftyCore/AgentControlModels.swift @@ -34,6 +34,7 @@ public enum AgentControlErrorCode: String, Codable, Equatable, Sendable { case childCommandFailed = "CHILD_COMMAND_FAILED" case prepareRateLimited = "PREPARE_RATE_LIMITED" case restoreRequested = "RESTORE_REQUESTED" + case persistenceFailure = "PERSISTENCE_FAILURE" } public struct AgentControlRequest: Codable, Equatable, Sendable { @@ -142,12 +143,54 @@ public struct AgentCoolingLease: Codable, Equatable, Sendable { } } +public struct AgentControlPersistenceHealth: Codable, Equatable, Sendable { + public var policyStatusAvailable: Bool + public var policyError: String? + public var auditStatusAvailable: Bool + public var auditError: String? + + private enum CodingKeys: String, CodingKey { + case policyStatusAvailable + case policyError + case auditStatusAvailable + case auditError + } + + public init( + policyStatusAvailable: Bool, + policyError: String?, + auditStatusAvailable: Bool, + auditError: String? + ) { + self.policyStatusAvailable = policyStatusAvailable + self.policyError = policyError + self.auditStatusAvailable = auditStatusAvailable + self.auditError = auditError + } + + public static let healthy = Self( + policyStatusAvailable: true, + policyError: nil, + auditStatusAvailable: true, + auditError: nil + ) + + public func encode(to encoder: any Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(policyStatusAvailable, forKey: .policyStatusAvailable) + try container.encode(policyError, forKey: .policyError) + try container.encode(auditStatusAvailable, forKey: .auditStatusAvailable) + try container.encode(auditError, forKey: .auditError) + } +} + public struct AgentControlStatus: Codable, Equatable, Sendable { public var enabled: Bool public var activeLease: AgentCoolingLease? public var lastDecision: AgentControlDecision? public var lastErrorCode: AgentControlErrorCode? public var policy: AgentControlPolicySnapshot? + public var persistenceHealth: AgentControlPersistenceHealth private enum CodingKeys: String, CodingKey { case enabled @@ -155,6 +198,7 @@ public struct AgentControlStatus: Codable, Equatable, Sendable { case lastDecision case lastErrorCode case policy + case persistenceHealth } // Emit nil optionals as explicit JSON nulls so strict schema consumers see @@ -166,6 +210,20 @@ public struct AgentControlStatus: Codable, Equatable, Sendable { try container.encode(lastDecision, forKey: .lastDecision) try container.encode(lastErrorCode, forKey: .lastErrorCode) try container.encode(policy, forKey: .policy) + try container.encode(persistenceHealth, forKey: .persistenceHealth) + } + + public init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + enabled = try container.decode(Bool.self, forKey: .enabled) + activeLease = try container.decodeIfPresent(AgentCoolingLease.self, forKey: .activeLease) + lastDecision = try container.decodeIfPresent(AgentControlDecision.self, forKey: .lastDecision) + lastErrorCode = try container.decodeIfPresent(AgentControlErrorCode.self, forKey: .lastErrorCode) + policy = try container.decodeIfPresent(AgentControlPolicySnapshot.self, forKey: .policy) + persistenceHealth = try container.decodeIfPresent( + AgentControlPersistenceHealth.self, + forKey: .persistenceHealth + ) ?? .healthy } public init( @@ -173,13 +231,15 @@ public struct AgentControlStatus: Codable, Equatable, Sendable { activeLease: AgentCoolingLease?, lastDecision: AgentControlDecision?, lastErrorCode: AgentControlErrorCode?, - policy: AgentControlPolicySnapshot? = nil + policy: AgentControlPolicySnapshot? = nil, + persistenceHealth: AgentControlPersistenceHealth = .healthy ) { self.enabled = enabled self.activeLease = activeLease self.lastDecision = lastDecision self.lastErrorCode = lastErrorCode self.policy = policy + self.persistenceHealth = persistenceHealth } } diff --git a/Sources/ViftyCore/AgentControlService.swift b/Sources/ViftyCore/AgentControlService.swift index 75f31f3e..ebe86783 100644 --- a/Sources/ViftyCore/AgentControlService.swift +++ b/Sources/ViftyCore/AgentControlService.swift @@ -51,6 +51,7 @@ public actor AgentControlService { private var activeLease: AgentCoolingLease? private var persistenceLoadErrorMessage: String? + private var persistenceHealth = AgentControlPersistenceHealth.healthy private var lastDecision: AgentControlDecision? private var lastErrorCode: AgentControlErrorCode? private var operationInProgress = false @@ -86,8 +87,18 @@ public actor AgentControlService { self.activeLease = nil self.persistenceLoadErrorMessage = error.localizedDescription } - if let storedEnabled = try? store.loadAgentControlEnabled() { - self.policy.enabled = storedEnabled + do { + if let storedEnabled = try store.loadAgentControlEnabled() { + self.policy.enabled = storedEnabled + } + } catch { + self.policy.enabled = false + self.persistenceHealth = AgentControlPersistenceHealth( + policyStatusAvailable: false, + policyError: Self.boundedPolicyPersistenceMessage(error), + auditStatusAvailable: true, + auditError: nil + ) } self.scheduledExpiry = nil if automaticallySchedulePersistedLeaseMonitor, let activeLease { @@ -104,7 +115,8 @@ public actor AgentControlService { activeLease: lease, lastDecision: lastDecision, lastErrorCode: lastErrorCode, - policy: policy.snapshot + policy: policy.snapshot, + persistenceHealth: persistenceHealth ) } @@ -116,8 +128,35 @@ public actor AgentControlService { if !enabled, activeLease != nil { _ = try await restoreAuto(reason: "Agent control disabled by user") } + let previousEnabled = policy.enabled + do { + try store.saveAgentControlEnabled(enabled) + } catch { + policy.enabled = previousEnabled + persistenceHealth = AgentControlPersistenceHealth( + policyStatusAvailable: false, + policyError: Self.boundedPolicyPersistenceMessage(error), + auditStatusAvailable: persistenceHealth.auditStatusAvailable, + auditError: persistenceHealth.auditError + ) + let decision = AgentControlDecision.denied( + .persistenceFailure, + message: persistenceHealth.policyError ?? "Agent-control policy persistence is unavailable." + ) + lastDecision = decision + lastErrorCode = decision.errorCode + appendAudit(action: "policy-persistence-failed", leaseID: nil, message: decision.message) + throw error + } policy.enabled = enabled - try store.saveAgentControlEnabled(enabled) + persistenceHealth = AgentControlPersistenceHealth( + policyStatusAvailable: true, + policyError: nil, + auditStatusAvailable: persistenceHealth.auditStatusAvailable, + auditError: persistenceHealth.auditError + ) + lastDecision = nil + lastErrorCode = nil return status() } @@ -187,6 +226,16 @@ public actor AgentControlService { "Agent-control ownership state is unreadable; startup Auto recovery is required before prepare: \(persistenceLoadErrorMessage)" ) } + guard persistenceHealth.policyStatusAvailable else { + let decision = AgentControlDecision.denied( + .persistenceFailure, + message: persistenceHealth.policyError ?? "Agent-control policy persistence is unavailable." + ) + lastDecision = decision + lastErrorCode = decision.errorCode + appendAudit(action: "prepare-denied", leaseID: nil, message: decision.message) + return status() + } let prepareRestoreGeneration = restoreRequestGeneration guard let request = request.normalizedMetadata else { let decision = AgentControlDecision.denied( @@ -629,12 +678,30 @@ public actor AgentControlService { } private func appendAudit(action: String, leaseID: String?, message: String) { - try? store.appendAuditEvent(AgentControlAuditEvent( + let event = AgentControlAuditEvent( timestamp: now(), action: action, leaseID: leaseID, message: message - )) + ) + do { + try store.appendAuditEvent(event) + persistenceHealth = AgentControlPersistenceHealth( + policyStatusAvailable: persistenceHealth.policyStatusAvailable, + policyError: persistenceHealth.policyError, + auditStatusAvailable: true, + auditError: nil + ) + } catch { + let auditError = String(error.localizedDescription.prefix(512)) + persistenceHealth = AgentControlPersistenceHealth( + policyStatusAvailable: persistenceHealth.policyStatusAvailable, + policyError: persistenceHealth.policyError, + auditStatusAvailable: false, + auditError: auditError + ) + ViftyCoreLog.agentControl.error("Agent audit persistence failed") + } } private static func normalizedAuditReason(_ reason: String, fallback: String) -> String { @@ -642,6 +709,14 @@ public actor AgentControlService { let normalized = trimmed.isEmpty ? fallback : trimmed return String(normalized.prefix(AgentControlRequest.maximumReasonLength)) } + + private static func boundedPolicyPersistenceMessage(_ error: any Error) -> String { + let trimmed = error.localizedDescription.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { + return "Agent-control policy persistence is unavailable." + } + return String(trimmed.prefix(AgentControlRequest.maximumReasonLength)) + } } private final class AgentControlSendableFlag: @unchecked Sendable { diff --git a/Sources/ViftyCore/AgentDiagnostics.swift b/Sources/ViftyCore/AgentDiagnostics.swift index 2c89acbc..99ff1e71 100644 --- a/Sources/ViftyCore/AgentDiagnostics.swift +++ b/Sources/ViftyCore/AgentDiagnostics.swift @@ -481,7 +481,7 @@ public struct ViftyCtlReadinessReport: Codable, Equatable, Sendable { ownershipStatusError: fanControlOwnershipStatusError ), daemonRuntimeMatchesExpectedCheck(daemonRuntime), - supportedHardwareCheck(snapshot), + supportedHardwareCheck(snapshot, snapshotError: daemonSnapshotError), agentControlEnabledCheck(agentControl), temperatureSensorsPresentCheck(snapshot), controllableFansPresentCheck(controllableFans), @@ -859,7 +859,19 @@ public struct ViftyCtlReadinessReport: Codable, Equatable, Sendable { ) } - private static func supportedHardwareCheck(_ snapshot: HardwareSnapshot) -> ViftyCtlReadinessCheck { + private static func supportedHardwareCheck( + _ snapshot: HardwareSnapshot, + snapshotError: String? + ) -> ViftyCtlReadinessCheck { + guard snapshotError == nil else { + return ViftyCtlReadinessCheck( + id: "supportedHardware", + severity: .error, + passed: false, + message: "Supported hardware could not be determined because the daemon hardware snapshot is unavailable." + ) + } + let passed = snapshot.isAppleSilicon && snapshot.isMacBookPro return ViftyCtlReadinessCheck( id: "supportedHardware", diff --git a/Sources/ViftyCore/ViftyCoreLog.swift b/Sources/ViftyCore/ViftyCoreLog.swift index 22b5b70d..67c396b7 100644 --- a/Sources/ViftyCore/ViftyCoreLog.swift +++ b/Sources/ViftyCore/ViftyCoreLog.swift @@ -2,4 +2,5 @@ import OSLog enum ViftyCoreLog { static let xpc = Logger(subsystem: "tech.reidar.vifty", category: "XPC") + static let agentControl = Logger(subsystem: "tech.reidar.vifty", category: "AgentControl") } diff --git a/Sources/ViftyCore/ViftyCtlArguments.swift b/Sources/ViftyCore/ViftyCtlArguments.swift index 00050cbb..746f612d 100644 --- a/Sources/ViftyCore/ViftyCtlArguments.swift +++ b/Sources/ViftyCore/ViftyCtlArguments.swift @@ -1,6 +1,7 @@ import Foundation public enum ViftyCtlCommand: Equatable, Sendable { + case help case status(json: Bool) case capabilities(json: Bool) case agentRule(json: Bool) @@ -16,6 +17,11 @@ public enum ViftyCtlCommand: Equatable, Sendable { public enum ViftyCtlArguments { public static let defaultAuditLimit = 20 + public static let usage = """ + Usage: viftyctl [options] + Commands: status, capabilities, agent-rule, diagnose, audit, prepare, restore-auto, run + Run 'viftyctl agent-rule' for the guarded agent workflow. + """ public static func parse(_ arguments: [String]) throws -> ViftyCtlCommand { guard let command = arguments.first else { @@ -25,6 +31,11 @@ public enum ViftyCtlArguments { let rest = Array(arguments.dropFirst()) switch command { + case "help", "--help", "-h": + guard rest.isEmpty else { + throw ViftyCtlParseError.unexpectedArgument(rest[0]) + } + return .help case "status": try validateOptions(rest, flagOnly: ["--json"], valueFlags: []) return .status(json: rest.contains("--json")) diff --git a/Sources/ViftyCore/ViftyCtlRunner.swift b/Sources/ViftyCore/ViftyCtlRunner.swift index 2500cda7..888a66e8 100644 --- a/Sources/ViftyCore/ViftyCtlRunner.swift +++ b/Sources/ViftyCore/ViftyCtlRunner.swift @@ -845,7 +845,8 @@ public enum ViftyCtlCommandErrorRecoveryAction: String, Codable, Equatable, Send .rpmOutOfRange, .thermalCritical, .leaseNotFound, - .restoreFailed: + .restoreFailed, + .persistenceFailure: return .runDiagnose } } @@ -1163,6 +1164,7 @@ public struct ViftyCtlStatusReport: Codable, Equatable, Sendable { public var lastDecision: AgentControlDecision? public var lastErrorCode: AgentControlErrorCode? public var policy: AgentControlPolicySnapshot? + public var persistenceHealth: AgentControlPersistenceHealth private enum CodingKeys: String, CodingKey { case schemaVersion @@ -1173,6 +1175,7 @@ public struct ViftyCtlStatusReport: Codable, Equatable, Sendable { case lastDecision case lastErrorCode case policy + case persistenceHealth } // Emit nil optionals as explicit JSON nulls so the status schema's required @@ -1187,6 +1190,7 @@ public struct ViftyCtlStatusReport: Codable, Equatable, Sendable { try container.encode(lastDecision, forKey: .lastDecision) try container.encode(lastErrorCode, forKey: .lastErrorCode) try container.encode(policy, forKey: .policy) + try container.encode(persistenceHealth, forKey: .persistenceHealth) } public init( @@ -1203,6 +1207,7 @@ public struct ViftyCtlStatusReport: Codable, Equatable, Sendable { self.lastDecision = status.lastDecision self.lastErrorCode = status.lastErrorCode self.policy = status.policy + self.persistenceHealth = status.persistenceHealth } } @@ -1344,6 +1349,8 @@ public struct ViftyCtlDaemonClient: ViftyCtlAgentControlClient { } public struct ViftyCtlRunner: Sendable { + private static let policyPersistenceFallbackMessage = "Agent-control policy persistence is unavailable." + private let client: any ViftyCtlAgentControlClient private let processRunner: any ViftyCtlProcessRunning private let thermalReader: @Sendable () -> ThermalPressure @@ -1391,6 +1398,8 @@ public struct ViftyCtlRunner: Sendable { public func run(_ command: ViftyCtlCommand) async throws -> ViftyCtlResult { do { switch command { + case .help: + return ViftyCtlResult(stdout: ViftyCtlArguments.usage + "\n") case .status(let json): let status = try await client.status() let stdout = try formatStatus(status, json: json) @@ -1406,7 +1415,10 @@ public struct ViftyCtlRunner: Sendable { } let stderr: String if let error = capabilities.agentControlStatusError { - stderr = "viftyctl capabilities: daemon status unavailable; policy is a disabled fallback: \(error)\n" + let source = capabilities.daemonStatusAvailable + ? "policy persistence unavailable" + : "daemon status unavailable" + stderr = "viftyctl capabilities: \(source); policy is a disabled fallback: \(error)\n" } else if !capabilities.policyStatusAvailable { stderr = "viftyctl capabilities: daemon returned no usable policy; policy is a disabled fallback\n" } else { @@ -1631,6 +1643,8 @@ public struct ViftyCtlRunner: Sendable { private func jsonRequested(for command: ViftyCtlCommand) -> Bool { switch command { + case .help: + return false case .status(let json), .capabilities(let json), .agentRule(let json), @@ -1651,6 +1665,8 @@ public struct ViftyCtlRunner: Sendable { private func commandName(for command: ViftyCtlCommand) -> String { switch command { + case .help: + return "help" case .status: return "status" case .capabilities: @@ -1752,10 +1768,19 @@ public struct ViftyCtlRunner: Sendable { private func capabilitiesReport() async -> ViftyCtlCapabilities { do { let status = try await client.status() - let policy = status.policy ?? AgentControlPolicy(enabled: false).snapshot + guard let policy = status.policy, + status.persistenceHealth.policyStatusAvailable else { + return ViftyCtlCapabilities( + policy: AgentControlPolicy(enabled: false).snapshot, + policySource: .fallbackUnavailable, + daemonStatusAvailable: true, + policyStatusAvailable: false, + agentControlStatusError: Self.boundedPolicyPersistenceMessage(status.persistenceHealth.policyError) + ) + } return ViftyCtlCapabilities( policy: policy, - policyStatusAvailable: status.policy != nil + policyStatusAvailable: true ) } catch { return ViftyCtlCapabilities( @@ -1763,21 +1788,31 @@ public struct ViftyCtlRunner: Sendable { policySource: .fallbackUnavailable, daemonStatusAvailable: false, policyStatusAvailable: false, - agentControlStatusError: error.localizedDescription + agentControlStatusError: Self.boundedPolicyPersistenceMessage(error.localizedDescription) ) } } + private static func boundedPolicyPersistenceMessage(_ message: String?) -> String { + guard let message else { + return policyPersistenceFallbackMessage + } + let trimmed = message.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { + return policyPersistenceFallbackMessage + } + return String(trimmed.prefix(AgentControlRequest.maximumReasonLength)) + } + private func diagnoseReport() async -> ViftyCtlReadinessReport { let generatedAt = now() - async let snapshotProbe = capture { try await client.snapshot() } - async let statusProbe = capture { try await client.status() } - async let ownershipProbe = capture { try await client.fanControlOwnershipStatus() } - let (snapshotResult, statusResult, ownershipResult) = await ( - snapshotProbe, - statusProbe, - ownershipProbe - ) + // Keep async-let lifetimes nested to avoid Swift 6.1 task-stack misordering (swiftlang/swift#81771). + let (snapshotResult, statusResult, ownershipResult) = await { + async let snapshotProbe = capture { try await client.snapshot() } + async let statusProbe = capture { try await client.status() } + async let ownershipProbe = capture { try await client.fanControlOwnershipStatus() } + return await (snapshotProbe, statusProbe, ownershipProbe) + }() let snapshot: HardwareSnapshot let daemonSnapshotError: String? diff --git a/Sources/ViftyCore/ViftyDaemonProtocol.swift b/Sources/ViftyCore/ViftyDaemonProtocol.swift index 959ec921..592d1c6c 100644 --- a/Sources/ViftyCore/ViftyDaemonProtocol.swift +++ b/Sources/ViftyCore/ViftyDaemonProtocol.swift @@ -420,6 +420,8 @@ public enum XPCSnapshotCoding { } public enum XPCAgentControlCoding { + private static let missingPersistenceHealthMessage = "Persistence health unavailable from older daemon response." + public static func encode(_ request: AgentControlRequest) -> NSDictionary { [ "workload": request.workload.rawValue, @@ -465,6 +467,12 @@ public enum XPCAgentControlCoding { if let policy = status.policy { encoded["policy"] = encodePolicy(policy) } + encoded["persistenceHealth"] = [ + "policyStatusAvailable": status.persistenceHealth.policyStatusAvailable, + "policyError": status.persistenceHealth.policyError.map { $0 as Any } ?? NSNull(), + "auditStatusAvailable": status.persistenceHealth.auditStatusAvailable, + "auditError": status.persistenceHealth.auditError.map { $0 as Any } ?? NSNull() + ] return encoded as NSDictionary } @@ -509,12 +517,37 @@ public enum XPCAgentControlCoding { policy = decodedPolicy } + let persistenceHealth: AgentControlPersistenceHealth + if let value = dictionary["persistenceHealth"] { + guard let healthDictionary = value as? NSDictionary, + let policyStatusAvailable = boolValue(healthDictionary["policyStatusAvailable"]), + let auditStatusAvailable = boolValue(healthDictionary["auditStatusAvailable"]), + (healthDictionary["policyError"] == nil || healthDictionary["policyError"] is String || healthDictionary["policyError"] is NSNull), + (healthDictionary["auditError"] == nil || healthDictionary["auditError"] is String || healthDictionary["auditError"] is NSNull) else { + return nil + } + persistenceHealth = AgentControlPersistenceHealth( + policyStatusAvailable: policyStatusAvailable, + policyError: healthDictionary["policyError"] as? String, + auditStatusAvailable: auditStatusAvailable, + auditError: healthDictionary["auditError"] as? String + ) + } else { + persistenceHealth = AgentControlPersistenceHealth( + policyStatusAvailable: false, + policyError: Self.missingPersistenceHealthMessage, + auditStatusAvailable: false, + auditError: Self.missingPersistenceHealthMessage + ) + } + return AgentControlStatus( enabled: enabled, activeLease: activeLease, lastDecision: lastDecision, lastErrorCode: lastErrorCode, - policy: policy + policy: policy, + persistenceHealth: persistenceHealth ) } diff --git a/Tests/ViftyCoreTests/AXCollectorAdapterTests.swift b/Tests/ViftyCoreTests/AXCollectorAdapterTests.swift index 87092a25..ac921b61 100644 --- a/Tests/ViftyCoreTests/AXCollectorAdapterTests.swift +++ b/Tests/ViftyCoreTests/AXCollectorAdapterTests.swift @@ -18,7 +18,7 @@ final class AXCollectorAdapterTests: XCTestCase { attribute: AXReadAttribute.identifier ) ) - XCTAssertFalse( + XCTAssertTrue( AXSystemReader.treatsGenericFailureAsMissing( errorCode: -25_200, attribute: AXReadAttribute.description diff --git a/Tests/ViftyCoreTests/AgentControlServiceTests.swift b/Tests/ViftyCoreTests/AgentControlServiceTests.swift index 2aa4af3f..5a0d2993 100644 --- a/Tests/ViftyCoreTests/AgentControlServiceTests.swift +++ b/Tests/ViftyCoreTests/AgentControlServiceTests.swift @@ -107,7 +107,7 @@ final class AgentControlServiceTests: XCTestCase { XCTAssertEqual(disabled.policy?.enabled, false) XCTAssertEqual(try store.loadAgentControlEnabled(), false) - let status = try await service.status() + let status = await service.status() XCTAssertEqual(status.policy?.enabled, false) } @@ -161,6 +161,180 @@ final class AgentControlServiceTests: XCTestCase { XCTAssertEqual(status.policy?.enabled, false) } + func testServiceLoadsPersistedPolicyEnabledTrueAtInit() async throws { + let store = AgentControlFaultStore(directory: temporaryDirectory()) + store.loadedPolicy = .success(true) + let hardware = AgentServiceFakeHardware(snapshot: Self.snapshot(fans: [Self.fan(id: 0, minimumRPM: 1500, maximumRPM: 4500)])) + let service = AgentControlService( + hardware: hardware, + policy: AgentControlPolicy(enabled: false), + store: store, + thermalReader: { .nominal } + ) + + let status = await service.status() + + XCTAssertTrue(status.enabled) + XCTAssertEqual(status.policy?.enabled, true) + XCTAssertTrue(status.persistenceHealth.policyStatusAvailable) + } + + func testServiceKeepsPolicyDefaultWhenPersistedPolicyIsAbsent() async throws { + let store = AgentControlFaultStore(directory: temporaryDirectory()) + let hardware = AgentServiceFakeHardware(snapshot: Self.snapshot(fans: [Self.fan(id: 0, minimumRPM: 1500, maximumRPM: 4500)])) + let service = AgentControlService( + hardware: hardware, + policy: AgentControlPolicy(enabled: false), + store: store, + thermalReader: { .nominal } + ) + + let status = await service.status() + + XCTAssertFalse(status.enabled) + XCTAssertEqual(status.policy?.enabled, false) + XCTAssertTrue(status.persistenceHealth.policyStatusAvailable) + } + + func testPolicyLoadFailureFailsClosedAndDeniesPrepareBeforeHardwareApply() async throws { + let store = AgentControlFaultStore(directory: temporaryDirectory()) + store.loadedPolicy = .failure(.loadPolicy) + let hardware = AgentServiceFakeHardware(snapshot: Self.snapshot(fans: [Self.fan(id: 0, minimumRPM: 1500, maximumRPM: 4500)])) + let service = AgentControlService( + hardware: hardware, + policy: AgentControlPolicy(enabled: true), + store: store, + thermalReader: { .nominal } + ) + + let initial = await service.status() + let denied = try await service.prepare(AgentControlRequest( + workload: .build, + durationSeconds: 600, + maxRPMPercent: 75, + reason: "Build", + idempotencyKey: "load-failure" + )) + + XCTAssertFalse(initial.enabled) + XCTAssertFalse(initial.persistenceHealth.policyStatusAvailable) + XCTAssertEqual(denied.lastErrorCode, .persistenceFailure) + XCTAssertEqual(denied.lastDecision?.errorCode, .persistenceFailure) + let snapshotCallCount = await hardware.snapshotCallCount + let appliedCommands = await hardware.appliedCommands + XCTAssertEqual(snapshotCallCount, 0) + XCTAssertEqual(appliedCommands, []) + } + + func testFailedEnableSaveKeepsPolicyDisabledAndPublishesPersistenceFailure() async throws { + let store = AgentControlFaultStore(directory: temporaryDirectory()) + store.loadedPolicy = .success(false) + store.savePolicyError = .savePolicy + let hardware = AgentServiceFakeHardware(snapshot: Self.snapshot(fans: [Self.fan(id: 0, minimumRPM: 1500, maximumRPM: 4500)])) + let service = AgentControlService( + hardware: hardware, + policy: AgentControlPolicy(enabled: true), + store: store, + thermalReader: { .nominal } + ) + + do { + _ = try await service.setPolicyEnabled(true) + XCTFail("Expected policy enable persistence to fail") + } catch { + XCTAssertTrue(error is AgentControlFault) + } + + let status = await service.status() + XCTAssertFalse(status.enabled) + XCTAssertEqual(status.policy?.enabled, false) + XCTAssertFalse(status.persistenceHealth.policyStatusAvailable) + XCTAssertEqual(status.lastErrorCode, .persistenceFailure) + XCTAssertEqual(store.savedPolicies, []) + } + + func testAuditAppendFailureIsVisibleAndLaterSuccessRecoversWithoutChangingTransactionResult() async throws { + let store = AgentControlFaultStore(directory: temporaryDirectory()) + store.auditErrorsRemaining = 1 + let hardware = AgentServiceFakeHardware(snapshot: Self.snapshot(fans: [Self.fan(id: 0, minimumRPM: 1500, maximumRPM: 4500)])) + let service = AgentControlService( + hardware: hardware, + policy: AgentControlPolicy(enabled: true), + store: store, + thermalReader: { .nominal }, + now: { Date(timeIntervalSince1970: 1_000) }, + leaseID: { "lease-1" } + ) + let request = AgentControlRequest( + workload: .build, + durationSeconds: 600, + maxRPMPercent: 75, + reason: "Build", + idempotencyKey: "audit-failure" + ) + + let prepared = try await service.prepare(request) + + XCTAssertNotNil(prepared.activeLease) + XCTAssertFalse(prepared.persistenceHealth.auditStatusAvailable) + XCTAssertNotNil(prepared.persistenceHealth.auditError) + + let restored = try await service.restoreAuto(reason: "Finished") + + XCTAssertNil(restored.activeLease) + XCTAssertTrue(restored.persistenceHealth.auditStatusAvailable) + XCTAssertNil(restored.persistenceHealth.auditError) + XCTAssertEqual(restored.lastErrorCode, nil) + } + + func testFailedDisableSaveRestoresActiveLeaseAndSuccessfulRetryPublishesToggle() async throws { + let store = AgentControlFaultStore(directory: temporaryDirectory()) + store.loadedPolicy = .success(true) + try store.base.saveAgentControlEnabled(true) + let hardware = AgentServiceFakeHardware(snapshot: Self.snapshot(fans: [Self.fan(id: 0, minimumRPM: 1500, maximumRPM: 4500)])) + let service = AgentControlService( + hardware: hardware, + policy: AgentControlPolicy(enabled: false), + store: store, + thermalReader: { .nominal }, + now: { Date(timeIntervalSince1970: 1_000) }, + leaseID: { "lease-1" } + ) + _ = try await service.prepare(AgentControlRequest( + workload: .build, + durationSeconds: 600, + maxRPMPercent: 75, + reason: "Build", + idempotencyKey: "disable-failure" + )) + store.savePolicyError = .savePolicy + + do { + _ = try await service.setPolicyEnabled(false) + XCTFail("Expected policy disable persistence to fail") + } catch { + XCTAssertTrue(error is AgentControlFault) + } + + let failed = await service.status() + XCTAssertTrue(failed.enabled) + XCTAssertEqual(failed.policy?.enabled, true) + XCTAssertNil(failed.activeLease) + XCTAssertFalse(failed.persistenceHealth.policyStatusAvailable) + XCTAssertEqual(failed.lastErrorCode, .persistenceFailure) + let restoredFanIDs = await hardware.restoredFanIDs + XCTAssertEqual(restoredFanIDs, [0]) + XCTAssertEqual(try store.base.loadAgentControlEnabled(), true) + + store.savePolicyError = nil + let retried = try await service.setPolicyEnabled(false) + XCTAssertFalse(retried.enabled) + XCTAssertEqual(retried.policy?.enabled, false) + XCTAssertTrue(retried.persistenceHealth.policyStatusAvailable) + XCTAssertEqual(try store.base.loadAgentControlEnabled(), false) + XCTAssertEqual(store.savedPolicies, [false]) + } + func testLeaseExpiryUsesMonotonicDecisionClockNotWallClockRollback() async throws { let hardware = AgentServiceFakeHardware(snapshot: Self.snapshot(fans: [Self.fan(id: 0, minimumRPM: 1500, maximumRPM: 4500)])) let wall = AgentControlTestClock(now: Date(timeIntervalSince1970: 1_000)) @@ -1773,6 +1947,42 @@ private final class FailingActiveLeaseSaveStore: AgentControlPersisting, @unchec } } +private enum AgentControlFault: Error { + case loadPolicy + case savePolicy +} + +private final class AgentControlFaultStore: AgentControlPersisting, @unchecked Sendable { + let base: AgentControlStore + var loadedPolicy: Result = .success(nil) + var savePolicyError: AgentControlFault? + var savedPolicies: [Bool] = [] + var auditErrorsRemaining = 0 + + init(directory: URL) { + self.base = AgentControlStore(directory: directory) + } + + func saveActiveLease(_ lease: AgentCoolingLease?) throws { try base.saveActiveLease(lease) } + func loadActiveLease() throws -> AgentCoolingLease? { try base.loadActiveLease() } + func appendAuditEvent(_ event: AgentControlAuditEvent) throws { + if auditErrorsRemaining > 0 { + auditErrorsRemaining -= 1 + throw AgentControlFault.savePolicy + } + try base.appendAuditEvent(event) + } + func loadRecentAuditEvents(limit: Int) throws -> [AgentControlAuditEvent] { + try base.loadRecentAuditEvents(limit: limit) + } + func loadAgentControlEnabled() throws -> Bool? { try loadedPolicy.get() } + func saveAgentControlEnabled(_ enabled: Bool) throws { + if let savePolicyError { throw savePolicyError } + savedPolicies.append(enabled) + try base.saveAgentControlEnabled(enabled) + } +} + private actor AgentServiceFakeHardware: HardwareService { enum Failure: Error, Equatable { case applyFailed diff --git a/Tests/ViftyCoreTests/AppModelFanControlTests.swift b/Tests/ViftyCoreTests/AppModelFanControlTests.swift index 36c23d1a..33158d37 100644 --- a/Tests/ViftyCoreTests/AppModelFanControlTests.swift +++ b/Tests/ViftyCoreTests/AppModelFanControlTests.swift @@ -478,6 +478,29 @@ final class AppModelFanControlTests: XCTestCase { XCTAssertEqual(model.agentCoolingRestoreActionHelp, "Restore Auto before starting another agent workload") } + func testAuditPersistenceFailureIsPresentedWithoutBlockingCoolingStatus() { + let model = AppModel() + model.agentControlStatus = AgentControlStatus( + enabled: true, + activeLease: nil, + lastDecision: nil, + lastErrorCode: nil, + persistenceHealth: AgentControlPersistenceHealth( + policyStatusAvailable: true, + policyError: nil, + auditStatusAvailable: false, + auditError: "audit file unavailable" + ) + ) + + XCTAssertEqual( + model.agentAuditPersistenceMessage, + "Agent audit history unavailable; cooling control is unchanged. audit file unavailable" + ) + XCTAssertFalse(model.agentCoolingNeedsAttention) + XCTAssertNil(model.agentCoolingSummary) + } + func testAgentCoolingSummaryIncludesWorkloadAndSortedTargets() { let model = AppModel(now: { Date(timeIntervalSince1970: 1200) }) model.agentControlStatus = AgentControlStatus( diff --git a/Tests/ViftyCoreTests/AppModelPreferencesTests.swift b/Tests/ViftyCoreTests/AppModelPreferencesTests.swift index c0ff2715..49291ce1 100644 --- a/Tests/ViftyCoreTests/AppModelPreferencesTests.swift +++ b/Tests/ViftyCoreTests/AppModelPreferencesTests.swift @@ -1,9 +1,190 @@ +import Darwin import XCTest @testable import ViftyCore @testable import Vifty @MainActor final class AppModelPreferencesTests: XCTestCase { + func testSaveFailsBeforeReplacingValidPrimaryWhenBackupPreservationFails() throws { + let preferencesURL = temporaryPreferencesPath() + let directory = preferencesURL.deletingLastPathComponent() + defer { + try? FileManager.default.setAttributes( + [.posixPermissions: NSNumber(value: 0o700)], + ofItemAtPath: directory.path + ) + try? FileManager.default.removeItem(at: directory) + } + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + + let existing = AppPreferences( + menuBarDisplayMode: .temperature, + startupMode: .curve, + notificationSettings: .disabled + ) + let replacement = AppPreferences( + menuBarDisplayMode: .averageFanRPM, + startupMode: .fixed, + notificationSettings: .disabled + ) + let existingData = try JSONEncoder().encode(existing) + try existingData.write(to: preferencesURL) + let backupURL = preferencesURL.appendingPathExtension("bak") + try Data("keep this invalid backup".utf8).write(to: backupURL) + XCTAssertEqual(chflags(backupURL.path, UInt32(UF_IMMUTABLE)), 0) + defer { _ = chflags(backupURL.path, 0) } + + XCTAssertThrowsError(try AppPreferencesStore(url: preferencesURL, legacyDefaults: nil).saveThrowing(replacement)) + XCTAssertEqual(try Data(contentsOf: preferencesURL), existingData) + XCTAssertEqual(try Data(contentsOf: backupURL), Data("keep this invalid backup".utf8)) + } + + func testAppModelSurfacesPreferenceRecoverySeparatelyFromFanError() throws { + let preferencesURL = temporaryPreferencesPath() + let directory = preferencesURL.deletingLastPathComponent() + defer { try? FileManager.default.removeItem(at: directory) } + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + try Data("corrupt primary".utf8).write(to: preferencesURL) + let recovered = AppPreferences( + menuBarDisplayMode: .temperature, + startupMode: .curve, + notificationSettings: .disabled + ) + try JSONEncoder().encode(recovered).write(to: preferencesURL.appendingPathExtension("bak")) + + let model = AppModel( + preferencesStore: AppPreferencesStore(url: preferencesURL, legacyDefaults: nil) + ) + + XCTAssertEqual(model.menuBarDisplayMode, .temperature) + XCTAssertTrue(model.appPreferencesRecoveryMessage?.contains("backup") == true) + XCTAssertNil(model.appPreferencesPersistenceMessage) + XCTAssertNil(model.lastError) + } + + func testCorruptPrimaryRecoversValidBackupWithoutMutatingBackup() throws { + let preferencesURL = temporaryPreferencesPath() + let directory = preferencesURL.deletingLastPathComponent() + defer { try? FileManager.default.removeItem(at: directory) } + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + try FileManager.default.setAttributes( + [.posixPermissions: NSNumber(value: 0o700)], + ofItemAtPath: directory.path + ) + + let backupPreferences = AppPreferences( + menuBarDisplayMode: .temperature, + startupMode: .curve, + notificationSettings: .disabled + ) + let backupData = try JSONEncoder().encode(backupPreferences) + let corruptData = Data("corrupt primary".utf8) + try corruptData.write(to: preferencesURL) + try backupData.write(to: preferencesURL.appendingPathExtension("bak")) + try FileManager.default.setAttributes( + [.posixPermissions: NSNumber(value: 0o600)], + ofItemAtPath: preferencesURL.path + ) + try FileManager.default.setAttributes( + [.posixPermissions: NSNumber(value: 0o600)], + ofItemAtPath: preferencesURL.appendingPathExtension("bak").path + ) + + let result = try AppPreferencesStore(url: preferencesURL, legacyDefaults: nil).loadResult() + + XCTAssertEqual(result.preferences, backupPreferences) + XCTAssertNotNil(result.recoveryMessage) + XCTAssertEqual(try Data(contentsOf: preferencesURL.appendingPathExtension("bak")), backupData) + XCTAssertEqual(try posixPermissions(at: preferencesURL.appendingPathExtension("bak")), 0o600) + } + + func testValidPrimaryLoadRestrictsExistingValidBackupPermissions() throws { + let preferencesURL = temporaryPreferencesPath() + let directory = preferencesURL.deletingLastPathComponent() + defer { + try? FileManager.default.setAttributes( + [.posixPermissions: NSNumber(value: 0o700)], + ofItemAtPath: directory.path + ) + try? FileManager.default.removeItem(at: directory) + } + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + + let primary = AppPreferences( + menuBarDisplayMode: .fanIcon, + startupMode: .auto, + notificationSettings: .disabled + ) + let backup = AppPreferences( + menuBarDisplayMode: .temperature, + startupMode: .curve, + notificationSettings: .disabled + ) + try JSONEncoder().encode(primary).write(to: preferencesURL) + try JSONEncoder().encode(backup).write(to: preferencesURL.appendingPathExtension("bak")) + try FileManager.default.setAttributes( + [.posixPermissions: NSNumber(value: 0o644)], + ofItemAtPath: preferencesURL.path + ) + try FileManager.default.setAttributes( + [.posixPermissions: NSNumber(value: 0o644)], + ofItemAtPath: preferencesURL.appendingPathExtension("bak").path + ) + try FileManager.default.setAttributes( + [.posixPermissions: NSNumber(value: 0o755)], + ofItemAtPath: directory.path + ) + + let result = try AppPreferencesStore(url: preferencesURL, legacyDefaults: nil).loadResult() + + XCTAssertEqual(result.preferences, primary) + XCTAssertEqual(try posixPermissions(at: directory), 0o700) + XCTAssertEqual(try posixPermissions(at: preferencesURL), 0o600) + XCTAssertEqual(try posixPermissions(at: preferencesURL.appendingPathExtension("bak")), 0o600) + } + + func testCorruptPrimaryAndBackupUseVisibleDefaultsRecoveryState() throws { + let preferencesURL = temporaryPreferencesPath() + let directory = preferencesURL.deletingLastPathComponent() + defer { try? FileManager.default.removeItem(at: directory) } + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + try Data("corrupt primary".utf8).write(to: preferencesURL) + try Data("corrupt backup".utf8).write(to: preferencesURL.appendingPathExtension("bak")) + + let model = AppModel( + preferencesStore: AppPreferencesStore(url: preferencesURL, legacyDefaults: nil) + ) + + XCTAssertEqual(model.menuBarDisplayMode, AppPreferences.defaults.menuBarDisplayMode) + XCTAssertTrue(model.appPreferencesRecoveryMessage?.localizedCaseInsensitiveContains("defaults") == true) + XCTAssertNil(model.appPreferencesPersistenceMessage) + XCTAssertNil(model.lastError) + } + + func testPreferenceSaveFailureIsSeparateAndRetryClearsMessage() throws { + let root = temporaryPreferencesPath().deletingLastPathComponent() + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + let invalidParent = root.appendingPathComponent("preferences-parent") + try Data("not a directory".utf8).write(to: invalidParent) + let preferencesURL = invalidParent.appendingPathComponent("app-preferences.json") + let store = AppPreferencesStore(url: preferencesURL, legacyDefaults: nil) + let model = AppModel(preferencesStore: store) + model.lastError = "fan control failure" + + model.menuBarDisplayMode = .temperature + + XCTAssertTrue(model.appPreferencesPersistenceMessage?.hasPrefix("Settings were not saved:") == true) + XCTAssertEqual(model.lastError, "fan control failure") + + try FileManager.default.removeItem(at: invalidParent) + try FileManager.default.createDirectory(at: invalidParent, withIntermediateDirectories: true) + model.retryAppPreferencesSave() + + XCTAssertNil(model.appPreferencesPersistenceMessage) + XCTAssertEqual(store.load().menuBarDisplayMode, .temperature) + } + func testSaveProfileWithDuplicateNameRequiresConfirmationAndPreservesIdentity() throws { let model = AppModel() model.savedProfiles = [] diff --git a/Tests/ViftyCoreTests/AppSourceRegressionTests.swift b/Tests/ViftyCoreTests/AppSourceRegressionTests.swift new file mode 100644 index 00000000..9ca8668e --- /dev/null +++ b/Tests/ViftyCoreTests/AppSourceRegressionTests.swift @@ -0,0 +1,47 @@ +import Foundation +import XCTest + +final class AppSourceRegressionTests: XCTestCase { + func testGeneralSettingsSurfacesRetryablePreferenceSaveFailure() throws { + let source = try read("Sources/Vifty/SettingsGeneralView.swift") + + XCTAssertTrue(source.contains("model.appPreferencesPersistenceMessage")) + XCTAssertTrue(source.contains("Button(\"Retry Save\")")) + XCTAssertTrue(source.contains("model.retryAppPreferencesSave()")) + XCTAssertTrue(source.contains(".accessibilityLabel(\"Settings were not saved\")")) + } + + func testPreferenceStoreDoesNotKeepSilentSaveOrIgnoreSecurityFailures() throws { + let store = try read("Sources/Vifty/AppPreferencesStore.swift") + let model = try read("Sources/Vifty/AppModel.swift") + let general = try read("Sources/Vifty/SettingsGeneralView.swift") + + XCTAssertFalse(store.contains("func save(_ preferences: AppPreferences)")) + XCTAssertFalse(store.contains("try? restrictDirectoryPermissions()")) + XCTAssertFalse(store.contains("try? restrictFilePermissions(at:")) + XCTAssertTrue(model.contains("appPreferencesRecoveryMessage")) + XCTAssertTrue(general.contains("Settings recovered")) + } + + func testAgentSettingsSurfacesAuditPersistenceAttentionWithoutChangingCoolingPolicy() throws { + let settings = try read("Sources/Vifty/SettingsAgentWorkflowView.swift") + let control = try read("Sources/Vifty/AppModel+Control.swift") + + XCTAssertTrue(settings.contains("model.agentAuditPersistenceMessage")) + XCTAssertTrue(settings.contains("Agent audit persistence needs attention")) + XCTAssertTrue(control.contains("auditStatusAvailable")) + XCTAssertTrue(control.contains("cooling control is unchanged")) + } + + private func read(_ relativePath: String) throws -> String { + let testFile = URL(fileURLWithPath: #filePath) + let repositoryRoot = testFile + .deletingLastPathComponent() + .deletingLastPathComponent() + .deletingLastPathComponent() + return try String( + contentsOf: repositoryRoot.appendingPathComponent(relativePath), + encoding: .utf8 + ) + } +} diff --git a/Tests/ViftyCoreTests/DaemonInstallServiceTests.swift b/Tests/ViftyCoreTests/DaemonInstallServiceTests.swift index a4628076..33b46801 100644 --- a/Tests/ViftyCoreTests/DaemonInstallServiceTests.swift +++ b/Tests/ViftyCoreTests/DaemonInstallServiceTests.swift @@ -77,6 +77,64 @@ final class DaemonInstallServiceTests: XCTestCase { } } + func testSystemRunnerDrainsLargeOutputWithoutDeadlock() async throws { + let script = FileManager.default.temporaryDirectory + .appendingPathComponent("vifty-output-" + UUID().uuidString + ".sh") + defer { try? FileManager.default.removeItem(at: script) } + try Data("#!/bin/bash\ncat >/dev/null\ndd if=/dev/zero bs=262144 count=1 2>/dev/null\ndd if=/dev/zero bs=262144 count=1 1>&2 2>/dev/null\nexit 0\n".utf8) + .write(to: script) + XCTAssertEqual(chmod(script.path, 0o755), 0) + + let resultBox = ProcessOutputBox() + let completion = expectation(description: "high-output process completes") + let maximumBytesPerStream = 64 * 1_024 + let task = Task { + defer { completion.fulfill() } + resultBox.set(try? await DaemonInstallProcessRunner.system.run(script, [], Data("input\n".utf8))) + } + defer { task.cancel() } + + await fulfillment(of: [completion], timeout: 2) + let output = resultBox.value + let result = try XCTUnwrap(output) + XCTAssertEqual(result.terminationStatus, 0) + XCTAssertEqual(result.standardOutput.utf8.count, maximumBytesPerStream) + XCTAssertEqual(result.standardError.utf8.count, maximumBytesPerStream) + } + + func testSystemRunnerBoundsCleanupAfterStdinWriteFailure() async throws { + let script = FileManager.default.temporaryDirectory + .appendingPathComponent("vifty-input-failure-" + UUID().uuidString + ".sh") + defer { try? FileManager.default.removeItem(at: script) } + try Data("#!/bin/bash\nexec 0<&-\ntrap '' TERM\nwhile :; do :; done\n".utf8).write(to: script) + XCTAssertEqual(chmod(script.path, 0o755), 0) + + let previousSIGPIPEHandler = signal(SIGPIPE, SIG_IGN) + defer { signal(SIGPIPE, previousSIGPIPEHandler) } + let resultBox = ProcessFailureBox() + let completion = expectation(description: "stdin failure cleanup completes") + let startedAt = Date() + let task = Task { + defer { completion.fulfill() } + do { + _ = try await DaemonInstallProcessRunner.system.run( + script, + [], + Data(repeating: 0, count: 1 * 1_024 * 1_024) + ) + resultBox.set(threw: false) + } catch { + resultBox.set(threw: true) + } + } + defer { task.cancel() } + + await fulfillment(of: [completion], timeout: 2) + XCTAssertLessThan(Date().timeIntervalSince(startedAt), 1.25) + let didThrow = resultBox.threw + XCTAssertTrue(didThrow) + } + func testBundledLoaderAcceptsOnlyTheReviewedLifecycleScriptBytes() throws { let reviewedScript = repositoryRoot.appendingPathComponent("scripts/vifty-helper-lifecycle.sh") let expectedData = try Data(contentsOf: reviewedScript) @@ -215,3 +273,29 @@ private actor InstallRunnerRecorder { ) } } + +private final class ProcessOutputBox: @unchecked Sendable { + private let lock = NSLock() + private var storedValue: DaemonInstallProcessOutput? + + var value: DaemonInstallProcessOutput? { + lock.withLock { storedValue } + } + + func set(_ value: DaemonInstallProcessOutput?) { + lock.withLock { storedValue = value } + } +} + +private final class ProcessFailureBox: @unchecked Sendable { + private let lock = NSLock() + private var storedThrew = false + + var threw: Bool { + lock.withLock { storedThrew } + } + + func set(threw: Bool) { + lock.withLock { storedThrew = threw } + } +} diff --git a/Tests/ViftyCoreTests/MakefileTrustGateTests.swift b/Tests/ViftyCoreTests/MakefileTrustGateTests.swift index b01f4ec9..8507bf82 100644 --- a/Tests/ViftyCoreTests/MakefileTrustGateTests.swift +++ b/Tests/ViftyCoreTests/MakefileTrustGateTests.swift @@ -105,8 +105,10 @@ final class MakefileTrustGateTests: XCTestCase { XCTAssertTrue(makefile.contains("/bin/bash -n scripts/*.sh scripts/lib/*.sh examples/viftyctl/*.sh")) XCTAssertTrue(makefile.contains("scripts/check-community-standards.sh")) XCTAssertTrue(makefile.contains("scripts/validate-release-metadata.sh --mode \"$(RELEASE_METADATA_MODE)\"")) - XCTAssertTrue(makefile.contains("swift test $(SWIFT_BUILD_ARGS) $(SLOW_TEST_SKIP_ARGS)")) - XCTAssertTrue(makefile.contains("swift test $(SWIFT_BUILD_ARGS)")) + let contractViolations = warningContractViolations(in: makefile) + XCTAssertEqual(contractViolations, []) + let unboundMakefile = makefile.replacingOccurrences(of: " $(SWIFT_TEST_WARNING_ARGS)", with: "") + XCTAssertFalse(warningContractViolations(in: unboundMakefile).isEmpty) XCTAssertTrue(makefile.contains("$(MAKE) $(VERIFY_TEST_TARGET)")) XCTAssertTrue(makefile.contains("verify-full: VERIFY_TEST_TARGET = test-full")) XCTAssertTrue(makefile.contains("verify-full: verify")) @@ -293,4 +295,38 @@ final class MakefileTrustGateTests: XCTestCase { .appendingPathComponent(relativePath) return try String(contentsOf: url, encoding: .utf8) } + + private func warningContractViolations(in makefile: String) -> [String] { + let lines = makefile.components(separatedBy: "\n") + var violations: [String] = [] + let expectedDefinition = "SWIFT_TEST_WARNING_ARGS = -Xswiftc -warnings-as-errors" + let definitions = lines.filter { line in + line.hasPrefix("SWIFT_TEST_WARNING_ARGS") && line.contains("=") + } + if definitions.count != 1 || definitions.first != expectedDefinition { + violations.append("warning argument definition must appear exactly once") + } + + for (target, expectedRecipe) in [ + ("test-fast", "\tswift test $(SWIFT_BUILD_ARGS) $(SLOW_TEST_SKIP_ARGS) $(SWIFT_TEST_WARNING_ARGS)"), + ("test-full", "\tswift test $(SWIFT_BUILD_ARGS) $(SWIFT_TEST_WARNING_ARGS)") + ] { + let headers = lines.indices.filter { lines[$0].hasPrefix("\(target):") } + guard headers.count == 1, let header = headers.first else { + violations.append("\(target) target must appear exactly once") + continue + } + var block = [lines[header]] + var index = header + 1 + while index < lines.count, lines[index].isEmpty || lines[index].hasPrefix("\t") { + block.append(lines[index]) + index += 1 + } + let swiftTestRecipes = block.filter { $0.hasPrefix("\tswift test ") } + if swiftTestRecipes != [expectedRecipe] { + violations.append("\(target) recipe must invoke Swift tests with warning arguments") + } + } + return violations + } } diff --git a/Tests/ViftyCoreTests/ReleaseEnvironmentScriptTests.swift b/Tests/ViftyCoreTests/ReleaseEnvironmentScriptTests.swift index 6004cb82..0e7a7c07 100644 --- a/Tests/ViftyCoreTests/ReleaseEnvironmentScriptTests.swift +++ b/Tests/ViftyCoreTests/ReleaseEnvironmentScriptTests.swift @@ -110,8 +110,8 @@ final class ReleaseEnvironmentScriptTests: XCTestCase { XCTAssertTrue(trackedResult.stderr.contains("must not replace a tracked worktree path")) XCTAssertEqual(try Data(contentsOf: trackedURL), trackedBefore) - let refURL = repositoryRoot - .appendingPathComponent(".git/refs/tags/vifty-environment-output-\(UUID().uuidString)") + let refURL = try repositoryGitCommonDirectory(from: repositoryRoot) + .appendingPathComponent("refs/tags/vifty-environment-output-\(UUID().uuidString)") let metadataResult = try runChecker(fixture: fixture, output: refURL) XCTAssertEqual(metadataResult.exitCode, 65) XCTAssertTrue(metadataResult.stderr.contains("must not be inside Git metadata")) @@ -732,6 +732,27 @@ final class ReleaseEnvironmentScriptTests: XCTestCase { } } +func repositoryGitCommonDirectory(from repositoryRoot: URL) throws -> URL { + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/git") + process.arguments = [ + "-C", repositoryRoot.path, + "rev-parse", "--path-format=absolute", "--git-common-dir" + ] + let stdout = Pipe() + process.standardOutput = stdout + try process.run() + process.waitUntilExit() + guard process.terminationStatus == 0 else { + throw NSError(domain: "ViftyTests.Git", code: Int(process.terminationStatus)) + } + let path = String( + decoding: stdout.fileHandleForReading.readDataToEndOfFile(), + as: UTF8.self + ).trimmingCharacters(in: .whitespacesAndNewlines) + return URL(fileURLWithPath: path, isDirectory: true) +} + private struct ReleaseEnvironmentProcessResult { let stdout: String let stderr: String diff --git a/Tests/ViftyCoreTests/ReleaseGovernanceScriptTests.swift b/Tests/ViftyCoreTests/ReleaseGovernanceScriptTests.swift index b59144a4..fd5f52ec 100644 --- a/Tests/ViftyCoreTests/ReleaseGovernanceScriptTests.swift +++ b/Tests/ViftyCoreTests/ReleaseGovernanceScriptTests.swift @@ -258,8 +258,8 @@ final class ReleaseGovernanceScriptTests: XCTestCase { XCTAssertTrue(result.stderr.contains("must not replace a checker input"), result.stderr) XCTAssertEqual(try Data(contentsOf: fixture.repoURL), originalFixtureBytes) - let gitMetadataOutput = repositoryRoot - .appendingPathComponent(".git/refs/tags") + let gitMetadataOutput = try repositoryGitCommonDirectory(from: repositoryRoot) + .appendingPathComponent("refs/tags") .appendingPathComponent("vifty-governance-output-\(UUID().uuidString)") result = try runChecker(fixture, outputURL: gitMetadataOutput) diff --git a/Tests/ViftyCoreTests/UIReviewEvidenceScriptTests.swift b/Tests/ViftyCoreTests/UIReviewEvidenceScriptTests.swift index 1506a87a..4afe5864 100644 --- a/Tests/ViftyCoreTests/UIReviewEvidenceScriptTests.swift +++ b/Tests/ViftyCoreTests/UIReviewEvidenceScriptTests.swift @@ -676,7 +676,7 @@ final class UIReviewEvidenceScriptTests: XCTestCase { XCTAssertEqual(identity["executablePath"] as? String, try canonicalFilesystemPath(fixture.debugExecutable)) } - func testCaptureModePassesPersistenceIsolationArgumentsToFixtureProcess() throws { + func testCaptureModeDoesNotPassCrossRuntimeReadinessDeadlineToFixtureProcess() throws { let fixture = try orchestrationFixture(mode: .successful) defer { try? FileManager.default.removeItem(at: fixture.root) } @@ -703,14 +703,10 @@ final class UIReviewEvidenceScriptTests: XCTestCase { 1, launchArguments.joined(separator: "\n") ) - let deadlineIndex = try XCTUnwrap( - launchArguments.firstIndex(of: "--ui-review-readiness-deadline-uptime") - ) - let deadlineValue = try XCTUnwrap( - Double(launchArguments[deadlineIndex + 1]) + XCTAssertFalse( + launchArguments.contains("--ui-review-readiness-deadline-uptime"), + launchArguments.joined(separator: "\n") ) - XCTAssertTrue(deadlineValue.isFinite) - XCTAssertGreaterThan(deadlineValue, 0) } func testCaptureModePreservesStructuredReadyTimeoutAndCleansUpChild() throws { diff --git a/Tests/ViftyCoreTests/ValidationEvidenceReviewScriptTests.swift b/Tests/ViftyCoreTests/ValidationEvidenceReviewScriptTests.swift index 62827a0a..a9430cbb 100644 --- a/Tests/ViftyCoreTests/ValidationEvidenceReviewScriptTests.swift +++ b/Tests/ViftyCoreTests/ValidationEvidenceReviewScriptTests.swift @@ -2597,7 +2597,7 @@ private final class ValidationEvidenceReviewHarness { ? [ "attempted": true, "retryAfterSeconds": rateLimitRetryAfterSeconds, - "initialExitStatus": rateLimitInitialMetadataExitStatus ?? rateLimitInitialExitStatus, + "initialExitStatus": (rateLimitInitialMetadataExitStatus ?? rateLimitInitialExitStatus) as Any, "stdout": "viftyctl-run.json", "stderr": "viftyctl-run.stderr" ] diff --git a/Tests/ViftyCoreTests/ViftyCtlArgumentsTests.swift b/Tests/ViftyCoreTests/ViftyCtlArgumentsTests.swift index 33ba25c7..5315853b 100644 --- a/Tests/ViftyCoreTests/ViftyCtlArgumentsTests.swift +++ b/Tests/ViftyCoreTests/ViftyCtlArgumentsTests.swift @@ -10,6 +10,12 @@ final class ViftyCtlArgumentsTests: XCTestCase { assertParseError(["frobnicate"], equals: .unknownCommand("frobnicate")) } + func testParsesHelpAliases() throws { + XCTAssertEqual(try ViftyCtlArguments.parse(["help"]), .help) + XCTAssertEqual(try ViftyCtlArguments.parse(["--help"]), .help) + XCTAssertEqual(try ViftyCtlArguments.parse(["-h"]), .help) + } + func testParsesStatusJSON() throws { let command = try ViftyCtlArguments.parse(["status", "--json"]) diff --git a/Tests/ViftyCoreTests/ViftyCtlJSONExampleTests.swift b/Tests/ViftyCoreTests/ViftyCtlJSONExampleTests.swift index 5b77b525..e541b37b 100644 --- a/Tests/ViftyCoreTests/ViftyCtlJSONExampleTests.swift +++ b/Tests/ViftyCoreTests/ViftyCtlJSONExampleTests.swift @@ -186,6 +186,33 @@ final class ViftyCtlJSONExampleTests: XCTestCase { XCTAssertNil(report.agentControlStatusError) } + func testKnownUnsupportedHardwareRetainsExistingBlockerAndCopy() { + let report = ViftyCtlReadinessReport.make( + snapshot: HardwareSnapshot( + fans: [], + temperatureSensors: [], + modelIdentifier: "Mac14,15", + isAppleSilicon: false, + isMacBookPro: false, + capturedAt: Date(timeIntervalSince1970: 1_000) + ), + agentControl: AgentControlStatus( + enabled: false, + activeLease: nil, + lastDecision: nil, + lastErrorCode: nil + ), + thermalPressure: .nominal, + generatedAt: Date(timeIntervalSince1970: 1_000) + ) + + let check = report.checks.first { $0.id == "supportedHardware" } + XCTAssertEqual(check?.passed, false) + XCTAssertTrue(check?.message.contains("supported only") == true) + XCTAssertTrue(report.failedCheckIDs.contains("supportedHardware")) + XCTAssertTrue(report.coolingBlockerIDs.contains("supportedHardware")) + } + func testDiagnoseBlockedHelperUnreachableExampleDecodesAgainstCurrentModel() throws { let report = try decode(ViftyCtlReadinessReport.self, from: "diagnose-blocked-helper-unreachable.json") @@ -384,6 +411,7 @@ final class ViftyCtlJSONExampleTests: XCTestCase { XCTAssertEqual(status.lastDecision?.allowed, true) XCTAssertEqual(status.lastDecision?.targetRPMByFanID[1], 3700) XCTAssertNil(status.lastErrorCode) + XCTAssertEqual(status.persistenceHealth, .healthy) } func testCommandErrorExampleDecodesAgainstCurrentModel() throws { @@ -974,6 +1002,7 @@ final class ViftyCtlJSONExampleTests: XCTestCase { try assertRequiredFields(definition: "request", in: statusDefinitions, arePresentIn: activeLease["request"] as? [String: Any], context: "lease request") try assertRequiredFields(definition: "decision", in: statusDefinitions, arePresentIn: statusExample["lastDecision"] as? [String: Any], context: "last decision") try assertRequiredFields(definition: "policy", in: statusDefinitions, arePresentIn: statusExample["policy"] as? [String: Any], context: "status policy") + try assertRequiredFields(definition: "persistenceHealth", in: statusDefinitions, arePresentIn: statusExample["persistenceHealth"] as? [String: Any], context: "status persistence health") let commandErrorSchema = try readJSON(schemaURL("viftyctl-command-error.schema.json")) let commandErrorProperties = try XCTUnwrap(commandErrorSchema["properties"] as? [String: Any]) @@ -1157,7 +1186,8 @@ final class ViftyCtlJSONExampleTests: XCTestCase { "INVALID_ARGUMENTS", "CHILD_COMMAND_FAILED", "PREPARE_RATE_LIMITED", - "RESTORE_REQUESTED" + "RESTORE_REQUESTED", + "PERSISTENCE_FAILURE" ] } diff --git a/Tests/ViftyCoreTests/ViftyCtlProcessRunnerTests.swift b/Tests/ViftyCoreTests/ViftyCtlProcessRunnerTests.swift index 53fd7c3d..4c604fa7 100644 --- a/Tests/ViftyCoreTests/ViftyCtlProcessRunnerTests.swift +++ b/Tests/ViftyCoreTests/ViftyCtlProcessRunnerTests.swift @@ -122,7 +122,7 @@ final class ViftyCtlProcessRunnerTests: XCTestCase { childProcessGroup: Darwin.getpgid(childPID), wrapperProcessGroup: Darwin.getpgrp() ) - _ = Self.waitForFile(at: pidFile, timeout: 1) + _ = Self.waitForFile(at: pidFile, componentCount: 2, timeout: 1) XCTAssertEqual(Darwin.kill(Darwin.getpid(), SIGTERM), 0) } ) @@ -287,9 +287,14 @@ final class ViftyCtlProcessRunnerTests: XCTestCase { return (child, grandchild) } - private static func waitForFile(at url: URL, timeout: TimeInterval) -> Bool { + private static func waitForFile( + at url: URL, + componentCount: Int = 1, + timeout: TimeInterval + ) -> Bool { waitUntil(timeout: timeout) { - FileManager.default.fileExists(atPath: url.path) + guard let contents = try? String(contentsOf: url, encoding: .utf8) else { return false } + return contents.split(whereSeparator: \.isWhitespace).count >= componentCount } } diff --git a/Tests/ViftyCoreTests/ViftyCtlRunnerTests.swift b/Tests/ViftyCoreTests/ViftyCtlRunnerTests.swift index 892cb671..89a1d5b0 100644 --- a/Tests/ViftyCoreTests/ViftyCtlRunnerTests.swift +++ b/Tests/ViftyCoreTests/ViftyCtlRunnerTests.swift @@ -14,6 +14,7 @@ final class ViftyCtlRunnerTests: XCTestCase { XCTAssertEqual(ViftyCtlCommandErrorRecoveryAction.recommended(for: .childCommandFailed), .fixChildCommand) XCTAssertEqual(ViftyCtlCommandErrorRecoveryAction.recommended(for: .restoreRequested), .restoreAutoBeforeRetry) XCTAssertEqual(ViftyCtlCommandErrorRecoveryAction.recommended(for: .prepareRateLimited), .waitBeforeRetry) + XCTAssertEqual(ViftyCtlCommandErrorRecoveryAction.recommended(for: .persistenceFailure), .runDiagnose) XCTAssertEqual(ViftyCtlCommandErrorRecoveryAction.recommended(for: .thermalCritical), .runDiagnose) XCTAssertEqual(ViftyCtlCommandErrorRecoveryAction.recommended(for: nil), .runDiagnose) } @@ -112,6 +113,27 @@ final class ViftyCtlRunnerTests: XCTestCase { XCTAssertTrue(result.stdout.contains("agent-rule")) } + func testHelpAliasesReturnIdenticalCanonicalUsage() async throws { + let runner = ViftyCtlRunner( + client: FakeAgentControlClient(), + processRunner: FakeProcessRunner() + ) + + var outputs: [String] = [] + for argument in ["help", "--help", "-h"] { + let command = try ViftyCtlArguments.parse([argument]) + let result = try await runner.run(command) + XCTAssertEqual(result.exitCode, 0) + XCTAssertEqual(result.stderr, "") + XCTAssertTrue(result.stdout.hasSuffix("\n")) + outputs.append(result.stdout) + } + + XCTAssertEqual(Set(outputs).count, 1) + XCTAssertEqual(outputs.first, ViftyCtlArguments.usage + "\n") + XCTAssertFalse(outputs[0].contains("helper-maintenance")) + } + func testAgentRuleReturnsPasteableRuleWithoutDaemonMutation() async throws { let client = FakeAgentControlClient( status: AgentControlStatus( @@ -389,13 +411,49 @@ final class ViftyCtlRunnerTests: XCTestCase { let json = try XCTUnwrap(JSONSerialization.jsonObject(with: data) as? [String: Any]) XCTAssertEqual(json["daemonStatusAvailable"] as? Bool, true) XCTAssertEqual(json["policyStatusAvailable"] as? Bool, false) - XCTAssertEqual(json["policySource"] as? String, ViftyCtlPolicySource.daemonStatus.rawValue) - XCTAssertNil(json["agentControlStatusError"] as? String) + XCTAssertEqual(json["policySource"] as? String, ViftyCtlPolicySource.fallbackUnavailable.rawValue) + XCTAssertEqual(json["agentControlStatusError"] as? String, "Agent-control policy persistence is unavailable.") let policy = try XCTUnwrap(json["policy"] as? [String: Any]) XCTAssertEqual(policy["enabled"] as? Bool, false) XCTAssertEqual(policy["maxDurationSeconds"] as? Int, 1_800) } + func testCapabilitiesJSONBoundsPolicyPersistenceMessage() async throws { + let messages = [ + "", + String(repeating: "x", count: AgentControlRequest.maximumReasonLength + 1) + ] + + for originalMessage in messages { + let runner = ViftyCtlRunner( + client: FakeAgentControlClient(status: AgentControlStatus( + enabled: true, + activeLease: nil, + lastDecision: nil, + lastErrorCode: nil, + policy: AgentControlPolicy(enabled: true).snapshot, + persistenceHealth: AgentControlPersistenceHealth( + policyStatusAvailable: false, + policyError: originalMessage, + auditStatusAvailable: true, + auditError: nil + ) + )), + processRunner: FakeProcessRunner() + ) + + let result = try await runner.run(.capabilities(json: true)) + let data = try XCTUnwrap(result.stdout.data(using: .utf8)) + let json = try XCTUnwrap(JSONSerialization.jsonObject(with: data) as? [String: Any]) + let message = try XCTUnwrap(json["agentControlStatusError"] as? String) + let expected = originalMessage.isEmpty + ? "Agent-control policy persistence is unavailable." + : String(originalMessage.prefix(AgentControlRequest.maximumReasonLength)) + XCTAssertEqual(message, expected) + XCTAssertLessThanOrEqual(message.count, AgentControlRequest.maximumReasonLength) + } + } + func testCapabilitiesJSONReturnsStaticContractWhenDaemonStatusUnavailable() async throws { let runner = ViftyCtlRunner( client: FakeAgentControlClient( @@ -456,6 +514,50 @@ final class ViftyCtlRunnerTests: XCTestCase { XCTAssertEqual(policy["maxDurationSeconds"] as? Int, 1_800) } + func testCapabilitiesHumanReadableDescribesPolicyPersistenceWhenDaemonResponds() async throws { + let runner = ViftyCtlRunner( + client: FakeAgentControlClient(status: AgentControlStatus( + enabled: true, + activeLease: nil, + lastDecision: nil, + lastErrorCode: .persistenceFailure, + policy: AgentControlPolicy(enabled: true).snapshot, + persistenceHealth: AgentControlPersistenceHealth( + policyStatusAvailable: false, + policyError: "policy file unreadable", + auditStatusAvailable: true, + auditError: nil + ) + )), + processRunner: FakeProcessRunner() + ) + + let result = try await runner.run(.capabilities(json: false)) + + XCTAssertEqual(result.exitCode, 69) + XCTAssertTrue(result.stderr.contains("policy persistence unavailable")) + XCTAssertTrue(result.stderr.contains("policy file unreadable")) + XCTAssertFalse(result.stderr.contains("daemon status unavailable")) + } + + func testCapabilitiesJSONBoundsDaemonRequestError() async throws { + let originalMessage = String(repeating: "x", count: AgentControlRequest.maximumReasonLength + 100) + let runner = ViftyCtlRunner( + client: FakeAgentControlClient( + statusError: ViftyError.helperRejected(originalMessage) + ), + processRunner: FakeProcessRunner() + ) + + let result = try await runner.run(.capabilities(json: true)) + + let data = try XCTUnwrap(result.stdout.data(using: .utf8)) + let json = try XCTUnwrap(JSONSerialization.jsonObject(with: data) as? [String: Any]) + let message = try XCTUnwrap(json["agentControlStatusError"] as? String) + XCTAssertEqual(message.count, AgentControlRequest.maximumReasonLength) + XCTAssertTrue(message.hasSuffix(String(repeating: "x", count: 20))) + } + func testCapabilitiesHumanReadableReturnsCommandsAndUnavailableExitWhenDaemonStatusUnavailable() async throws { let runner = ViftyCtlRunner( client: FakeAgentControlClient( @@ -543,6 +645,44 @@ final class ViftyCtlRunnerTests: XCTestCase { XCTAssertEqual(restoreReasonCount, 0) } + func testDiagnoseExposesAuditPersistenceHealthWithoutChangingCoolingReadiness() async throws { + let client = FakeAgentControlClient( + snapshot: Self.readySnapshot(), + status: AgentControlStatus( + enabled: true, + activeLease: nil, + lastDecision: nil, + lastErrorCode: nil, + policy: AgentControlPolicy(enabled: true).snapshot, + persistenceHealth: AgentControlPersistenceHealth( + policyStatusAvailable: true, + policyError: nil, + auditStatusAvailable: false, + auditError: "audit file unavailable" + ) + ) + ) + let runner = ViftyCtlRunner( + client: client, + processRunner: FakeProcessRunner(), + thermalReader: { .nominal }, + manualControlActiveReader: { false } + ) + + let result = try await runner.run(.diagnose(json: true)) + + XCTAssertEqual(result.exitCode, 0) + let json = try jsonObject(in: result.stdout) + XCTAssertEqual(json["safeToRequestCooling"] as? Bool, true) + XCTAssertEqual(json["state"] as? String, "ready") + let agentControl = try XCTUnwrap(json["agentControl"] as? [String: Any]) + let persistenceHealth = try XCTUnwrap(agentControl["persistenceHealth"] as? [String: Any]) + XCTAssertEqual(persistenceHealth["auditStatusAvailable"] as? Bool, false) + XCTAssertEqual(persistenceHealth["auditError"] as? String, "audit file unavailable") + let checks = try XCTUnwrap(json["checks"] as? [[String: Any]]) + XCTAssertFalse(checks.contains { ($0["id"] as? String)?.localizedCaseInsensitiveContains("audit") == true }) + } + func testDiagnoseReplacementMaintenanceAttestationDoesNotAssumeTwoFans() async throws { var snapshot = Self.readySnapshot() snapshot.fans = Array(snapshot.fans.prefix(1)) @@ -809,6 +949,15 @@ final class ViftyCtlRunnerTests: XCTestCase { && (check["passed"] as? Bool) == false && (check["severity"] as? String) == "error" }) + XCTAssertTrue(checks.contains { check in + guard (check["id"] as? String) == "supportedHardware", + let message = check["message"] as? String else { + return false + } + return (check["passed"] as? Bool) == false + && message.contains("could not be determined") + && !message.contains("supported only") + }) } func testDiagnoseJSONReturnsBlockedReportWhenAgentControlStatusFails() async throws { diff --git a/Tests/ViftyCoreTests/ViftyReviewFixtureTests.swift b/Tests/ViftyCoreTests/ViftyReviewFixtureTests.swift index 02505055..f7a393ef 100644 --- a/Tests/ViftyCoreTests/ViftyReviewFixtureTests.swift +++ b/Tests/ViftyCoreTests/ViftyReviewFixtureTests.swift @@ -162,6 +162,63 @@ final class ViftyReviewFixtureTests: XCTestCase { XCTAssertEqual(stabilizer.consume(matching, request: request), matching) } + func testHostedSceneSchedulesObservationAfterPreparation() async throws { + let root = fixtureRoot() + defer { try? FileManager.default.removeItem(at: root) } + let executable = try writeExecutableFixture(in: root) + let workspace = NSWorkspace.shared + let request = try fixtureRequest( + root: root, + captureID: "capture-hosted-scene", + contrast: workspace.accessibilityDisplayShouldIncreaseContrast ? .increased : .standard, + transparency: workspace.accessibilityDisplayShouldReduceTransparency ? .reduced : .standard + ) + let runtime = try ViftyReviewFixtureRuntime( + request: request, + executableURL: executable, + processIdentifier: 42 + ) + + try await runtime.prepare() + + let window = NSWindow( + contentRect: NSRect(origin: .zero, size: request.window.size), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + window.isReleasedWhenClosed = false + let hostingView = NSHostingView( + rootView: ViftyReviewFixtureSceneHost( + runtime: runtime, + provenance: "swiftui-main-window" + ) { + Color.clear + } + ) + window.contentView = hostingView + window.orderFrontRegardless() + window.displayIfNeeded() + defer { + window.orderOut(nil) + window.close() + } + + let deadline = Date().addingTimeInterval(2) + while !runtime.hasReadyObservation, Date() < deadline { + try await Task.sleep(for: .milliseconds(20)) + } + + XCTAssertTrue(runtime.hasReadyObservation) + let report = runtime.report(phase: "current") + XCTAssertTrue(report.passed) + XCTAssertTrue(report.modelStartSkipped) + XCTAssertEqual(report.runtimeIdentity?.provenance, "swiftui-main-window") + XCTAssertTrue(report.recorder.attemptedHardwareCommands.isEmpty) + XCTAssertTrue(report.recorder.attemptedExternalMutations.isEmpty) + XCTAssertTrue(report.recorder.realControlPathConstructions.isEmpty) + } + func testWindowConfiguratorOverridesRestoredMainAndSettingsGeometry() throws { let root = fixtureRoot() defer { try? FileManager.default.removeItem(at: root) } @@ -1351,6 +1408,8 @@ final class ViftyReviewFixtureTests: XCTestCase { state: ViftyReviewFixtureState = .healthyAuto, surface: ViftyReviewFixtureSurface = .main, window: ViftyReviewFixtureWindow = .standard, + contrast: ViftyReviewFixtureContrast = .standard, + transparency: ViftyReviewFixtureTransparency = .standard, interaction: ViftyReviewFixtureInteraction = .none, expectedExecutableSHA256: String? = nil ) throws -> ViftyReviewFixtureRequest { @@ -1359,6 +1418,8 @@ final class ViftyReviewFixtureTests: XCTestCase { "--ui-review-fixture", state.rawValue, "--ui-review-surface", surface.rawValue, "--ui-review-window", window.rawValue, + "--ui-review-contrast", contrast.rawValue, + "--ui-review-transparency", transparency.rawValue, "--ui-review-interaction", interaction.rawValue, "--ui-review-capture-id", captureID, "--ui-review-output", root.path diff --git a/Tests/ViftyCoreTests/XPCAgentControlCodingTests.swift b/Tests/ViftyCoreTests/XPCAgentControlCodingTests.swift index 084b655e..cf738693 100644 --- a/Tests/ViftyCoreTests/XPCAgentControlCodingTests.swift +++ b/Tests/ViftyCoreTests/XPCAgentControlCodingTests.swift @@ -13,6 +13,12 @@ final class XPCAgentControlCodingTests: XCTestCase { func testStatusRoundTripsThroughNSDictionary() { let created = Date(timeIntervalSince1970: 1_000) + let health = AgentControlPersistenceHealth( + policyStatusAvailable: false, + policyError: "policy unreadable", + auditStatusAvailable: true, + auditError: nil + ) let status = AgentControlStatus( enabled: true, activeLease: AgentCoolingLease( @@ -24,7 +30,8 @@ final class XPCAgentControlCodingTests: XCTestCase { ), lastDecision: .denied(.prepareRateLimited, message: "Wait", retryAfterSeconds: 12), lastErrorCode: .prepareRateLimited, - policy: AgentControlPolicy(enabled: true, minimumAgentRPMPercent: 40, maximumAllowedRPMPercent: 75, maxDurationSeconds: 1_800, prepareCooldownSeconds: 12).snapshot + policy: AgentControlPolicy(enabled: true, minimumAgentRPMPercent: 40, maximumAllowedRPMPercent: 75, maxDurationSeconds: 1_800, prepareCooldownSeconds: 12).snapshot, + persistenceHealth: health ) let encoded = XPCAgentControlCoding.encode(status) @@ -35,6 +42,30 @@ final class XPCAgentControlCodingTests: XCTestCase { XCTAssertEqual(decoded?.policy?.maximumAllowedRPMPercent, 75) } + func testPersistenceFailureRoundTripsThroughJSONAndXPC() throws { + let status = AgentControlStatus( + enabled: false, + activeLease: nil, + lastDecision: .denied(.persistenceFailure, message: "policy unreadable"), + lastErrorCode: .persistenceFailure, + policy: AgentControlPolicy(enabled: false).snapshot, + persistenceHealth: AgentControlPersistenceHealth( + policyStatusAvailable: false, + policyError: "policy unreadable", + auditStatusAvailable: true, + auditError: nil + ) + ) + + let json = try JSONDecoder().decode( + AgentControlStatus.self, + from: JSONEncoder().encode(status) + ) + + XCTAssertEqual(json, status) + XCTAssertEqual(XPCAgentControlCoding.decodeStatus(XPCAgentControlCoding.encode(status)), status) + } + func testOlderStatusWithoutLeaseStillDecodes() { let dictionary: NSDictionary = ["enabled": true] @@ -44,6 +75,10 @@ final class XPCAgentControlCodingTests: XCTestCase { XCTAssertNil(decoded?.activeLease) XCTAssertNil(decoded?.lastDecision) XCTAssertNil(decoded?.policy) + XCTAssertEqual(decoded?.persistenceHealth.policyStatusAvailable, false) + XCTAssertEqual(decoded?.persistenceHealth.auditStatusAvailable, false) + XCTAssertEqual(decoded?.persistenceHealth.policyError, "Persistence health unavailable from older daemon response.") + XCTAssertEqual(decoded?.persistenceHealth.auditError, "Persistence health unavailable from older daemon response.") } func testAuditEventsRoundTripThroughNSDictionary() { diff --git a/docs/agent-workflows.md b/docs/agent-workflows.md index e4f42ab4..57b80001 100644 --- a/docs/agent-workflows.md +++ b/docs/agent-workflows.md @@ -67,6 +67,7 @@ Vifty never exposes raw SMC writes through `viftyctl`. Agents request intent: wo ## Commands ```sh +viftyctl help viftyctl status --json viftyctl capabilities --json viftyctl agent-rule --json diff --git a/docs/examples/viftyctl/status-active-lease.json b/docs/examples/viftyctl/status-active-lease.json index dd613909..3e5c55cd 100644 --- a/docs/examples/viftyctl/status-active-lease.json +++ b/docs/examples/viftyctl/status-active-lease.json @@ -38,6 +38,12 @@ "minimumAgentRPMPercent": 45, "prepareCooldownSeconds": 30 }, + "persistenceHealth": { + "auditError": null, + "auditStatusAvailable": true, + "policyError": null, + "policyStatusAvailable": true + }, "generatedAt": 700000900, "schemaID": "https://vifty.local/schemas/viftyctl-status.schema.json", "schemaVersion": 1 diff --git a/docs/images/vifty-screenshot.png b/docs/images/vifty-screenshot.png index 9700aea4..e08b1e3c 100644 Binary files a/docs/images/vifty-screenshot.png and b/docs/images/vifty-screenshot.png differ diff --git a/docs/schemas/viftyctl-command-error.schema.json b/docs/schemas/viftyctl-command-error.schema.json index b1f5edbf..1adba460 100644 --- a/docs/schemas/viftyctl-command-error.schema.json +++ b/docs/schemas/viftyctl-command-error.schema.json @@ -46,7 +46,8 @@ "INVALID_ARGUMENTS", "CHILD_COMMAND_FAILED", "PREPARE_RATE_LIMITED", - "RESTORE_REQUESTED" + "RESTORE_REQUESTED", + "PERSISTENCE_FAILURE" ] }, { diff --git a/docs/schemas/viftyctl-status.schema.json b/docs/schemas/viftyctl-status.schema.json index ff6a98db..b65eccb1 100644 --- a/docs/schemas/viftyctl-status.schema.json +++ b/docs/schemas/viftyctl-status.schema.json @@ -12,7 +12,8 @@ "activeLease", "lastDecision", "lastErrorCode", - "policy" + "policy", + "persistenceHealth" ], "properties": { "schemaVersion": { @@ -66,6 +67,9 @@ "type": "null" } ] + }, + "persistenceHealth": { + "$ref": "#/$defs/persistenceHealth" } }, "$defs": { @@ -94,7 +98,8 @@ "INVALID_ARGUMENTS", "CHILD_COMMAND_FAILED", "PREPARE_RATE_LIMITED", - "RESTORE_REQUESTED" + "RESTORE_REQUESTED", + "PERSISTENCE_FAILURE" ] }, "request": { @@ -241,6 +246,35 @@ } } }, + "persistenceHealth": { + "type": "object", + "required": [ + "policyStatusAvailable", + "policyError", + "auditStatusAvailable", + "auditError" + ], + "properties": { + "policyStatusAvailable": { + "type": "boolean" + }, + "policyError": { + "type": [ + "string", + "null" + ] + }, + "auditStatusAvailable": { + "type": "boolean" + }, + "auditError": { + "type": [ + "string", + "null" + ] + } + } + }, "fanRPMMap": { "type": "object", "additionalProperties": { diff --git a/docs/superpowers/plans/2026-09-06-vifty-stage-1-safety-persistence.md b/docs/superpowers/plans/2026-09-06-vifty-stage-1-safety-persistence.md new file mode 100644 index 00000000..f1f3aa52 --- /dev/null +++ b/docs/superpowers/plans/2026-09-06-vifty-stage-1-safety-persistence.md @@ -0,0 +1,294 @@ +# Vifty Stage 1 Safety and Persistence Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make agent policy, application preferences, and helper lifecycle execution fail closed, durable, recoverable, and truthful. + +**Architecture:** Add one small status value for daemon persistence health, keep policy mutations inside `AgentControlService`, copy the proven `CurveProfileStore` recovery sequence into the app-preference store, and drain lifecycle process output concurrently with fixed byte limits. Do not create a general persistence framework or alter fan mutation paths. + +**Tech Stack:** Swift 6, Foundation, Swift Concurrency, XCTest, Swift Package Manager, macOS `Process` and `Pipe`. + +**Spec:** `docs/superpowers/specs/2026-09-06-vifty-audit-remediation-design.md` + +## Global Constraints + +- macOS 15 minimum; no third-party dependency. +- No fan command, cooling lease, helper maintenance, `sudo`, install, Auto restoration, or release mutation during verification. +- Preserve all daemon/XPC/SMC/ownership/journal/readback gates. +- Use `.build` as the Swift scratch path and stop if `/System/Volumes/Data` has less than 30 GiB free. + +--- + +### Task 1: Define the persistence-health contract + +**Files:** +- Modify: `Sources/ViftyCore/AgentControlModels.swift` +- Modify: `Sources/ViftyCore/ViftyDaemonProtocol.swift` +- Modify: `Sources/ViftyCore/ViftyCtlRunner.swift` +- Modify: `docs/schemas/viftyctl-status.schema.json` +- Modify: `docs/schemas/viftyctl-command-error.schema.json` +- Modify: `docs/examples/viftyctl/status-active-lease.json` +- Test: `Tests/ViftyCoreTests/XPCAgentControlCodingTests.swift` +- Test: `Tests/ViftyCoreTests/ViftyCtlJSONExampleTests.swift` + +**Interfaces:** +- Produces: `AgentControlPersistenceHealth(policyStatusAvailable:policyError:auditStatusAvailable:auditError:)`. +- Produces: non-optional `AgentControlStatus.persistenceHealth`, defaulting to `.healthy` for source call-site compatibility. +- XPC decode treats a missing persistence dictionary as unavailable rather than healthy. + +- [ ] **Step 1: Add failing model and XPC round-trip tests** + +```swift +let health = AgentControlPersistenceHealth( + policyStatusAvailable: false, + policyError: "policy unreadable", + auditStatusAvailable: true, + auditError: nil +) +let status = AgentControlStatus( + enabled: false, + activeLease: nil, + lastDecision: nil, + lastErrorCode: .persistenceFailure, + persistenceHealth: health +) +XCTAssertEqual(XPCAgentControlCoding.decodeStatus(XPCAgentControlCoding.encode(status)), status) +``` + +- [ ] **Step 2: Run the focused tests and verify the missing-type failure** + +Run: `swift test --scratch-path "$PWD/.build" --filter XPCAgentControlCodingTests` + +Expected: compilation fails because `AgentControlPersistenceHealth` and `.persistenceFailure` do not exist. + +- [ ] **Step 3: Add the minimal model** + +```swift +public struct AgentControlPersistenceHealth: Codable, Equatable, Sendable { + public var policyStatusAvailable: Bool + public var policyError: String? + public var auditStatusAvailable: Bool + public var auditError: String? + + public static let healthy = Self( + policyStatusAvailable: true, + policyError: nil, + auditStatusAvailable: true, + auditError: nil + ) +} +``` + +Add `case persistenceFailure = "PERSISTENCE_FAILURE"` to `AgentControlErrorCode` and map it to the existing `.runDiagnose` command-error recovery action. Encode all four persistence fields through JSON and XPC; decode absent XPC persistence data as both statuses unavailable with a bounded compatibility message. In `capabilitiesReport()`, require both `status.policy != nil` and `status.persistenceHealth.policyStatusAvailable` before setting `policyStatusAvailable: true`; otherwise return the disabled fallback policy and the bounded policy persistence message without claiming the daemon itself is unreachable. + +- [ ] **Step 4: Update the strict status schema and schema tests** + +Require `persistenceHealth`; require both Boolean availability fields; allow both error fields to be string or null. Add `PERSISTENCE_FAILURE` to every schema/test enum for `AgentControlErrorCode`, including the command-error schema. Update canonical status fixture builders to emit `.healthy` without changing unrelated fields. + +- [ ] **Step 5: Run contract tests** + +Run: `swift test --scratch-path "$PWD/.build" --filter 'XPCAgentControlCodingTests|ViftyCtlJSONExampleTests'` + +Expected: PASS. + +- [ ] **Step 6: Commit** + +```bash +git add Sources/ViftyCore/AgentControlModels.swift Sources/ViftyCore/ViftyDaemonProtocol.swift Sources/ViftyCore/ViftyCtlRunner.swift docs/schemas/viftyctl-status.schema.json docs/schemas/viftyctl-command-error.schema.json docs/examples/viftyctl/status-active-lease.json Tests/ViftyCoreTests/XPCAgentControlCodingTests.swift Tests/ViftyCoreTests/ViftyCtlJSONExampleTests.swift +git commit -m "feat: expose agent persistence health" +``` + +### Task 2: Fail closed and make policy toggles transactional + +**Files:** +- Modify: `Sources/ViftyCore/AgentControlService.swift` +- Test: `Tests/ViftyCoreTests/AgentControlServiceTests.swift` + +**Interfaces:** +- Consumes: `AgentControlPersistenceHealth` and `.persistenceFailure` from Task 1. +- Produces: policy prepare gate and transactional `setPolicyEnabled(_:)` behavior. + +- [ ] **Step 1: Add one fault-injecting store to the existing test support** + +```swift +private enum AgentControlFault: Error { case loadPolicy, savePolicy } + +private final class AgentControlFaultStore: AgentControlPersisting, @unchecked Sendable { + let base: AgentControlStore + var loadedPolicy: Result = .success(nil) + var savePolicyError: AgentControlFault? + var savedPolicies: [Bool] = [] + + func saveActiveLease(_ lease: AgentCoolingLease?) throws { try base.saveActiveLease(lease) } + func loadActiveLease() throws -> AgentCoolingLease? { try base.loadActiveLease() } + func appendAuditEvent(_ event: AgentControlAuditEvent) throws { try base.appendAuditEvent(event) } + func loadRecentAuditEvents(limit: Int) throws -> [AgentControlAuditEvent] { + try base.loadRecentAuditEvents(limit: limit) + } + func loadAgentControlEnabled() throws -> Bool? { try loadedPolicy.get() } + func saveAgentControlEnabled(_ enabled: Bool) throws { + if let savePolicyError { throw savePolicyError } + savedPolicies.append(enabled) + try base.saveAgentControlEnabled(enabled) + } +} +``` + +Add tests for stored true, stored false, absent, load failure, failed enable save, failed disable save, and successful retry. For load failure assert `enabled == false`, `policyStatusAvailable == false`, error code `.persistenceFailure`, and prepare denial before `hardware.apply`. + +- [ ] **Step 2: Run tests and verify failure on current behavior** + +Run: `swift test --scratch-path "$PWD/.build" --filter AgentControlServiceTests` + +Expected: new load-failure test observes enabled policy or missing health; mutation tests observe memory/disk disagreement. + +- [ ] **Step 3: Separate lease and policy load errors in initialization** + +Use an explicit `do/catch` for `loadAgentControlEnabled()`. On catch set `policy.enabled = false`, set `policyStatusAvailable = false`, and retain a bounded localized message. Do not add the policy error to `persistenceLoadErrorMessage`, because that lease error intentionally triggers Auto recovery. + +- [ ] **Step 4: Gate prepare and reorder durable mutation** + +At the top of `prepare`, deny with `.persistenceFailure` while policy status is unavailable. In `setPolicyEnabled`, perform active-lease Auto restoration first when disabling, then call `saveAgentControlEnabled(enabled)`, and only then assign `policy.enabled = enabled`. On failure retain the pre-call policy value, publish the typed health error, and rethrow. + +- [ ] **Step 5: Run service and store tests** + +Run: `swift test --scratch-path "$PWD/.build" --filter 'AgentControlServiceTests|AgentControlStoreTests'` + +Expected: PASS with no hardware apply in persistence-failure cases. + +- [ ] **Step 6: Commit** + +```bash +git add Sources/ViftyCore/AgentControlService.swift Tests/ViftyCoreTests/AgentControlServiceTests.swift +git commit -m "fix: fail closed on agent policy persistence errors" +``` + +### Task 3: Recover app preferences and surface failed saves + +**Files:** +- Modify: `Sources/Vifty/AppPreferencesStore.swift` +- Modify: `Sources/Vifty/AppModel.swift` +- Modify: `Sources/Vifty/AppModel+MenuBar.swift` +- Modify: `Sources/Vifty/SettingsGeneralView.swift` +- Test: `Tests/ViftyCoreTests/AppModelPreferencesTests.swift` +- Test: `Tests/ViftyCoreTests/AppSourceRegressionTests.swift` + +**Interfaces:** +- Produces: `AppPreferencesLoadResult(preferences:recoveryMessage:)`. +- Produces: `AppModel.appPreferencesPersistenceMessage` and `retryAppPreferencesSave()`. + +- [ ] **Step 1: Add failing backup and write-error tests** + +Create private `0700` test directories and `0600` files. Assert that a corrupt primary plus valid backup returns the backup, does not mutate the backup, and reports recovery. Assert that an unwritable/invalid destination sets `appPreferencesPersistenceMessage`, while a later valid retry clears it. + +- [ ] **Step 2: Run the focused tests and capture the failures** + +Run: `swift test --scratch-path "$PWD/.build" --filter AppModelPreferencesTests` + +Expected: backup is replaced by corrupt bytes and save failure remains invisible. + +- [ ] **Step 3: Implement the store recovery sequence** + +```swift +struct AppPreferencesLoadResult: Equatable { + var preferences: AppPreferences + var recoveryMessage: String? +} +``` + +Add `loadResult() throws -> AppPreferencesLoadResult`. Decode primary, then backup. Preserve a valid primary to `.bak` before replacement. Never delete `.bak` during corrupt-primary handling. Use same-directory atomic writes and reapply `0700`/`0600` permissions. Keep `load()` as a test/compatibility convenience returning `(try? loadResult().preferences) ?? migratedPreferences()`; production initialization uses `loadResult()`. + +- [ ] **Step 4: Route throwing saves through AppModel** + +Replace `preferencesStore.save(...)` with `do { try saveThrowing; message = nil } catch { message = "Settings were not saved: …" }`. Add `retryAppPreferencesSave()` that calls the same single persistence function. Avoid rollback and recursive `didSet` writes. + +- [ ] **Step 5: Present the error and retry in General settings** + +Use a compact native `Label` plus `Button("Retry Save")` in `SettingsGeneralView`; give both stable accessibility text. Do not mix this message into fan-control `lastError`. + +- [ ] **Step 6: Run focused UI/model tests** + +Run: `swift test --scratch-path "$PWD/.build" --filter 'AppModelPreferencesTests|AppSourceRegressionTests'` + +Expected: PASS; no test hardware command recorded. + +- [ ] **Step 7: Commit** + +```bash +git add Sources/Vifty/AppPreferencesStore.swift Sources/Vifty/AppModel.swift Sources/Vifty/AppModel+MenuBar.swift Sources/Vifty/SettingsGeneralView.swift Tests/ViftyCoreTests/AppModelPreferencesTests.swift Tests/ViftyCoreTests/AppSourceRegressionTests.swift +git commit -m "fix: make app preferences recoverable and truthful" +``` + +### Task 4: Drain helper lifecycle output without deadlock + +**Files:** +- Modify: `Sources/Vifty/DaemonInstallService.swift` +- Test: `Tests/ViftyCoreTests/DaemonInstallServiceTests.swift` + +**Interfaces:** +- Produces: bounded concurrent stdout/stderr capture inside `DaemonInstallProcessRunner.system`. + +- [ ] **Step 1: Add the real high-output regression** + +Create a temporary executable shell script that reads stdin, writes 256 KiB to stdout and 256 KiB to stderr, and exits 0. Invoke `DaemonInstallProcessRunner.system.run` under a two-second XCTest timeout and assert exit 0 plus captured lengths no greater than a named per-stream limit. + +- [ ] **Step 2: Run the test against current code** + +Run: `swift test --scratch-path "$PWD/.build" --filter DaemonInstallServiceTests/testSystemRunnerDrainsLargeOutputWithoutDeadlock` + +Expected: timeout/failure because the child fills a pipe before `waitUntilExit` returns. + +- [ ] **Step 3: Add a bounded collector and drain both handles concurrently** + +```swift +private actor BoundedProcessOutput { + static let maximumBytesPerStream = 64 * 1_024 + private var data = Data() + func append(_ chunk: Data) { + guard data.count < Self.maximumBytesPerStream else { return } + data.append(chunk.prefix(Self.maximumBytesPerStream - data.count)) + } +} +``` + +Start one task per read handle before writing stdin; read until EOF, retaining only the bound. Wait for process termination and both reader tasks. Close handles on every error path. Keep environment and exit mapping unchanged. + +- [ ] **Step 4: Run lifecycle tests repeatedly** + +Run: `for i in 1 2 3; do swift test --scratch-path "$PWD/.build" --filter DaemonInstallServiceTests || exit 1; done` + +Expected: all three runs PASS without a hang. + +- [ ] **Step 5: Commit** + +```bash +git add Sources/Vifty/DaemonInstallService.swift Tests/ViftyCoreTests/DaemonInstallServiceTests.swift +git commit -m "fix: drain helper lifecycle output concurrently" +``` + +### Task 5: Stage 1 verification checkpoint + +**Files:** +- Verify only; modify a file only to fix a Stage 1 regression. + +- [ ] **Step 1: Check disk and diff hygiene** + +Run: `df -h /System/Volumes/Data && git diff --check` + +Expected: at least 30 GiB free and exit 0. + +- [ ] **Step 2: Run the fast gate** + +Run: `make test-fast` + +Expected: all fast XCTest cases pass. + +- [ ] **Step 3: Run the production build gate** + +Run: `swift build --scratch-path "$PWD/.build" -Xswiftc -warnings-as-errors` + +Expected: exit 0. + +- [ ] **Step 4: Review Stage 1 before Stage 2** + +Inspect `git log --oneline` and `git diff 94ae958..HEAD`. Confirm no SMC, XPC authorization, release, or helper-maintenance script weakening. Record `94ae958` and the exact Stage 1 head SHA in the review message; do not create a review-only commit. diff --git a/docs/superpowers/plans/2026-09-06-vifty-stage-2-operational-truth.md b/docs/superpowers/plans/2026-09-06-vifty-stage-2-operational-truth.md new file mode 100644 index 00000000..58e878f4 --- /dev/null +++ b/docs/superpowers/plans/2026-09-06-vifty-stage-2-operational-truth.md @@ -0,0 +1,220 @@ +# Vifty Stage 2 Operational Truth Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make hardware readiness, audit persistence, CLI discovery, and compiler gates report exactly what is known. + +**Architecture:** Reuse Stage 1's persistence-health object, pass snapshot availability into the existing readiness builder, add one pure help command, and put warnings-as-errors directly on the two existing test targets. Preserve JSON field names and exit codes unless the schema is explicitly advanced in the same commit. + +**Tech Stack:** Swift 6, Foundation, XCTest, JSON Schema fixtures, Make, Swift Package Manager. + +**Spec:** `docs/superpowers/specs/2026-09-06-vifty-audit-remediation-design.md` + +## Global Constraints + +- Stage 1 must be approved and green first. +- No third-party dependency or privileged/hardware operation. +- Preserve `policyStatusAvailable`, `safeToRequestCooling`, blocker IDs, recovery actions, and exit-code contracts. + +--- + +### Task 1: Distinguish unavailable from unsupported hardware + +**Files:** +- Modify: `Sources/ViftyCore/AgentDiagnostics.swift` +- Test: `Tests/ViftyCoreTests/ViftyCtlRunnerTests.swift` +- Test: `Tests/ViftyCoreTests/ViftyCtlJSONExampleTests.swift` + +**Interfaces:** +- Produces: `supportedHardwareCheck(_:snapshotError:)` with supported, unsupported, and unavailable copy. + +- [ ] **Step 1: Add failing diagnostic cases** + +For a snapshot error, assert the `supportedHardware` message contains “could not be determined” and does not contain “supported only”. For a successful Intel/non-MacBook snapshot, retain the unsupported message and blocker. + +- [ ] **Step 2: Run the focused failure** + +Run: `swift test --scratch-path "$PWD/.build" --filter 'ViftyCtlRunnerTests|ViftyCtlJSONExampleTests'` + +Expected: unavailable case is mislabeled unsupported. + +- [ ] **Step 3: Pass the existing error into the check** + +```swift +private static func supportedHardwareCheck( + _ snapshot: HardwareSnapshot, + snapshotError: String? +) -> ViftyCtlReadinessCheck +``` + +If `snapshotError != nil`, return `passed: false` with unavailable copy. Otherwise evaluate the two hardware booleans exactly as today. Keep the stable check ID so existing agent blocker processing remains compatible. + +- [ ] **Step 4: Run tests and commit** + +Run: `swift test --scratch-path "$PWD/.build" --filter 'ViftyCtlRunnerTests|ViftyCtlJSONExampleTests'` + +```bash +git add Sources/ViftyCore/AgentDiagnostics.swift Tests/ViftyCoreTests/ViftyCtlRunnerTests.swift Tests/ViftyCoreTests/ViftyCtlJSONExampleTests.swift +git commit -m "fix: report unavailable hardware truthfully" +``` + +### Task 2: Surface audit persistence health + +**Files:** +- Modify: `Sources/ViftyCore/AgentControlService.swift` +- Modify: `Sources/ViftyCore/ViftyCoreLog.swift` +- Modify: `Sources/ViftyCore/AgentDiagnostics.swift` +- Modify: `Sources/Vifty/AppModel+Control.swift` +- Modify: `Sources/Vifty/SettingsAgentWorkflowView.swift` +- Test: `Tests/ViftyCoreTests/AgentControlServiceTests.swift` +- Test: `Tests/ViftyCoreTests/ViftyCtlRunnerTests.swift` +- Test: `Tests/ViftyCoreTests/AppModelFanControlTests.swift` +- Test: `Tests/ViftyCoreTests/AppSourceRegressionTests.swift` + +**Interfaces:** +- Consumes: `AgentControlStatus.persistenceHealth` from Stage 1. +- Produces: last audit append failure and later recovery without changing fan transaction results. + +- [ ] **Step 1: Extend the Stage 1 fault store and add red tests** + +Make `appendAuditEvent` fail once, then succeed. Assert the first status has `auditStatusAvailable == false`, the next successful append restores it to true, and a successful mocked fan transaction still returns success. + +- [ ] **Step 2: Run service tests and verify health remains falsely green** + +Run: `swift test --scratch-path "$PWD/.build" --filter AgentControlServiceTests` + +- [ ] **Step 3: Replace swallowed append with bounded health state** + +```swift +private func appendAudit(...) { + do { + try store.appendAuditEvent(event) + auditPersistenceErrorMessage = nil + } catch { + auditPersistenceErrorMessage = String(error.localizedDescription.prefix(512)) + ViftyCoreLog.agentControl.error("Agent audit persistence failed") + } +} +``` + +Add `static let agentControl = Logger(subsystem: "tech.reidar.vifty", category: "AgentControl")` beside the existing core XPC logger. Build `status().persistenceHealth` from policy and audit health. Do not throw from `appendAudit` and do not replace a fan/restore `lastErrorCode`. + +- [ ] **Step 4: Add diagnostic and UI presentation** + +Keep the existing readiness-check ID set stable. Diagnose and status already embed `AgentControlStatus`, so expose audit health through its `persistenceHealth` object and surface concise attention copy in the existing agent settings/status area. Do not let audit availability change `safeToRequestCooling`, authorize cooling, reverse fan control, or overwrite a higher-priority fan/restore error. + +- [ ] **Step 5: Run focused tests and commit** + +Run: `swift test --scratch-path "$PWD/.build" --filter 'AgentControlServiceTests|ViftyCtlRunnerTests|AppModelFanControlTests|AppSourceRegressionTests'` + +```bash +git add Sources/ViftyCore/AgentControlService.swift Sources/ViftyCore/ViftyCoreLog.swift Sources/ViftyCore/AgentDiagnostics.swift Sources/Vifty/AppModel+Control.swift Sources/Vifty/SettingsAgentWorkflowView.swift Tests/ViftyCoreTests/AgentControlServiceTests.swift Tests/ViftyCoreTests/ViftyCtlRunnerTests.swift Tests/ViftyCoreTests/AppModelFanControlTests.swift Tests/ViftyCoreTests/AppSourceRegressionTests.swift +git commit -m "fix: expose agent audit persistence failures" +``` + +### Task 3: Add deterministic CLI help + +**Files:** +- Modify: `Sources/ViftyCore/ViftyCtlArguments.swift` +- Modify: `Sources/ViftyCore/ViftyCtlRunner.swift` +- Modify: `docs/agent-workflows.md` +- Test: `Tests/ViftyCoreTests/ViftyCtlArgumentsTests.swift` +- Test: `Tests/ViftyCoreTests/ViftyCtlRunnerTests.swift` + +**Interfaces:** +- Produces: `ViftyCtlCommand.help` and `ViftyCtlArguments.usage`. + +- [ ] **Step 1: Add parser and output tests** + +Assert `help`, `--help`, and `-h` parse to `.help`; running `.help` returns exit 0 and identical text ending in a newline. Assert `frobnicate` still throws `.unknownCommand`. + +- [ ] **Step 2: Run the red tests** + +Run: `swift test --scratch-path "$PWD/.build" --filter 'ViftyCtlArgumentsTests|ViftyCtlRunnerTests'` + +- [ ] **Step 3: Add the command and one usage string** + +```swift +public static let usage = """ +Usage: viftyctl [options] +Commands: status, capabilities, agent-rule, diagnose, audit, prepare, restore-auto, run +Run 'viftyctl agent-rule' for the guarded agent workflow. +""" +``` + +Handle aliases in `parse`, return the usage from `ViftyCtlRunner`, add `.help` to command-name/JSON switches, and keep helper-maintenance commands out of public help. + +- [ ] **Step 4: Verify the built executable** + +Run: `bin_dir="$(swift build --scratch-path "$PWD/.build" --show-bin-path)" && for arg in help --help -h; do "$bin_dir/viftyctl" "$arg"; done` + +Expected: three identical outputs and three zero exits. + +- [ ] **Step 5: Commit** + +```bash +git add Sources/ViftyCore/ViftyCtlArguments.swift Sources/ViftyCore/ViftyCtlRunner.swift docs/agent-workflows.md Tests/ViftyCoreTests/ViftyCtlArgumentsTests.swift Tests/ViftyCoreTests/ViftyCtlRunnerTests.swift +git commit -m "feat: add viftyctl help" +``` + +### Task 4: Enforce warning-free test compilation + +**Files:** +- Modify: `Tests/ViftyCoreTests/AgentControlServiceTests.swift` +- Modify: `Makefile` +- Modify: `Tests/ViftyCoreTests/MakefileTrustGateTests.swift` + +**Interfaces:** +- Produces: `SWIFT_TEST_WARNING_ARGS = -Xswiftc -warnings-as-errors` used by both test targets. + +- [ ] **Step 1: Pin the intended Makefile contract in a failing test** + +Assert both `swift test` invocations include `$(SWIFT_TEST_WARNING_ARGS)` and the variable equals `-Xswiftc -warnings-as-errors`. + +- [ ] **Step 2: Run the trust-gate test** + +Run: `swift test --scratch-path "$PWD/.build" --filter MakefileTrustGateTests` + +Expected: FAIL because the argument is absent. + +- [ ] **Step 3: Remove the known warning and wire the flag once** + +Change `let status = try await service.status()` to `let status = await service.status()`. Add the named Make variable and use it in `test-fast` and `test-full`; do not duplicate a second test target. + +- [ ] **Step 4: Run the warning gate** + +Run: `make test-fast` + +Expected: exit 0 with test sources compiled under warnings-as-errors. + +- [ ] **Step 5: Commit** + +```bash +git add Tests/ViftyCoreTests/AgentControlServiceTests.swift Makefile Tests/ViftyCoreTests/MakefileTrustGateTests.swift +git commit -m "test: reject Swift test warnings" +``` + +### Task 5: Stage 2 verification checkpoint + +**Files:** +- Verify schemas, fixtures, docs, and bundles. + +- [ ] **Step 1: Run diff and JSON checks** + +Run: `git diff --check && plutil -lint Resources/Info.plist` + +- [ ] **Step 2: Run the fast trust gate** + +Run: `make verify` + +Expected: tests, warnings-as-errors build, bundle, plist, codesign, schema resources, and identifiers pass. + +- [ ] **Step 3: Inspect CLI compatibility** + +Run: `stage2_tmp="$(mktemp -d)"; trap 'rm -rf "$stage2_tmp"' EXIT; .build/Vifty.app/Contents/MacOS/viftyctl --help && .build/Vifty.app/Contents/MacOS/viftyctl diagnose --json >"$stage2_tmp/diagnose.json"; status=$?; /usr/bin/python3 -m json.tool "$stage2_tmp/diagnose.json" >/dev/null; test "$status" -eq 0 -o "$status" -eq 75` + +Expected: help exits 0; diagnose is parseable and either ready or safely blocked. Do not run recovery commands. + +- [ ] **Step 4: Review Stage 2 before UI work** + +Review the Stage 2 commit range and confirm stable existing JSON keys and exit codes. Record any schema addition explicitly; do not begin Stage 3 with a failing gate. diff --git a/docs/superpowers/plans/2026-09-06-vifty-stage-3-ui-evidence-polish.md b/docs/superpowers/plans/2026-09-06-vifty-stage-3-ui-evidence-polish.md new file mode 100644 index 00000000..74b4f887 --- /dev/null +++ b/docs/superpowers/plans/2026-09-06-vifty-stage-3-ui-evidence-polish.md @@ -0,0 +1,270 @@ +# Vifty Stage 3 UI Evidence and Polish Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Restore exact-source UI capture readiness, complete the declared evidence matrix, and apply only current-evidence-backed polish. + +**Architecture:** Fix the existing SwiftUI/AppKit observation bridge at its root, retain the current 50-row provenance-bound harness, and keep visual changes downstream of accepted captures. Use native SwiftUI and existing tests; add no snapshot framework. + +**Tech Stack:** SwiftUI, AppKit, XCTest, Ruby orchestration, macOS Accessibility APIs, existing UI evidence schemas and Make targets. + +**Spec:** `docs/superpowers/specs/2026-09-06-vifty-audit-remediation-design.md` + +## Global Constraints + +- Stages 1 and 2 must be approved and green. +- Standard captures require at least 30 GiB free and a clean exact Git tree. +- Preserve `modelStartSkipped=true`, inert dependencies, and zero hardware/helper mutations. +- Historical checkpoint `6ac429cbacf7cc3358c74493ab7461a43fa40275` is not current evidence. +- Human visual and VoiceOver claims require actual current observations; automated AX is not VoiceOver evidence. + +--- + +### Task 1: Make the observation bridge reliably schedule readiness + +**Files:** +- Modify: `Sources/Vifty/ViftyReviewFixture.swift` +- Test: `Tests/ViftyCoreTests/ViftyReviewFixtureTests.swift` +- Test: `Tests/ViftyCoreTests/UIReviewEvidenceScriptTests.swift` + +**Interfaces:** +- Produces: observation scheduling from both `makeNSView` and later updates, still requiring two stable samples. + +- [ ] **Step 1: Add a hosted-window regression** + +Create an `NSWindow` with an `NSHostingView` containing `ViftyReviewFixtureSceneHost`, order it front, and wait for `runtime.hasReadyObservation`. Do not call `recordObservation` directly. Assert the ready report appears before the test deadline and records no mutations. + +- [ ] **Step 2: Run the regression against current source** + +Run: `swift test --scratch-path "$PWD/.build" --filter ViftyReviewFixtureTests/testHostedSceneSchedulesObservationAfterPreparation` + +Expected: timeout because a newly created observation representable is not guaranteed an update callback after insertion. + +If this test reaches ready on unchanged source, do not apply Step 3. Preserve that evidence, rerun the exact product capture once in the same interactive WindowServer session, and compare lifecycle timing. A non-reproducing unit/hosted test means the current timeout is environmental or occurs outside this bridge; revise this task from observed evidence before editing production fixture code. + +- [ ] **Step 3: Schedule from creation as well as update** + +```swift +func makeNSView(context: Context) -> ViftyReviewFixtureWindowObserverView { + let view = ViftyReviewFixtureWindowObserverView(frame: .zero) + configure(view) + view.scheduleObservationPair() + return view +} +``` + +Keep `viewDidMoveToWindow`, `layout`, the update call, and `observationPairScheduled`; nil-window emissions remain harmless and later lifecycle callbacks reschedule. + +- [ ] **Step 4: Run Swift and orchestrator regression tests** + +Run: `swift test --scratch-path "$PWD/.build" --filter 'ViftyReviewFixtureTests|UIReviewEvidenceScriptTests'` + +Expected: PASS, including structured timeout behavior for intentionally non-ready fixtures. + +- [ ] **Step 5: Commit** + +```bash +git add Sources/Vifty/ViftyReviewFixture.swift Tests/ViftyCoreTests/ViftyReviewFixtureTests.swift Tests/ViftyCoreTests/UIReviewEvidenceScriptTests.swift +git commit -m "fix: schedule UI fixture observation on creation" +``` + +### Task 2: Prove one real exact-source capture before matrix work + +**Files:** +- Generated ignored evidence only under `.build/ui-review-evidence/`. +- Do not modify the committed manifest in this task. + +- [ ] **Step 1: Verify disk, clean tree, and host settings** + +Run: `df -h /System/Volumes/Data && test -z "$(git status --porcelain --untracked-files=all)"` + +Expected: at least 30 GiB free and clean tree. Standard rows require Increase Contrast and Reduce Transparency off; inspect them manually/read-only per `docs/ui-review/README.md`. + +- [ ] **Step 2: Build exact products and initialize the local ledger** + +Run: `make ui-review-start-session` + +Expected: one provenance-bound debug app, release exclusion binary, AX collector, and ignored local ledger for current `HEAD`. + +- [ ] **Step 3: Capture the previously failing row** + +Run the documented `--capture --row-kind visual --row-id main-1180x820-light` command with five-second readiness and 120-second hold, then seal its returned capture ID. + +Expected: capture and seal exit 0; final fixture report has `phase: final`, `passed: true`, `modelStartSkipped: true`, and empty mutation arrays. + +- [ ] **Step 4: Inspect the immutable PNG** + +Open the sealed screenshot, confirm it is the requested Vifty window rather than transparent/solid/launcher content, and record any visual defect separately. Do not patch UI in this task. + +- [ ] **Step 5: Commit only a checkpoint note if source documentation requires it** + +Normally make no commit: ignored evidence is the deliverable. If a source change was required after Task 1, return to Task 1 and repeat its red/green cycle instead of committing an unreviewed capture workaround. + +### Task 3: Recapture and verify the automated 50-row matrix + +**Files:** +- Generated: ignored `.build/ui-review-evidence/**` +- Generated: ignored `docs/ui-review/evidence-manifest.local.json` +- Modify after automated pass only: `docs/ui-review/automated-checkpoint.json` +- Modify after automated pass only: `docs/images/vifty-screenshot.png` + +**Interfaces:** +- Consumes: nine fixture, 28 visual, and 13 AX requests from `scripts/lib/ui_review_contract.rb`. +- Produces: exact-current-source automated checkpoint and canonical hero. + +- [ ] **Step 1: Capture the nine fixture rows** + +Use the documented capture and seal commands for every exact state emitted by: + +```bash +jq -r '.fixtureReports[].state' docs/ui-review/evidence-manifest.json +``` + +For each emitted value, pass it unchanged to `--row-id`, extract `captureID` from the returned JSON with `/usr/bin/ruby -rjson`, and pass that exact ID to `--seal`. Stop on the first nonzero capture or seal result. + +- [ ] **Step 2: Capture the standard visual rows** + +Capture and seal every standard light/dark, size, state, Settings, popover, and accessibility-text row listed by `jq -r '.visualCells[].id'`. Keep the two system-setting rows pending until their settings are explicitly arranged. + +- [ ] **Step 3: Capture and collect automated AX rows** + +For each `jq -r '.accessibilityChecks[].id'` row, launch with a 300-second hold, run `--collect-ax` with the exact collector, and seal. Do not activate inspect-only controls. + +- [ ] **Step 4: Capture the two explicit system-setting rows** + +With owner-controlled settings, capture Reduce Transparency under standard contrast, then Increase Contrast with macOS-implied reduced transparency. Restore the owner's preferred settings afterward and record only observed readback. + +- [ ] **Step 5: Verify automated evidence** + +Run: `make ui-review-verify-automated` + +Expected: 9/9 fixture, 28/28 visual, and 13/13 AX rows pass with zero mutation aggregates. + +- [ ] **Step 6: Publish the portable checkpoint** + +Run: `UI_REVIEW_SOURCE_COMMIT="$(git rev-parse HEAD)" make ui-review-write-checkpoint` + +Expected: path-free checkpoint bound to current source/products and canonical hero. + +- [ ] **Step 7: Commit checkpoint artifacts** + +```bash +git add docs/ui-review/automated-checkpoint.json docs/images/vifty-screenshot.png +git commit -m "docs: refresh current Vifty UI evidence checkpoint" +``` + +### Task 4: Conduct human review and create file-specific fix plans + +**Files:** +- Update: `/Users/reidar/Obsidian/Hermes/Hermes/Personal/Projects/Vifty/Vifty.md` +- Create when findings exist: one dated design/implementation-plan pair per coherent validated UI problem. +- Generated: visual and VoiceOver attestations when actually completed. + +**Interfaces:** +- Produces: a ranked finding ledger containing row ID, capture ID, observed defect, expected behavior, owning file, and acceptance check. +- Produces: concrete file-specific plans before any UI production edit. + +- [ ] **Step 1: Review every sealed visual row** + +Check clipping, overlap, truncation, contrast, hierarchy, spacing, state truth, and compact-scroll reachability. Record “no issue” or one concrete finding per row; do not infer defects from source alone. + +- [ ] **Step 2: Perform the VoiceOver session if the owner authorizes it** + +Use the exact seven scripted steps and row subsets in `docs/ui-review/README.md`. If not authorized, preserve `skipped-by-owner` and make no VoiceOver claim. + +- [ ] **Step 3: Write the durable current-review ledger** + +In the Vifty Obsidian project note, record all 28 visual rows and 13 AX rows with exact row ID, capture ID, disposition (`no-issue` or `validated-finding`), observation, and evidence hash. For every validated finding also record severity, owning source path, focused test path, and affected recapture row IDs. Do not write production code or tracked repository documentation in this step, because either would invalidate the exact-source capture set. + +- [ ] **Step 4: Verify the review did not invalidate source provenance** + +```bash +test -z "$(git status --porcelain --untracked-files=all)" +make ui-review-verify-automated +``` + +- [ ] **Step 5: Create concrete follow-on plans for validated findings** + +If the ledger contains no validated finding, record that Stage 3 needs no UI production edit. Otherwise, group only findings that share one owning presentation/layout path, run the brainstorming approval gate, and create dated file-specific plans under `docs/superpowers/plans/`. Each follow-on plan names exact Swift files, exact focused tests, exact failing assertion, exact affected capture rows, and its commit message before implementation begins. + +- [ ] **Step 6: Execute approved follow-on plans and recapture affected rows** + +Each approved fix must pass its red/green test and replace every stale affected capture. Re-run `make ui-review-verify-automated`; update human visual bindings only after inspecting the new immutable PNG. Do not proceed on an unapproved or non-file-specific fix plan. + +### Task 5: Reduce harness duplication only after stability + +**Files:** +- Modify if duplication is proven: `Tests/ViftyCoreTests/UIReviewEvidenceScriptTests.swift` +- Modify if duplication is proven: `scripts/lib/ui_review_contract.rb` +- Do not modify: verifier safety, provenance, sealing, schemas, or path-containment code. + +- [ ] **Step 1: Count repeated request/expectation blocks** + +Run: `rg -n 'main-1180x820|settings-general|accessibility-text' Tests/ViftyCoreTests/UIReviewEvidenceScriptTests.swift scripts/lib/ui_review_contract.rb` + +Identify only byte-structurally repeated cases already represented by the canonical request table. + +- [ ] **Step 2: Add one parameterized test loop alongside existing cases** + +Keep the old cases temporarily, run the new loop, and prove identical row IDs and expected request dictionaries. + +- [ ] **Step 3: Delete only superseded duplicated cases** + +Run `git diff --stat` and require net line deletion. Keep explicit adversarial security/path/provenance tests separate. + +- [ ] **Step 4: Run all UI evidence suites** + +Run: `swift test --scratch-path "$PWD/.build" --filter UIReviewEvidenceScriptTests && make ui-review-ruby-tests` + +Expected: identical required matrix coverage and all tests pass. + +- [ ] **Step 5: Commit only if the result is smaller** + +```bash +git add Tests/ViftyCoreTests/UIReviewEvidenceScriptTests.swift scripts/lib/ui_review_contract.rb +git commit -m "test: deduplicate UI evidence matrix cases" +``` + +Skip this commit entirely if the change is not a net deletion or weakens explicit adversarial coverage. + +If this task creates a commit, its new source tree invalidates the existing products and capture ledger. Repeat Tasks 2 through 4 against the new exact `HEAD` before entering Task 6. If no net-deletion commit is justified, retain the already verified matrix and continue directly to Task 6. + +### Task 6: Final program verification and proof report + +**Files:** +- Modify: `docs/ui-review/README.md` only for commands or state that actually changed. +- Log: Obsidian Vifty project note and current daily note. + +- [ ] **Step 1: Run full repository verification** + +Run: `df -h /System/Volumes/Data && git diff --check && make verify-full` + +Expected: at least 30 GiB free and every Swift/Ruby/build/bundle/schema/plist/codesign gate passes. + +- [ ] **Step 2: Run current automated UI verification** + +Run: `make ui-review-verify-automated` + +Expected: complete automated matrix pass for exact current products. + +- [ ] **Step 3: Run full matrix verification only when both human attestations exist** + +Run: `make ui-review-verify` + +Expected: pass only if visual and VoiceOver attestations are genuinely complete. Otherwise report the exact pending human gate and do not relabel it as failure or success. + +- [ ] **Step 4: Record a read-only runtime diagnostic** + +Run: `.build/Vifty.app/Contents/MacOS/viftyctl diagnose --json` + +Accept exit 0 or safely blocked exit 75. Do not follow repair or cooling recommendations automatically. + +- [ ] **Step 5: Check owned temporary leftovers** + +Run: `du -sh /private/tmp/[Vv]ifty* 2>/dev/null || true` + +Remove only stale Vifty-owned temporary directories after confirming no active `lsof` handles. + +- [ ] **Step 6: Write the evidence-backed closeout** + +Report exact test counts, commit range, automated matrix counts, human-review status, runtime status, and explicit non-claims for hardware compatibility, helper replacement, notarization, and release publication. diff --git a/docs/superpowers/specs/2026-09-06-vifty-audit-remediation-design.md b/docs/superpowers/specs/2026-09-06-vifty-audit-remediation-design.md new file mode 100644 index 00000000..84499acd --- /dev/null +++ b/docs/superpowers/specs/2026-09-06-vifty-audit-remediation-design.md @@ -0,0 +1,216 @@ +# Vifty Audit Remediation Design + +**Date:** 2026-09-06 +**Status:** Approved design +**Repository baseline:** `93fa955d6cea4b90c5d5d64090cccd3677efbe44` + +## Purpose + +Resolve the confirmed correctness, safety, operational-truth, and UI-evidence findings from the 2026-09-06 comprehensive audit without weakening Vifty's existing fan-control, XPC, helper-maintenance, local-data, or release-trust boundaries. + +The work is split into three independently shippable stages. Each stage must leave the repository green and may be reviewed or reverted without depending on unfinished work in a later stage. + +## Global constraints + +- macOS 15 remains the minimum deployment target and Swift Package Manager remains the build system. +- Add no third-party dependency. +- Preserve daemon-first, fail-closed fan writes and all existing fresh-snapshot, transaction, readback, ownership, restore, lock, and journal requirements. +- Do not expose raw SMC writes or relax the agent cooling lease policy. +- Do not change published release history, release tags, cask metadata, signing identities, or notarization policy. +- Implementation verification is read-only with respect to hardware: no fan command, cooling lease, helper maintenance, `sudo`, install, Auto restoration, or release mutation. +- Reuse existing persistence and error-presentation patterns where they satisfy the requirement; do not create a general storage framework. +- Every behavioral change starts with a failing focused test and ends with an independently reviewable commit. +- Current screenshots and accessibility evidence are required before claiming visual polish. Historical evidence is context only. + +## Selected architecture + +Use a safety-first staged program: + +1. Correct durable policy, preference, and helper-process behavior. +2. Make diagnostics, audit health, CLI help, and compiler gates truthful. +3. Repair exact-source UI evidence, review current captures, and apply only evidence-backed polish. + +This order prevents presentation work from hiding unresolved safety or data-integrity defects. It also keeps the UI stage grounded in current screenshots instead of source inspection or historical captures. + +## Stage 1: Safety and persistence + +### 1. Agent-control policy loading + +`AgentControlService` must distinguish active-lease persistence from policy persistence. + +- A successful stored policy value remains authoritative. +- Absence of a stored policy retains the bootstrap default for first-run compatibility. +- Any error reading the policy file forces the in-memory policy to disabled for that daemon session. +- The failure must remain observable in daemon status or diagnostics using a typed persistence-health signal; it must not be represented as unsupported hardware or helper unreachability. +- A policy-read failure by itself must not trigger an SMC write or unnecessary Auto restoration. Existing active-lease or ownership recovery requirements remain unchanged. +- No agent prepare request may succeed while policy persistence health is unresolved. + +### 2. Transactional policy mutation + +Policy changes must not leave memory and durable state disagreeing. + +- Enabling persists `true` before the in-memory policy becomes enabled. +- If enabling persistence fails, the policy remains disabled and the caller receives a typed failure. +- Disabling an active lease first completes the existing Auto restoration path. Durable `false` is then written before the successful disabled status is returned. +- If the durable disable write fails after Auto restoration, the active lease stays cleared, the prior persisted policy remains authoritative, and the caller receives an explicit persistence failure. The service must not report a successfully committed toggle. +- A successful save clears the policy-persistence health error. +- Restart tests must prove success and failure behavior from stored `true`, stored `false`, absent, corrupt, and unreadable policy states. + +### 3. Application preference durability and recovery + +`AppPreferencesStore` must adopt the small, proven recovery behavior used by `CurveProfileStore`, without introducing a shared abstraction. + +- Before replacing a valid primary file, preserve it as `.bak` using same-directory atomic operations. +- On load, try the primary first and then a valid backup. +- A corrupt primary must never overwrite or delete a valid backup. +- Preserve corrupt bytes under a distinct quarantine suffix only when that can be done without endangering the primary or backup; quarantine failure must not block recovery from a valid backup. +- A recovered backup becomes the returned preference state. Rewriting the primary may be attempted, but failure must remain visible rather than destroying the recovered in-memory value. +- Directory permissions remain `0700`; primary, backup, temporary, and quarantine files remain `0600`. +- The nonthrowing `save` path is removed from production use. `AppModel` catches the throwing save, retains the user's in-memory selection, and exposes a clear “settings were not saved” state with a retry path. The state clears on the next successful save. +- Preference write failures must not affect fan-control ownership or invoke hardware operations. + +### 4. Helper lifecycle subprocess reliability + +`DaemonInstallProcessRunner.system` must not wait for process exit while bounded pipes are undrained. + +- Drain stdout and stderr concurrently from process launch through termination. +- Bound captured bytes per stream. Additional bytes may be discarded after the bound; helper lifecycle correctness cannot depend on unbounded diagnostic output. +- Preserve the current exit-status mapping in `DaemonInstallService`. +- On stdin write failure, terminate the process, close all handles, wait for bounded cleanup, and return an error. +- Add a real-process regression whose child writes more than the platform pipe capacity to both stdout and stderr before exiting. The test must complete within a bounded timeout and prove both streams are handled without deadlock. + +### Stage 1 acceptance + +- Injected policy load/save failures prove fail-closed and transactional behavior. +- Preference tests cover valid primary, valid backup, corrupt primary plus valid backup, corrupt primary and backup, permission failure, and successful retry. +- The high-output lifecycle regression passes repeatedly. +- Focused suites and `make test-fast` pass. +- No privileged or hardware mutation command is executed. + +## Stage 2: Diagnostics and operational truth + +### 1. Unknown hardware reporting + +Do not encode snapshot unavailability as a known negative hardware identity. + +- Preserve the existing fallback snapshot only as an internal container where required for report construction. +- The readiness builder must consider `daemonSnapshotError` before interpreting `isAppleSilicon` or `isMacBookPro`. +- When snapshot acquisition fails, the supported-hardware check reports `unknown/unavailable` copy and recommends repairing the daemon path; it must not claim that the machine is unsupported. +- A successful snapshot from genuinely unsupported hardware retains the existing unsupported-hardware blocker and read-only guidance. +- JSON output remains machine-readable and keeps stable existing fields. If a new state field is necessary, update Codable, XPC bridges, JSON schemas, canonical examples, documentation, and compatibility tests together. + +### 2. Audit persistence health + +Audit logging remains observability, not an authorization prerequisite, but failures must be visible. + +- `appendAudit` records the latest persistence error in bounded in-memory state and logs it through the existing Vifty logging surface. +- A later successful append clears the in-memory failure. +- Status and diagnose expose audit availability without overwriting a more important fan-control or restore error. +- An audit append failure must not falsely report that a fan mutation failed if the authoritative transaction and readback succeeded. +- Audit content, paths, permissions, maximum event count, and privacy boundaries remain unchanged. + +### 3. CLI help + +Add discoverable help without changing agent JSON command semantics. + +- `viftyctl help`, `viftyctl --help`, and `viftyctl -h` print the same deterministic usage text and exit zero. +- The usage lists public agent commands and directs helper-maintenance operations to documented supervised flows; it must not advertise raw helper or SMC commands. +- Invalid commands continue returning usage exit code `64` and the structured error behavior already selected by `--json`. +- Help output is version-stable enough for snapshot testing but does not duplicate full integration documentation. + +### 4. Warning-free test gate + +- Remove the unnecessary `try` in `AgentControlServiceTests`. +- Compile test sources with warnings-as-errors in both fast and full verification paths. +- Keep the production warnings-as-errors build. +- CI continues to invoke `make verify-full`; no parallel compiler policy is introduced. + +### Stage 2 acceptance + +- Fixture tests distinguish supported, unsupported, and unavailable hardware. +- Audit failure and recovery are observable without changing fan mutation results. +- All three help forms exit zero; invalid commands retain exit `64`. +- A deliberately introduced test warning would fail the gate. +- `make verify` passes with schemas, examples, documentation contracts, bundle checks, and codesign structure intact. + +## Stage 3: UI evidence and polish + +### 1. Repair fixture readiness + +Treat the 10-second and 60-second `READY_TIMEOUT` reports as the starting failure. + +- Trace the exact-source fixture from preparation through window creation, observation bridge delivery, stability checks, capture, and final report publication. +- Determine whether the failure is deterministic application logic or a WindowServer/session precondition before changing production UI code. +- Add the smallest behavioral seam that makes readiness observable and testable. +- Add a regression that launches the inert fixture, receives a stable observation, and reaches capture-ready state within the documented bound. +- Preserve `modelStartSkipped=true`, inert hardware dependencies, and zero external fan/helper mutations. + +### 2. Capture current evidence + +Run the repository's declared matrix rather than inventing a new parallel harness. + +- Capture required main-window widths, menu-bar popover, settings views, helper/error states, agent-control states, light mode, dark mode, and increased text scale. +- Collect accessibility evidence for labels, roles, values, actions, focus order, adjustable curve controls, keyboard operation, and meaningful status text. +- Each accepted row must bind to the exact source revision and build provenance. +- Failed or missing rows stay pending; do not promote a partial matrix as complete. + +### 3. Evidence-backed polish pass + +Only current evidence may create polish work. + +- Fix clipping, truncation, low contrast, excessive dead space, inconsistent spacing, unclear hierarchy, misleading status copy, inaccessible controls, or broken focus behavior found in the accepted matrix. +- Prefer native SwiftUI layout, accessibility, and control behavior. +- Preserve the existing application information architecture unless evidence shows a concrete workflow failure. +- Each visual change receives a focused behavioral/layout test and before/after capture at affected matrix rows. + +### 4. Simplify evidence maintenance + +After the capture path is stable, convert repeated matrix cases to data-driven declarations where this reduces duplication without weakening provenance or acceptance rules. + +- Do not simplify fan safety, release verification, evidence signing, provenance binding, or fail-closed review semantics. +- Do not introduce a new snapshot-testing dependency. +- Deletion is accepted only when the replacement tests prove equivalent required matrix coverage. + +### Stage 3 acceptance + +- Every required evidence-manifest row is accepted or explicitly documented as pending with a concrete blocker. +- No accepted capture shows clipping, unreadable contrast, inaccessible primary controls, misleading ownership state, or broken focus behavior. +- Accessibility evidence covers the complete supported interaction surface. +- `make verify-full` passes. +- A final read-only `viftyctl diagnose --json` is recorded; if the installed helper is unavailable, that limitation is reported rather than repaired automatically. + +## Commit and review boundaries + +Use small commits aligned with independently testable behavior: + +1. Fail-closed policy loading. +2. Transactional policy mutation. +3. Recoverable app preferences and visible save failure. +4. Nonblocking lifecycle output handling. +5. Truthful unknown-hardware diagnostics. +6. Observable audit persistence health. +7. CLI help and warning-free test gates. +8. UI fixture readiness repair. +9. Current evidence capture. +10. Evidence-backed UI fixes. +11. Data-driven evidence-harness reduction, only if equivalence is proven. + +Review Stage 1 before starting Stage 2, and Stage 2 before starting Stage 3. A failed gate stops the program without folding unrelated fixes into the failing commit. + +## Verification and proof boundaries + +Repository tests and inert fixtures can prove deterministic source behavior, schemas, packaging, and read-only diagnostics. They cannot prove physical fan compatibility, successful privileged helper replacement, notarization of new bytes, or subjective visual quality on every display. + +Those claims require separate, explicitly authorized evidence: + +- Supported-hardware Fixed/Curve/Auto compatibility requires supervised hardware validation and fresh full-set readback. +- Helper repair or install behavior requires an explicit privileged maintenance session. +- Release trust requires the existing Developer ID, notarization, stapling, checksum, Gatekeeper, governance, and public artifact workflow. +- Visual polish requires current accepted screenshots and human review after the fixture is repaired. + +## Non-goals + +- No new fan-control mode, agent policy feature, updater, telemetry persistence, analytics, network service, or third-party dependency. +- No rewrite of `AgentControlService`, `AppModel`, the release pipeline, or the UI evidence system. +- No weakening of output schemas for convenience. +- No automatic repair, install, fan control, cooling request, Auto restoration, release, or deployment during implementation. diff --git a/docs/ui-review/automated-checkpoint.json b/docs/ui-review/automated-checkpoint.json index 0a3d55d4..b045a80b 100644 --- a/docs/ui-review/automated-checkpoint.json +++ b/docs/ui-review/automated-checkpoint.json @@ -1 +1 @@ -{"counts":{"accessibility":13,"fixture":9,"total":50,"visual":28},"evidenceKind":"hardware-free-native-container-debug-fixture","hero":{"canonicalPixelSHA256":"94ed1a5668b3eefbd77b14fe648290c4bb049479dd2e155ea7bb507939193fa3","captureIDHash":"f23b4898196a7e90932845bdbfb9575fbcb231eb4222b320e1d38d9e2f0dfc79","heroArtifactSHA256":"1ab95bd2eda4fb0c2b66aa3ee4f075bc196874f6b5b580063b0bf134f1292190","rowID":"main-1180x820-light","screenshotSHA256":"1ab95bd2eda4fb0c2b66aa3ee4f075bc196874f6b5b580063b0bf134f1292190"},"nonClaims":["full-evidence-bundle-not-committed","hardware-compatibility-not-claimed","release-readiness-not-claimed"],"products":{"axCollectorProvenance":{"buildTransactionID":"1aa7aabea94fd9d1a0f77cd3a10705f6e9b3c0211b4f5b8754afdec36ccee832","configuration":"debug","productRole":"ax-collector","schemaID":"https://vifty.app/schemas/ui-review-build-provenance-v1.schema.json","schemaVersion":1,"sourceCommit":"6ac429cbacf7cc3358c74493ab7461a43fa40275","sourceTree":"cc22ef128818b41368d0f27411dc7abe67e98673"},"axCollectorProvenanceSHA256":"c6a1dd3754e5062e5e7f9e4c00f119bac4d8e2b4f8563d2562928c46ba807c6a","axCollectorSHA256":"accd31f94edd27c4dee3a6ee6aedf369e429d510279e5b5576adaefed12ceda9","buildTransactionID":"1aa7aabea94fd9d1a0f77cd3a10705f6e9b3c0211b4f5b8754afdec36ccee832","debugFixtureProvenance":{"buildTransactionID":"1aa7aabea94fd9d1a0f77cd3a10705f6e9b3c0211b4f5b8754afdec36ccee832","configuration":"debug","productRole":"debug-fixture-app","schemaID":"https://vifty.app/schemas/ui-review-build-provenance-v1.schema.json","schemaVersion":1,"sourceCommit":"6ac429cbacf7cc3358c74493ab7461a43fa40275","sourceTree":"cc22ef128818b41368d0f27411dc7abe67e98673"},"debugFixtureProvenanceSHA256":"e2dd07898be1d3aad5af7eb3c4e3bf8d9a346bae6b32d83fdc711b3eeeadb382","debugFixtureSHA256":"9ebe5a6e82e45a20ff33fea18ce883b5042c69d549abf2888ca34721c0f79378","releaseExclusionProvenance":{"buildTransactionID":"1aa7aabea94fd9d1a0f77cd3a10705f6e9b3c0211b4f5b8754afdec36ccee832","configuration":"release","productRole":"release-exclusion","schemaID":"https://vifty.app/schemas/ui-review-build-provenance-v1.schema.json","schemaVersion":1,"sourceCommit":"6ac429cbacf7cc3358c74493ab7461a43fa40275","sourceTree":"cc22ef128818b41368d0f27411dc7abe67e98673"},"releaseExclusionProvenanceSHA256":"6008baeddb492eadb376f689cf8d4a3a8624eb69107cc078cc2894ea0c011388","releaseExclusionSHA256":"c0817f99f2eea6619cf55b0dbaf3af8f841dfe4f4c84a9c2eead0218fb1c7469"},"reviewGates":{"visual":{"claims":[],"priorEvidence":"superseded","status":"pending"},"voiceOver":{"claims":[],"decision":"skipped-by-owner","status":"pending"}},"rows":[{"captureIDHash":"c7dce190ce9e5d627e7d8265ef90a0c510d581c479ff98d37657e3ae3809d497","debugBuildProvenanceSHA256":"e2dd07898be1d3aad5af7eb3c4e3bf8d9a346bae6b32d83fdc711b3eeeadb382","debugFixtureSHA256":"9ebe5a6e82e45a20ff33fea18ce883b5042c69d549abf2888ca34721c0f79378","fixtureReportSHA256":"940d8ca9d6af9c2572e67ed02d153fce747ca4791698a152c0d1a0d33624911b","id":"healthy-auto","kind":"fixture","requestSHA256":"b98d552cdad1f859cb12af693cea3259509f804a9b9a69c780b7954912a55db1"},{"captureIDHash":"88ef84b9819587ba9cd1c4700a13ce4976546a50551b1e48306e5a71306ba69e","debugBuildProvenanceSHA256":"e2dd07898be1d3aad5af7eb3c4e3bf8d9a346bae6b32d83fdc711b3eeeadb382","debugFixtureSHA256":"9ebe5a6e82e45a20ff33fea18ce883b5042c69d549abf2888ca34721c0f79378","fixtureReportSHA256":"4acfd1f5bb100af3f09501512e0c2da2d70fa11520f04e249f1a2e54e9413fb1","id":"divergent-per-fan-curve-draft","kind":"fixture","requestSHA256":"c40244a7207996bfd7d4a20c75467ea519bba3fc710cd0eac417c888d67f2bc7"},{"captureIDHash":"b96a707603bf6c9455650b811828e5885f88e03b855fdf82e7bcf6a3b1569f50","debugBuildProvenanceSHA256":"e2dd07898be1d3aad5af7eb3c4e3bf8d9a346bae6b32d83fdc711b3eeeadb382","debugFixtureSHA256":"9ebe5a6e82e45a20ff33fea18ce883b5042c69d549abf2888ca34721c0f79378","fixtureReportSHA256":"ecd8c5ccdc6aa686d508a9eb55b76a0baf77a2e471c6eb228646845d509191ed","id":"active-manual","kind":"fixture","requestSHA256":"357c7554d9b7565645537f5eeab8e94e954d332fbb15229dcfc9c55e400ae42b"},{"captureIDHash":"25476ef18698c0cd06e47e355f84df861c238bc0869784c300ead6a4939bc6fc","debugBuildProvenanceSHA256":"e2dd07898be1d3aad5af7eb3c4e3bf8d9a346bae6b32d83fdc711b3eeeadb382","debugFixtureSHA256":"9ebe5a6e82e45a20ff33fea18ce883b5042c69d549abf2888ca34721c0f79378","fixtureReportSHA256":"61500b36b8db57f87ecd8eb7a1f6cf6d6d447fc17820fd6622c1837ea0c64569","id":"recovery-mixed-ownership","kind":"fixture","requestSHA256":"922dae33cd3e1954f5d29d3edd1d58a66848229dc290c9e6c4e59627c0dac46e"},{"captureIDHash":"1f7d61d8324fd3a8ab4bc61ac84fbf9bd1cf4bf897ce10ac3aa9d19ba8041002","debugBuildProvenanceSHA256":"e2dd07898be1d3aad5af7eb3c4e3bf8d9a346bae6b32d83fdc711b3eeeadb382","debugFixtureSHA256":"9ebe5a6e82e45a20ff33fea18ce883b5042c69d549abf2888ca34721c0f79378","fixtureReportSHA256":"ed41a96740b9441fccc468b2213584dbee57feb5004583e2be0de17aa9b7a045","id":"helper-blocked","kind":"fixture","requestSHA256":"266bc5b039931c7d15d938674309f8982d60faba4f17c3b95e798f9750901597"},{"captureIDHash":"66adc1d00611a7d57ed8e961decb5f91e9e82cac2c4a3ef890920880d2d9fbf9","debugBuildProvenanceSHA256":"e2dd07898be1d3aad5af7eb3c4e3bf8d9a346bae6b32d83fdc711b3eeeadb382","debugFixtureSHA256":"9ebe5a6e82e45a20ff33fea18ce883b5042c69d549abf2888ca34721c0f79378","fixtureReportSHA256":"45ea1b627bb7d918b894af653eed8cabc2d20b19ab41a7a3204f5d49b198c07a","id":"notification-denied","kind":"fixture","requestSHA256":"33b9aa34cb75aa2f7221410a4529d50d0228729eaad6c8990f6892ea59c7fa86"},{"captureIDHash":"533c39d7c65142be3f4a0799b0d26bc1b113a4dbab15f15aa049e64ac3447445","debugBuildProvenanceSHA256":"e2dd07898be1d3aad5af7eb3c4e3bf8d9a346bae6b32d83fdc711b3eeeadb382","debugFixtureSHA256":"9ebe5a6e82e45a20ff33fea18ce883b5042c69d549abf2888ca34721c0f79378","fixtureReportSHA256":"bfbd26bac059a5f916466b25615d6f411c47c15254cbc08ef69f3fdb3d63cde9","id":"edited-profile","kind":"fixture","requestSHA256":"17c3ec7261f8c10e2c607a9049642417406fb500ef3bf65141f1b30504456a07"},{"captureIDHash":"e5e2ce83a37db5dfbf2c2f45e75bd94479e92eb3dea99e35f1b9f127c527136e","debugBuildProvenanceSHA256":"e2dd07898be1d3aad5af7eb3c4e3bf8d9a346bae6b32d83fdc711b3eeeadb382","debugFixtureSHA256":"9ebe5a6e82e45a20ff33fea18ce883b5042c69d549abf2888ca34721c0f79378","fixtureReportSHA256":"e7c5ef3231abf4dc2cb440f94d3832ba706311f9c0a5fc7b5215eed6d2024fd2","id":"selected-vs-highest-temperature","kind":"fixture","requestSHA256":"4a103f7896f25e5db1c8c154a9d5bd7f80b031b0b395f4fd4457e34456624e33"},{"captureIDHash":"e702238e33b4be11de148c91e8e1ba0003db1752d3df703b1fae2e70dbb745f0","debugBuildProvenanceSHA256":"e2dd07898be1d3aad5af7eb3c4e3bf8d9a346bae6b32d83fdc711b3eeeadb382","debugFixtureSHA256":"9ebe5a6e82e45a20ff33fea18ce883b5042c69d549abf2888ca34721c0f79378","fixtureReportSHA256":"d8df988c8da5c2cf227cc79d4480ff8ad9be06e11879a906bdb3dfec6502b129","id":"raw-spike-telemetry","kind":"fixture","requestSHA256":"cb850ba1eb313e2daafa9263efe5e3b3eaceda51dd4dd8a56e6725d085fd2fa7"},{"canonicalPixelSHA256":"f90c53bb400517e18ce925607b202e2c5dfbb14926f888bc5a7a7e471e2c8b76","captureIDHash":"ff0ab618f23b42679b3166f44f5d41c97277f876aed339380f2a86e74c8063d8","debugBuildProvenanceSHA256":"e2dd07898be1d3aad5af7eb3c4e3bf8d9a346bae6b32d83fdc711b3eeeadb382","debugFixtureSHA256":"9ebe5a6e82e45a20ff33fea18ce883b5042c69d549abf2888ca34721c0f79378","fixtureReportSHA256":"4931b1ab3b423e3b27a3e6454cfe4397bfd0a6d0050e34dcf0a4e929b91c4332","id":"main-780x480-light","kind":"visual","requestSHA256":"6343f043c95507a0bf3bca03d4b2640882f1da998b7c760d51420815137ad42c","screenshotSHA256":"969f45b2cdec66ffcc853a2121a8ece7e774fe0838d60bac1457eddca45934d7"},{"canonicalPixelSHA256":"05d1c4f8f35059f1d92a26e16e03bd7d9214d92360b38afea394c19328f2db0d","captureIDHash":"3a74d4b81b00864abea024fb7a163df14eb0aee22c56aa366fe466d395782677","debugBuildProvenanceSHA256":"e2dd07898be1d3aad5af7eb3c4e3bf8d9a346bae6b32d83fdc711b3eeeadb382","debugFixtureSHA256":"9ebe5a6e82e45a20ff33fea18ce883b5042c69d549abf2888ca34721c0f79378","fixtureReportSHA256":"fe169f53fb39fa276e3eb76eef0d13208626fbf94816b70142fabe9b07105403","id":"main-780x480-dark","kind":"visual","requestSHA256":"7a7a94aba066bc74a0a4d39bd907c4a967b151e3d32ea5406bdc9b0bc1a10791","screenshotSHA256":"de50458fa260718d3ef0db4d56932eb6e0253264a9fc2492f5a381e56ce81f03"},{"canonicalPixelSHA256":"94ed1a5668b3eefbd77b14fe648290c4bb049479dd2e155ea7bb507939193fa3","captureIDHash":"f23b4898196a7e90932845bdbfb9575fbcb231eb4222b320e1d38d9e2f0dfc79","debugBuildProvenanceSHA256":"e2dd07898be1d3aad5af7eb3c4e3bf8d9a346bae6b32d83fdc711b3eeeadb382","debugFixtureSHA256":"9ebe5a6e82e45a20ff33fea18ce883b5042c69d549abf2888ca34721c0f79378","fixtureReportSHA256":"27880cc99fd70e568840d820ddc0c0ec985ff008649e295b83aa0b36aef057fc","id":"main-1180x820-light","kind":"visual","requestSHA256":"b98d552cdad1f859cb12af693cea3259509f804a9b9a69c780b7954912a55db1","screenshotSHA256":"1ab95bd2eda4fb0c2b66aa3ee4f075bc196874f6b5b580063b0bf134f1292190"},{"canonicalPixelSHA256":"15935cb8205dbd5aa65c39f159428af776be3002244814f00673387af2900b16","captureIDHash":"61a7df3463c056645f7013f16e26e887dfa2d38ba88d89653f0889f0715bb6b8","debugBuildProvenanceSHA256":"e2dd07898be1d3aad5af7eb3c4e3bf8d9a346bae6b32d83fdc711b3eeeadb382","debugFixtureSHA256":"9ebe5a6e82e45a20ff33fea18ce883b5042c69d549abf2888ca34721c0f79378","fixtureReportSHA256":"7391d8c55fd575339b852da378bd194d13cec6305d27c1a66134fbc97cb7fb8f","id":"main-1180x820-dark","kind":"visual","requestSHA256":"0df86b8f7a07fa1e934a40ad455b217aeafc3960e918904434292a7a2bdd8b91","screenshotSHA256":"817d476f207711c651016a91a219da56170ffaa20cb38095ae7310460f731ea0"},{"canonicalPixelSHA256":"5a5bac364872886b90fe7c9afdb786112f107a6a999408b523d4902326bf0fd0","captureIDHash":"828233f0be8c349f0d595e2f7ac4b93d290656b4c3741d96827953e155e7e98a","debugBuildProvenanceSHA256":"e2dd07898be1d3aad5af7eb3c4e3bf8d9a346bae6b32d83fdc711b3eeeadb382","debugFixtureSHA256":"9ebe5a6e82e45a20ff33fea18ce883b5042c69d549abf2888ca34721c0f79378","fixtureReportSHA256":"5b19ab5f2b268cedef6b3230232a153a3d83e35bfcc96d42ec270f265207f492","id":"main-1280x720-light","kind":"visual","requestSHA256":"edcb941caf1c1240669a4e9be836e828c568fed10c251eb284ef9d81bf70753c","screenshotSHA256":"00dcee7215a8abc1f3a466a487eb8159e118cdc3973922b9612747289a74dce4"},{"canonicalPixelSHA256":"4a8300a8fcce1189a973d5c949a1c0fe832c0e76c665db1d1a112f546ee65a5b","captureIDHash":"202a6bb3f02b07ec1e2d713feac5dfafe85a0af559c849c9f5f3528495337b5c","debugBuildProvenanceSHA256":"e2dd07898be1d3aad5af7eb3c4e3bf8d9a346bae6b32d83fdc711b3eeeadb382","debugFixtureSHA256":"9ebe5a6e82e45a20ff33fea18ce883b5042c69d549abf2888ca34721c0f79378","fixtureReportSHA256":"decad917a6b568c596962d9f44299d212c216173d0d305ce81e864a98867b3db","id":"main-1280x720-dark","kind":"visual","requestSHA256":"6367c41b8dfa354e76ac7b3bfaecd5ffafbc78ec8de475cef7b13640b3dd05a4","screenshotSHA256":"6a85579120619906134daa90f7b8bde103d5592439996375eba835b0a4744e91"},{"canonicalPixelSHA256":"24bfb4163248fdc414045bcb82e440fa98f73404669e3030bec6d14ddb1b300a","captureIDHash":"b2695d081696f75ec20072a0f2704d3c5564a8a82141936dd37c4edc5c295488","debugBuildProvenanceSHA256":"e2dd07898be1d3aad5af7eb3c4e3bf8d9a346bae6b32d83fdc711b3eeeadb382","debugFixtureSHA256":"9ebe5a6e82e45a20ff33fea18ce883b5042c69d549abf2888ca34721c0f79378","fixtureReportSHA256":"072f8de8e8a4cccb2d14b710b673384a78e56d378b5b3e80402693eec8bf776d","id":"main-1500x900-light","kind":"visual","requestSHA256":"583ec26d1455fdcdb4d931d699a3c478a03cfaf87d81d9128b865ecf2fc57992","screenshotSHA256":"62e4c0e5fdb281f1d3a0a14367c1251e905ffd495414f17a7039d070ac253196"},{"canonicalPixelSHA256":"5d8d1b32b49bf7ae23e93731eb4117615d79d9565e7d7f662eb0ec5da0725817","captureIDHash":"005559dec91edf9e347c69769882ddb06329acaa05008bfba48bc6e278da57d0","debugBuildProvenanceSHA256":"e2dd07898be1d3aad5af7eb3c4e3bf8d9a346bae6b32d83fdc711b3eeeadb382","debugFixtureSHA256":"9ebe5a6e82e45a20ff33fea18ce883b5042c69d549abf2888ca34721c0f79378","fixtureReportSHA256":"6452e45f3067c5b70a8b566dadcc8fe39f351fdcc7536bd8ba5b39f2ef947e76","id":"main-1500x900-dark","kind":"visual","requestSHA256":"86473313ce5225cde9c29ced6f68f3513b0fd9e0a647a0d97aeaf2cbf4d6b73f","screenshotSHA256":"53986f15c3899958120f0f45fcddfbd9e370a873060acf21b5d6359126a82872"},{"canonicalPixelSHA256":"59280f59f951df514129c4930ed70df5fbc9b3b650f193fbddfc64f63cf3a7a0","captureIDHash":"9f355693030f66d1c6ed19a73a2a4d75a2e8f0f41bde67a78b85e2f201292b39","debugBuildProvenanceSHA256":"e2dd07898be1d3aad5af7eb3c4e3bf8d9a346bae6b32d83fdc711b3eeeadb382","debugFixtureSHA256":"9ebe5a6e82e45a20ff33fea18ce883b5042c69d549abf2888ca34721c0f79378","fixtureReportSHA256":"dc73663c7991a0859178c422f5bda3be71a23be5ca419dcaf90e207b5e1376da","id":"state-divergent-per-fan-curve-draft","kind":"visual","requestSHA256":"c40244a7207996bfd7d4a20c75467ea519bba3fc710cd0eac417c888d67f2bc7","screenshotSHA256":"a731c005cec23f997462e68704e314a3722ed303cb2dc1743304b93ff5e8cee2"},{"canonicalPixelSHA256":"1fae5f755fc771e9d8ba91e46a74eebab1252478733cff9b373f6629ac89e1af","captureIDHash":"0567feef6c703e7e6f10f4b8f8c530ab1881d4648eb65632aaf2289b9b56e203","debugBuildProvenanceSHA256":"e2dd07898be1d3aad5af7eb3c4e3bf8d9a346bae6b32d83fdc711b3eeeadb382","debugFixtureSHA256":"9ebe5a6e82e45a20ff33fea18ce883b5042c69d549abf2888ca34721c0f79378","fixtureReportSHA256":"4afddca26cea6584efeed7c2c8e8243c98d29ef0a2361fca50cf871784ba161f","id":"state-active-manual","kind":"visual","requestSHA256":"357c7554d9b7565645537f5eeab8e94e954d332fbb15229dcfc9c55e400ae42b","screenshotSHA256":"a31520d6929211d27c99e17c70753c28a84c70f7d758a925b3124a23b3b704e0"},{"canonicalPixelSHA256":"9a44e005d90298ddaf96325ee86102eead7c9d200363f2b9f68224f7ad003e51","captureIDHash":"689ffe07bc2f3bf08b7c7d0d335052156998b5aa7b7d909595cc860813ec1ba1","debugBuildProvenanceSHA256":"e2dd07898be1d3aad5af7eb3c4e3bf8d9a346bae6b32d83fdc711b3eeeadb382","debugFixtureSHA256":"9ebe5a6e82e45a20ff33fea18ce883b5042c69d549abf2888ca34721c0f79378","fixtureReportSHA256":"8d4aa7d4f05b895c59e21f9bc48af9ab4c226539761ae924683fdca8bcfbb087","id":"state-recovery-mixed-ownership","kind":"visual","requestSHA256":"922dae33cd3e1954f5d29d3edd1d58a66848229dc290c9e6c4e59627c0dac46e","screenshotSHA256":"28adb59b6783209ee72e4d8ace238ce456cd1a85f121a623579139b43c216ccd"},{"canonicalPixelSHA256":"7197558dc5764fc0bd842353040a83fd0d32185aa94ee6872e9923df2aa8fcf5","captureIDHash":"7f8d83aa5efa1c0f86aa0dc4f7bfe8775b0c5debaacb97a8ff3d7162cd0ec16f","debugBuildProvenanceSHA256":"e2dd07898be1d3aad5af7eb3c4e3bf8d9a346bae6b32d83fdc711b3eeeadb382","debugFixtureSHA256":"9ebe5a6e82e45a20ff33fea18ce883b5042c69d549abf2888ca34721c0f79378","fixtureReportSHA256":"c2a41dba655ac2fcd0509ab8736a2f68731ed6942f85a666895fba1535acdf3c","id":"state-helper-blocked","kind":"visual","requestSHA256":"266bc5b039931c7d15d938674309f8982d60faba4f17c3b95e798f9750901597","screenshotSHA256":"f8c8be6af1c3fcb1dc87021094ec932e7dd3db1925f43cf2da937a5b536398c5"},{"canonicalPixelSHA256":"2a75ac7797897fdd1cd40ba18f9a2fb444fd7923b70db0f0c4acba78bbead75b","captureIDHash":"a79283ac8d9173752147bcb2a1300c2d7d5055798a9ec3f02ca4d22922260ba5","debugBuildProvenanceSHA256":"e2dd07898be1d3aad5af7eb3c4e3bf8d9a346bae6b32d83fdc711b3eeeadb382","debugFixtureSHA256":"9ebe5a6e82e45a20ff33fea18ce883b5042c69d549abf2888ca34721c0f79378","fixtureReportSHA256":"18070ec8988ae601e00a4e1e9b473bfdd08e8712cd5dce7b79fea9507267e74d","id":"state-notification-denied","kind":"visual","requestSHA256":"55c3cb22d211c8164b11e63689ed7dbf259c95e1f8ff9e612e1514aa7d6ea683","screenshotSHA256":"5742c0c0c669c3af6098831c45a9753b691e2e1c03c124443a4cc8ac29ff4296"},{"canonicalPixelSHA256":"5a1e8a9a2de9a9e4710dacdffa83049354825b8d166de2ec874039c009890637","captureIDHash":"862f5f5e0ffcc257155f39ab24e75cc7b0030fd0a57a814950d79f5308010af6","debugBuildProvenanceSHA256":"e2dd07898be1d3aad5af7eb3c4e3bf8d9a346bae6b32d83fdc711b3eeeadb382","debugFixtureSHA256":"9ebe5a6e82e45a20ff33fea18ce883b5042c69d549abf2888ca34721c0f79378","fixtureReportSHA256":"f520f40bbc665963fe48c8446bd0257e60d5a6c6417f8026b9059175431c78b7","id":"state-edited-profile","kind":"visual","requestSHA256":"17c3ec7261f8c10e2c607a9049642417406fb500ef3bf65141f1b30504456a07","screenshotSHA256":"b9c8b0c2ffb8317af75ff250817cedc36984248c4fb1dd446205e15fec5cd9f6"},{"canonicalPixelSHA256":"6e0e2f6514ecc3082c87ab86c0f855ceed096bc18fb408bfecb078c16e5df8e9","captureIDHash":"229f47f4396141438a3a7fa785bbcab557a139193e48a7f1e021e68ab25689f5","debugBuildProvenanceSHA256":"e2dd07898be1d3aad5af7eb3c4e3bf8d9a346bae6b32d83fdc711b3eeeadb382","debugFixtureSHA256":"9ebe5a6e82e45a20ff33fea18ce883b5042c69d549abf2888ca34721c0f79378","fixtureReportSHA256":"5ac3a70ed93cb76633cc5df1e71134420f725ec95d1a9fffe9ed8d4f290633e7","id":"state-selected-vs-highest-temperature","kind":"visual","requestSHA256":"4a103f7896f25e5db1c8c154a9d5bd7f80b031b0b395f4fd4457e34456624e33","screenshotSHA256":"b802cbd5c3ce1bca67811892122964a9247bb7f014bf41f1bff508f27d7cb625"},{"canonicalPixelSHA256":"bd45363d8cb7fa2da90295d5ef4eba7a1e39829433d86424c1b0b2730e06c419","captureIDHash":"9f13938ca90b7ec2835aced26c65cae0cddbe5d2f397fb32cb52b1bc4990307f","debugBuildProvenanceSHA256":"e2dd07898be1d3aad5af7eb3c4e3bf8d9a346bae6b32d83fdc711b3eeeadb382","debugFixtureSHA256":"9ebe5a6e82e45a20ff33fea18ce883b5042c69d549abf2888ca34721c0f79378","fixtureReportSHA256":"7d22993e0ad290508656baa71a5005f11add4beafad5a261739bc51e3302d0d8","id":"state-raw-spike-telemetry","kind":"visual","requestSHA256":"cb850ba1eb313e2daafa9263efe5e3b3eaceda51dd4dd8a56e6725d085fd2fa7","screenshotSHA256":"2fe799f4b758b7332419e7b508f14a0c529c0ae355f4a534e379479d4a364fec"},{"canonicalPixelSHA256":"a3b1440af8e0e584fb717c6957f0fadb58370278f9aa35d8013740821dfc9ff7","captureIDHash":"d99321cfa870f51a59fdfa114002b0df0c15b9a1835b5750383dbc5aabff2a6f","debugBuildProvenanceSHA256":"e2dd07898be1d3aad5af7eb3c4e3bf8d9a346bae6b32d83fdc711b3eeeadb382","debugFixtureSHA256":"9ebe5a6e82e45a20ff33fea18ce883b5042c69d549abf2888ca34721c0f79378","fixtureReportSHA256":"6c28bf6a3c6558131edd752228be1649cff64065fc14f7c2e9940855ae4d524e","id":"settings-general","kind":"visual","requestSHA256":"987e3e64153ba3cd3b55b8d2c672ecf43f00a3b7d7957de79a6ff2103ef01aa1","screenshotSHA256":"48fcc95f855337f9eef717b2cead0e46ebd421800c5344a6088833f537e375f8"},{"canonicalPixelSHA256":"66c44176c6787d6c40c57e6e4c81613d99d28267e5f9130a0c5d4b1a5120f989","captureIDHash":"1446641c167fbe6efaa692372a6051746e2bc1ba2bcb19411bbd78bc83e487c9","debugBuildProvenanceSHA256":"e2dd07898be1d3aad5af7eb3c4e3bf8d9a346bae6b32d83fdc711b3eeeadb382","debugFixtureSHA256":"9ebe5a6e82e45a20ff33fea18ce883b5042c69d549abf2888ca34721c0f79378","fixtureReportSHA256":"784fd3f4a18930d01d79d058ffd88b732982c5f87076c3baf715b3aaf6c05c1c","id":"settings-menu-bar","kind":"visual","requestSHA256":"398d0ace3621d468b0dc8cf7dce1ae4a23a064dfbf60ed702cbd819e97e821fd","screenshotSHA256":"ce1b722f66fdc62f844d02bf539b83e6cbd619ef09b8103ed015f23789025400"},{"canonicalPixelSHA256":"56ac16c01b8d385d90ad90afb2f1a0ff7032967aeb954a40decf9608d52d7c08","captureIDHash":"0652f9b1edff7ab56c8dc16156f137b02fe1b8e4224d6a78816394fee8719001","debugBuildProvenanceSHA256":"e2dd07898be1d3aad5af7eb3c4e3bf8d9a346bae6b32d83fdc711b3eeeadb382","debugFixtureSHA256":"9ebe5a6e82e45a20ff33fea18ce883b5042c69d549abf2888ca34721c0f79378","fixtureReportSHA256":"876bf85900cf2ed3dc1b9c92cd4a76a34abf09cc767597205f3d1bc9d20dd537","id":"settings-notifications","kind":"visual","requestSHA256":"fbd20a8956ac61d5fb44e16151ddb9834b0e1c9966b540bb787c82f53a32568f","screenshotSHA256":"0969ff99d5d21ead1bdcda740d72119ba7bfadeb0ea83b2f4517d5cc6fc32468"},{"canonicalPixelSHA256":"b956018e17742f8e5b076a744c9478238b68df73df196430255cf407b2ddce5e","captureIDHash":"3a53956f70b8e335dbedc417fd0b24bcaf13d4bdf30d378eb9882208c9d6024a","debugBuildProvenanceSHA256":"e2dd07898be1d3aad5af7eb3c4e3bf8d9a346bae6b32d83fdc711b3eeeadb382","debugFixtureSHA256":"9ebe5a6e82e45a20ff33fea18ce883b5042c69d549abf2888ca34721c0f79378","fixtureReportSHA256":"63af95cbd74109d03a484aaf825b04558e5efaa0bcb85647b3f8ab1d83107163","id":"settings-agent-workflows","kind":"visual","requestSHA256":"69d15a3f7fe3c38ef059c8f07298620dccd101f4d678c5759b19b2c3bd1f1970","screenshotSHA256":"c1992685be1b0267b3a3af7ddbd6615f7a891deae33897b4b75ed8575af2865e"},{"canonicalPixelSHA256":"37f9876a86218f9ae60f12cfece5b41832511a44e91e38622ddeaccb2058e259","captureIDHash":"f56465a7a176db82dfc54f310bc268f04a9cd8bdbe0716bb7eb5c8a7132b6542","debugBuildProvenanceSHA256":"e2dd07898be1d3aad5af7eb3c4e3bf8d9a346bae6b32d83fdc711b3eeeadb382","debugFixtureSHA256":"9ebe5a6e82e45a20ff33fea18ce883b5042c69d549abf2888ca34721c0f79378","fixtureReportSHA256":"a114572f042df6225f23a7a929d3173a9a93ffda5d326f1812dd2d7a0d68dec5","id":"menu-popover","kind":"visual","requestSHA256":"cb03f3378c6539e637e300b154f9bae22b5790e2194b5c5d721072fce285b76c","screenshotSHA256":"c36761f069874b528b39d849e398b8c77e7f96faf53608e00167d19116eca26d"},{"canonicalPixelSHA256":"919aa6e37c6656fa21d87f0c3742d534e91bb4b086b5ec9ca45c47902cc56cf8","captureIDHash":"3f361d035c631904eed37f75d54e95f22911f176a0eed6960b89e570ce5ef937","debugBuildProvenanceSHA256":"e2dd07898be1d3aad5af7eb3c4e3bf8d9a346bae6b32d83fdc711b3eeeadb382","debugFixtureSHA256":"9ebe5a6e82e45a20ff33fea18ce883b5042c69d549abf2888ca34721c0f79378","fixtureReportSHA256":"8e371f6d68af015d8ae43a449b2dfa87628d6793422408d3a829968314f69545","id":"main-increase-contrast","kind":"visual","requestSHA256":"6c833933f9d386ebe2a4a1274cf627c26065b32d6795a50053637f65bf292140","screenshotSHA256":"ecfcc92f262afc3134b92f61b7186984059d6a63953fa9d24489d97cee7221a2"},{"canonicalPixelSHA256":"e8f5a67a429308de85b5e1f202f9bf44269a80b4cb47b1decf6c9aeed6b9612a","captureIDHash":"c7cf6fd6599e53109c96ba19ccc12da9e832d017b70c4fa181579ebcb1b73de6","debugBuildProvenanceSHA256":"e2dd07898be1d3aad5af7eb3c4e3bf8d9a346bae6b32d83fdc711b3eeeadb382","debugFixtureSHA256":"9ebe5a6e82e45a20ff33fea18ce883b5042c69d549abf2888ca34721c0f79378","fixtureReportSHA256":"06c62f246643d5d3a22a479db2576d1361488fee49dbe137fb7f25640439c6c4","id":"main-reduce-transparency","kind":"visual","requestSHA256":"3f511ec536dde4fa5f62b835eab9f3e7f3665bcd04f51e90c7b27c1471a67146","screenshotSHA256":"59e004c0154a1d3d8e504dcde80ca3458d2e89f37c4e5a8353ad623478f13a53"},{"canonicalPixelSHA256":"464091e23d63f6b2e6310f023016bfe84ab03d7d2ddff3181c6456d3b8ff37de","captureIDHash":"fd5fe1ebc89909a3b668410fac045b33685a1f5aeb6e6dccbbacdf87303a59c7","debugBuildProvenanceSHA256":"e2dd07898be1d3aad5af7eb3c4e3bf8d9a346bae6b32d83fdc711b3eeeadb382","debugFixtureSHA256":"9ebe5a6e82e45a20ff33fea18ce883b5042c69d549abf2888ca34721c0f79378","fixtureReportSHA256":"044f0e4e657994c689f81e9f4593428c38140f5d632633c046e0d1a1d766af3e","id":"main-accessibility-text","kind":"visual","requestSHA256":"06ef1a3339cea079de91a3a2a475dfe89e27d7610e3145191dec223cf4532536","screenshotSHA256":"d0617d60191b172a940fc8a46d3fed215168af2517328c8a5ff51d2559bc1049"},{"canonicalPixelSHA256":"f08765c168fd6cadab01507623144e28da26814c1d78891ca1a6e56da7ef15e1","captureIDHash":"83db7412b99b17d53f39163ef5a2a9019b610691abb32489378bc50a0c33cc07","debugBuildProvenanceSHA256":"e2dd07898be1d3aad5af7eb3c4e3bf8d9a346bae6b32d83fdc711b3eeeadb382","debugFixtureSHA256":"9ebe5a6e82e45a20ff33fea18ce883b5042c69d549abf2888ca34721c0f79378","fixtureReportSHA256":"d5ddd4a54621558130afaf86bd38e3929836c0a7566c0d5b8f80b75c8cf9d348","id":"settings-general-accessibility-text","kind":"visual","requestSHA256":"13cad00f5411b5406a7eab48f682dfe28689719ceaec223a1fa2ec1cce37cf7d","screenshotSHA256":"0f0c79da627df9f48c75d1966968c38221834bae41d5d4db896680a865e7ae5d"},{"canonicalPixelSHA256":"bd5a1ae2bb5d031815ea542e434a1f71cbe54d111742fe42c02f3b543aa84041","captureIDHash":"25e8197c910530352247f205fbfeea70b10fd2e4e3931d0d4c9f9520531f4552","debugBuildProvenanceSHA256":"e2dd07898be1d3aad5af7eb3c4e3bf8d9a346bae6b32d83fdc711b3eeeadb382","debugFixtureSHA256":"9ebe5a6e82e45a20ff33fea18ce883b5042c69d549abf2888ca34721c0f79378","fixtureReportSHA256":"23e2776d43cdfd9cf6018ddbe714264a9f88db2fedcd1baf3bd040619454add3","id":"settings-menu-bar-accessibility-text","kind":"visual","requestSHA256":"b98b34396a5424617583574f815c1cc15c43b40385b41238b4ecefa289077fef","screenshotSHA256":"3d27af09356120e9609178adeb5f1cd466c3777c3d6d5ae55e7f08a9cfdbb3c3"},{"canonicalPixelSHA256":"753f7dc79ee57170c27586c4922918de5f479607a7cc5f48dba1d0ed49c7061b","captureIDHash":"a42542c78b933c5a1c50853665eb0ff3788d217b2df72dccaaa93cef0a81b91d","debugBuildProvenanceSHA256":"e2dd07898be1d3aad5af7eb3c4e3bf8d9a346bae6b32d83fdc711b3eeeadb382","debugFixtureSHA256":"9ebe5a6e82e45a20ff33fea18ce883b5042c69d549abf2888ca34721c0f79378","fixtureReportSHA256":"24653e181fa0a91c2d6aa8a4e2c0253c4e08cc08a3cde9a1fe9e00ebaded8f68","id":"settings-notifications-accessibility-text","kind":"visual","requestSHA256":"8fcb30e4f1eb18e8f07b3a932bd9cd35dc16fb5d86e797912f1055c5e8365dba","screenshotSHA256":"f11b3d92d931258c3f05adb670138897f2546196ca52fc8a8d473b370162c640"},{"canonicalPixelSHA256":"9f3a7e33221f579a4bba20e7fe9981470e61382ff78edce683c94c957ae7a82b","captureIDHash":"4e009181500f7506b047829bd2c6d550951bb77df1feaefeb8ead43da47b89ae","debugBuildProvenanceSHA256":"e2dd07898be1d3aad5af7eb3c4e3bf8d9a346bae6b32d83fdc711b3eeeadb382","debugFixtureSHA256":"9ebe5a6e82e45a20ff33fea18ce883b5042c69d549abf2888ca34721c0f79378","fixtureReportSHA256":"a6f1f232b795a901ad8feacac367128db244b715ce98a29c562f85de0e5a0ed7","id":"settings-agent-workflows-accessibility-text","kind":"visual","requestSHA256":"60bb10f84325eb39f88d57aae132df4495f96d82cfabe5dfdc2ee7661a48c875","screenshotSHA256":"eda9891605615414414c886d27e034b48bc75b98ea4dab04db29cd7adf1501f4"},{"accessibilityRawSHA256":"8b489bb7b87db1e8170b68e004b228ecca3849a8f4bf3555eab32ea3502fa236","accessibilitySealedSHA256":"a4770d708085177508f853fda6f355e36643653f84be4fbabe3e3fe0db1ec638","axCollectorBuildProvenanceSHA256":"c6a1dd3754e5062e5e7f9e4c00f119bac4d8e2b4f8563d2562928c46ba807c6a","axCollectorSHA256":"accd31f94edd27c4dee3a6ee6aedf369e429d510279e5b5576adaefed12ceda9","captureIDHash":"b89cbcb0a951308b8012f094f774be4fb3d9eb26598224ebf82a18882063074e","debugBuildProvenanceSHA256":"e2dd07898be1d3aad5af7eb3c4e3bf8d9a346bae6b32d83fdc711b3eeeadb382","debugFixtureSHA256":"9ebe5a6e82e45a20ff33fea18ce883b5042c69d549abf2888ca34721c0f79378","fixtureReportSHA256":"a6a9dbee32dfc17631c234b8adb54bd5db732fc2ada93084347b90fd7a957129","id":"confirmed-owner-headline","kind":"accessibility","requestSHA256":"357c7554d9b7565645537f5eeab8e94e954d332fbb15229dcfc9c55e400ae42b"},{"accessibilityRawSHA256":"0c8df7f79395d599e7cf8924f386596ca8bd235162e3f368d6be84322c410682","accessibilitySealedSHA256":"8e4c60dca27df40686a5d051d02871fd453ee7f7a5d8a3b464da7d090d4a5c93","axCollectorBuildProvenanceSHA256":"c6a1dd3754e5062e5e7f9e4c00f119bac4d8e2b4f8563d2562928c46ba807c6a","axCollectorSHA256":"accd31f94edd27c4dee3a6ee6aedf369e429d510279e5b5576adaefed12ceda9","captureIDHash":"1479a85c8b9c2b4574ea2ffc56e52ee436a8104514d56c64572ecbe0ea844337","debugBuildProvenanceSHA256":"e2dd07898be1d3aad5af7eb3c4e3bf8d9a346bae6b32d83fdc711b3eeeadb382","debugFixtureSHA256":"9ebe5a6e82e45a20ff33fea18ce883b5042c69d549abf2888ca34721c0f79378","fixtureReportSHA256":"f51805aff6cd18f30c74f6655d8019213b9eddb8b17f70e0bf5d75602e161856","id":"correct-per-fan-target","kind":"accessibility","requestSHA256":"c40244a7207996bfd7d4a20c75467ea519bba3fc710cd0eac417c888d67f2bc7"},{"accessibilityRawSHA256":"5b23ce43ad2485fba49988b4e0fba7b3e2b6b2aac9c7f0fffeb49eff34ae399c","accessibilitySealedSHA256":"31776aff9d98791adc96545f488e47a6b6278730aba75f177b64753074ed420e","axCollectorBuildProvenanceSHA256":"c6a1dd3754e5062e5e7f9e4c00f119bac4d8e2b4f8563d2562928c46ba807c6a","axCollectorSHA256":"accd31f94edd27c4dee3a6ee6aedf369e429d510279e5b5576adaefed12ceda9","captureIDHash":"c36ffcc518f0826f924a532025c008a32598fb0dd9893d71f515d4b9903bcafb","debugBuildProvenanceSHA256":"e2dd07898be1d3aad5af7eb3c4e3bf8d9a346bae6b32d83fdc711b3eeeadb382","debugFixtureSHA256":"9ebe5a6e82e45a20ff33fea18ce883b5042c69d549abf2888ca34721c0f79378","fixtureReportSHA256":"fa0f69edf4a13c0fd7d356d05f33d87ff569633d353c7e99a1c85167aa6bb531","id":"six-adjustable-point-controls","kind":"accessibility","requestSHA256":"c40244a7207996bfd7d4a20c75467ea519bba3fc710cd0eac417c888d67f2bc7"},{"accessibilityRawSHA256":"306ba55dd681d26d881ca32c0b3d027510c032353641af1ace3c1b67456447a6","accessibilitySealedSHA256":"a6864e26cb6c137e4ebe15aed54b9d1d7fe80087e1a3a93aee3a9a8267b8926c","axCollectorBuildProvenanceSHA256":"c6a1dd3754e5062e5e7f9e4c00f119bac4d8e2b4f8563d2562928c46ba807c6a","axCollectorSHA256":"accd31f94edd27c4dee3a6ee6aedf369e429d510279e5b5576adaefed12ceda9","captureIDHash":"61aed40abca2ae76c03c7666f79c0a62823e0e9ffe166f080dc89065140fd8c0","debugBuildProvenanceSHA256":"e2dd07898be1d3aad5af7eb3c4e3bf8d9a346bae6b32d83fdc711b3eeeadb382","debugFixtureSHA256":"9ebe5a6e82e45a20ff33fea18ce883b5042c69d549abf2888ca34721c0f79378","fixtureReportSHA256":"e63004076514335162acac6f87e3b608ffadf8c28383d5a005450b5423e835fa","id":"sensor-selected-trait-value","kind":"accessibility","requestSHA256":"4a103f7896f25e5db1c8c154a9d5bd7f80b031b0b395f4fd4457e34456624e33"},{"accessibilityRawSHA256":"1592c0dfc5c9d50495788628cbcd038db14a3c34eb6d31104e0e583516bdb70f","accessibilitySealedSHA256":"5304c2ef83c5b647f1f51a6fc77e4efc4575f1b4ecd9156c08e24a0f6c53941c","axCollectorBuildProvenanceSHA256":"c6a1dd3754e5062e5e7f9e4c00f119bac4d8e2b4f8563d2562928c46ba807c6a","axCollectorSHA256":"accd31f94edd27c4dee3a6ee6aedf369e429d510279e5b5576adaefed12ceda9","captureIDHash":"d08159481bd97de85981e8d35bb8dd32f77be608b1043f07064639c0ee819161","debugBuildProvenanceSHA256":"e2dd07898be1d3aad5af7eb3c4e3bf8d9a346bae6b32d83fdc711b3eeeadb382","debugFixtureSHA256":"9ebe5a6e82e45a20ff33fea18ce883b5042c69d549abf2888ca34721c0f79378","fixtureReportSHA256":"321985dd645527985fe9efa1528394f401e21c630fcd52790e30dbc0858a462a","id":"explicit-temperature-role","kind":"accessibility","requestSHA256":"4a103f7896f25e5db1c8c154a9d5bd7f80b031b0b395f4fd4457e34456624e33"},{"accessibilityRawSHA256":"cc9cf1502be1efad12383647f060d52ec9a58839e0aa3ef1971388ea693c6fdc","accessibilitySealedSHA256":"c1a7e0918729f648a0cac7d1f138aabbc1334499755510c3bbacff7bc872647a","axCollectorBuildProvenanceSHA256":"c6a1dd3754e5062e5e7f9e4c00f119bac4d8e2b4f8563d2562928c46ba807c6a","axCollectorSHA256":"accd31f94edd27c4dee3a6ee6aedf369e429d510279e5b5576adaefed12ceda9","captureIDHash":"b09238fb90f98c381c5f702583a5794ab40201fce677a9229915d9c8c8b3ffc0","debugBuildProvenanceSHA256":"e2dd07898be1d3aad5af7eb3c4e3bf8d9a346bae6b32d83fdc711b3eeeadb382","debugFixtureSHA256":"9ebe5a6e82e45a20ff33fea18ce883b5042c69d549abf2888ca34721c0f79378","fixtureReportSHA256":"737737bf60548c0aa2c8ccf16a1636de9ac2a9f08e5024d08406b3071860483b","id":"notification-actions","kind":"accessibility","requestSHA256":"55c3cb22d211c8164b11e63689ed7dbf259c95e1f8ff9e612e1514aa7d6ea683"},{"accessibilityRawSHA256":"bcd7f3dcc91e5707d813bc9baee3a4b1778e1703e05bf760cd138a031184d5ce","accessibilitySealedSHA256":"019960dfc0ee2770fa7416dd8f783d4c61c87bf001d051b1f2a17cd7031abdf8","axCollectorBuildProvenanceSHA256":"c6a1dd3754e5062e5e7f9e4c00f119bac4d8e2b4f8563d2562928c46ba807c6a","axCollectorSHA256":"accd31f94edd27c4dee3a6ee6aedf369e429d510279e5b5576adaefed12ceda9","captureIDHash":"5ff3334bdb885dda3e34afd176b802e13a525b39c2394757c9b99985ed1b09d8","debugBuildProvenanceSHA256":"e2dd07898be1d3aad5af7eb3c4e3bf8d9a346bae6b32d83fdc711b3eeeadb382","debugFixtureSHA256":"9ebe5a6e82e45a20ff33fea18ce883b5042c69d549abf2888ca34721c0f79378","fixtureReportSHA256":"6ee8116dae961b5f45689bed7f22987765bd681ffce8afafacb4d57770b45ac8","id":"settings-logical-traversal","kind":"accessibility","requestSHA256":"987e3e64153ba3cd3b55b8d2c672ecf43f00a3b7d7957de79a6ff2103ef01aa1"},{"accessibilityRawSHA256":"b0ed87a9386837bc3ca0bc64134b446b3f9dd9b357162440abe3b981db3f5cc0","accessibilitySealedSHA256":"63733a5f7dbb57ed9517b075159ea681afbc88ebf9cd1af00cadfae9364a61f4","axCollectorBuildProvenanceSHA256":"c6a1dd3754e5062e5e7f9e4c00f119bac4d8e2b4f8563d2562928c46ba807c6a","axCollectorSHA256":"accd31f94edd27c4dee3a6ee6aedf369e429d510279e5b5576adaefed12ceda9","captureIDHash":"e15552f89fb00032cf46270d0ed26af3ce613a279c7b2bdc9166c8d15f66cacc","debugBuildProvenanceSHA256":"e2dd07898be1d3aad5af7eb3c4e3bf8d9a346bae6b32d83fdc711b3eeeadb382","debugFixtureSHA256":"9ebe5a6e82e45a20ff33fea18ce883b5042c69d549abf2888ca34721c0f79378","fixtureReportSHA256":"17d283643c907cdf4ee6c215db8f434a7c26fac8e3541da8132b6cd5c01b2115","id":"no-duplicate-chart-elements","kind":"accessibility","requestSHA256":"c40244a7207996bfd7d4a20c75467ea519bba3fc710cd0eac417c888d67f2bc7"},{"accessibilityRawSHA256":"bfecf34116a5e71116465760e11930fd7821174b9adb6ae207b0ec3da1cf7eac","accessibilitySealedSHA256":"229ffba87eec7562a4e6e54744c5b55258e095ad32bc20342d40fec5d88d4784","axCollectorBuildProvenanceSHA256":"c6a1dd3754e5062e5e7f9e4c00f119bac4d8e2b4f8563d2562928c46ba807c6a","axCollectorSHA256":"accd31f94edd27c4dee3a6ee6aedf369e429d510279e5b5576adaefed12ceda9","captureIDHash":"eb01d86a40a715d0dc5b7e627af2799cedc31c5c5ff9968c59298c9a0b243920","debugBuildProvenanceSHA256":"e2dd07898be1d3aad5af7eb3c4e3bf8d9a346bae6b32d83fdc711b3eeeadb382","debugFixtureSHA256":"9ebe5a6e82e45a20ff33fea18ce883b5042c69d549abf2888ca34721c0f79378","fixtureReportSHA256":"6d84d3b4f51ed19a623a688267c2ac36386b67d5afacc3c9f937c0d1e90706e0","id":"compact-main-scroll-reachable","kind":"accessibility","requestSHA256":"9d3fbb204496fe492711815609ee6ca684d66073373ff6ba2415ef7240679635"},{"accessibilityRawSHA256":"d7bf36b417ffa20126c2fe4b7000383792f2875620bb8acef1baeb2cc8b93916","accessibilitySealedSHA256":"f46501982746c2ff686e8b200ddf3d903a039c8724626585b717b2a02acc63a3","axCollectorBuildProvenanceSHA256":"c6a1dd3754e5062e5e7f9e4c00f119bac4d8e2b4f8563d2562928c46ba807c6a","axCollectorSHA256":"accd31f94edd27c4dee3a6ee6aedf369e429d510279e5b5576adaefed12ceda9","captureIDHash":"d8ce14c1d50f8f57d8a88378124fd6403b3a53d777d3f9c8a4d824c4de7f0909","debugBuildProvenanceSHA256":"e2dd07898be1d3aad5af7eb3c4e3bf8d9a346bae6b32d83fdc711b3eeeadb382","debugFixtureSHA256":"9ebe5a6e82e45a20ff33fea18ce883b5042c69d549abf2888ca34721c0f79378","fixtureReportSHA256":"b61617bea33cc9c830124e4d8d5dfdd09641b5fec6982e521f2fd9b031928d24","id":"settings-general-scroll-reachable","kind":"accessibility","requestSHA256":"4c086d7fdb10cf87ca5a6ebaf3703bc412ea27e75a4fe6b43646bbad25b3bfca"},{"accessibilityRawSHA256":"c71f456e962fdde314c2eea6fdc6171475098839fee23362cd3a2498b89d38c6","accessibilitySealedSHA256":"8f32fa1c2459c1755821642dbaf5c1c01460f4abd0afa5bd29706e089c1065c5","axCollectorBuildProvenanceSHA256":"c6a1dd3754e5062e5e7f9e4c00f119bac4d8e2b4f8563d2562928c46ba807c6a","axCollectorSHA256":"accd31f94edd27c4dee3a6ee6aedf369e429d510279e5b5576adaefed12ceda9","captureIDHash":"35f312d042ef815693bef01ac5fed73e23badddbe602589ba12edc10b63890eb","debugBuildProvenanceSHA256":"e2dd07898be1d3aad5af7eb3c4e3bf8d9a346bae6b32d83fdc711b3eeeadb382","debugFixtureSHA256":"9ebe5a6e82e45a20ff33fea18ce883b5042c69d549abf2888ca34721c0f79378","fixtureReportSHA256":"f070f027cab0df15b75a17803ece9712690f0fcd8d9c6a37cf33c9dd9fe122eb","id":"settings-menu-bar-scroll-reachable","kind":"accessibility","requestSHA256":"bfa01810723b71dc34a9c3b6a94a161960e60b6ac318db09970a6d74103e24f5"},{"accessibilityRawSHA256":"3643b58965930a531e49299ba0ada6e61e7c68eca97b5ed1a443ba019d333267","accessibilitySealedSHA256":"ec9a6bc61b8f9b8c0dd8a0f8a94bb345dc921d73855779e242308219441e9785","axCollectorBuildProvenanceSHA256":"c6a1dd3754e5062e5e7f9e4c00f119bac4d8e2b4f8563d2562928c46ba807c6a","axCollectorSHA256":"accd31f94edd27c4dee3a6ee6aedf369e429d510279e5b5576adaefed12ceda9","captureIDHash":"a60bea438e3db3df3bc8492d0d11f758fefef173f6035baaac72debb0c30eb47","debugBuildProvenanceSHA256":"e2dd07898be1d3aad5af7eb3c4e3bf8d9a346bae6b32d83fdc711b3eeeadb382","debugFixtureSHA256":"9ebe5a6e82e45a20ff33fea18ce883b5042c69d549abf2888ca34721c0f79378","fixtureReportSHA256":"3692eb71e1d865e2c309a78bbd6b32a38b586456c9b3a2e88c4fdf79c0a2339a","id":"settings-notifications-scroll-reachable","kind":"accessibility","requestSHA256":"93c79cd3053aaac63f572258c8e558b147f1e76cb23ddeb9335243ddb568cf3c"},{"accessibilityRawSHA256":"a08b64bb93a62d4f07993f2c1973c8144a03f92ec63e5de4e46f006043170c34","accessibilitySealedSHA256":"b9a158666675c11b809da2310df2c2861d3cf6caed21e92f940e0bd9c8009ed5","axCollectorBuildProvenanceSHA256":"c6a1dd3754e5062e5e7f9e4c00f119bac4d8e2b4f8563d2562928c46ba807c6a","axCollectorSHA256":"accd31f94edd27c4dee3a6ee6aedf369e429d510279e5b5576adaefed12ceda9","captureIDHash":"0f5bedeaa3d5c4b9208b814e5c08c64bebfb284eb39e9f1ed46b6ad1fa757c80","debugBuildProvenanceSHA256":"e2dd07898be1d3aad5af7eb3c4e3bf8d9a346bae6b32d83fdc711b3eeeadb382","debugFixtureSHA256":"9ebe5a6e82e45a20ff33fea18ce883b5042c69d549abf2888ca34721c0f79378","fixtureReportSHA256":"84e792ca28b5839878a14e551ba941af7840d49998cb602c3e17638c9cbdfc04","id":"settings-agent-workflows-scroll-reachable","kind":"accessibility","requestSHA256":"07994c8a7f853f1444deffc6447b8b21a8fc44bedc03b446b474d81b01254db1"}],"safetyAggregate":{"attemptedExternalMutations":0,"attemptedHardwareCommands":0,"finalReportsPassed":50,"modelStartSkipped":50,"realControlPathConstructions":0},"schemaID":"https://vifty.app/schemas/ui-review-automated-checkpoint-v1.schema.json","schemaVersion":1,"source":{"commit":"6ac429cbacf7cc3358c74493ab7461a43fa40275","manifestSHA256":"bc5734af3a2074bf244d0f0471ecc20aa4be1ef010637290eb0bcdaaed4145ed","tree":"cc22ef128818b41368d0f27411dc7abe67e98673"},"status":"automated-passed"} +{"counts":{"accessibility":13,"fixture":9,"total":50,"visual":28},"evidenceKind":"hardware-free-native-container-debug-fixture","hero":{"canonicalPixelSHA256":"42fe34fa9add13e9360f3030e2ff6458513dc70857d57b8d04c0ef5203ced332","captureIDHash":"874689bde574aa2f551e467b05be84427b90406b5756e4e1ceade7de234e2683","heroArtifactSHA256":"77bfdc4c2284b16096ca877b44c094576f773d6244e39b1aa738a7186f5d1a89","rowID":"main-1180x820-light","screenshotSHA256":"77bfdc4c2284b16096ca877b44c094576f773d6244e39b1aa738a7186f5d1a89"},"nonClaims":["full-evidence-bundle-not-committed","hardware-compatibility-not-claimed","release-readiness-not-claimed"],"products":{"axCollectorProvenance":{"buildTransactionID":"fae136354b90306736d101981e16da943951f666ccc76beb43e4d3da3408086b","configuration":"debug","productRole":"ax-collector","schemaID":"https://vifty.app/schemas/ui-review-build-provenance-v1.schema.json","schemaVersion":1,"sourceCommit":"3b869337c1249c18d26caf4a0558dda53152e775","sourceTree":"756300206274844d99fd8202d44592522cb3fcd0"},"axCollectorProvenanceSHA256":"752f5659f1b4a95b1840a11176acb10fe94cf9e6595457042d9ddcceb9e288d8","axCollectorSHA256":"bfcb45708b47898068b931d7a6b1432f99c2d69d2d7f77c3ee80b697d89646c2","buildTransactionID":"fae136354b90306736d101981e16da943951f666ccc76beb43e4d3da3408086b","debugFixtureProvenance":{"buildTransactionID":"fae136354b90306736d101981e16da943951f666ccc76beb43e4d3da3408086b","configuration":"debug","productRole":"debug-fixture-app","schemaID":"https://vifty.app/schemas/ui-review-build-provenance-v1.schema.json","schemaVersion":1,"sourceCommit":"3b869337c1249c18d26caf4a0558dda53152e775","sourceTree":"756300206274844d99fd8202d44592522cb3fcd0"},"debugFixtureProvenanceSHA256":"d4f2ae1112ccfcadeb73baf81b94465362a0655ab5b58540d3ed27c9b33c0f1b","debugFixtureSHA256":"e03fdc6ccce75c1afd177d4bd7ede1fb578fad6524951d895ec4df171afae5cc","releaseExclusionProvenance":{"buildTransactionID":"fae136354b90306736d101981e16da943951f666ccc76beb43e4d3da3408086b","configuration":"release","productRole":"release-exclusion","schemaID":"https://vifty.app/schemas/ui-review-build-provenance-v1.schema.json","schemaVersion":1,"sourceCommit":"3b869337c1249c18d26caf4a0558dda53152e775","sourceTree":"756300206274844d99fd8202d44592522cb3fcd0"},"releaseExclusionProvenanceSHA256":"5257482ba7ef555ce91685ec2c818542873e785addb46bc19c28f980969863dd","releaseExclusionSHA256":"a12a89edcb91785d3fc86caebe1177371cf9b292a410235741e58d00008cbb8a"},"reviewGates":{"visual":{"claims":[],"priorEvidence":"superseded","status":"pending"},"voiceOver":{"claims":[],"decision":"skipped-by-owner","status":"pending"}},"rows":[{"captureIDHash":"ba8bbb9cec1d2beacfb6d2b5c42c67807571dff7e9a261d5b278acf16aca5092","debugBuildProvenanceSHA256":"d4f2ae1112ccfcadeb73baf81b94465362a0655ab5b58540d3ed27c9b33c0f1b","debugFixtureSHA256":"e03fdc6ccce75c1afd177d4bd7ede1fb578fad6524951d895ec4df171afae5cc","fixtureReportSHA256":"deb1bb6a370564259c5d9440828ed29eb23bae24a3042bee5701b7941266f318","id":"healthy-auto","kind":"fixture","requestSHA256":"b98d552cdad1f859cb12af693cea3259509f804a9b9a69c780b7954912a55db1"},{"captureIDHash":"c498e96f579a084a639446e7421a4e8783323b6732842660f234ddbfc2edc3e1","debugBuildProvenanceSHA256":"d4f2ae1112ccfcadeb73baf81b94465362a0655ab5b58540d3ed27c9b33c0f1b","debugFixtureSHA256":"e03fdc6ccce75c1afd177d4bd7ede1fb578fad6524951d895ec4df171afae5cc","fixtureReportSHA256":"688d16a6695850ff795a7d14c1dda6690d1bcd9e6f2bdda91f162dc049626c30","id":"divergent-per-fan-curve-draft","kind":"fixture","requestSHA256":"c40244a7207996bfd7d4a20c75467ea519bba3fc710cd0eac417c888d67f2bc7"},{"captureIDHash":"37b1a1c3f37f481f688cd595374852f6473c9897d7c16b52ab8207053d45310c","debugBuildProvenanceSHA256":"d4f2ae1112ccfcadeb73baf81b94465362a0655ab5b58540d3ed27c9b33c0f1b","debugFixtureSHA256":"e03fdc6ccce75c1afd177d4bd7ede1fb578fad6524951d895ec4df171afae5cc","fixtureReportSHA256":"a7e4bf13ad763fc4286e9a8158afc9b8d0f11f123f8e90cbfaf097dd4d31d8cd","id":"active-manual","kind":"fixture","requestSHA256":"357c7554d9b7565645537f5eeab8e94e954d332fbb15229dcfc9c55e400ae42b"},{"captureIDHash":"93295a5162a083ef0bec89d2402791279b7409d0db8a2e7ace9b1f5899046e9b","debugBuildProvenanceSHA256":"d4f2ae1112ccfcadeb73baf81b94465362a0655ab5b58540d3ed27c9b33c0f1b","debugFixtureSHA256":"e03fdc6ccce75c1afd177d4bd7ede1fb578fad6524951d895ec4df171afae5cc","fixtureReportSHA256":"f17856b3a45c0be117d8ea60916a4c86f31b1e355c6dfb7e973a4d61ea9dc6cf","id":"recovery-mixed-ownership","kind":"fixture","requestSHA256":"922dae33cd3e1954f5d29d3edd1d58a66848229dc290c9e6c4e59627c0dac46e"},{"captureIDHash":"fe7dfaf6b904d2ae941d02c152e3bff28c27b70dbefad17e4fe8caf134c59924","debugBuildProvenanceSHA256":"d4f2ae1112ccfcadeb73baf81b94465362a0655ab5b58540d3ed27c9b33c0f1b","debugFixtureSHA256":"e03fdc6ccce75c1afd177d4bd7ede1fb578fad6524951d895ec4df171afae5cc","fixtureReportSHA256":"7c831b3d012d93ae9832f31bd7d485c6cc5e4215277c5e8bfe5592053781694a","id":"helper-blocked","kind":"fixture","requestSHA256":"266bc5b039931c7d15d938674309f8982d60faba4f17c3b95e798f9750901597"},{"captureIDHash":"534d6bfdc023036ec2ef8f43cf475edb73d6fd8ec2bad9fb9d030a5c38fe34f9","debugBuildProvenanceSHA256":"d4f2ae1112ccfcadeb73baf81b94465362a0655ab5b58540d3ed27c9b33c0f1b","debugFixtureSHA256":"e03fdc6ccce75c1afd177d4bd7ede1fb578fad6524951d895ec4df171afae5cc","fixtureReportSHA256":"2d685f444b314f78cb710c05940f4f4f897f1490c59c321c32f85559a5437fc9","id":"notification-denied","kind":"fixture","requestSHA256":"33b9aa34cb75aa2f7221410a4529d50d0228729eaad6c8990f6892ea59c7fa86"},{"captureIDHash":"0156c62ca4c7b7f4cf976a0c77d30afae9d5489e9bb03d1a496ecf167aebf3cf","debugBuildProvenanceSHA256":"d4f2ae1112ccfcadeb73baf81b94465362a0655ab5b58540d3ed27c9b33c0f1b","debugFixtureSHA256":"e03fdc6ccce75c1afd177d4bd7ede1fb578fad6524951d895ec4df171afae5cc","fixtureReportSHA256":"593aaabbe60ca0a2a633e76dc188e1265e38e2aae62cad58ff5e4e3063adf472","id":"edited-profile","kind":"fixture","requestSHA256":"17c3ec7261f8c10e2c607a9049642417406fb500ef3bf65141f1b30504456a07"},{"captureIDHash":"5867305bbef0a8e5ddcd50a5a41485cf58db30dc4593deea53c28ed596ba1716","debugBuildProvenanceSHA256":"d4f2ae1112ccfcadeb73baf81b94465362a0655ab5b58540d3ed27c9b33c0f1b","debugFixtureSHA256":"e03fdc6ccce75c1afd177d4bd7ede1fb578fad6524951d895ec4df171afae5cc","fixtureReportSHA256":"6ba836945c9b14c84f56e06a806cf2cd20efbff021dc27dba7a535e68578d879","id":"selected-vs-highest-temperature","kind":"fixture","requestSHA256":"4a103f7896f25e5db1c8c154a9d5bd7f80b031b0b395f4fd4457e34456624e33"},{"captureIDHash":"f9a7afa559d726893a8fb019c372fff80db8581d8217a3513e3762f5a4c53f31","debugBuildProvenanceSHA256":"d4f2ae1112ccfcadeb73baf81b94465362a0655ab5b58540d3ed27c9b33c0f1b","debugFixtureSHA256":"e03fdc6ccce75c1afd177d4bd7ede1fb578fad6524951d895ec4df171afae5cc","fixtureReportSHA256":"1d00e62ce4fb24ace005d67b65d0fd1686622f7dee8acd6753f3c79e69190089","id":"raw-spike-telemetry","kind":"fixture","requestSHA256":"cb850ba1eb313e2daafa9263efe5e3b3eaceda51dd4dd8a56e6725d085fd2fa7"},{"canonicalPixelSHA256":"74076c8a14e41151939af80a5d532fdcf9eb26c4ae5b1928929e902903d6619b","captureIDHash":"7cb5d50dee5cc095c59e613bc15a783a46e782382e9008eb1fd1c5cbc31418ca","debugBuildProvenanceSHA256":"d4f2ae1112ccfcadeb73baf81b94465362a0655ab5b58540d3ed27c9b33c0f1b","debugFixtureSHA256":"e03fdc6ccce75c1afd177d4bd7ede1fb578fad6524951d895ec4df171afae5cc","fixtureReportSHA256":"c389f8b49e465b14c1554d13cbc9566b6b4cf4dcca7786a430b13189766afed1","id":"main-780x480-light","kind":"visual","requestSHA256":"6343f043c95507a0bf3bca03d4b2640882f1da998b7c760d51420815137ad42c","screenshotSHA256":"d428a4f933da448817f4879502998ee083456ea7c5148739e1759d54ad5799f2"},{"canonicalPixelSHA256":"461d62e06ad3f3de2bdd4e988ef4f9bbfffbaf13bf0a3062a0ad77aa4043d813","captureIDHash":"ce2b7266c542aca3adb1eff808e944dd9f6468ff2a1a7146732ae8f054600b80","debugBuildProvenanceSHA256":"d4f2ae1112ccfcadeb73baf81b94465362a0655ab5b58540d3ed27c9b33c0f1b","debugFixtureSHA256":"e03fdc6ccce75c1afd177d4bd7ede1fb578fad6524951d895ec4df171afae5cc","fixtureReportSHA256":"c066d32b2a00c80622dd07cbe2a351a522999db159692e8e57349f712f0e9691","id":"main-780x480-dark","kind":"visual","requestSHA256":"7a7a94aba066bc74a0a4d39bd907c4a967b151e3d32ea5406bdc9b0bc1a10791","screenshotSHA256":"2ce957137af73fac6755b0d8d0235b61c30de00176ef7877ce0371a385037def"},{"canonicalPixelSHA256":"42fe34fa9add13e9360f3030e2ff6458513dc70857d57b8d04c0ef5203ced332","captureIDHash":"874689bde574aa2f551e467b05be84427b90406b5756e4e1ceade7de234e2683","debugBuildProvenanceSHA256":"d4f2ae1112ccfcadeb73baf81b94465362a0655ab5b58540d3ed27c9b33c0f1b","debugFixtureSHA256":"e03fdc6ccce75c1afd177d4bd7ede1fb578fad6524951d895ec4df171afae5cc","fixtureReportSHA256":"331d4a6d7a3bb497a4f18109c1e360fccc7ceb45353fc809e407b892833ce1e1","id":"main-1180x820-light","kind":"visual","requestSHA256":"b98d552cdad1f859cb12af693cea3259509f804a9b9a69c780b7954912a55db1","screenshotSHA256":"77bfdc4c2284b16096ca877b44c094576f773d6244e39b1aa738a7186f5d1a89"},{"canonicalPixelSHA256":"b17b4791855d949a0590d0dd845fd620060df9f07be26ed9449ba66158e3eeaf","captureIDHash":"79fe5073dfe115cdd27d21c23f4365e097d4669ce9605970770fab10b2b98aa5","debugBuildProvenanceSHA256":"d4f2ae1112ccfcadeb73baf81b94465362a0655ab5b58540d3ed27c9b33c0f1b","debugFixtureSHA256":"e03fdc6ccce75c1afd177d4bd7ede1fb578fad6524951d895ec4df171afae5cc","fixtureReportSHA256":"0eda6038d81b51dacb93d1f7aa3a4777e948ca9614a92672d43479926caf06ae","id":"main-1180x820-dark","kind":"visual","requestSHA256":"0df86b8f7a07fa1e934a40ad455b217aeafc3960e918904434292a7a2bdd8b91","screenshotSHA256":"2d7874f72dda5b2d54c2a50034196e08e3e2c17f69d2bf718269cea9bd830e41"},{"canonicalPixelSHA256":"f259553554a5d3ebdc4331447ca96db5387098185364e5b8f3c3671cde41b124","captureIDHash":"3491e075da65ed261b060aca860012ac66d3067026b3b43273edb602c7b18172","debugBuildProvenanceSHA256":"d4f2ae1112ccfcadeb73baf81b94465362a0655ab5b58540d3ed27c9b33c0f1b","debugFixtureSHA256":"e03fdc6ccce75c1afd177d4bd7ede1fb578fad6524951d895ec4df171afae5cc","fixtureReportSHA256":"016717748147828ed4ac9fd3a7746e9c7dcc5984350e4c33f296c50f7f713234","id":"main-1280x720-light","kind":"visual","requestSHA256":"edcb941caf1c1240669a4e9be836e828c568fed10c251eb284ef9d81bf70753c","screenshotSHA256":"b6d3269b95035fbca880780d02433bfa41e14af096df71ee33d7039c5245757c"},{"canonicalPixelSHA256":"7e066eaaa346c451349fcfad3b59bf23650c3b173f47c5faf4176b60b0666895","captureIDHash":"dfe9b4a0aa63281b4277494c68c32392da6ede7afa68f53e21c5b2805c25985d","debugBuildProvenanceSHA256":"d4f2ae1112ccfcadeb73baf81b94465362a0655ab5b58540d3ed27c9b33c0f1b","debugFixtureSHA256":"e03fdc6ccce75c1afd177d4bd7ede1fb578fad6524951d895ec4df171afae5cc","fixtureReportSHA256":"ff9f22b7d05046678850c3483e6cf521ef656f6092bcc1e6b86d82d41faef012","id":"main-1280x720-dark","kind":"visual","requestSHA256":"6367c41b8dfa354e76ac7b3bfaecd5ffafbc78ec8de475cef7b13640b3dd05a4","screenshotSHA256":"b5e4f0a2aa20cb05f03b439cba3628cf28522f9fc2d310f657290e70d6b1b52d"},{"canonicalPixelSHA256":"d606e47fc33e278fbbf3526e78d2cd7dd0a14a2bde2351b683ae9610f6c61920","captureIDHash":"75bf5d446409608753bb2074be3171a2f78de7b2519a8f6caeb2d54ddf160460","debugBuildProvenanceSHA256":"d4f2ae1112ccfcadeb73baf81b94465362a0655ab5b58540d3ed27c9b33c0f1b","debugFixtureSHA256":"e03fdc6ccce75c1afd177d4bd7ede1fb578fad6524951d895ec4df171afae5cc","fixtureReportSHA256":"96ea1579ab8fb1bc433b81daa6613ba6874efd99bdb14b912ca69b9559f126dc","id":"main-1500x900-light","kind":"visual","requestSHA256":"583ec26d1455fdcdb4d931d699a3c478a03cfaf87d81d9128b865ecf2fc57992","screenshotSHA256":"e75f2afaa53805355bcdcb1258a65cebb4af6168333bf195dd7e6fe3d7957223"},{"canonicalPixelSHA256":"8c84479886accb90c0170215a0b623380b5b27d1548c3bb1e944ee196281494c","captureIDHash":"6d4f3bee4ebedf439376853a39180c5def249ffa48cf2635dc4153d5fe5b92f4","debugBuildProvenanceSHA256":"d4f2ae1112ccfcadeb73baf81b94465362a0655ab5b58540d3ed27c9b33c0f1b","debugFixtureSHA256":"e03fdc6ccce75c1afd177d4bd7ede1fb578fad6524951d895ec4df171afae5cc","fixtureReportSHA256":"733bb93496fd3d3431de2cd43b932e6e7d7e74d888395c90bc840181fad1033f","id":"main-1500x900-dark","kind":"visual","requestSHA256":"86473313ce5225cde9c29ced6f68f3513b0fd9e0a647a0d97aeaf2cbf4d6b73f","screenshotSHA256":"ecdbd7a9c5f27e97ee6e8f22054e9a761284a4781a8ecc9f240e47ddf682af5a"},{"canonicalPixelSHA256":"256ecfa238aa4bba6410baccef9aedc931d07bb43903602a9b54c8e150d75d4b","captureIDHash":"3191758a8c657f4985892462ab765f3feaaf7f794a3be4d734a3f4196368cb87","debugBuildProvenanceSHA256":"d4f2ae1112ccfcadeb73baf81b94465362a0655ab5b58540d3ed27c9b33c0f1b","debugFixtureSHA256":"e03fdc6ccce75c1afd177d4bd7ede1fb578fad6524951d895ec4df171afae5cc","fixtureReportSHA256":"5144181026b59ec9e0118bb8e94e0debad8dd2dcdd2e86ef570f09d935196e34","id":"state-divergent-per-fan-curve-draft","kind":"visual","requestSHA256":"c40244a7207996bfd7d4a20c75467ea519bba3fc710cd0eac417c888d67f2bc7","screenshotSHA256":"545c346757ce6a803737aa658b0f4c26247a8c80fceee69c8a574d345bb75a12"},{"canonicalPixelSHA256":"0ad95f87c94de44c8456e36dcb633b87dfd6add2f4740742ee2751c6369422d2","captureIDHash":"3b12b16f03c6d15239d1787fd6489e1aba5e398fe11dc57df49c36ca6de3b933","debugBuildProvenanceSHA256":"d4f2ae1112ccfcadeb73baf81b94465362a0655ab5b58540d3ed27c9b33c0f1b","debugFixtureSHA256":"e03fdc6ccce75c1afd177d4bd7ede1fb578fad6524951d895ec4df171afae5cc","fixtureReportSHA256":"e639ff6bf1f80e574b55487a05759e6d785020f453a41c461e4a1583daeaecf9","id":"state-active-manual","kind":"visual","requestSHA256":"357c7554d9b7565645537f5eeab8e94e954d332fbb15229dcfc9c55e400ae42b","screenshotSHA256":"8b8a659bca3d21e7d297e74d8341c596db0233531aa44ae87ffcb1957a1e2912"},{"canonicalPixelSHA256":"8f16bc4324fe93d1a7d41f198e60e714a6109d8d3e8dd3c86d1c26ba473ec2e5","captureIDHash":"0abf741f6ea9b6acfa736917d30af752df1136edd07905bf7db35a31900e1e15","debugBuildProvenanceSHA256":"d4f2ae1112ccfcadeb73baf81b94465362a0655ab5b58540d3ed27c9b33c0f1b","debugFixtureSHA256":"e03fdc6ccce75c1afd177d4bd7ede1fb578fad6524951d895ec4df171afae5cc","fixtureReportSHA256":"6ff0f3b09e10138416b8c6bfac817b52cc5b4c734468bd991c6808d508e0e1e6","id":"state-recovery-mixed-ownership","kind":"visual","requestSHA256":"922dae33cd3e1954f5d29d3edd1d58a66848229dc290c9e6c4e59627c0dac46e","screenshotSHA256":"08be09730f7c890f35bfd93581c29a917dcbb759bd3b273c05bfca35557c8325"},{"canonicalPixelSHA256":"8fe4131c1535c5f3f6ea5ea352c75d66356388bc27fa50902a8fb05c7ee23225","captureIDHash":"98c5ab00e16b98d23ef4f249f4416098d52d38b6e98e9cfd03c381b6d760e257","debugBuildProvenanceSHA256":"d4f2ae1112ccfcadeb73baf81b94465362a0655ab5b58540d3ed27c9b33c0f1b","debugFixtureSHA256":"e03fdc6ccce75c1afd177d4bd7ede1fb578fad6524951d895ec4df171afae5cc","fixtureReportSHA256":"e27dab75004b0d1d045298662660fcd9d9c9718b3aade42e6c85718e06c350cf","id":"state-helper-blocked","kind":"visual","requestSHA256":"266bc5b039931c7d15d938674309f8982d60faba4f17c3b95e798f9750901597","screenshotSHA256":"0d35fb6bc8355a3976f872833624520dfa03cc7832a1b5a8096d8056cefff899"},{"canonicalPixelSHA256":"f7e61fe1f806f435d167efa0b05d4ba98e8f285f32ca8d7d334e5d5e2a9b1a2b","captureIDHash":"333bb47578fd319db63d9741728d50163aaa3d9f0f4ec0499f0e1acc0e6995e6","debugBuildProvenanceSHA256":"d4f2ae1112ccfcadeb73baf81b94465362a0655ab5b58540d3ed27c9b33c0f1b","debugFixtureSHA256":"e03fdc6ccce75c1afd177d4bd7ede1fb578fad6524951d895ec4df171afae5cc","fixtureReportSHA256":"1b722ce86733033c8f03983f2257feacbba0ef22e1422972faa2d609c0f4e37b","id":"state-notification-denied","kind":"visual","requestSHA256":"55c3cb22d211c8164b11e63689ed7dbf259c95e1f8ff9e612e1514aa7d6ea683","screenshotSHA256":"7c9710a724aecdd57d7cc0debc2c2c3571d9a043cccb96747b35729934f44e7a"},{"canonicalPixelSHA256":"2a24502516d4df8e266823c0a5d7b6af91e62f6132ee864c73a59545257bec91","captureIDHash":"81849198f873b88ec0bc30b7d87d4b5f38e5b14346381453f254b478c99e43be","debugBuildProvenanceSHA256":"d4f2ae1112ccfcadeb73baf81b94465362a0655ab5b58540d3ed27c9b33c0f1b","debugFixtureSHA256":"e03fdc6ccce75c1afd177d4bd7ede1fb578fad6524951d895ec4df171afae5cc","fixtureReportSHA256":"8daece70dc353dc5a1bda0bf0ed9e71f2957dafbcd28052035fdf61d8acdcc62","id":"state-edited-profile","kind":"visual","requestSHA256":"17c3ec7261f8c10e2c607a9049642417406fb500ef3bf65141f1b30504456a07","screenshotSHA256":"af1947ec170cc2015f709851091875c250c9c8904782883e9aa6ffb57b9f633f"},{"canonicalPixelSHA256":"184c9cc218b3cfeb46e5d1cd29ddee7d5e65eb867769c2194872290c23def3b2","captureIDHash":"32519f50114fd416f47e65518575396c1d474ee78d2f9e5d6ee071d6492acc56","debugBuildProvenanceSHA256":"d4f2ae1112ccfcadeb73baf81b94465362a0655ab5b58540d3ed27c9b33c0f1b","debugFixtureSHA256":"e03fdc6ccce75c1afd177d4bd7ede1fb578fad6524951d895ec4df171afae5cc","fixtureReportSHA256":"a295054660b8940380b51ae151a5aee543a1f65c465c69ef970d023884227a79","id":"state-selected-vs-highest-temperature","kind":"visual","requestSHA256":"4a103f7896f25e5db1c8c154a9d5bd7f80b031b0b395f4fd4457e34456624e33","screenshotSHA256":"1918d42d4334d5de2712cf93c9bb51fde45ebc93bc9989e3beae9861ca2ce803"},{"canonicalPixelSHA256":"f4e6a2f2c87096bec6b90cf96a3dc0807e3b985d3c9614409069b17583251189","captureIDHash":"2acc45f68e26bd9f9a783797d007874ef95fc5b7e5e1e431e55c46420d28f431","debugBuildProvenanceSHA256":"d4f2ae1112ccfcadeb73baf81b94465362a0655ab5b58540d3ed27c9b33c0f1b","debugFixtureSHA256":"e03fdc6ccce75c1afd177d4bd7ede1fb578fad6524951d895ec4df171afae5cc","fixtureReportSHA256":"b7d82089766997ea311d0eb1fc085c29c65751afac976e921d54f9c4b8a78aad","id":"state-raw-spike-telemetry","kind":"visual","requestSHA256":"cb850ba1eb313e2daafa9263efe5e3b3eaceda51dd4dd8a56e6725d085fd2fa7","screenshotSHA256":"44d5a595f896759d596d39d58745a5970fe34e9aae232529e7eba3b55acb6c46"},{"canonicalPixelSHA256":"5779213ced63da391e54cd06a939e30910a128c23622e66992314a87d1cbfa6c","captureIDHash":"b732ae6dd144cf24cd8bb9a9e13e9bcbfc41782baef772e028c00c6e596fa85e","debugBuildProvenanceSHA256":"d4f2ae1112ccfcadeb73baf81b94465362a0655ab5b58540d3ed27c9b33c0f1b","debugFixtureSHA256":"e03fdc6ccce75c1afd177d4bd7ede1fb578fad6524951d895ec4df171afae5cc","fixtureReportSHA256":"1a2a849d343a971481ef7e76ee5b1c69fd0e33911be0f859638d9b42b2952431","id":"settings-general","kind":"visual","requestSHA256":"987e3e64153ba3cd3b55b8d2c672ecf43f00a3b7d7957de79a6ff2103ef01aa1","screenshotSHA256":"e218f53cf24f219f40fd4d3cbd3cd76533f7d3ca670bd31e6b5b6a36c5162082"},{"canonicalPixelSHA256":"0edfa965601687dc41a0ec4fe4e436a821310372d71df4727f6de3fffbd028a9","captureIDHash":"e2ade2e6e8b47563f1a44e3268662397914e6a43c6fbf5516e886d2b916f4c54","debugBuildProvenanceSHA256":"d4f2ae1112ccfcadeb73baf81b94465362a0655ab5b58540d3ed27c9b33c0f1b","debugFixtureSHA256":"e03fdc6ccce75c1afd177d4bd7ede1fb578fad6524951d895ec4df171afae5cc","fixtureReportSHA256":"e38cbcdeaef29ed7d5ed090adf6a44ecdb27bb317300f61a153d0ba2d98f9284","id":"settings-menu-bar","kind":"visual","requestSHA256":"398d0ace3621d468b0dc8cf7dce1ae4a23a064dfbf60ed702cbd819e97e821fd","screenshotSHA256":"9f673eb27a191424072fea2290e92ce7d9eb3663cb5821cb954957b774814102"},{"canonicalPixelSHA256":"90b09f5fd0e8bd6542052ca8cb6f9b56ad06edba9a26ba85a7f33938ae059e0e","captureIDHash":"7e7251afebd5e52f4802f01b78001ad4e2006e9726571de8f9987aadbdcd641b","debugBuildProvenanceSHA256":"d4f2ae1112ccfcadeb73baf81b94465362a0655ab5b58540d3ed27c9b33c0f1b","debugFixtureSHA256":"e03fdc6ccce75c1afd177d4bd7ede1fb578fad6524951d895ec4df171afae5cc","fixtureReportSHA256":"4c8a19eb8872416d493e004f59669c3ecc0e1b9e09be2438af780064797888a8","id":"settings-notifications","kind":"visual","requestSHA256":"fbd20a8956ac61d5fb44e16151ddb9834b0e1c9966b540bb787c82f53a32568f","screenshotSHA256":"6f265e6031c39b4f4538cde00975d2bd5850139faa8110c7193ffa1c31b27008"},{"canonicalPixelSHA256":"49c540a61d15487776ca0827a60531d17ad0ec4f1d0d0e01bb07bb456bdcbc1a","captureIDHash":"ed474aca0e306c033dbef57b9cf804b5d669a7345b4e548db873086b5349db4d","debugBuildProvenanceSHA256":"d4f2ae1112ccfcadeb73baf81b94465362a0655ab5b58540d3ed27c9b33c0f1b","debugFixtureSHA256":"e03fdc6ccce75c1afd177d4bd7ede1fb578fad6524951d895ec4df171afae5cc","fixtureReportSHA256":"6113baaa08a607188f4b74cb66efc48c5717e7d41b5cd23e6da53509defc4d3e","id":"settings-agent-workflows","kind":"visual","requestSHA256":"69d15a3f7fe3c38ef059c8f07298620dccd101f4d678c5759b19b2c3bd1f1970","screenshotSHA256":"62de000d8de491b3572caab097876386f9ada015e2bd468fedcfe0d5b3061a7c"},{"canonicalPixelSHA256":"fdd901ffaafe5151f74fa380a47be0e706b7378720981bc9c9672c7f9a237ac4","captureIDHash":"d8e57ca953790a9f1eb0c68d26c1217df801246ef6b1c1b96704a56ac0fa2144","debugBuildProvenanceSHA256":"d4f2ae1112ccfcadeb73baf81b94465362a0655ab5b58540d3ed27c9b33c0f1b","debugFixtureSHA256":"e03fdc6ccce75c1afd177d4bd7ede1fb578fad6524951d895ec4df171afae5cc","fixtureReportSHA256":"22316b3ece3d864ae35048927c2fc10c7a9bcd60ceca7a399190e38147102f5a","id":"menu-popover","kind":"visual","requestSHA256":"cb03f3378c6539e637e300b154f9bae22b5790e2194b5c5d721072fce285b76c","screenshotSHA256":"eeef7bbc1d525a12c837ed375d019ef6d0572c67f6a7a3d3019f5d5bb3c968ee"},{"canonicalPixelSHA256":"aba25df3a105be22df12949fd75eab620f1def56cbf28f23a3b809128c0341da","captureIDHash":"050c6ba33091111e05376fcd9993f9c90a3a4a2b03d3d82a7400e7b1a40b777c","debugBuildProvenanceSHA256":"d4f2ae1112ccfcadeb73baf81b94465362a0655ab5b58540d3ed27c9b33c0f1b","debugFixtureSHA256":"e03fdc6ccce75c1afd177d4bd7ede1fb578fad6524951d895ec4df171afae5cc","fixtureReportSHA256":"4bd239ceb83552d426dbdef66992ae1d71f808b7108f968c36e4b3dcea65006d","id":"main-increase-contrast","kind":"visual","requestSHA256":"6c833933f9d386ebe2a4a1274cf627c26065b32d6795a50053637f65bf292140","screenshotSHA256":"8887aad35d70aaf6a5162d7528cd94f58dad5b3f7bbdce650a1a7d0c5153fad0"},{"canonicalPixelSHA256":"7a91ce346b0d8c9fec3777fc2cccc6e30b83588cd0516b33ffca0731e7c5e87b","captureIDHash":"d9df091897bfbf80b7a516820c9e7a6933460818113d336936fd3bf01f77211b","debugBuildProvenanceSHA256":"d4f2ae1112ccfcadeb73baf81b94465362a0655ab5b58540d3ed27c9b33c0f1b","debugFixtureSHA256":"e03fdc6ccce75c1afd177d4bd7ede1fb578fad6524951d895ec4df171afae5cc","fixtureReportSHA256":"e77cc29829587b25de92c12235de287260ae0aaee73a176579c4b3928a1cf882","id":"main-reduce-transparency","kind":"visual","requestSHA256":"3f511ec536dde4fa5f62b835eab9f3e7f3665bcd04f51e90c7b27c1471a67146","screenshotSHA256":"843a01c3f5ca32f0c7c8a9743b4f0a578cb87b78dd92443fa2ecff83a37d87d0"},{"canonicalPixelSHA256":"313d5b773c2b7a4733a044729dfa22dfd1858c304ff0c9b6cf8791c0ea2cb542","captureIDHash":"d189669767651f8c746e7112a65da998c06a26514d43836fb94e26a4132b40f0","debugBuildProvenanceSHA256":"d4f2ae1112ccfcadeb73baf81b94465362a0655ab5b58540d3ed27c9b33c0f1b","debugFixtureSHA256":"e03fdc6ccce75c1afd177d4bd7ede1fb578fad6524951d895ec4df171afae5cc","fixtureReportSHA256":"2d3952fd82728baeb61c4754d3e092d698d273e3352a40183321d283752a21d6","id":"main-accessibility-text","kind":"visual","requestSHA256":"06ef1a3339cea079de91a3a2a475dfe89e27d7610e3145191dec223cf4532536","screenshotSHA256":"9d78d42f9f30a06b733e23e1f348f5e2906aef322f7bf3bd829abf338add2dc7"},{"canonicalPixelSHA256":"3b235d77b5f0833999777f1b35b7321f466a84be3b4058463f0a03df4aadeecb","captureIDHash":"e1de44a9d3b4db97a248b3850027638818c4bd786591d80740556f8d8faa2e8a","debugBuildProvenanceSHA256":"d4f2ae1112ccfcadeb73baf81b94465362a0655ab5b58540d3ed27c9b33c0f1b","debugFixtureSHA256":"e03fdc6ccce75c1afd177d4bd7ede1fb578fad6524951d895ec4df171afae5cc","fixtureReportSHA256":"782a3075eb93812dbdfbb81e4604c51b196f32346c0a9e8c9257b62750f6fd36","id":"settings-general-accessibility-text","kind":"visual","requestSHA256":"13cad00f5411b5406a7eab48f682dfe28689719ceaec223a1fa2ec1cce37cf7d","screenshotSHA256":"c3de664a8924abd3cda0dcb2b876f4f72210ae337798f8d3085ca2d57da00a11"},{"canonicalPixelSHA256":"dcc1c62c9bdd03975685c0412c25c24f3cf24b9cd1825650597ff6e442203184","captureIDHash":"babac13e9a4d6ef55059037a2033878bf1c13973383b6f838adbc4b54ae02396","debugBuildProvenanceSHA256":"d4f2ae1112ccfcadeb73baf81b94465362a0655ab5b58540d3ed27c9b33c0f1b","debugFixtureSHA256":"e03fdc6ccce75c1afd177d4bd7ede1fb578fad6524951d895ec4df171afae5cc","fixtureReportSHA256":"63efae131e5d292e7606326e46d5c2b29c08b5ddcaa4fd3f3a952c6d011560a4","id":"settings-menu-bar-accessibility-text","kind":"visual","requestSHA256":"b98b34396a5424617583574f815c1cc15c43b40385b41238b4ecefa289077fef","screenshotSHA256":"cbe4029401c85b651f15e1ddcadc19c3afdbe9599968c4a4915059bd67380e90"},{"canonicalPixelSHA256":"ef248153233c0d3a8b4a0a0bdbf230053ba225db233ed4838c18400bc275a820","captureIDHash":"55e9436b4daba6989cb20cbaf7405cb98287e24fae37e994aa9b408e95870e58","debugBuildProvenanceSHA256":"d4f2ae1112ccfcadeb73baf81b94465362a0655ab5b58540d3ed27c9b33c0f1b","debugFixtureSHA256":"e03fdc6ccce75c1afd177d4bd7ede1fb578fad6524951d895ec4df171afae5cc","fixtureReportSHA256":"f585d5c2a3be4acfa49f209ba57579f2bf4922c2a5225e8ad625f69470134555","id":"settings-notifications-accessibility-text","kind":"visual","requestSHA256":"8fcb30e4f1eb18e8f07b3a932bd9cd35dc16fb5d86e797912f1055c5e8365dba","screenshotSHA256":"b7a2d88f6d40a63bf1e63d369dc3379f1019d402c45663329a3518730459ca18"},{"canonicalPixelSHA256":"fdc4dc792d92f44d7b44973d23080ce95c0587c174d9c29803e9cf78933fded4","captureIDHash":"5720dd429540c8951f8069ad650c6a0ee731c995d9e509e249628703c39678c1","debugBuildProvenanceSHA256":"d4f2ae1112ccfcadeb73baf81b94465362a0655ab5b58540d3ed27c9b33c0f1b","debugFixtureSHA256":"e03fdc6ccce75c1afd177d4bd7ede1fb578fad6524951d895ec4df171afae5cc","fixtureReportSHA256":"fcf3f34273b533b00f2a8e5681486ec8ab12445bf2c2231b3ee6244c63f6b7ce","id":"settings-agent-workflows-accessibility-text","kind":"visual","requestSHA256":"60bb10f84325eb39f88d57aae132df4495f96d82cfabe5dfdc2ee7661a48c875","screenshotSHA256":"f7df137d87e4ac2dd93e51abe19e3b967eb181589b3c8c2a546c974d1ded4943"},{"accessibilityRawSHA256":"ef189c0d724ed4736049cfc5a2fe4f7a6738155f49f240d15e0d8bb469354b9f","accessibilitySealedSHA256":"e8e28702f95a37d5273d4625c9bbbb113788b28d3c8ea593560333d840bc9f54","axCollectorBuildProvenanceSHA256":"752f5659f1b4a95b1840a11176acb10fe94cf9e6595457042d9ddcceb9e288d8","axCollectorSHA256":"bfcb45708b47898068b931d7a6b1432f99c2d69d2d7f77c3ee80b697d89646c2","captureIDHash":"e7fe1af7399b640036bfcf356b060756bc25e9c12a09e39bd6db4a3b3259d2b7","debugBuildProvenanceSHA256":"d4f2ae1112ccfcadeb73baf81b94465362a0655ab5b58540d3ed27c9b33c0f1b","debugFixtureSHA256":"e03fdc6ccce75c1afd177d4bd7ede1fb578fad6524951d895ec4df171afae5cc","fixtureReportSHA256":"8ef0acaf79f70cf6cfe0d169758a273178e8b1d8ff927a1f5d67f5edb76d6f74","id":"confirmed-owner-headline","kind":"accessibility","requestSHA256":"357c7554d9b7565645537f5eeab8e94e954d332fbb15229dcfc9c55e400ae42b"},{"accessibilityRawSHA256":"9891c478d77ddc27a8d9c75134adbfa34efba3ff9871e004917d815e5394bec0","accessibilitySealedSHA256":"72d7d5da30de992bad93023af7d5288a0623474e9b1024039ad4136acd089b58","axCollectorBuildProvenanceSHA256":"752f5659f1b4a95b1840a11176acb10fe94cf9e6595457042d9ddcceb9e288d8","axCollectorSHA256":"bfcb45708b47898068b931d7a6b1432f99c2d69d2d7f77c3ee80b697d89646c2","captureIDHash":"f950543b0d4bbcf671b8f6a111be8e0228cdcc1bd4002e15dfafaf58a674be2b","debugBuildProvenanceSHA256":"d4f2ae1112ccfcadeb73baf81b94465362a0655ab5b58540d3ed27c9b33c0f1b","debugFixtureSHA256":"e03fdc6ccce75c1afd177d4bd7ede1fb578fad6524951d895ec4df171afae5cc","fixtureReportSHA256":"90b82ff48a8a38a3787124176378c508d0f3d619cf4884e4e75a8e439b8b80c1","id":"correct-per-fan-target","kind":"accessibility","requestSHA256":"c40244a7207996bfd7d4a20c75467ea519bba3fc710cd0eac417c888d67f2bc7"},{"accessibilityRawSHA256":"7cdfdeb2a0a8b7fb15fe872e548152217a8f7855ff96b43d72048be97d20a8dd","accessibilitySealedSHA256":"f4d004a80eb571e5d9d63aa36ab762aa36e983773860446d13fedf2829535c93","axCollectorBuildProvenanceSHA256":"752f5659f1b4a95b1840a11176acb10fe94cf9e6595457042d9ddcceb9e288d8","axCollectorSHA256":"bfcb45708b47898068b931d7a6b1432f99c2d69d2d7f77c3ee80b697d89646c2","captureIDHash":"9bc2e57b6d10116ad46d66426358a81d019cc513d521a319d1dc894ccf5a6030","debugBuildProvenanceSHA256":"d4f2ae1112ccfcadeb73baf81b94465362a0655ab5b58540d3ed27c9b33c0f1b","debugFixtureSHA256":"e03fdc6ccce75c1afd177d4bd7ede1fb578fad6524951d895ec4df171afae5cc","fixtureReportSHA256":"6ef31333024ecb156535131c5d0d377e0f1aef4214431652c71f4e232f5e4a9d","id":"six-adjustable-point-controls","kind":"accessibility","requestSHA256":"c40244a7207996bfd7d4a20c75467ea519bba3fc710cd0eac417c888d67f2bc7"},{"accessibilityRawSHA256":"69677f3873b393f80a421c353c7dbe447b47d639c1f01ae9ee6b656e8a4fa47d","accessibilitySealedSHA256":"6a9796c1a4752322c35452696e8df126f6eaefce8578a31c91814faf6a161b83","axCollectorBuildProvenanceSHA256":"752f5659f1b4a95b1840a11176acb10fe94cf9e6595457042d9ddcceb9e288d8","axCollectorSHA256":"bfcb45708b47898068b931d7a6b1432f99c2d69d2d7f77c3ee80b697d89646c2","captureIDHash":"b9e21b896b69d6577e349d9b65ddc74a2d02790ea207097fc148fca67ee2ddc0","debugBuildProvenanceSHA256":"d4f2ae1112ccfcadeb73baf81b94465362a0655ab5b58540d3ed27c9b33c0f1b","debugFixtureSHA256":"e03fdc6ccce75c1afd177d4bd7ede1fb578fad6524951d895ec4df171afae5cc","fixtureReportSHA256":"c2b9e1c61e77c030d685cee43e500744099a307778e76e84d0b9df286a086dea","id":"sensor-selected-trait-value","kind":"accessibility","requestSHA256":"4a103f7896f25e5db1c8c154a9d5bd7f80b031b0b395f4fd4457e34456624e33"},{"accessibilityRawSHA256":"652fd899c1cd7c9b8e082b338621866b72034f94a4f9adf6e7b50550400fb839","accessibilitySealedSHA256":"b42c83835f8e30232f45bd0d5270f108c17eb85d9ac59b0e4dcf4ce5e8a801d3","axCollectorBuildProvenanceSHA256":"752f5659f1b4a95b1840a11176acb10fe94cf9e6595457042d9ddcceb9e288d8","axCollectorSHA256":"bfcb45708b47898068b931d7a6b1432f99c2d69d2d7f77c3ee80b697d89646c2","captureIDHash":"a0f7021fb92fdf6fba541d8fa139b2d21021abf55251646925fe9dc29129e695","debugBuildProvenanceSHA256":"d4f2ae1112ccfcadeb73baf81b94465362a0655ab5b58540d3ed27c9b33c0f1b","debugFixtureSHA256":"e03fdc6ccce75c1afd177d4bd7ede1fb578fad6524951d895ec4df171afae5cc","fixtureReportSHA256":"773adfeb74d5ce807f6b082c4963c573cbcc68a8d2e318e1d83f3dbcf5ebb5e5","id":"explicit-temperature-role","kind":"accessibility","requestSHA256":"4a103f7896f25e5db1c8c154a9d5bd7f80b031b0b395f4fd4457e34456624e33"},{"accessibilityRawSHA256":"42b63042930483f2493df9df3dff95b250eec468d56135765fce37f3b6365c05","accessibilitySealedSHA256":"e56adcf1ab7b056660c3d70dafbe316c1e7b6d7a7ec88bf81555c1f93d195066","axCollectorBuildProvenanceSHA256":"752f5659f1b4a95b1840a11176acb10fe94cf9e6595457042d9ddcceb9e288d8","axCollectorSHA256":"bfcb45708b47898068b931d7a6b1432f99c2d69d2d7f77c3ee80b697d89646c2","captureIDHash":"4da7ad8e78218d61bd18e4d5dc38b38a0b5dda3e0f204b9f36d5522aeae5eb31","debugBuildProvenanceSHA256":"d4f2ae1112ccfcadeb73baf81b94465362a0655ab5b58540d3ed27c9b33c0f1b","debugFixtureSHA256":"e03fdc6ccce75c1afd177d4bd7ede1fb578fad6524951d895ec4df171afae5cc","fixtureReportSHA256":"00ea100c055ffd708dfb343419a4f402ed972469fde476a7ae3c139eb5f06cfb","id":"notification-actions","kind":"accessibility","requestSHA256":"55c3cb22d211c8164b11e63689ed7dbf259c95e1f8ff9e612e1514aa7d6ea683"},{"accessibilityRawSHA256":"c8b26c5b073b59c1f5ed8b24f5a757f1b0fb8141640f007495b38ec91d520204","accessibilitySealedSHA256":"44d0ea18b8f22011f87a95466546195f3ea262506cd4b6447a452c83452899fb","axCollectorBuildProvenanceSHA256":"752f5659f1b4a95b1840a11176acb10fe94cf9e6595457042d9ddcceb9e288d8","axCollectorSHA256":"bfcb45708b47898068b931d7a6b1432f99c2d69d2d7f77c3ee80b697d89646c2","captureIDHash":"c53a2d7b15189ba3920c87ccdc674bcd62dcce61258ed1cc10b01a2e43570878","debugBuildProvenanceSHA256":"d4f2ae1112ccfcadeb73baf81b94465362a0655ab5b58540d3ed27c9b33c0f1b","debugFixtureSHA256":"e03fdc6ccce75c1afd177d4bd7ede1fb578fad6524951d895ec4df171afae5cc","fixtureReportSHA256":"43ed55683ba017da6460ab2f8a614fdf25d7a2e5b87dd3326e3a7b96a4dbda12","id":"settings-logical-traversal","kind":"accessibility","requestSHA256":"987e3e64153ba3cd3b55b8d2c672ecf43f00a3b7d7957de79a6ff2103ef01aa1"},{"accessibilityRawSHA256":"64acd8e4796c5b161b5830573221e62c08e56decc5d6380ff5cf636a8b79d61e","accessibilitySealedSHA256":"a6a998d1504d4f9d26047463adbabd05699e8cca0b8f9edfee5f5d995ccff927","axCollectorBuildProvenanceSHA256":"752f5659f1b4a95b1840a11176acb10fe94cf9e6595457042d9ddcceb9e288d8","axCollectorSHA256":"bfcb45708b47898068b931d7a6b1432f99c2d69d2d7f77c3ee80b697d89646c2","captureIDHash":"1d6674fb81b71ec4d5dce89c0860771bf4904953e5c74a806c4245a57d41bbce","debugBuildProvenanceSHA256":"d4f2ae1112ccfcadeb73baf81b94465362a0655ab5b58540d3ed27c9b33c0f1b","debugFixtureSHA256":"e03fdc6ccce75c1afd177d4bd7ede1fb578fad6524951d895ec4df171afae5cc","fixtureReportSHA256":"f4b126709937e83f05922bd7aa3693c031d10c6614a09872f70a55d3802b7cd8","id":"no-duplicate-chart-elements","kind":"accessibility","requestSHA256":"c40244a7207996bfd7d4a20c75467ea519bba3fc710cd0eac417c888d67f2bc7"},{"accessibilityRawSHA256":"a57e1fb404877e17d240082376eee4eeda62b36531652ec874507be3e925baee","accessibilitySealedSHA256":"bd1d6d88d032b87dfcf2a500d9d57437d8802e5e411ee99d2107bb2ef38658f4","axCollectorBuildProvenanceSHA256":"752f5659f1b4a95b1840a11176acb10fe94cf9e6595457042d9ddcceb9e288d8","axCollectorSHA256":"bfcb45708b47898068b931d7a6b1432f99c2d69d2d7f77c3ee80b697d89646c2","captureIDHash":"649e813d049bdee402d5b235a2c9a62df30fdee17b9e439d6e9b6c5fc8b33303","debugBuildProvenanceSHA256":"d4f2ae1112ccfcadeb73baf81b94465362a0655ab5b58540d3ed27c9b33c0f1b","debugFixtureSHA256":"e03fdc6ccce75c1afd177d4bd7ede1fb578fad6524951d895ec4df171afae5cc","fixtureReportSHA256":"ef1dc3677ca35f16b3e852b55f584c8d4c020c89d8ae99f347e0d024ba083304","id":"compact-main-scroll-reachable","kind":"accessibility","requestSHA256":"9d3fbb204496fe492711815609ee6ca684d66073373ff6ba2415ef7240679635"},{"accessibilityRawSHA256":"7d2ee052f6bab3b6eb1a2b63e3ed1a4a10c0740a3775f052d8cb714f18aa3a8b","accessibilitySealedSHA256":"01e9812b8bfd49cf99fb77d2a042189af248604a33f6b80278bb86c76b8e6777","axCollectorBuildProvenanceSHA256":"752f5659f1b4a95b1840a11176acb10fe94cf9e6595457042d9ddcceb9e288d8","axCollectorSHA256":"bfcb45708b47898068b931d7a6b1432f99c2d69d2d7f77c3ee80b697d89646c2","captureIDHash":"24adb28d2f2c8f4da7303ac81b4c092d2f44995ddd1df3bd14e9e5e8819770a0","debugBuildProvenanceSHA256":"d4f2ae1112ccfcadeb73baf81b94465362a0655ab5b58540d3ed27c9b33c0f1b","debugFixtureSHA256":"e03fdc6ccce75c1afd177d4bd7ede1fb578fad6524951d895ec4df171afae5cc","fixtureReportSHA256":"43bbb6ebec1cc41372eab830f643aed8cf0c0570ec03fafcaed430e06d828a79","id":"settings-general-scroll-reachable","kind":"accessibility","requestSHA256":"4c086d7fdb10cf87ca5a6ebaf3703bc412ea27e75a4fe6b43646bbad25b3bfca"},{"accessibilityRawSHA256":"5d4177379f15bdbac27358eae9cd1d737557c9fd8fb37c37b9baa08607470a71","accessibilitySealedSHA256":"ea6358c6ad43691c33e41ff0cbbd6a1c79cf23979d7a602c403b69984c964211","axCollectorBuildProvenanceSHA256":"752f5659f1b4a95b1840a11176acb10fe94cf9e6595457042d9ddcceb9e288d8","axCollectorSHA256":"bfcb45708b47898068b931d7a6b1432f99c2d69d2d7f77c3ee80b697d89646c2","captureIDHash":"6b3b521f271854741bccffc45ff6f516f4690391f4f960397d4b00e545288a9a","debugBuildProvenanceSHA256":"d4f2ae1112ccfcadeb73baf81b94465362a0655ab5b58540d3ed27c9b33c0f1b","debugFixtureSHA256":"e03fdc6ccce75c1afd177d4bd7ede1fb578fad6524951d895ec4df171afae5cc","fixtureReportSHA256":"9d88db9a29a957fba4c5b583e185e2484d0a6f5c8ab89f46357648cc28291659","id":"settings-menu-bar-scroll-reachable","kind":"accessibility","requestSHA256":"bfa01810723b71dc34a9c3b6a94a161960e60b6ac318db09970a6d74103e24f5"},{"accessibilityRawSHA256":"7acbfbdb80e75c4097bc12bd30f6327e1c70741f77c10646a5600f359a01b86f","accessibilitySealedSHA256":"fd87b6139ef1b2a2c098df1773eb7475d05bbb764cbcd1b1caf71c20dc24ea11","axCollectorBuildProvenanceSHA256":"752f5659f1b4a95b1840a11176acb10fe94cf9e6595457042d9ddcceb9e288d8","axCollectorSHA256":"bfcb45708b47898068b931d7a6b1432f99c2d69d2d7f77c3ee80b697d89646c2","captureIDHash":"c4c42dc594b1cb3983ceee9c8d98b362ad17dc755e6956c8947fb75b77ecd105","debugBuildProvenanceSHA256":"d4f2ae1112ccfcadeb73baf81b94465362a0655ab5b58540d3ed27c9b33c0f1b","debugFixtureSHA256":"e03fdc6ccce75c1afd177d4bd7ede1fb578fad6524951d895ec4df171afae5cc","fixtureReportSHA256":"b5012dc341a423ec6ab93764a8e0523d952830808630a45044100554c08888ad","id":"settings-notifications-scroll-reachable","kind":"accessibility","requestSHA256":"93c79cd3053aaac63f572258c8e558b147f1e76cb23ddeb9335243ddb568cf3c"},{"accessibilityRawSHA256":"f2be1bec2b74bbb3f30b835f8974e87b6df5009eeed6d889154515805992309a","accessibilitySealedSHA256":"53272aab39548fe30e958edf82e8d31d93f16f65c46e84141ae6d271b4c87fa8","axCollectorBuildProvenanceSHA256":"752f5659f1b4a95b1840a11176acb10fe94cf9e6595457042d9ddcceb9e288d8","axCollectorSHA256":"bfcb45708b47898068b931d7a6b1432f99c2d69d2d7f77c3ee80b697d89646c2","captureIDHash":"97b344340f7cbf166d92ab05a08402830633322c0db131570dfe27383f2acdfa","debugBuildProvenanceSHA256":"d4f2ae1112ccfcadeb73baf81b94465362a0655ab5b58540d3ed27c9b33c0f1b","debugFixtureSHA256":"e03fdc6ccce75c1afd177d4bd7ede1fb578fad6524951d895ec4df171afae5cc","fixtureReportSHA256":"405ab2486a53cc246b322eb3033331dedacc45d430d855539c3564f75b2fd5ff","id":"settings-agent-workflows-scroll-reachable","kind":"accessibility","requestSHA256":"07994c8a7f853f1444deffc6447b8b21a8fc44bedc03b446b474d81b01254db1"}],"safetyAggregate":{"attemptedExternalMutations":0,"attemptedHardwareCommands":0,"finalReportsPassed":50,"modelStartSkipped":50,"realControlPathConstructions":0},"schemaID":"https://vifty.app/schemas/ui-review-automated-checkpoint-v1.schema.json","schemaVersion":1,"source":{"commit":"3b869337c1249c18d26caf4a0558dda53152e775","manifestSHA256":"9d8843d84f04df5d458ce69669e133751d129be75e692ac635ee2278378e385f","tree":"756300206274844d99fd8202d44592522cb3fcd0"},"status":"automated-passed"} diff --git a/scripts/lib/ui_review_orchestrator.rb b/scripts/lib/ui_review_orchestrator.rb index c4682e6c..efdd9981 100644 --- a/scripts/lib/ui_review_orchestrator.rb +++ b/scripts/lib/ui_review_orchestrator.rb @@ -181,7 +181,7 @@ def capture(options) } write_json_atomic(session_path, session, containment_root: evidence_root) - readiness_deadline = monotonic_now + options.fetch(:timeout_seconds) + deadline = monotonic_now + options.fetch(:timeout_seconds) fixture_arguments = fixture_arguments( request: request, capture_id: capture_id, @@ -189,8 +189,7 @@ def capture(options) screenshot_path: screenshot_path, completion_path: completion_path, executable_sha: debug_sha, - timeout_seconds: options.fetch(:fixture_hold_seconds), - readiness_deadline: readiness_deadline + timeout_seconds: options.fetch(:fixture_hold_seconds) ) process_log = open_output_file( process_log_path, @@ -208,7 +207,6 @@ def capture(options) session["processIdentifier"] = pid write_json_atomic(session_path, session, containment_root: evidence_root) - deadline = readiness_deadline report = wait_for_report( path: report_path, phase: "ready", @@ -761,7 +759,7 @@ def requirement_id(row, kind) row[kind == "fixture" ? "state" : "id"] end - def fixture_arguments(request:, capture_id:, output_path:, screenshot_path:, completion_path:, executable_sha:, timeout_seconds:, readiness_deadline:) + def fixture_arguments(request:, capture_id:, output_path:, screenshot_path:, completion_path:, executable_sha:, timeout_seconds:) arguments = [ "-ApplePersistenceIgnoreState", "YES", "--ui-review-fixture", request.fetch("state"), @@ -776,7 +774,6 @@ def fixture_arguments(request:, capture_id:, output_path:, screenshot_path:, com "--ui-review-output", output_path, "--ui-review-completion-file", completion_path, "--ui-review-timeout-seconds", timeout_seconds.to_s, - "--ui-review-readiness-deadline-uptime", readiness_deadline.to_s, "--ui-review-executable-sha256", executable_sha ] arguments.concat(["--ui-review-screenshot", screenshot_path]) if screenshot_path