import Foundation enum AssistantPersistenceError: LocalizedError, Sendable { case applicationSupportDirectoryUnavailable case unsupportedSchemaVersion(Int) var errorDescription: String? { switch self { case .applicationSupportDirectoryUnavailable: "The app's Application Support directory is unavailable." case .unsupportedSchemaVersion(let version): "The saved assistant workspace uses unsupported schema version \(version)." } } } /// Owns the durable assistant workspace. All disk access is serialized by the /// actor and writes use an atomic replacement so an interrupted save cannot /// leave a partially written JSON document. actor AssistantPersistence { private struct Envelope: Codable, Sendable { let schemaVersion: Int let workspace: AssistantWorkspace } private static let currentSchemaVersion = 5 nonisolated let fileURL: URL init(fileURL: URL? = nil, fileManager: FileManager = .default) throws { if let fileURL { self.fileURL = fileURL return } guard let applicationSupport = fileManager.urls( for: .applicationSupportDirectory, in: .userDomainMask ).first else { throw AssistantPersistenceError.applicationSupportDirectoryUnavailable } self.fileURL = applicationSupport .appendingPathComponent("DolphinAssistant", isDirectory: true) .appendingPathComponent("workspace.json", isDirectory: false) } /// Returns an empty workspace on first launch. Decode and filesystem errors /// are surfaced to the caller so corrupt state is never silently discarded. func load() throws -> AssistantWorkspace { let fileManager = FileManager.default guard fileManager.fileExists(atPath: fileURL.path) else { return .empty } let data = try Data(contentsOf: fileURL, options: [.mappedIfSafe]) let envelope = try Self.makeDecoder().decode(Envelope.self, from: data) switch envelope.schemaVersion { case Self.currentSchemaVersion: return envelope.workspace case 4, 3, 2: return envelope.workspace case 1: return Self.migrateSchemaOne(envelope.workspace) default: throw AssistantPersistenceError.unsupportedSchemaVersion( envelope.schemaVersion ) } } func save(_ workspace: AssistantWorkspace) throws { let fileManager = FileManager.default let directoryURL = fileURL.deletingLastPathComponent() try fileManager.createDirectory( at: directoryURL, withIntermediateDirectories: true, attributes: nil ) let envelope = Envelope( schemaVersion: Self.currentSchemaVersion, workspace: workspace ) let data = try Self.makeEncoder().encode(envelope) try data.write( to: fileURL, options: [.atomic, .completeFileProtectionUnlessOpen] ) } private static func makeEncoder() -> JSONEncoder { let encoder = JSONEncoder() // Numeric seconds preserve Date's full Codable precision for exact // workspace round trips (ISO-8601's default strategy drops fractions). encoder.dateEncodingStrategy = .secondsSince1970 encoder.outputFormatting = [.prettyPrinted, .sortedKeys, .withoutEscapingSlashes] return encoder } private static func makeDecoder() -> JSONDecoder { let decoder = JSONDecoder() decoder.dateDecodingStrategy = .secondsSince1970 return decoder } private static func migrateSchemaOne( _ legacyWorkspace: AssistantWorkspace ) -> AssistantWorkspace { var workspace = legacyWorkspace if workspace.settings.maxNewTokens == AgentSettings.legacyDefaultMaxNewTokens { workspace.settings.maxNewTokens = AgentSettings.defaultMaxNewTokens } workspace.messages = workspace.messages.compactMap { message in guard message.role == .assistant else { return message } var cleaned = message cleaned.content = AssistantChatText.cleaned(message.content) return cleaned.content.isEmpty ? nil : cleaned } return workspace } }