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]>
216 lines
10 KiB
Swift
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) ?? "")
|
|
}
|
|
}
|
|
}
|