From 1c94b60a44bf903cbbb178bcd2a0978bb38b7abc Mon Sep 17 00:00:00 2001 From: Reidar Date: Sat, 8 Aug 2026 20:29:43 +0200 Subject: [PATCH] Preserve unreadable preferences and emit explicit nulls in status JSON --- Sources/Vifty/AppPreferencesStore.swift | 8 +++++++ Sources/ViftyCore/AgentControlModels.swift | 19 ++++++++++++++++ Sources/ViftyCore/ViftyCtlRunner.swift | 25 ++++++++++++++++++++++ 3 files changed, 52 insertions(+) diff --git a/Sources/Vifty/AppPreferencesStore.swift b/Sources/Vifty/AppPreferencesStore.swift index b5d773b..7885c74 100644 --- a/Sources/Vifty/AppPreferencesStore.swift +++ b/Sources/Vifty/AppPreferencesStore.swift @@ -81,6 +81,14 @@ final class AppPreferencesStore: @unchecked Sendable { return preferences } + // 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) + } + let migrated = migratedPreferences() if migrated != .defaults { try? saveThrowing(migrated) diff --git a/Sources/ViftyCore/AgentControlModels.swift b/Sources/ViftyCore/AgentControlModels.swift index b39259f..2422d1a 100644 --- a/Sources/ViftyCore/AgentControlModels.swift +++ b/Sources/ViftyCore/AgentControlModels.swift @@ -149,6 +149,25 @@ public struct AgentControlStatus: Codable, Equatable, Sendable { public var lastErrorCode: AgentControlErrorCode? public var policy: AgentControlPolicySnapshot? + private enum CodingKeys: String, CodingKey { + case enabled + case activeLease + case lastDecision + case lastErrorCode + case policy + } + + // Emit nil optionals as explicit JSON nulls so strict schema consumers see + // the full contract shape even in the idle state. + public func encode(to encoder: any Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(enabled, forKey: .enabled) + try container.encode(activeLease, forKey: .activeLease) + try container.encode(lastDecision, forKey: .lastDecision) + try container.encode(lastErrorCode, forKey: .lastErrorCode) + try container.encode(policy, forKey: .policy) + } + public init( enabled: Bool, activeLease: AgentCoolingLease?, diff --git a/Sources/ViftyCore/ViftyCtlRunner.swift b/Sources/ViftyCore/ViftyCtlRunner.swift index a1ec7d9..0c1430e 100644 --- a/Sources/ViftyCore/ViftyCtlRunner.swift +++ b/Sources/ViftyCore/ViftyCtlRunner.swift @@ -1164,6 +1164,31 @@ public struct ViftyCtlStatusReport: Codable, Equatable, Sendable { public var lastErrorCode: AgentControlErrorCode? public var policy: AgentControlPolicySnapshot? + private enum CodingKeys: String, CodingKey { + case schemaVersion + case schemaID + case generatedAt + case enabled + case activeLease + case lastDecision + case lastErrorCode + case policy + } + + // Emit nil optionals as explicit JSON nulls so the status schema's required + // keys are present in the idle payload. + public func encode(to encoder: any Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(schemaVersion, forKey: .schemaVersion) + try container.encode(schemaID, forKey: .schemaID) + try container.encode(generatedAt, forKey: .generatedAt) + try container.encode(enabled, forKey: .enabled) + try container.encode(activeLease, forKey: .activeLease) + try container.encode(lastDecision, forKey: .lastDecision) + try container.encode(lastErrorCode, forKey: .lastErrorCode) + try container.encode(policy, forKey: .policy) + } + public init( schemaVersion: Int = 1, schemaID: String = ViftyCtlSchemaReferences.schemaIDs.status,