Files
nucleic/Sources/NucleicCore/SessionStorageArchiver.swift
T
abkslmandClaude Fable 5 35f611cd7e Fold in the parallel storage-retention work (SessionDataArchive)
A second implementation of session-storage reclamation was drafted
uncommitted on the control root's dev checkout (SessionDataArchive).
This folds its distinct ideas into the committed implementation, which
stays the base (per-directory locking, partial-then-rename with the
directory-wins invariant, funnel-covered transparent restore, tests):

- deleteSession is now truly permanent: it removes sessions/<id>/, the
  compressed <id>.tar.gz if the sweep packed it, and any local
  Carbon-mirror copy — previously every deleted chat leaked its
  transcript directory forever. Project deletion purges the same per
  chat; deleteEphemeralSession drops its now-redundant own removal.
- The worktree-cleanup and storage sweeps source candidates from
  database.loadAllSessions() instead of persistedSessionRecords, which
  only holds active projects' sessions — an archived project's chats
  could otherwise never be reclaimed (a permanent worktree leak).
- New opt-in retention policy (Settings ▸ Chats ▸ "Reclaim archived
  chat data", Never by default): an archived, non-favorite chat past
  the window is deleted outright on an Optimized-storage Mac, but on an
  All-Copies Mac it is compressed in place through the same guarded
  compression path — full storage never deletes. Off by default because
  discarding chat data must be an explicit opt-in.
- The archiver verifies the fresh archive is listable (tar -tzf)
  before the source directory is removed, so a truncated stream is
  caught at compress time, not restore time.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-08-04 03:41:25 +00:00

216 lines
10 KiB
Swift

import Foundation
/// Compresses one session's on-disk storage — the `…/sessions/<id>/` directory holding the
/// canonical `transcript.jsonl`, its render sidecars, and the agent home (`claude-home/`) — into
/// a sibling `…/sessions/<id>.tar.gz`, and transparently unpacks it the moment anything needs
/// the files again. At hundreds-to-thousands of sessions those directories are what balloons
/// Nucleic's storage into the tens-to-hundreds of gigabytes; the JSONL transcripts and seeded
/// agent homes they contain are exactly the kind of redundant text gzip collapses by an order
/// of magnitude.
///
/// Mechanics are deliberately boring: the system `tar` (present on macOS, Linux, and modern
/// Windows — macOS has no system zstd, and the vendored libarchive module is not linked into
/// NucleicCore) writes a `.partial` temp in the same directory, which is renamed into place
/// only after tar exits cleanly, and only then is the directory removed. Every step degrades
/// safely: a crash mid-compress leaves the directory authoritative (the stale temp/archive is
/// swept on the next attempt), and a failed extraction leaves the archive in place for the next
/// try. The invariant readers rely on: **whenever a directory and an archive both exist, the
/// directory wins** — `compress` deletes the directory last, so its presence proves the archive
/// may be stale.
///
/// Callers serialize through a per-directory lock, so a compress racing an unpack of the same
/// session converges instead of corrupting (the loser blocks, then observes the winner's end
/// state). *Cross-process* exclusion is out of scope — the `AppStore` only compresses sessions
/// with no live controller, no hydration in flight, and no active transfer, which is what keeps
/// tar from ever running under a live reader (see `sweepCompressibleSessionStorage`).
public enum SessionStorageArchiver {
public enum ArchiveError: Error, LocalizedError {
case tarNotFound
case missingDirectory(String)
case tarFailed(status: Int32, stderr: String)
case emptyArchive(String)
public var errorDescription: String? {
switch self {
case .tarNotFound:
return "no `tar` executable found — session storage cannot be (un)compressed"
case .missingDirectory(let path):
return "session storage directory missing: \(path)"
case .tarFailed(let status, let stderr):
return "tar exited \(status): \(stderr)"
case .emptyArchive(let path):
return "tar produced an empty archive at \(path)"
}
}
}
/// One lock per session directory path, created on demand and never discarded — the map
/// grows by one small object per session ever (un)compressed in this process, which is
/// bounded by the session count and far cheaper than the correctness it buys: a compress
/// and an unpack of the *same* session serialize, while different sessions proceed in
/// parallel. `stateLock` guards only the map itself.
private static let stateLock = NSLock()
nonisolated(unsafe) private static var dirLocks: [String: NSLock] = [:]
private static func lock(for dir: URL) -> NSLock {
stateLock.lock()
defer { stateLock.unlock() }
if let existing = dirLocks[dir.path] { return existing }
let fresh = NSLock()
dirLocks[dir.path] = fresh
return fresh
}
/// The archive a session directory compresses into: a `.tar.gz` sibling named after the
/// directory (`sessions/<id>/` ↔ `sessions/<id>.tar.gz`), so a glance at `sessions/`
/// shows exactly which chats are packed away.
public static func archiveURL(forSessionDir dir: URL) -> URL {
URL(fileURLWithPath: dir.path + ".tar.gz", isDirectory: false)
}
/// The temp the archive is assembled in before the atomic rename — dot-prefixed and
/// deterministic, so a crash leaves at most one stale partial per session and the next
/// attempt reclaims it.
private static func partialURL(forSessionDir dir: URL) -> URL {
let name = dir.lastPathComponent
return dir.deletingLastPathComponent()
.appendingPathComponent(".\(name).tar.gz.partial", isDirectory: false)
}
/// Whether this session's storage currently lives as a compressed archive (and only as
/// one — a directory that still exists wins, see the type comment's invariant).
public static func isArchived(sessionDir dir: URL) -> Bool {
let fm = FileManager.default
return !fm.fileExists(atPath: dir.path)
&& fm.fileExists(atPath: archiveURL(forSessionDir: dir).path)
}
/// Compress `sessions/<id>/` into `sessions/<id>.tar.gz` and remove the directory.
/// A no-op when the directory is already compressed away. Throws without touching the
/// directory on any failure, so the worst outcome of a bad pass is a stale partial the
/// next pass reclaims.
public static func compress(sessionDir dir: URL) throws {
let lock = lock(for: dir)
lock.lock()
defer { lock.unlock() }
let fm = FileManager.default
let archive = archiveURL(forSessionDir: dir)
guard fm.fileExists(atPath: dir.path) else {
// Already packed (a concurrent pass won the race) — done. Neither present is a
// caller error: the sweep decided on state that no longer holds.
if fm.fileExists(atPath: archive.path) { return }
throw ArchiveError.missingDirectory(dir.path)
}
let partial = partialURL(forSessionDir: dir)
try? fm.removeItem(at: partial)
// Relative member paths (`-C <parent> <name>`) so the archive re-roots wherever the
// sessions tree lives — a support-dir move or a test fixture both extract cleanly.
try runTar([
"-czf", partial.path,
"-C", dir.deletingLastPathComponent().path,
dir.lastPathComponent,
])
let size = ((try? fm.attributesOfItem(atPath: partial.path))?[.size] as? Int) ?? 0
guard size > 0 else {
try? fm.removeItem(at: partial)
throw ArchiveError.emptyArchive(partial.path)
}
// Data-integrity gate: the directory is only removed below if the just-written archive
// proves *listable* — a truncated or corrupt gzip stream fails `tar -tzf` here rather
// than being discovered at restore time, when the directory is long gone.
do {
try runTar(["-tzf", partial.path])
} catch {
try? fm.removeItem(at: partial)
throw error
}
// The agent home inside can carry credential material (`.nucleic-git-credentials`),
// so the archive keeps it owner-only like the directory it replaces.
try? fm.setAttributes([.posixPermissions: 0o600], ofItemAtPath: partial.path)
// A stale archive here means an earlier pass died between rename and directory
// removal — the directory stayed authoritative, so replace the archive wholesale.
try? fm.removeItem(at: archive)
try fm.moveItem(at: partial, to: archive)
do {
try fm.removeItem(at: dir)
} catch {
// Couldn't clear the directory → it must stay authoritative. Drop the archive so
// no reader ever prefers it over the (complete) directory, and let the next
// sweep retry the whole pass.
try? fm.removeItem(at: archive)
throw error
}
}
/// Restore `sessions/<id>/` from its archive if — and only if — the directory itself is
/// gone. Returns whether the directory exists once this returns: `true` means callers can
/// read the session's files as if compression never happened; `false` means there was
/// neither a directory nor a restorable archive (the same "transcript gone" state those
/// callers already tolerate). A failed extraction leaves the archive in place for the
/// next attempt rather than destroying the only copy.
@discardableResult
public static func ensureUnpacked(sessionDir dir: URL) -> Bool {
let lock = lock(for: dir)
lock.lock()
defer { lock.unlock() }
let fm = FileManager.default
let archive = archiveURL(forSessionDir: dir)
if fm.fileExists(atPath: dir.path) {
// Directory wins (see the invariant above): any archive alongside it is a
// leftover from an interrupted compress and must not shadow newer writes.
try? fm.removeItem(at: archive)
return true
}
guard fm.fileExists(atPath: archive.path) else { return false }
do {
try runTar(["-xzf", archive.path, "-C", dir.deletingLastPathComponent().path])
} catch {
return false
}
guard fm.fileExists(atPath: dir.path) else { return false }
try? fm.removeItem(at: archive)
return true
}
// MARK: - tar plumbing
/// The system tar, resolved once. macOS and every mainstream Linux ship `/usr/bin/tar`
/// (BusyBox distros `/bin/tar`); Windows 10+ ships `tar.exe` in System32. No PATH search:
/// this runs against user data, so only well-known absolute locations are trusted.
nonisolated(unsafe) private static let tarURL: URL? = {
#if os(Windows)
let candidates = ["C:\\Windows\\System32\\tar.exe"]
#else
let candidates = ["/usr/bin/tar", "/bin/tar"]
#endif
return candidates.first { FileManager.default.isExecutableFile(atPath: $0) }
.map { URL(fileURLWithPath: $0) }
}()
private static func runTar(_ arguments: [String]) throws {
guard let tarURL else { throw ArchiveError.tarNotFound }
let process = Process()
process.executableURL = tarURL
process.arguments = arguments
process.standardInput = FileHandle.nullDevice
process.standardOutput = FileHandle.nullDevice
let stderrPipe = Pipe()
process.standardError = stderrPipe
try process.run()
// Drain stderr *before* waiting so a chatty tar can't deadlock on a full pipe.
let stderrData = stderrPipe.fileHandleForReading.readDataToEndOfFile()
process.waitUntilExit()
guard process.terminationStatus == 0 else {
throw ArchiveError.tarFailed(
status: process.terminationStatus,
stderr: String(data: stderrData, encoding: .utf8)?
.trimmingCharacters(in: .whitespacesAndNewlines) ?? "")
}
}
}