Files
cinder27/Sources/GuardJournal.swift
T

159 lines
5.4 KiB
Swift

import Foundation
import Combine
/// Write-ahead journal. Every primitive that can terminate the process arms an entry
/// before the trigger and disarms it after. An entry still armed at the next launch is a
/// process death inside that primitive — the differential oracle, generalized.
struct JournalEntry: Codable, Identifiable, Equatable {
var id: UUID = UUID()
var moduleID: String
var phase: String // armed | done | threw | died
var armedAt: Date
var detail: String
}
final class GuardJournal: ObservableObject {
@Published private(set) var entries: [JournalEntry] = []
@Published private(set) var recovered: [JournalEntry] = []
private let url: URL
private let lock = NSLock()
private let encoder: JSONEncoder = {
let e = JSONEncoder(); e.dateEncodingStrategy = .iso8601; return e
}()
private let decoder: JSONDecoder = {
let d = JSONDecoder(); d.dateDecodingStrategy = .iso8601; return d
}()
init() {
// Container.ensureAll() guarantees Application Support exists; the URL alone does not.
let base = Container.appSupport
url = base.appendingPathComponent("cinder27-journal.json")
load()
}
/// Bumped whenever the primitive set changes. Results recorded under a different stamp
/// describe a different app, so they are dropped rather than shown as if they still
/// apply — otherwise a reinstall over an old build reports work that never happened in
/// this one.
static let buildStamp: String = {
let version = (Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String) ?? "0"
let build = (Bundle.main.infoDictionary?["CFBundleVersion"] as? String) ?? "0"
let count = Catalog.all.count
return "\(version).\(build).\(count)"
}()
private var stampURL: URL {
url.deletingLastPathComponent().appendingPathComponent("cinder27-journal.stamp")
}
private func load() {
let stored = (try? String(contentsOf: stampURL, encoding: .utf8))?
.trimmingCharacters(in: .whitespacesAndNewlines)
guard stored == Self.buildStamp else {
// First launch, or the primitive set changed: start from nothing.
entries = []
recovered = []
persistLocked([])
try? Self.buildStamp.write(to: stampURL, atomically: true, encoding: .utf8)
return
}
guard let data = try? Data(contentsOf: url),
let decoded = try? decoder.decode([JournalEntry].self, from: data) else { return }
let died = decoded.filter { $0.phase == "armed" }
let rewritten = decoded.map { e -> JournalEntry in
guard e.phase == "armed" else { return e }
var copy = e
copy.phase = "died"
return copy
}
entries = rewritten
recovered = died
if !died.isEmpty { persistLocked(rewritten) }
}
private func persistLocked(_ list: [JournalEntry]) {
guard let data = try? encoder.encode(list) else { return }
try? data.write(to: url, options: .atomic)
let fd = open(url.path, O_RDONLY)
if fd >= 0 { fsync(fd); close(fd) }
}
private func persist() {
persistLocked(entries)
}
func arm(_ moduleID: String, _ detail: String) {
lock.lock()
entries.append(JournalEntry(moduleID: moduleID, phase: "armed", armedAt: Date(), detail: detail))
persist()
lock.unlock()
refresh()
}
func disarm(_ moduleID: String, phase: String = "done", detail: String = "") {
lock.lock()
for i in entries.indices.reversed() where entries[i].moduleID == moduleID && entries[i].phase == "armed" {
entries[i].phase = phase
if !detail.isEmpty { entries[i].detail = detail }
break
}
persist()
lock.unlock()
refresh()
}
func clear() {
lock.lock()
entries.removeAll()
recovered.removeAll()
persist()
lock.unlock()
refresh()
}
private func refresh() {
let snapshot = entries
DispatchQueue.main.async { [weak self] in
self?.objectWillChange.send()
self?.entries = snapshot
}
}
func history(for moduleID: String) -> [JournalEntry] {
lock.lock(); defer { lock.unlock() }
return entries.filter { $0.moduleID == moduleID }
}
func verdict(for moduleID: String) -> LabVerdict {
lock.lock(); defer { lock.unlock() }
let mine = entries.filter { $0.moduleID == moduleID }
guard !mine.isEmpty else { return .untested }
if mine.contains(where: { $0.phase == "died" }) { return .processDied }
if mine.contains(where: { $0.phase == "threw" }) { return .refused }
if mine.contains(where: { $0.phase == "done" }) { return .completed }
return .running
}
}
enum LabVerdict: String, Codable {
case untested
case running
case completed
case refused
case processDied
/// Localization key; callers go through t().
var key: String {
switch self {
case .untested: return "lab.untested"
case .running: return "lab.running"
case .completed: return "lab.completed"
case .refused: return "lab.refused"
case .processDied: return "lab.died"
}
}
var label: String { t(key) }
}