From 1a7d0781cb30d72839781c1ac39990ad9cb9a778 Mon Sep 17 00:00:00 2001 From: Zac White Date: Thu, 9 Apr 2026 08:08:02 -0700 Subject: [PATCH 01/14] Added gitignore --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index d6c4af4..5a8bf0a 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ .build .swiftpm +references Package.resolved .DS_Store From 4919b132d5635b5871cb763efdd785f9b744914e Mon Sep 17 00:00:00 2001 From: Zac White Date: Thu, 9 Apr 2026 09:40:56 -0700 Subject: [PATCH 02/14] Initial implementation of checkpoints --- Sources/Workspace/CheckpointStore.swift | 122 ++ Sources/Workspace/FileEdit.swift | 8 +- Sources/Workspace/History.swift | 1254 +++++++++++++++++ Sources/Workspace/HistoryTypes.swift | 206 +++ Sources/Workspace/Snapshot.swift | 285 ++++ Sources/Workspace/Workspace.swift | 8 + .../{WorkspaceTests.swift => CoreTests.swift} | 26 +- ...ystemTests.swift => FilesystemTests.swift} | 124 +- Tests/WorkspaceTests/HistoryTests.swift | 272 ++++ ...ountingTests.swift => MountingTests.swift} | 40 +- 10 files changed, 2246 insertions(+), 99 deletions(-) create mode 100644 Sources/Workspace/CheckpointStore.swift create mode 100644 Sources/Workspace/History.swift create mode 100644 Sources/Workspace/HistoryTypes.swift create mode 100644 Sources/Workspace/Snapshot.swift rename Tests/WorkspaceTests/{WorkspaceTests.swift => CoreTests.swift} (98%) rename Tests/WorkspaceTests/{WorkspaceFilesystemTests.swift => FilesystemTests.swift} (87%) create mode 100644 Tests/WorkspaceTests/HistoryTests.swift rename Tests/WorkspaceTests/{WorkspaceMountingTests.swift => MountingTests.swift} (88%) diff --git a/Sources/Workspace/CheckpointStore.swift b/Sources/Workspace/CheckpointStore.swift new file mode 100644 index 0000000..4dc5c66 --- /dev/null +++ b/Sources/Workspace/CheckpointStore.swift @@ -0,0 +1,122 @@ +import Foundation + +/// Persistence for workspace checkpoints, snapshots, and mutation logs. +protocol CheckpointStore: AnyObject, Sendable { + /// Persists checkpoint metadata. + func saveCheckpoint(_ checkpoint: History.Checkpoint) async throws + /// Loads one checkpoint by identifier. + func loadCheckpoint(id: UUID, workspaceId: UUID) async throws -> History.Checkpoint? + /// Lists persisted checkpoints for a workspace. + func listCheckpoints(workspaceId: UUID) async throws -> [History.Checkpoint] + /// Appends a mutation record to the workspace log. + func appendMutation(_ mutation: MutationRecord) async throws + /// Lists the mutation log for a workspace. + func listMutationRecords(workspaceId: UUID) async throws -> [MutationRecord] +} + +/// A JSON file-backed checkpoint store. +actor FileCheckpointStore: CheckpointStore { + private let rootDirectory: URL + private let fileManager: FileManager + private let encoder: JSONEncoder + private let decoder: JSONDecoder + + /// Creates a file-backed checkpoint store rooted at `rootDirectory`. + init(rootDirectory: URL, fileManager: FileManager = .default) { + self.rootDirectory = rootDirectory.standardizedFileURL + self.fileManager = fileManager + + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + self.encoder = encoder + self.decoder = JSONDecoder() + } + + /// See ``CheckpointStore/saveCheckpoint(_:)``. + func saveCheckpoint(_ checkpoint: History.Checkpoint) async throws { + try ensureWorkspaceDirectories(for: checkpoint.workspaceId) + try write(checkpoint, to: checkpointURL(id: checkpoint.id, workspaceId: checkpoint.workspaceId)) + } + + /// See ``CheckpointStore/loadCheckpoint(id:workspaceId:)``. + func loadCheckpoint(id: UUID, workspaceId: UUID) async throws -> History.Checkpoint? { + let url = checkpointURL(id: id, workspaceId: workspaceId) + guard fileManager.fileExists(atPath: url.path) else { + return nil + } + return try read(History.Checkpoint.self, from: url) + } + + /// See ``CheckpointStore/listCheckpoints(workspaceId:)``. + func listCheckpoints(workspaceId: UUID) async throws -> [History.Checkpoint] { + let directoryURL = checkpointsDirectoryURL(workspaceId: workspaceId) + guard fileManager.fileExists(atPath: directoryURL.path) else { + return [] + } + + return try fileManager + .contentsOfDirectory( + at: directoryURL, + includingPropertiesForKeys: nil, + options: [.skipsHiddenFiles] + ) + .filter { $0.pathExtension == "json" } + .map { try read(History.Checkpoint.self, from: $0) } + .sorted { + if $0.createdAt == $1.createdAt { + return $0.id.uuidString < $1.id.uuidString + } + return $0.createdAt < $1.createdAt + } + } + + /// See ``CheckpointStore/appendMutation(_:)``. + func appendMutation(_ mutation: MutationRecord) async throws { + try ensureWorkspaceDirectories(for: mutation.workspaceId) + let url = mutationsURL(workspaceId: mutation.workspaceId) + var records = try loadMutations(from: url) + records.append(mutation) + try write(records.sorted(by: { $0.sequence < $1.sequence }), to: url) + } + + /// See ``CheckpointStore/listMutationRecords(workspaceId:)``. + func listMutationRecords(workspaceId: UUID) async throws -> [MutationRecord] { + try loadMutations(from: mutationsURL(workspaceId: workspaceId)).sorted(by: { $0.sequence < $1.sequence }) + } + + private func loadMutations(from url: URL) throws -> [MutationRecord] { + guard fileManager.fileExists(atPath: url.path) else { + return [] + } + return try read([MutationRecord].self, from: url) + } + + private func ensureWorkspaceDirectories(for workspaceId: UUID) throws { + try fileManager.createDirectory(at: workspaceDirectoryURL(workspaceId: workspaceId), withIntermediateDirectories: true) + try fileManager.createDirectory(at: checkpointsDirectoryURL(workspaceId: workspaceId), withIntermediateDirectories: true) + } + + private func workspaceDirectoryURL(workspaceId: UUID) -> URL { + rootDirectory.appendingPathComponent(workspaceId.uuidString, isDirectory: true) + } + + private func checkpointsDirectoryURL(workspaceId: UUID) -> URL { + workspaceDirectoryURL(workspaceId: workspaceId).appendingPathComponent("checkpoints", isDirectory: true) + } + + private func checkpointURL(id: UUID, workspaceId: UUID) -> URL { + checkpointsDirectoryURL(workspaceId: workspaceId).appendingPathComponent("\(id.uuidString).json", isDirectory: false) + } + + private func mutationsURL(workspaceId: UUID) -> URL { + workspaceDirectoryURL(workspaceId: workspaceId).appendingPathComponent("mutations.json", isDirectory: false) + } + + private func write(_ value: T, to url: URL) throws { + try encoder.encode(value).write(to: url, options: .atomic) + } + + private func read(_ type: T.Type, from url: URL) throws -> T { + try decoder.decode(type, from: Data(contentsOf: url)) + } +} diff --git a/Sources/Workspace/FileEdit.swift b/Sources/Workspace/FileEdit.swift index ed2e77c..417a5b5 100644 --- a/Sources/Workspace/FileEdit.swift +++ b/Sources/Workspace/FileEdit.swift @@ -40,7 +40,7 @@ public enum FileEdit: Sendable, Equatable, Codable { } /// A file-level change contained within a batch edit result. - public struct FileChange: Sendable, Codable { + public struct FileChange: Sendable, Codable, Equatable { /// The affected file or symlink path. public var path: WorkspacePath /// The original path for move and copy operations when applicable. @@ -73,7 +73,7 @@ public enum FileEdit: Sendable, Equatable, Codable { } /// A preview or result entry for a single batch edit operation. - public struct Entry: Sendable, Codable { + public struct Entry: Sendable, Codable, Equatable { /// The requested edit. public var edit: FileEdit /// Whether the requested edit materially changes the workspace. @@ -102,7 +102,7 @@ public enum FileEdit: Sendable, Equatable, Codable { } /// A failure encountered while executing a single batch edit. - public struct Failure: Sendable, Codable { + public struct Failure: Sendable, Codable, Equatable { /// The index of the failed edit in the original request. public var index: Int /// The edit that failed. @@ -119,7 +119,7 @@ public enum FileEdit: Sendable, Equatable, Codable { } /// The result of applying a batch of workspace edits. - public struct BatchResult: Sendable, Codable { + public struct BatchResult: Sendable, Codable, Equatable { /// Whether the operation was previewed or executed. public var mode: MutationMode /// Canonicalized paths touched by the batch. diff --git a/Sources/Workspace/History.swift b/Sources/Workspace/History.swift new file mode 100644 index 0000000..e038caf --- /dev/null +++ b/Sources/Workspace/History.swift @@ -0,0 +1,1254 @@ +import Foundation + +/// Coordinates shared history, session overlays, checkpoints, rollback, and publish. +public actor History { + private struct SessionState { + var workspace: Workspace + var baseSharedCheckpointId: UUID? + var lastCheckpointId: UUID? + } + + private struct EventWatcher { + var workspaceId: UUID + var continuation: AsyncStream.Continuation + } + + private struct SnapshotDelta { + var touchedPaths: Set = [] + var fileChanges: [FileEdit.FileChange] = [] + + var hasChanges: Bool { + !touchedPaths.isEmpty || !fileChanges.isEmpty + } + } + + public let workspaceId: UUID + + private let sharedWorkspace: Workspace + private let store: any CheckpointStore + + private var didLoadStoreState = false + private var sessions: [UUID: SessionState] = [:] + private var checkpoints: [Checkpoint] = [] + private var mutations: [MutationRecord] = [] + private var sharedHeadCheckpointId: UUID? + private var nextMutationSequence = 1 + private var eventWatchers: [UUID: EventWatcher] = [:] + + /// Creates a history coordinator over `filesystem`. + public init( + workspaceId: UUID = UUID(), + filesystem: any FileSystem, + historyDirectory: URL + ) { + self.workspaceId = workspaceId + sharedWorkspace = Workspace(filesystem: filesystem) + store = FileCheckpointStore(rootDirectory: historyDirectory) + } + + /// Creates a history coordinator over an existing shared workspace. + public init( + workspaceId: UUID = UUID(), + workspace: Workspace, + historyDirectory: URL + ) { + self.workspaceId = workspaceId + sharedWorkspace = workspace + store = FileCheckpointStore(rootDirectory: historyDirectory) + } + + init( + workspaceId: UUID = UUID(), + filesystem: any FileSystem, + store: any CheckpointStore + ) { + self.workspaceId = workspaceId + sharedWorkspace = Workspace(filesystem: filesystem) + self.store = store + } + + init( + workspaceId: UUID = UUID(), + workspace: Workspace, + store: any CheckpointStore + ) { + self.workspaceId = workspaceId + sharedWorkspace = workspace + self.store = store + } + + /// Reads raw data from the shared workspace. + public func readData(from path: WorkspacePath) async throws -> Data { + try await ensureLoaded() + return try await sharedWorkspace.readData(from: path) + } + + /// Reads UTF-8 text from the shared workspace. + public func readFile(_ path: WorkspacePath) async throws -> String { + try await ensureLoaded() + return try await sharedWorkspace.readFile(path) + } + + /// Reads JSON from the shared workspace. + public func readJSON(_ type: T.Type = T.self, from path: WorkspacePath) async throws -> T { + try await ensureLoaded() + return try await sharedWorkspace.readJSON(type, from: path) + } + + /// Returns whether an entry exists in the shared workspace. + public func exists(_ path: WorkspacePath) async throws -> Bool { + try await ensureLoaded() + return await sharedWorkspace.exists(path) + } + + /// Returns shared workspace metadata. + public func fileInfo(at path: WorkspacePath) async throws -> FileInfo { + try await ensureLoaded() + return try await sharedWorkspace.fileInfo(at: path) + } + + /// Lists a shared workspace directory. + public func listDirectory(at path: WorkspacePath) async throws -> [DirectoryEntry] { + try await ensureLoaded() + return try await sharedWorkspace.listDirectory(at: path) + } + + /// Expands a glob against the shared workspace. + public func glob(_ pattern: String, currentDirectory: WorkspacePath = .root) async throws -> [WorkspacePath] { + try await ensureLoaded() + return try await sharedWorkspace.glob(pattern, currentDirectory: currentDirectory) + } + + /// Walks the shared workspace tree. + public func walkTree(_ path: WorkspacePath, maxDepth: Int? = nil) async throws -> FileTree { + try await ensureLoaded() + return try await sharedWorkspace.walkTree(path, maxDepth: maxDepth) + } + + /// Summarizes the shared workspace tree. + public func summarizeTree(_ path: WorkspacePath, maxDepth: Int? = nil) async throws -> FileTreeSummary { + try await ensureLoaded() + return try await sharedWorkspace.summarizeTree(path, maxDepth: maxDepth) + } + + /// Watches shared workspace filesystem changes. + public func watchChanges(at path: WorkspacePath, recursive: Bool = true) async throws -> AsyncStream { + try await ensureLoaded() + return await sharedWorkspace.watchChanges(at: path, recursive: recursive) + } + + /// Watches durable checkpoint events for `workspaceId`. + func watchCheckpointEvents(workspaceId: UUID) async throws -> AsyncStream { + try await ensureLoaded() + + guard workspaceId == self.workspaceId else { + return AsyncStream { continuation in + continuation.finish() + } + } + + let watcherId = UUID() + var continuation: AsyncStream.Continuation? + let stream = AsyncStream { + continuation = $0 + } + + guard let continuation else { + return stream + } + + eventWatchers[watcherId] = EventWatcher(workspaceId: workspaceId, continuation: continuation) + continuation.onTermination = { [weak self] _ in + Task { + await self?.removeEventWatcher(id: watcherId) + } + } + return stream + } + + /// Creates a tracked session overlay from the current shared head. + public func createSession() async throws -> Session { + try await ensureLoaded() + + let sessionId = UUID() + let snapshot = try await sharedWorkspace.captureSnapshot() + let overlay = InMemoryFilesystem() + let sessionWorkspace = Workspace(filesystem: overlay) + try await sessionWorkspace.restoreSnapshot(snapshot) + + sessions[sessionId] = SessionState( + workspace: sessionWorkspace, + baseSharedCheckpointId: sharedHeadCheckpointId, + lastCheckpointId: nil + ) + + return Session( + id: sessionId, + workspaceId: workspaceId, + workspace: sessionWorkspace, + history: self + ) + } + + /// Writes UTF-8 text to the shared workspace. + public func writeFile(_ path: WorkspacePath, content: String) async throws { + try await ensureLoaded() + try await performDirectEdit( + on: sharedWorkspace, + scope: .shared, + sessionId: nil, + kind: .writeFile, + edit: .writeFile(path: path, content: content) + ) { workspace in + try await workspace.writeFile(path, content: content) + } + } + + /// Appends UTF-8 text to a shared workspace file. + public func appendFile(_ path: WorkspacePath, content: String) async throws { + try await ensureLoaded() + try await performDirectEdit( + on: sharedWorkspace, + scope: .shared, + sessionId: nil, + kind: .appendFile, + edit: .appendFile(path: path, content: content) + ) { workspace in + try await workspace.appendFile(path, content: content) + } + } + + /// Writes raw data to the shared workspace. + public func writeData(_ data: Data, to path: WorkspacePath) async throws { + try await ensureLoaded() + try await performBinaryWrite( + on: sharedWorkspace, + scope: .shared, + sessionId: nil, + data: data, + path: path + ) + } + + /// Encodes JSON and writes it to the shared workspace. + public func writeJSON( + _ value: T, + to path: WorkspacePath, + prettyPrinted: Bool = true + ) async throws { + try await ensureLoaded() + let content = try encodedJSONString(for: value, prettyPrinted: prettyPrinted) + try await performDirectEdit( + on: sharedWorkspace, + scope: .shared, + sessionId: nil, + kind: .writeJSON, + edit: .writeFile(path: path, content: content) + ) { workspace in + try await workspace.writeFile(path, content: content) + } + } + + /// Creates a directory in the shared workspace. + public func createDirectory(at path: WorkspacePath, recursive: Bool = true) async throws { + try await ensureLoaded() + try await performDirectEdit( + on: sharedWorkspace, + scope: .shared, + sessionId: nil, + kind: .createDirectory, + edit: .createDirectory(path: path, recursive: recursive) + ) { workspace in + try await workspace.createDirectory(at: path, recursive: recursive) + } + } + + /// Removes an item from the shared workspace. + public func removeItem(at path: WorkspacePath, recursive: Bool = true) async throws { + try await ensureLoaded() + try await performDirectEdit( + on: sharedWorkspace, + scope: .shared, + sessionId: nil, + kind: .removeItem, + edit: .delete(path: path, recursive: recursive) + ) { workspace in + try await workspace.removeItem(at: path, recursive: recursive) + } + } + + /// Copies an item inside the shared workspace. + public func copyItem(from source: WorkspacePath, to destination: WorkspacePath, recursive: Bool = true) async throws { + try await ensureLoaded() + try await performDirectEdit( + on: sharedWorkspace, + scope: .shared, + sessionId: nil, + kind: .copyItem, + edit: .copy(from: source, to: destination, recursive: recursive) + ) { workspace in + try await workspace.copyItem(from: source, to: destination, recursive: recursive) + } + } + + /// Moves an item inside the shared workspace. + public func moveItem(from source: WorkspacePath, to destination: WorkspacePath) async throws { + try await ensureLoaded() + try await performDirectEdit( + on: sharedWorkspace, + scope: .shared, + sessionId: nil, + kind: .moveItem, + edit: .move(from: source, to: destination) + ) { workspace in + try await workspace.moveItem(from: source, to: destination) + } + } + + /// Applies a batch of tracked edits to the shared workspace. + public func applyEdits( + _ edits: [FileEdit], + failurePolicy: MutationFailurePolicy = .rollback + ) async throws -> FileEdit.BatchResult { + try await ensureLoaded() + let result = try await sharedWorkspace.applyEdits(edits, failurePolicy: failurePolicy) + if !edits.isEmpty { + try await appendMutation( + kind: .applyEdits, + scope: .shared, + sessionId: nil, + touchedPaths: result.touchedPaths, + fileChanges: result.edits.flatMap(\.fileChanges), + diff: result.edits.count == 1 ? result.edits[0].fileChanges.first?.diff : nil + ) + } + return result + } + + /// Applies a tracked multi-file replacement to the shared workspace. + public func applyReplacement( + _ request: ReplacementRequest, + failurePolicy: MutationFailurePolicy = .rollback + ) async throws -> ReplacementResult { + try await ensureLoaded() + let result = try await sharedWorkspace.applyReplacement(request, failurePolicy: failurePolicy) + if !result.changes.isEmpty || !result.failures.isEmpty { + try await appendMutation( + kind: .applyReplacement, + scope: .shared, + sessionId: nil, + touchedPaths: result.touchedPaths, + fileChanges: result.changes.map { + FileEdit.FileChange( + path: $0.path, + kind: .file, + effect: .modified, + status: $0.status, + diff: $0.diff + ) + }, + diff: result.changes.count == 1 ? result.changes[0].diff : nil + ) + } + return result + } + + /// Creates a shared checkpoint. + public func createCheckpoint(label: String? = nil) async throws -> Checkpoint { + try await ensureLoaded() + let snapshot = try await sharedWorkspace.captureSnapshot() + return try await persistCheckpoint( + snapshot: snapshot, + scope: .shared, + sessionId: nil, + label: label, + parentCheckpointId: sharedHeadCheckpointId, + baseSharedCheckpointId: nil, + originSessionId: nil, + rollbackSourceCheckpointId: nil, + eventKind: .created + ) + } + + /// Rolls the shared workspace back to a prior shared checkpoint. + public func rollbackShared(to checkpointId: UUID, label: String? = nil) async throws -> Checkpoint { + try await ensureLoaded() + let checkpoint = try checkpointOrThrow(id: checkpointId) + guard checkpoint.scope == .shared else { + throw HistoryError.checkpointScopeMismatch(expected: .shared, actual: checkpoint.scope) + } + try await sharedWorkspace.restoreSnapshot(checkpoint.snapshot) + let restoredSnapshot = try await sharedWorkspace.captureSnapshot() + return try await persistCheckpoint( + snapshot: restoredSnapshot, + scope: .shared, + sessionId: nil, + label: label, + parentCheckpointId: sharedHeadCheckpointId, + baseSharedCheckpointId: nil, + originSessionId: nil, + rollbackSourceCheckpointId: checkpoint.id, + eventKind: .rolledBack + ) + } + + /// Publishes the current session head into the shared workspace. + func publishSessionHead(sessionId: UUID, label: String? = nil) async throws -> Checkpoint { + try await ensureLoaded() + var session = try sessionState(for: sessionId) + + guard sharedHeadCheckpointId == session.baseSharedCheckpointId else { + throw HistoryError.publishConflict( + sessionId: sessionId, + expectedBaseSharedCheckpointId: session.baseSharedCheckpointId, + actualSharedCheckpointId: sharedHeadCheckpointId + ) + } + + let previousSharedSnapshot = try await sharedWorkspace.captureSnapshot() + let sessionSnapshot = try await session.workspace.captureSnapshot() + let delta = snapshotDelta(from: previousSharedSnapshot.entry, to: sessionSnapshot.entry) + + try await sharedWorkspace.restoreSnapshot(sessionSnapshot) + + if delta.hasChanges { + try await appendMutation( + kind: .publishSessionHead, + scope: .shared, + sessionId: sessionId, + touchedPaths: Array(delta.touchedPaths).sorted(), + fileChanges: delta.fileChanges, + diff: nil + ) + } + + let checkpoint = try await persistCheckpoint( + snapshot: sessionSnapshot, + scope: .shared, + sessionId: sessionId, + label: label, + parentCheckpointId: sharedHeadCheckpointId, + baseSharedCheckpointId: nil, + originSessionId: sessionId, + rollbackSourceCheckpointId: nil, + eventKind: .published + ) + + session.baseSharedCheckpointId = checkpoint.id + sessions[sessionId] = session + return checkpoint + } + + /// Lists checkpoints for the managed workspace. + public func listCheckpoints( + scope: Checkpoint.Scope? = nil, + sessionId: UUID? = nil + ) async throws -> [Checkpoint] { + try await ensureLoaded() + return checkpoints.filter { checkpoint in + if let scope, checkpoint.scope != scope { + return false + } + if let sessionId { + return checkpoint.sessionId == sessionId + } + return true + } + } + + /// Lists mutation records for the managed workspace. + func listMutationRecords( + scope: Checkpoint.Scope? = nil, + sessionId: UUID? = nil + ) async throws -> [MutationRecord] { + try await ensureLoaded() + return mutations.filter { mutation in + if let scope, mutation.scope != scope { + return false + } + if let sessionId { + return mutation.sessionId == sessionId + } + return true + } + } + + /// Returns one checkpoint when present. + public func checkpoint(id: UUID) async throws -> Checkpoint? { + try await ensureLoaded() + return checkpoints.first(where: { $0.id == id }) + } + + func sessionWriteFile(_ sessionId: UUID, path: WorkspacePath, content: String) async throws { + try await ensureLoaded() + try await performSessionDirectEdit( + sessionId: sessionId, + kind: .writeFile, + edit: .writeFile(path: path, content: content) + ) { workspace in + try await workspace.writeFile(path, content: content) + } + } + + func sessionAppendFile(_ sessionId: UUID, path: WorkspacePath, content: String) async throws { + try await ensureLoaded() + try await performSessionDirectEdit( + sessionId: sessionId, + kind: .appendFile, + edit: .appendFile(path: path, content: content) + ) { workspace in + try await workspace.appendFile(path, content: content) + } + } + + func sessionWriteData(_ sessionId: UUID, data: Data, path: WorkspacePath) async throws { + try await ensureLoaded() + let workspace = try sessionState(for: sessionId).workspace + try await performBinaryWrite(on: workspace, scope: .session, sessionId: sessionId, data: data, path: path) + } + + func sessionWriteJSON( + _ sessionId: UUID, + value: T, + path: WorkspacePath, + prettyPrinted: Bool + ) async throws { + try await ensureLoaded() + let workspace = try sessionState(for: sessionId).workspace + let content = try encodedJSONString(for: value, prettyPrinted: prettyPrinted) + try await performDirectEdit( + on: workspace, + scope: .session, + sessionId: sessionId, + kind: .writeJSON, + edit: .writeFile(path: path, content: content) + ) { workspace in + try await workspace.writeFile(path, content: content) + } + } + + func sessionCreateDirectory(_ sessionId: UUID, path: WorkspacePath, recursive: Bool) async throws { + try await ensureLoaded() + try await performSessionDirectEdit( + sessionId: sessionId, + kind: .createDirectory, + edit: .createDirectory(path: path, recursive: recursive) + ) { workspace in + try await workspace.createDirectory(at: path, recursive: recursive) + } + } + + func sessionRemoveItem(_ sessionId: UUID, path: WorkspacePath, recursive: Bool) async throws { + try await ensureLoaded() + try await performSessionDirectEdit( + sessionId: sessionId, + kind: .removeItem, + edit: .delete(path: path, recursive: recursive) + ) { workspace in + try await workspace.removeItem(at: path, recursive: recursive) + } + } + + func sessionCopyItem( + _ sessionId: UUID, + source: WorkspacePath, + destination: WorkspacePath, + recursive: Bool + ) async throws { + try await ensureLoaded() + try await performSessionDirectEdit( + sessionId: sessionId, + kind: .copyItem, + edit: .copy(from: source, to: destination, recursive: recursive) + ) { workspace in + try await workspace.copyItem(from: source, to: destination, recursive: recursive) + } + } + + func sessionMoveItem(_ sessionId: UUID, source: WorkspacePath, destination: WorkspacePath) async throws { + try await ensureLoaded() + try await performSessionDirectEdit( + sessionId: sessionId, + kind: .moveItem, + edit: .move(from: source, to: destination) + ) { workspace in + try await workspace.moveItem(from: source, to: destination) + } + } + + func sessionApplyEdits( + _ sessionId: UUID, + edits: [FileEdit], + failurePolicy: MutationFailurePolicy + ) async throws -> FileEdit.BatchResult { + try await ensureLoaded() + let workspace = try sessionState(for: sessionId).workspace + let result = try await workspace.applyEdits(edits, failurePolicy: failurePolicy) + if !edits.isEmpty { + try await appendMutation( + kind: .applyEdits, + scope: .session, + sessionId: sessionId, + touchedPaths: result.touchedPaths, + fileChanges: result.edits.flatMap(\.fileChanges), + diff: result.edits.count == 1 ? result.edits[0].fileChanges.first?.diff : nil + ) + } + return result + } + + func sessionApplyReplacement( + _ sessionId: UUID, + request: ReplacementRequest, + failurePolicy: MutationFailurePolicy + ) async throws -> ReplacementResult { + try await ensureLoaded() + let workspace = try sessionState(for: sessionId).workspace + let result = try await workspace.applyReplacement(request, failurePolicy: failurePolicy) + if !result.changes.isEmpty || !result.failures.isEmpty { + try await appendMutation( + kind: .applyReplacement, + scope: .session, + sessionId: sessionId, + touchedPaths: result.touchedPaths, + fileChanges: result.changes.map { + FileEdit.FileChange( + path: $0.path, + kind: .file, + effect: .modified, + status: $0.status, + diff: $0.diff + ) + }, + diff: result.changes.count == 1 ? result.changes[0].diff : nil + ) + } + return result + } + + func createSessionCheckpoint(sessionId: UUID, label: String? = nil) async throws -> Checkpoint { + try await ensureLoaded() + let session = try sessionState(for: sessionId) + let snapshot = try await session.workspace.captureSnapshot() + return try await persistCheckpoint( + snapshot: snapshot, + scope: .session, + sessionId: sessionId, + label: label, + parentCheckpointId: session.lastCheckpointId, + baseSharedCheckpointId: session.baseSharedCheckpointId, + originSessionId: nil, + rollbackSourceCheckpointId: nil, + eventKind: .created + ) + } + + func rollbackSession( + sessionId: UUID, + to checkpointId: UUID, + label: String? = nil + ) async throws -> Checkpoint { + try await ensureLoaded() + var session = try sessionState(for: sessionId) + let checkpoint = try checkpointOrThrow(id: checkpointId) + + switch checkpoint.scope { + case .shared: + break + case .session where checkpoint.sessionId == sessionId: + break + case .session: + throw HistoryError.checkpointSessionMismatch( + checkpointId: checkpoint.id, + expectedSessionId: sessionId, + actualSessionId: checkpoint.sessionId + ) + } + + try await session.workspace.restoreSnapshot(checkpoint.snapshot) + + switch checkpoint.scope { + case .shared: + session.baseSharedCheckpointId = checkpoint.id + case .session: + session.baseSharedCheckpointId = checkpoint.baseSharedCheckpointId + } + sessions[sessionId] = session + + let restoredSnapshot = try await session.workspace.captureSnapshot() + return try await persistCheckpoint( + snapshot: restoredSnapshot, + scope: .session, + sessionId: sessionId, + label: label, + parentCheckpointId: session.lastCheckpointId, + baseSharedCheckpointId: session.baseSharedCheckpointId, + originSessionId: nil, + rollbackSourceCheckpointId: checkpoint.id, + eventKind: .rolledBack + ) + } + + func sessionBaseSharedCheckpointId(_ sessionId: UUID) async throws -> UUID? { + try await ensureLoaded() + return try sessionState(for: sessionId).baseSharedCheckpointId + } + + private func ensureLoaded() async throws { + guard !didLoadStoreState else { + return + } + + checkpoints = try await store.listCheckpoints(workspaceId: workspaceId) + mutations = try await store.listMutationRecords(workspaceId: workspaceId) + nextMutationSequence = (mutations.map(\.sequence).max() ?? 0) + 1 + sharedHeadCheckpointId = checkpoints + .filter { $0.scope == .shared } + .sorted(by: checkpointSort) + .last? + .id + didLoadStoreState = true + } + + private func sessionState(for sessionId: UUID) throws -> SessionState { + guard let session = sessions[sessionId] else { + throw HistoryError.sessionNotFound(sessionId) + } + return session + } + + private func checkpointOrThrow(id: UUID) throws -> Checkpoint { + guard let checkpoint = checkpoints.first(where: { $0.id == id }) else { + throw HistoryError.checkpointNotFound(id) + } + return checkpoint + } + + private func performSessionDirectEdit( + sessionId: UUID, + kind: MutationRecord.Kind, + edit: FileEdit, + apply: @escaping @Sendable (Workspace) async throws -> Void + ) async throws { + let workspace = try sessionState(for: sessionId).workspace + try await performDirectEdit( + on: workspace, + scope: .session, + sessionId: sessionId, + kind: kind, + edit: edit, + apply: apply + ) + } + + private func performDirectEdit( + on workspace: Workspace, + scope: Checkpoint.Scope, + sessionId: UUID?, + kind: MutationRecord.Kind, + edit: FileEdit, + apply: @escaping @Sendable (Workspace) async throws -> Void + ) async throws { + let preview = try await workspace.previewEdits([edit]) + try await apply(workspace) + + let appliedFileChanges = markApplied(preview.edits.first?.fileChanges ?? []) + try await appendMutation( + kind: kind, + scope: scope, + sessionId: sessionId, + touchedPaths: preview.touchedPaths, + fileChanges: appliedFileChanges, + diff: appliedFileChanges.count == 1 ? appliedFileChanges[0].diff : nil + ) + } + + private func performBinaryWrite( + on workspace: Workspace, + scope: Checkpoint.Scope, + sessionId: UUID?, + data: Data, + path: WorkspacePath + ) async throws { + let effect: FileEdit.Effect + let kind: FileTree.Kind + + if await workspace.exists(path) { + let info = try await workspace.fileInfo(at: path) + kind = info.kind + if info.kind == .directory { + effect = .unchanged + } else { + effect = try await workspace.readData(from: path) == data ? .unchanged : .modified + } + } else { + kind = .file + effect = .created + } + + try await workspace.writeData(data, to: path) + try await appendMutation( + kind: .writeData, + scope: scope, + sessionId: sessionId, + touchedPaths: [path], + fileChanges: [ + FileEdit.FileChange( + path: path, + kind: kind, + effect: effect, + status: .applied, + diff: nil + ), + ], + diff: nil + ) + } + + private func persistCheckpoint( + snapshot: Snapshot, + scope: Checkpoint.Scope, + sessionId: UUID?, + label: String?, + parentCheckpointId: UUID?, + baseSharedCheckpointId: UUID?, + originSessionId: UUID?, + rollbackSourceCheckpointId: UUID?, + eventKind: CheckpointEvent.Kind + ) async throws -> Checkpoint { + let parentCheckpoint = parentCheckpointId.flatMap { id in + checkpoints.first(where: { $0.id == id }) + } + let previousCursor = parentCheckpoint?.mutationCursor ?? 0 + let currentCursor = latestMutationSequence(scope: scope, sessionId: sessionId) + let checkpointId = UUID() + let resolvedBaseSharedCheckpointId = scope == .shared ? checkpointId : baseSharedCheckpointId + + let checkpoint = Checkpoint( + id: checkpointId, + workspaceId: workspaceId, + sessionId: sessionId, + scope: scope, + label: label, + parentCheckpointId: parentCheckpointId, + baseSharedCheckpointId: resolvedBaseSharedCheckpointId, + firstMutationSequence: currentCursor > previousCursor ? previousCursor + 1 : nil, + lastMutationSequence: currentCursor > previousCursor ? currentCursor : nil, + mutationCursor: currentCursor, + originSessionId: originSessionId, + rollbackSourceCheckpointId: rollbackSourceCheckpointId, + snapshot: snapshot + ) + + try await store.saveCheckpoint(checkpoint) + + checkpoints.append(checkpoint) + checkpoints.sort(by: checkpointSort) + + switch scope { + case .shared: + sharedHeadCheckpointId = checkpoint.id + case .session: + if let sessionId, var session = sessions[sessionId] { + session.lastCheckpointId = checkpoint.id + sessions[sessionId] = session + } + } + + emit(CheckpointEvent(kind: eventKind, checkpoint: checkpoint)) + return checkpoint + } + + private func appendMutation( + kind: MutationRecord.Kind, + scope: Checkpoint.Scope, + sessionId: UUID?, + touchedPaths: [WorkspacePath], + fileChanges: [FileEdit.FileChange], + diff: TextDiff? + ) async throws { + let mutation = MutationRecord( + sequence: nextMutationSequence, + workspaceId: workspaceId, + sessionId: sessionId, + scope: scope, + kind: kind, + touchedPaths: Array(Set(touchedPaths)).sorted(), + fileChanges: fileChanges.sorted(by: fileChangeSort), + diff: diff + ) + + nextMutationSequence += 1 + mutations.append(mutation) + mutations.sort(by: { $0.sequence < $1.sequence }) + try await store.appendMutation(mutation) + } + + private func latestMutationSequence(scope: Checkpoint.Scope, sessionId: UUID?) -> Int { + mutations + .filter { + guard $0.scope == scope else { + return false + } + if scope == .session { + return $0.sessionId == sessionId + } + return true + } + .map(\.sequence) + .max() ?? 0 + } + + private func encodedJSONString(for value: T, prettyPrinted: Bool) throws -> String { + let encoder = JSONEncoder() + if prettyPrinted { + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + } + + var data = try encoder.encode(value) + data.append(Data("\n".utf8)) + guard let string = String(data: data, encoding: .utf8) else { + throw WorkspaceError.invalidEncoding(.root) + } + return string + } + + private func markApplied(_ fileChanges: [FileEdit.FileChange]) -> [FileEdit.FileChange] { + fileChanges.map { change in + var copy = change + copy.status = .applied + return copy + } + } + + private func snapshotDelta(from original: Snapshot.Entry, to updated: Snapshot.Entry) -> SnapshotDelta { + var delta = SnapshotDelta() + collectDelta(from: original, to: updated, into: &delta) + delta.fileChanges.sort(by: fileChangeSort) + return delta + } + + private func collectDelta( + from original: Snapshot.Entry, + to updated: Snapshot.Entry, + into delta: inout SnapshotDelta + ) { + switch (original, updated) { + case (.missing, .missing): + return + case (.missing, _): + collectCreated(updated, into: &delta) + case (_, .missing): + collectDeleted(original, into: &delta) + case let (.file(lhs), .file(rhs)): + if lhs.data != rhs.data || lhs.permissions != rhs.permissions { + delta.touchedPaths.insert(rhs.path) + delta.fileChanges.append( + FileEdit.FileChange( + path: rhs.path, + kind: .file, + effect: .modified, + status: .applied, + diff: nil + ) + ) + } + case let (.symlink(lhs), .symlink(rhs)): + if lhs.target != rhs.target || lhs.permissions != rhs.permissions { + delta.touchedPaths.insert(rhs.path) + delta.fileChanges.append( + FileEdit.FileChange( + path: rhs.path, + kind: .symlink, + effect: .modified, + status: .applied, + diff: nil + ) + ) + } + case let (.directory(lhs), .directory(rhs)): + if lhs.permissions != rhs.permissions { + delta.touchedPaths.insert(rhs.path) + } + + let lhsChildren = Dictionary(uniqueKeysWithValues: lhs.children.map { (Snapshot.path(of: $0).basename, $0) }) + let rhsChildren = Dictionary(uniqueKeysWithValues: rhs.children.map { (Snapshot.path(of: $0).basename, $0) }) + let names = Set(lhsChildren.keys).union(rhsChildren.keys).sorted() + + for name in names { + switch (lhsChildren[name], rhsChildren[name]) { + case let (lhs?, rhs?): + collectDelta(from: lhs, to: rhs, into: &delta) + case let (lhs?, nil): + collectDeleted(lhs, into: &delta) + case let (nil, rhs?): + collectCreated(rhs, into: &delta) + case (nil, nil): + break + } + } + default: + collectDeleted(original, into: &delta) + collectCreated(updated, into: &delta) + } + } + + private func collectCreated(_ entry: Snapshot.Entry, into delta: inout SnapshotDelta) { + switch entry { + case let .missing(entry): + delta.touchedPaths.insert(entry.path) + case let .file(entry): + delta.touchedPaths.insert(entry.path) + delta.fileChanges.append( + FileEdit.FileChange( + path: entry.path, + kind: .file, + effect: .created, + status: .applied, + diff: nil + ) + ) + case let .directory(entry): + delta.touchedPaths.insert(entry.path) + for child in entry.children { + collectCreated(child, into: &delta) + } + case let .symlink(entry): + delta.touchedPaths.insert(entry.path) + delta.fileChanges.append( + FileEdit.FileChange( + path: entry.path, + kind: .symlink, + effect: .created, + status: .applied, + diff: nil + ) + ) + } + } + + private func collectDeleted(_ entry: Snapshot.Entry, into delta: inout SnapshotDelta) { + switch entry { + case let .missing(entry): + delta.touchedPaths.insert(entry.path) + case let .file(entry): + delta.touchedPaths.insert(entry.path) + delta.fileChanges.append( + FileEdit.FileChange( + path: entry.path, + kind: .file, + effect: .deleted, + status: .applied, + diff: nil + ) + ) + case let .directory(entry): + delta.touchedPaths.insert(entry.path) + for child in entry.children { + collectDeleted(child, into: &delta) + } + case let .symlink(entry): + delta.touchedPaths.insert(entry.path) + delta.fileChanges.append( + FileEdit.FileChange( + path: entry.path, + kind: .symlink, + effect: .deleted, + status: .applied, + diff: nil + ) + ) + } + } + + private func emit(_ event: CheckpointEvent) { + let watchers = eventWatchers.values.filter { $0.workspaceId == event.workspaceId } + for watcher in watchers { + watcher.continuation.yield(event) + } + } + + private func removeEventWatcher(id: UUID) { + eventWatchers.removeValue(forKey: id) + } + + private func checkpointSort(lhs: Checkpoint, rhs: Checkpoint) -> Bool { + if lhs.createdAt == rhs.createdAt { + return lhs.id.uuidString < rhs.id.uuidString + } + return lhs.createdAt < rhs.createdAt + } + + private func fileChangeSort(lhs: FileEdit.FileChange, rhs: FileEdit.FileChange) -> Bool { + if lhs.path == rhs.path { + return lhs.effect.rawValue < rhs.effect.rawValue + } + return lhs.path < rhs.path + } +} + +extension History { + /// A tracked session-local editing overlay managed by `History`. + public actor Session { + public let id: UUID + public let workspaceId: UUID + + private let workspace: Workspace + private let history: History + + init(id: UUID, workspaceId: UUID, workspace: Workspace, history: History) { + self.id = id + self.workspaceId = workspaceId + self.workspace = workspace + self.history = history + } + + /// Reads raw data from the session overlay. + public func readData(from path: WorkspacePath) async throws -> Data { + try await workspace.readData(from: path) + } + + /// Reads UTF-8 text from the session overlay. + public func readFile(_ path: WorkspacePath) async throws -> String { + try await workspace.readFile(path) + } + + /// Reads JSON from the session overlay. + public func readJSON(_ type: T.Type = T.self, from path: WorkspacePath) async throws -> T { + try await workspace.readJSON(type, from: path) + } + + /// Returns whether an entry exists in the session overlay. + public func exists(_ path: WorkspacePath) async -> Bool { + await workspace.exists(path) + } + + /// Returns session overlay metadata. + public func fileInfo(at path: WorkspacePath) async throws -> FileInfo { + try await workspace.fileInfo(at: path) + } + + /// Lists a session overlay directory. + public func listDirectory(at path: WorkspacePath) async throws -> [DirectoryEntry] { + try await workspace.listDirectory(at: path) + } + + /// Expands a glob against the session overlay. + public func glob(_ pattern: String, currentDirectory: WorkspacePath = .root) async throws -> [WorkspacePath] { + try await workspace.glob(pattern, currentDirectory: currentDirectory) + } + + /// Walks the session overlay tree. + public func walkTree(_ path: WorkspacePath, maxDepth: Int? = nil) async throws -> FileTree { + try await workspace.walkTree(path, maxDepth: maxDepth) + } + + /// Summarizes the session overlay tree. + public func summarizeTree(_ path: WorkspacePath, maxDepth: Int? = nil) async throws -> FileTreeSummary { + try await workspace.summarizeTree(path, maxDepth: maxDepth) + } + + /// Watches session-local filesystem changes. + public func watchChanges(at path: WorkspacePath, recursive: Bool = true) async -> AsyncStream { + await workspace.watchChanges(at: path, recursive: recursive) + } + + /// Writes UTF-8 text into the session overlay. + public func writeFile(_ path: WorkspacePath, content: String) async throws { + try await history.sessionWriteFile(id, path: path, content: content) + } + + /// Appends UTF-8 text into the session overlay. + public func appendFile(_ path: WorkspacePath, content: String) async throws { + try await history.sessionAppendFile(id, path: path, content: content) + } + + /// Writes raw data into the session overlay. + public func writeData(_ data: Data, to path: WorkspacePath) async throws { + try await history.sessionWriteData(id, data: data, path: path) + } + + /// Encodes JSON into the session overlay. + public func writeJSON( + _ value: T, + to path: WorkspacePath, + prettyPrinted: Bool = true + ) async throws { + try await history.sessionWriteJSON(id, value: value, path: path, prettyPrinted: prettyPrinted) + } + + /// Creates a directory in the session overlay. + public func createDirectory(at path: WorkspacePath, recursive: Bool = true) async throws { + try await history.sessionCreateDirectory(id, path: path, recursive: recursive) + } + + /// Removes an item from the session overlay. + public func removeItem(at path: WorkspacePath, recursive: Bool = true) async throws { + try await history.sessionRemoveItem(id, path: path, recursive: recursive) + } + + /// Copies an item inside the session overlay. + public func copyItem(from source: WorkspacePath, to destination: WorkspacePath, recursive: Bool = true) async throws { + try await history.sessionCopyItem(id, source: source, destination: destination, recursive: recursive) + } + + /// Moves an item inside the session overlay. + public func moveItem(from source: WorkspacePath, to destination: WorkspacePath) async throws { + try await history.sessionMoveItem(id, source: source, destination: destination) + } + + /// Applies a tracked batch of edits to the session overlay. + public func applyEdits( + _ edits: [FileEdit], + failurePolicy: MutationFailurePolicy = .rollback + ) async throws -> FileEdit.BatchResult { + try await history.sessionApplyEdits(id, edits: edits, failurePolicy: failurePolicy) + } + + /// Applies a tracked replacement to the session overlay. + public func applyReplacement( + _ request: ReplacementRequest, + failurePolicy: MutationFailurePolicy = .rollback + ) async throws -> ReplacementResult { + try await history.sessionApplyReplacement(id, request: request, failurePolicy: failurePolicy) + } + + /// Creates a session-local checkpoint. + public func createCheckpoint(label: String? = nil) async throws -> Checkpoint { + try await history.createSessionCheckpoint(sessionId: id, label: label) + } + + /// Restores the session overlay to a prior checkpoint. + public func rollback(to checkpointId: UUID, label: String? = nil) async throws -> Checkpoint { + try await history.rollbackSession(sessionId: id, to: checkpointId, label: label) + } + + /// Publishes the current session head into the shared workspace. + public func publishHead(label: String? = nil) async throws -> Checkpoint { + try await history.publishSessionHead(sessionId: id, label: label) + } + + /// Lists checkpoints visible to this session. + public func listCheckpoints() async throws -> [Checkpoint] { + let checkpoints = try await history.listCheckpoints() + return checkpoints.filter { checkpoint in + switch checkpoint.scope { + case .shared: + return true + case .session: + return checkpoint.sessionId == id + } + } + } + + /// Lists mutation records for this session. + func listMutationRecords() async throws -> [MutationRecord] { + try await history.listMutationRecords(scope: .session, sessionId: id) + } + + /// Returns the shared checkpoint this session is currently based on. + func baseSharedCheckpointId() async throws -> UUID? { + try await history.sessionBaseSharedCheckpointId(id) + } + } +} diff --git a/Sources/Workspace/HistoryTypes.swift b/Sources/Workspace/HistoryTypes.swift new file mode 100644 index 0000000..6411705 --- /dev/null +++ b/Sources/Workspace/HistoryTypes.swift @@ -0,0 +1,206 @@ +import Foundation + +extension History { + /// A durable checkpoint over a managed workspace or session overlay. + public struct Checkpoint: Sendable, Codable, Equatable { + /// The checkpoint scope. + public enum Scope: String, Sendable, Codable { + /// A checkpoint over a session-local overlay. + case session + /// A checkpoint over the shared workspace head. + case shared + } + + /// The checkpoint identifier. + public var id: UUID + /// The managed workspace identifier. + var workspaceId: UUID + /// The session that created or owns the checkpoint when available. + public var sessionId: UUID? + /// Whether the checkpoint belongs to a session overlay or the shared head. + public var scope: Scope + /// An optional human-readable label. + public var label: String? + /// The checkpoint creation timestamp. + public var createdAt: Date + /// The prior checkpoint in the same scope lineage. + var parentCheckpointId: UUID? + /// The shared checkpoint the session was based on when this checkpoint was created. + var baseSharedCheckpointId: UUID? + /// The first mutation sequence captured by this checkpoint, if any. + var firstMutationSequence: Int? + /// The last mutation sequence captured by this checkpoint, if any. + var lastMutationSequence: Int? + /// The latest visible mutation sequence when the checkpoint was created. + var mutationCursor: Int + /// The originating session for shared publish checkpoints when available. + var originSessionId: UUID? + /// The source checkpoint for rollback checkpoints when available. + var rollbackSourceCheckpointId: UUID? + /// The captured restorable workspace state for this checkpoint. + var snapshot: Snapshot + + /// Creates a workspace checkpoint. + init( + id: UUID = UUID(), + workspaceId: UUID, + sessionId: UUID?, + scope: Scope, + label: String?, + createdAt: Date = Date(), + parentCheckpointId: UUID?, + baseSharedCheckpointId: UUID?, + firstMutationSequence: Int?, + lastMutationSequence: Int?, + mutationCursor: Int, + originSessionId: UUID? = nil, + rollbackSourceCheckpointId: UUID? = nil, + snapshot: Snapshot + ) { + self.id = id + self.workspaceId = workspaceId + self.sessionId = sessionId + self.scope = scope + self.label = label + self.createdAt = createdAt + self.parentCheckpointId = parentCheckpointId + self.baseSharedCheckpointId = baseSharedCheckpointId + self.firstMutationSequence = firstMutationSequence + self.lastMutationSequence = lastMutationSequence + self.mutationCursor = mutationCursor + self.originSessionId = originSessionId + self.rollbackSourceCheckpointId = rollbackSourceCheckpointId + self.snapshot = snapshot + } + } +} + +/// A normalized mutation record stored between checkpoints. +struct MutationRecord: Sendable, Codable, Equatable { + /// The tracked mutation kind. + enum Kind: String, Sendable, Codable { + case writeFile + case appendFile + case writeData + case writeJSON + case createDirectory + case removeItem + case copyItem + case moveItem + case applyEdits + case applyReplacement + case publishSessionHead + } + + /// The monotonically increasing mutation sequence. + var sequence: Int + /// The managed workspace identifier. + var workspaceId: UUID + /// The originating session when available. + var sessionId: UUID? + /// Whether the mutation targeted a session overlay or the shared head. + var scope: History.Checkpoint.Scope + /// The mutation timestamp. + var timestamp: Date + /// The tracked mutation kind. + var kind: Kind + /// The canonicalized paths touched by the mutation. + var touchedPaths: [WorkspacePath] + /// The normalized file-level changes described by the mutation. + var fileChanges: [FileEdit.FileChange] + /// A primary text diff when the mutation naturally maps to one. + var diff: TextDiff? + + /// Creates a workspace mutation record. + init( + sequence: Int, + workspaceId: UUID, + sessionId: UUID?, + scope: History.Checkpoint.Scope, + timestamp: Date = Date(), + kind: Kind, + touchedPaths: [WorkspacePath], + fileChanges: [FileEdit.FileChange], + diff: TextDiff? = nil + ) { + self.sequence = sequence + self.workspaceId = workspaceId + self.sessionId = sessionId + self.scope = scope + self.timestamp = timestamp + self.kind = kind + self.touchedPaths = touchedPaths + self.fileChanges = fileChanges + self.diff = diff + } +} + +/// A durable checkpoint event emitted by `History`. +struct CheckpointEvent: Sendable, Codable, Equatable { + /// The event kind. + enum Kind: String, Sendable, Codable { + /// A checkpoint was created manually. + case created + /// A rollback restored a prior checkpoint and emitted a new rollback checkpoint. + case rolledBack + /// A session head was published to the shared workspace. + case published + } + + /// The event kind. + var kind: Kind + /// The resulting checkpoint metadata. + var checkpoint: History.Checkpoint + + /// Creates a checkpoint event. + init(kind: Kind, checkpoint: History.Checkpoint) { + self.kind = kind + self.checkpoint = checkpoint + } + + /// The managed workspace identifier. + var workspaceId: UUID { + checkpoint.workspaceId + } + + /// The originating session identifier when available. + var sessionId: UUID? { + checkpoint.sessionId + } + + /// The checkpoint scope. + var scope: History.Checkpoint.Scope { + checkpoint.scope + } +} + +/// Errors produced by session-scoped checkpointing and publish flows. +enum HistoryError: Error, CustomStringConvertible, Sendable { + case sessionNotFound(UUID) + case checkpointNotFound(UUID) + case checkpointScopeMismatch(expected: History.Checkpoint.Scope, actual: History.Checkpoint.Scope) + case checkpointSessionMismatch(checkpointId: UUID, expectedSessionId: UUID, actualSessionId: UUID?) + case publishConflict(sessionId: UUID, expectedBaseSharedCheckpointId: UUID?, actualSharedCheckpointId: UUID?) + case mutationFailed(String) + + /// A human-readable description of the error. + var description: String { + switch self { + case let .sessionNotFound(sessionId): + return "workspace session not found: \(sessionId.uuidString)" + case let .checkpointNotFound(checkpointId): + return "workspace checkpoint not found: \(checkpointId.uuidString)" + case let .checkpointScopeMismatch(expected, actual): + return "checkpoint scope mismatch: expected \(expected.rawValue), got \(actual.rawValue)" + case let .checkpointSessionMismatch(checkpointId, expectedSessionId, actualSessionId): + let actual = actualSessionId?.uuidString ?? "nil" + return "checkpoint \(checkpointId.uuidString) belongs to session \(actual), expected \(expectedSessionId.uuidString)" + case let .publishConflict(sessionId, expectedBaseSharedCheckpointId, actualSharedCheckpointId): + let expected = expectedBaseSharedCheckpointId?.uuidString ?? "nil" + let actual = actualSharedCheckpointId?.uuidString ?? "nil" + return "cannot publish session \(sessionId.uuidString): expected shared head \(expected), current shared head is \(actual)" + case let .mutationFailed(message): + return message + } + } +} diff --git a/Sources/Workspace/Snapshot.swift b/Sources/Workspace/Snapshot.swift new file mode 100644 index 0000000..5d3b82c --- /dev/null +++ b/Sources/Workspace/Snapshot.swift @@ -0,0 +1,285 @@ +import Foundation + +/// A durable recursive snapshot of a workspace subtree. +struct Snapshot: Sendable, Codable, Equatable { + /// A snapshot entry in the captured tree. + indirect enum Entry: Sendable, Codable, Equatable { + /// The captured path did not exist. + case missing(Missing) + /// A regular file with captured bytes. + case file(File) + /// A directory with recursive children. + case directory(Directory) + /// A symbolic link and its captured target. + case symlink(Symlink) + } + + /// A missing path entry. + struct Missing: Sendable, Codable, Equatable { + /// The missing path. + var path: WorkspacePath + + /// Creates a missing entry. + init(path: WorkspacePath) { + self.path = path + } + } + + /// A file entry. + struct File: Sendable, Codable, Equatable { + /// The file path. + var path: WorkspacePath + /// The file contents. + var data: Data + /// The file permissions. + var permissions: POSIXPermissions + + /// Creates a file entry. + init(path: WorkspacePath, data: Data, permissions: POSIXPermissions) { + self.path = path + self.data = data + self.permissions = permissions + } + } + + /// A directory entry. + struct Directory: Sendable, Codable, Equatable { + /// The directory path. + var path: WorkspacePath + /// The directory permissions. + var permissions: POSIXPermissions + /// The captured child entries. + var children: [Entry] + + /// Creates a directory entry. + init(path: WorkspacePath, permissions: POSIXPermissions, children: [Entry]) { + self.path = path + self.permissions = permissions + self.children = children + } + } + + /// A symbolic link entry. + struct Symlink: Sendable, Codable, Equatable { + /// The symlink path. + var path: WorkspacePath + /// The symlink target string. + var target: String + /// The symlink permissions. + var permissions: POSIXPermissions + + /// Creates a symlink entry. + init(path: WorkspacePath, target: String, permissions: POSIXPermissions) { + self.path = path + self.target = target + self.permissions = permissions + } + } + + /// The snapshot identifier. + var id: UUID + /// The root path represented by the snapshot. + var rootPath: WorkspacePath + /// The captured entry tree at `rootPath`. + var entry: Entry + + /// Creates a workspace snapshot. + init(id: UUID = UUID(), rootPath: WorkspacePath, entry: Entry) { + self.id = id + self.rootPath = rootPath + self.entry = entry + } +} + +extension Snapshot { + static func capture( + from target: any FileSystem, + at path: WorkspacePath = .root, + snapshotId: UUID = UUID() + ) async throws -> Snapshot { + Snapshot( + id: snapshotId, + rootPath: path, + entry: try await captureEntry(from: target, at: path) + ) + } + + static func restore(_ snapshot: Snapshot, to target: any FileSystem) async throws { + try await restoreEntry(snapshot.entry, to: target) + } + + static func path(of entry: Entry) -> WorkspacePath { + switch entry { + case let .missing(entry): + entry.path + case let .file(entry): + entry.path + case let .directory(entry): + entry.path + case let .symlink(entry): + entry.path + } + } + + private static func captureEntry(from target: any FileSystem, at path: WorkspacePath) async throws -> Entry { + guard await target.exists(path: path) else { + return .missing(.init(path: path)) + } + + let info = try await target.stat(path: path) + switch info.kind { + case .directory: + let entries = try await target.listDirectory(path: path) + let children = try await entries + .sorted { $0.name < $1.name } + .snapshotAsyncMap { entry in + try await captureEntry(from: target, at: path.appending(entry.name)) + } + return .directory(.init(path: path, permissions: info.permissions, children: children)) + case .symlink: + return .symlink( + .init( + path: path, + target: try await target.readSymlink(path: path), + permissions: info.permissions + ) + ) + case .file: + return .file( + .init( + path: path, + data: try await target.readFile(path: path), + permissions: info.permissions + ) + ) + } + } + + private static func restoreEntry(_ entry: Entry, to target: any FileSystem) async throws { + switch entry { + case let .missing(missing): + try await removeEntryIfPresent(at: missing.path, on: target) + case let .file(file): + try await ensureParentDirectory(for: file.path, on: target) + + let rewriteRequired: Bool + if await target.exists(path: file.path) { + let info = try await target.stat(path: file.path) + if info.kind == .file { + rewriteRequired = try await target.readFile(path: file.path) != file.data + } else { + try await target.remove(path: file.path, recursive: true) + rewriteRequired = true + } + } else { + rewriteRequired = true + } + + if rewriteRequired { + try await target.writeFile(path: file.path, data: file.data, append: false) + } + try await target.setPermissions(path: file.path, permissions: file.permissions) + case let .symlink(symlink): + try await ensureParentDirectory(for: symlink.path, on: target) + + var rewriteRequired = true + if await target.exists(path: symlink.path) { + let info = try await target.stat(path: symlink.path) + if info.kind == .symlink, try await target.readSymlink(path: symlink.path) == symlink.target { + rewriteRequired = false + } else { + try await target.remove(path: symlink.path, recursive: true) + } + } + + if rewriteRequired { + try await target.createSymlink(path: symlink.path, target: symlink.target) + } + try await target.setPermissions(path: symlink.path, permissions: symlink.permissions) + case let .directory(directory): + try await ensureDirectory(at: directory.path, permissions: directory.permissions, on: target) + try await syncChildren(directory.children, under: directory.path, on: target) + try await target.setPermissions(path: directory.path, permissions: directory.permissions) + } + } + + private static func ensureParentDirectory(for path: WorkspacePath, on target: any FileSystem) async throws { + let parent = path.dirname + guard !parent.isRoot else { + return + } + try await ensureDirectory(at: parent, permissions: .defaultDirectory, on: target) + } + + private static func ensureDirectory( + at path: WorkspacePath, + permissions: POSIXPermissions, + on target: any FileSystem + ) async throws { + if await target.exists(path: path) { + let info = try await target.stat(path: path) + if info.kind == .directory { + return + } + try await target.remove(path: path, recursive: true) + } + + if !path.isRoot { + try await target.createDirectory(path: path, recursive: true) + } + try await target.setPermissions(path: path, permissions: permissions) + } + + private static func syncChildren( + _ expectedChildren: [Entry], + under parentPath: WorkspacePath, + on target: any FileSystem + ) async throws { + let expectedNames = Set(expectedChildren.map { path(of: $0).basename }) + let existingEntries = (try? await target.listDirectory(path: parentPath)) ?? [] + + for entry in existingEntries where !expectedNames.contains(entry.name) { + try await target.remove(path: parentPath.appending(entry.name), recursive: true) + } + + for child in expectedChildren.sorted(by: { path(of: $0) < path(of: $1) }) { + try await restoreEntry(child, to: target) + } + } + + private static func removeEntryIfPresent(at path: WorkspacePath, on target: any FileSystem) async throws { + guard await target.exists(path: path) else { + return + } + + if path.isRoot { + let existingEntries = try await target.listDirectory(path: path) + for entry in existingEntries { + try await target.remove(path: path.appending(entry.name), recursive: true) + } + return + } + + let info = try await target.stat(path: path) + if info.kind == .directory { + let existingEntries = try await target.listDirectory(path: path) + for entry in existingEntries { + try await target.remove(path: path.appending(entry.name), recursive: true) + } + } + try await target.remove(path: path, recursive: true) + } +} + +private extension Array { + func snapshotAsyncMap( + _ transform: @escaping @Sendable (Element) async throws -> T + ) async throws -> [T] { + var values: [T] = [] + values.reserveCapacity(count) + for element in self { + values.append(try await transform(element)) + } + return values + } +} diff --git a/Sources/Workspace/Workspace.swift b/Sources/Workspace/Workspace.swift index 473cca7..d500ca0 100644 --- a/Sources/Workspace/Workspace.swift +++ b/Sources/Workspace/Workspace.swift @@ -181,6 +181,14 @@ public actor Workspace { try await buildSummary(path: path, depth: 0, maxDepth: maxDepth) } + func captureSnapshot(at path: WorkspacePath = .root) async throws -> Snapshot { + try await Snapshot.capture(from: filesystem, at: path) + } + + func restoreSnapshot(_ snapshot: Snapshot) async throws { + try await Snapshot.restore(snapshot, to: filesystem) + } + private struct PlannedReplacement { var change: ReplacementResult.Change var updatedContent: String diff --git a/Tests/WorkspaceTests/WorkspaceTests.swift b/Tests/WorkspaceTests/CoreTests.swift similarity index 98% rename from Tests/WorkspaceTests/WorkspaceTests.swift rename to Tests/WorkspaceTests/CoreTests.swift index 594668d..706e928 100644 --- a/Tests/WorkspaceTests/WorkspaceTests.swift +++ b/Tests/WorkspaceTests/CoreTests.swift @@ -2,8 +2,8 @@ import Foundation import Testing @testable import Workspace -private enum WorkspaceTestSupport { - static func makeTempDirectory(prefix: String = "WorkspaceTests") throws -> URL { +private enum CoreTestSupport { + static func makeTempDirectory(prefix: String = "CoreTests") throws -> URL { let base = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true) let url = base.appendingPathComponent("\(prefix)-\(UUID().uuidString)", isDirectory: true) try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true) @@ -123,7 +123,7 @@ private final class NSErrorFailOnceFilesystem: FileSystem, @unchecked Sendable { func writeFile(path: WorkspacePath, data: Data, append: Bool) async throws { if failingWritePaths.contains(path), !failedWritePaths.contains(path) { failedWritePaths.insert(path) - throw NSError(domain: "WorkspaceTests", code: 42, userInfo: [NSLocalizedDescriptionKey: "forced NSError write failure"]) + throw NSError(domain: "CoreTests", code: 42, userInfo: [NSLocalizedDescriptionKey: "forced NSError write failure"]) } try await base.writeFile(path: path, data: data, append: append) } @@ -312,8 +312,8 @@ private func assertBasicWatchSemantics( ) } -@Suite("Workspace") -struct WorkspaceTests { +@Suite("Core") +struct CoreTests { @Test func `workspace module reexports core filesystem primitives`() async throws { let workspaceFilesystem: any FileSystem = InMemoryFilesystem() @@ -615,8 +615,8 @@ struct WorkspaceTests { watchedPath: "/note.txt" ) - let overlayRoot = try WorkspaceTestSupport.makeTempDirectory(prefix: "WorkspaceOverlayWatch") - defer { WorkspaceTestSupport.removeDirectory(overlayRoot) } + let overlayRoot = try CoreTestSupport.makeTempDirectory(prefix: "OverlayWatch") + defer { CoreTestSupport.removeDirectory(overlayRoot) } let overlayWorkspace = Workspace(filesystem: try await OverlayFilesystem(rootDirectory: overlayRoot)) try await assertBasicWatchSemantics( workspace: overlayWorkspace, @@ -912,7 +912,7 @@ struct WorkspaceTests { #expect(!result.rolledBack) #expect(result.failures.count == 1) #expect(result.failures.first?.path == "/src/b.txt") - #expect(result.failures.first?.message.contains("WorkspaceTests") == true) + #expect(result.failures.first?.message.contains("CoreTests") == true) #expect(result.changes.map(\.status) == [.applied, .failed, .skipped]) #expect(try await base.readFile(path: "/src/a.txt") == Data("bar".utf8)) #expect(try await base.readFile(path: "/src/b.txt") == Data("foo".utf8)) @@ -1098,7 +1098,7 @@ struct WorkspaceTests { #expect(!result.rolledBack) #expect(result.failures.count == 1) #expect(result.failures.first?.index == 1) - #expect(result.failures.first?.message.contains("WorkspaceTests") == true) + #expect(result.failures.first?.message.contains("CoreTests") == true) #expect(result.edits.map(\.status) == [.applied, .failed, .skipped]) #expect(!(await base.exists(path: "/old.txt"))) #expect(!(await base.exists(path: "/blocked.txt"))) @@ -1196,11 +1196,11 @@ struct WorkspaceTests { @Test(.tags(.edits)) func `applyEdits works with overlay and mountable filesystems`() async throws { - let workspaceRoot = try WorkspaceTestSupport.makeTempDirectory(prefix: "WorkspaceMountRoot") - defer { WorkspaceTestSupport.removeDirectory(workspaceRoot) } + let workspaceRoot = try CoreTestSupport.makeTempDirectory(prefix: "MountRoot") + defer { CoreTestSupport.removeDirectory(workspaceRoot) } - let docsRoot = try WorkspaceTestSupport.makeTempDirectory(prefix: "WorkspaceDocsRoot") - defer { WorkspaceTestSupport.removeDirectory(docsRoot) } + let docsRoot = try CoreTestSupport.makeTempDirectory(prefix: "DocsRoot") + defer { CoreTestSupport.removeDirectory(docsRoot) } try Data("guide".utf8).write(to: docsRoot.appendingPathComponent("guide.txt")) let mountable = MountableFilesystem( diff --git a/Tests/WorkspaceTests/WorkspaceFilesystemTests.swift b/Tests/WorkspaceTests/FilesystemTests.swift similarity index 87% rename from Tests/WorkspaceTests/WorkspaceFilesystemTests.swift rename to Tests/WorkspaceTests/FilesystemTests.swift index fe950d6..c56c969 100644 --- a/Tests/WorkspaceTests/WorkspaceFilesystemTests.swift +++ b/Tests/WorkspaceTests/FilesystemTests.swift @@ -14,8 +14,8 @@ private actor PermissionRecorder { } } -private enum WorkspaceFilesystemTestSupport { - static func makeTempDirectory(prefix: String = "WorkspaceFilesystemTests") throws -> URL { +private enum FilesystemTestSupport { + static func makeTempDirectory(prefix: String = "FilesystemTests") throws -> URL { let base = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true) let url = base.appendingPathComponent("\(prefix)-\(UUID().uuidString)", isDirectory: true) try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true) @@ -30,7 +30,7 @@ private enum WorkspaceFilesystemTestSupport { Data(value.utf8) } - static func uniqueSuiteName(prefix: String = "WorkspaceFilesystemTests") -> String { + static func uniqueSuiteName(prefix: String = "FilesystemTests") -> String { "\(prefix).\(UUID().uuidString)" } } @@ -55,8 +55,8 @@ extension Tag { @Tag static var tree: Self } -@Suite("Workspace Filesystem") -struct WorkspaceFilesystemTests { +@Suite("Filesystem") +struct FilesystemTests { @Test(.tags(.permissions)) func `permissioned filesystem normalizes paths and blocks denied writes`() async throws { let base = InMemoryFilesystem() @@ -134,11 +134,11 @@ struct WorkspaceFilesystemTests { @Test(.tags(.readWrite)) func `read-write filesystem rejects symlink escapes outside root`() async throws { - let root = try WorkspaceFilesystemTestSupport.makeTempDirectory(prefix: "WorkspaceFilesystemRoot") - defer { WorkspaceFilesystemTestSupport.removeDirectory(root) } + let root = try FilesystemTestSupport.makeTempDirectory(prefix: "FilesystemRoot") + defer { FilesystemTestSupport.removeDirectory(root) } - let outside = try WorkspaceFilesystemTestSupport.makeTempDirectory(prefix: "WorkspaceFilesystemOutside") - defer { WorkspaceFilesystemTestSupport.removeDirectory(outside) } + let outside = try FilesystemTestSupport.makeTempDirectory(prefix: "FilesystemOutside") + defer { FilesystemTestSupport.removeDirectory(outside) } let outsideFile = outside.appendingPathComponent("outside.txt") try Data("secret".utf8).write(to: outsideFile) @@ -169,8 +169,8 @@ struct WorkspaceFilesystemTests { @Test(.tags(.overlay)) func `overlay reload restores source snapshot`() async throws { - let root = try WorkspaceFilesystemTestSupport.makeTempDirectory(prefix: "WorkspaceOverlayRoot") - defer { WorkspaceFilesystemTestSupport.removeDirectory(root) } + let root = try FilesystemTestSupport.makeTempDirectory(prefix: "OverlayRoot") + defer { FilesystemTestSupport.removeDirectory(root) } let fileURL = root.appendingPathComponent("note.txt") try Data("disk".utf8).write(to: fileURL) @@ -319,16 +319,16 @@ struct WorkspaceFilesystemTests { @Test(.tags(.readWrite)) func `read-write filesystem supports file metadata links globbing and recursive copies`() async throws { - let root = try WorkspaceFilesystemTestSupport.makeTempDirectory(prefix: "WorkspaceReadWriteRoot") - defer { WorkspaceFilesystemTestSupport.removeDirectory(root) } + let root = try FilesystemTestSupport.makeTempDirectory(prefix: "ReadWriteRoot") + defer { FilesystemTestSupport.removeDirectory(root) } let filesystem = try ReadWriteFilesystem(rootDirectory: root) try await filesystem.createDirectory(path: "/docs", recursive: false) - try await filesystem.writeFile(path: "/docs/note.txt", data: WorkspaceFilesystemTestSupport.data("hello"), append: false) - try await filesystem.writeFile(path: "/docs/note.txt", data: WorkspaceFilesystemTestSupport.data(" world"), append: true) + try await filesystem.writeFile(path: "/docs/note.txt", data: FilesystemTestSupport.data("hello"), append: false) + try await filesystem.writeFile(path: "/docs/note.txt", data: FilesystemTestSupport.data(" world"), append: true) - #expect(try await filesystem.readFile(path: "/docs/note.txt") == WorkspaceFilesystemTestSupport.data("hello world")) + #expect(try await filesystem.readFile(path: "/docs/note.txt") == FilesystemTestSupport.data("hello world")) let fileInfo = try await filesystem.stat(path: "/docs/note.txt") #expect(fileInfo.size == 11) @@ -340,9 +340,9 @@ struct WorkspaceFilesystemTests { #expect(directoryEntries.map(\.name) == ["note.txt"]) try await filesystem.copy(from: "/docs/note.txt", to: "/docs/replaced.txt", recursive: false) - try await filesystem.writeFile(path: "/docs/replaced.txt", data: WorkspaceFilesystemTestSupport.data("stale"), append: false) + try await filesystem.writeFile(path: "/docs/replaced.txt", data: FilesystemTestSupport.data("stale"), append: false) try await filesystem.copy(from: "/docs/note.txt", to: "/docs/replaced.txt", recursive: false) - #expect(try await filesystem.readFile(path: "/docs/replaced.txt") == WorkspaceFilesystemTestSupport.data("hello world")) + #expect(try await filesystem.readFile(path: "/docs/replaced.txt") == FilesystemTestSupport.data("hello world")) try await filesystem.move(from: "/docs/replaced.txt", to: "/docs/moved.txt") #expect(!(await filesystem.exists(path: "/docs/replaced.txt"))) @@ -354,16 +354,16 @@ struct WorkspaceFilesystemTests { #expect(try await filesystem.stat(path: "/docs/link.txt").kind == .symlink) try await filesystem.createHardLink(path: "/docs/hard.txt", target: "/docs/note.txt") - #expect(try await filesystem.readFile(path: "/docs/hard.txt") == WorkspaceFilesystemTestSupport.data("hello world")) + #expect(try await filesystem.readFile(path: "/docs/hard.txt") == FilesystemTestSupport.data("hello world")) try await filesystem.setPermissions(path: "/docs/note.txt", permissions: POSIXPermissions(0o600)) let updatedInfo = try await filesystem.stat(path: "/docs/note.txt") #expect(updatedInfo.permissions == POSIXPermissions(0o600)) try await filesystem.createDirectory(path: "/tree/sub", recursive: true) - try await filesystem.writeFile(path: "/tree/sub/deep.txt", data: WorkspaceFilesystemTestSupport.data("nested"), append: false) + try await filesystem.writeFile(path: "/tree/sub/deep.txt", data: FilesystemTestSupport.data("nested"), append: false) try await filesystem.copy(from: "/tree", to: "/tree-copy", recursive: true) - #expect(try await filesystem.readFile(path: "/tree-copy/sub/deep.txt") == WorkspaceFilesystemTestSupport.data("nested")) + #expect(try await filesystem.readFile(path: "/tree-copy/sub/deep.txt") == FilesystemTestSupport.data("nested")) let globbed = try await filesystem.glob(pattern: "/docs/*.txt", currentDirectory: "/") #expect(globbed.contains("/docs/note.txt")) @@ -392,12 +392,12 @@ struct WorkspaceFilesystemTests { #expect(!(await unconfigured.exists(path: "/\u{0}"))) - let root = try WorkspaceFilesystemTestSupport.makeTempDirectory(prefix: "WorkspaceReadWriteErrors") - defer { WorkspaceFilesystemTestSupport.removeDirectory(root) } + let root = try FilesystemTestSupport.makeTempDirectory(prefix: "ReadWriteErrors") + defer { FilesystemTestSupport.removeDirectory(root) } let filesystem = try ReadWriteFilesystem(rootDirectory: root) try await filesystem.createDirectory(path: "/dir", recursive: false) - try await filesystem.writeFile(path: "/dir/file.txt", data: WorkspaceFilesystemTestSupport.data("x"), append: false) + try await filesystem.writeFile(path: "/dir/file.txt", data: FilesystemTestSupport.data("x"), append: false) do { _ = try await filesystem.listDirectory(path: "/dir/file.txt") @@ -429,14 +429,14 @@ struct WorkspaceFilesystemTests { @Test(.tags(.overlay)) func `overlay filesystem imports disk state and proxies mutations`() async throws { - let root = try WorkspaceFilesystemTestSupport.makeTempDirectory(prefix: "WorkspaceOverlayCoverage") - defer { WorkspaceFilesystemTestSupport.removeDirectory(root) } + let root = try FilesystemTestSupport.makeTempDirectory(prefix: "OverlayCoverage") + defer { FilesystemTestSupport.removeDirectory(root) } let dirURL = root.appendingPathComponent("dir", isDirectory: true) try FileManager.default.createDirectory(at: dirURL, withIntermediateDirectories: true) let fileURL = dirURL.appendingPathComponent("file.txt") - try WorkspaceFilesystemTestSupport.data("disk").write(to: fileURL) + try FilesystemTestSupport.data("disk").write(to: fileURL) try FileManager.default.setAttributes([.posixPermissions: 0o640], ofItemAtPath: fileURL.path) let symlinkURL = root.appendingPathComponent("alias.txt") @@ -446,12 +446,12 @@ struct WorkspaceFilesystemTests { #expect((try await filesystem.listDirectory(path: "/")).map(\.name) == ["alias.txt", "dir"]) #expect((try await filesystem.listDirectory(path: "/dir")).map(\.name) == ["file.txt"]) - #expect(try await filesystem.readFile(path: "/alias.txt") == WorkspaceFilesystemTestSupport.data("disk")) + #expect(try await filesystem.readFile(path: "/alias.txt") == FilesystemTestSupport.data("disk")) #expect(try await filesystem.readSymlink(path: "/alias.txt") == "dir/file.txt") #expect(try await filesystem.stat(path: "/dir/file.txt").permissions == POSIXPermissions(0o640)) try await filesystem.createDirectory(path: "/scratch", recursive: true) - try await filesystem.writeFile(path: "/scratch/note.txt", data: WorkspaceFilesystemTestSupport.data("hello"), append: false) + try await filesystem.writeFile(path: "/scratch/note.txt", data: FilesystemTestSupport.data("hello"), append: false) try await filesystem.copy(from: "/scratch/note.txt", to: "/scratch/copy.txt", recursive: false) try await filesystem.move(from: "/scratch/copy.txt", to: "/scratch/moved.txt") try await filesystem.createSymlink(path: "/scratch/link.txt", target: "note.txt") @@ -460,7 +460,7 @@ struct WorkspaceFilesystemTests { #expect(try await filesystem.readSymlink(path: "/scratch/link.txt") == "note.txt") #expect(try await filesystem.resolveRealPath(path: "/scratch/link.txt") == "/scratch/note.txt") - #expect(try await filesystem.readFile(path: "/scratch/hard.txt") == WorkspaceFilesystemTestSupport.data("hello")) + #expect(try await filesystem.readFile(path: "/scratch/hard.txt") == FilesystemTestSupport.data("hello")) #expect((try await filesystem.glob(pattern: "/scratch/*.txt", currentDirectory: "/")).contains("/scratch/moved.txt")) try await filesystem.remove(path: "/scratch/moved.txt", recursive: false) @@ -478,7 +478,7 @@ struct WorkspaceFilesystemTests { #expect(error.description.contains("requires rootDirectory")) } - let root = try WorkspaceFilesystemTestSupport.makeTempDirectory(prefix: "WorkspaceOverlayMissingRoot") + let root = try FilesystemTestSupport.makeTempDirectory(prefix: "OverlayMissingRoot") try FileManager.default.removeItem(at: root) let filesystem = OverlayFilesystem() @@ -498,7 +498,7 @@ struct WorkspaceFilesystemTests { } let filesystem = PermissionedFileSystem(base: base, authorizer: authorizer) - try await filesystem.writeFile(path: "/dir/../note.txt", data: WorkspaceFilesystemTestSupport.data("hello"), append: false) + try await filesystem.writeFile(path: "/dir/../note.txt", data: FilesystemTestSupport.data("hello"), append: false) try await filesystem.createDirectory(path: "/links", recursive: true) try await filesystem.copy(from: "/note.txt", to: "/copy.txt", recursive: false) try await filesystem.move(from: "/copy.txt", to: "/moved.txt") @@ -545,8 +545,8 @@ struct WorkspaceFilesystemTests { @Test(.tags(.permissions)) func `permissioned filesystem forwards configuration and denied remove operations`() async throws { - let root = try WorkspaceFilesystemTestSupport.makeTempDirectory(prefix: "WorkspacePermissionedConfig") - defer { WorkspaceFilesystemTestSupport.removeDirectory(root) } + let root = try FilesystemTestSupport.makeTempDirectory(prefix: "PermissionedConfig") + defer { FilesystemTestSupport.removeDirectory(root) } let base = ReadWriteFilesystem() let filesystem = PermissionedFileSystem( @@ -560,7 +560,7 @@ struct WorkspaceFilesystemTests { ) try await filesystem.configure(rootDirectory: root) - try await filesystem.writeFile(path: "/note.txt", data: WorkspaceFilesystemTestSupport.data("hello"), append: false) + try await filesystem.writeFile(path: "/note.txt", data: FilesystemTestSupport.data("hello"), append: false) do { try await filesystem.remove(path: "/note.txt", recursive: false) @@ -580,12 +580,12 @@ struct WorkspaceFilesystemTests { @Test(.tags(.sandbox)) func `sandbox filesystem rooted at a URL supports filesystem operations`() async throws { - let root = try WorkspaceFilesystemTestSupport.makeTempDirectory(prefix: "WorkspaceSandboxURL") - defer { WorkspaceFilesystemTestSupport.removeDirectory(root) } + let root = try FilesystemTestSupport.makeTempDirectory(prefix: "SandboxURL") + defer { FilesystemTestSupport.removeDirectory(root) } let filesystem = try SandboxFilesystem(root: .url(root)) - try await filesystem.writeFile(path: "/note.txt", data: WorkspaceFilesystemTestSupport.data("hello"), append: false) + try await filesystem.writeFile(path: "/note.txt", data: FilesystemTestSupport.data("hello"), append: false) try await filesystem.createDirectory(path: "/dir", recursive: true) try await filesystem.copy(from: "/note.txt", to: "/copy.txt", recursive: false) try await filesystem.move(from: "/copy.txt", to: "/moved.txt") @@ -593,7 +593,7 @@ struct WorkspaceFilesystemTests { try await filesystem.createHardLink(path: "/hard.txt", target: "/note.txt") try await filesystem.setPermissions(path: "/note.txt", permissions: POSIXPermissions(0o600)) - #expect(try await filesystem.readFile(path: "/note.txt") == WorkspaceFilesystemTestSupport.data("hello")) + #expect(try await filesystem.readFile(path: "/note.txt") == FilesystemTestSupport.data("hello")) #expect(try await filesystem.readSymlink(path: "/link.txt") == "note.txt") #expect(try await filesystem.resolveRealPath(path: "/link.txt") == "/note.txt") #expect(try await filesystem.stat(path: "/note.txt").permissions == POSIXPermissions(0o600)) @@ -634,26 +634,26 @@ struct WorkspaceFilesystemTests { #expect(error.description.contains("app group container unavailable")) } - let firstRoot = try WorkspaceFilesystemTestSupport.makeTempDirectory(prefix: "WorkspaceSandboxFirst") - defer { WorkspaceFilesystemTestSupport.removeDirectory(firstRoot) } + let firstRoot = try FilesystemTestSupport.makeTempDirectory(prefix: "SandboxFirst") + defer { FilesystemTestSupport.removeDirectory(firstRoot) } - let secondRoot = try WorkspaceFilesystemTestSupport.makeTempDirectory(prefix: "WorkspaceSandboxSecond") - defer { WorkspaceFilesystemTestSupport.removeDirectory(secondRoot) } + let secondRoot = try FilesystemTestSupport.makeTempDirectory(prefix: "SandboxSecond") + defer { FilesystemTestSupport.removeDirectory(secondRoot) } let filesystem = try SandboxFilesystem(root: .url(firstRoot)) try await filesystem.configure(rootDirectory: secondRoot) - try await filesystem.writeFile(path: "/configured.txt", data: WorkspaceFilesystemTestSupport.data("configured"), append: false) + try await filesystem.writeFile(path: "/configured.txt", data: FilesystemTestSupport.data("configured"), append: false) #expect(FileManager.default.fileExists(atPath: secondRoot.appendingPathComponent("configured.txt").path)) } @Test(.tags(.bookmarks)) func `user defaults bookmark store persists and deletes suite values`() async throws { - let suiteName = WorkspaceFilesystemTestSupport.uniqueSuiteName() + let suiteName = FilesystemTestSupport.uniqueSuiteName() defer { UserDefaults(suiteName: suiteName)?.removePersistentDomain(forName: suiteName) } let store = UserDefaultsBookmarkStore(suiteName: suiteName, keyPrefix: "workspace.tests.") - let data = WorkspaceFilesystemTestSupport.data("bookmark") + let data = FilesystemTestSupport.data("bookmark") try await store.saveBookmark(data, for: "demo") #expect(try await store.loadBookmark(for: "demo") == data) @@ -666,7 +666,7 @@ struct WorkspaceFilesystemTests { func `user defaults bookmark store supports standard defaults`() async throws { let id = "standard-\(UUID().uuidString)" let store = UserDefaultsBookmarkStore(keyPrefix: "workspace.tests.standard.") - let data = WorkspaceFilesystemTestSupport.data("bookmark") + let data = FilesystemTestSupport.data("bookmark") defer { UserDefaults.standard.removeObject(forKey: "workspace.tests.standard." + id) } try await store.saveBookmark(data, for: id) @@ -679,15 +679,15 @@ struct WorkspaceFilesystemTests { #if os(macOS) @Test(.tags(.securityScoped)) func `security-scoped filesystem supports url access reconfiguration and read-only mode`() async throws { - let firstRoot = try WorkspaceFilesystemTestSupport.makeTempDirectory(prefix: "WorkspaceSecurityScopedFirst") - defer { WorkspaceFilesystemTestSupport.removeDirectory(firstRoot) } + let firstRoot = try FilesystemTestSupport.makeTempDirectory(prefix: "SecurityScopedFirst") + defer { FilesystemTestSupport.removeDirectory(firstRoot) } - let secondRoot = try WorkspaceFilesystemTestSupport.makeTempDirectory(prefix: "WorkspaceSecurityScopedSecond") - defer { WorkspaceFilesystemTestSupport.removeDirectory(secondRoot) } + let secondRoot = try FilesystemTestSupport.makeTempDirectory(prefix: "SecurityScopedSecond") + defer { FilesystemTestSupport.removeDirectory(secondRoot) } let filesystem = try SecurityScopedFilesystem(url: firstRoot, mode: .readWrite) - try await filesystem.writeFile(path: "/note.txt", data: WorkspaceFilesystemTestSupport.data("hello"), append: false) + try await filesystem.writeFile(path: "/note.txt", data: FilesystemTestSupport.data("hello"), append: false) try await filesystem.createDirectory(path: "/dir", recursive: true) try await filesystem.copy(from: "/note.txt", to: "/copy.txt", recursive: false) try await filesystem.move(from: "/copy.txt", to: "/moved.txt") @@ -695,7 +695,7 @@ struct WorkspaceFilesystemTests { try await filesystem.createHardLink(path: "/hard.txt", target: "/note.txt") try await filesystem.setPermissions(path: "/note.txt", permissions: POSIXPermissions(0o600)) - #expect(try await filesystem.readFile(path: "/note.txt") == WorkspaceFilesystemTestSupport.data("hello")) + #expect(try await filesystem.readFile(path: "/note.txt") == FilesystemTestSupport.data("hello")) #expect(try await filesystem.readSymlink(path: "/link.txt") == "note.txt") #expect(try await filesystem.resolveRealPath(path: "/link.txt") == "/note.txt") #expect(try await filesystem.stat(path: "/note.txt").permissions == POSIXPermissions(0o600)) @@ -709,14 +709,14 @@ struct WorkspaceFilesystemTests { try await filesystem.configure(rootDirectory: secondRoot) #expect(!(await filesystem.exists(path: "/note.txt"))) - try await filesystem.writeFile(path: "/fresh.txt", data: WorkspaceFilesystemTestSupport.data("fresh"), append: false) + try await filesystem.writeFile(path: "/fresh.txt", data: FilesystemTestSupport.data("fresh"), append: false) #expect(FileManager.default.fileExists(atPath: secondRoot.appendingPathComponent("fresh.txt").path)) let readOnly = try SecurityScopedFilesystem(url: secondRoot, mode: .readOnly) - #expect(try await readOnly.readFile(path: "/fresh.txt") == WorkspaceFilesystemTestSupport.data("fresh")) + #expect(try await readOnly.readFile(path: "/fresh.txt") == FilesystemTestSupport.data("fresh")) do { - try await readOnly.writeFile(path: "/blocked.txt", data: WorkspaceFilesystemTestSupport.data("x"), append: false) + try await readOnly.writeFile(path: "/blocked.txt", data: FilesystemTestSupport.data("x"), append: false) Issue.record("expected read-only rejection") } catch let error as WorkspaceError { #expect(error.description.contains("read-only")) @@ -725,7 +725,7 @@ struct WorkspaceFilesystemTests { @Test(.tags(.securityScoped, .bookmarks)) func `security-scoped filesystem reports missing stored bookmarks`() async throws { - let suiteName = WorkspaceFilesystemTestSupport.uniqueSuiteName() + let suiteName = FilesystemTestSupport.uniqueSuiteName() defer { UserDefaults(suiteName: suiteName)?.removePersistentDomain(forName: suiteName) } let store = UserDefaultsBookmarkStore(suiteName: suiteName, keyPrefix: "workspace.tests.") @@ -740,11 +740,11 @@ struct WorkspaceFilesystemTests { @Test(.tags(.securityScoped, .bookmarks)) func `security-scoped filesystem rejects invalid stored bookmark data`() async throws { - let suiteName = WorkspaceFilesystemTestSupport.uniqueSuiteName() + let suiteName = FilesystemTestSupport.uniqueSuiteName() defer { UserDefaults(suiteName: suiteName)?.removePersistentDomain(forName: suiteName) } let store = UserDefaultsBookmarkStore(suiteName: suiteName, keyPrefix: "workspace.tests.") - try await store.saveBookmark(WorkspaceFilesystemTestSupport.data("not-a-bookmark"), for: "invalid") + try await store.saveBookmark(FilesystemTestSupport.data("not-a-bookmark"), for: "invalid") do { _ = try await SecurityScopedFilesystem.loadBookmark(id: "invalid", store: store) @@ -758,10 +758,10 @@ struct WorkspaceFilesystemTests { @Test(.tags(.securityScoped, .bookmarks)) func `security-scoped filesystem bookmark creation either saves or reports Cocoa errors`() async throws { - let root = try WorkspaceFilesystemTestSupport.makeTempDirectory(prefix: "WorkspaceSecurityScopedBookmark") - defer { WorkspaceFilesystemTestSupport.removeDirectory(root) } + let root = try FilesystemTestSupport.makeTempDirectory(prefix: "SecurityScopedBookmark") + defer { FilesystemTestSupport.removeDirectory(root) } - let suiteName = WorkspaceFilesystemTestSupport.uniqueSuiteName() + let suiteName = FilesystemTestSupport.uniqueSuiteName() defer { UserDefaults(suiteName: suiteName)?.removePersistentDomain(forName: suiteName) } let filesystem = try SecurityScopedFilesystem(url: root, mode: .readWrite) diff --git a/Tests/WorkspaceTests/HistoryTests.swift b/Tests/WorkspaceTests/HistoryTests.swift new file mode 100644 index 0000000..e99276d --- /dev/null +++ b/Tests/WorkspaceTests/HistoryTests.swift @@ -0,0 +1,272 @@ +import Foundation +import Testing +@testable import Workspace + +private enum HistoryTestSupport { + static func makeTempDirectory(prefix: String = "HistoryTests") throws -> URL { + let base = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true) + let url = base.appendingPathComponent("\(prefix)-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true) + return url + } + + static func removeDirectory(_ url: URL) { + try? FileManager.default.removeItem(at: url) + } +} + +private actor CheckpointEventRecorder { + private var events: [CheckpointEvent] = [] + + func append(_ event: CheckpointEvent) { + events.append(event) + } + + func snapshot() -> [CheckpointEvent] { + events + } +} + +private func startCheckpointRecording( + _ stream: AsyncStream, + into recorder: CheckpointEventRecorder +) -> Task { + Task { + for await event in stream { + await recorder.append(event) + } + } +} + +private func waitForCheckpointEvents( + _ expectedCount: Int, + recorder: CheckpointEventRecorder, + timeout: Duration = .seconds(1) +) async throws -> [CheckpointEvent] { + let clock = ContinuousClock() + let deadline = clock.now.advanced(by: timeout) + + while clock.now < deadline { + let snapshot = await recorder.snapshot() + if snapshot.count >= expectedCount { + return Array(snapshot.prefix(expectedCount)) + } + try await Task.sleep(for: .milliseconds(10)) + } + + return await recorder.snapshot() +} + +@Suite("History") +struct HistoryTests { + @Test + func `session checkpoints persist mutation ranges and shared workspace remains unchanged`() async throws { + let root = try HistoryTestSupport.makeTempDirectory() + defer { HistoryTestSupport.removeDirectory(root) } + + let workspaceId = UUID() + let history = History( + workspaceId: workspaceId, + filesystem: InMemoryFilesystem(), + historyDirectory: root + ) + + let session = try await history.createSession() + let sessionId = await session.id + try await session.writeFile("/note.txt", content: "one") + try await session.appendFile("/note.txt", content: " two") + try await session.createDirectory(at: "/docs") + + let checkpoint = try await session.createCheckpoint(label: "draft") + let mutations = try await session.listMutationRecords() + let storeCheckpoints = try await history.listCheckpoints(scope: .session, sessionId: sessionId) + + #expect(checkpoint.scope == .session) + #expect(checkpoint.sessionId == sessionId) + #expect(checkpoint.firstMutationSequence == 1) + #expect(checkpoint.lastMutationSequence == 3) + #expect(checkpoint.mutationCursor == 3) + #expect(checkpoint.label == "draft") + #expect(mutations.map(\.kind) == [.writeFile, .appendFile, .createDirectory]) + #expect(mutations[0].diff?.hunks.isEmpty == false) + #expect(mutations[1].diff?.hunks.isEmpty == false) + #expect(mutations[2].diff == nil) + #expect(storeCheckpoints == [checkpoint]) + #expect(!(try await history.exists("/note.txt"))) + } + + @Test + func `empty checkpoints are persisted and session rollback creates a rollback checkpoint`() async throws { + let root = try HistoryTestSupport.makeTempDirectory() + defer { HistoryTestSupport.removeDirectory(root) } + + let history = History( + filesystem: InMemoryFilesystem(), + historyDirectory: root + ) + + let session = try await history.createSession() + let sessionId = await session.id + let emptyCheckpoint = try await session.createCheckpoint(label: "start") + + try await session.writeFile("/note.txt", content: "draft") + let rollbackCheckpoint = try await session.rollback(to: emptyCheckpoint.id, label: "undo") + + #expect(emptyCheckpoint.firstMutationSequence == nil) + #expect(emptyCheckpoint.lastMutationSequence == nil) + #expect(emptyCheckpoint.mutationCursor == 0) + #expect(!(await session.exists("/note.txt"))) + #expect(rollbackCheckpoint.scope == .session) + #expect(rollbackCheckpoint.rollbackSourceCheckpointId == emptyCheckpoint.id) + #expect(rollbackCheckpoint.sessionId == sessionId) + #expect((try await session.listCheckpoints()).count == 2) + } + + @Test + func `shared rollback restores the shared tree and emits a rollback event`() async throws { + let root = try HistoryTestSupport.makeTempDirectory() + defer { HistoryTestSupport.removeDirectory(root) } + + let workspaceId = UUID() + let history = History( + workspaceId: workspaceId, + filesystem: InMemoryFilesystem(), + historyDirectory: root + ) + + try await history.writeFile("/shared.txt", content: "one") + let checkpoint = try await history.createCheckpoint(label: "v1") + try await history.writeFile("/shared.txt", content: "two") + + let recorder = CheckpointEventRecorder() + let stream = try await history.watchCheckpointEvents(workspaceId: workspaceId) + let task = startCheckpointRecording(stream, into: recorder) + defer { task.cancel() } + + let rollbackCheckpoint = try await history.rollbackShared(to: checkpoint.id, label: "revert") + let events = try await waitForCheckpointEvents(1, recorder: recorder) + + #expect(try await history.readFile("/shared.txt") == "one") + #expect(rollbackCheckpoint.scope == .shared) + #expect(rollbackCheckpoint.rollbackSourceCheckpointId == checkpoint.id) + #expect(events.count == 1) + #expect(events[0].kind == .rolledBack) + #expect(events[0].checkpoint == rollbackCheckpoint) + } + + @Test + func `sessions checkpoint independently and publish conflicts when shared head has advanced`() async throws { + let root = try HistoryTestSupport.makeTempDirectory() + defer { HistoryTestSupport.removeDirectory(root) } + + let history = History( + filesystem: InMemoryFilesystem(), + historyDirectory: root + ) + + let sessionA = try await history.createSession() + let sessionB = try await history.createSession() + let sessionAId = await sessionA.id + let sessionBId = await sessionB.id + + try await sessionA.writeFile("/note.txt", content: "alpha") + try await sessionB.writeFile("/note.txt", content: "beta") + + let checkpointA = try await sessionA.createCheckpoint(label: "a1") + let checkpointB = try await sessionB.createCheckpoint(label: "b1") + let published = try await sessionA.publishHead(label: "publish a") + + #expect(checkpointA.scope == .session) + #expect(checkpointB.scope == .session) + #expect(checkpointA.sessionId == sessionAId) + #expect(checkpointB.sessionId == sessionBId) + #expect(checkpointA.baseSharedCheckpointId == checkpointB.baseSharedCheckpointId) + #expect(published.scope == .shared) + #expect(published.originSessionId == sessionAId) + #expect(published.sessionId == sessionAId) + #expect(try await history.readFile("/note.txt") == "alpha") + #expect(try await sessionA.baseSharedCheckpointId() == published.id) + + do { + _ = try await sessionB.publishHead(label: "publish b") + Issue.record("expected publish conflict") + } catch let error as HistoryError { + switch error { + case let .publishConflict(sessionId, expectedBaseSharedCheckpointId, actualSharedCheckpointId): + #expect(sessionId == sessionBId) + #expect(expectedBaseSharedCheckpointId == checkpointB.baseSharedCheckpointId) + #expect(actualSharedCheckpointId == published.id) + default: + Issue.record("unexpected history error: \(error)") + } + } + } + + @Test + func `history metadata and file-backed store roundtrip through Codable`() async throws { + let root = try HistoryTestSupport.makeTempDirectory() + defer { HistoryTestSupport.removeDirectory(root) } + + let workspaceId = UUID() + let history = History( + workspaceId: workspaceId, + filesystem: InMemoryFilesystem(), + historyDirectory: root + ) + + let session = try await history.createSession() + try await session.writeFile("/note.txt", content: "hello") + let checkpoint = try await session.createCheckpoint(label: "draft") + let mutation = try #require((try await session.listMutationRecords()).first) + let reloaded = History( + workspaceId: workspaceId, + filesystem: InMemoryFilesystem(), + historyDirectory: root + ) + + let encoder = JSONEncoder() + let decoder = JSONDecoder() + + let decodedCheckpoint = try decoder.decode(History.Checkpoint.self, from: encoder.encode(checkpoint)) + let decodedMutation = try decoder.decode(MutationRecord.self, from: encoder.encode(mutation)) + let decodedEvent = try decoder.decode( + CheckpointEvent.self, + from: encoder.encode(CheckpointEvent(kind: .created, checkpoint: checkpoint)) + ) + + #expect(decodedCheckpoint == checkpoint) + #expect(decodedMutation == mutation) + #expect(decodedEvent == CheckpointEvent(kind: .created, checkpoint: checkpoint)) + #expect(try await reloaded.checkpoint(id: checkpoint.id) == checkpoint) + #expect(try await reloaded.listMutationRecords() == [mutation]) + } + + @Test + func `session creation and checkpointing work with mounted shared filesystems`() async throws { + let root = try HistoryTestSupport.makeTempDirectory() + defer { HistoryTestSupport.removeDirectory(root) } + + let mountedWorkspace = InMemoryFilesystem() + try await mountedWorkspace.writeFile(path: "/file.txt", data: Data("shared".utf8), append: false) + + let history = History( + filesystem: MountableFilesystem( + base: InMemoryFilesystem(), + mounts: [.init(mountPoint: "/workspace", filesystem: mountedWorkspace)] + ), + historyDirectory: root + ) + + let session = try await history.createSession() + let sessionId = await session.id + let original = try await session.readFile("/workspace/file.txt") + try await session.writeFile("/workspace/file.txt", content: "session") + let checkpoint = try await session.createCheckpoint(label: "mounted") + + #expect(original == "shared") + #expect(try await session.readFile("/workspace/file.txt") == "session") + #expect(try await history.readFile("/workspace/file.txt") == "shared") + #expect(checkpoint.scope == .session) + #expect(checkpoint.sessionId == sessionId) + } +} diff --git a/Tests/WorkspaceTests/WorkspaceMountingTests.swift b/Tests/WorkspaceTests/MountingTests.swift similarity index 88% rename from Tests/WorkspaceTests/WorkspaceMountingTests.swift rename to Tests/WorkspaceTests/MountingTests.swift index 4cf8257..9c723b4 100644 --- a/Tests/WorkspaceTests/WorkspaceMountingTests.swift +++ b/Tests/WorkspaceTests/MountingTests.swift @@ -2,8 +2,8 @@ import Foundation import Testing @testable import Workspace -private enum WorkspaceMountingTestSupport { - static func makeTempDirectory(prefix: String = "WorkspaceMountingTests") throws -> URL { +private enum MountingTestSupport { + static func makeTempDirectory(prefix: String = "MountingTests") throws -> URL { let base = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true) let url = base.appendingPathComponent("\(prefix)-\(UUID().uuidString)", isDirectory: true) try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true) @@ -19,8 +19,8 @@ private enum WorkspaceMountingTestSupport { } } -@Suite("Workspace Mounting") -struct WorkspaceMountingTests { +@Suite("Mounting") +struct MountingTests { @Test func `multiple isolated mounts can share a memory workspace`() async throws { let workspaceA = InMemoryFilesystem() @@ -122,11 +122,11 @@ struct WorkspaceMountingTests { func `mountable filesystem merges base entries with mounted directories`() async throws { let base = InMemoryFilesystem() try await base.createDirectory(path: "/docs", recursive: true) - try await base.writeFile(path: "/docs/local.txt", data: WorkspaceMountingTestSupport.data("local"), append: false) - try await base.writeFile(path: "/base.txt", data: WorkspaceMountingTestSupport.data("base"), append: false) + try await base.writeFile(path: "/docs/local.txt", data: MountingTestSupport.data("local"), append: false) + try await base.writeFile(path: "/base.txt", data: MountingTestSupport.data("base"), append: false) let mountedDocs = InMemoryFilesystem() - try await mountedDocs.writeFile(path: "/guide.txt", data: WorkspaceMountingTestSupport.data("guide"), append: false) + try await mountedDocs.writeFile(path: "/guide.txt", data: MountingTestSupport.data("guide"), append: false) let filesystem = MountableFilesystem( base: base, @@ -136,7 +136,7 @@ struct WorkspaceMountingTests { let docsInfo = try await filesystem.stat(path: "/docs") #expect(docsInfo.kind == .directory) #expect(await filesystem.exists(path: "/docs")) - #expect(try await filesystem.readFile(path: "/base.txt") == WorkspaceMountingTestSupport.data("base")) + #expect(try await filesystem.readFile(path: "/base.txt") == MountingTestSupport.data("base")) let docsEntries = try await filesystem.listDirectory(path: "/docs") #expect(docsEntries.map(\.name) == ["external", "local.txt"]) @@ -144,7 +144,7 @@ struct WorkspaceMountingTests { let mountedEntries = try await filesystem.listDirectory(path: "/docs/external") #expect(mountedEntries.map(\.name) == ["guide.txt"]) - try await filesystem.writeFile(path: "/docs/external/new.txt", data: WorkspaceMountingTestSupport.data("new"), append: false) + try await filesystem.writeFile(path: "/docs/external/new.txt", data: MountingTestSupport.data("new"), append: false) try await filesystem.createDirectory(path: "/docs/external/sub", recursive: true) try await filesystem.createSymlink(path: "/docs/external/link.txt", target: "guide.txt") try await filesystem.createHardLink(path: "/docs/external/hard.txt", target: "/docs/external/guide.txt") @@ -152,8 +152,8 @@ struct WorkspaceMountingTests { #expect(try await filesystem.readSymlink(path: "/docs/external/link.txt") == "guide.txt") #expect(try await filesystem.resolveRealPath(path: "/docs/external/link.txt") == "/docs/external/guide.txt") - #expect(try await mountedDocs.readFile(path: "/new.txt") == WorkspaceMountingTestSupport.data("new")) - #expect(try await mountedDocs.readFile(path: "/hard.txt") == WorkspaceMountingTestSupport.data("guide")) + #expect(try await mountedDocs.readFile(path: "/new.txt") == MountingTestSupport.data("new")) + #expect(try await mountedDocs.readFile(path: "/hard.txt") == MountingTestSupport.data("guide")) #expect(try await mountedDocs.stat(path: "/guide.txt").permissions == POSIXPermissions(0o600)) let globbed = try await filesystem.glob(pattern: "/docs/*.txt", currentDirectory: "/") @@ -176,7 +176,7 @@ struct WorkspaceMountingTests { func `mountable filesystem supports directory copy and move across mounts`() async throws { let source = InMemoryFilesystem() try await source.createDirectory(path: "/tree/sub", recursive: true) - try await source.writeFile(path: "/tree/sub/file.txt", data: WorkspaceMountingTestSupport.data("nested"), append: false) + try await source.writeFile(path: "/tree/sub/file.txt", data: MountingTestSupport.data("nested"), append: false) try await source.createSymlink(path: "/tree/link.txt", target: "sub/file.txt") let destination = InMemoryFilesystem() @@ -197,12 +197,12 @@ struct WorkspaceMountingTests { } try await filesystem.copy(from: "/src/tree", to: "/dst/tree", recursive: true) - #expect(try await destination.readFile(path: "/tree/sub/file.txt") == WorkspaceMountingTestSupport.data("nested")) + #expect(try await destination.readFile(path: "/tree/sub/file.txt") == MountingTestSupport.data("nested")) #expect(try await destination.readSymlink(path: "/tree/link.txt") == "sub/file.txt") try await filesystem.move(from: "/src/tree/sub/file.txt", to: "/dst/moved.txt") #expect(!(await source.exists(path: "/tree/sub/file.txt"))) - #expect(try await destination.readFile(path: "/moved.txt") == WorkspaceMountingTestSupport.data("nested")) + #expect(try await destination.readFile(path: "/moved.txt") == MountingTestSupport.data("nested")) do { try await filesystem.createHardLink(path: "/dst/cross-hard.txt", target: "/src/tree/link.txt") @@ -214,8 +214,8 @@ struct WorkspaceMountingTests { @Test func `mountable filesystem supports dynamic mounts and configurable base storage`() async throws { - let baseRoot = try WorkspaceMountingTestSupport.makeTempDirectory(prefix: "WorkspaceMountableBase") - defer { WorkspaceMountingTestSupport.removeDirectory(baseRoot) } + let baseRoot = try MountingTestSupport.makeTempDirectory(prefix: "MountableBase") + defer { MountingTestSupport.removeDirectory(baseRoot) } let base = ReadWriteFilesystem() let filesystem = MountableFilesystem(base: base) @@ -224,10 +224,10 @@ struct WorkspaceMountingTests { let memory = InMemoryFilesystem() filesystem.mount("/memory", filesystem: memory) - try await filesystem.writeFile(path: "/root.txt", data: WorkspaceMountingTestSupport.data("root"), append: false) - try await filesystem.writeFile(path: "/memory/note.txt", data: WorkspaceMountingTestSupport.data("memo"), append: false) + try await filesystem.writeFile(path: "/root.txt", data: MountingTestSupport.data("root"), append: false) + try await filesystem.writeFile(path: "/memory/note.txt", data: MountingTestSupport.data("memo"), append: false) - #expect(try await filesystem.readFile(path: "/root.txt") == WorkspaceMountingTestSupport.data("root")) - #expect(try await memory.readFile(path: "/note.txt") == WorkspaceMountingTestSupport.data("memo")) + #expect(try await filesystem.readFile(path: "/root.txt") == MountingTestSupport.data("root")) + #expect(try await memory.readFile(path: "/note.txt") == MountingTestSupport.data("memo")) } } From 02a47f2ea0c9e8e67e3b2200a64113e1d2bd87ab Mon Sep 17 00:00:00 2001 From: Zac White Date: Thu, 9 Apr 2026 10:33:03 -0700 Subject: [PATCH 03/14] Simplified history APIs --- Sources/Workspace/CheckpointStore.swift | 75 +++++- Sources/Workspace/History.swift | 324 ++++++++++++------------ Sources/Workspace/HistoryTypes.swift | 88 +++---- Sources/Workspace/Snapshot.swift | 100 ++++++-- Tests/WorkspaceTests/HistoryTests.swift | 136 +++++----- 5 files changed, 403 insertions(+), 320 deletions(-) diff --git a/Sources/Workspace/CheckpointStore.swift b/Sources/Workspace/CheckpointStore.swift index 4dc5c66..7557a65 100644 --- a/Sources/Workspace/CheckpointStore.swift +++ b/Sources/Workspace/CheckpointStore.swift @@ -2,18 +2,55 @@ import Foundation /// Persistence for workspace checkpoints, snapshots, and mutation logs. protocol CheckpointStore: AnyObject, Sendable { - /// Persists checkpoint metadata. func saveCheckpoint(_ checkpoint: History.Checkpoint) async throws - /// Loads one checkpoint by identifier. func loadCheckpoint(id: UUID, workspaceId: UUID) async throws -> History.Checkpoint? - /// Lists persisted checkpoints for a workspace. func listCheckpoints(workspaceId: UUID) async throws -> [History.Checkpoint] - /// Appends a mutation record to the workspace log. + func saveSnapshot(_ snapshot: Snapshot, workspaceId: UUID) async throws + func loadSnapshot(id: UUID, workspaceId: UUID) async throws -> Snapshot? func appendMutation(_ mutation: MutationRecord) async throws - /// Lists the mutation log for a workspace. func listMutationRecords(workspaceId: UUID) async throws -> [MutationRecord] } +/// An in-memory checkpoint store for tests and ephemeral sessions. +actor InMemoryCheckpointStore: CheckpointStore { + private var checkpointsByWorkspace: [UUID: [History.Checkpoint]] = [:] + private var snapshotsByWorkspace: [UUID: [UUID: Snapshot]] = [:] + private var mutationsByWorkspace: [UUID: [MutationRecord]] = [:] + + func saveCheckpoint(_ checkpoint: History.Checkpoint) async throws { + checkpointsByWorkspace[checkpoint.workspaceId, default: []].append(checkpoint) + } + + func loadCheckpoint(id: UUID, workspaceId: UUID) async throws -> History.Checkpoint? { + checkpointsByWorkspace[workspaceId]?.first { $0.id == id } + } + + func listCheckpoints(workspaceId: UUID) async throws -> [History.Checkpoint] { + (checkpointsByWorkspace[workspaceId] ?? []).sorted { + if $0.createdAt == $1.createdAt { + return $0.id.uuidString < $1.id.uuidString + } + return $0.createdAt < $1.createdAt + } + } + + func saveSnapshot(_ snapshot: Snapshot, workspaceId: UUID) async throws { + snapshotsByWorkspace[workspaceId, default: [:]][snapshot.id] = snapshot + } + + func loadSnapshot(id: UUID, workspaceId: UUID) async throws -> Snapshot? { + snapshotsByWorkspace[workspaceId]?[id] + } + + func appendMutation(_ mutation: MutationRecord) async throws { + mutationsByWorkspace[mutation.workspaceId, default: []].append(mutation) + } + + func listMutationRecords(workspaceId: UUID) async throws -> [MutationRecord] { + (mutationsByWorkspace[workspaceId] ?? []).sorted { $0.sequence < $1.sequence } + } +} + /// A JSON file-backed checkpoint store. actor FileCheckpointStore: CheckpointStore { private let rootDirectory: URL @@ -21,7 +58,6 @@ actor FileCheckpointStore: CheckpointStore { private let encoder: JSONEncoder private let decoder: JSONDecoder - /// Creates a file-backed checkpoint store rooted at `rootDirectory`. init(rootDirectory: URL, fileManager: FileManager = .default) { self.rootDirectory = rootDirectory.standardizedFileURL self.fileManager = fileManager @@ -32,13 +68,11 @@ actor FileCheckpointStore: CheckpointStore { self.decoder = JSONDecoder() } - /// See ``CheckpointStore/saveCheckpoint(_:)``. func saveCheckpoint(_ checkpoint: History.Checkpoint) async throws { try ensureWorkspaceDirectories(for: checkpoint.workspaceId) try write(checkpoint, to: checkpointURL(id: checkpoint.id, workspaceId: checkpoint.workspaceId)) } - /// See ``CheckpointStore/loadCheckpoint(id:workspaceId:)``. func loadCheckpoint(id: UUID, workspaceId: UUID) async throws -> History.Checkpoint? { let url = checkpointURL(id: id, workspaceId: workspaceId) guard fileManager.fileExists(atPath: url.path) else { @@ -47,7 +81,6 @@ actor FileCheckpointStore: CheckpointStore { return try read(History.Checkpoint.self, from: url) } - /// See ``CheckpointStore/listCheckpoints(workspaceId:)``. func listCheckpoints(workspaceId: UUID) async throws -> [History.Checkpoint] { let directoryURL = checkpointsDirectoryURL(workspaceId: workspaceId) guard fileManager.fileExists(atPath: directoryURL.path) else { @@ -70,7 +103,19 @@ actor FileCheckpointStore: CheckpointStore { } } - /// See ``CheckpointStore/appendMutation(_:)``. + func saveSnapshot(_ snapshot: Snapshot, workspaceId: UUID) async throws { + try ensureWorkspaceDirectories(for: workspaceId) + try write(snapshot, to: snapshotURL(id: snapshot.id, workspaceId: workspaceId)) + } + + func loadSnapshot(id: UUID, workspaceId: UUID) async throws -> Snapshot? { + let url = snapshotURL(id: id, workspaceId: workspaceId) + guard fileManager.fileExists(atPath: url.path) else { + return nil + } + return try read(Snapshot.self, from: url) + } + func appendMutation(_ mutation: MutationRecord) async throws { try ensureWorkspaceDirectories(for: mutation.workspaceId) let url = mutationsURL(workspaceId: mutation.workspaceId) @@ -79,7 +124,6 @@ actor FileCheckpointStore: CheckpointStore { try write(records.sorted(by: { $0.sequence < $1.sequence }), to: url) } - /// See ``CheckpointStore/listMutationRecords(workspaceId:)``. func listMutationRecords(workspaceId: UUID) async throws -> [MutationRecord] { try loadMutations(from: mutationsURL(workspaceId: workspaceId)).sorted(by: { $0.sequence < $1.sequence }) } @@ -94,6 +138,7 @@ actor FileCheckpointStore: CheckpointStore { private func ensureWorkspaceDirectories(for workspaceId: UUID) throws { try fileManager.createDirectory(at: workspaceDirectoryURL(workspaceId: workspaceId), withIntermediateDirectories: true) try fileManager.createDirectory(at: checkpointsDirectoryURL(workspaceId: workspaceId), withIntermediateDirectories: true) + try fileManager.createDirectory(at: snapshotsDirectoryURL(workspaceId: workspaceId), withIntermediateDirectories: true) } private func workspaceDirectoryURL(workspaceId: UUID) -> URL { @@ -104,10 +149,18 @@ actor FileCheckpointStore: CheckpointStore { workspaceDirectoryURL(workspaceId: workspaceId).appendingPathComponent("checkpoints", isDirectory: true) } + private func snapshotsDirectoryURL(workspaceId: UUID) -> URL { + workspaceDirectoryURL(workspaceId: workspaceId).appendingPathComponent("snapshots", isDirectory: true) + } + private func checkpointURL(id: UUID, workspaceId: UUID) -> URL { checkpointsDirectoryURL(workspaceId: workspaceId).appendingPathComponent("\(id.uuidString).json", isDirectory: false) } + private func snapshotURL(id: UUID, workspaceId: UUID) -> URL { + snapshotsDirectoryURL(workspaceId: workspaceId).appendingPathComponent("\(id.uuidString).json", isDirectory: false) + } + private func mutationsURL(workspaceId: UUID) -> URL { workspaceDirectoryURL(workspaceId: workspaceId).appendingPathComponent("mutations.json", isDirectory: false) } diff --git a/Sources/Workspace/History.swift b/Sources/Workspace/History.swift index e038caf..5508280 100644 --- a/Sources/Workspace/History.swift +++ b/Sources/Workspace/History.swift @@ -8,8 +8,8 @@ public actor History { var lastCheckpointId: UUID? } - private struct EventWatcher { - var workspaceId: UUID + private struct CheckpointWatcher { + var deliveredCheckpointIds: Set var continuation: AsyncStream.Continuation } @@ -22,9 +22,20 @@ public actor History { } } + /// Where to persist checkpoint and snapshot data. + public enum Storage { + /// In-memory storage that does not survive process exit. + case inMemory + /// File-backed JSON storage at the given directory URL. + case directory(URL) + } + public let workspaceId: UUID - private let sharedWorkspace: Workspace + /// The shared workspace backing this history coordinator. + /// Use this for all read operations (readFile, exists, walkTree, etc.). + public nonisolated let shared: Workspace + private let store: any CheckpointStore private var didLoadStoreState = false @@ -33,28 +44,29 @@ public actor History { private var mutations: [MutationRecord] = [] private var sharedHeadCheckpointId: UUID? private var nextMutationSequence = 1 - private var eventWatchers: [UUID: EventWatcher] = [:] + private var checkpointWatchers: [UUID: CheckpointWatcher] = [:] + private var checkpointPollingTask: Task? /// Creates a history coordinator over `filesystem`. public init( workspaceId: UUID = UUID(), filesystem: any FileSystem, - historyDirectory: URL + storage: Storage = .inMemory ) { self.workspaceId = workspaceId - sharedWorkspace = Workspace(filesystem: filesystem) - store = FileCheckpointStore(rootDirectory: historyDirectory) + shared = Workspace(filesystem: filesystem) + store = Self.makeStore(for: storage) } /// Creates a history coordinator over an existing shared workspace. public init( workspaceId: UUID = UUID(), workspace: Workspace, - historyDirectory: URL + storage: Storage = .inMemory ) { self.workspaceId = workspaceId - sharedWorkspace = workspace - store = FileCheckpointStore(rootDirectory: historyDirectory) + shared = workspace + store = Self.makeStore(for: storage) } init( @@ -63,7 +75,7 @@ public actor History { store: any CheckpointStore ) { self.workspaceId = workspaceId - sharedWorkspace = Workspace(filesystem: filesystem) + shared = Workspace(filesystem: filesystem) self.store = store } @@ -73,81 +85,27 @@ public actor History { store: any CheckpointStore ) { self.workspaceId = workspaceId - sharedWorkspace = workspace + shared = workspace self.store = store } - /// Reads raw data from the shared workspace. - public func readData(from path: WorkspacePath) async throws -> Data { - try await ensureLoaded() - return try await sharedWorkspace.readData(from: path) - } - - /// Reads UTF-8 text from the shared workspace. - public func readFile(_ path: WorkspacePath) async throws -> String { - try await ensureLoaded() - return try await sharedWorkspace.readFile(path) - } - - /// Reads JSON from the shared workspace. - public func readJSON(_ type: T.Type = T.self, from path: WorkspacePath) async throws -> T { - try await ensureLoaded() - return try await sharedWorkspace.readJSON(type, from: path) - } - - /// Returns whether an entry exists in the shared workspace. - public func exists(_ path: WorkspacePath) async throws -> Bool { - try await ensureLoaded() - return await sharedWorkspace.exists(path) - } - - /// Returns shared workspace metadata. - public func fileInfo(at path: WorkspacePath) async throws -> FileInfo { - try await ensureLoaded() - return try await sharedWorkspace.fileInfo(at: path) - } - - /// Lists a shared workspace directory. - public func listDirectory(at path: WorkspacePath) async throws -> [DirectoryEntry] { - try await ensureLoaded() - return try await sharedWorkspace.listDirectory(at: path) - } - - /// Expands a glob against the shared workspace. - public func glob(_ pattern: String, currentDirectory: WorkspacePath = .root) async throws -> [WorkspacePath] { - try await ensureLoaded() - return try await sharedWorkspace.glob(pattern, currentDirectory: currentDirectory) - } - - /// Walks the shared workspace tree. - public func walkTree(_ path: WorkspacePath, maxDepth: Int? = nil) async throws -> FileTree { - try await ensureLoaded() - return try await sharedWorkspace.walkTree(path, maxDepth: maxDepth) - } - - /// Summarizes the shared workspace tree. - public func summarizeTree(_ path: WorkspacePath, maxDepth: Int? = nil) async throws -> FileTreeSummary { - try await ensureLoaded() - return try await sharedWorkspace.summarizeTree(path, maxDepth: maxDepth) + private static func makeStore(for storage: Storage) -> any CheckpointStore { + switch storage { + case .inMemory: + InMemoryCheckpointStore() + case .directory(let url): + FileCheckpointStore(rootDirectory: url) + } } - /// Watches shared workspace filesystem changes. - public func watchChanges(at path: WorkspacePath, recursive: Bool = true) async throws -> AsyncStream { - try await ensureLoaded() - return await sharedWorkspace.watchChanges(at: path, recursive: recursive) - } + // MARK: - Checkpoint watching - /// Watches durable checkpoint events for `workspaceId`. - func watchCheckpointEvents(workspaceId: UUID) async throws -> AsyncStream { + /// Watches for checkpoint events, including those created by other History instances sharing the same store. + public func watchCheckpointEvents() async throws -> AsyncStream { try await ensureLoaded() - guard workspaceId == self.workspaceId else { - return AsyncStream { continuation in - continuation.finish() - } - } - let watcherId = UUID() + let deliveredIds = Set(checkpoints.map(\.id)) var continuation: AsyncStream.Continuation? let stream = AsyncStream { continuation = $0 @@ -157,21 +115,27 @@ public actor History { return stream } - eventWatchers[watcherId] = EventWatcher(workspaceId: workspaceId, continuation: continuation) + checkpointWatchers[watcherId] = CheckpointWatcher( + deliveredCheckpointIds: deliveredIds, + continuation: continuation + ) continuation.onTermination = { [weak self] _ in Task { - await self?.removeEventWatcher(id: watcherId) + await self?.removeCheckpointWatcher(id: watcherId) } } + ensureCheckpointPolling() return stream } + // MARK: - Sessions + /// Creates a tracked session overlay from the current shared head. public func createSession() async throws -> Session { try await ensureLoaded() let sessionId = UUID() - let snapshot = try await sharedWorkspace.captureSnapshot() + let snapshot = try await shared.captureSnapshot() let overlay = InMemoryFilesystem() let sessionWorkspace = Workspace(filesystem: overlay) try await sessionWorkspace.restoreSnapshot(snapshot) @@ -190,11 +154,13 @@ public actor History { ) } + // MARK: - Shared write operations + /// Writes UTF-8 text to the shared workspace. public func writeFile(_ path: WorkspacePath, content: String) async throws { try await ensureLoaded() try await performDirectEdit( - on: sharedWorkspace, + on: shared, scope: .shared, sessionId: nil, kind: .writeFile, @@ -208,7 +174,7 @@ public actor History { public func appendFile(_ path: WorkspacePath, content: String) async throws { try await ensureLoaded() try await performDirectEdit( - on: sharedWorkspace, + on: shared, scope: .shared, sessionId: nil, kind: .appendFile, @@ -222,7 +188,7 @@ public actor History { public func writeData(_ data: Data, to path: WorkspacePath) async throws { try await ensureLoaded() try await performBinaryWrite( - on: sharedWorkspace, + on: shared, scope: .shared, sessionId: nil, data: data, @@ -239,7 +205,7 @@ public actor History { try await ensureLoaded() let content = try encodedJSONString(for: value, prettyPrinted: prettyPrinted) try await performDirectEdit( - on: sharedWorkspace, + on: shared, scope: .shared, sessionId: nil, kind: .writeJSON, @@ -253,7 +219,7 @@ public actor History { public func createDirectory(at path: WorkspacePath, recursive: Bool = true) async throws { try await ensureLoaded() try await performDirectEdit( - on: sharedWorkspace, + on: shared, scope: .shared, sessionId: nil, kind: .createDirectory, @@ -267,7 +233,7 @@ public actor History { public func removeItem(at path: WorkspacePath, recursive: Bool = true) async throws { try await ensureLoaded() try await performDirectEdit( - on: sharedWorkspace, + on: shared, scope: .shared, sessionId: nil, kind: .removeItem, @@ -281,7 +247,7 @@ public actor History { public func copyItem(from source: WorkspacePath, to destination: WorkspacePath, recursive: Bool = true) async throws { try await ensureLoaded() try await performDirectEdit( - on: sharedWorkspace, + on: shared, scope: .shared, sessionId: nil, kind: .copyItem, @@ -295,7 +261,7 @@ public actor History { public func moveItem(from source: WorkspacePath, to destination: WorkspacePath) async throws { try await ensureLoaded() try await performDirectEdit( - on: sharedWorkspace, + on: shared, scope: .shared, sessionId: nil, kind: .moveItem, @@ -311,7 +277,7 @@ public actor History { failurePolicy: MutationFailurePolicy = .rollback ) async throws -> FileEdit.BatchResult { try await ensureLoaded() - let result = try await sharedWorkspace.applyEdits(edits, failurePolicy: failurePolicy) + let result = try await shared.applyEdits(edits, failurePolicy: failurePolicy) if !edits.isEmpty { try await appendMutation( kind: .applyEdits, @@ -331,7 +297,7 @@ public actor History { failurePolicy: MutationFailurePolicy = .rollback ) async throws -> ReplacementResult { try await ensureLoaded() - let result = try await sharedWorkspace.applyReplacement(request, failurePolicy: failurePolicy) + let result = try await shared.applyReplacement(request, failurePolicy: failurePolicy) if !result.changes.isEmpty || !result.failures.isEmpty { try await appendMutation( kind: .applyReplacement, @@ -353,10 +319,12 @@ public actor History { return result } + // MARK: - Checkpoints + /// Creates a shared checkpoint. public func createCheckpoint(label: String? = nil) async throws -> Checkpoint { try await ensureLoaded() - let snapshot = try await sharedWorkspace.captureSnapshot() + let snapshot = try await shared.captureSnapshot() return try await persistCheckpoint( snapshot: snapshot, scope: .shared, @@ -377,8 +345,9 @@ public actor History { guard checkpoint.scope == .shared else { throw HistoryError.checkpointScopeMismatch(expected: .shared, actual: checkpoint.scope) } - try await sharedWorkspace.restoreSnapshot(checkpoint.snapshot) - let restoredSnapshot = try await sharedWorkspace.captureSnapshot() + let targetSnapshot = try await loadSnapshotOrThrow(id: checkpoint.snapshotId) + try await shared.restoreSnapshot(targetSnapshot) + let restoredSnapshot = try await shared.captureSnapshot() return try await persistCheckpoint( snapshot: restoredSnapshot, scope: .shared, @@ -405,11 +374,11 @@ public actor History { ) } - let previousSharedSnapshot = try await sharedWorkspace.captureSnapshot() + let previousSharedSnapshot = try await shared.captureSnapshot() let sessionSnapshot = try await session.workspace.captureSnapshot() let delta = snapshotDelta(from: previousSharedSnapshot.entry, to: sessionSnapshot.entry) - try await sharedWorkspace.restoreSnapshot(sessionSnapshot) + try await shared.restoreSnapshot(sessionSnapshot) if delta.hasChanges { try await appendMutation( @@ -479,6 +448,12 @@ public actor History { return checkpoints.first(where: { $0.id == id }) } + func loadSnapshot(for checkpoint: Checkpoint) async throws -> Snapshot { + try await loadSnapshotOrThrow(id: checkpoint.snapshotId) + } + + // MARK: - Session write operations (internal, called by Session) + func sessionWriteFile(_ sessionId: UUID, path: WorkspacePath, content: String) async throws { try await ensureLoaded() try await performSessionDirectEdit( @@ -665,7 +640,8 @@ public actor History { ) } - try await session.workspace.restoreSnapshot(checkpoint.snapshot) + let targetSnapshot = try await loadSnapshotOrThrow(id: checkpoint.snapshotId) + try await session.workspace.restoreSnapshot(targetSnapshot) switch checkpoint.scope { case .shared: @@ -694,6 +670,8 @@ public actor History { return try sessionState(for: sessionId).baseSharedCheckpointId } + // MARK: - Private + private func ensureLoaded() async throws { guard !didLoadStoreState else { return @@ -724,6 +702,13 @@ public actor History { return checkpoint } + private func loadSnapshotOrThrow(id: UUID) async throws -> Snapshot { + guard let snapshot = try await store.loadSnapshot(id: id, workspaceId: workspaceId) else { + throw HistoryError.snapshotNotFound(id) + } + return snapshot + } + private func performSessionDirectEdit( sessionId: UUID, kind: MutationRecord.Kind, @@ -816,9 +801,20 @@ public actor History { rollbackSourceCheckpointId: UUID?, eventKind: CheckpointEvent.Kind ) async throws -> Checkpoint { + try await store.saveSnapshot(snapshot, workspaceId: workspaceId) + let parentCheckpoint = parentCheckpointId.flatMap { id in checkpoints.first(where: { $0.id == id }) } + + let previousSnapshot: Snapshot? + if let parentCheckpoint { + previousSnapshot = try? await store.loadSnapshot(id: parentCheckpoint.snapshotId, workspaceId: workspaceId) + } else { + previousSnapshot = nil + } + let summary = Snapshot.summary(comparing: snapshot, to: previousSnapshot) + let previousCursor = parentCheckpoint?.mutationCursor ?? 0 let currentCursor = latestMutationSequence(scope: scope, sessionId: sessionId) let checkpointId = UUID() @@ -837,7 +833,8 @@ public actor History { mutationCursor: currentCursor, originSessionId: originSessionId, rollbackSourceCheckpointId: rollbackSourceCheckpointId, - snapshot: snapshot + snapshotId: snapshot.id, + summary: summary ) try await store.saveCheckpoint(checkpoint) @@ -855,7 +852,7 @@ public actor History { } } - emit(CheckpointEvent(kind: eventKind, checkpoint: checkpoint)) + emitCheckpointEvent(CheckpointEvent(kind: eventKind, checkpoint: checkpoint)) return checkpoint } @@ -921,6 +918,64 @@ public actor History { } } + // MARK: - Checkpoint watching internals + + private func removeCheckpointWatcher(id: UUID) { + checkpointWatchers.removeValue(forKey: id) + if checkpointWatchers.isEmpty { + checkpointPollingTask?.cancel() + checkpointPollingTask = nil + } + } + + private func ensureCheckpointPolling() { + guard checkpointPollingTask == nil else { + return + } + checkpointPollingTask = Task { [weak self] in + while !Task.isCancelled { + try? await Task.sleep(for: .milliseconds(500)) + await self?.pollCheckpointEvents() + } + } + } + + private func pollCheckpointEvents() async { + guard !checkpointWatchers.isEmpty else { + return + } + guard let loaded = try? await store.listCheckpoints(workspaceId: workspaceId) else { + return + } + + for cp in loaded where !checkpoints.contains(where: { $0.id == cp.id }) { + checkpoints.append(cp) + } + checkpoints.sort(by: checkpointSort) + + for cp in loaded.sorted(by: checkpointSort) { + emitCheckpointEvent(CheckpointEvent(kind: cp.inferredEventKind, checkpoint: cp)) + } + } + + private func emitCheckpointEvent(_ event: CheckpointEvent) { + guard !checkpointWatchers.isEmpty else { + return + } + + for id in Array(checkpointWatchers.keys) { + guard var watcher = checkpointWatchers[id] else { + continue + } + if watcher.deliveredCheckpointIds.insert(event.checkpoint.id).inserted { + watcher.continuation.yield(event) + checkpointWatchers[id] = watcher + } + } + } + + // MARK: - Snapshot delta + private func snapshotDelta(from original: Snapshot.Entry, to updated: Snapshot.Entry) -> SnapshotDelta { var delta = SnapshotDelta() collectDelta(from: original, to: updated, into: &delta) @@ -1061,17 +1116,6 @@ public actor History { } } - private func emit(_ event: CheckpointEvent) { - let watchers = eventWatchers.values.filter { $0.workspaceId == event.workspaceId } - for watcher in watchers { - watcher.continuation.yield(event) - } - } - - private func removeEventWatcher(id: UUID) { - eventWatchers.removeValue(forKey: id) - } - private func checkpointSort(lhs: Checkpoint, rhs: Checkpoint) -> Bool { if lhs.createdAt == rhs.createdAt { return lhs.id.uuidString < rhs.id.uuidString @@ -1087,13 +1131,19 @@ public actor History { } } +// MARK: - Session + extension History { /// A tracked session-local editing overlay managed by `History`. public actor Session { - public let id: UUID - public let workspaceId: UUID + public nonisolated let id: UUID + public nonisolated let workspaceId: UUID + + /// The session's underlying workspace. + /// Use this for all read operations (readFile, exists, walkTree, etc.). + /// Use Session's write methods for tracked mutations. + public nonisolated let workspace: Workspace - private let workspace: Workspace private let history: History init(id: UUID, workspaceId: UUID, workspace: Workspace, history: History) { @@ -1103,56 +1153,6 @@ extension History { self.history = history } - /// Reads raw data from the session overlay. - public func readData(from path: WorkspacePath) async throws -> Data { - try await workspace.readData(from: path) - } - - /// Reads UTF-8 text from the session overlay. - public func readFile(_ path: WorkspacePath) async throws -> String { - try await workspace.readFile(path) - } - - /// Reads JSON from the session overlay. - public func readJSON(_ type: T.Type = T.self, from path: WorkspacePath) async throws -> T { - try await workspace.readJSON(type, from: path) - } - - /// Returns whether an entry exists in the session overlay. - public func exists(_ path: WorkspacePath) async -> Bool { - await workspace.exists(path) - } - - /// Returns session overlay metadata. - public func fileInfo(at path: WorkspacePath) async throws -> FileInfo { - try await workspace.fileInfo(at: path) - } - - /// Lists a session overlay directory. - public func listDirectory(at path: WorkspacePath) async throws -> [DirectoryEntry] { - try await workspace.listDirectory(at: path) - } - - /// Expands a glob against the session overlay. - public func glob(_ pattern: String, currentDirectory: WorkspacePath = .root) async throws -> [WorkspacePath] { - try await workspace.glob(pattern, currentDirectory: currentDirectory) - } - - /// Walks the session overlay tree. - public func walkTree(_ path: WorkspacePath, maxDepth: Int? = nil) async throws -> FileTree { - try await workspace.walkTree(path, maxDepth: maxDepth) - } - - /// Summarizes the session overlay tree. - public func summarizeTree(_ path: WorkspacePath, maxDepth: Int? = nil) async throws -> FileTreeSummary { - try await workspace.summarizeTree(path, maxDepth: maxDepth) - } - - /// Watches session-local filesystem changes. - public func watchChanges(at path: WorkspacePath, recursive: Bool = true) async -> AsyncStream { - await workspace.watchChanges(at: path, recursive: recursive) - } - /// Writes UTF-8 text into the session overlay. public func writeFile(_ path: WorkspacePath, content: String) async throws { try await history.sessionWriteFile(id, path: path, content: content) diff --git a/Sources/Workspace/HistoryTypes.swift b/Sources/Workspace/HistoryTypes.swift index 6411705..c4a342a 100644 --- a/Sources/Workspace/HistoryTypes.swift +++ b/Sources/Workspace/HistoryTypes.swift @@ -11,6 +11,16 @@ extension History { case shared } + /// A lightweight summary of changes relative to the parent checkpoint. + public struct Summary: Sendable, Codable, Equatable { + /// The number of paths that differ from the parent checkpoint. + public var changeCount: Int + /// The paths that changed relative to the parent checkpoint. + public var touchedPaths: [WorkspacePath] + /// Whether any changed file paths involve UTF-8 decodable text. + public var hasTextDiffs: Bool + } + /// The checkpoint identifier. public var id: UUID /// The managed workspace identifier. @@ -23,24 +33,24 @@ extension History { public var label: String? /// The checkpoint creation timestamp. public var createdAt: Date - /// The prior checkpoint in the same scope lineage. + /// A summary of what changed relative to the parent checkpoint. + public var summary: Summary + var parentCheckpointId: UUID? - /// The shared checkpoint the session was based on when this checkpoint was created. var baseSharedCheckpointId: UUID? - /// The first mutation sequence captured by this checkpoint, if any. var firstMutationSequence: Int? - /// The last mutation sequence captured by this checkpoint, if any. var lastMutationSequence: Int? - /// The latest visible mutation sequence when the checkpoint was created. var mutationCursor: Int - /// The originating session for shared publish checkpoints when available. var originSessionId: UUID? - /// The source checkpoint for rollback checkpoints when available. var rollbackSourceCheckpointId: UUID? - /// The captured restorable workspace state for this checkpoint. - var snapshot: Snapshot + var snapshotId: UUID + + var inferredEventKind: CheckpointEvent.Kind { + if rollbackSourceCheckpointId != nil { return .rolledBack } + if originSessionId != nil, scope == .shared { return .published } + return .created + } - /// Creates a workspace checkpoint. init( id: UUID = UUID(), workspaceId: UUID, @@ -55,7 +65,8 @@ extension History { mutationCursor: Int, originSessionId: UUID? = nil, rollbackSourceCheckpointId: UUID? = nil, - snapshot: Snapshot + snapshotId: UUID, + summary: Summary ) { self.id = id self.workspaceId = workspaceId @@ -70,14 +81,13 @@ extension History { self.mutationCursor = mutationCursor self.originSessionId = originSessionId self.rollbackSourceCheckpointId = rollbackSourceCheckpointId - self.snapshot = snapshot + self.snapshotId = snapshotId + self.summary = summary } } } -/// A normalized mutation record stored between checkpoints. struct MutationRecord: Sendable, Codable, Equatable { - /// The tracked mutation kind. enum Kind: String, Sendable, Codable { case writeFile case appendFile @@ -92,26 +102,16 @@ struct MutationRecord: Sendable, Codable, Equatable { case publishSessionHead } - /// The monotonically increasing mutation sequence. var sequence: Int - /// The managed workspace identifier. var workspaceId: UUID - /// The originating session when available. var sessionId: UUID? - /// Whether the mutation targeted a session overlay or the shared head. var scope: History.Checkpoint.Scope - /// The mutation timestamp. var timestamp: Date - /// The tracked mutation kind. var kind: Kind - /// The canonicalized paths touched by the mutation. var touchedPaths: [WorkspacePath] - /// The normalized file-level changes described by the mutation. var fileChanges: [FileEdit.FileChange] - /// A primary text diff when the mutation naturally maps to one. var diff: TextDiff? - /// Creates a workspace mutation record. init( sequence: Int, workspaceId: UUID, @@ -135,61 +135,53 @@ struct MutationRecord: Sendable, Codable, Equatable { } } -/// A durable checkpoint event emitted by `History`. -struct CheckpointEvent: Sendable, Codable, Equatable { +/// A checkpoint event emitted by `History`. +public struct CheckpointEvent: Sendable, Codable, Equatable { /// The event kind. - enum Kind: String, Sendable, Codable { - /// A checkpoint was created manually. + public enum Kind: String, Sendable, Codable { + /// A checkpoint was created. case created - /// A rollback restored a prior checkpoint and emitted a new rollback checkpoint. + /// A rollback restored a prior checkpoint. case rolledBack /// A session head was published to the shared workspace. case published } /// The event kind. - var kind: Kind - /// The resulting checkpoint metadata. - var checkpoint: History.Checkpoint + public var kind: Kind + /// The checkpoint that triggered this event. + public var checkpoint: History.Checkpoint /// Creates a checkpoint event. - init(kind: Kind, checkpoint: History.Checkpoint) { + public init(kind: Kind, checkpoint: History.Checkpoint) { self.kind = kind self.checkpoint = checkpoint } - /// The managed workspace identifier. - var workspaceId: UUID { - checkpoint.workspaceId - } - - /// The originating session identifier when available. - var sessionId: UUID? { - checkpoint.sessionId - } - /// The checkpoint scope. - var scope: History.Checkpoint.Scope { + public var scope: History.Checkpoint.Scope { checkpoint.scope } } -/// Errors produced by session-scoped checkpointing and publish flows. -enum HistoryError: Error, CustomStringConvertible, Sendable { +/// Errors produced by `History` operations. +public enum HistoryError: Error, CustomStringConvertible, Sendable { case sessionNotFound(UUID) case checkpointNotFound(UUID) + case snapshotNotFound(UUID) case checkpointScopeMismatch(expected: History.Checkpoint.Scope, actual: History.Checkpoint.Scope) case checkpointSessionMismatch(checkpointId: UUID, expectedSessionId: UUID, actualSessionId: UUID?) case publishConflict(sessionId: UUID, expectedBaseSharedCheckpointId: UUID?, actualSharedCheckpointId: UUID?) case mutationFailed(String) - /// A human-readable description of the error. - var description: String { + public var description: String { switch self { case let .sessionNotFound(sessionId): return "workspace session not found: \(sessionId.uuidString)" case let .checkpointNotFound(checkpointId): return "workspace checkpoint not found: \(checkpointId.uuidString)" + case let .snapshotNotFound(snapshotId): + return "workspace snapshot not found: \(snapshotId.uuidString)" case let .checkpointScopeMismatch(expected, actual): return "checkpoint scope mismatch: expected \(expected.rawValue), got \(actual.rawValue)" case let .checkpointSessionMismatch(checkpointId, expectedSessionId, actualSessionId): diff --git a/Sources/Workspace/Snapshot.swift b/Sources/Workspace/Snapshot.swift index 5d3b82c..0e08c41 100644 --- a/Sources/Workspace/Snapshot.swift +++ b/Sources/Workspace/Snapshot.swift @@ -2,39 +2,26 @@ import Foundation /// A durable recursive snapshot of a workspace subtree. struct Snapshot: Sendable, Codable, Equatable { - /// A snapshot entry in the captured tree. indirect enum Entry: Sendable, Codable, Equatable { - /// The captured path did not exist. case missing(Missing) - /// A regular file with captured bytes. case file(File) - /// A directory with recursive children. case directory(Directory) - /// A symbolic link and its captured target. case symlink(Symlink) } - /// A missing path entry. struct Missing: Sendable, Codable, Equatable { - /// The missing path. var path: WorkspacePath - /// Creates a missing entry. init(path: WorkspacePath) { self.path = path } } - /// A file entry. struct File: Sendable, Codable, Equatable { - /// The file path. var path: WorkspacePath - /// The file contents. var data: Data - /// The file permissions. var permissions: POSIXPermissions - /// Creates a file entry. init(path: WorkspacePath, data: Data, permissions: POSIXPermissions) { self.path = path self.data = data @@ -42,16 +29,11 @@ struct Snapshot: Sendable, Codable, Equatable { } } - /// A directory entry. struct Directory: Sendable, Codable, Equatable { - /// The directory path. var path: WorkspacePath - /// The directory permissions. var permissions: POSIXPermissions - /// The captured child entries. var children: [Entry] - /// Creates a directory entry. init(path: WorkspacePath, permissions: POSIXPermissions, children: [Entry]) { self.path = path self.permissions = permissions @@ -59,16 +41,11 @@ struct Snapshot: Sendable, Codable, Equatable { } } - /// A symbolic link entry. struct Symlink: Sendable, Codable, Equatable { - /// The symlink path. var path: WorkspacePath - /// The symlink target string. var target: String - /// The symlink permissions. var permissions: POSIXPermissions - /// Creates a symlink entry. init(path: WorkspacePath, target: String, permissions: POSIXPermissions) { self.path = path self.target = target @@ -76,14 +53,10 @@ struct Snapshot: Sendable, Codable, Equatable { } } - /// The snapshot identifier. var id: UUID - /// The root path represented by the snapshot. var rootPath: WorkspacePath - /// The captured entry tree at `rootPath`. var entry: Entry - /// Creates a workspace snapshot. init(id: UUID = UUID(), rootPath: WorkspacePath, entry: Entry) { self.id = id self.rootPath = rootPath @@ -91,6 +64,79 @@ struct Snapshot: Sendable, Codable, Equatable { } } +// MARK: - Summary + +extension Snapshot { + static func summary(comparing current: Snapshot, to previous: Snapshot?) -> History.Checkpoint.Summary { + let currentNodes = flattenNodes(current.entry) + let previousNodes = previous.map { flattenNodes($0.entry) } ?? [:] + let allPaths = Set(currentNodes.keys).union(previousNodes.keys).sorted() + let changedPaths = allPaths.filter { currentNodes[$0] != previousNodes[$0] } + let hasTextDiffs = changedPaths.contains { hasTextualChange(old: previousNodes[$0], new: currentNodes[$0]) } + + return History.Checkpoint.Summary( + changeCount: changedPaths.count, + touchedPaths: changedPaths, + hasTextDiffs: hasTextDiffs + ) + } + + private struct FlatNode: Equatable { + var kind: FileTree.Kind + var permissions: POSIXPermissions + var fileData: Data? + var symlinkTarget: String? + } + + private static func flattenNodes(_ entry: Entry) -> [WorkspacePath: FlatNode] { + var nodes: [WorkspacePath: FlatNode] = [:] + collectNodes(entry, into: &nodes) + nodes.removeValue(forKey: .root) + return nodes + } + + private static func collectNodes(_ entry: Entry, into nodes: inout [WorkspacePath: FlatNode]) { + switch entry { + case .missing: + return + case let .file(f): + nodes[f.path] = FlatNode(kind: .file, permissions: f.permissions, fileData: f.data) + case let .symlink(s): + nodes[s.path] = FlatNode(kind: .symlink, permissions: s.permissions, symlinkTarget: s.target) + case let .directory(d): + nodes[d.path] = FlatNode(kind: .directory, permissions: d.permissions) + for child in d.children { + collectNodes(child, into: &nodes) + } + } + } + + private static func hasTextualChange(old: FlatNode?, new: FlatNode?) -> Bool { + let oldText: String? + if let data = old?.fileData { + oldText = String(data: data, encoding: .utf8) + } else if old == nil { + oldText = "" + } else { + oldText = nil + } + + let newText: String? + if let data = new?.fileData { + newText = String(data: data, encoding: .utf8) + } else if new == nil { + newText = "" + } else { + newText = nil + } + + guard let oldText, let newText else { return false } + return oldText != newText + } +} + +// MARK: - Capture / Restore + extension Snapshot { static func capture( from target: any FileSystem, diff --git a/Tests/WorkspaceTests/HistoryTests.swift b/Tests/WorkspaceTests/HistoryTests.swift index e99276d..201f129 100644 --- a/Tests/WorkspaceTests/HistoryTests.swift +++ b/Tests/WorkspaceTests/HistoryTests.swift @@ -2,19 +2,6 @@ import Foundation import Testing @testable import Workspace -private enum HistoryTestSupport { - static func makeTempDirectory(prefix: String = "HistoryTests") throws -> URL { - let base = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true) - let url = base.appendingPathComponent("\(prefix)-\(UUID().uuidString)", isDirectory: true) - try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true) - return url - } - - static func removeDirectory(_ url: URL) { - try? FileManager.default.removeItem(at: url) - } -} - private actor CheckpointEventRecorder { private var events: [CheckpointEvent] = [] @@ -61,28 +48,23 @@ private func waitForCheckpointEvents( struct HistoryTests { @Test func `session checkpoints persist mutation ranges and shared workspace remains unchanged`() async throws { - let root = try HistoryTestSupport.makeTempDirectory() - defer { HistoryTestSupport.removeDirectory(root) } - let workspaceId = UUID() let history = History( workspaceId: workspaceId, - filesystem: InMemoryFilesystem(), - historyDirectory: root + filesystem: InMemoryFilesystem() ) let session = try await history.createSession() - let sessionId = await session.id try await session.writeFile("/note.txt", content: "one") try await session.appendFile("/note.txt", content: " two") try await session.createDirectory(at: "/docs") let checkpoint = try await session.createCheckpoint(label: "draft") let mutations = try await session.listMutationRecords() - let storeCheckpoints = try await history.listCheckpoints(scope: .session, sessionId: sessionId) + let storeCheckpoints = try await history.listCheckpoints(scope: .session, sessionId: session.id) #expect(checkpoint.scope == .session) - #expect(checkpoint.sessionId == sessionId) + #expect(checkpoint.sessionId == session.id) #expect(checkpoint.firstMutationSequence == 1) #expect(checkpoint.lastMutationSequence == 3) #expect(checkpoint.mutationCursor == 3) @@ -92,21 +74,14 @@ struct HistoryTests { #expect(mutations[1].diff?.hunks.isEmpty == false) #expect(mutations[2].diff == nil) #expect(storeCheckpoints == [checkpoint]) - #expect(!(try await history.exists("/note.txt"))) + #expect(!(await history.shared.exists("/note.txt"))) } @Test func `empty checkpoints are persisted and session rollback creates a rollback checkpoint`() async throws { - let root = try HistoryTestSupport.makeTempDirectory() - defer { HistoryTestSupport.removeDirectory(root) } - - let history = History( - filesystem: InMemoryFilesystem(), - historyDirectory: root - ) + let history = History(filesystem: InMemoryFilesystem()) let session = try await history.createSession() - let sessionId = await session.id let emptyCheckpoint = try await session.createCheckpoint(label: "start") try await session.writeFile("/note.txt", content: "draft") @@ -115,38 +90,30 @@ struct HistoryTests { #expect(emptyCheckpoint.firstMutationSequence == nil) #expect(emptyCheckpoint.lastMutationSequence == nil) #expect(emptyCheckpoint.mutationCursor == 0) - #expect(!(await session.exists("/note.txt"))) + #expect(!(await session.workspace.exists("/note.txt"))) #expect(rollbackCheckpoint.scope == .session) #expect(rollbackCheckpoint.rollbackSourceCheckpointId == emptyCheckpoint.id) - #expect(rollbackCheckpoint.sessionId == sessionId) + #expect(rollbackCheckpoint.sessionId == session.id) #expect((try await session.listCheckpoints()).count == 2) } @Test func `shared rollback restores the shared tree and emits a rollback event`() async throws { - let root = try HistoryTestSupport.makeTempDirectory() - defer { HistoryTestSupport.removeDirectory(root) } - - let workspaceId = UUID() - let history = History( - workspaceId: workspaceId, - filesystem: InMemoryFilesystem(), - historyDirectory: root - ) + let history = History(filesystem: InMemoryFilesystem()) try await history.writeFile("/shared.txt", content: "one") let checkpoint = try await history.createCheckpoint(label: "v1") try await history.writeFile("/shared.txt", content: "two") let recorder = CheckpointEventRecorder() - let stream = try await history.watchCheckpointEvents(workspaceId: workspaceId) + let stream = try await history.watchCheckpointEvents() let task = startCheckpointRecording(stream, into: recorder) defer { task.cancel() } let rollbackCheckpoint = try await history.rollbackShared(to: checkpoint.id, label: "revert") let events = try await waitForCheckpointEvents(1, recorder: recorder) - #expect(try await history.readFile("/shared.txt") == "one") + #expect(try await history.shared.readFile("/shared.txt") == "one") #expect(rollbackCheckpoint.scope == .shared) #expect(rollbackCheckpoint.rollbackSourceCheckpointId == checkpoint.id) #expect(events.count == 1) @@ -156,18 +123,10 @@ struct HistoryTests { @Test func `sessions checkpoint independently and publish conflicts when shared head has advanced`() async throws { - let root = try HistoryTestSupport.makeTempDirectory() - defer { HistoryTestSupport.removeDirectory(root) } - - let history = History( - filesystem: InMemoryFilesystem(), - historyDirectory: root - ) + let history = History(filesystem: InMemoryFilesystem()) let sessionA = try await history.createSession() let sessionB = try await history.createSession() - let sessionAId = await sessionA.id - let sessionBId = await sessionB.id try await sessionA.writeFile("/note.txt", content: "alpha") try await sessionB.writeFile("/note.txt", content: "beta") @@ -178,13 +137,13 @@ struct HistoryTests { #expect(checkpointA.scope == .session) #expect(checkpointB.scope == .session) - #expect(checkpointA.sessionId == sessionAId) - #expect(checkpointB.sessionId == sessionBId) + #expect(checkpointA.sessionId == sessionA.id) + #expect(checkpointB.sessionId == sessionB.id) #expect(checkpointA.baseSharedCheckpointId == checkpointB.baseSharedCheckpointId) #expect(published.scope == .shared) - #expect(published.originSessionId == sessionAId) - #expect(published.sessionId == sessionAId) - #expect(try await history.readFile("/note.txt") == "alpha") + #expect(published.originSessionId == sessionA.id) + #expect(published.sessionId == sessionA.id) + #expect(try await history.shared.readFile("/note.txt") == "alpha") #expect(try await sessionA.baseSharedCheckpointId() == published.id) do { @@ -193,7 +152,7 @@ struct HistoryTests { } catch let error as HistoryError { switch error { case let .publishConflict(sessionId, expectedBaseSharedCheckpointId, actualSharedCheckpointId): - #expect(sessionId == sessionBId) + #expect(sessionId == sessionB.id) #expect(expectedBaseSharedCheckpointId == checkpointB.baseSharedCheckpointId) #expect(actualSharedCheckpointId == published.id) default: @@ -202,16 +161,41 @@ struct HistoryTests { } } + @Test + func `checkpoint summary tracks file changes and text diffs`() async throws { + let history = History(filesystem: InMemoryFilesystem()) + + let session = try await history.createSession() + try await session.writeFile("/a.txt", content: "hello") + try await session.writeFile("/b.txt", content: "world") + try await session.createDirectory(at: "/docs") + + let checkpoint = try await session.createCheckpoint(label: "initial") + + #expect(checkpoint.summary.changeCount == 3) + #expect(checkpoint.summary.touchedPaths.contains("/a.txt")) + #expect(checkpoint.summary.touchedPaths.contains("/b.txt")) + #expect(checkpoint.summary.touchedPaths.contains("/docs")) + #expect(checkpoint.summary.hasTextDiffs == true) + + try await session.writeFile("/a.txt", content: "updated") + let second = try await session.createCheckpoint(label: "update") + + #expect(second.summary.changeCount == 1) + #expect(second.summary.touchedPaths == [WorkspacePath("/a.txt")]) + #expect(second.summary.hasTextDiffs == true) + } + @Test func `history metadata and file-backed store roundtrip through Codable`() async throws { - let root = try HistoryTestSupport.makeTempDirectory() - defer { HistoryTestSupport.removeDirectory(root) } + let root = try makeTempDirectory() + defer { removeTempDirectory(root) } let workspaceId = UUID() let history = History( workspaceId: workspaceId, filesystem: InMemoryFilesystem(), - historyDirectory: root + storage: .directory(root) ) let session = try await history.createSession() @@ -221,7 +205,7 @@ struct HistoryTests { let reloaded = History( workspaceId: workspaceId, filesystem: InMemoryFilesystem(), - historyDirectory: root + storage: .directory(root) ) let encoder = JSONEncoder() @@ -243,9 +227,6 @@ struct HistoryTests { @Test func `session creation and checkpointing work with mounted shared filesystems`() async throws { - let root = try HistoryTestSupport.makeTempDirectory() - defer { HistoryTestSupport.removeDirectory(root) } - let mountedWorkspace = InMemoryFilesystem() try await mountedWorkspace.writeFile(path: "/file.txt", data: Data("shared".utf8), append: false) @@ -253,20 +234,31 @@ struct HistoryTests { filesystem: MountableFilesystem( base: InMemoryFilesystem(), mounts: [.init(mountPoint: "/workspace", filesystem: mountedWorkspace)] - ), - historyDirectory: root + ) ) let session = try await history.createSession() - let sessionId = await session.id - let original = try await session.readFile("/workspace/file.txt") + let original = try await session.workspace.readFile("/workspace/file.txt") try await session.writeFile("/workspace/file.txt", content: "session") let checkpoint = try await session.createCheckpoint(label: "mounted") #expect(original == "shared") - #expect(try await session.readFile("/workspace/file.txt") == "session") - #expect(try await history.readFile("/workspace/file.txt") == "shared") + #expect(try await session.workspace.readFile("/workspace/file.txt") == "session") + #expect(try await history.shared.readFile("/workspace/file.txt") == "shared") #expect(checkpoint.scope == .session) - #expect(checkpoint.sessionId == sessionId) + #expect(checkpoint.sessionId == session.id) + } + + // MARK: - Helpers + + private func makeTempDirectory() throws -> URL { + let base = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true) + let url = base.appendingPathComponent("HistoryTests-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true) + return url + } + + private func removeTempDirectory(_ url: URL) { + try? FileManager.default.removeItem(at: url) } } From d9649e9b2bb5f525802e738cc5a15bdf8ab65891 Mon Sep 17 00:00:00 2001 From: Zac White Date: Sat, 18 Apr 2026 15:16:27 -0700 Subject: [PATCH 04/14] Better tests --- Sources/Workspace/Workspace.swift | 69 ++++++++------- Tests/WorkspaceTests/HistoryTests.swift | 32 +++++++ Tests/WorkspaceTests/SnapshotTests.swift | 107 +++++++++++++++++++++++ 3 files changed, 174 insertions(+), 34 deletions(-) create mode 100644 Tests/WorkspaceTests/SnapshotTests.swift diff --git a/Sources/Workspace/Workspace.swift b/Sources/Workspace/Workspace.swift index d500ca0..0413764 100644 --- a/Sources/Workspace/Workspace.swift +++ b/Sources/Workspace/Workspace.swift @@ -240,7 +240,7 @@ public actor Workspace { ) } - let snapshots = failurePolicy == .rollback ? try await snapshotPaths(touchedPaths) : [] + let captures = failurePolicy == .rollback ? try await rollbackCaptures(for: touchedPaths) : [] var failures: [ReplacementResult.Failure] = [] var appliedIndices: [Int] = [] @@ -254,7 +254,7 @@ public actor Workspace { changes[index].status = .failed let failure = ReplacementResult.Failure(path: plannedChange.change.path, message: describe(error)) if failurePolicy == .rollback { - try await rollback(snapshots) + try await rollback(captures) for appliedIndex in appliedIndices { changes[appliedIndex].status = .rolledBack } @@ -331,7 +331,7 @@ public actor Workspace { ) } - let snapshots = failurePolicy == .rollback ? try await snapshotPaths(touchedPaths) : [] + let captures = failurePolicy == .rollback ? try await rollbackCaptures(for: touchedPaths) : [] var failures: [FileEdit.Failure] = [] var appliedIndices: [Int] = [] @@ -349,7 +349,7 @@ public actor Workspace { message: describe(error) ) if failurePolicy == .rollback { - try await rollback(snapshots) + try await rollback(captures) for appliedIndex in appliedIndices { setStatus(.rolledBack, for: &executionEntries[appliedIndex]) } @@ -686,13 +686,13 @@ public actor Workspace { let planningFilesystem = InMemoryFilesystem() let ancestors = Set(paths.flatMap(ancestorPaths(for:))).sorted() for ancestor in ancestors { - let snapshot = try await shallowSnapshot(path: ancestor, on: filesystem) - try await restore(snapshot, on: planningFilesystem) + let capture = try await shallowRollbackCapture(path: ancestor, on: filesystem) + try await restore(capture, on: planningFilesystem) } - let snapshots = try await snapshotPaths(paths) - for snapshot in snapshots { - try await restore(snapshot, on: planningFilesystem) + let captures = try await rollbackCaptures(for: paths) + for capture in captures { + try await restore(capture, on: planningFilesystem) } return planningFilesystem } @@ -990,8 +990,8 @@ public actor Workspace { at path: WorkspacePath, on target: any FileSystem ) async throws -> [ChangeEvent] { - let snapshot = try await snapshot(path: path, on: target) - return flattenDeletionEvents(from: snapshot) + let capture = try await rollbackCapture(path: path, on: target) + return flattenDeletionEvents(from: capture) } private func plannedTransferEvents( @@ -1000,9 +1000,9 @@ public actor Workspace { kind: ChangeEvent.Kind, on target: any FileSystem ) async throws -> [ChangeEvent] { - let snapshot = try await snapshot(path: sourcePath, on: target) + let capture = try await rollbackCapture(path: sourcePath, on: target) return flattenTransferEvents( - from: snapshot, + from: capture, sourceRoot: sourcePath, destinationRoot: destinationPath, kind: kind @@ -1023,8 +1023,8 @@ public actor Workspace { ] } - private func flattenDeletionEvents(from snapshot: SnapshotEntry) -> [ChangeEvent] { - switch snapshot { + private func flattenDeletionEvents(from capture: RollbackCapture) -> [ChangeEvent] { + switch capture { case .missing: return [] case let .file(path, _, _): @@ -1048,12 +1048,12 @@ public actor Workspace { } private func flattenTransferEvents( - from snapshot: SnapshotEntry, + from capture: RollbackCapture, sourceRoot: WorkspacePath, destinationRoot: WorkspacePath, kind: ChangeEvent.Kind ) -> [ChangeEvent] { - switch snapshot { + switch capture { case .missing: return [] case let .file(path, _, _): @@ -1282,27 +1282,28 @@ public actor Workspace { return String(describing: error) } - private enum SnapshotEntry: Sendable { + /// A short-lived capture used only to restore failed batched mutations. + private enum RollbackCapture: Sendable { case missing(path: WorkspacePath) case file(path: WorkspacePath, data: Data, permissions: POSIXPermissions) - case directory(path: WorkspacePath, permissions: POSIXPermissions, children: [SnapshotEntry]) + case directory(path: WorkspacePath, permissions: POSIXPermissions, children: [RollbackCapture]) case symlink(path: WorkspacePath, target: String, permissions: POSIXPermissions) } - private func snapshotPaths(_ paths: [WorkspacePath]) async throws -> [SnapshotEntry] { - try await snapshotPaths(paths, on: filesystem) + private func rollbackCaptures(for paths: [WorkspacePath]) async throws -> [RollbackCapture] { + try await rollbackCaptures(paths, on: filesystem) } - private func snapshotPaths( + private func rollbackCaptures( _ paths: [WorkspacePath], on target: any FileSystem - ) async throws -> [SnapshotEntry] { + ) async throws -> [RollbackCapture] { try await paths.asyncMap { path in - try await self.snapshot(path: path, on: target) + try await self.rollbackCapture(path: path, on: target) } } - private func shallowSnapshot(path: WorkspacePath, on target: any FileSystem) async throws -> SnapshotEntry { + private func shallowRollbackCapture(path: WorkspacePath, on target: any FileSystem) async throws -> RollbackCapture { guard await target.exists(path: path) else { return .missing(path: path) } @@ -1319,7 +1320,7 @@ public actor Workspace { } } - private func snapshot(path: WorkspacePath, on target: any FileSystem) async throws -> SnapshotEntry { + private func rollbackCapture(path: WorkspacePath, on target: any FileSystem) async throws -> RollbackCapture { guard await target.exists(path: path) else { return .missing(path: path) } @@ -1330,7 +1331,7 @@ public actor Workspace { let children = try await entries .sorted { $0.name < $1.name } .asyncMap { [self] entry in - try await self.snapshot(path: path.appending(entry.name), on: target) + try await self.rollbackCapture(path: path.appending(entry.name), on: target) } return .directory(path: path, permissions: info.permissions, children: children) } @@ -1347,14 +1348,14 @@ public actor Workspace { ) } - private func rollback(_ snapshots: [SnapshotEntry]) async throws { - for snapshot in snapshots.sorted(by: { path(of: $0).string.count < path(of: $1).string.count }) { - try await restore(snapshot, on: filesystem) + private func rollback(_ captures: [RollbackCapture]) async throws { + for capture in captures.sorted(by: { path(of: $0).string.count < path(of: $1).string.count }) { + try await restore(capture, on: filesystem) } } - private func restore(_ snapshot: SnapshotEntry, on target: any FileSystem) async throws { - switch snapshot { + private func restore(_ capture: RollbackCapture, on target: any FileSystem) async throws { + switch capture { case let .missing(path): if await target.exists(path: path) { try await target.remove(path: path, recursive: true) @@ -1389,8 +1390,8 @@ public actor Workspace { } } - private func path(of snapshot: SnapshotEntry) -> WorkspacePath { - switch snapshot { + private func path(of capture: RollbackCapture) -> WorkspacePath { + switch capture { case let .missing(path), let .file(path, _, _), let .directory(path, _, _), diff --git a/Tests/WorkspaceTests/HistoryTests.swift b/Tests/WorkspaceTests/HistoryTests.swift index 201f129..ec74db3 100644 --- a/Tests/WorkspaceTests/HistoryTests.swift +++ b/Tests/WorkspaceTests/HistoryTests.swift @@ -225,6 +225,38 @@ struct HistoryTests { #expect(try await reloaded.listMutationRecords() == [mutation]) } + @Test + func `file backed history reloads checkpoint snapshot artifact`() async throws { + let root = try makeTempDirectory() + defer { removeTempDirectory(root) } + + let workspaceId = UUID() + let history = History( + workspaceId: workspaceId, + filesystem: InMemoryFilesystem(), + storage: .directory(root) + ) + + try await history.writeFile("/note.txt", content: "checkpoint") + let checkpoint = try await history.createCheckpoint(label: "saved") + try await history.writeFile("/note.txt", content: "current") + + let reloaded = History( + workspaceId: workspaceId, + filesystem: InMemoryFilesystem(), + storage: .directory(root) + ) + let persistedCheckpoint = try #require(try await reloaded.checkpoint(id: checkpoint.id)) + let snapshot = try await reloaded.loadSnapshot(for: persistedCheckpoint) + let restored = InMemoryFilesystem() + + try await Snapshot.restore(snapshot, to: restored) + + #expect(try await history.shared.readFile("/note.txt") == "current") + #expect(try await restored.readFile(path: "/note.txt") == Data("checkpoint".utf8)) + #expect(Snapshot.summary(comparing: snapshot, to: nil) == checkpoint.summary) + } + @Test func `session creation and checkpointing work with mounted shared filesystems`() async throws { let mountedWorkspace = InMemoryFilesystem() diff --git a/Tests/WorkspaceTests/SnapshotTests.swift b/Tests/WorkspaceTests/SnapshotTests.swift new file mode 100644 index 0000000..c9db883 --- /dev/null +++ b/Tests/WorkspaceTests/SnapshotTests.swift @@ -0,0 +1,107 @@ +import Foundation +import Testing +@testable import Workspace + +@Suite("Snapshot") +struct SnapshotTests { + @Test + func `capture and restore roundtrips tree contents symlinks and permissions`() async throws { + let source = InMemoryFilesystem() + try await source.createDirectory(path: "/docs/nested", recursive: true) + try await source.writeFile(path: "/docs/nested/note.txt", data: Data("hello".utf8), append: false) + try await source.createSymlink(path: "/docs/link.txt", target: "/docs/nested/note.txt") + try await source.setPermissions(path: "/docs", permissions: POSIXPermissions(0o750)) + try await source.setPermissions(path: "/docs/nested", permissions: POSIXPermissions(0o700)) + try await source.setPermissions(path: "/docs/nested/note.txt", permissions: POSIXPermissions(0o600)) + try await source.setPermissions(path: "/docs/link.txt", permissions: POSIXPermissions(0o777)) + + let snapshotId = UUID() + let snapshot = try await Snapshot.capture(from: source, snapshotId: snapshotId) + + let target = InMemoryFilesystem() + try await target.createDirectory(path: "/docs", recursive: true) + try await target.writeFile(path: "/docs/stale.txt", data: Data("stale".utf8), append: false) + try await target.writeFile(path: "/stale-root.txt", data: Data("stale".utf8), append: false) + + try await Snapshot.restore(snapshot, to: target) + + #expect(snapshot.id == snapshotId) + #expect(snapshot.rootPath == .root) + #expect(try await target.readFile(path: "/docs/nested/note.txt") == Data("hello".utf8)) + #expect(try await target.readSymlink(path: "/docs/link.txt") == "/docs/nested/note.txt") + #expect(try await target.stat(path: "/docs").permissions == POSIXPermissions(0o750)) + #expect(try await target.stat(path: "/docs/nested").permissions == POSIXPermissions(0o700)) + #expect(try await target.stat(path: "/docs/nested/note.txt").permissions == POSIXPermissions(0o600)) + #expect(try await target.stat(path: "/docs/link.txt").permissions == POSIXPermissions(0o777)) + #expect(!(await target.exists(path: "/docs/stale.txt"))) + #expect(!(await target.exists(path: "/stale-root.txt"))) + } + + @Test + func `empty root and missing subtree snapshots restore expected absence`() async throws { + let source = InMemoryFilesystem() + let emptyRoot = try await Snapshot.capture(from: source) + let emptySummary = Snapshot.summary(comparing: emptyRoot, to: nil) + + let target = InMemoryFilesystem() + try await target.writeFile(path: "/stale.txt", data: Data("stale".utf8), append: false) + try await Snapshot.restore(emptyRoot, to: target) + + #expect(emptySummary.changeCount == 0) + #expect(emptySummary.touchedPaths.isEmpty) + #expect(emptySummary.hasTextDiffs == false) + #expect(!(await target.exists(path: "/stale.txt"))) + + let missing = try await Snapshot.capture(from: source, at: "/missing") + let missingTarget = InMemoryFilesystem() + try await missingTarget.createDirectory(path: "/missing", recursive: true) + try await missingTarget.writeFile(path: "/missing/file.txt", data: Data("remove me".utf8), append: false) + try await missingTarget.writeFile(path: "/kept.txt", data: Data("kept".utf8), append: false) + + try await Snapshot.restore(missing, to: missingTarget) + + guard case let .missing(entry) = missing.entry else { + Issue.record("expected missing snapshot entry") + return + } + #expect(entry.path == "/missing") + #expect(!(await missingTarget.exists(path: "/missing"))) + #expect(try await missingTarget.readFile(path: "/kept.txt") == Data("kept".utf8)) + } + + @Test + func `summary reports stable changed paths and text diff availability`() async throws { + let base = try await summaryFilesystem(text: "old", binary: Data([0xFF, 0xFE])) + let baseSnapshot = try await Snapshot.capture(from: base) + + let unchanged = try await Snapshot.capture(from: try await summaryFilesystem(text: "old", binary: Data([0xFF, 0xFE]))) + let unchangedSummary = Snapshot.summary(comparing: unchanged, to: baseSnapshot) + + let textChanged = try await Snapshot.capture(from: try await summaryFilesystem(text: "new", binary: Data([0xFF, 0xFE]))) + let textSummary = Snapshot.summary(comparing: textChanged, to: baseSnapshot) + + let binaryChanged = try await Snapshot.capture(from: try await summaryFilesystem(text: "old", binary: Data([0x00, 0xFF]))) + let binarySummary = Snapshot.summary(comparing: binaryChanged, to: baseSnapshot) + + #expect(unchangedSummary.changeCount == 0) + #expect(unchangedSummary.touchedPaths.isEmpty) + #expect(unchangedSummary.hasTextDiffs == false) + + #expect(textSummary.changeCount == 1) + #expect(textSummary.touchedPaths == [WorkspacePath("/text.txt")]) + #expect(textSummary.hasTextDiffs == true) + + #expect(binarySummary.changeCount == 1) + #expect(binarySummary.touchedPaths == [WorkspacePath("/binary.dat")]) + #expect(binarySummary.hasTextDiffs == false) + } + + private func summaryFilesystem(text: String, binary: Data) async throws -> InMemoryFilesystem { + let filesystem = InMemoryFilesystem() + try await filesystem.writeFile(path: "/text.txt", data: Data(text.utf8), append: false) + try await filesystem.writeFile(path: "/binary.dat", data: binary, append: false) + try await filesystem.createDirectory(path: "/docs", recursive: true) + try await filesystem.createSymlink(path: "/link.txt", target: "/text.txt") + return filesystem + } +} From 96b131c38755135cf6fdfa8c4cc7c3d7e9a2e094 Mon Sep 17 00:00:00 2001 From: Zac White Date: Sat, 18 Apr 2026 15:42:16 -0700 Subject: [PATCH 05/14] More improvements --- Sources/Workspace/History.swift | 43 ++++--- Sources/Workspace/HistoryTypes.swift | 39 +++++-- Sources/Workspace/Snapshot.swift | 137 +++++++++++++++-------- Sources/Workspace/Workspace.swift | 12 +- Tests/WorkspaceTests/HistoryTests.swift | 126 +++++++++++++++++++-- Tests/WorkspaceTests/SnapshotTests.swift | 77 ++++++++++++- 6 files changed, 342 insertions(+), 92 deletions(-) diff --git a/Sources/Workspace/History.swift b/Sources/Workspace/History.swift index 5508280..8ac9f18 100644 --- a/Sources/Workspace/History.swift +++ b/Sources/Workspace/History.swift @@ -23,11 +23,11 @@ public actor History { } /// Where to persist checkpoint and snapshot data. - public enum Storage { + public enum Storage: Sendable { /// In-memory storage that does not survive process exit. case inMemory - /// File-backed JSON storage at the given directory URL. - case directory(URL) + /// File-backed JSON storage rooted at `url`. + case directory(at: URL) } public let workspaceId: UUID @@ -47,6 +47,11 @@ public actor History { private var checkpointWatchers: [UUID: CheckpointWatcher] = [:] private var checkpointPollingTask: Task? + /// Creates an in-memory history coordinator suitable for tests and ephemeral work. + public init() { + self.init(filesystem: InMemoryFilesystem(), storage: .inMemory) + } + /// Creates a history coordinator over `filesystem`. public init( workspaceId: UUID = UUID(), @@ -93,7 +98,7 @@ public actor History { switch storage { case .inMemory: InMemoryCheckpointStore() - case .directory(let url): + case .directory(at: let url): FileCheckpointStore(rootDirectory: url) } } @@ -339,7 +344,7 @@ public actor History { } /// Rolls the shared workspace back to a prior shared checkpoint. - public func rollbackShared(to checkpointId: UUID, label: String? = nil) async throws -> Checkpoint { + public func rollback(to checkpointId: UUID, label: String? = nil) async throws -> Checkpoint { try await ensureLoaded() let checkpoint = try checkpointOrThrow(id: checkpointId) guard checkpoint.scope == .shared else { @@ -426,7 +431,7 @@ public actor History { } /// Lists mutation records for the managed workspace. - func listMutationRecords( + func mutationRecords( scope: Checkpoint.Scope? = nil, sessionId: UUID? = nil ) async throws -> [MutationRecord] { @@ -448,8 +453,14 @@ public actor History { return checkpoints.first(where: { $0.id == id }) } - func loadSnapshot(for checkpoint: Checkpoint) async throws -> Snapshot { - try await loadSnapshotOrThrow(id: checkpoint.snapshotId) + /// Loads the snapshot artifact persisted alongside `checkpoint`. + /// + /// Use this to inspect prior workspace contents, render diffs against the current + /// shared head, or restore the captured tree onto a different ``Workspace`` or + /// ``FileSystem`` via ``Snapshot/restore(_:to:)``. + public func snapshot(for checkpoint: Checkpoint) async throws -> Snapshot { + try await ensureLoaded() + return try await loadSnapshotOrThrow(id: checkpoint.snapshotId) } // MARK: - Session write operations (internal, called by Session) @@ -813,7 +824,7 @@ public actor History { } else { previousSnapshot = nil } - let summary = Snapshot.summary(comparing: snapshot, to: previousSnapshot) + let summary = snapshot.summary(comparedTo: previousSnapshot) let previousCursor = parentCheckpoint?.mutationCursor ?? 0 let currentCursor = latestMutationSequence(scope: scope, sessionId: sessionId) @@ -1026,8 +1037,8 @@ public actor History { delta.touchedPaths.insert(rhs.path) } - let lhsChildren = Dictionary(uniqueKeysWithValues: lhs.children.map { (Snapshot.path(of: $0).basename, $0) }) - let rhsChildren = Dictionary(uniqueKeysWithValues: rhs.children.map { (Snapshot.path(of: $0).basename, $0) }) + let lhsChildren = Dictionary(uniqueKeysWithValues: lhs.children.map { ($0.path.basename, $0) }) + let rhsChildren = Dictionary(uniqueKeysWithValues: rhs.children.map { ($0.path.basename, $0) }) let names = Set(lhsChildren.keys).union(rhsChildren.keys).sorted() for name in names { @@ -1223,8 +1234,10 @@ extension History { try await history.rollbackSession(sessionId: id, to: checkpointId, label: label) } - /// Publishes the current session head into the shared workspace. - public func publishHead(label: String? = nil) async throws -> Checkpoint { + /// Publishes this session's current state into the shared workspace. + /// + /// Conflicts with concurrent publishes raise ``HistoryError/publishConflict(sessionId:expectedBaseSharedCheckpointId:actualSharedCheckpointId:)``. + public func publish(label: String? = nil) async throws -> Checkpoint { try await history.publishSessionHead(sessionId: id, label: label) } @@ -1242,8 +1255,8 @@ extension History { } /// Lists mutation records for this session. - func listMutationRecords() async throws -> [MutationRecord] { - try await history.listMutationRecords(scope: .session, sessionId: id) + func mutationRecords() async throws -> [MutationRecord] { + try await history.mutationRecords(scope: .session, sessionId: id) } /// Returns the shared checkpoint this session is currently based on. diff --git a/Sources/Workspace/HistoryTypes.swift b/Sources/Workspace/HistoryTypes.swift index c4a342a..2eab135 100644 --- a/Sources/Workspace/HistoryTypes.swift +++ b/Sources/Workspace/HistoryTypes.swift @@ -1,7 +1,16 @@ import Foundation extension History { - /// A durable checkpoint over a managed workspace or session overlay. + /// A labeled, parented moment in workspace history. + /// + /// A `Checkpoint` is the *event* — when it happened, who created it, what scope it + /// belongs to, and a structural summary of what changed. The actual file contents at + /// that moment live in a separate ``Snapshot`` artifact, which can be loaded on + /// demand via ``History/snapshot(for:)``. + /// + /// The split mirrors the relationship between a git commit (this type) and a git + /// tree (``Snapshot``): commits are cheap to enumerate, trees are loaded only when + /// you actually need to inspect or restore the file contents. public struct Checkpoint: Sendable, Codable, Equatable { /// The checkpoint scope. public enum Scope: String, Sendable, Codable { @@ -19,13 +28,18 @@ extension History { public var touchedPaths: [WorkspacePath] /// Whether any changed file paths involve UTF-8 decodable text. public var hasTextDiffs: Bool + + /// Creates a checkpoint summary. + public init(changeCount: Int, touchedPaths: [WorkspacePath], hasTextDiffs: Bool) { + self.changeCount = changeCount + self.touchedPaths = touchedPaths + self.hasTextDiffs = hasTextDiffs + } } /// The checkpoint identifier. public var id: UUID - /// The managed workspace identifier. - var workspaceId: UUID - /// The session that created or owns the checkpoint when available. + /// The session that created or owns the checkpoint when applicable. public var sessionId: UUID? /// Whether the checkpoint belongs to a session overlay or the shared head. public var scope: Scope @@ -35,14 +49,19 @@ extension History { public var createdAt: Date /// A summary of what changed relative to the parent checkpoint. public var summary: Summary + /// The previous checkpoint in the same scope/session, when present. + public var parentCheckpointId: UUID? + /// The shared checkpoint this checkpoint is based on (the head at session creation). + public var baseSharedCheckpointId: UUID? + /// The session that originated this checkpoint when it represents a publish. + public var originSessionId: UUID? + /// The source checkpoint a rollback restored from when applicable. + public var rollbackSourceCheckpointId: UUID? - var parentCheckpointId: UUID? - var baseSharedCheckpointId: UUID? + var workspaceId: UUID var firstMutationSequence: Int? var lastMutationSequence: Int? var mutationCursor: Int - var originSessionId: UUID? - var rollbackSourceCheckpointId: UUID? var snapshotId: UUID var inferredEventKind: CheckpointEvent.Kind { @@ -135,7 +154,7 @@ struct MutationRecord: Sendable, Codable, Equatable { } } -/// A checkpoint event emitted by `History`. +/// A checkpoint event emitted by ``History``. public struct CheckpointEvent: Sendable, Codable, Equatable { /// The event kind. public enum Kind: String, Sendable, Codable { @@ -164,7 +183,7 @@ public struct CheckpointEvent: Sendable, Codable, Equatable { } } -/// Errors produced by `History` operations. +/// Errors produced by ``History`` operations. public enum HistoryError: Error, CustomStringConvertible, Sendable { case sessionNotFound(UUID) case checkpointNotFound(UUID) diff --git a/Sources/Workspace/Snapshot.swift b/Sources/Workspace/Snapshot.swift index 0e08c41..95fc81b 100644 --- a/Sources/Workspace/Snapshot.swift +++ b/Sources/Workspace/Snapshot.swift @@ -1,63 +1,108 @@ import Foundation -/// A durable recursive snapshot of a workspace subtree. -struct Snapshot: Sendable, Codable, Equatable { - indirect enum Entry: Sendable, Codable, Equatable { +/// A durable, in-memory snapshot of a workspace subtree. +/// +/// A snapshot captures the file contents, symbolic links, directory structure, and +/// POSIX permissions of an entire tree at a single point in time. It is `Codable` +/// so it can be persisted, transmitted, or compared to other snapshots. +/// +/// Capture and restore snapshots through ``Workspace/captureSnapshot(at:)`` and +/// ``Workspace/restoreSnapshot(_:)``. To inspect the tree captured by a checkpoint, +/// use ``History/snapshot(for:)``. +public struct Snapshot: Sendable, Codable, Equatable { + /// A node within a snapshot tree. + public indirect enum Entry: Sendable, Codable, Equatable { + /// A path that did not exist when the snapshot was captured. case missing(Missing) + /// A regular file with captured contents and permissions. case file(File) + /// A directory with captured permissions and recursively captured children. case directory(Directory) + /// A symbolic link pointing at `target`. case symlink(Symlink) + + /// The workspace path of the entry. + public var path: WorkspacePath { + switch self { + case let .missing(entry): entry.path + case let .file(entry): entry.path + case let .directory(entry): entry.path + case let .symlink(entry): entry.path + } + } } - struct Missing: Sendable, Codable, Equatable { - var path: WorkspacePath + /// A snapshot entry representing a path that does not exist. + public struct Missing: Sendable, Codable, Equatable { + /// The captured path. + public var path: WorkspacePath - init(path: WorkspacePath) { + /// Creates a missing entry. + public init(path: WorkspacePath) { self.path = path } } - struct File: Sendable, Codable, Equatable { - var path: WorkspacePath - var data: Data - var permissions: POSIXPermissions - - init(path: WorkspacePath, data: Data, permissions: POSIXPermissions) { + /// A snapshot entry representing a regular file. + public struct File: Sendable, Codable, Equatable { + /// The captured path. + public var path: WorkspacePath + /// The file's contents at the time of capture. + public var data: Data + /// The file's POSIX permissions at the time of capture. + public var permissions: POSIXPermissions + + /// Creates a file entry. + public init(path: WorkspacePath, data: Data, permissions: POSIXPermissions) { self.path = path self.data = data self.permissions = permissions } } - struct Directory: Sendable, Codable, Equatable { - var path: WorkspacePath - var permissions: POSIXPermissions - var children: [Entry] - - init(path: WorkspacePath, permissions: POSIXPermissions, children: [Entry]) { + /// A snapshot entry representing a directory. + public struct Directory: Sendable, Codable, Equatable { + /// The captured path. + public var path: WorkspacePath + /// The directory's POSIX permissions at the time of capture. + public var permissions: POSIXPermissions + /// The directory's children, sorted by name. + public var children: [Entry] + + /// Creates a directory entry. + public init(path: WorkspacePath, permissions: POSIXPermissions, children: [Entry]) { self.path = path self.permissions = permissions self.children = children } } - struct Symlink: Sendable, Codable, Equatable { - var path: WorkspacePath - var target: String - var permissions: POSIXPermissions - - init(path: WorkspacePath, target: String, permissions: POSIXPermissions) { + /// A snapshot entry representing a symbolic link. + public struct Symlink: Sendable, Codable, Equatable { + /// The captured path. + public var path: WorkspacePath + /// The symbolic link's target string. + public var target: String + /// The symbolic link's POSIX permissions at the time of capture. + public var permissions: POSIXPermissions + + /// Creates a symlink entry. + public init(path: WorkspacePath, target: String, permissions: POSIXPermissions) { self.path = path self.target = target self.permissions = permissions } } - var id: UUID - var rootPath: WorkspacePath - var entry: Entry + /// A stable identifier for the snapshot. + public var id: UUID + /// The path that was used as the snapshot root. + public var rootPath: WorkspacePath + /// The captured root entry. + public var entry: Entry - init(id: UUID = UUID(), rootPath: WorkspacePath, entry: Entry) { + /// Creates a snapshot from a previously captured tree. + public init(id: UUID = UUID(), rootPath: WorkspacePath, entry: Entry) { self.id = id self.rootPath = rootPath self.entry = entry @@ -67,12 +112,19 @@ struct Snapshot: Sendable, Codable, Equatable { // MARK: - Summary extension Snapshot { - static func summary(comparing current: Snapshot, to previous: Snapshot?) -> History.Checkpoint.Summary { - let currentNodes = flattenNodes(current.entry) - let previousNodes = previous.map { flattenNodes($0.entry) } ?? [:] + /// Returns a structural summary of the changes between this snapshot and `other`. + /// + /// Pass `nil` to summarize this snapshot as if every entry were newly created. + /// + /// - Parameter other: The snapshot to compare against, typically a parent checkpoint. + /// - Returns: A summary describing how many paths changed and whether any of those + /// paths involve UTF-8 decodable text (and therefore have a meaningful textual diff). + public func summary(comparedTo other: Snapshot?) -> History.Checkpoint.Summary { + let currentNodes = Self.flattenNodes(entry, rootPath: rootPath) + let previousNodes = other.map { Self.flattenNodes($0.entry, rootPath: $0.rootPath) } ?? [:] let allPaths = Set(currentNodes.keys).union(previousNodes.keys).sorted() let changedPaths = allPaths.filter { currentNodes[$0] != previousNodes[$0] } - let hasTextDiffs = changedPaths.contains { hasTextualChange(old: previousNodes[$0], new: currentNodes[$0]) } + let hasTextDiffs = changedPaths.contains { Self.hasTextualChange(old: previousNodes[$0], new: currentNodes[$0]) } return History.Checkpoint.Summary( changeCount: changedPaths.count, @@ -88,10 +140,10 @@ extension Snapshot { var symlinkTarget: String? } - private static func flattenNodes(_ entry: Entry) -> [WorkspacePath: FlatNode] { + private static func flattenNodes(_ entry: Entry, rootPath: WorkspacePath) -> [WorkspacePath: FlatNode] { var nodes: [WorkspacePath: FlatNode] = [:] collectNodes(entry, into: &nodes) - nodes.removeValue(forKey: .root) + nodes.removeValue(forKey: rootPath) return nodes } @@ -154,19 +206,6 @@ extension Snapshot { try await restoreEntry(snapshot.entry, to: target) } - static func path(of entry: Entry) -> WorkspacePath { - switch entry { - case let .missing(entry): - entry.path - case let .file(entry): - entry.path - case let .directory(entry): - entry.path - case let .symlink(entry): - entry.path - } - } - private static func captureEntry(from target: any FileSystem, at path: WorkspacePath) async throws -> Entry { guard await target.exists(path: path) else { return .missing(.init(path: path)) @@ -281,14 +320,14 @@ extension Snapshot { under parentPath: WorkspacePath, on target: any FileSystem ) async throws { - let expectedNames = Set(expectedChildren.map { path(of: $0).basename }) + let expectedNames = Set(expectedChildren.map { $0.path.basename }) let existingEntries = (try? await target.listDirectory(path: parentPath)) ?? [] for entry in existingEntries where !expectedNames.contains(entry.name) { try await target.remove(path: parentPath.appending(entry.name), recursive: true) } - for child in expectedChildren.sorted(by: { path(of: $0) < path(of: $1) }) { + for child in expectedChildren.sorted(by: { $0.path < $1.path }) { try await restoreEntry(child, to: target) } } diff --git a/Sources/Workspace/Workspace.swift b/Sources/Workspace/Workspace.swift index 0413764..471ccd2 100644 --- a/Sources/Workspace/Workspace.swift +++ b/Sources/Workspace/Workspace.swift @@ -181,11 +181,19 @@ public actor Workspace { try await buildSummary(path: path, depth: 0, maxDepth: maxDepth) } - func captureSnapshot(at path: WorkspacePath = .root) async throws -> Snapshot { + /// Captures a durable snapshot of the subtree rooted at `path`. + /// + /// The returned ``Snapshot`` records file contents, symlinks, directory structure, + /// and POSIX permissions, and can later be replayed with ``restoreSnapshot(_:)``. + /// + /// - Parameter path: The subtree to capture. Defaults to the workspace root. + public func captureSnapshot(at path: WorkspacePath = .root) async throws -> Snapshot { try await Snapshot.capture(from: filesystem, at: path) } - func restoreSnapshot(_ snapshot: Snapshot) async throws { + /// Restores `snapshot` onto the workspace, removing any extra entries beneath the + /// snapshot's root so that the workspace exactly matches the captured tree. + public func restoreSnapshot(_ snapshot: Snapshot) async throws { try await Snapshot.restore(snapshot, to: filesystem) } diff --git a/Tests/WorkspaceTests/HistoryTests.swift b/Tests/WorkspaceTests/HistoryTests.swift index ec74db3..0c7f13f 100644 --- a/Tests/WorkspaceTests/HistoryTests.swift +++ b/Tests/WorkspaceTests/HistoryTests.swift @@ -60,7 +60,7 @@ struct HistoryTests { try await session.createDirectory(at: "/docs") let checkpoint = try await session.createCheckpoint(label: "draft") - let mutations = try await session.listMutationRecords() + let mutations = try await session.mutationRecords() let storeCheckpoints = try await history.listCheckpoints(scope: .session, sessionId: session.id) #expect(checkpoint.scope == .session) @@ -110,7 +110,7 @@ struct HistoryTests { let task = startCheckpointRecording(stream, into: recorder) defer { task.cancel() } - let rollbackCheckpoint = try await history.rollbackShared(to: checkpoint.id, label: "revert") + let rollbackCheckpoint = try await history.rollback(to: checkpoint.id, label: "revert") let events = try await waitForCheckpointEvents(1, recorder: recorder) #expect(try await history.shared.readFile("/shared.txt") == "one") @@ -133,7 +133,7 @@ struct HistoryTests { let checkpointA = try await sessionA.createCheckpoint(label: "a1") let checkpointB = try await sessionB.createCheckpoint(label: "b1") - let published = try await sessionA.publishHead(label: "publish a") + let published = try await sessionA.publish(label: "publish a") #expect(checkpointA.scope == .session) #expect(checkpointB.scope == .session) @@ -147,7 +147,7 @@ struct HistoryTests { #expect(try await sessionA.baseSharedCheckpointId() == published.id) do { - _ = try await sessionB.publishHead(label: "publish b") + _ = try await sessionB.publish(label: "publish b") Issue.record("expected publish conflict") } catch let error as HistoryError { switch error { @@ -195,17 +195,17 @@ struct HistoryTests { let history = History( workspaceId: workspaceId, filesystem: InMemoryFilesystem(), - storage: .directory(root) + storage: .directory(at: root) ) let session = try await history.createSession() try await session.writeFile("/note.txt", content: "hello") let checkpoint = try await session.createCheckpoint(label: "draft") - let mutation = try #require((try await session.listMutationRecords()).first) + let mutation = try #require((try await session.mutationRecords()).first) let reloaded = History( workspaceId: workspaceId, filesystem: InMemoryFilesystem(), - storage: .directory(root) + storage: .directory(at: root) ) let encoder = JSONEncoder() @@ -222,7 +222,7 @@ struct HistoryTests { #expect(decodedMutation == mutation) #expect(decodedEvent == CheckpointEvent(kind: .created, checkpoint: checkpoint)) #expect(try await reloaded.checkpoint(id: checkpoint.id) == checkpoint) - #expect(try await reloaded.listMutationRecords() == [mutation]) + #expect(try await reloaded.mutationRecords() == [mutation]) } @Test @@ -234,7 +234,7 @@ struct HistoryTests { let history = History( workspaceId: workspaceId, filesystem: InMemoryFilesystem(), - storage: .directory(root) + storage: .directory(at: root) ) try await history.writeFile("/note.txt", content: "checkpoint") @@ -244,17 +244,17 @@ struct HistoryTests { let reloaded = History( workspaceId: workspaceId, filesystem: InMemoryFilesystem(), - storage: .directory(root) + storage: .directory(at: root) ) let persistedCheckpoint = try #require(try await reloaded.checkpoint(id: checkpoint.id)) - let snapshot = try await reloaded.loadSnapshot(for: persistedCheckpoint) + let snapshot = try await reloaded.snapshot(for: persistedCheckpoint) let restored = InMemoryFilesystem() try await Snapshot.restore(snapshot, to: restored) #expect(try await history.shared.readFile("/note.txt") == "current") #expect(try await restored.readFile(path: "/note.txt") == Data("checkpoint".utf8)) - #expect(Snapshot.summary(comparing: snapshot, to: nil) == checkpoint.summary) + #expect(snapshot.summary(comparedTo: nil) == checkpoint.summary) } @Test @@ -281,6 +281,108 @@ struct HistoryTests { #expect(checkpoint.sessionId == session.id) } + @Test + func `convenience History init defaults to in-memory shared workspace`() async throws { + let history = History() + + try await history.writeFile("/note.txt", content: "hello") + let checkpoint = try await history.createCheckpoint(label: "initial") + + #expect(checkpoint.scope == .shared) + #expect(try await history.shared.readFile("/note.txt") == "hello") + #expect(try await history.mutationRecords(scope: .shared).map(\.kind) == [.writeFile]) + } + + @Test + func `mutationRecords filters by shared scope and snapshot(for:) loads checkpoint contents`() async throws { + let history = History() + let session = try await history.createSession() + try await session.writeFile("/session.txt", content: "session") + + try await history.writeFile("/shared.txt", content: "shared-1") + let checkpoint = try await history.createCheckpoint(label: "shared") + try await history.writeFile("/shared.txt", content: "shared-2") + + let sharedMutations = try await history.mutationRecords(scope: .shared) + let sessionMutations = try await history.mutationRecords(scope: .session, sessionId: session.id) + let snapshot = try await history.snapshot(for: checkpoint) + + #expect(sharedMutations.map(\.kind) == [.writeFile, .writeFile]) + #expect(sharedMutations.allSatisfy { $0.scope == .shared }) + #expect(sessionMutations.map(\.kind) == [.writeFile]) + #expect(sessionMutations.allSatisfy { $0.sessionId == session.id }) + #expect(snapshot.id == checkpoint.snapshotId) + + let restored = Workspace(filesystem: InMemoryFilesystem()) + try await restored.restoreSnapshot(snapshot) + #expect(try await restored.readFile("/shared.txt") == "shared-1") + #expect(!(await restored.exists("/session.txt"))) + } + + @Test + func `rollback throws checkpointNotFound and rejects session checkpoints from shared scope`() async throws { + let history = History() + try await history.writeFile("/shared.txt", content: "v1") + let sharedCheckpoint = try await history.createCheckpoint(label: "shared-v1") + + let session = try await history.createSession() + try await session.writeFile("/note.txt", content: "draft") + let sessionCheckpoint = try await session.createCheckpoint(label: "session-v1") + + let bogusId = UUID() + do { + _ = try await history.rollback(to: bogusId) + Issue.record("expected checkpointNotFound") + } catch let error as HistoryError { + guard case let .checkpointNotFound(id) = error else { + Issue.record("unexpected error: \(error)") + return + } + #expect(id == bogusId) + } + + do { + _ = try await history.rollback(to: sessionCheckpoint.id) + Issue.record("expected checkpointScopeMismatch") + } catch let error as HistoryError { + guard case let .checkpointScopeMismatch(expected, actual) = error else { + Issue.record("unexpected error: \(error)") + return + } + #expect(expected == .shared) + #expect(actual == .session) + } + + let rolled = try await history.rollback(to: sharedCheckpoint.id) + #expect(rolled.rollbackSourceCheckpointId == sharedCheckpoint.id) + } + + @Test + func `Storage directory(at:) round-trips checkpoints across History instances`() async throws { + let root = try makeTempDirectory() + defer { removeTempDirectory(root) } + + let workspaceId = UUID() + let history = History( + workspaceId: workspaceId, + filesystem: InMemoryFilesystem(), + storage: .directory(at: root) + ) + try await history.writeFile("/state.json", content: "{}") + let checkpoint = try await history.createCheckpoint(label: "checkpoint-a") + + let reopened = History( + workspaceId: workspaceId, + filesystem: InMemoryFilesystem(), + storage: .directory(at: root) + ) + let reloaded = try #require(try await reopened.checkpoint(id: checkpoint.id)) + let snapshot = try await reopened.snapshot(for: reloaded) + + #expect(reloaded == checkpoint) + #expect(snapshot.entry.path == .root) + } + // MARK: - Helpers private func makeTempDirectory() throws -> URL { diff --git a/Tests/WorkspaceTests/SnapshotTests.swift b/Tests/WorkspaceTests/SnapshotTests.swift index c9db883..8331c64 100644 --- a/Tests/WorkspaceTests/SnapshotTests.swift +++ b/Tests/WorkspaceTests/SnapshotTests.swift @@ -41,7 +41,7 @@ struct SnapshotTests { func `empty root and missing subtree snapshots restore expected absence`() async throws { let source = InMemoryFilesystem() let emptyRoot = try await Snapshot.capture(from: source) - let emptySummary = Snapshot.summary(comparing: emptyRoot, to: nil) + let emptySummary = emptyRoot.summary(comparedTo: nil) let target = InMemoryFilesystem() try await target.writeFile(path: "/stale.txt", data: Data("stale".utf8), append: false) @@ -75,13 +75,13 @@ struct SnapshotTests { let baseSnapshot = try await Snapshot.capture(from: base) let unchanged = try await Snapshot.capture(from: try await summaryFilesystem(text: "old", binary: Data([0xFF, 0xFE]))) - let unchangedSummary = Snapshot.summary(comparing: unchanged, to: baseSnapshot) + let unchangedSummary = unchanged.summary(comparedTo: baseSnapshot) let textChanged = try await Snapshot.capture(from: try await summaryFilesystem(text: "new", binary: Data([0xFF, 0xFE]))) - let textSummary = Snapshot.summary(comparing: textChanged, to: baseSnapshot) + let textSummary = textChanged.summary(comparedTo: baseSnapshot) let binaryChanged = try await Snapshot.capture(from: try await summaryFilesystem(text: "old", binary: Data([0x00, 0xFF]))) - let binarySummary = Snapshot.summary(comparing: binaryChanged, to: baseSnapshot) + let binarySummary = binaryChanged.summary(comparedTo: baseSnapshot) #expect(unchangedSummary.changeCount == 0) #expect(unchangedSummary.touchedPaths.isEmpty) @@ -96,6 +96,75 @@ struct SnapshotTests { #expect(binarySummary.hasTextDiffs == false) } + @Test + func `entry path accessor reflects each captured node kind`() async throws { + let filesystem = InMemoryFilesystem() + try await filesystem.createDirectory(path: "/dir", recursive: true) + try await filesystem.writeFile(path: "/dir/note.txt", data: Data("hi".utf8), append: false) + try await filesystem.createSymlink(path: "/dir/link", target: "/dir/note.txt") + + let snapshot = try await Snapshot.capture(from: filesystem) + guard case let .directory(rootDir) = snapshot.entry else { + Issue.record("expected directory root entry") + return + } + guard case let .directory(dir) = rootDir.children.first(where: { $0.path == "/dir" }) ?? .missing(.init(path: .root)) else { + Issue.record("expected /dir directory entry") + return + } + + #expect(snapshot.entry.path == .root) + #expect(rootDir.path == .root) + #expect(dir.children.map(\.path).sorted() == ["/dir/link", "/dir/note.txt"]) + + let missing = try await Snapshot.capture(from: filesystem, at: "/missing") + #expect(missing.entry.path == "/missing") + } + + @Test + func `Workspace capture and restore round trips through the public API`() async throws { + let workspace = Workspace(filesystem: InMemoryFilesystem()) + try await workspace.createDirectory(at: "/notes") + try await workspace.writeFile("/notes/a.txt", content: "alpha") + try await workspace.writeFile("/notes/b.txt", content: "beta") + try await workspace.createDirectory(at: "/empty") + + let snapshot = try await workspace.captureSnapshot() + + try await workspace.writeFile("/notes/a.txt", content: "alpha-updated") + try await workspace.removeItem(at: "/notes/b.txt") + try await workspace.writeFile("/notes/c.txt", content: "gamma") + + try await workspace.restoreSnapshot(snapshot) + + #expect(try await workspace.readFile("/notes/a.txt") == "alpha") + #expect(try await workspace.readFile("/notes/b.txt") == "beta") + #expect(!(await workspace.exists("/notes/c.txt"))) + #expect(await workspace.exists("/empty")) + } + + @Test + func `subtree snapshot summary excludes the captured root and Codable round-trips`() async throws { + let filesystem = InMemoryFilesystem() + try await filesystem.createDirectory(path: "/proj/src", recursive: true) + try await filesystem.writeFile(path: "/proj/src/main.swift", data: Data("print(\"hi\")".utf8), append: false) + try await filesystem.writeFile(path: "/proj/README.md", data: Data("docs".utf8), append: false) + + let baseSubtree = try await Snapshot.capture(from: filesystem, at: "/proj") + try await filesystem.writeFile(path: "/proj/src/main.swift", data: Data("print(\"updated\")".utf8), append: false) + let updatedSubtree = try await Snapshot.capture(from: filesystem, at: "/proj") + let summary = updatedSubtree.summary(comparedTo: baseSubtree) + + let encoded = try JSONEncoder().encode(updatedSubtree) + let decoded = try JSONDecoder().decode(Snapshot.self, from: encoded) + + #expect(summary.changeCount == 1) + #expect(summary.touchedPaths == [WorkspacePath("/proj/src/main.swift")]) + #expect(summary.hasTextDiffs == true) + #expect(decoded == updatedSubtree) + #expect(decoded.rootPath == "/proj") + } + private func summaryFilesystem(text: String, binary: Data) async throws -> InMemoryFilesystem { let filesystem = InMemoryFilesystem() try await filesystem.writeFile(path: "/text.txt", data: Data(text.utf8), append: false) From 60c9266a811fda5355824957a71da9d731aa520f Mon Sep 17 00:00:00 2001 From: Zac White Date: Sat, 18 Apr 2026 20:52:38 -0700 Subject: [PATCH 06/14] More tests --- .../WorkspaceTests/CheckpointStoreTests.swift | 335 ++++++++++++ Tests/WorkspaceTests/FilesystemTests.swift | 61 +++ Tests/WorkspaceTests/HistoryTests.swift | 486 ++++++++++++++++++ Tests/WorkspaceTests/HistoryTypesTests.swift | 165 ++++++ Tests/WorkspaceTests/MountingTests.swift | 63 +++ 5 files changed, 1110 insertions(+) create mode 100644 Tests/WorkspaceTests/CheckpointStoreTests.swift create mode 100644 Tests/WorkspaceTests/HistoryTypesTests.swift diff --git a/Tests/WorkspaceTests/CheckpointStoreTests.swift b/Tests/WorkspaceTests/CheckpointStoreTests.swift new file mode 100644 index 0000000..987bca9 --- /dev/null +++ b/Tests/WorkspaceTests/CheckpointStoreTests.swift @@ -0,0 +1,335 @@ +import Foundation +import Testing +@testable import Workspace + +@Suite("CheckpointStore") +struct CheckpointStoreTests { + @Test + func `InMemoryCheckpointStore persists checkpoints snapshots and mutations per workspace`() async throws { + let store = InMemoryCheckpointStore() + let workspaceA = UUID() + let workspaceB = UUID() + let snapshotId = UUID() + let summary = History.Checkpoint.Summary(changeCount: 0, touchedPaths: [], hasTextDiffs: false) + + let snapshot = Snapshot( + id: snapshotId, + rootPath: .root, + entry: .directory( + Snapshot.Directory(path: .root, permissions: .defaultDirectory, children: []) + ) + ) + + let cpEarly = History.Checkpoint( + id: UUID(), + workspaceId: workspaceA, + sessionId: nil, + scope: .shared, + label: "a", + createdAt: Date(timeIntervalSince1970: 100), + parentCheckpointId: nil, + baseSharedCheckpointId: nil, + firstMutationSequence: nil, + lastMutationSequence: nil, + mutationCursor: 0, + snapshotId: snapshotId, + summary: summary + ) + + let cpLate = History.Checkpoint( + id: UUID(), + workspaceId: workspaceA, + sessionId: nil, + scope: .shared, + label: "b", + createdAt: Date(timeIntervalSince1970: 200), + parentCheckpointId: cpEarly.id, + baseSharedCheckpointId: nil, + firstMutationSequence: 1, + lastMutationSequence: 1, + mutationCursor: 1, + snapshotId: snapshotId, + summary: summary + ) + + try await store.saveSnapshot(snapshot, workspaceId: workspaceA) + try await store.saveCheckpoint(cpLate) + try await store.saveCheckpoint(cpEarly) + + let listed = try await store.listCheckpoints(workspaceId: workspaceA) + #expect(listed.map(\.id) == [cpEarly.id, cpLate.id]) + + let loadedEarly = try await store.loadCheckpoint(id: cpEarly.id, workspaceId: workspaceA) + #expect(loadedEarly == cpEarly) + + let loadedMissing = try await store.loadCheckpoint(id: UUID(), workspaceId: workspaceA) + #expect(loadedMissing == nil) + + #expect(try await store.listCheckpoints(workspaceId: workspaceB).isEmpty) + + let m3 = MutationRecord( + sequence: 3, + workspaceId: workspaceA, + sessionId: nil, + scope: .shared, + kind: .writeFile, + touchedPaths: ["/x"], + fileChanges: [] + ) + let m1 = MutationRecord( + sequence: 1, + workspaceId: workspaceA, + sessionId: nil, + scope: .shared, + kind: .writeFile, + touchedPaths: ["/y"], + fileChanges: [] + ) + try await store.appendMutation(m3) + try await store.appendMutation(m1) + + let mutations = try await store.listMutationRecords(workspaceId: workspaceA) + #expect(mutations.map(\.sequence) == [1, 3]) + + let reloadedSnapshot = try await store.loadSnapshot(id: snapshotId, workspaceId: workspaceA) + #expect(reloadedSnapshot == snapshot) + #expect(try await store.loadSnapshot(id: UUID(), workspaceId: workspaceA) == nil) + } + + @Test + func `FileCheckpointStore roundtrips data on disk across actor instances`() async throws { + let root = try makeTempDirectory() + defer { removeTempDirectory(root) } + + let workspaceId = UUID() + let snapshotId = UUID() + let summary = History.Checkpoint.Summary(changeCount: 1, touchedPaths: ["/f"], hasTextDiffs: true) + + let snapshot = Snapshot( + id: snapshotId, + rootPath: .root, + entry: .file( + Snapshot.File(path: "/f", data: Data("hi".utf8), permissions: .defaultFile) + ) + ) + + let checkpoint = History.Checkpoint( + workspaceId: workspaceId, + sessionId: nil, + scope: .shared, + label: "disk", + parentCheckpointId: nil, + baseSharedCheckpointId: nil, + firstMutationSequence: 1, + lastMutationSequence: 2, + mutationCursor: 2, + snapshotId: snapshotId, + summary: summary + ) + + let mutation = MutationRecord( + sequence: 1, + workspaceId: workspaceId, + sessionId: nil, + scope: .shared, + kind: .writeFile, + touchedPaths: ["/f"], + fileChanges: [] + ) + + let writer = FileCheckpointStore(rootDirectory: root) + try await writer.saveSnapshot(snapshot, workspaceId: workspaceId) + try await writer.saveCheckpoint(checkpoint) + try await writer.appendMutation(mutation) + + let reader = FileCheckpointStore(rootDirectory: root) + let checkpoints = try await reader.listCheckpoints(workspaceId: workspaceId) + #expect(checkpoints == [checkpoint]) + + let loaded = try await reader.loadCheckpoint(id: checkpoint.id, workspaceId: workspaceId) + #expect(loaded == checkpoint) + + let loadedSnap = try await reader.loadSnapshot(id: snapshotId, workspaceId: workspaceId) + #expect(loadedSnap == snapshot) + + let mutations = try await reader.listMutationRecords(workspaceId: workspaceId) + #expect(mutations == [mutation]) + + #expect(try await reader.listCheckpoints(workspaceId: UUID()).isEmpty) + #expect(try await reader.listMutationRecords(workspaceId: UUID()).isEmpty) + } + + @Test + func `InMemoryCheckpointStore saveSnapshot replaces an existing snapshot id`() async throws { + let store = InMemoryCheckpointStore() + let workspaceId = UUID() + let snapshotId = UUID() + + let first = Snapshot( + id: snapshotId, + rootPath: .root, + entry: .file(Snapshot.File(path: "/a", data: Data("1".utf8), permissions: .defaultFile)) + ) + let second = Snapshot( + id: snapshotId, + rootPath: .root, + entry: .file(Snapshot.File(path: "/a", data: Data("2".utf8), permissions: .defaultFile)) + ) + + try await store.saveSnapshot(first, workspaceId: workspaceId) + try await store.saveSnapshot(second, workspaceId: workspaceId) + + let loaded = try await store.loadSnapshot(id: snapshotId, workspaceId: workspaceId) + #expect(loaded == second) + } + + @Test + func `InMemoryCheckpointStore loadCheckpoint returns the first saved match when ids collide`() async throws { + let store = InMemoryCheckpointStore() + let workspaceId = UUID() + let sharedId = UUID() + let snapshotId = UUID() + let summary = History.Checkpoint.Summary(changeCount: 0, touchedPaths: [], hasTextDiffs: false) + + let first = History.Checkpoint( + id: sharedId, + workspaceId: workspaceId, + sessionId: nil, + scope: .shared, + label: "first", + createdAt: Date(timeIntervalSince1970: 50), + parentCheckpointId: nil, + baseSharedCheckpointId: nil, + firstMutationSequence: nil, + lastMutationSequence: nil, + mutationCursor: 0, + snapshotId: snapshotId, + summary: summary + ) + let second = History.Checkpoint( + id: sharedId, + workspaceId: workspaceId, + sessionId: nil, + scope: .shared, + label: "second", + createdAt: Date(timeIntervalSince1970: 150), + parentCheckpointId: nil, + baseSharedCheckpointId: nil, + firstMutationSequence: nil, + lastMutationSequence: nil, + mutationCursor: 0, + snapshotId: snapshotId, + summary: summary + ) + + try await store.saveCheckpoint(first) + try await store.saveCheckpoint(second) + + let loaded = try await store.loadCheckpoint(id: sharedId, workspaceId: workspaceId) + #expect(loaded == first) + + let listed = try await store.listCheckpoints(workspaceId: workspaceId) + #expect(listed.count == 2) + } + + @Test + func `FileCheckpointStore appendMutation merges and sorts existing log on disk`() async throws { + let root = try makeTempDirectory() + defer { removeTempDirectory(root) } + + let workspaceId = UUID() + let storeA = FileCheckpointStore(rootDirectory: root) + + let m2 = MutationRecord( + sequence: 2, + workspaceId: workspaceId, + sessionId: nil, + scope: .shared, + kind: .writeFile, + touchedPaths: ["/b"], + fileChanges: [] + ) + let m1 = MutationRecord( + sequence: 1, + workspaceId: workspaceId, + sessionId: nil, + scope: .shared, + kind: .writeFile, + touchedPaths: ["/a"], + fileChanges: [] + ) + + try await storeA.appendMutation(m2) + try await storeA.appendMutation(m1) + + let storeB = FileCheckpointStore(rootDirectory: root) + let merged = try await storeB.listMutationRecords(workspaceId: workspaceId) + #expect(merged.map(\.sequence) == [1, 2]) + #expect(merged.map(\.touchedPaths) == [["/a"], ["/b"]]) + + let m3 = MutationRecord( + sequence: 3, + workspaceId: workspaceId, + sessionId: nil, + scope: .shared, + kind: .appendFile, + touchedPaths: ["/c"], + fileChanges: [] + ) + try await storeB.appendMutation(m3) + + let storeC = FileCheckpointStore(rootDirectory: root) + #expect(try await storeC.listMutationRecords(workspaceId: workspaceId).map(\.sequence) == [1, 2, 3]) + } + + @Test + func `FileCheckpointStore saveCheckpoint overwrites the same id file`() async throws { + let root = try makeTempDirectory() + defer { removeTempDirectory(root) } + + let workspaceId = UUID() + let checkpointId = UUID() + let snapshotId = UUID() + let summary = History.Checkpoint.Summary(changeCount: 0, touchedPaths: [], hasTextDiffs: false) + + let first = History.Checkpoint( + id: checkpointId, + workspaceId: workspaceId, + sessionId: nil, + scope: .shared, + label: "v1", + parentCheckpointId: nil, + baseSharedCheckpointId: nil, + firstMutationSequence: nil, + lastMutationSequence: nil, + mutationCursor: 0, + snapshotId: snapshotId, + summary: summary + ) + var second = first + second.label = "v2" + + let writer = FileCheckpointStore(rootDirectory: root) + try await writer.saveCheckpoint(first) + try await writer.saveCheckpoint(second) + + let reader = FileCheckpointStore(rootDirectory: root) + let loaded = try await reader.loadCheckpoint(id: checkpointId, workspaceId: workspaceId) + #expect(loaded?.label == "v2") + + let listed = try await reader.listCheckpoints(workspaceId: workspaceId) + #expect(listed.count == 1) + #expect(listed[0].label == "v2") + } + + private func makeTempDirectory() throws -> URL { + let base = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true) + let url = base.appendingPathComponent("CheckpointStoreTests-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true) + return url + } + + private func removeTempDirectory(_ url: URL) { + try? FileManager.default.removeItem(at: url) + } +} diff --git a/Tests/WorkspaceTests/FilesystemTests.swift b/Tests/WorkspaceTests/FilesystemTests.swift index c56c969..2fd2c1d 100644 --- a/Tests/WorkspaceTests/FilesystemTests.swift +++ b/Tests/WorkspaceTests/FilesystemTests.swift @@ -317,6 +317,67 @@ struct FilesystemTests { } } + @Test(.tags(.inMemory)) + func `in-memory filesystem detects symlink cycles when resolving paths`() async throws { + let filesystem = InMemoryFilesystem() + try await filesystem.createSymlink(path: "/loop", target: "/loop") + + do { + _ = try await filesystem.readFile(path: "/loop") + Issue.record("expected ELOOP") + } catch let error as NSError { + #expect(error.domain == NSPOSIXErrorDomain) + #expect(error.code == Int(ELOOP)) + } + } + + @Test(.tags(.inMemory)) + func `in-memory filesystem move no-op when source and destination are identical`() async throws { + let filesystem = InMemoryFilesystem() + try await filesystem.writeFile(path: "/a.txt", data: Data("x".utf8), append: false) + try await filesystem.move(from: "/a.txt", to: "/a.txt") + #expect(try await filesystem.readFile(path: "/a.txt") == Data("x".utf8)) + } + + @Test(.tags(.inMemory)) + func `in-memory filesystem remove is silent when the path is missing`() async throws { + let filesystem = InMemoryFilesystem() + try await filesystem.remove(path: "/missing.txt", recursive: false) + #expect(!(await filesystem.exists(path: "/missing.txt"))) + } + + @Test(.tags(.inMemory)) + func `in-memory filesystem glob returns matching paths sorted`() async throws { + let filesystem = InMemoryFilesystem() + try await filesystem.createDirectory(path: "/docs", recursive: true) + try await filesystem.writeFile(path: "/docs/a.txt", data: Data(), append: false) + try await filesystem.writeFile(path: "/docs/b.txt", data: Data(), append: false) + try await filesystem.writeFile(path: "/docs/readme.md", data: Data(), append: false) + + let matches = try await filesystem.glob(pattern: "/docs/*.txt", currentDirectory: "/") + #expect(matches == ["/docs/a.txt", "/docs/b.txt"]) + } + + @Test(.tags(.inMemory)) + func `in-memory filesystem setPermissions updates stat results`() async throws { + let filesystem = InMemoryFilesystem() + try await filesystem.writeFile(path: "/x.txt", data: Data("y".utf8), append: false) + + try await filesystem.setPermissions(path: "/x.txt", permissions: POSIXPermissions(0o600)) + let info = try await filesystem.stat(path: "/x.txt") + #expect(info.permissions == POSIXPermissions(0o600)) + } + + @Test(.tags(.inMemory)) + func `in-memory filesystem stat reports symlink kind for the link path`() async throws { + let filesystem = InMemoryFilesystem() + try await filesystem.writeFile(path: "/target.txt", data: Data("z".utf8), append: false) + try await filesystem.createSymlink(path: "/link.txt", target: "target.txt") + + let info = try await filesystem.stat(path: "/link.txt") + #expect(info.kind == .symlink) + } + @Test(.tags(.readWrite)) func `read-write filesystem supports file metadata links globbing and recursive copies`() async throws { let root = try FilesystemTestSupport.makeTempDirectory(prefix: "ReadWriteRoot") diff --git a/Tests/WorkspaceTests/HistoryTests.swift b/Tests/WorkspaceTests/HistoryTests.swift index 0c7f13f..83dc002 100644 --- a/Tests/WorkspaceTests/HistoryTests.swift +++ b/Tests/WorkspaceTests/HistoryTests.swift @@ -44,6 +44,47 @@ private func waitForCheckpointEvents( return await recorder.snapshot() } +/// Delegates to ``InMemoryCheckpointStore`` but can force ``loadSnapshot`` to return nil for specific ids (exercises ``HistoryError/snapshotNotFound``). +private actor FlakySnapshotCheckpointStore: CheckpointStore { + private let base = InMemoryCheckpointStore() + private var snapshotIdsReturningNil: Set = [] + + func breakLoadingSnapshot(id: UUID) { + snapshotIdsReturningNil.insert(id) + } + + func saveCheckpoint(_ checkpoint: History.Checkpoint) async throws { + try await base.saveCheckpoint(checkpoint) + } + + func loadCheckpoint(id: UUID, workspaceId: UUID) async throws -> History.Checkpoint? { + try await base.loadCheckpoint(id: id, workspaceId: workspaceId) + } + + func listCheckpoints(workspaceId: UUID) async throws -> [History.Checkpoint] { + try await base.listCheckpoints(workspaceId: workspaceId) + } + + func saveSnapshot(_ snapshot: Snapshot, workspaceId: UUID) async throws { + try await base.saveSnapshot(snapshot, workspaceId: workspaceId) + } + + func loadSnapshot(id: UUID, workspaceId: UUID) async throws -> Snapshot? { + if snapshotIdsReturningNil.contains(id) { + return nil + } + return try await base.loadSnapshot(id: id, workspaceId: workspaceId) + } + + func appendMutation(_ mutation: MutationRecord) async throws { + try await base.appendMutation(mutation) + } + + func listMutationRecords(workspaceId: UUID) async throws -> [MutationRecord] { + try await base.listMutationRecords(workspaceId: workspaceId) + } +} + @Suite("History") struct HistoryTests { @Test @@ -383,6 +424,451 @@ struct HistoryTests { #expect(snapshot.entry.path == .root) } + @Test + func `shared mutations record append writeData writeJSON and tree operations`() async throws { + let history = History(filesystem: InMemoryFilesystem()) + + try await history.writeFile("/base.txt", content: "x") + try await history.appendFile("/base.txt", content: "y") + try await history.writeData(Data([0, 1, 2]), to: "/bin.dat") + try await history.writeJSON(["a": 1], to: "/config.json", prettyPrinted: false) + try await history.createDirectory(at: "/nested/deep", recursive: true) + try await history.writeFile("/nested/deep/a.txt", content: "a") + try await history.copyItem(from: "/nested/deep/a.txt", to: "/nested/deep/b.txt") + try await history.moveItem(from: "/nested/deep/b.txt", to: "/moved.txt") + try await history.removeItem(at: "/nested", recursive: true) + + let kinds = try await history.mutationRecords(scope: .shared).map(\.kind) + #expect( + kinds == [ + .writeFile, + .appendFile, + .writeData, + .writeJSON, + .createDirectory, + .writeFile, + .copyItem, + .moveItem, + .removeItem, + ] + ) + #expect(try await history.shared.readFile("/base.txt") == "xy") + #expect(try await history.shared.readFile("/moved.txt") == "a") + } + + @Test + func `shared applyEdits and applyReplacement append mutation records`() async throws { + let history = History(filesystem: InMemoryFilesystem()) + + let batch = try await history.applyEdits( + [ + .writeFile(path: "/a.txt", content: "line\n"), + .writeFile(path: "/b.txt", content: "other\n"), + ] + ) + #expect(batch.edits.count == 2) + + try await history.writeFile("/replace.txt", content: "hello old world") + let replacement = try await history.applyReplacement( + ReplacementRequest(pattern: "*.txt", search: "old", replacement: "new") + ) + #expect(replacement.changes.count == 1) + + let mutations = try await history.mutationRecords(scope: .shared).map(\.kind) + #expect(mutations == [.applyEdits, .writeFile, .applyReplacement]) + #expect(try await history.shared.readFile("/replace.txt") == "hello new world") + } + + @Test + func `session rollback to shared checkpoint rewires base and rejects other session checkpoints`() async throws { + let history = History(filesystem: InMemoryFilesystem()) + try await history.writeFile("/shared.txt", content: "v1") + let sharedCp = try await history.createCheckpoint(label: "shared-head") + + let sessionA = try await history.createSession() + let sessionB = try await history.createSession() + try await sessionA.writeFile("/a.txt", content: "a") + try await sessionB.writeFile("/b.txt", content: "b") + let bCheckpoint = try await sessionB.createCheckpoint(label: "b-only") + + let rolled = try await sessionA.rollback(to: sharedCp.id, label: "to-shared") + #expect(try await sessionA.baseSharedCheckpointId() == sharedCp.id) + #expect(!(await sessionA.workspace.exists("/a.txt"))) + #expect(rolled.rollbackSourceCheckpointId == sharedCp.id) + + do { + _ = try await sessionA.rollback(to: bCheckpoint.id) + Issue.record("expected checkpointSessionMismatch") + } catch let error as HistoryError { + guard case let .checkpointSessionMismatch(checkpointId, expectedSessionId, actualSessionId) = error else { + Issue.record("unexpected error: \(error)") + return + } + #expect(checkpointId == bCheckpoint.id) + #expect(expectedSessionId == sessionA.id) + #expect(actualSessionId == sessionB.id) + } + } + + @Test + func `watchCheckpointEvents emits created for new shared checkpoints after subscribing`() async throws { + let history = History(filesystem: InMemoryFilesystem()) + + let recorder = CheckpointEventRecorder() + let stream = try await history.watchCheckpointEvents() + let task = startCheckpointRecording(stream, into: recorder) + defer { task.cancel() } + + try await history.writeFile("/tracked.txt", content: "v1") + let checkpoint = try await history.createCheckpoint(label: "snap") + + let events = try await waitForCheckpointEvents(1, recorder: recorder) + #expect(events.count == 1) + #expect(events[0].kind == .created) + #expect(events[0].checkpoint.id == checkpoint.id) + #expect(events[0].checkpoint.label == "snap") + } + + @Test + func `watchCheckpointEvents emits published when a session head is published`() async throws { + let history = History(filesystem: InMemoryFilesystem()) + let session = try await history.createSession() + try await session.writeFile("/pub.txt", content: "content") + + let recorder = CheckpointEventRecorder() + let stream = try await history.watchCheckpointEvents() + let task = startCheckpointRecording(stream, into: recorder) + defer { task.cancel() } + + let published = try await session.publish(label: "out") + + let events = try await waitForCheckpointEvents(1, recorder: recorder) + #expect(events.count == 1) + #expect(events[0].kind == .published) + #expect(events[0].checkpoint.id == published.id) + #expect(events[0].checkpoint.originSessionId == session.id) + } + + @Test + func `publishSessionHead throws sessionNotFound for an unknown session id`() async throws { + let history = History() + let unknown = UUID() + do { + _ = try await history.publishSessionHead(sessionId: unknown) + Issue.record("expected sessionNotFound") + } catch let error as HistoryError { + guard case let .sessionNotFound(id) = error else { + Issue.record("unexpected error: \(error)") + return + } + #expect(id == unknown) + } + } + + @Test + func `shared applyEdits with an empty batch does not append a mutation`() async throws { + let history = History() + try await history.writeFile("/x.txt", content: "x") + let before = try await history.mutationRecords(scope: .shared).count + + _ = try await history.applyEdits([]) + + let after = try await history.mutationRecords(scope: .shared).count + #expect(after == before) + } + + @Test + func `session applyEdits and applyReplacement record session scoped mutations`() async throws { + let history = History() + let session = try await history.createSession() + + _ = try await session.applyEdits( + [ + .writeFile(path: "/batch.txt", content: "a\n"), + ] + ) + try await session.writeFile("/replace.txt", content: "find me") + _ = try await session.applyReplacement( + ReplacementRequest(pattern: "*.txt", search: "find", replacement: "found") + ) + + let kinds = try await history.mutationRecords(scope: .session, sessionId: session.id).map(\.kind) + #expect(kinds == [.applyEdits, .writeFile, .applyReplacement]) + #expect(try await session.workspace.readFile("/replace.txt") == "found me") + } + + @Test + func `History init with an existing workspace uses it as shared`() async throws { + let fs = InMemoryFilesystem() + let workspace = Workspace(filesystem: fs) + try await workspace.writeFile("/seed.txt", content: "seed") + + let history = History(workspace: workspace) + + #expect(try await history.shared.readFile("/seed.txt") == "seed") + try await history.writeFile("/more.txt", content: "more") + #expect(try await workspace.readFile("/more.txt") == "more") + } + + @Test + func `listCheckpoints with scope shared excludes session checkpoints`() async throws { + let history = History() + try await history.writeFile("/s.txt", content: "s") + let sharedCP = try await history.createCheckpoint(label: "shared-only") + + let session = try await history.createSession() + try await session.writeFile("/sess.txt", content: "sess") + let sessionCP = try await session.createCheckpoint(label: "sess-only") + + let sharedOnly = try await history.listCheckpoints(scope: .shared) + let forSession = try await history.listCheckpoints(scope: .session, sessionId: session.id) + + #expect(sharedOnly.contains(where: { $0.id == sharedCP.id })) + #expect(sharedOnly.contains(where: { $0.id == sessionCP.id }) == false) + #expect(forSession == [sessionCP]) + } + + @Test + func `checkpoint id returns nil when no checkpoint exists`() async throws { + let history = History() + let missing = try await history.checkpoint(id: UUID()) + #expect(missing == nil) + } + + @Test + func `publish with no session edits still creates a published shared checkpoint`() async throws { + let history = History() + try await history.writeFile("/base.txt", content: "base") + let session = try await history.createSession() + + let published = try await session.publish(label: "noop") + + #expect(published.scope == .shared) + #expect(published.originSessionId == session.id) + #expect(published.inferredEventKind == .published) + + let publishMutations = try await history.mutationRecords(scope: .shared).filter { $0.kind == .publishSessionHead } + #expect(publishMutations.isEmpty) + + #expect(try await history.shared.readFile("/base.txt") == "base") + } + + @Test + func `session writeData and writeJSON append session mutations`() async throws { + let history = History() + let session = try await history.createSession() + + try await session.writeData(Data([9, 8]), to: "/raw.bin") + try await session.writeJSON(["k": true], to: "/cfg.json", prettyPrinted: true) + + let kinds = try await history.mutationRecords(scope: .session, sessionId: session.id).map(\.kind) + #expect(kinds == [.writeData, .writeJSON]) + } + + @Test + func `snapshot for checkpoint throws snapshotNotFound when store drops the artifact`() async throws { + let store = FlakySnapshotCheckpointStore() + let workspace = Workspace(filesystem: InMemoryFilesystem()) + let history = History(workspace: workspace, store: store) + + try await history.writeFile("/a.txt", content: "a") + let checkpoint = try await history.createCheckpoint(label: "has-snapshot") + + await store.breakLoadingSnapshot(id: checkpoint.snapshotId) + + do { + _ = try await history.snapshot(for: checkpoint) + Issue.record("expected snapshotNotFound") + } catch let error as HistoryError { + guard case let .snapshotNotFound(id) = error else { + Issue.record("unexpected error: \(error)") + return + } + #expect(id == checkpoint.snapshotId) + } + } + + @Test + func `shared rollback throws snapshotNotFound when snapshot artifact is missing`() async throws { + let store = FlakySnapshotCheckpointStore() + let history = History(workspace: Workspace(filesystem: InMemoryFilesystem()), store: store) + + try await history.writeFile("/x.txt", content: "x") + let checkpoint = try await history.createCheckpoint(label: "v1") + try await history.writeFile("/x.txt", content: "y") + + await store.breakLoadingSnapshot(id: checkpoint.snapshotId) + + do { + _ = try await history.rollback(to: checkpoint.id) + Issue.record("expected snapshotNotFound") + } catch let error as HistoryError { + guard case let .snapshotNotFound(id) = error else { + Issue.record("unexpected error: \(error)") + return + } + #expect(id == checkpoint.snapshotId) + } + } + + @Test + func `watchCheckpointEvents receives checkpoints created by another History sharing the store`() async throws { + let store = InMemoryCheckpointStore() + let workspaceId = UUID() + + let observer = History(workspaceId: workspaceId, filesystem: InMemoryFilesystem(), store: store) + let producer = History(workspaceId: workspaceId, filesystem: InMemoryFilesystem(), store: store) + + let recorder = CheckpointEventRecorder() + let stream = try await observer.watchCheckpointEvents() + let task = startCheckpointRecording(stream, into: recorder) + defer { task.cancel() } + + try await producer.writeFile("/remote.txt", content: "r") + let created = try await producer.createCheckpoint(label: "other-instance") + + let events = try await waitForCheckpointEvents(1, recorder: recorder, timeout: .seconds(2)) + #expect(events.count >= 1) + let match = try #require(events.first(where: { $0.checkpoint.id == created.id })) + #expect(match.kind == .created) + } + + @Test + func `session removeItem copyItem and moveItem record session mutations`() async throws { + let history = History() + let session = try await history.createSession() + + try await session.writeFile("/a.txt", content: "a") + try await session.createDirectory(at: "/d", recursive: true) + try await session.writeFile("/d/inner.txt", content: "inner") + try await session.copyItem(from: "/a.txt", to: "/d/copy.txt") + try await session.moveItem(from: "/d/copy.txt", to: "/moved.txt") + try await session.removeItem(at: "/moved.txt") + + let kinds = try await history.mutationRecords(scope: .session, sessionId: session.id).map(\.kind) + #expect( + kinds == [ + .writeFile, + .createDirectory, + .writeFile, + .copyItem, + .moveItem, + .removeItem, + ] + ) + } + + @Test + func `shared writeData to an existing file is unchanged when bytes match`() async throws { + let history = History() + let data = Data([7, 7, 7]) + + try await history.writeData(data, to: "/bin.dat") + try await history.writeData(data, to: "/bin.dat") + + let mutations = try await history.mutationRecords(scope: .shared) + #expect(mutations.count == 2) + #expect(mutations.allSatisfy { $0.kind == .writeData }) + let effects = mutations.flatMap(\.fileChanges).map(\.effect) + #expect(effects == [.created, .unchanged]) + } + + @Test + func `shared writeData targets an existing directory before overwriting with file contents`() async throws { + let history = History() + try await history.createDirectory(at: "/bucket", recursive: true) + + try await history.writeData(Data("blob".utf8), to: "/bucket") + + let mutation = try #require((try await history.mutationRecords(scope: .shared)).last) + #expect(mutation.kind == .writeData) + let change = try #require(mutation.fileChanges.first) + #expect(change.kind == .directory) + #expect(change.effect == .unchanged) + } + + @Test + func `publish records a publishSessionHead mutation when session replaces a file with a directory`() async throws { + let history = History() + try await history.writeFile("/shape.txt", content: "was-a-file") + + let session = try await history.createSession() + try await session.removeItem(at: "/shape.txt") + try await session.createDirectory(at: "/shape.txt", recursive: true) + try await session.writeFile("/shape.txt/nested.txt", content: "now-tree") + + _ = try await session.publish(label: "restructure") + + let publishRows = try await history.mutationRecords(scope: .shared).filter { $0.kind == .publishSessionHead } + #expect(publishRows.count == 1) + #expect(try await history.shared.readFile("/shape.txt/nested.txt") == "now-tree") + } + + @Test + func `session listCheckpoints includes shared checkpoints and session scoped rows`() async throws { + let history = History() + try await history.writeFile("/shared-only.txt", content: "s") + let sharedCP = try await history.createCheckpoint(label: "on-shared") + + let session = try await history.createSession() + try await session.writeFile("/local.txt", content: "l") + let sessionCP = try await session.createCheckpoint(label: "on-session") + + let visible = try await session.listCheckpoints() + #expect(visible.contains(where: { $0.id == sharedCP.id && $0.scope == .shared })) + #expect(visible.contains(where: { $0.id == sessionCP.id && $0.scope == .session })) + } + + @Test + func `session applyEdits with an empty batch does not append mutations`() async throws { + let history = History() + let session = try await history.createSession() + try await session.writeFile("/z.txt", content: "z") + + let before = try await history.mutationRecords(scope: .session, sessionId: session.id).count + _ = try await session.applyEdits([]) + let after = try await history.mutationRecords(scope: .session, sessionId: session.id).count + + #expect(after == before) + } + + @Test + func `multiple watchCheckpointEvents subscriptions share one polling loop`() async throws { + let history = History(filesystem: InMemoryFilesystem()) + + _ = try await history.watchCheckpointEvents() + _ = try await history.watchCheckpointEvents() + + try await history.writeFile("/multi-watch.txt", content: "mw") + let checkpoint = try await history.createCheckpoint(label: "mw") + + #expect(checkpoint.label == "mw") + } + + @Test + func `cancelling a checkpoint stream allows a new watch to receive subsequent events`() async throws { + let history = History() + + let firstStream = try await history.watchCheckpointEvents() + let consumer = Task { + for await _ in firstStream {} + } + try await Task.sleep(for: .milliseconds(30)) + consumer.cancel() + try await Task.sleep(for: .milliseconds(120)) + + let recorder = CheckpointEventRecorder() + let secondStream = try await history.watchCheckpointEvents() + let recording = startCheckpointRecording(secondStream, into: recorder) + defer { recording.cancel() } + + try await history.writeFile("/after-resubscribe.txt", content: "ar") + let checkpoint = try await history.createCheckpoint(label: "resubscribe") + + let events = try await waitForCheckpointEvents(1, recorder: recorder) + #expect(events.contains(where: { $0.checkpoint.id == checkpoint.id && $0.kind == .created })) + } + // MARK: - Helpers private func makeTempDirectory() throws -> URL { diff --git a/Tests/WorkspaceTests/HistoryTypesTests.swift b/Tests/WorkspaceTests/HistoryTypesTests.swift new file mode 100644 index 0000000..84496de --- /dev/null +++ b/Tests/WorkspaceTests/HistoryTypesTests.swift @@ -0,0 +1,165 @@ +import Foundation +import Testing +@testable import Workspace + +@Suite("HistoryTypes") +struct HistoryTypesTests { + @Test + func `Checkpoint inferredEventKind classifies created, rolledBack, and published`() async throws { + let workspaceId = UUID() + let snapshotId = UUID() + let summary = History.Checkpoint.Summary(changeCount: 0, touchedPaths: [], hasTextDiffs: false) + + let created = History.Checkpoint( + workspaceId: workspaceId, + sessionId: nil, + scope: .shared, + label: nil, + parentCheckpointId: nil, + baseSharedCheckpointId: nil, + firstMutationSequence: nil, + lastMutationSequence: nil, + mutationCursor: 0, + originSessionId: nil, + rollbackSourceCheckpointId: nil, + snapshotId: snapshotId, + summary: summary + ) + #expect(created.inferredEventKind == .created) + + let rolledBack = History.Checkpoint( + workspaceId: workspaceId, + sessionId: nil, + scope: .shared, + label: nil, + parentCheckpointId: nil, + baseSharedCheckpointId: nil, + firstMutationSequence: nil, + lastMutationSequence: nil, + mutationCursor: 0, + originSessionId: nil, + rollbackSourceCheckpointId: UUID(), + snapshotId: snapshotId, + summary: summary + ) + #expect(rolledBack.inferredEventKind == .rolledBack) + + let published = History.Checkpoint( + workspaceId: workspaceId, + sessionId: UUID(), + scope: .shared, + label: nil, + parentCheckpointId: nil, + baseSharedCheckpointId: nil, + firstMutationSequence: nil, + lastMutationSequence: nil, + mutationCursor: 0, + originSessionId: UUID(), + rollbackSourceCheckpointId: nil, + snapshotId: snapshotId, + summary: summary + ) + #expect(published.inferredEventKind == .published) + + let sessionWithOrigin = History.Checkpoint( + workspaceId: workspaceId, + sessionId: UUID(), + scope: .session, + label: nil, + parentCheckpointId: nil, + baseSharedCheckpointId: UUID(), + firstMutationSequence: nil, + lastMutationSequence: nil, + mutationCursor: 0, + originSessionId: UUID(), + rollbackSourceCheckpointId: nil, + snapshotId: snapshotId, + summary: summary + ) + #expect(sessionWithOrigin.inferredEventKind == .created) + } + + @Test + func `CheckpointEvent scope mirrors checkpoint scope`() async throws { + let workspaceId = UUID() + let snapshotId = UUID() + let summary = History.Checkpoint.Summary(changeCount: 0, touchedPaths: [], hasTextDiffs: false) + let checkpoint = History.Checkpoint( + workspaceId: workspaceId, + sessionId: UUID(), + scope: .session, + label: "x", + parentCheckpointId: nil, + baseSharedCheckpointId: nil, + firstMutationSequence: nil, + lastMutationSequence: nil, + mutationCursor: 0, + snapshotId: snapshotId, + summary: summary + ) + let event = CheckpointEvent(kind: .created, checkpoint: checkpoint) + #expect(event.scope == .session) + #expect(event.checkpoint.scope == event.scope) + } + + @Test + func `HistoryError descriptions are stable and include identifiers`() async throws { + let sessionId = UUID() + let checkpointId = UUID() + let snapshotId = UUID() + let originSession = UUID() + + let cases: [(HistoryError, String)] = [ + (.sessionNotFound(sessionId), sessionId.uuidString), + (.checkpointNotFound(checkpointId), checkpointId.uuidString), + (.snapshotNotFound(snapshotId), snapshotId.uuidString), + (.checkpointScopeMismatch(expected: .shared, actual: .session), "shared"), + (.checkpointScopeMismatch(expected: .shared, actual: .session), "session"), + (.checkpointSessionMismatch(checkpointId: checkpointId, expectedSessionId: sessionId, actualSessionId: originSession), checkpointId.uuidString), + (.publishConflict(sessionId: sessionId, expectedBaseSharedCheckpointId: nil, actualSharedCheckpointId: checkpointId), sessionId.uuidString), + (.mutationFailed("boom"), "boom"), + ] + + for (error, needle) in cases { + let description = String(describing: error) + #expect(description.contains(needle), "missing '\(needle)' in: \(description)") + } + } + + @Test + func `MutationRecord roundtrips through Codable for representative kinds`() async throws { + let workspaceId = UUID() + let sessionId = UUID() + let encoder = JSONEncoder() + let decoder = JSONDecoder() + + let kinds: [MutationRecord.Kind] = [ + .writeFile, + .appendFile, + .writeData, + .writeJSON, + .createDirectory, + .removeItem, + .copyItem, + .moveItem, + .applyEdits, + .applyReplacement, + .publishSessionHead, + ] + + for kind in kinds { + let original = MutationRecord( + sequence: 1, + workspaceId: workspaceId, + sessionId: sessionId, + scope: .shared, + kind: kind, + touchedPaths: ["/a.txt"], + fileChanges: [] + ) + let decoded = try decoder.decode(MutationRecord.self, from: encoder.encode(original)) + #expect(decoded == original) + #expect(decoded.kind == kind) + } + } +} diff --git a/Tests/WorkspaceTests/MountingTests.swift b/Tests/WorkspaceTests/MountingTests.swift index 9c723b4..d02d09b 100644 --- a/Tests/WorkspaceTests/MountingTests.swift +++ b/Tests/WorkspaceTests/MountingTests.swift @@ -230,4 +230,67 @@ struct MountingTests { #expect(try await filesystem.readFile(path: "/root.txt") == MountingTestSupport.data("root")) #expect(try await memory.readFile(path: "/note.txt") == MountingTestSupport.data("memo")) } + + @Test + func `mountable filesystem prefers the longest matching mount prefix`() async throws { + let outer = InMemoryFilesystem() + let inner = InMemoryFilesystem() + try await outer.writeFile(path: "/outer.txt", data: MountingTestSupport.data("outer"), append: false) + try await inner.writeFile(path: "/inner.txt", data: MountingTestSupport.data("inner"), append: false) + + let filesystem = MountableFilesystem( + base: InMemoryFilesystem(), + mounts: [ + .init(mountPoint: "/mnt", filesystem: outer), + .init(mountPoint: "/mnt/deep", filesystem: inner), + ] + ) + + #expect(try await filesystem.readFile(path: "/mnt/outer.txt") == MountingTestSupport.data("outer")) + #expect(try await filesystem.readFile(path: "/mnt/deep/inner.txt") == MountingTestSupport.data("inner")) + } + + @Test + func `mountable filesystem exposes synthetic parents for nested mount points`() async throws { + let nested = InMemoryFilesystem() + try await nested.writeFile(path: "/leaf.txt", data: MountingTestSupport.data("leaf"), append: false) + + let filesystem = MountableFilesystem( + base: InMemoryFilesystem(), + mounts: [.init(mountPoint: "/data/repo", filesystem: nested)] + ) + + #expect(await filesystem.exists(path: "/data")) + #expect(await filesystem.exists(path: "/data/repo")) + + let dataInfo = try await filesystem.stat(path: "/data") + #expect(dataInfo.kind == .directory) + #expect(dataInfo.path == "/data") + + let dataEntries = try await filesystem.listDirectory(path: "/data") + #expect(dataEntries.map(\.name) == ["repo"]) + + let repoEntries = try await filesystem.listDirectory(path: "/data/repo") + #expect(repoEntries.map(\.name) == ["leaf.txt"]) + + #expect(try await filesystem.readFile(path: "/data/repo/leaf.txt") == MountingTestSupport.data("leaf")) + } + + @Test + func `mountable filesystem glob aggregates paths from base and mounts`() async throws { + let mounted = InMemoryFilesystem() + try await mounted.writeFile(path: "/b.txt", data: MountingTestSupport.data("mb"), append: false) + + let base = InMemoryFilesystem() + try await base.writeFile(path: "/a.txt", data: MountingTestSupport.data("ba"), append: false) + + let filesystem = MountableFilesystem( + base: base, + mounts: [.init(mountPoint: "/m", filesystem: mounted)] + ) + + let matches = try await filesystem.glob(pattern: "/*.txt", currentDirectory: "/") + #expect(matches.contains("/a.txt")) + #expect(matches.contains("/m/b.txt")) + } } From 346d50dd0480789cab2ef64832fd410c8857b2ab Mon Sep 17 00:00:00 2001 From: Zac White Date: Sat, 18 Apr 2026 22:01:10 -0700 Subject: [PATCH 07/14] Fixed race issue --- Sources/Workspace/History.swift | 26 +++++++++++++++++++--- Tests/WorkspaceTests/HistoryTests.swift | 29 +++++++++++++++++++++++++ 2 files changed, 52 insertions(+), 3 deletions(-) diff --git a/Sources/Workspace/History.swift b/Sources/Workspace/History.swift index 8ac9f18..aae9706 100644 --- a/Sources/Workspace/History.swift +++ b/Sources/Workspace/History.swift @@ -959,10 +959,30 @@ public actor History { return } - for cp in loaded where !checkpoints.contains(where: { $0.id == cp.id }) { - checkpoints.append(cp) + let newCheckpoints = loaded.filter { cp in + !checkpoints.contains(where: { $0.id == cp.id }) + } + + if !newCheckpoints.isEmpty { + checkpoints.append(contentsOf: newCheckpoints) + checkpoints.sort(by: checkpointSort) + + // Re-derive the shared head from the merged checkpoint set so a + // subsequent write on this instance anchors to the latest known + // shared checkpoint instead of its stale local head. + sharedHeadCheckpointId = checkpoints + .filter { $0.scope == .shared } + .last? + .id + + // Refresh the mutation log and sequence cursor so this instance + // does not reuse sequence numbers another writer has already + // claimed against the same store. + if let refreshedMutations = try? await store.listMutationRecords(workspaceId: workspaceId) { + mutations = refreshedMutations.sorted { $0.sequence < $1.sequence } + nextMutationSequence = (mutations.map(\.sequence).max() ?? 0) + 1 + } } - checkpoints.sort(by: checkpointSort) for cp in loaded.sorted(by: checkpointSort) { emitCheckpointEvent(CheckpointEvent(kind: cp.inferredEventKind, checkpoint: cp)) diff --git a/Tests/WorkspaceTests/HistoryTests.swift b/Tests/WorkspaceTests/HistoryTests.swift index 83dc002..8e1c6b8 100644 --- a/Tests/WorkspaceTests/HistoryTests.swift +++ b/Tests/WorkspaceTests/HistoryTests.swift @@ -733,6 +733,35 @@ struct HistoryTests { #expect(match.kind == .created) } + @Test + func `observed checkpoints from another instance update the shared head and mutation cursor`() async throws { + let store = InMemoryCheckpointStore() + let workspaceId = UUID() + let sharedFilesystem = InMemoryFilesystem() + + let observer = History(workspaceId: workspaceId, filesystem: sharedFilesystem, store: store) + let producer = History(workspaceId: workspaceId, filesystem: sharedFilesystem, store: store) + + let recorder = CheckpointEventRecorder() + let stream = try await observer.watchCheckpointEvents() + let task = startCheckpointRecording(stream, into: recorder) + defer { task.cancel() } + + try await producer.writeFile("/remote.txt", content: "r") + let producerCheckpoint = try await producer.createCheckpoint(label: "from-producer") + + _ = try await waitForCheckpointEvents(1, recorder: recorder, timeout: .seconds(2)) + + try await observer.writeFile("/observer.txt", content: "o") + let observerCheckpoint = try await observer.createCheckpoint(label: "from-observer") + + #expect(observerCheckpoint.parentCheckpointId == producerCheckpoint.id) + + let storedMutations = try await store.listMutationRecords(workspaceId: workspaceId) + let sequences = storedMutations.map(\.sequence) + #expect(sequences.count == Set(sequences).count) + } + @Test func `session removeItem copyItem and moveItem record session mutations`() async throws { let history = History() From 8a2b14fd2e9a327725aecd19d2f18f0052d4ca59 Mon Sep 17 00:00:00 2001 From: Zac White Date: Sat, 18 Apr 2026 22:42:48 -0700 Subject: [PATCH 08/14] More updates and fixes to address improvement suggestions --- README.md | 38 +++++ Sources/Workspace/CheckpointStore.swift | 106 ++++++++++--- Sources/Workspace/History.swift | 91 ++++++++--- Sources/Workspace/HistoryTypes.swift | 29 ++-- Sources/Workspace/Snapshot.swift | 3 + Sources/Workspace/TextDiff.swift | 144 ++++++++++++++++++ Sources/Workspace/Workspace.swift | 136 +---------------- .../WorkspaceTests/CheckpointStoreTests.swift | 25 +-- 8 files changed, 365 insertions(+), 207 deletions(-) diff --git a/README.md b/README.md index a2365b0..c9a5706 100644 --- a/README.md +++ b/README.md @@ -37,6 +37,9 @@ Many agent and tooling flows need more than plain disk I/O: - `SandboxFilesystem`: convenience wrapper for app sandbox roots - `SecurityScopedFilesystem`: security-scoped URL and bookmark-backed access - `WorkspacePath`: path normalization and joining helpers +- `History`: tracks a shared workspace, optional session overlays, checkpoints, snapshots, mutation logs, rollback, and publish flows +- `Snapshot`: durable capture/restore of a subtree (also used by checkpoints) +- `CheckpointStore`: persist checkpoints and mutations (`InMemoryCheckpointStore`, `FileCheckpointStore`) ## Installation @@ -116,6 +119,41 @@ Task { try await workspace.writeFile("/notes/todo.txt", content: "ship it") ``` +## History & checkpoints + +`History` wraps a **`shared`** ``Workspace`` (usually your canonical tree) plus optional **`Session`** overlays cloned from the current shared head. Sessions edit in isolation, create **session checkpoints**, optionally **publish** back to shared (with optimistic concurrency when the shared head advances), or **rollback** to prior shared or session checkpoints. + +- **Snapshots** store full subtree state; **checkpoints** store labels, scope, lineage, summaries, and point at a snapshot id. +- Use **`History.writeFile`** / **`Session.writeFile`** / **`applyEdits`** etc. so every change is journaled. Calling **`history.shared.writeFile`** or **`session.workspace.writeFile`** skips mutation recording and checkpoints will stop lining up with the mutation log—only use ``History.shared`` / ``Session.workspace`` for reads unless you deliberately accept that mismatch. +- **`History.Storage.directory(at:)`** writes JSON under your URL. `mutations.json` updates use an advisory file lock where the OS supports it; coordinating multiple hosts or quirky network disks may still require your own synchronization. + +Quick outline: + +```swift +import Foundation +import Workspace + +let history = History(filesystem: InMemoryFilesystem()) + +try await history.writeFile("/readme.txt", content: "hello") +let sharedCP = try await history.createCheckpoint(label: "before agent") + +let session = try await history.createSession() +try await session.writeFile("/draft.txt", content: "wip") +try await session.createCheckpoint(label: "scratch") + +try await session.publish(label: "landed") + +let diskRoot = URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent("workspace-history") +let persisted = History( + workspaceId: history.workspaceId, + filesystem: InMemoryFilesystem(), + storage: .directory(at: diskRoot) +) +``` + +Implement a custom **`CheckpointStore`** for non-file backends using the **`History(workspaceId:filesystem:store:)`** initializer. + ## Common Patterns ### Rooted Disk Workspace diff --git a/Sources/Workspace/CheckpointStore.swift b/Sources/Workspace/CheckpointStore.swift index 7557a65..d648f5e 100644 --- a/Sources/Workspace/CheckpointStore.swift +++ b/Sources/Workspace/CheckpointStore.swift @@ -1,7 +1,13 @@ import Foundation +#if canImport(Darwin) +import Darwin +#elseif canImport(Glibc) +import Glibc +#endif + /// Persistence for workspace checkpoints, snapshots, and mutation logs. -protocol CheckpointStore: AnyObject, Sendable { +public protocol CheckpointStore: AnyObject, Sendable { func saveCheckpoint(_ checkpoint: History.Checkpoint) async throws func loadCheckpoint(id: UUID, workspaceId: UUID) async throws -> History.Checkpoint? func listCheckpoints(workspaceId: UUID) async throws -> [History.Checkpoint] @@ -12,20 +18,28 @@ protocol CheckpointStore: AnyObject, Sendable { } /// An in-memory checkpoint store for tests and ephemeral sessions. -actor InMemoryCheckpointStore: CheckpointStore { +public actor InMemoryCheckpointStore: CheckpointStore { private var checkpointsByWorkspace: [UUID: [History.Checkpoint]] = [:] private var snapshotsByWorkspace: [UUID: [UUID: Snapshot]] = [:] private var mutationsByWorkspace: [UUID: [MutationRecord]] = [:] - func saveCheckpoint(_ checkpoint: History.Checkpoint) async throws { - checkpointsByWorkspace[checkpoint.workspaceId, default: []].append(checkpoint) + public init() {} + + public func saveCheckpoint(_ checkpoint: History.Checkpoint) async throws { + var list = checkpointsByWorkspace[checkpoint.workspaceId] ?? [] + if let index = list.firstIndex(where: { $0.id == checkpoint.id }) { + list[index] = checkpoint + } else { + list.append(checkpoint) + } + checkpointsByWorkspace[checkpoint.workspaceId] = list } - func loadCheckpoint(id: UUID, workspaceId: UUID) async throws -> History.Checkpoint? { + public func loadCheckpoint(id: UUID, workspaceId: UUID) async throws -> History.Checkpoint? { checkpointsByWorkspace[workspaceId]?.first { $0.id == id } } - func listCheckpoints(workspaceId: UUID) async throws -> [History.Checkpoint] { + public func listCheckpoints(workspaceId: UUID) async throws -> [History.Checkpoint] { (checkpointsByWorkspace[workspaceId] ?? []).sorted { if $0.createdAt == $1.createdAt { return $0.id.uuidString < $1.id.uuidString @@ -34,31 +48,37 @@ actor InMemoryCheckpointStore: CheckpointStore { } } - func saveSnapshot(_ snapshot: Snapshot, workspaceId: UUID) async throws { + public func saveSnapshot(_ snapshot: Snapshot, workspaceId: UUID) async throws { snapshotsByWorkspace[workspaceId, default: [:]][snapshot.id] = snapshot } - func loadSnapshot(id: UUID, workspaceId: UUID) async throws -> Snapshot? { + public func loadSnapshot(id: UUID, workspaceId: UUID) async throws -> Snapshot? { snapshotsByWorkspace[workspaceId]?[id] } - func appendMutation(_ mutation: MutationRecord) async throws { + public func appendMutation(_ mutation: MutationRecord) async throws { mutationsByWorkspace[mutation.workspaceId, default: []].append(mutation) } - func listMutationRecords(workspaceId: UUID) async throws -> [MutationRecord] { + public func listMutationRecords(workspaceId: UUID) async throws -> [MutationRecord] { (mutationsByWorkspace[workspaceId] ?? []).sorted { $0.sequence < $1.sequence } } } /// A JSON file-backed checkpoint store. -actor FileCheckpointStore: CheckpointStore { +/// +/// Mutations are appended to `mutations.json` under an advisory exclusive lock when supported +/// by the platform (`flock`), so concurrent processes using separate store instances against +/// the same root are less likely to corrupt that file. Checkpoint and snapshot writes remain +/// per-artifact atomic replaces; coordinating writers on network filesystems may still require +/// application-level serialization. +public actor FileCheckpointStore: CheckpointStore { private let rootDirectory: URL private let fileManager: FileManager private let encoder: JSONEncoder private let decoder: JSONDecoder - init(rootDirectory: URL, fileManager: FileManager = .default) { + public init(rootDirectory: URL, fileManager: FileManager = .default) { self.rootDirectory = rootDirectory.standardizedFileURL self.fileManager = fileManager @@ -68,12 +88,12 @@ actor FileCheckpointStore: CheckpointStore { self.decoder = JSONDecoder() } - func saveCheckpoint(_ checkpoint: History.Checkpoint) async throws { + public func saveCheckpoint(_ checkpoint: History.Checkpoint) async throws { try ensureWorkspaceDirectories(for: checkpoint.workspaceId) try write(checkpoint, to: checkpointURL(id: checkpoint.id, workspaceId: checkpoint.workspaceId)) } - func loadCheckpoint(id: UUID, workspaceId: UUID) async throws -> History.Checkpoint? { + public func loadCheckpoint(id: UUID, workspaceId: UUID) async throws -> History.Checkpoint? { let url = checkpointURL(id: id, workspaceId: workspaceId) guard fileManager.fileExists(atPath: url.path) else { return nil @@ -81,7 +101,7 @@ actor FileCheckpointStore: CheckpointStore { return try read(History.Checkpoint.self, from: url) } - func listCheckpoints(workspaceId: UUID) async throws -> [History.Checkpoint] { + public func listCheckpoints(workspaceId: UUID) async throws -> [History.Checkpoint] { let directoryURL = checkpointsDirectoryURL(workspaceId: workspaceId) guard fileManager.fileExists(atPath: directoryURL.path) else { return [] @@ -103,12 +123,12 @@ actor FileCheckpointStore: CheckpointStore { } } - func saveSnapshot(_ snapshot: Snapshot, workspaceId: UUID) async throws { + public func saveSnapshot(_ snapshot: Snapshot, workspaceId: UUID) async throws { try ensureWorkspaceDirectories(for: workspaceId) try write(snapshot, to: snapshotURL(id: snapshot.id, workspaceId: workspaceId)) } - func loadSnapshot(id: UUID, workspaceId: UUID) async throws -> Snapshot? { + public func loadSnapshot(id: UUID, workspaceId: UUID) async throws -> Snapshot? { let url = snapshotURL(id: id, workspaceId: workspaceId) guard fileManager.fileExists(atPath: url.path) else { return nil @@ -116,23 +136,35 @@ actor FileCheckpointStore: CheckpointStore { return try read(Snapshot.self, from: url) } - func appendMutation(_ mutation: MutationRecord) async throws { + public func appendMutation(_ mutation: MutationRecord) async throws { try ensureWorkspaceDirectories(for: mutation.workspaceId) let url = mutationsURL(workspaceId: mutation.workspaceId) - var records = try loadMutations(from: url) - records.append(mutation) - try write(records.sorted(by: { $0.sequence < $1.sequence }), to: url) + try Self.withMutationsExclusiveLock(at: url) { + var records = try loadMutations(from: url) + records.append(mutation) + try write(records.sorted(by: { $0.sequence < $1.sequence }), to: url) + } } - func listMutationRecords(workspaceId: UUID) async throws -> [MutationRecord] { - try loadMutations(from: mutationsURL(workspaceId: workspaceId)).sorted(by: { $0.sequence < $1.sequence }) + public func listMutationRecords(workspaceId: UUID) async throws -> [MutationRecord] { + let url = mutationsURL(workspaceId: workspaceId) + guard fileManager.fileExists(atPath: url.path) else { + return [] + } + return try Self.withMutationsExclusiveLock(at: url) { + try loadMutations(from: url).sorted(by: { $0.sequence < $1.sequence }) + } } private func loadMutations(from url: URL) throws -> [MutationRecord] { guard fileManager.fileExists(atPath: url.path) else { return [] } - return try read([MutationRecord].self, from: url) + let data = try Data(contentsOf: url) + if data.isEmpty { + return [] + } + return try decoder.decode([MutationRecord].self, from: data) } private func ensureWorkspaceDirectories(for workspaceId: UUID) throws { @@ -172,4 +204,30 @@ actor FileCheckpointStore: CheckpointStore { private func read(_ type: T.Type, from url: URL) throws -> T { try decoder.decode(type, from: Data(contentsOf: url)) } + + private struct MutationsFileError: Error { + var message: String + } + +#if canImport(Darwin) || canImport(Glibc) + private static func withMutationsExclusiveLock(at url: URL, _ body: () throws -> R) throws -> R { + let path = url.path + let fd = open(path, O_RDWR | O_CREAT, 0o644) + guard fd >= 0 else { + throw MutationsFileError(message: "could not open mutations file at \(path)") + } + defer { close(fd) } + while flock(fd, LOCK_EX) != 0 { + if errno != EINTR { + throw MutationsFileError(message: "could not acquire exclusive lock on mutations file at \(path)") + } + } + defer { _ = flock(fd, LOCK_UN) } + return try body() + } +#else + private static func withMutationsExclusiveLock(at url: URL, _ body: () throws -> R) throws -> R { + try body() + } +#endif } diff --git a/Sources/Workspace/History.swift b/Sources/Workspace/History.swift index aae9706..1de2941 100644 --- a/Sources/Workspace/History.swift +++ b/Sources/Workspace/History.swift @@ -27,17 +27,26 @@ public actor History { /// In-memory storage that does not survive process exit. case inMemory /// File-backed JSON storage rooted at `url`. + /// + /// ``FileCheckpointStore`` uses per-file atomic writes and locks `mutations.json` while mutating it + /// on Darwin and Linux. Multiple processes should still treat the directory as a single writer domain; + /// some network filesystems may not honor advisory locks. case directory(at: URL) } public let workspaceId: UUID /// The shared workspace backing this history coordinator. - /// Use this for all read operations (readFile, exists, walkTree, etc.). + /// + /// Use this handle for **reads** (``Workspace/readFile``, ``Workspace/exists``, ``Workspace/walkTree``, etc.). + /// Writes must go through ``History`` APIs (for example ``writeFile(_:content:)``, ``applyEdits(_:failurePolicy:)``) + /// so checkpoints and mutation logs stay consistent. Calling write APIs on ``shared`` directly bypasses history + /// tracking and yields state that later checkpoints cannot explain from the mutation log alone. public nonisolated let shared: Workspace private let store: any CheckpointStore + private var loadTask: Task? private var didLoadStoreState = false private var sessions: [UUID: SessionState] = [:] private var checkpoints: [Checkpoint] = [] @@ -74,7 +83,8 @@ public actor History { store = Self.makeStore(for: storage) } - init( + /// Creates a history coordinator with a custom ``CheckpointStore`` (for example a persistent or remote backend). + public init( workspaceId: UUID = UUID(), filesystem: any FileSystem, store: any CheckpointStore @@ -84,7 +94,8 @@ public actor History { self.store = store } - init( + /// Creates a history coordinator over an existing shared workspace and custom ``CheckpointStore``. + public init( workspaceId: UUID = UUID(), workspace: Workspace, store: any CheckpointStore @@ -284,13 +295,19 @@ public actor History { try await ensureLoaded() let result = try await shared.applyEdits(edits, failurePolicy: failurePolicy) if !edits.isEmpty { + let topDiff: TextDiff? = + if result.edits.count == 1, let single = result.edits.first, single.fileChanges.count == 1 { + single.fileChanges[0].diff + } else { + nil + } try await appendMutation( kind: .applyEdits, scope: .shared, sessionId: nil, touchedPaths: result.touchedPaths, fileChanges: result.edits.flatMap(\.fileChanges), - diff: result.edits.count == 1 ? result.edits[0].fileChanges.first?.diff : nil + diff: topDiff ) } return result @@ -386,13 +403,15 @@ public actor History { try await shared.restoreSnapshot(sessionSnapshot) if delta.hasChanges { + let topDiff: TextDiff? = + if delta.fileChanges.count == 1 { delta.fileChanges[0].diff } else { nil } try await appendMutation( kind: .publishSessionHead, scope: .shared, sessionId: sessionId, touchedPaths: Array(delta.touchedPaths).sorted(), fileChanges: delta.fileChanges, - diff: nil + diff: topDiff ) } @@ -571,13 +590,19 @@ public actor History { let workspace = try sessionState(for: sessionId).workspace let result = try await workspace.applyEdits(edits, failurePolicy: failurePolicy) if !edits.isEmpty { + let topDiff: TextDiff? = + if result.edits.count == 1, let single = result.edits.first, single.fileChanges.count == 1 { + single.fileChanges[0].diff + } else { + nil + } try await appendMutation( kind: .applyEdits, scope: .session, sessionId: sessionId, touchedPaths: result.touchedPaths, fileChanges: result.edits.flatMap(\.fileChanges), - diff: result.edits.count == 1 ? result.edits[0].fileChanges.first?.diff : nil + diff: topDiff ) } return result @@ -684,13 +709,30 @@ public actor History { // MARK: - Private private func ensureLoaded() async throws { - guard !didLoadStoreState else { + if didLoadStoreState { + return + } + if let loadTask { + try await loadTask.value return } + let task = Task { + try await self.loadStoreState() + } + loadTask = task + do { + try await task.value + loadTask = nil + } catch { + loadTask = nil + throw error + } + } + private func loadStoreState() async throws { checkpoints = try await store.listCheckpoints(workspaceId: workspaceId) mutations = try await store.listMutationRecords(workspaceId: workspaceId) - nextMutationSequence = (mutations.map(\.sequence).max() ?? 0) + 1 + nextMutationSequence = (mutations.last?.sequence ?? 0) + 1 sharedHeadCheckpointId = checkpoints .filter { $0.scope == .shared } .sorted(by: checkpointSort) @@ -916,7 +958,7 @@ public actor History { var data = try encoder.encode(value) data.append(Data("\n".utf8)) guard let string = String(data: data, encoding: .utf8) else { - throw WorkspaceError.invalidEncoding(.root) + throw HistoryError.mutationFailed("encoded JSON is not valid UTF-8") } return string } @@ -980,11 +1022,11 @@ public actor History { // claimed against the same store. if let refreshedMutations = try? await store.listMutationRecords(workspaceId: workspaceId) { mutations = refreshedMutations.sorted { $0.sequence < $1.sequence } - nextMutationSequence = (mutations.map(\.sequence).max() ?? 0) + 1 + nextMutationSequence = (mutations.last?.sequence ?? 0) + 1 } } - for cp in loaded.sorted(by: checkpointSort) { + for cp in newCheckpoints.sorted(by: checkpointSort) { emitCheckpointEvent(CheckpointEvent(kind: cp.inferredEventKind, checkpoint: cp)) } } @@ -1007,6 +1049,16 @@ public actor History { // MARK: - Snapshot delta + private func utf8TextFileDiff(oldData: Data, newData: Data) -> TextDiff? { + guard let oldStr = String(data: oldData, encoding: .utf8), + let newStr = String(data: newData, encoding: .utf8) + else { + return nil + } + let diff = TextDiff.lineBased(from: oldStr, to: newStr) + return diff.hunks.isEmpty ? nil : diff + } + private func snapshotDelta(from original: Snapshot.Entry, to updated: Snapshot.Entry) -> SnapshotDelta { var delta = SnapshotDelta() collectDelta(from: original, to: updated, into: &delta) @@ -1029,13 +1081,14 @@ public actor History { case let (.file(lhs), .file(rhs)): if lhs.data != rhs.data || lhs.permissions != rhs.permissions { delta.touchedPaths.insert(rhs.path) + let diff = lhs.data != rhs.data ? utf8TextFileDiff(oldData: lhs.data, newData: rhs.data) : nil delta.fileChanges.append( FileEdit.FileChange( path: rhs.path, kind: .file, effect: .modified, status: .applied, - diff: nil + diff: diff ) ) } @@ -1053,6 +1106,8 @@ public actor History { ) } case let (.directory(lhs), .directory(rhs)): + // Permission-only directory updates are surfaced via `touchedPaths` (and thus checkpoint summaries) + // but do not emit a leaf `FileChange`, matching how directory nodes are not individual files. if lhs.permissions != rhs.permissions { delta.touchedPaths.insert(rhs.path) } @@ -1091,7 +1146,7 @@ public actor History { kind: .file, effect: .created, status: .applied, - diff: nil + diff: utf8TextFileDiff(oldData: Data(), newData: entry.data) ) ) case let .directory(entry): @@ -1125,7 +1180,7 @@ public actor History { kind: .file, effect: .deleted, status: .applied, - diff: nil + diff: utf8TextFileDiff(oldData: entry.data, newData: Data()) ) ) case let .directory(entry): @@ -1170,9 +1225,11 @@ extension History { public nonisolated let id: UUID public nonisolated let workspaceId: UUID - /// The session's underlying workspace. - /// Use this for all read operations (readFile, exists, walkTree, etc.). - /// Use Session's write methods for tracked mutations. + /// The session's underlying workspace (an isolated in-memory overlay). + /// + /// Use this handle for **reads**. All writes should go through ``Session`` methods such as + /// ``writeFile(_:content:)`` or ``applyEdits(_:failurePolicy:)`` so session checkpoints and mutation + /// records stay accurate. Writing through ``workspace`` directly skips history tracking. public nonisolated let workspace: Workspace private let history: History diff --git a/Sources/Workspace/HistoryTypes.swift b/Sources/Workspace/HistoryTypes.swift index 2eab135..e35e1cd 100644 --- a/Sources/Workspace/HistoryTypes.swift +++ b/Sources/Workspace/HistoryTypes.swift @@ -106,8 +106,10 @@ extension History { } } -struct MutationRecord: Sendable, Codable, Equatable { - enum Kind: String, Sendable, Codable { +/// A recorded filesystem mutation emitted by ``History``. +public struct MutationRecord: Sendable, Codable, Equatable { + /// The coarse operation kind for filtering and tooling. + public enum Kind: String, Sendable, Codable { case writeFile case appendFile case writeData @@ -121,17 +123,18 @@ struct MutationRecord: Sendable, Codable, Equatable { case publishSessionHead } - var sequence: Int - var workspaceId: UUID - var sessionId: UUID? - var scope: History.Checkpoint.Scope - var timestamp: Date - var kind: Kind - var touchedPaths: [WorkspacePath] - var fileChanges: [FileEdit.FileChange] - var diff: TextDiff? - - init( + public var sequence: Int + public var workspaceId: UUID + public var sessionId: UUID? + public var scope: History.Checkpoint.Scope + public var timestamp: Date + public var kind: Kind + public var touchedPaths: [WorkspacePath] + public var fileChanges: [FileEdit.FileChange] + public var diff: TextDiff? + + /// Creates a mutation record (primarily for tests and custom ``CheckpointStore`` implementations). + public init( sequence: Int, workspaceId: UUID, sessionId: UUID?, diff --git a/Sources/Workspace/Snapshot.swift b/Sources/Workspace/Snapshot.swift index 95fc81b..2a25571 100644 --- a/Sources/Workspace/Snapshot.swift +++ b/Sources/Workspace/Snapshot.swift @@ -116,6 +116,9 @@ extension Snapshot { /// /// Pass `nil` to summarize this snapshot as if every entry were newly created. /// + /// This walks every node under the snapshot root twice (current and prior trees). For very large trees, + /// prefer caching summaries at checkpoint creation time rather than recomputing on hot paths. + /// /// - Parameter other: The snapshot to compare against, typically a parent checkpoint. /// - Returns: A summary describing how many paths changed and whether any of those /// paths involve UTF-8 decodable text (and therefore have a meaningful textual diff). diff --git a/Sources/Workspace/TextDiff.swift b/Sources/Workspace/TextDiff.swift index e6bd9a2..39059d3 100644 --- a/Sources/Workspace/TextDiff.swift +++ b/Sources/Workspace/TextDiff.swift @@ -78,3 +78,147 @@ public struct TextDiff: Sendable, Equatable, Codable { self.hunks = hunks } } + +// MARK: - Line-based diff (shared with ``Workspace`` previews) + +extension TextDiff { + /// A line-based diff between two UTF-8 text blobs, using the same algorithm as + /// ``Workspace`` replacement and batch-edit previews. + public static func lineBased(from originalContent: String, to updatedContent: String) -> TextDiff { + let originalLines = diffLineTokens(in: originalContent) + let updatedLines = diffLineTokens(in: updatedContent) + let changes = Array(updatedLines.difference(from: originalLines)) + + let removals = Dictionary(grouping: changes.compactMap { change -> (Int, DiffLineToken)? in + if case let .remove(offset, element, _) = change { + return (offset, element) + } + return nil + }, by: \.0) + + let insertions = Dictionary(grouping: changes.compactMap { change -> (Int, DiffLineToken)? in + if case let .insert(offset, element, _) = change { + return (offset, element) + } + return nil + }, by: \.0) + + var lines: [Line] = [] + var originalIndex = 0 + var updatedIndex = 0 + var originalLineNumber = 1 + var updatedLineNumber = 1 + + while originalIndex < originalLines.count || updatedIndex < updatedLines.count { + if let removed = removals[originalIndex] { + for (_, token) in removed { + lines.append( + Line( + kind: .removed, + text: token.text, + hasTrailingNewline: token.hasTrailingNewline, + oldLineNumber: originalLineNumber + ) + ) + originalIndex += 1 + originalLineNumber += 1 + } + continue + } + + if let inserted = insertions[updatedIndex] { + for (_, token) in inserted { + lines.append( + Line( + kind: .added, + text: token.text, + hasTrailingNewline: token.hasTrailingNewline, + newLineNumber: updatedLineNumber + ) + ) + updatedIndex += 1 + updatedLineNumber += 1 + } + continue + } + + guard originalIndex < originalLines.count, updatedIndex < updatedLines.count else { + break + } + + let updatedLine = updatedLines[updatedIndex] + lines.append( + Line( + kind: .context, + text: updatedLine.text, + hasTrailingNewline: updatedLine.hasTrailingNewline, + oldLineNumber: originalLineNumber, + newLineNumber: updatedLineNumber + ) + ) + originalIndex += 1 + updatedIndex += 1 + originalLineNumber += 1 + updatedLineNumber += 1 + } + + let changedIndices = lines.indices.filter { lines[$0].kind != .context } + guard !changedIndices.isEmpty else { + return TextDiff(hunks: []) + } + + var ranges: [Range] = [] + for index in changedIndices { + let lowerBound = max(0, index - 3) + let upperBound = min(lines.count, index + 4) + if let last = ranges.last, lowerBound <= last.upperBound { + ranges[ranges.count - 1] = last.lowerBound.. [DiffLineToken] { + guard !text.isEmpty else { + return [] + } + + var tokens: [DiffLineToken] = [] + var current = "" + for character in text { + if character == "\n" { + tokens.append(DiffLineToken(text: current, hasTrailingNewline: true)) + current.removeAll(keepingCapacity: true) + } else { + current.append(character) + } + } + + if !current.isEmpty || text.last != "\n" { + tokens.append(DiffLineToken(text: current, hasTrailingNewline: false)) + } + + return tokens +} diff --git a/Sources/Workspace/Workspace.swift b/Sources/Workspace/Workspace.swift index 471ccd2..7e81d4d 100644 --- a/Sources/Workspace/Workspace.swift +++ b/Sources/Workspace/Workspace.swift @@ -209,11 +209,6 @@ public actor Workspace { var changeEvents: [ChangeEvent] } - private struct DiffToken: Hashable { - var text: String - var hasTrailingNewline: Bool - } - /// Returns a preview of a replacement request without mutating the workspace. public func previewReplacement(_ request: ReplacementRequest) async throws -> ReplacementResult { let changes = try await plannedReplacementChanges(for: request).map(\.change) @@ -1151,136 +1146,7 @@ public actor Workspace { } private func textDiff(from originalContent: String, to updatedContent: String) -> TextDiff { - let originalLines = diffTokens(in: originalContent) - let updatedLines = diffTokens(in: updatedContent) - let changes = Array(updatedLines.difference(from: originalLines)) - - let removals = Dictionary(grouping: changes.compactMap { change in - if case let .remove(offset, element, _) = change { - return (offset, element) - } - return nil - }, by: \.0) - - let insertions = Dictionary(grouping: changes.compactMap { change in - if case let .insert(offset, element, _) = change { - return (offset, element) - } - return nil - }, by: \.0) - - var lines: [TextDiff.Line] = [] - var originalIndex = 0 - var updatedIndex = 0 - var originalLineNumber = 1 - var updatedLineNumber = 1 - - while originalIndex < originalLines.count || updatedIndex < updatedLines.count { - if let removed = removals[originalIndex] { - for (_, token) in removed { - lines.append( - TextDiff.Line( - kind: .removed, - text: token.text, - hasTrailingNewline: token.hasTrailingNewline, - oldLineNumber: originalLineNumber - ) - ) - originalIndex += 1 - originalLineNumber += 1 - } - continue - } - - if let inserted = insertions[updatedIndex] { - for (_, token) in inserted { - lines.append( - TextDiff.Line( - kind: .added, - text: token.text, - hasTrailingNewline: token.hasTrailingNewline, - newLineNumber: updatedLineNumber - ) - ) - updatedIndex += 1 - updatedLineNumber += 1 - } - continue - } - - guard originalIndex < originalLines.count, updatedIndex < updatedLines.count else { - break - } - - let updatedLine = updatedLines[updatedIndex] - lines.append( - TextDiff.Line( - kind: .context, - text: updatedLine.text, - hasTrailingNewline: updatedLine.hasTrailingNewline, - oldLineNumber: originalLineNumber, - newLineNumber: updatedLineNumber - ) - ) - originalIndex += 1 - updatedIndex += 1 - originalLineNumber += 1 - updatedLineNumber += 1 - } - - let changedIndices = lines.indices.filter { lines[$0].kind != .context } - guard !changedIndices.isEmpty else { - return TextDiff(hunks: []) - } - - var ranges: [Range] = [] - for index in changedIndices { - let lowerBound = max(0, index - 3) - let upperBound = min(lines.count, index + 4) - if let last = ranges.last, lowerBound <= last.upperBound { - ranges[ranges.count - 1] = last.lowerBound.. [DiffToken] { - guard !text.isEmpty else { - return [] - } - - var tokens: [DiffToken] = [] - var current = "" - for character in text { - if character == "\n" { - tokens.append(DiffToken(text: current, hasTrailingNewline: true)) - current.removeAll(keepingCapacity: true) - } else { - current.append(character) - } - } - - if !current.isEmpty || text.last != "\n" { - tokens.append(DiffToken(text: current, hasTrailingNewline: false)) - } - - return tokens + TextDiff.lineBased(from: originalContent, to: updatedContent) } private func describe(_ error: any Error) -> String { diff --git a/Tests/WorkspaceTests/CheckpointStoreTests.swift b/Tests/WorkspaceTests/CheckpointStoreTests.swift index 987bca9..d2177aa 100644 --- a/Tests/WorkspaceTests/CheckpointStoreTests.swift +++ b/Tests/WorkspaceTests/CheckpointStoreTests.swift @@ -184,7 +184,7 @@ struct CheckpointStoreTests { } @Test - func `InMemoryCheckpointStore loadCheckpoint returns the first saved match when ids collide`() async throws { + func `InMemoryCheckpointStore saveCheckpoint overwrites an existing checkpoint id`() async throws { let store = InMemoryCheckpointStore() let workspaceId = UUID() let sharedId = UUID() @@ -206,30 +206,19 @@ struct CheckpointStoreTests { snapshotId: snapshotId, summary: summary ) - let second = History.Checkpoint( - id: sharedId, - workspaceId: workspaceId, - sessionId: nil, - scope: .shared, - label: "second", - createdAt: Date(timeIntervalSince1970: 150), - parentCheckpointId: nil, - baseSharedCheckpointId: nil, - firstMutationSequence: nil, - lastMutationSequence: nil, - mutationCursor: 0, - snapshotId: snapshotId, - summary: summary - ) + var second = first + second.label = "second" + second.createdAt = Date(timeIntervalSince1970: 150) try await store.saveCheckpoint(first) try await store.saveCheckpoint(second) let loaded = try await store.loadCheckpoint(id: sharedId, workspaceId: workspaceId) - #expect(loaded == first) + #expect(loaded == second) let listed = try await store.listCheckpoints(workspaceId: workspaceId) - #expect(listed.count == 2) + #expect(listed.count == 1) + #expect(listed[0].label == "second") } @Test From da705c89304f999a9fadcec501bd71c6ad62b1ee Mon Sep 17 00:00:00 2001 From: Zac White Date: Sat, 18 Apr 2026 23:17:28 -0700 Subject: [PATCH 09/14] More improvements to surface area --- README.md | 5 +- Sources/Workspace/CheckpointStore.swift | 25 +++- Sources/Workspace/History.swift | 78 ++++++------ Sources/Workspace/WorkspaceReading.swift | 88 ++++++++++++++ .../WorkspaceTests/CheckpointStoreTests.swift | 88 ++++++++++++++ Tests/WorkspaceTests/HistoryTests.swift | 96 +++++++++++++++ Tests/WorkspaceTests/TextDiffTests.swift | 111 ++++++++++++++++++ 7 files changed, 450 insertions(+), 41 deletions(-) create mode 100644 Sources/Workspace/WorkspaceReading.swift create mode 100644 Tests/WorkspaceTests/TextDiffTests.swift diff --git a/README.md b/README.md index c9a5706..422bba0 100644 --- a/README.md +++ b/README.md @@ -38,6 +38,7 @@ Many agent and tooling flows need more than plain disk I/O: - `SecurityScopedFilesystem`: security-scoped URL and bookmark-backed access - `WorkspacePath`: path normalization and joining helpers - `History`: tracks a shared workspace, optional session overlays, checkpoints, snapshots, mutation logs, rollback, and publish flows +- `WorkspaceReading`: read-only protocol exposed by `History.shared` and `Session.workspace` so writes cannot bypass history tracking through those handles - `Snapshot`: durable capture/restore of a subtree (also used by checkpoints) - `CheckpointStore`: persist checkpoints and mutations (`InMemoryCheckpointStore`, `FileCheckpointStore`) @@ -124,8 +125,8 @@ try await workspace.writeFile("/notes/todo.txt", content: "ship it") `History` wraps a **`shared`** ``Workspace`` (usually your canonical tree) plus optional **`Session`** overlays cloned from the current shared head. Sessions edit in isolation, create **session checkpoints**, optionally **publish** back to shared (with optimistic concurrency when the shared head advances), or **rollback** to prior shared or session checkpoints. - **Snapshots** store full subtree state; **checkpoints** store labels, scope, lineage, summaries, and point at a snapshot id. -- Use **`History.writeFile`** / **`Session.writeFile`** / **`applyEdits`** etc. so every change is journaled. Calling **`history.shared.writeFile`** or **`session.workspace.writeFile`** skips mutation recording and checkpoints will stop lining up with the mutation log—only use ``History.shared`` / ``Session.workspace`` for reads unless you deliberately accept that mismatch. -- **`History.Storage.directory(at:)`** writes JSON under your URL. `mutations.json` updates use an advisory file lock where the OS supports it; coordinating multiple hosts or quirky network disks may still require your own synchronization. +- **`History.shared`** and **`Session.workspace`** are typed as `any WorkspaceReading`, so write APIs are unreachable through them. Use **`History.writeFile`** / **`Session.writeFile`** / **`applyEdits`** etc. for any change you want journaled, checkpointed, or published. +- **`History.Storage.directory(at:)`** writes JSON under your URL. Mutation log appends are serialized through a `mutations.lock` sidecar using `flock` where the OS supports it; coordinating multiple hosts or network disks that don't honor `flock` may still require your own synchronization. Quick outline: diff --git a/Sources/Workspace/CheckpointStore.swift b/Sources/Workspace/CheckpointStore.swift index d648f5e..573e5a2 100644 --- a/Sources/Workspace/CheckpointStore.swift +++ b/Sources/Workspace/CheckpointStore.swift @@ -67,10 +67,11 @@ public actor InMemoryCheckpointStore: CheckpointStore { /// A JSON file-backed checkpoint store. /// -/// Mutations are appended to `mutations.json` under an advisory exclusive lock when supported -/// by the platform (`flock`), so concurrent processes using separate store instances against -/// the same root are less likely to corrupt that file. Checkpoint and snapshot writes remain -/// per-artifact atomic replaces; coordinating writers on network filesystems may still require +/// Mutation log writes (`mutations.json`) are serialized through a persistent sidecar lockfile +/// (`mutations.lock`) using an advisory exclusive lock when supported by the platform (`flock`), +/// so concurrent ``FileCheckpointStore`` instances within the same process and across cooperating +/// processes do not lose appends. Checkpoint and snapshot writes are per-artifact atomic replaces. +/// Coordinating writers on network filesystems that do not honor `flock` may still require /// application-level serialization. public actor FileCheckpointStore: CheckpointStore { private let rootDirectory: URL @@ -139,7 +140,8 @@ public actor FileCheckpointStore: CheckpointStore { public func appendMutation(_ mutation: MutationRecord) async throws { try ensureWorkspaceDirectories(for: mutation.workspaceId) let url = mutationsURL(workspaceId: mutation.workspaceId) - try Self.withMutationsExclusiveLock(at: url) { + let lockURL = mutationsLockURL(workspaceId: mutation.workspaceId) + try Self.withMutationsExclusiveLock(at: lockURL) { var records = try loadMutations(from: url) records.append(mutation) try write(records.sorted(by: { $0.sequence < $1.sequence }), to: url) @@ -151,7 +153,9 @@ public actor FileCheckpointStore: CheckpointStore { guard fileManager.fileExists(atPath: url.path) else { return [] } - return try Self.withMutationsExclusiveLock(at: url) { + try ensureWorkspaceDirectories(for: workspaceId) + let lockURL = mutationsLockURL(workspaceId: workspaceId) + return try Self.withMutationsExclusiveLock(at: lockURL) { try loadMutations(from: url).sorted(by: { $0.sequence < $1.sequence }) } } @@ -197,6 +201,15 @@ public actor FileCheckpointStore: CheckpointStore { workspaceDirectoryURL(workspaceId: workspaceId).appendingPathComponent("mutations.json", isDirectory: false) } + /// A persistent sidecar lockfile used by ``withMutationsExclusiveLock(at:_:)``. + /// + /// `mutations.json` itself is atomically replaced on every append, which would invalidate any + /// `flock` taken on its file descriptor (the on-disk inode changes at each rename). We therefore + /// take the advisory lock on this stable sidecar that nobody renames or unlinks. + private func mutationsLockURL(workspaceId: UUID) -> URL { + workspaceDirectoryURL(workspaceId: workspaceId).appendingPathComponent("mutations.lock", isDirectory: false) + } + private func write(_ value: T, to url: URL) throws { try encoder.encode(value).write(to: url, options: .atomic) } diff --git a/Sources/Workspace/History.swift b/Sources/Workspace/History.swift index 1de2941..88c8f73 100644 --- a/Sources/Workspace/History.swift +++ b/Sources/Workspace/History.swift @@ -36,13 +36,18 @@ public actor History { public let workspaceId: UUID - /// The shared workspace backing this history coordinator. + /// The shared workspace backing this history coordinator, exposed as a read-only handle. /// - /// Use this handle for **reads** (``Workspace/readFile``, ``Workspace/exists``, ``Workspace/walkTree``, etc.). - /// Writes must go through ``History`` APIs (for example ``writeFile(_:content:)``, ``applyEdits(_:failurePolicy:)``) - /// so checkpoints and mutation logs stay consistent. Calling write APIs on ``shared`` directly bypasses history - /// tracking and yields state that later checkpoints cannot explain from the mutation log alone. - public nonisolated let shared: Workspace + /// Reads (``WorkspaceReading/readFile(_:)``, ``WorkspaceReading/exists(_:)``, + /// ``WorkspaceReading/walkTree(_:)``, ``WorkspaceReading/captureSnapshot()``, etc.) are always safe. + /// All writes must go through ``History`` (for example ``writeFile(_:content:)``, + /// ``applyEdits(_:failurePolicy:)``) so checkpoints and mutation logs stay consistent. + /// The read-only type prevents accidentally bypassing history tracking through this property. + public nonisolated let shared: any WorkspaceReading + + /// Internal full-access view onto the same shared workspace as ``shared``. Used by ``History`` itself for + /// tracked write operations and snapshot restore. + private nonisolated let sharedWorkspace: Workspace private let store: any CheckpointStore @@ -67,9 +72,11 @@ public actor History { filesystem: any FileSystem, storage: Storage = .inMemory ) { + let workspace = Workspace(filesystem: filesystem) self.workspaceId = workspaceId - shared = Workspace(filesystem: filesystem) - store = Self.makeStore(for: storage) + self.sharedWorkspace = workspace + self.shared = workspace + self.store = Self.makeStore(for: storage) } /// Creates a history coordinator over an existing shared workspace. @@ -79,8 +86,9 @@ public actor History { storage: Storage = .inMemory ) { self.workspaceId = workspaceId - shared = workspace - store = Self.makeStore(for: storage) + self.sharedWorkspace = workspace + self.shared = workspace + self.store = Self.makeStore(for: storage) } /// Creates a history coordinator with a custom ``CheckpointStore`` (for example a persistent or remote backend). @@ -89,8 +97,10 @@ public actor History { filesystem: any FileSystem, store: any CheckpointStore ) { + let workspace = Workspace(filesystem: filesystem) self.workspaceId = workspaceId - shared = Workspace(filesystem: filesystem) + self.sharedWorkspace = workspace + self.shared = workspace self.store = store } @@ -101,7 +111,8 @@ public actor History { store: any CheckpointStore ) { self.workspaceId = workspaceId - shared = workspace + self.sharedWorkspace = workspace + self.shared = workspace self.store = store } @@ -151,7 +162,7 @@ public actor History { try await ensureLoaded() let sessionId = UUID() - let snapshot = try await shared.captureSnapshot() + let snapshot = try await sharedWorkspace.captureSnapshot() let overlay = InMemoryFilesystem() let sessionWorkspace = Workspace(filesystem: overlay) try await sessionWorkspace.restoreSnapshot(snapshot) @@ -176,7 +187,7 @@ public actor History { public func writeFile(_ path: WorkspacePath, content: String) async throws { try await ensureLoaded() try await performDirectEdit( - on: shared, + on: sharedWorkspace, scope: .shared, sessionId: nil, kind: .writeFile, @@ -190,7 +201,7 @@ public actor History { public func appendFile(_ path: WorkspacePath, content: String) async throws { try await ensureLoaded() try await performDirectEdit( - on: shared, + on: sharedWorkspace, scope: .shared, sessionId: nil, kind: .appendFile, @@ -204,7 +215,7 @@ public actor History { public func writeData(_ data: Data, to path: WorkspacePath) async throws { try await ensureLoaded() try await performBinaryWrite( - on: shared, + on: sharedWorkspace, scope: .shared, sessionId: nil, data: data, @@ -221,7 +232,7 @@ public actor History { try await ensureLoaded() let content = try encodedJSONString(for: value, prettyPrinted: prettyPrinted) try await performDirectEdit( - on: shared, + on: sharedWorkspace, scope: .shared, sessionId: nil, kind: .writeJSON, @@ -235,7 +246,7 @@ public actor History { public func createDirectory(at path: WorkspacePath, recursive: Bool = true) async throws { try await ensureLoaded() try await performDirectEdit( - on: shared, + on: sharedWorkspace, scope: .shared, sessionId: nil, kind: .createDirectory, @@ -249,7 +260,7 @@ public actor History { public func removeItem(at path: WorkspacePath, recursive: Bool = true) async throws { try await ensureLoaded() try await performDirectEdit( - on: shared, + on: sharedWorkspace, scope: .shared, sessionId: nil, kind: .removeItem, @@ -263,7 +274,7 @@ public actor History { public func copyItem(from source: WorkspacePath, to destination: WorkspacePath, recursive: Bool = true) async throws { try await ensureLoaded() try await performDirectEdit( - on: shared, + on: sharedWorkspace, scope: .shared, sessionId: nil, kind: .copyItem, @@ -277,7 +288,7 @@ public actor History { public func moveItem(from source: WorkspacePath, to destination: WorkspacePath) async throws { try await ensureLoaded() try await performDirectEdit( - on: shared, + on: sharedWorkspace, scope: .shared, sessionId: nil, kind: .moveItem, @@ -293,7 +304,7 @@ public actor History { failurePolicy: MutationFailurePolicy = .rollback ) async throws -> FileEdit.BatchResult { try await ensureLoaded() - let result = try await shared.applyEdits(edits, failurePolicy: failurePolicy) + let result = try await sharedWorkspace.applyEdits(edits, failurePolicy: failurePolicy) if !edits.isEmpty { let topDiff: TextDiff? = if result.edits.count == 1, let single = result.edits.first, single.fileChanges.count == 1 { @@ -319,7 +330,7 @@ public actor History { failurePolicy: MutationFailurePolicy = .rollback ) async throws -> ReplacementResult { try await ensureLoaded() - let result = try await shared.applyReplacement(request, failurePolicy: failurePolicy) + let result = try await sharedWorkspace.applyReplacement(request, failurePolicy: failurePolicy) if !result.changes.isEmpty || !result.failures.isEmpty { try await appendMutation( kind: .applyReplacement, @@ -346,7 +357,7 @@ public actor History { /// Creates a shared checkpoint. public func createCheckpoint(label: String? = nil) async throws -> Checkpoint { try await ensureLoaded() - let snapshot = try await shared.captureSnapshot() + let snapshot = try await sharedWorkspace.captureSnapshot() return try await persistCheckpoint( snapshot: snapshot, scope: .shared, @@ -368,8 +379,8 @@ public actor History { throw HistoryError.checkpointScopeMismatch(expected: .shared, actual: checkpoint.scope) } let targetSnapshot = try await loadSnapshotOrThrow(id: checkpoint.snapshotId) - try await shared.restoreSnapshot(targetSnapshot) - let restoredSnapshot = try await shared.captureSnapshot() + try await sharedWorkspace.restoreSnapshot(targetSnapshot) + let restoredSnapshot = try await sharedWorkspace.captureSnapshot() return try await persistCheckpoint( snapshot: restoredSnapshot, scope: .shared, @@ -396,11 +407,11 @@ public actor History { ) } - let previousSharedSnapshot = try await shared.captureSnapshot() + let previousSharedSnapshot = try await sharedWorkspace.captureSnapshot() let sessionSnapshot = try await session.workspace.captureSnapshot() let delta = snapshotDelta(from: previousSharedSnapshot.entry, to: sessionSnapshot.entry) - try await shared.restoreSnapshot(sessionSnapshot) + try await sharedWorkspace.restoreSnapshot(sessionSnapshot) if delta.hasChanges { let topDiff: TextDiff? = @@ -1225,12 +1236,13 @@ extension History { public nonisolated let id: UUID public nonisolated let workspaceId: UUID - /// The session's underlying workspace (an isolated in-memory overlay). + /// The session's underlying workspace overlay, exposed as a read-only handle. /// - /// Use this handle for **reads**. All writes should go through ``Session`` methods such as - /// ``writeFile(_:content:)`` or ``applyEdits(_:failurePolicy:)`` so session checkpoints and mutation - /// records stay accurate. Writing through ``workspace`` directly skips history tracking. - public nonisolated let workspace: Workspace + /// Reads are always safe. All writes must go through ``Session`` methods (for example + /// ``writeFile(_:content:)`` or ``applyEdits(_:failurePolicy:)``) so session checkpoints and + /// mutation records stay accurate. The read-only type prevents accidentally bypassing history + /// tracking through this property. + public nonisolated let workspace: any WorkspaceReading private let history: History diff --git a/Sources/Workspace/WorkspaceReading.swift b/Sources/Workspace/WorkspaceReading.swift new file mode 100644 index 0000000..d5c4a3f --- /dev/null +++ b/Sources/Workspace/WorkspaceReading.swift @@ -0,0 +1,88 @@ +import Foundation + +/// Read-only view over a ``Workspace``. +/// +/// ``History/shared`` and ``History/Session/workspace`` are exposed as `any WorkspaceReading` so that +/// callers cannot accidentally bypass mutation tracking by writing through them. Any code that needs to +/// **mutate** a workspace tracked by ``History`` must go through the corresponding ``History`` or +/// ``History/Session`` API (for example ``History/writeFile(_:content:)`` or +/// ``History/Session/applyEdits(_:failurePolicy:)``). +/// +/// All conformances are expected to be safe to share across actors and concurrency domains; the protocol +/// therefore refines `Sendable`. ``Workspace`` itself conforms. +public protocol WorkspaceReading: Sendable { + /// Reads raw file contents from the workspace. + func readData(from path: WorkspacePath) async throws -> Data + + /// Reads a UTF-8 file from the workspace. + func readFile(_ path: WorkspacePath) async throws -> String + + /// Returns whether an entry exists at `path`. + func exists(_ path: WorkspacePath) async -> Bool + + /// Returns metadata for the entry at `path`. + func fileInfo(at path: WorkspacePath) async throws -> FileInfo + + /// Lists the direct children of the directory at `path`. + func listDirectory(at path: WorkspacePath) async throws -> [DirectoryEntry] + + /// Expands a glob pattern relative to `currentDirectory`. + func glob(_ pattern: String, currentDirectory: WorkspacePath) async throws -> [WorkspacePath] + + /// Builds a recursive tree representation for the entry at `path`. + func walkTree(_ path: WorkspacePath, maxDepth: Int?) async throws -> FileTree + + /// Summarizes the subtree rooted at `path`. + func summarizeTree(_ path: WorkspacePath, maxDepth: Int?) async throws -> FileTreeSummary + + /// Captures a durable snapshot of the subtree rooted at `path`. + func captureSnapshot(at path: WorkspacePath) async throws -> Snapshot + + /// Returns a preview of a replacement request without mutating the workspace. + func previewReplacement(_ request: ReplacementRequest) async throws -> ReplacementResult + + /// Returns a preview of a batch of edits without mutating the workspace. + func previewEdits(_ edits: [FileEdit]) async throws -> FileEdit.BatchResult + + /// Watches for future changes affecting `path`. + func watchChanges(at path: WorkspacePath, recursive: Bool) async -> AsyncStream +} + +extension WorkspaceReading { + /// Reads and decodes JSON from a UTF-8 file. + public func readJSON(_ type: T.Type = T.self, from path: WorkspacePath) async throws -> T { + let data = try await readData(from: path) + do { + return try JSONDecoder().decode(T.self, from: data) + } catch { + throw WorkspaceError.decodingFailed(path, underlying: String(describing: error)) + } + } + + /// Convenience overload that globs from the workspace root. + public func glob(_ pattern: String) async throws -> [WorkspacePath] { + try await glob(pattern, currentDirectory: .root) + } + + /// Convenience overload that walks the entire subtree at `path`. + public func walkTree(_ path: WorkspacePath) async throws -> FileTree { + try await walkTree(path, maxDepth: nil) + } + + /// Convenience overload that summarizes the entire subtree at `path`. + public func summarizeTree(_ path: WorkspacePath) async throws -> FileTreeSummary { + try await summarizeTree(path, maxDepth: nil) + } + + /// Convenience overload that captures a snapshot of the workspace root. + public func captureSnapshot() async throws -> Snapshot { + try await captureSnapshot(at: .root) + } + + /// Convenience overload that watches recursively. + public func watchChanges(at path: WorkspacePath) async -> AsyncStream { + await watchChanges(at: path, recursive: true) + } +} + +extension Workspace: WorkspaceReading {} diff --git a/Tests/WorkspaceTests/CheckpointStoreTests.swift b/Tests/WorkspaceTests/CheckpointStoreTests.swift index d2177aa..332bd06 100644 --- a/Tests/WorkspaceTests/CheckpointStoreTests.swift +++ b/Tests/WorkspaceTests/CheckpointStoreTests.swift @@ -311,6 +311,94 @@ struct CheckpointStoreTests { #expect(listed[0].label == "v2") } + @Test + func `FileCheckpointStore listMutationRecords returns empty array when no log file exists`() async throws { + let root = try makeTempDirectory() + defer { removeTempDirectory(root) } + + let workspaceId = UUID() + let store = FileCheckpointStore(rootDirectory: root) + + let mutations = try await store.listMutationRecords(workspaceId: workspaceId) + #expect(mutations.isEmpty) + + let workspaceRoot = root.appendingPathComponent(workspaceId.uuidString, isDirectory: true) + let mutationsFile = workspaceRoot.appendingPathComponent("mutations.json") + #expect(!FileManager.default.fileExists(atPath: mutationsFile.path)) + } + + @Test + func `FileCheckpointStore listMutationRecords treats an empty mutations file as no records`() async throws { + let root = try makeTempDirectory() + defer { removeTempDirectory(root) } + + let workspaceId = UUID() + let workspaceRoot = root.appendingPathComponent(workspaceId.uuidString, isDirectory: true) + try FileManager.default.createDirectory(at: workspaceRoot, withIntermediateDirectories: true) + let mutationsFile = workspaceRoot.appendingPathComponent("mutations.json") + try Data().write(to: mutationsFile) + + let store = FileCheckpointStore(rootDirectory: root) + + let mutations = try await store.listMutationRecords(workspaceId: workspaceId) + #expect(mutations.isEmpty) + + let appended = MutationRecord( + sequence: 1, + workspaceId: workspaceId, + sessionId: nil, + scope: .shared, + kind: .writeFile, + touchedPaths: ["/x"], + fileChanges: [] + ) + try await store.appendMutation(appended) + + let after = try await store.listMutationRecords(workspaceId: workspaceId) + #expect(after == [appended]) + } + + @Test + func `FileCheckpointStore appendMutation serializes concurrent writers without losing records`() async throws { + let root = try makeTempDirectory() + defer { removeTempDirectory(root) } + + let workspaceId = UUID() + let writerCount = 8 + let perWriter = 25 + let stores = (0.. URL { let base = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true) let url = base.appendingPathComponent("CheckpointStoreTests-\(UUID().uuidString)", isDirectory: true) diff --git a/Tests/WorkspaceTests/HistoryTests.swift b/Tests/WorkspaceTests/HistoryTests.swift index 8e1c6b8..4e7c583 100644 --- a/Tests/WorkspaceTests/HistoryTests.swift +++ b/Tests/WorkspaceTests/HistoryTests.swift @@ -898,6 +898,102 @@ struct HistoryTests { #expect(events.contains(where: { $0.checkpoint.id == checkpoint.id && $0.kind == .created })) } + @Test + func `applyEdits with one batch touching multiple files clears the top-level mutation diff`() async throws { + let history = History() + try await history.writeFile("/a.txt", content: "alpha\n") + try await history.writeFile("/b.txt", content: "bravo\n") + + let beforeCount = try await history.mutationRecords(scope: .shared).count + + let result = try await history.applyEdits([ + .writeFile(path: "/a.txt", content: "alpha-2\n"), + .writeFile(path: "/b.txt", content: "bravo-2\n") + ]) + #expect(result.edits.count == 2) + + let after = try await history.mutationRecords(scope: .shared) + #expect(after.count == beforeCount + 1) + let mutation = try #require(after.last) + #expect(mutation.kind == .applyEdits) + #expect(mutation.diff == nil) + #expect(mutation.fileChanges.count == 2) + #expect(mutation.fileChanges.allSatisfy { $0.diff != nil }) + } + + @Test + func `applyEdits with one batch touching one file keeps the top-level mutation diff`() async throws { + let history = History() + try await history.writeFile("/only.txt", content: "first\n") + + let result = try await history.applyEdits([ + .writeFile(path: "/only.txt", content: "second\n") + ]) + #expect(result.edits.count == 1) + + let mutation = try #require((try await history.mutationRecords(scope: .shared)).last) + #expect(mutation.kind == .applyEdits) + #expect(mutation.fileChanges.count == 1) + #expect(mutation.diff != nil) + #expect(mutation.diff == mutation.fileChanges[0].diff) + } + + @Test + func `publishSessionHead emits per-file UTF-8 diffs in the recorded mutation`() async throws { + let history = History() + try await history.writeFile("/notes.txt", content: "one\n") + try await history.writeFile("/keep.txt", content: "keep\n") + + let session = try await history.createSession() + try await session.writeFile("/notes.txt", content: "one\ntwo\n") + try await session.writeFile("/new.txt", content: "fresh\n") + + _ = try await session.publish(label: "publish-with-diffs") + + let publishMutation = try #require( + (try await history.mutationRecords(scope: .shared)) + .last(where: { $0.kind == .publishSessionHead }) + ) + + let changesByPath = Dictionary(uniqueKeysWithValues: publishMutation.fileChanges.map { ($0.path, $0) }) + let modified = try #require(changesByPath["/notes.txt"]) + #expect(modified.effect == .modified) + #expect(modified.diff != nil) + #expect(modified.diff?.hunks.isEmpty == false) + + let created = try #require(changesByPath["/new.txt"]) + #expect(created.effect == .created) + #expect(created.diff != nil) + #expect(created.diff?.hunks.isEmpty == false) + + // Top-level diff is set only when there is exactly one file change in the delta. + #expect(publishMutation.diff == nil) + } + + @Test + func `publishSessionHead emits a delete diff when the session removes a UTF-8 file`() async throws { + let history = History() + try await history.writeFile("/gone.txt", content: "going-away\n") + + let session = try await history.createSession() + try await session.removeItem(at: "/gone.txt") + + _ = try await session.publish(label: "publish-with-deletion") + + let publishMutation = try #require( + (try await history.mutationRecords(scope: .shared)) + .last(where: { $0.kind == .publishSessionHead }) + ) + let deleted = try #require(publishMutation.fileChanges.first { $0.path == "/gone.txt" }) + #expect(deleted.effect == .deleted) + #expect(deleted.diff != nil) + #expect(deleted.diff?.hunks.isEmpty == false) + + // Single-change delta -> top-level diff equals the only file change's diff. + #expect(publishMutation.fileChanges.count == 1) + #expect(publishMutation.diff == deleted.diff) + } + // MARK: - Helpers private func makeTempDirectory() throws -> URL { diff --git a/Tests/WorkspaceTests/TextDiffTests.swift b/Tests/WorkspaceTests/TextDiffTests.swift new file mode 100644 index 0000000..0e30fcb --- /dev/null +++ b/Tests/WorkspaceTests/TextDiffTests.swift @@ -0,0 +1,111 @@ +import Foundation +import Testing +@testable import Workspace + +@Suite("TextDiff") +struct TextDiffTests { + @Test + func `lineBased returns no hunks for identical content`() { + let diff = TextDiff.lineBased(from: "alpha\nbravo\n", to: "alpha\nbravo\n") + #expect(diff.hunks.isEmpty) + } + + @Test + func `lineBased returns no hunks for two empty strings`() { + let diff = TextDiff.lineBased(from: "", to: "") + #expect(diff.hunks.isEmpty) + } + + @Test + func `lineBased treats creation from empty as a single added hunk`() { + let diff = TextDiff.lineBased(from: "", to: "hello\nworld\n") + let hunk = try! #require(diff.hunks.first) + #expect(diff.hunks.count == 1) + + let added = hunk.lines.filter { $0.kind == .added } + #expect(added.map(\.text) == ["hello", "world"]) + #expect(added.allSatisfy { $0.hasTrailingNewline }) + #expect(added.compactMap(\.newLineNumber) == [1, 2]) + #expect(hunk.lines.allSatisfy { $0.kind != .removed }) + } + + @Test + func `lineBased treats deletion to empty as a single removed hunk`() { + let diff = TextDiff.lineBased(from: "keep\nme\n", to: "") + let hunk = try! #require(diff.hunks.first) + #expect(diff.hunks.count == 1) + + let removed = hunk.lines.filter { $0.kind == .removed } + #expect(removed.map(\.text) == ["keep", "me"]) + #expect(removed.compactMap(\.oldLineNumber) == [1, 2]) + #expect(hunk.lines.allSatisfy { $0.kind != .added }) + } + + @Test + func `lineBased emits add and remove with surrounding context for a single-line change`() { + let original = """ + one + two + three + four + five + """ + let updated = """ + one + two + THREE + four + five + """ + + let diff = TextDiff.lineBased(from: original, to: updated) + let hunk = try! #require(diff.hunks.first) + #expect(diff.hunks.count == 1) + + let removedTexts = hunk.lines.filter { $0.kind == .removed }.map(\.text) + let addedTexts = hunk.lines.filter { $0.kind == .added }.map(\.text) + #expect(removedTexts == ["three"]) + #expect(addedTexts == ["THREE"]) + + let contextTexts = hunk.lines.filter { $0.kind == .context }.map(\.text) + #expect(contextTexts == ["one", "two", "four", "five"]) + } + + @Test + func `lineBased preserves trailing-newline metadata on the last line`() { + let withNewline = TextDiff.lineBased(from: "", to: "x\n") + let withoutNewline = TextDiff.lineBased(from: "", to: "x") + + let firstWithNewline = try! #require(withNewline.hunks.first?.lines.first) + let firstWithoutNewline = try! #require(withoutNewline.hunks.first?.lines.first) + + #expect(firstWithNewline.hasTrailingNewline == true) + #expect(firstWithoutNewline.hasTrailingNewline == false) + } + + @Test + func `lineBased matches Workspace previewReplacement diff for the same edit`() async throws { + let original = """ + red + green + blue + """ + let updated = """ + red + teal + blue + """ + + let workspace = Workspace(filesystem: InMemoryFilesystem()) + try await workspace.writeFile("/colors.txt", content: original) + + let preview = try await workspace.previewReplacement( + ReplacementRequest(pattern: "/colors.txt", search: "green", replacement: "teal") + ) + let workspaceDiff = try #require(preview.changes.first?.diff) + + let utilityDiff = TextDiff.lineBased(from: original, to: updated) + + #expect(workspaceDiff == utilityDiff) + } +} From 57c8260a717015b1261e739733a105273bb34ef1 Mon Sep 17 00:00:00 2001 From: Zac White Date: Sat, 18 Apr 2026 23:18:22 -0700 Subject: [PATCH 10/14] Updated .gitignore --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 5a8bf0a..c08a29b 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,4 @@ references Package.resolved .DS_Store +.cursor From 33e6f3889395b9652f97216c0ccd64719443b604 Mon Sep 17 00:00:00 2001 From: Zac White Date: Sat, 18 Apr 2026 23:36:47 -0700 Subject: [PATCH 11/14] Simplification of API surface area --- README.md | 268 ++-- Sources/Workspace/Checkpoint.swift | 113 ++ Sources/Workspace/CheckpointStore.swift | 52 +- Sources/Workspace/FS/FileSystem.swift | 18 + Sources/Workspace/History.swift | 1356 ----------------- Sources/Workspace/HistoryTypes.swift | 220 --- Sources/Workspace/MutationRecord.swift | 46 + Sources/Workspace/Snapshot.swift | 6 +- Sources/Workspace/Workspace+Branching.swift | 96 ++ Sources/Workspace/Workspace+Checkpoints.swift | 81 + Sources/Workspace/Workspace+Internals.swift | 449 ++++++ Sources/Workspace/Workspace.swift | 231 ++- Sources/Workspace/WorkspaceReading.swift | 88 -- .../WorkspaceTests/CheckpointStoreTests.swift | 49 +- Tests/WorkspaceTests/CheckpointTests.swift | 126 ++ Tests/WorkspaceTests/HistoryTests.swift | 1009 ------------ Tests/WorkspaceTests/HistoryTypesTests.swift | 165 -- .../WorkspaceCheckpointTests.swift | 370 +++++ 18 files changed, 1678 insertions(+), 3065 deletions(-) create mode 100644 Sources/Workspace/Checkpoint.swift delete mode 100644 Sources/Workspace/History.swift delete mode 100644 Sources/Workspace/HistoryTypes.swift create mode 100644 Sources/Workspace/MutationRecord.swift create mode 100644 Sources/Workspace/Workspace+Branching.swift create mode 100644 Sources/Workspace/Workspace+Checkpoints.swift create mode 100644 Sources/Workspace/Workspace+Internals.swift delete mode 100644 Sources/Workspace/WorkspaceReading.swift create mode 100644 Tests/WorkspaceTests/CheckpointTests.swift delete mode 100644 Tests/WorkspaceTests/HistoryTests.swift delete mode 100644 Tests/WorkspaceTests/HistoryTypesTests.swift create mode 100644 Tests/WorkspaceTests/WorkspaceCheckpointTests.swift diff --git a/README.md b/README.md index 422bba0..6fc330d 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ It gives you: - copy-on-write overlays - mounted multi-root workspaces - explicit permission checks for file operations -- a typed `Workspace` actor for reading, writing, walking trees, and applying batched edits +- one `Workspace` actor for reads, tracked writes, tree summaries, snapshots, checkpoints, rollback, branching, and merge `Workspace` is beta software and should be used at your own risk. It is useful for app and agent workflows, but it is not a hardened sandbox or a security boundary by itself. @@ -20,27 +20,25 @@ Many agent and tooling flows need more than plain disk I/O: - a shared scratch or memory area - the ability to read a real project without writing back to it - explicit approvals before reads or writes -- tree summaries, JSON helpers, and batched edits without shell parsing +- tree summaries, JSON helpers, batched edits, and checkpoints without shell parsing `Workspace` provides one model for those cases. You can back it with memory, a rooted directory on disk, an overlay snapshot, or a mounted combination of several filesystems. ## What It Provides -- `Workspace`: high-level actor API for common file operations and batch edits +- `Workspace`: high-level actor API for file operations, mutation tracking, snapshots, checkpoints, rollback, branch, and merge +- `Checkpoint` and `CheckpointEvent`: public checkpoint metadata and event stream values +- `Snapshot`: durable capture/restore of a subtree - `ChangeEvent`: structured change notifications emitted by `Workspace.watchChanges(at:recursive:)` -- `FileSystem`: low-level protocol for custom filesystem backends (see also `ReadableFileSystem` / `WritableFileSystem`) +- `FileSystem`: low-level protocol for custom filesystem backends - `ReadWriteFilesystem`: real disk access rooted to a configured directory -- `InMemoryFilesystem`: fully in-memory filesystem for isolated sessions and tests +- `InMemoryFilesystem`: fully in-memory filesystem for isolated workspaces and tests - `OverlayFilesystem`: snapshot a disk root and keep writes in memory - `MountableFilesystem`: compose multiple filesystems under one virtual tree - `PermissionedFileSystem`: wrap any filesystem with operation-level approvals - `SandboxFilesystem`: convenience wrapper for app sandbox roots - `SecurityScopedFilesystem`: security-scoped URL and bookmark-backed access - `WorkspacePath`: path normalization and joining helpers -- `History`: tracks a shared workspace, optional session overlays, checkpoints, snapshots, mutation logs, rollback, and publish flows -- `WorkspaceReading`: read-only protocol exposed by `History.shared` and `Session.workspace` so writes cannot bypass history tracking through those handles -- `Snapshot`: durable capture/restore of a subtree (also used by checkpoints) -- `CheckpointStore`: persist checkpoints and mutations (`InMemoryCheckpointStore`, `FileCheckpointStore`) ## Installation @@ -58,57 +56,105 @@ Until this package is published to a remote, use it as a local SwiftPM dependenc ] ``` -## Quick Start +## Workspace + +Create an ephemeral workspace with the default initializer: ```swift import Workspace -let filesystem = InMemoryFilesystem() - -let workspace = Workspace(filesystem: filesystem) +let workspace = Workspace() try await workspace.writeFile("/notes/todo.txt", content: "ship it") let text = try await workspace.readFile("/notes/todo.txt") print(text) // ship it ``` -### Binary Data +Use a custom filesystem when the files should come from memory, disk, an overlay, a mount table, or a permission wrapper: ```swift -import Workspace +let filesystem = InMemoryFilesystem() +let workspace = Workspace(filesystem: filesystem) +``` -let workspace = Workspace(filesystem: InMemoryFilesystem()) -try await workspace.writeData(Data([0xDE, 0xAD, 0xBE, 0xEF]), to: "/blob.bin") +Persist checkpoint artifacts with file-backed storage: -let blob = try await workspace.readData(from: "/blob.bin") -print(blob.count) // 4 +```swift +let root = URL(fileURLWithPath: "/tmp/workspace-checkpoints", isDirectory: true) +let workspace = Workspace( + filesystem: InMemoryFilesystem(), + storage: .directory(at: root) +) ``` -### JSON Helpers +`Storage.directory(at:)` writes checkpoint, snapshot, and mutation JSON under the supplied URL. Mutation log appends are serialized through a `mutations.lock` sidecar using `flock` where the OS supports it; coordinating multiple hosts or network disks that do not honor `flock` may still require application-level synchronization. + +### Reads ```swift -import Workspace +let data = try await workspace.readData(from: "/blob.bin") +let text = try await workspace.readFile("/notes/todo.txt") +let exists = await workspace.exists("/notes/todo.txt") +let info = try await workspace.fileInfo(at: "/notes/todo.txt") +let entries = try await workspace.listDirectory(at: "/notes") +let matches = try await workspace.glob("/notes/*.txt", currentDirectory: "/") +let tree = try await workspace.walkTree("/") +let summary = try await workspace.summarizeTree("/") +``` +JSON helpers encode and decode through `Codable`: + +```swift struct Config: Codable { var name: String var enabled: Bool } -let filesystem = InMemoryFilesystem() - -let workspace = Workspace(filesystem: filesystem) try await workspace.writeJSON(Config(name: "demo", enabled: true), to: "/config.json") - -let config = try await workspace.readJSON(Config.self, from: "/config.json") +let config: Config = try await workspace.readJSON(from: "/config.json") print(config.enabled) // true ``` -### Change Watching +### Tracked Writes + +Every public write records an internal mutation: ```swift -import Workspace +try await workspace.writeFile("/notes/todo.txt", content: "one") +try await workspace.appendFile("/notes/todo.txt", content: " two") +try await workspace.writeData(Data([0xDE, 0xAD]), to: "/blob.bin") +try await workspace.createDirectory(at: "/docs") +try await workspace.copyItem(from: "/notes/todo.txt", to: "/docs/todo.txt") +try await workspace.moveItem(from: "/docs/todo.txt", to: "/docs/done.txt") +try await workspace.removeItem(at: "/docs/done.txt") +``` + +Batched edits and replacements can be previewed before execution: -let workspace = Workspace(filesystem: InMemoryFilesystem()) +```swift +let preview = try await workspace.previewEdits([ + .createDirectory(path: "/src"), + .writeFile(path: "/src/a.txt", content: "one"), +]) + +let result = try await workspace.applyEdits([ + .appendFile(path: "/src/a.txt", content: " two"), +]) + +let replacement = try await workspace.applyReplacement( + ReplacementRequest(pattern: "/src/*.txt", search: "one", replacement: "1") +) + +print(preview.mode) // preview +print(result.mode) // execution +print(replacement.mode) // execution +``` + +`applyEdits` and `applyReplacement` use logical rollback when `failurePolicy` is `.rollback`. Other policies may leave partial changes in place. + +### Watchers + +```swift let changes = await workspace.watchChanges(at: "/notes") Task { @@ -120,51 +166,65 @@ Task { try await workspace.writeFile("/notes/todo.txt", content: "ship it") ``` -## History & checkpoints +Checkpoint events are separate from file change events: -`History` wraps a **`shared`** ``Workspace`` (usually your canonical tree) plus optional **`Session`** overlays cloned from the current shared head. Sessions edit in isolation, create **session checkpoints**, optionally **publish** back to shared (with optimistic concurrency when the shared head advances), or **rollback** to prior shared or session checkpoints. +```swift +let checkpoints = try await workspace.watchCheckpointEvents() + +Task { + for await event in checkpoints { + print(event.kind, event.checkpoint.label ?? "") + } +} +``` -- **Snapshots** store full subtree state; **checkpoints** store labels, scope, lineage, summaries, and point at a snapshot id. -- **`History.shared`** and **`Session.workspace`** are typed as `any WorkspaceReading`, so write APIs are unreachable through them. Use **`History.writeFile`** / **`Session.writeFile`** / **`applyEdits`** etc. for any change you want journaled, checkpointed, or published. -- **`History.Storage.directory(at:)`** writes JSON under your URL. Mutation log appends are serialized through a `mutations.lock` sidecar using `flock` where the OS supports it; coordinating multiple hosts or network disks that don't honor `flock` may still require your own synchronization. +### Snapshots And Checkpoints -Quick outline: +Snapshots capture filesystem contents. Checkpoints persist a snapshot plus lineage and summary metadata. ```swift -import Foundation -import Workspace +try await workspace.writeFile("/readme.txt", content: "v1") +let checkpoint = try await workspace.createCheckpoint(label: "before edits") -let history = History(filesystem: InMemoryFilesystem()) +try await workspace.writeFile("/readme.txt", content: "v2") +let rollback = try await workspace.rollback(to: checkpoint.id, label: "restore v1") -try await history.writeFile("/readme.txt", content: "hello") -let sharedCP = try await history.createCheckpoint(label: "before agent") +let all = try await workspace.listCheckpoints() +let snapshot = try await workspace.snapshot(for: checkpoint) -let session = try await history.createSession() -try await session.writeFile("/draft.txt", content: "wip") -try await session.createCheckpoint(label: "scratch") +print(rollback.rollbackSourceCheckpointId == checkpoint.id) // true +print(all.count) +print(snapshot.rootPath) +``` -try await session.publish(label: "landed") +Public `restoreSnapshot(_:)` is also tracked as a workspace mutation. Checkpoint rollback uses an internal untracked restore so the rollback is represented by the rollback checkpoint, not by a second restore mutation. -let diskRoot = URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent("workspace-history") -let persisted = History( - workspaceId: history.workspaceId, - filesystem: InMemoryFilesystem(), - storage: .directory(at: diskRoot) -) +### Branch And Merge + +Branches are isolated `Workspace` actors cloned from the parent's current snapshot. They share the checkpoint store but not the filesystem, watchers, or mutation sequence. + +```swift +try await workspace.writeFile("/readme.txt", content: "base") +let base = try await workspace.createCheckpoint(label: "base") + +let branch = try await workspace.branch(label: "agent draft") +try await branch.writeFile("/readme.txt", content: "draft") +let branchHead = try await branch.createCheckpoint(label: "draft ready") + +let merged = try await workspace.merge(branch, label: "merge draft") + +print(merged.parentCheckpointId == base.id) // true +print(merged.mergedFromWorkspaceId == branch.workspaceId) // true +print(merged.mergedFromCheckpointId == branchHead.id) // true ``` -Implement a custom **`CheckpointStore`** for non-file backends using the **`History(workspaceId:filesystem:store:)`** initializer. +`merge(_:)` is optimistic. If the parent workspace head changed after `branch()` was created, merge throws `WorkspaceError.mergeConflict(parentWorkspaceId:expectedBase:actualHead:)`. -## Common Patterns +## Common Filesystem Patterns ### Rooted Disk Workspace -Use `ReadWriteFilesystem` when you want real file access under one root: - ```swift -import Foundation -import Workspace - let root = URL(fileURLWithPath: "/tmp/demo-workspace", isDirectory: true) let filesystem = try ReadWriteFilesystem(rootDirectory: root) let workspace = Workspace(filesystem: filesystem) @@ -175,12 +235,7 @@ try await workspace.writeFile("/src/main.swift", content: "print(\"hello\")\n") ### Overlay On Top Of A Real Project -Use `OverlayFilesystem` when you want to read a real project but keep writes isolated in memory: - ```swift -import Foundation -import Workspace - let projectRoot = URL(fileURLWithPath: "/path/to/project", isDirectory: true) let filesystem = try await OverlayFilesystem(rootDirectory: projectRoot) let workspace = Workspace(filesystem: filesystem) @@ -189,44 +244,28 @@ let preview = try await workspace.summarizeTree("/Sources", maxDepth: 2) try await workspace.writeFile("/SCRATCH.md", content: "overlay-only change\n") ``` -### Multiple Workspaces Plus Shared Memory - -Use `MountableFilesystem` to combine isolated roots and shared state in one virtual tree: +### Mounted Workspaces ```swift -import Workspace - -let workspaceA = InMemoryFilesystem() - -let workspaceB = InMemoryFilesystem() - -let sharedMemory = InMemoryFilesystem() - let mounted = MountableFilesystem( base: InMemoryFilesystem(), mounts: [ - .init(mountPoint: "/workspace-a", filesystem: workspaceA), - .init(mountPoint: "/workspace-b", filesystem: workspaceB), - .init(mountPoint: "/memory", filesystem: sharedMemory), + .init(mountPoint: "/workspace-a", filesystem: InMemoryFilesystem()), + .init(mountPoint: "/workspace-b", filesystem: InMemoryFilesystem()), + .init(mountPoint: "/memory", filesystem: InMemoryFilesystem()), ] ) let workspace = Workspace(filesystem: mounted) try await workspace.writeFile("/memory/plan.txt", content: "shared notes") -try await workspace.copyItem(from: "/memory/plan.txt", to: "/workspace-a/plan.txt", recursive: false) +try await workspace.copyItem(from: "/memory/plan.txt", to: "/workspace-a/plan.txt") ``` ### Operation-Level Permissions -Use `PermissionedFileSystem` when the host should decide which operations are allowed: - ```swift -import Workspace - -let base = InMemoryFilesystem() - let filesystem = PermissionedFileSystem( - base: base, + base: InMemoryFilesystem(), authorizer: PermissionAuthorizer { request in switch request.operation { case .readFile, .listDirectory, .stat: @@ -240,69 +279,18 @@ let filesystem = PermissionedFileSystem( let workspace = Workspace(filesystem: filesystem) ``` -## Batched Edits - -`Workspace` includes explicit preview and apply APIs for tool-driven mutations: - -```swift -let result = try await workspace.applyEdits([ - .createDirectory(path: "/src"), - .writeFile(path: "/src/a.txt", content: "one"), - .appendFile(path: "/src/a.txt", content: " two"), - .copy(from: "/src/a.txt", to: "/src/b.txt"), -]) - -let writeChange = result.edits[1].fileChanges[0] -print(writeChange.status) // applied -print(writeChange.diff?.hunks.count) // Optional(1) -``` - -You can preview a batch before executing it: - -```swift -let preview = try await workspace.previewEdits([ - .copy(from: "/docs/guide.txt", to: "/workspace/guide.txt"), - .appendFile(path: "/workspace/notes.txt", content: "\nnext") -]) - -let appendPreview = preview.edits[1].fileChanges[0] -print(appendPreview.status) // planned -print(appendPreview.diff?.hunks.count) // Optional(...) -``` - -Text replacements use a request type so scope, include, exclude, and matching strategy live in one value: - -```swift -let preview = try await workspace.previewReplacement( - ReplacementRequest( - pattern: "/src/*.txt", - search: .literal("foo"), - replacement: "bar" - ) -) - -let replacement = preview.changes[0] -print(replacement.status) // planned -print(replacement.replacements) // number of matched replacements -print(replacement.diff.hunks) // structured line-based diff hunks -``` - -`ReplacementRequest`, `ReplacementResult`, edit metadata, tree metadata, and diffs are `Codable`, which makes previews and results easy to serialize for agent or tool workflows. - ## Important Behavior -- `InMemoryFilesystem` is ready to use immediately after initialization. Call `await reset()` when you explicitly want to clear it. Actor isolation serializes access to the tree. -- `OverlayFilesystem` snapshots a real root into memory. Call `try await reload()` when you explicitly want to discard overlay edits and rebuild from disk. -- `ReadWriteFilesystem` and `OverlayFilesystem` normalize paths and enforce a rooted/jail model. -- `PermissionedFileSystem` sees normalized virtual paths, not raw user input paths. -- `FileSystem` provides default throwing implementations for advanced operations like symlinks, hard links, permission mutation, and real-path resolution, so minimal custom backends only need to implement the core read/write surface. +- Reads do not load checkpoint state. Writes, checkpoint calls, rollback, branch, and merge do. +- All checkpoint reads share the workspace actor barrier with file I/O, so they serialize behind in-flight writes. +- `.inMemory` storage still records one mutation per write in memory, including old-content capture for text diffs. +- Branches created with `.directory(at:)` share the same storage directory as the parent but are partitioned by `workspaceId`. - `walkTree` and `summarizeTree` return stable path ordering, which is useful for deterministic tool output. ## Limitations - `Workspace` is not a hardened sandbox. -- `applyEdits` and `applyReplacement` use logical rollback when `failurePolicy` is `.rollback`; other policies may leave partial changes in place. -- Rollback is not crash-safe and does not coordinate with external processes. +- Logical rollback is not crash-safe and does not coordinate with external processes. - `OverlayFilesystem` does not persist writes back to the original root. - Hard links across mounts are not supported. - Some filesystem types still use `@unchecked Sendable`; treat shared mutable class-based implementations carefully unless their synchronization guarantees are documented. diff --git a/Sources/Workspace/Checkpoint.swift b/Sources/Workspace/Checkpoint.swift new file mode 100644 index 0000000..bd3baae --- /dev/null +++ b/Sources/Workspace/Checkpoint.swift @@ -0,0 +1,113 @@ +import Foundation + +/// A labeled, parented moment in workspace history. +/// +/// A `Checkpoint` records when a workspace state was captured, the parent checkpoint it follows, +/// and a compact summary of the changes relative to that parent. The actual file contents live in a +/// separate ``Snapshot`` artifact that can be loaded on demand with ``Workspace/snapshot(for:)``. +public struct Checkpoint: Sendable, Codable, Equatable { + /// A lightweight summary of changes relative to the parent checkpoint. + public struct Summary: Sendable, Codable, Equatable { + /// The number of paths that differ from the parent checkpoint. + public var changeCount: Int + /// The paths that changed relative to the parent checkpoint. + public var touchedPaths: [WorkspacePath] + /// Whether any changed file paths involve UTF-8 decodable text. + public var hasTextDiffs: Bool + + /// Creates a checkpoint summary. + public init(changeCount: Int, touchedPaths: [WorkspacePath], hasTextDiffs: Bool) { + self.changeCount = changeCount + self.touchedPaths = touchedPaths + self.hasTextDiffs = hasTextDiffs + } + } + + /// The checkpoint identifier. + public var id: UUID + /// The workspace that owns this checkpoint. + public var workspaceId: UUID + /// An optional human-readable label. + public var label: String? + /// The checkpoint creation timestamp. + public var createdAt: Date + /// The previous checkpoint in the same workspace, when present. + public var parentCheckpointId: UUID? + /// The parent workspace's head checkpoint when this workspace was branched. + public var baseCheckpointId: UUID? + /// The workspace that was merged to create this checkpoint, when applicable. + public var mergedFromWorkspaceId: UUID? + /// The merged workspace's head checkpoint when this checkpoint was created. + public var mergedFromCheckpointId: UUID? + /// The source checkpoint a rollback restored from when applicable. + public var rollbackSourceCheckpointId: UUID? + /// A summary of what changed relative to the parent checkpoint. + public var summary: Summary + + var firstMutationSequence: Int? + var lastMutationSequence: Int? + var mutationCursor: Int + var snapshotId: UUID + + var inferredEventKind: CheckpointEvent.Kind { + if rollbackSourceCheckpointId != nil { return .rolledBack } + if mergedFromWorkspaceId != nil { return .merged } + return .created + } + + init( + id: UUID = UUID(), + workspaceId: UUID, + label: String?, + createdAt: Date = Date(), + parentCheckpointId: UUID?, + baseCheckpointId: UUID? = nil, + mergedFromWorkspaceId: UUID? = nil, + mergedFromCheckpointId: UUID? = nil, + rollbackSourceCheckpointId: UUID? = nil, + firstMutationSequence: Int?, + lastMutationSequence: Int?, + mutationCursor: Int, + snapshotId: UUID, + summary: Summary + ) { + self.id = id + self.workspaceId = workspaceId + self.label = label + self.createdAt = createdAt + self.parentCheckpointId = parentCheckpointId + self.baseCheckpointId = baseCheckpointId + self.mergedFromWorkspaceId = mergedFromWorkspaceId + self.mergedFromCheckpointId = mergedFromCheckpointId + self.rollbackSourceCheckpointId = rollbackSourceCheckpointId + self.firstMutationSequence = firstMutationSequence + self.lastMutationSequence = lastMutationSequence + self.mutationCursor = mutationCursor + self.snapshotId = snapshotId + self.summary = summary + } +} + +/// A checkpoint event emitted by ``Workspace``. +public struct CheckpointEvent: Sendable, Codable, Equatable { + /// The event kind. + public enum Kind: String, Sendable, Codable { + /// A checkpoint was created. + case created + /// A rollback restored a prior checkpoint. + case rolledBack + /// A branch workspace was merged. + case merged + } + + /// The event kind. + public var kind: Kind + /// The checkpoint that triggered this event. + public var checkpoint: Checkpoint + + /// Creates a checkpoint event. + public init(kind: Kind, checkpoint: Checkpoint) { + self.kind = kind + self.checkpoint = checkpoint + } +} diff --git a/Sources/Workspace/CheckpointStore.swift b/Sources/Workspace/CheckpointStore.swift index 573e5a2..b076cfd 100644 --- a/Sources/Workspace/CheckpointStore.swift +++ b/Sources/Workspace/CheckpointStore.swift @@ -7,25 +7,25 @@ import Glibc #endif /// Persistence for workspace checkpoints, snapshots, and mutation logs. -public protocol CheckpointStore: AnyObject, Sendable { - func saveCheckpoint(_ checkpoint: History.Checkpoint) async throws - func loadCheckpoint(id: UUID, workspaceId: UUID) async throws -> History.Checkpoint? - func listCheckpoints(workspaceId: UUID) async throws -> [History.Checkpoint] +protocol CheckpointStore: AnyObject, Sendable { + func saveCheckpoint(_ checkpoint: Checkpoint) async throws + func loadCheckpoint(id: UUID, workspaceId: UUID) async throws -> Checkpoint? + func listCheckpoints(workspaceId: UUID) async throws -> [Checkpoint] func saveSnapshot(_ snapshot: Snapshot, workspaceId: UUID) async throws func loadSnapshot(id: UUID, workspaceId: UUID) async throws -> Snapshot? func appendMutation(_ mutation: MutationRecord) async throws func listMutationRecords(workspaceId: UUID) async throws -> [MutationRecord] } -/// An in-memory checkpoint store for tests and ephemeral sessions. -public actor InMemoryCheckpointStore: CheckpointStore { - private var checkpointsByWorkspace: [UUID: [History.Checkpoint]] = [:] +/// An in-memory checkpoint store for tests and ephemeral workspaces. +actor InMemoryCheckpointStore: CheckpointStore { + private var checkpointsByWorkspace: [UUID: [Checkpoint]] = [:] private var snapshotsByWorkspace: [UUID: [UUID: Snapshot]] = [:] private var mutationsByWorkspace: [UUID: [MutationRecord]] = [:] - public init() {} + init() {} - public func saveCheckpoint(_ checkpoint: History.Checkpoint) async throws { + func saveCheckpoint(_ checkpoint: Checkpoint) async throws { var list = checkpointsByWorkspace[checkpoint.workspaceId] ?? [] if let index = list.firstIndex(where: { $0.id == checkpoint.id }) { list[index] = checkpoint @@ -35,11 +35,11 @@ public actor InMemoryCheckpointStore: CheckpointStore { checkpointsByWorkspace[checkpoint.workspaceId] = list } - public func loadCheckpoint(id: UUID, workspaceId: UUID) async throws -> History.Checkpoint? { + func loadCheckpoint(id: UUID, workspaceId: UUID) async throws -> Checkpoint? { checkpointsByWorkspace[workspaceId]?.first { $0.id == id } } - public func listCheckpoints(workspaceId: UUID) async throws -> [History.Checkpoint] { + func listCheckpoints(workspaceId: UUID) async throws -> [Checkpoint] { (checkpointsByWorkspace[workspaceId] ?? []).sorted { if $0.createdAt == $1.createdAt { return $0.id.uuidString < $1.id.uuidString @@ -48,19 +48,19 @@ public actor InMemoryCheckpointStore: CheckpointStore { } } - public func saveSnapshot(_ snapshot: Snapshot, workspaceId: UUID) async throws { + func saveSnapshot(_ snapshot: Snapshot, workspaceId: UUID) async throws { snapshotsByWorkspace[workspaceId, default: [:]][snapshot.id] = snapshot } - public func loadSnapshot(id: UUID, workspaceId: UUID) async throws -> Snapshot? { + func loadSnapshot(id: UUID, workspaceId: UUID) async throws -> Snapshot? { snapshotsByWorkspace[workspaceId]?[id] } - public func appendMutation(_ mutation: MutationRecord) async throws { + func appendMutation(_ mutation: MutationRecord) async throws { mutationsByWorkspace[mutation.workspaceId, default: []].append(mutation) } - public func listMutationRecords(workspaceId: UUID) async throws -> [MutationRecord] { + func listMutationRecords(workspaceId: UUID) async throws -> [MutationRecord] { (mutationsByWorkspace[workspaceId] ?? []).sorted { $0.sequence < $1.sequence } } } @@ -73,13 +73,13 @@ public actor InMemoryCheckpointStore: CheckpointStore { /// processes do not lose appends. Checkpoint and snapshot writes are per-artifact atomic replaces. /// Coordinating writers on network filesystems that do not honor `flock` may still require /// application-level serialization. -public actor FileCheckpointStore: CheckpointStore { +actor FileCheckpointStore: CheckpointStore { private let rootDirectory: URL private let fileManager: FileManager private let encoder: JSONEncoder private let decoder: JSONDecoder - public init(rootDirectory: URL, fileManager: FileManager = .default) { + init(rootDirectory: URL, fileManager: FileManager = .default) { self.rootDirectory = rootDirectory.standardizedFileURL self.fileManager = fileManager @@ -89,20 +89,20 @@ public actor FileCheckpointStore: CheckpointStore { self.decoder = JSONDecoder() } - public func saveCheckpoint(_ checkpoint: History.Checkpoint) async throws { + func saveCheckpoint(_ checkpoint: Checkpoint) async throws { try ensureWorkspaceDirectories(for: checkpoint.workspaceId) try write(checkpoint, to: checkpointURL(id: checkpoint.id, workspaceId: checkpoint.workspaceId)) } - public func loadCheckpoint(id: UUID, workspaceId: UUID) async throws -> History.Checkpoint? { + func loadCheckpoint(id: UUID, workspaceId: UUID) async throws -> Checkpoint? { let url = checkpointURL(id: id, workspaceId: workspaceId) guard fileManager.fileExists(atPath: url.path) else { return nil } - return try read(History.Checkpoint.self, from: url) + return try read(Checkpoint.self, from: url) } - public func listCheckpoints(workspaceId: UUID) async throws -> [History.Checkpoint] { + func listCheckpoints(workspaceId: UUID) async throws -> [Checkpoint] { let directoryURL = checkpointsDirectoryURL(workspaceId: workspaceId) guard fileManager.fileExists(atPath: directoryURL.path) else { return [] @@ -115,7 +115,7 @@ public actor FileCheckpointStore: CheckpointStore { options: [.skipsHiddenFiles] ) .filter { $0.pathExtension == "json" } - .map { try read(History.Checkpoint.self, from: $0) } + .map { try read(Checkpoint.self, from: $0) } .sorted { if $0.createdAt == $1.createdAt { return $0.id.uuidString < $1.id.uuidString @@ -124,12 +124,12 @@ public actor FileCheckpointStore: CheckpointStore { } } - public func saveSnapshot(_ snapshot: Snapshot, workspaceId: UUID) async throws { + func saveSnapshot(_ snapshot: Snapshot, workspaceId: UUID) async throws { try ensureWorkspaceDirectories(for: workspaceId) try write(snapshot, to: snapshotURL(id: snapshot.id, workspaceId: workspaceId)) } - public func loadSnapshot(id: UUID, workspaceId: UUID) async throws -> Snapshot? { + func loadSnapshot(id: UUID, workspaceId: UUID) async throws -> Snapshot? { let url = snapshotURL(id: id, workspaceId: workspaceId) guard fileManager.fileExists(atPath: url.path) else { return nil @@ -137,7 +137,7 @@ public actor FileCheckpointStore: CheckpointStore { return try read(Snapshot.self, from: url) } - public func appendMutation(_ mutation: MutationRecord) async throws { + func appendMutation(_ mutation: MutationRecord) async throws { try ensureWorkspaceDirectories(for: mutation.workspaceId) let url = mutationsURL(workspaceId: mutation.workspaceId) let lockURL = mutationsLockURL(workspaceId: mutation.workspaceId) @@ -148,7 +148,7 @@ public actor FileCheckpointStore: CheckpointStore { } } - public func listMutationRecords(workspaceId: UUID) async throws -> [MutationRecord] { + func listMutationRecords(workspaceId: UUID) async throws -> [MutationRecord] { let url = mutationsURL(workspaceId: workspaceId) guard fileManager.fileExists(atPath: url.path) else { return [] diff --git a/Sources/Workspace/FS/FileSystem.swift b/Sources/Workspace/FS/FileSystem.swift index f4deef3..63f7e62 100644 --- a/Sources/Workspace/FS/FileSystem.swift +++ b/Sources/Workspace/FS/FileSystem.swift @@ -16,6 +16,14 @@ public enum WorkspaceError: Error, CustomStringConvertible, Sendable { case accessDenied(operation: String, message: String?) /// The requested operation is not supported or cannot be completed in the current environment. case unsupported(String) + /// The requested checkpoint does not exist for this workspace. + case checkpointNotFound(UUID) + /// The snapshot artifact for a checkpoint is missing. + case snapshotNotFound(UUID) + /// The parent workspace changed after a branch was created, so the branch cannot be merged. + case mergeConflict(parentWorkspaceId: UUID, expectedBase: UUID?, actualHead: UUID?) + /// A tracked workspace mutation could not be recorded. + case mutationFailed(String) /// A human-readable description of the error. public var description: String { @@ -37,6 +45,16 @@ public enum WorkspaceError: Error, CustomStringConvertible, Sendable { return message ?? "workspace access denied: \(operation)" case let .unsupported(message): return message + case let .checkpointNotFound(checkpointId): + return "workspace checkpoint not found: \(checkpointId.uuidString)" + case let .snapshotNotFound(snapshotId): + return "workspace snapshot not found: \(snapshotId.uuidString)" + case let .mergeConflict(parentWorkspaceId, expectedBase, actualHead): + let expected = expectedBase?.uuidString ?? "nil" + let actual = actualHead?.uuidString ?? "nil" + return "cannot merge branch into workspace \(parentWorkspaceId.uuidString): expected base \(expected), current head is \(actual)" + case let .mutationFailed(message): + return message } } } diff --git a/Sources/Workspace/History.swift b/Sources/Workspace/History.swift deleted file mode 100644 index 88c8f73..0000000 --- a/Sources/Workspace/History.swift +++ /dev/null @@ -1,1356 +0,0 @@ -import Foundation - -/// Coordinates shared history, session overlays, checkpoints, rollback, and publish. -public actor History { - private struct SessionState { - var workspace: Workspace - var baseSharedCheckpointId: UUID? - var lastCheckpointId: UUID? - } - - private struct CheckpointWatcher { - var deliveredCheckpointIds: Set - var continuation: AsyncStream.Continuation - } - - private struct SnapshotDelta { - var touchedPaths: Set = [] - var fileChanges: [FileEdit.FileChange] = [] - - var hasChanges: Bool { - !touchedPaths.isEmpty || !fileChanges.isEmpty - } - } - - /// Where to persist checkpoint and snapshot data. - public enum Storage: Sendable { - /// In-memory storage that does not survive process exit. - case inMemory - /// File-backed JSON storage rooted at `url`. - /// - /// ``FileCheckpointStore`` uses per-file atomic writes and locks `mutations.json` while mutating it - /// on Darwin and Linux. Multiple processes should still treat the directory as a single writer domain; - /// some network filesystems may not honor advisory locks. - case directory(at: URL) - } - - public let workspaceId: UUID - - /// The shared workspace backing this history coordinator, exposed as a read-only handle. - /// - /// Reads (``WorkspaceReading/readFile(_:)``, ``WorkspaceReading/exists(_:)``, - /// ``WorkspaceReading/walkTree(_:)``, ``WorkspaceReading/captureSnapshot()``, etc.) are always safe. - /// All writes must go through ``History`` (for example ``writeFile(_:content:)``, - /// ``applyEdits(_:failurePolicy:)``) so checkpoints and mutation logs stay consistent. - /// The read-only type prevents accidentally bypassing history tracking through this property. - public nonisolated let shared: any WorkspaceReading - - /// Internal full-access view onto the same shared workspace as ``shared``. Used by ``History`` itself for - /// tracked write operations and snapshot restore. - private nonisolated let sharedWorkspace: Workspace - - private let store: any CheckpointStore - - private var loadTask: Task? - private var didLoadStoreState = false - private var sessions: [UUID: SessionState] = [:] - private var checkpoints: [Checkpoint] = [] - private var mutations: [MutationRecord] = [] - private var sharedHeadCheckpointId: UUID? - private var nextMutationSequence = 1 - private var checkpointWatchers: [UUID: CheckpointWatcher] = [:] - private var checkpointPollingTask: Task? - - /// Creates an in-memory history coordinator suitable for tests and ephemeral work. - public init() { - self.init(filesystem: InMemoryFilesystem(), storage: .inMemory) - } - - /// Creates a history coordinator over `filesystem`. - public init( - workspaceId: UUID = UUID(), - filesystem: any FileSystem, - storage: Storage = .inMemory - ) { - let workspace = Workspace(filesystem: filesystem) - self.workspaceId = workspaceId - self.sharedWorkspace = workspace - self.shared = workspace - self.store = Self.makeStore(for: storage) - } - - /// Creates a history coordinator over an existing shared workspace. - public init( - workspaceId: UUID = UUID(), - workspace: Workspace, - storage: Storage = .inMemory - ) { - self.workspaceId = workspaceId - self.sharedWorkspace = workspace - self.shared = workspace - self.store = Self.makeStore(for: storage) - } - - /// Creates a history coordinator with a custom ``CheckpointStore`` (for example a persistent or remote backend). - public init( - workspaceId: UUID = UUID(), - filesystem: any FileSystem, - store: any CheckpointStore - ) { - let workspace = Workspace(filesystem: filesystem) - self.workspaceId = workspaceId - self.sharedWorkspace = workspace - self.shared = workspace - self.store = store - } - - /// Creates a history coordinator over an existing shared workspace and custom ``CheckpointStore``. - public init( - workspaceId: UUID = UUID(), - workspace: Workspace, - store: any CheckpointStore - ) { - self.workspaceId = workspaceId - self.sharedWorkspace = workspace - self.shared = workspace - self.store = store - } - - private static func makeStore(for storage: Storage) -> any CheckpointStore { - switch storage { - case .inMemory: - InMemoryCheckpointStore() - case .directory(at: let url): - FileCheckpointStore(rootDirectory: url) - } - } - - // MARK: - Checkpoint watching - - /// Watches for checkpoint events, including those created by other History instances sharing the same store. - public func watchCheckpointEvents() async throws -> AsyncStream { - try await ensureLoaded() - - let watcherId = UUID() - let deliveredIds = Set(checkpoints.map(\.id)) - var continuation: AsyncStream.Continuation? - let stream = AsyncStream { - continuation = $0 - } - - guard let continuation else { - return stream - } - - checkpointWatchers[watcherId] = CheckpointWatcher( - deliveredCheckpointIds: deliveredIds, - continuation: continuation - ) - continuation.onTermination = { [weak self] _ in - Task { - await self?.removeCheckpointWatcher(id: watcherId) - } - } - ensureCheckpointPolling() - return stream - } - - // MARK: - Sessions - - /// Creates a tracked session overlay from the current shared head. - public func createSession() async throws -> Session { - try await ensureLoaded() - - let sessionId = UUID() - let snapshot = try await sharedWorkspace.captureSnapshot() - let overlay = InMemoryFilesystem() - let sessionWorkspace = Workspace(filesystem: overlay) - try await sessionWorkspace.restoreSnapshot(snapshot) - - sessions[sessionId] = SessionState( - workspace: sessionWorkspace, - baseSharedCheckpointId: sharedHeadCheckpointId, - lastCheckpointId: nil - ) - - return Session( - id: sessionId, - workspaceId: workspaceId, - workspace: sessionWorkspace, - history: self - ) - } - - // MARK: - Shared write operations - - /// Writes UTF-8 text to the shared workspace. - public func writeFile(_ path: WorkspacePath, content: String) async throws { - try await ensureLoaded() - try await performDirectEdit( - on: sharedWorkspace, - scope: .shared, - sessionId: nil, - kind: .writeFile, - edit: .writeFile(path: path, content: content) - ) { workspace in - try await workspace.writeFile(path, content: content) - } - } - - /// Appends UTF-8 text to a shared workspace file. - public func appendFile(_ path: WorkspacePath, content: String) async throws { - try await ensureLoaded() - try await performDirectEdit( - on: sharedWorkspace, - scope: .shared, - sessionId: nil, - kind: .appendFile, - edit: .appendFile(path: path, content: content) - ) { workspace in - try await workspace.appendFile(path, content: content) - } - } - - /// Writes raw data to the shared workspace. - public func writeData(_ data: Data, to path: WorkspacePath) async throws { - try await ensureLoaded() - try await performBinaryWrite( - on: sharedWorkspace, - scope: .shared, - sessionId: nil, - data: data, - path: path - ) - } - - /// Encodes JSON and writes it to the shared workspace. - public func writeJSON( - _ value: T, - to path: WorkspacePath, - prettyPrinted: Bool = true - ) async throws { - try await ensureLoaded() - let content = try encodedJSONString(for: value, prettyPrinted: prettyPrinted) - try await performDirectEdit( - on: sharedWorkspace, - scope: .shared, - sessionId: nil, - kind: .writeJSON, - edit: .writeFile(path: path, content: content) - ) { workspace in - try await workspace.writeFile(path, content: content) - } - } - - /// Creates a directory in the shared workspace. - public func createDirectory(at path: WorkspacePath, recursive: Bool = true) async throws { - try await ensureLoaded() - try await performDirectEdit( - on: sharedWorkspace, - scope: .shared, - sessionId: nil, - kind: .createDirectory, - edit: .createDirectory(path: path, recursive: recursive) - ) { workspace in - try await workspace.createDirectory(at: path, recursive: recursive) - } - } - - /// Removes an item from the shared workspace. - public func removeItem(at path: WorkspacePath, recursive: Bool = true) async throws { - try await ensureLoaded() - try await performDirectEdit( - on: sharedWorkspace, - scope: .shared, - sessionId: nil, - kind: .removeItem, - edit: .delete(path: path, recursive: recursive) - ) { workspace in - try await workspace.removeItem(at: path, recursive: recursive) - } - } - - /// Copies an item inside the shared workspace. - public func copyItem(from source: WorkspacePath, to destination: WorkspacePath, recursive: Bool = true) async throws { - try await ensureLoaded() - try await performDirectEdit( - on: sharedWorkspace, - scope: .shared, - sessionId: nil, - kind: .copyItem, - edit: .copy(from: source, to: destination, recursive: recursive) - ) { workspace in - try await workspace.copyItem(from: source, to: destination, recursive: recursive) - } - } - - /// Moves an item inside the shared workspace. - public func moveItem(from source: WorkspacePath, to destination: WorkspacePath) async throws { - try await ensureLoaded() - try await performDirectEdit( - on: sharedWorkspace, - scope: .shared, - sessionId: nil, - kind: .moveItem, - edit: .move(from: source, to: destination) - ) { workspace in - try await workspace.moveItem(from: source, to: destination) - } - } - - /// Applies a batch of tracked edits to the shared workspace. - public func applyEdits( - _ edits: [FileEdit], - failurePolicy: MutationFailurePolicy = .rollback - ) async throws -> FileEdit.BatchResult { - try await ensureLoaded() - let result = try await sharedWorkspace.applyEdits(edits, failurePolicy: failurePolicy) - if !edits.isEmpty { - let topDiff: TextDiff? = - if result.edits.count == 1, let single = result.edits.first, single.fileChanges.count == 1 { - single.fileChanges[0].diff - } else { - nil - } - try await appendMutation( - kind: .applyEdits, - scope: .shared, - sessionId: nil, - touchedPaths: result.touchedPaths, - fileChanges: result.edits.flatMap(\.fileChanges), - diff: topDiff - ) - } - return result - } - - /// Applies a tracked multi-file replacement to the shared workspace. - public func applyReplacement( - _ request: ReplacementRequest, - failurePolicy: MutationFailurePolicy = .rollback - ) async throws -> ReplacementResult { - try await ensureLoaded() - let result = try await sharedWorkspace.applyReplacement(request, failurePolicy: failurePolicy) - if !result.changes.isEmpty || !result.failures.isEmpty { - try await appendMutation( - kind: .applyReplacement, - scope: .shared, - sessionId: nil, - touchedPaths: result.touchedPaths, - fileChanges: result.changes.map { - FileEdit.FileChange( - path: $0.path, - kind: .file, - effect: .modified, - status: $0.status, - diff: $0.diff - ) - }, - diff: result.changes.count == 1 ? result.changes[0].diff : nil - ) - } - return result - } - - // MARK: - Checkpoints - - /// Creates a shared checkpoint. - public func createCheckpoint(label: String? = nil) async throws -> Checkpoint { - try await ensureLoaded() - let snapshot = try await sharedWorkspace.captureSnapshot() - return try await persistCheckpoint( - snapshot: snapshot, - scope: .shared, - sessionId: nil, - label: label, - parentCheckpointId: sharedHeadCheckpointId, - baseSharedCheckpointId: nil, - originSessionId: nil, - rollbackSourceCheckpointId: nil, - eventKind: .created - ) - } - - /// Rolls the shared workspace back to a prior shared checkpoint. - public func rollback(to checkpointId: UUID, label: String? = nil) async throws -> Checkpoint { - try await ensureLoaded() - let checkpoint = try checkpointOrThrow(id: checkpointId) - guard checkpoint.scope == .shared else { - throw HistoryError.checkpointScopeMismatch(expected: .shared, actual: checkpoint.scope) - } - let targetSnapshot = try await loadSnapshotOrThrow(id: checkpoint.snapshotId) - try await sharedWorkspace.restoreSnapshot(targetSnapshot) - let restoredSnapshot = try await sharedWorkspace.captureSnapshot() - return try await persistCheckpoint( - snapshot: restoredSnapshot, - scope: .shared, - sessionId: nil, - label: label, - parentCheckpointId: sharedHeadCheckpointId, - baseSharedCheckpointId: nil, - originSessionId: nil, - rollbackSourceCheckpointId: checkpoint.id, - eventKind: .rolledBack - ) - } - - /// Publishes the current session head into the shared workspace. - func publishSessionHead(sessionId: UUID, label: String? = nil) async throws -> Checkpoint { - try await ensureLoaded() - var session = try sessionState(for: sessionId) - - guard sharedHeadCheckpointId == session.baseSharedCheckpointId else { - throw HistoryError.publishConflict( - sessionId: sessionId, - expectedBaseSharedCheckpointId: session.baseSharedCheckpointId, - actualSharedCheckpointId: sharedHeadCheckpointId - ) - } - - let previousSharedSnapshot = try await sharedWorkspace.captureSnapshot() - let sessionSnapshot = try await session.workspace.captureSnapshot() - let delta = snapshotDelta(from: previousSharedSnapshot.entry, to: sessionSnapshot.entry) - - try await sharedWorkspace.restoreSnapshot(sessionSnapshot) - - if delta.hasChanges { - let topDiff: TextDiff? = - if delta.fileChanges.count == 1 { delta.fileChanges[0].diff } else { nil } - try await appendMutation( - kind: .publishSessionHead, - scope: .shared, - sessionId: sessionId, - touchedPaths: Array(delta.touchedPaths).sorted(), - fileChanges: delta.fileChanges, - diff: topDiff - ) - } - - let checkpoint = try await persistCheckpoint( - snapshot: sessionSnapshot, - scope: .shared, - sessionId: sessionId, - label: label, - parentCheckpointId: sharedHeadCheckpointId, - baseSharedCheckpointId: nil, - originSessionId: sessionId, - rollbackSourceCheckpointId: nil, - eventKind: .published - ) - - session.baseSharedCheckpointId = checkpoint.id - sessions[sessionId] = session - return checkpoint - } - - /// Lists checkpoints for the managed workspace. - public func listCheckpoints( - scope: Checkpoint.Scope? = nil, - sessionId: UUID? = nil - ) async throws -> [Checkpoint] { - try await ensureLoaded() - return checkpoints.filter { checkpoint in - if let scope, checkpoint.scope != scope { - return false - } - if let sessionId { - return checkpoint.sessionId == sessionId - } - return true - } - } - - /// Lists mutation records for the managed workspace. - func mutationRecords( - scope: Checkpoint.Scope? = nil, - sessionId: UUID? = nil - ) async throws -> [MutationRecord] { - try await ensureLoaded() - return mutations.filter { mutation in - if let scope, mutation.scope != scope { - return false - } - if let sessionId { - return mutation.sessionId == sessionId - } - return true - } - } - - /// Returns one checkpoint when present. - public func checkpoint(id: UUID) async throws -> Checkpoint? { - try await ensureLoaded() - return checkpoints.first(where: { $0.id == id }) - } - - /// Loads the snapshot artifact persisted alongside `checkpoint`. - /// - /// Use this to inspect prior workspace contents, render diffs against the current - /// shared head, or restore the captured tree onto a different ``Workspace`` or - /// ``FileSystem`` via ``Snapshot/restore(_:to:)``. - public func snapshot(for checkpoint: Checkpoint) async throws -> Snapshot { - try await ensureLoaded() - return try await loadSnapshotOrThrow(id: checkpoint.snapshotId) - } - - // MARK: - Session write operations (internal, called by Session) - - func sessionWriteFile(_ sessionId: UUID, path: WorkspacePath, content: String) async throws { - try await ensureLoaded() - try await performSessionDirectEdit( - sessionId: sessionId, - kind: .writeFile, - edit: .writeFile(path: path, content: content) - ) { workspace in - try await workspace.writeFile(path, content: content) - } - } - - func sessionAppendFile(_ sessionId: UUID, path: WorkspacePath, content: String) async throws { - try await ensureLoaded() - try await performSessionDirectEdit( - sessionId: sessionId, - kind: .appendFile, - edit: .appendFile(path: path, content: content) - ) { workspace in - try await workspace.appendFile(path, content: content) - } - } - - func sessionWriteData(_ sessionId: UUID, data: Data, path: WorkspacePath) async throws { - try await ensureLoaded() - let workspace = try sessionState(for: sessionId).workspace - try await performBinaryWrite(on: workspace, scope: .session, sessionId: sessionId, data: data, path: path) - } - - func sessionWriteJSON( - _ sessionId: UUID, - value: T, - path: WorkspacePath, - prettyPrinted: Bool - ) async throws { - try await ensureLoaded() - let workspace = try sessionState(for: sessionId).workspace - let content = try encodedJSONString(for: value, prettyPrinted: prettyPrinted) - try await performDirectEdit( - on: workspace, - scope: .session, - sessionId: sessionId, - kind: .writeJSON, - edit: .writeFile(path: path, content: content) - ) { workspace in - try await workspace.writeFile(path, content: content) - } - } - - func sessionCreateDirectory(_ sessionId: UUID, path: WorkspacePath, recursive: Bool) async throws { - try await ensureLoaded() - try await performSessionDirectEdit( - sessionId: sessionId, - kind: .createDirectory, - edit: .createDirectory(path: path, recursive: recursive) - ) { workspace in - try await workspace.createDirectory(at: path, recursive: recursive) - } - } - - func sessionRemoveItem(_ sessionId: UUID, path: WorkspacePath, recursive: Bool) async throws { - try await ensureLoaded() - try await performSessionDirectEdit( - sessionId: sessionId, - kind: .removeItem, - edit: .delete(path: path, recursive: recursive) - ) { workspace in - try await workspace.removeItem(at: path, recursive: recursive) - } - } - - func sessionCopyItem( - _ sessionId: UUID, - source: WorkspacePath, - destination: WorkspacePath, - recursive: Bool - ) async throws { - try await ensureLoaded() - try await performSessionDirectEdit( - sessionId: sessionId, - kind: .copyItem, - edit: .copy(from: source, to: destination, recursive: recursive) - ) { workspace in - try await workspace.copyItem(from: source, to: destination, recursive: recursive) - } - } - - func sessionMoveItem(_ sessionId: UUID, source: WorkspacePath, destination: WorkspacePath) async throws { - try await ensureLoaded() - try await performSessionDirectEdit( - sessionId: sessionId, - kind: .moveItem, - edit: .move(from: source, to: destination) - ) { workspace in - try await workspace.moveItem(from: source, to: destination) - } - } - - func sessionApplyEdits( - _ sessionId: UUID, - edits: [FileEdit], - failurePolicy: MutationFailurePolicy - ) async throws -> FileEdit.BatchResult { - try await ensureLoaded() - let workspace = try sessionState(for: sessionId).workspace - let result = try await workspace.applyEdits(edits, failurePolicy: failurePolicy) - if !edits.isEmpty { - let topDiff: TextDiff? = - if result.edits.count == 1, let single = result.edits.first, single.fileChanges.count == 1 { - single.fileChanges[0].diff - } else { - nil - } - try await appendMutation( - kind: .applyEdits, - scope: .session, - sessionId: sessionId, - touchedPaths: result.touchedPaths, - fileChanges: result.edits.flatMap(\.fileChanges), - diff: topDiff - ) - } - return result - } - - func sessionApplyReplacement( - _ sessionId: UUID, - request: ReplacementRequest, - failurePolicy: MutationFailurePolicy - ) async throws -> ReplacementResult { - try await ensureLoaded() - let workspace = try sessionState(for: sessionId).workspace - let result = try await workspace.applyReplacement(request, failurePolicy: failurePolicy) - if !result.changes.isEmpty || !result.failures.isEmpty { - try await appendMutation( - kind: .applyReplacement, - scope: .session, - sessionId: sessionId, - touchedPaths: result.touchedPaths, - fileChanges: result.changes.map { - FileEdit.FileChange( - path: $0.path, - kind: .file, - effect: .modified, - status: $0.status, - diff: $0.diff - ) - }, - diff: result.changes.count == 1 ? result.changes[0].diff : nil - ) - } - return result - } - - func createSessionCheckpoint(sessionId: UUID, label: String? = nil) async throws -> Checkpoint { - try await ensureLoaded() - let session = try sessionState(for: sessionId) - let snapshot = try await session.workspace.captureSnapshot() - return try await persistCheckpoint( - snapshot: snapshot, - scope: .session, - sessionId: sessionId, - label: label, - parentCheckpointId: session.lastCheckpointId, - baseSharedCheckpointId: session.baseSharedCheckpointId, - originSessionId: nil, - rollbackSourceCheckpointId: nil, - eventKind: .created - ) - } - - func rollbackSession( - sessionId: UUID, - to checkpointId: UUID, - label: String? = nil - ) async throws -> Checkpoint { - try await ensureLoaded() - var session = try sessionState(for: sessionId) - let checkpoint = try checkpointOrThrow(id: checkpointId) - - switch checkpoint.scope { - case .shared: - break - case .session where checkpoint.sessionId == sessionId: - break - case .session: - throw HistoryError.checkpointSessionMismatch( - checkpointId: checkpoint.id, - expectedSessionId: sessionId, - actualSessionId: checkpoint.sessionId - ) - } - - let targetSnapshot = try await loadSnapshotOrThrow(id: checkpoint.snapshotId) - try await session.workspace.restoreSnapshot(targetSnapshot) - - switch checkpoint.scope { - case .shared: - session.baseSharedCheckpointId = checkpoint.id - case .session: - session.baseSharedCheckpointId = checkpoint.baseSharedCheckpointId - } - sessions[sessionId] = session - - let restoredSnapshot = try await session.workspace.captureSnapshot() - return try await persistCheckpoint( - snapshot: restoredSnapshot, - scope: .session, - sessionId: sessionId, - label: label, - parentCheckpointId: session.lastCheckpointId, - baseSharedCheckpointId: session.baseSharedCheckpointId, - originSessionId: nil, - rollbackSourceCheckpointId: checkpoint.id, - eventKind: .rolledBack - ) - } - - func sessionBaseSharedCheckpointId(_ sessionId: UUID) async throws -> UUID? { - try await ensureLoaded() - return try sessionState(for: sessionId).baseSharedCheckpointId - } - - // MARK: - Private - - private func ensureLoaded() async throws { - if didLoadStoreState { - return - } - if let loadTask { - try await loadTask.value - return - } - let task = Task { - try await self.loadStoreState() - } - loadTask = task - do { - try await task.value - loadTask = nil - } catch { - loadTask = nil - throw error - } - } - - private func loadStoreState() async throws { - checkpoints = try await store.listCheckpoints(workspaceId: workspaceId) - mutations = try await store.listMutationRecords(workspaceId: workspaceId) - nextMutationSequence = (mutations.last?.sequence ?? 0) + 1 - sharedHeadCheckpointId = checkpoints - .filter { $0.scope == .shared } - .sorted(by: checkpointSort) - .last? - .id - didLoadStoreState = true - } - - private func sessionState(for sessionId: UUID) throws -> SessionState { - guard let session = sessions[sessionId] else { - throw HistoryError.sessionNotFound(sessionId) - } - return session - } - - private func checkpointOrThrow(id: UUID) throws -> Checkpoint { - guard let checkpoint = checkpoints.first(where: { $0.id == id }) else { - throw HistoryError.checkpointNotFound(id) - } - return checkpoint - } - - private func loadSnapshotOrThrow(id: UUID) async throws -> Snapshot { - guard let snapshot = try await store.loadSnapshot(id: id, workspaceId: workspaceId) else { - throw HistoryError.snapshotNotFound(id) - } - return snapshot - } - - private func performSessionDirectEdit( - sessionId: UUID, - kind: MutationRecord.Kind, - edit: FileEdit, - apply: @escaping @Sendable (Workspace) async throws -> Void - ) async throws { - let workspace = try sessionState(for: sessionId).workspace - try await performDirectEdit( - on: workspace, - scope: .session, - sessionId: sessionId, - kind: kind, - edit: edit, - apply: apply - ) - } - - private func performDirectEdit( - on workspace: Workspace, - scope: Checkpoint.Scope, - sessionId: UUID?, - kind: MutationRecord.Kind, - edit: FileEdit, - apply: @escaping @Sendable (Workspace) async throws -> Void - ) async throws { - let preview = try await workspace.previewEdits([edit]) - try await apply(workspace) - - let appliedFileChanges = markApplied(preview.edits.first?.fileChanges ?? []) - try await appendMutation( - kind: kind, - scope: scope, - sessionId: sessionId, - touchedPaths: preview.touchedPaths, - fileChanges: appliedFileChanges, - diff: appliedFileChanges.count == 1 ? appliedFileChanges[0].diff : nil - ) - } - - private func performBinaryWrite( - on workspace: Workspace, - scope: Checkpoint.Scope, - sessionId: UUID?, - data: Data, - path: WorkspacePath - ) async throws { - let effect: FileEdit.Effect - let kind: FileTree.Kind - - if await workspace.exists(path) { - let info = try await workspace.fileInfo(at: path) - kind = info.kind - if info.kind == .directory { - effect = .unchanged - } else { - effect = try await workspace.readData(from: path) == data ? .unchanged : .modified - } - } else { - kind = .file - effect = .created - } - - try await workspace.writeData(data, to: path) - try await appendMutation( - kind: .writeData, - scope: scope, - sessionId: sessionId, - touchedPaths: [path], - fileChanges: [ - FileEdit.FileChange( - path: path, - kind: kind, - effect: effect, - status: .applied, - diff: nil - ), - ], - diff: nil - ) - } - - private func persistCheckpoint( - snapshot: Snapshot, - scope: Checkpoint.Scope, - sessionId: UUID?, - label: String?, - parentCheckpointId: UUID?, - baseSharedCheckpointId: UUID?, - originSessionId: UUID?, - rollbackSourceCheckpointId: UUID?, - eventKind: CheckpointEvent.Kind - ) async throws -> Checkpoint { - try await store.saveSnapshot(snapshot, workspaceId: workspaceId) - - let parentCheckpoint = parentCheckpointId.flatMap { id in - checkpoints.first(where: { $0.id == id }) - } - - let previousSnapshot: Snapshot? - if let parentCheckpoint { - previousSnapshot = try? await store.loadSnapshot(id: parentCheckpoint.snapshotId, workspaceId: workspaceId) - } else { - previousSnapshot = nil - } - let summary = snapshot.summary(comparedTo: previousSnapshot) - - let previousCursor = parentCheckpoint?.mutationCursor ?? 0 - let currentCursor = latestMutationSequence(scope: scope, sessionId: sessionId) - let checkpointId = UUID() - let resolvedBaseSharedCheckpointId = scope == .shared ? checkpointId : baseSharedCheckpointId - - let checkpoint = Checkpoint( - id: checkpointId, - workspaceId: workspaceId, - sessionId: sessionId, - scope: scope, - label: label, - parentCheckpointId: parentCheckpointId, - baseSharedCheckpointId: resolvedBaseSharedCheckpointId, - firstMutationSequence: currentCursor > previousCursor ? previousCursor + 1 : nil, - lastMutationSequence: currentCursor > previousCursor ? currentCursor : nil, - mutationCursor: currentCursor, - originSessionId: originSessionId, - rollbackSourceCheckpointId: rollbackSourceCheckpointId, - snapshotId: snapshot.id, - summary: summary - ) - - try await store.saveCheckpoint(checkpoint) - - checkpoints.append(checkpoint) - checkpoints.sort(by: checkpointSort) - - switch scope { - case .shared: - sharedHeadCheckpointId = checkpoint.id - case .session: - if let sessionId, var session = sessions[sessionId] { - session.lastCheckpointId = checkpoint.id - sessions[sessionId] = session - } - } - - emitCheckpointEvent(CheckpointEvent(kind: eventKind, checkpoint: checkpoint)) - return checkpoint - } - - private func appendMutation( - kind: MutationRecord.Kind, - scope: Checkpoint.Scope, - sessionId: UUID?, - touchedPaths: [WorkspacePath], - fileChanges: [FileEdit.FileChange], - diff: TextDiff? - ) async throws { - let mutation = MutationRecord( - sequence: nextMutationSequence, - workspaceId: workspaceId, - sessionId: sessionId, - scope: scope, - kind: kind, - touchedPaths: Array(Set(touchedPaths)).sorted(), - fileChanges: fileChanges.sorted(by: fileChangeSort), - diff: diff - ) - - nextMutationSequence += 1 - mutations.append(mutation) - mutations.sort(by: { $0.sequence < $1.sequence }) - try await store.appendMutation(mutation) - } - - private func latestMutationSequence(scope: Checkpoint.Scope, sessionId: UUID?) -> Int { - mutations - .filter { - guard $0.scope == scope else { - return false - } - if scope == .session { - return $0.sessionId == sessionId - } - return true - } - .map(\.sequence) - .max() ?? 0 - } - - private func encodedJSONString(for value: T, prettyPrinted: Bool) throws -> String { - let encoder = JSONEncoder() - if prettyPrinted { - encoder.outputFormatting = [.prettyPrinted, .sortedKeys] - } - - var data = try encoder.encode(value) - data.append(Data("\n".utf8)) - guard let string = String(data: data, encoding: .utf8) else { - throw HistoryError.mutationFailed("encoded JSON is not valid UTF-8") - } - return string - } - - private func markApplied(_ fileChanges: [FileEdit.FileChange]) -> [FileEdit.FileChange] { - fileChanges.map { change in - var copy = change - copy.status = .applied - return copy - } - } - - // MARK: - Checkpoint watching internals - - private func removeCheckpointWatcher(id: UUID) { - checkpointWatchers.removeValue(forKey: id) - if checkpointWatchers.isEmpty { - checkpointPollingTask?.cancel() - checkpointPollingTask = nil - } - } - - private func ensureCheckpointPolling() { - guard checkpointPollingTask == nil else { - return - } - checkpointPollingTask = Task { [weak self] in - while !Task.isCancelled { - try? await Task.sleep(for: .milliseconds(500)) - await self?.pollCheckpointEvents() - } - } - } - - private func pollCheckpointEvents() async { - guard !checkpointWatchers.isEmpty else { - return - } - guard let loaded = try? await store.listCheckpoints(workspaceId: workspaceId) else { - return - } - - let newCheckpoints = loaded.filter { cp in - !checkpoints.contains(where: { $0.id == cp.id }) - } - - if !newCheckpoints.isEmpty { - checkpoints.append(contentsOf: newCheckpoints) - checkpoints.sort(by: checkpointSort) - - // Re-derive the shared head from the merged checkpoint set so a - // subsequent write on this instance anchors to the latest known - // shared checkpoint instead of its stale local head. - sharedHeadCheckpointId = checkpoints - .filter { $0.scope == .shared } - .last? - .id - - // Refresh the mutation log and sequence cursor so this instance - // does not reuse sequence numbers another writer has already - // claimed against the same store. - if let refreshedMutations = try? await store.listMutationRecords(workspaceId: workspaceId) { - mutations = refreshedMutations.sorted { $0.sequence < $1.sequence } - nextMutationSequence = (mutations.last?.sequence ?? 0) + 1 - } - } - - for cp in newCheckpoints.sorted(by: checkpointSort) { - emitCheckpointEvent(CheckpointEvent(kind: cp.inferredEventKind, checkpoint: cp)) - } - } - - private func emitCheckpointEvent(_ event: CheckpointEvent) { - guard !checkpointWatchers.isEmpty else { - return - } - - for id in Array(checkpointWatchers.keys) { - guard var watcher = checkpointWatchers[id] else { - continue - } - if watcher.deliveredCheckpointIds.insert(event.checkpoint.id).inserted { - watcher.continuation.yield(event) - checkpointWatchers[id] = watcher - } - } - } - - // MARK: - Snapshot delta - - private func utf8TextFileDiff(oldData: Data, newData: Data) -> TextDiff? { - guard let oldStr = String(data: oldData, encoding: .utf8), - let newStr = String(data: newData, encoding: .utf8) - else { - return nil - } - let diff = TextDiff.lineBased(from: oldStr, to: newStr) - return diff.hunks.isEmpty ? nil : diff - } - - private func snapshotDelta(from original: Snapshot.Entry, to updated: Snapshot.Entry) -> SnapshotDelta { - var delta = SnapshotDelta() - collectDelta(from: original, to: updated, into: &delta) - delta.fileChanges.sort(by: fileChangeSort) - return delta - } - - private func collectDelta( - from original: Snapshot.Entry, - to updated: Snapshot.Entry, - into delta: inout SnapshotDelta - ) { - switch (original, updated) { - case (.missing, .missing): - return - case (.missing, _): - collectCreated(updated, into: &delta) - case (_, .missing): - collectDeleted(original, into: &delta) - case let (.file(lhs), .file(rhs)): - if lhs.data != rhs.data || lhs.permissions != rhs.permissions { - delta.touchedPaths.insert(rhs.path) - let diff = lhs.data != rhs.data ? utf8TextFileDiff(oldData: lhs.data, newData: rhs.data) : nil - delta.fileChanges.append( - FileEdit.FileChange( - path: rhs.path, - kind: .file, - effect: .modified, - status: .applied, - diff: diff - ) - ) - } - case let (.symlink(lhs), .symlink(rhs)): - if lhs.target != rhs.target || lhs.permissions != rhs.permissions { - delta.touchedPaths.insert(rhs.path) - delta.fileChanges.append( - FileEdit.FileChange( - path: rhs.path, - kind: .symlink, - effect: .modified, - status: .applied, - diff: nil - ) - ) - } - case let (.directory(lhs), .directory(rhs)): - // Permission-only directory updates are surfaced via `touchedPaths` (and thus checkpoint summaries) - // but do not emit a leaf `FileChange`, matching how directory nodes are not individual files. - if lhs.permissions != rhs.permissions { - delta.touchedPaths.insert(rhs.path) - } - - let lhsChildren = Dictionary(uniqueKeysWithValues: lhs.children.map { ($0.path.basename, $0) }) - let rhsChildren = Dictionary(uniqueKeysWithValues: rhs.children.map { ($0.path.basename, $0) }) - let names = Set(lhsChildren.keys).union(rhsChildren.keys).sorted() - - for name in names { - switch (lhsChildren[name], rhsChildren[name]) { - case let (lhs?, rhs?): - collectDelta(from: lhs, to: rhs, into: &delta) - case let (lhs?, nil): - collectDeleted(lhs, into: &delta) - case let (nil, rhs?): - collectCreated(rhs, into: &delta) - case (nil, nil): - break - } - } - default: - collectDeleted(original, into: &delta) - collectCreated(updated, into: &delta) - } - } - - private func collectCreated(_ entry: Snapshot.Entry, into delta: inout SnapshotDelta) { - switch entry { - case let .missing(entry): - delta.touchedPaths.insert(entry.path) - case let .file(entry): - delta.touchedPaths.insert(entry.path) - delta.fileChanges.append( - FileEdit.FileChange( - path: entry.path, - kind: .file, - effect: .created, - status: .applied, - diff: utf8TextFileDiff(oldData: Data(), newData: entry.data) - ) - ) - case let .directory(entry): - delta.touchedPaths.insert(entry.path) - for child in entry.children { - collectCreated(child, into: &delta) - } - case let .symlink(entry): - delta.touchedPaths.insert(entry.path) - delta.fileChanges.append( - FileEdit.FileChange( - path: entry.path, - kind: .symlink, - effect: .created, - status: .applied, - diff: nil - ) - ) - } - } - - private func collectDeleted(_ entry: Snapshot.Entry, into delta: inout SnapshotDelta) { - switch entry { - case let .missing(entry): - delta.touchedPaths.insert(entry.path) - case let .file(entry): - delta.touchedPaths.insert(entry.path) - delta.fileChanges.append( - FileEdit.FileChange( - path: entry.path, - kind: .file, - effect: .deleted, - status: .applied, - diff: utf8TextFileDiff(oldData: entry.data, newData: Data()) - ) - ) - case let .directory(entry): - delta.touchedPaths.insert(entry.path) - for child in entry.children { - collectDeleted(child, into: &delta) - } - case let .symlink(entry): - delta.touchedPaths.insert(entry.path) - delta.fileChanges.append( - FileEdit.FileChange( - path: entry.path, - kind: .symlink, - effect: .deleted, - status: .applied, - diff: nil - ) - ) - } - } - - private func checkpointSort(lhs: Checkpoint, rhs: Checkpoint) -> Bool { - if lhs.createdAt == rhs.createdAt { - return lhs.id.uuidString < rhs.id.uuidString - } - return lhs.createdAt < rhs.createdAt - } - - private func fileChangeSort(lhs: FileEdit.FileChange, rhs: FileEdit.FileChange) -> Bool { - if lhs.path == rhs.path { - return lhs.effect.rawValue < rhs.effect.rawValue - } - return lhs.path < rhs.path - } -} - -// MARK: - Session - -extension History { - /// A tracked session-local editing overlay managed by `History`. - public actor Session { - public nonisolated let id: UUID - public nonisolated let workspaceId: UUID - - /// The session's underlying workspace overlay, exposed as a read-only handle. - /// - /// Reads are always safe. All writes must go through ``Session`` methods (for example - /// ``writeFile(_:content:)`` or ``applyEdits(_:failurePolicy:)``) so session checkpoints and - /// mutation records stay accurate. The read-only type prevents accidentally bypassing history - /// tracking through this property. - public nonisolated let workspace: any WorkspaceReading - - private let history: History - - init(id: UUID, workspaceId: UUID, workspace: Workspace, history: History) { - self.id = id - self.workspaceId = workspaceId - self.workspace = workspace - self.history = history - } - - /// Writes UTF-8 text into the session overlay. - public func writeFile(_ path: WorkspacePath, content: String) async throws { - try await history.sessionWriteFile(id, path: path, content: content) - } - - /// Appends UTF-8 text into the session overlay. - public func appendFile(_ path: WorkspacePath, content: String) async throws { - try await history.sessionAppendFile(id, path: path, content: content) - } - - /// Writes raw data into the session overlay. - public func writeData(_ data: Data, to path: WorkspacePath) async throws { - try await history.sessionWriteData(id, data: data, path: path) - } - - /// Encodes JSON into the session overlay. - public func writeJSON( - _ value: T, - to path: WorkspacePath, - prettyPrinted: Bool = true - ) async throws { - try await history.sessionWriteJSON(id, value: value, path: path, prettyPrinted: prettyPrinted) - } - - /// Creates a directory in the session overlay. - public func createDirectory(at path: WorkspacePath, recursive: Bool = true) async throws { - try await history.sessionCreateDirectory(id, path: path, recursive: recursive) - } - - /// Removes an item from the session overlay. - public func removeItem(at path: WorkspacePath, recursive: Bool = true) async throws { - try await history.sessionRemoveItem(id, path: path, recursive: recursive) - } - - /// Copies an item inside the session overlay. - public func copyItem(from source: WorkspacePath, to destination: WorkspacePath, recursive: Bool = true) async throws { - try await history.sessionCopyItem(id, source: source, destination: destination, recursive: recursive) - } - - /// Moves an item inside the session overlay. - public func moveItem(from source: WorkspacePath, to destination: WorkspacePath) async throws { - try await history.sessionMoveItem(id, source: source, destination: destination) - } - - /// Applies a tracked batch of edits to the session overlay. - public func applyEdits( - _ edits: [FileEdit], - failurePolicy: MutationFailurePolicy = .rollback - ) async throws -> FileEdit.BatchResult { - try await history.sessionApplyEdits(id, edits: edits, failurePolicy: failurePolicy) - } - - /// Applies a tracked replacement to the session overlay. - public func applyReplacement( - _ request: ReplacementRequest, - failurePolicy: MutationFailurePolicy = .rollback - ) async throws -> ReplacementResult { - try await history.sessionApplyReplacement(id, request: request, failurePolicy: failurePolicy) - } - - /// Creates a session-local checkpoint. - public func createCheckpoint(label: String? = nil) async throws -> Checkpoint { - try await history.createSessionCheckpoint(sessionId: id, label: label) - } - - /// Restores the session overlay to a prior checkpoint. - public func rollback(to checkpointId: UUID, label: String? = nil) async throws -> Checkpoint { - try await history.rollbackSession(sessionId: id, to: checkpointId, label: label) - } - - /// Publishes this session's current state into the shared workspace. - /// - /// Conflicts with concurrent publishes raise ``HistoryError/publishConflict(sessionId:expectedBaseSharedCheckpointId:actualSharedCheckpointId:)``. - public func publish(label: String? = nil) async throws -> Checkpoint { - try await history.publishSessionHead(sessionId: id, label: label) - } - - /// Lists checkpoints visible to this session. - public func listCheckpoints() async throws -> [Checkpoint] { - let checkpoints = try await history.listCheckpoints() - return checkpoints.filter { checkpoint in - switch checkpoint.scope { - case .shared: - return true - case .session: - return checkpoint.sessionId == id - } - } - } - - /// Lists mutation records for this session. - func mutationRecords() async throws -> [MutationRecord] { - try await history.mutationRecords(scope: .session, sessionId: id) - } - - /// Returns the shared checkpoint this session is currently based on. - func baseSharedCheckpointId() async throws -> UUID? { - try await history.sessionBaseSharedCheckpointId(id) - } - } -} diff --git a/Sources/Workspace/HistoryTypes.swift b/Sources/Workspace/HistoryTypes.swift deleted file mode 100644 index e35e1cd..0000000 --- a/Sources/Workspace/HistoryTypes.swift +++ /dev/null @@ -1,220 +0,0 @@ -import Foundation - -extension History { - /// A labeled, parented moment in workspace history. - /// - /// A `Checkpoint` is the *event* — when it happened, who created it, what scope it - /// belongs to, and a structural summary of what changed. The actual file contents at - /// that moment live in a separate ``Snapshot`` artifact, which can be loaded on - /// demand via ``History/snapshot(for:)``. - /// - /// The split mirrors the relationship between a git commit (this type) and a git - /// tree (``Snapshot``): commits are cheap to enumerate, trees are loaded only when - /// you actually need to inspect or restore the file contents. - public struct Checkpoint: Sendable, Codable, Equatable { - /// The checkpoint scope. - public enum Scope: String, Sendable, Codable { - /// A checkpoint over a session-local overlay. - case session - /// A checkpoint over the shared workspace head. - case shared - } - - /// A lightweight summary of changes relative to the parent checkpoint. - public struct Summary: Sendable, Codable, Equatable { - /// The number of paths that differ from the parent checkpoint. - public var changeCount: Int - /// The paths that changed relative to the parent checkpoint. - public var touchedPaths: [WorkspacePath] - /// Whether any changed file paths involve UTF-8 decodable text. - public var hasTextDiffs: Bool - - /// Creates a checkpoint summary. - public init(changeCount: Int, touchedPaths: [WorkspacePath], hasTextDiffs: Bool) { - self.changeCount = changeCount - self.touchedPaths = touchedPaths - self.hasTextDiffs = hasTextDiffs - } - } - - /// The checkpoint identifier. - public var id: UUID - /// The session that created or owns the checkpoint when applicable. - public var sessionId: UUID? - /// Whether the checkpoint belongs to a session overlay or the shared head. - public var scope: Scope - /// An optional human-readable label. - public var label: String? - /// The checkpoint creation timestamp. - public var createdAt: Date - /// A summary of what changed relative to the parent checkpoint. - public var summary: Summary - /// The previous checkpoint in the same scope/session, when present. - public var parentCheckpointId: UUID? - /// The shared checkpoint this checkpoint is based on (the head at session creation). - public var baseSharedCheckpointId: UUID? - /// The session that originated this checkpoint when it represents a publish. - public var originSessionId: UUID? - /// The source checkpoint a rollback restored from when applicable. - public var rollbackSourceCheckpointId: UUID? - - var workspaceId: UUID - var firstMutationSequence: Int? - var lastMutationSequence: Int? - var mutationCursor: Int - var snapshotId: UUID - - var inferredEventKind: CheckpointEvent.Kind { - if rollbackSourceCheckpointId != nil { return .rolledBack } - if originSessionId != nil, scope == .shared { return .published } - return .created - } - - init( - id: UUID = UUID(), - workspaceId: UUID, - sessionId: UUID?, - scope: Scope, - label: String?, - createdAt: Date = Date(), - parentCheckpointId: UUID?, - baseSharedCheckpointId: UUID?, - firstMutationSequence: Int?, - lastMutationSequence: Int?, - mutationCursor: Int, - originSessionId: UUID? = nil, - rollbackSourceCheckpointId: UUID? = nil, - snapshotId: UUID, - summary: Summary - ) { - self.id = id - self.workspaceId = workspaceId - self.sessionId = sessionId - self.scope = scope - self.label = label - self.createdAt = createdAt - self.parentCheckpointId = parentCheckpointId - self.baseSharedCheckpointId = baseSharedCheckpointId - self.firstMutationSequence = firstMutationSequence - self.lastMutationSequence = lastMutationSequence - self.mutationCursor = mutationCursor - self.originSessionId = originSessionId - self.rollbackSourceCheckpointId = rollbackSourceCheckpointId - self.snapshotId = snapshotId - self.summary = summary - } - } -} - -/// A recorded filesystem mutation emitted by ``History``. -public struct MutationRecord: Sendable, Codable, Equatable { - /// The coarse operation kind for filtering and tooling. - public enum Kind: String, Sendable, Codable { - case writeFile - case appendFile - case writeData - case writeJSON - case createDirectory - case removeItem - case copyItem - case moveItem - case applyEdits - case applyReplacement - case publishSessionHead - } - - public var sequence: Int - public var workspaceId: UUID - public var sessionId: UUID? - public var scope: History.Checkpoint.Scope - public var timestamp: Date - public var kind: Kind - public var touchedPaths: [WorkspacePath] - public var fileChanges: [FileEdit.FileChange] - public var diff: TextDiff? - - /// Creates a mutation record (primarily for tests and custom ``CheckpointStore`` implementations). - public init( - sequence: Int, - workspaceId: UUID, - sessionId: UUID?, - scope: History.Checkpoint.Scope, - timestamp: Date = Date(), - kind: Kind, - touchedPaths: [WorkspacePath], - fileChanges: [FileEdit.FileChange], - diff: TextDiff? = nil - ) { - self.sequence = sequence - self.workspaceId = workspaceId - self.sessionId = sessionId - self.scope = scope - self.timestamp = timestamp - self.kind = kind - self.touchedPaths = touchedPaths - self.fileChanges = fileChanges - self.diff = diff - } -} - -/// A checkpoint event emitted by ``History``. -public struct CheckpointEvent: Sendable, Codable, Equatable { - /// The event kind. - public enum Kind: String, Sendable, Codable { - /// A checkpoint was created. - case created - /// A rollback restored a prior checkpoint. - case rolledBack - /// A session head was published to the shared workspace. - case published - } - - /// The event kind. - public var kind: Kind - /// The checkpoint that triggered this event. - public var checkpoint: History.Checkpoint - - /// Creates a checkpoint event. - public init(kind: Kind, checkpoint: History.Checkpoint) { - self.kind = kind - self.checkpoint = checkpoint - } - - /// The checkpoint scope. - public var scope: History.Checkpoint.Scope { - checkpoint.scope - } -} - -/// Errors produced by ``History`` operations. -public enum HistoryError: Error, CustomStringConvertible, Sendable { - case sessionNotFound(UUID) - case checkpointNotFound(UUID) - case snapshotNotFound(UUID) - case checkpointScopeMismatch(expected: History.Checkpoint.Scope, actual: History.Checkpoint.Scope) - case checkpointSessionMismatch(checkpointId: UUID, expectedSessionId: UUID, actualSessionId: UUID?) - case publishConflict(sessionId: UUID, expectedBaseSharedCheckpointId: UUID?, actualSharedCheckpointId: UUID?) - case mutationFailed(String) - - public var description: String { - switch self { - case let .sessionNotFound(sessionId): - return "workspace session not found: \(sessionId.uuidString)" - case let .checkpointNotFound(checkpointId): - return "workspace checkpoint not found: \(checkpointId.uuidString)" - case let .snapshotNotFound(snapshotId): - return "workspace snapshot not found: \(snapshotId.uuidString)" - case let .checkpointScopeMismatch(expected, actual): - return "checkpoint scope mismatch: expected \(expected.rawValue), got \(actual.rawValue)" - case let .checkpointSessionMismatch(checkpointId, expectedSessionId, actualSessionId): - let actual = actualSessionId?.uuidString ?? "nil" - return "checkpoint \(checkpointId.uuidString) belongs to session \(actual), expected \(expectedSessionId.uuidString)" - case let .publishConflict(sessionId, expectedBaseSharedCheckpointId, actualSharedCheckpointId): - let expected = expectedBaseSharedCheckpointId?.uuidString ?? "nil" - let actual = actualSharedCheckpointId?.uuidString ?? "nil" - return "cannot publish session \(sessionId.uuidString): expected shared head \(expected), current shared head is \(actual)" - case let .mutationFailed(message): - return message - } - } -} diff --git a/Sources/Workspace/MutationRecord.swift b/Sources/Workspace/MutationRecord.swift new file mode 100644 index 0000000..5cd9e98 --- /dev/null +++ b/Sources/Workspace/MutationRecord.swift @@ -0,0 +1,46 @@ +import Foundation + +/// A recorded filesystem mutation emitted by ``Workspace``. +struct MutationRecord: Sendable, Codable, Equatable { + /// The coarse operation kind for filtering and tooling. + enum Kind: String, Sendable, Codable { + case writeFile + case appendFile + case writeData + case writeJSON + case createDirectory + case removeItem + case copyItem + case moveItem + case applyEdits + case applyReplacement + case restoreSnapshot + case mergeWorkspace + } + + var sequence: Int + var workspaceId: UUID + var timestamp: Date + var kind: Kind + var touchedPaths: [WorkspacePath] + var fileChanges: [FileEdit.FileChange] + var diff: TextDiff? + + init( + sequence: Int, + workspaceId: UUID, + timestamp: Date = Date(), + kind: Kind, + touchedPaths: [WorkspacePath], + fileChanges: [FileEdit.FileChange], + diff: TextDiff? = nil + ) { + self.sequence = sequence + self.workspaceId = workspaceId + self.timestamp = timestamp + self.kind = kind + self.touchedPaths = touchedPaths + self.fileChanges = fileChanges + self.diff = diff + } +} diff --git a/Sources/Workspace/Snapshot.swift b/Sources/Workspace/Snapshot.swift index 2a25571..77bf122 100644 --- a/Sources/Workspace/Snapshot.swift +++ b/Sources/Workspace/Snapshot.swift @@ -8,7 +8,7 @@ import Foundation /// /// Capture and restore snapshots through ``Workspace/captureSnapshot(at:)`` and /// ``Workspace/restoreSnapshot(_:)``. To inspect the tree captured by a checkpoint, -/// use ``History/snapshot(for:)``. +/// use ``Workspace/snapshot(for:)``. public struct Snapshot: Sendable, Codable, Equatable { /// A node within a snapshot tree. public indirect enum Entry: Sendable, Codable, Equatable { @@ -122,14 +122,14 @@ extension Snapshot { /// - Parameter other: The snapshot to compare against, typically a parent checkpoint. /// - Returns: A summary describing how many paths changed and whether any of those /// paths involve UTF-8 decodable text (and therefore have a meaningful textual diff). - public func summary(comparedTo other: Snapshot?) -> History.Checkpoint.Summary { + public func summary(comparedTo other: Snapshot?) -> Checkpoint.Summary { let currentNodes = Self.flattenNodes(entry, rootPath: rootPath) let previousNodes = other.map { Self.flattenNodes($0.entry, rootPath: $0.rootPath) } ?? [:] let allPaths = Set(currentNodes.keys).union(previousNodes.keys).sorted() let changedPaths = allPaths.filter { currentNodes[$0] != previousNodes[$0] } let hasTextDiffs = changedPaths.contains { Self.hasTextualChange(old: previousNodes[$0], new: currentNodes[$0]) } - return History.Checkpoint.Summary( + return Checkpoint.Summary( changeCount: changedPaths.count, touchedPaths: changedPaths, hasTextDiffs: hasTextDiffs diff --git a/Sources/Workspace/Workspace+Branching.swift b/Sources/Workspace/Workspace+Branching.swift new file mode 100644 index 0000000..38d534a --- /dev/null +++ b/Sources/Workspace/Workspace+Branching.swift @@ -0,0 +1,96 @@ +import Foundation + +extension Workspace { + /// Creates an isolated branch workspace from the current filesystem state. + /// + /// Branches share only the checkpoint store with their parent. Filesystem state, watchers, and mutation + /// sequences are isolated to the returned workspace. + public func branch(label: String? = nil) async throws -> Workspace { + try await ensureLoaded() + + let baseCheckpointId = headCheckpointId + let snapshot = try await captureSnapshot() + let branchFilesystem = InMemoryFilesystem() + try await Snapshot.restore(snapshot, to: branchFilesystem) + + let branch = Workspace( + workspaceId: UUID(), + filesystem: branchFilesystem, + store: store, + baseCheckpointId: baseCheckpointId + ) + _ = try await branch.seedBranchCheckpoint( + snapshot: snapshot, + label: label, + baseCheckpointId: baseCheckpointId + ) + return branch + } + + /// Merges another workspace into this workspace when this workspace still points at the other's base. + public func merge(_ other: Workspace, label: String? = nil) async throws -> Checkpoint { + try await ensureLoaded() + + let expectedBase = await other.mergeBaseCheckpointId() + guard headCheckpointId == expectedBase else { + throw WorkspaceError.mergeConflict( + parentWorkspaceId: workspaceId, + expectedBase: expectedBase, + actualHead: headCheckpointId + ) + } + + let previousSnapshot = try await captureSnapshot() + let incomingSnapshot = try await other.captureSnapshot() + let incomingHead = try await other.currentHeadCheckpointId() + let delta = snapshotDelta(from: previousSnapshot.entry, to: incomingSnapshot.entry) + + try await untrackedRestore(incomingSnapshot) + + if delta.hasChanges { + try await appendMutation( + kind: .mergeWorkspace, + touchedPaths: Array(delta.touchedPaths).sorted(), + fileChanges: delta.fileChanges, + diff: delta.fileChanges.count == 1 ? delta.fileChanges[0].diff : nil + ) + } + + let mergedSnapshot = try await captureSnapshot() + return try await persistCheckpoint( + snapshot: mergedSnapshot, + label: label, + parentCheckpointId: headCheckpointId, + baseCheckpointId: baseCheckpointId, + mergedFromWorkspaceId: other.workspaceId, + mergedFromCheckpointId: incomingHead, + rollbackSourceCheckpointId: nil, + eventKind: .merged + ) + } + + func seedBranchCheckpoint( + snapshot: Snapshot, + label: String?, + baseCheckpointId: UUID? + ) async throws -> Checkpoint { + try await ensureLoaded() + return try await persistCheckpoint( + snapshot: snapshot, + label: label, + parentCheckpointId: nil, + baseCheckpointId: baseCheckpointId, + eventKind: .created, + comparisonSnapshot: snapshot + ) + } + + func mergeBaseCheckpointId() -> UUID? { + baseCheckpointId + } + + func currentHeadCheckpointId() async throws -> UUID? { + try await ensureLoaded() + return headCheckpointId + } +} diff --git a/Sources/Workspace/Workspace+Checkpoints.swift b/Sources/Workspace/Workspace+Checkpoints.swift new file mode 100644 index 0000000..a46d53f --- /dev/null +++ b/Sources/Workspace/Workspace+Checkpoints.swift @@ -0,0 +1,81 @@ +import Foundation + +extension Workspace { + /// Creates a checkpoint for the workspace's current filesystem state. + public func createCheckpoint(label: String? = nil) async throws -> Checkpoint { + try await ensureLoaded() + let snapshot = try await captureSnapshot() + return try await persistCheckpoint( + snapshot: snapshot, + label: label, + parentCheckpointId: headCheckpointId, + baseCheckpointId: baseCheckpointId, + rollbackSourceCheckpointId: nil, + eventKind: .created + ) + } + + /// Restores the workspace to a prior checkpoint and records the rollback as a new checkpoint. + public func rollback(to checkpointId: UUID, label: String? = nil) async throws -> Checkpoint { + try await ensureLoaded() + let checkpoint = try checkpointOrThrow(id: checkpointId) + let targetSnapshot = try await loadSnapshotOrThrow(id: checkpoint.snapshotId) + try await untrackedRestore(targetSnapshot) + let restoredSnapshot = try await captureSnapshot() + return try await persistCheckpoint( + snapshot: restoredSnapshot, + label: label, + parentCheckpointId: headCheckpointId, + baseCheckpointId: baseCheckpointId, + rollbackSourceCheckpointId: checkpoint.id, + eventKind: .rolledBack + ) + } + + /// Lists checkpoints owned by this workspace. + public func listCheckpoints() async throws -> [Checkpoint] { + try await ensureLoaded() + return checkpoints + } + + /// Returns one checkpoint owned by this workspace when present. + public func checkpoint(id: UUID) async throws -> Checkpoint? { + try await ensureLoaded() + return checkpoints.first(where: { $0.id == id }) + } + + /// Loads the snapshot artifact persisted alongside `checkpoint`. + public func snapshot(for checkpoint: Checkpoint) async throws -> Snapshot { + try await ensureLoaded() + return try await loadSnapshotOrThrow(id: checkpoint.snapshotId, workspaceId: checkpoint.workspaceId) + } + + /// Watches for checkpoint events, including checkpoints created by other workspace instances + /// with the same `workspaceId` and shared store. + public func watchCheckpointEvents() async throws -> AsyncStream { + try await ensureLoaded() + + let watcherId = UUID() + let deliveredIds = Set(checkpoints.map(\.id)) + var continuation: AsyncStream.Continuation? + let stream = AsyncStream { + continuation = $0 + } + + guard let continuation else { + return stream + } + + checkpointWatchers[watcherId] = CheckpointWatcher( + deliveredCheckpointIds: deliveredIds, + continuation: continuation + ) + continuation.onTermination = { [weak self] _ in + Task { + await self?.removeCheckpointWatcher(id: watcherId) + } + } + ensureCheckpointPolling() + return stream + } +} diff --git a/Sources/Workspace/Workspace+Internals.swift b/Sources/Workspace/Workspace+Internals.swift new file mode 100644 index 0000000..1b2e274 --- /dev/null +++ b/Sources/Workspace/Workspace+Internals.swift @@ -0,0 +1,449 @@ +import Foundation + +extension Workspace { + struct SnapshotDelta { + var touchedPaths: Set = [] + var fileChanges: [FileEdit.FileChange] = [] + + var hasChanges: Bool { + !touchedPaths.isEmpty || !fileChanges.isEmpty + } + } + + func ensureLoaded() async throws { + if didLoadStoreState { + return + } + if let loadTask { + try await loadTask.value + return + } + let task = Task { + try await self.loadStoreState() + } + loadTask = task + do { + try await task.value + loadTask = nil + } catch { + loadTask = nil + throw error + } + } + + func loadStoreState() async throws { + checkpoints = try await store.listCheckpoints(workspaceId: workspaceId) + mutations = try await store.listMutationRecords(workspaceId: workspaceId) + nextMutationSequence = (mutations.last?.sequence ?? 0) + 1 + headCheckpointId = checkpoints.sorted(by: checkpointSort).last?.id + didLoadStoreState = true + } + + func checkpointOrThrow(id: UUID) throws -> Checkpoint { + guard let checkpoint = checkpoints.first(where: { $0.id == id }) else { + throw WorkspaceError.checkpointNotFound(id) + } + return checkpoint + } + + func loadSnapshotOrThrow(id: UUID, workspaceId: UUID? = nil) async throws -> Snapshot { + let ownerWorkspaceId = workspaceId ?? self.workspaceId + guard let snapshot = try await store.loadSnapshot(id: id, workspaceId: ownerWorkspaceId) else { + throw WorkspaceError.snapshotNotFound(id) + } + return snapshot + } + + func performDirectEdit( + kind: MutationRecord.Kind, + edit: FileEdit, + apply: () async throws -> Void + ) async throws { + try await ensureLoaded() + let preview = try await previewEdits([edit]) + try await apply() + + let appliedFileChanges = markApplied(preview.edits.first?.fileChanges ?? []) + try await appendMutation( + kind: kind, + touchedPaths: preview.touchedPaths, + fileChanges: appliedFileChanges, + diff: appliedFileChanges.count == 1 ? appliedFileChanges[0].diff : nil + ) + } + + func performBinaryWrite(data: Data, path: WorkspacePath) async throws { + try await ensureLoaded() + + let effect: FileEdit.Effect + let kind: FileTree.Kind + + if await exists(path) { + let info = try await fileInfo(at: path) + kind = info.kind + if info.kind == .directory { + effect = .unchanged + } else { + effect = try await readData(from: path) == data ? .unchanged : .modified + } + } else { + kind = .file + effect = .created + } + + try await untrackedWriteData(data, to: path) + try await appendMutation( + kind: .writeData, + touchedPaths: [path], + fileChanges: [ + FileEdit.FileChange( + path: path, + kind: kind, + effect: effect, + status: .applied, + diff: nil + ), + ], + diff: nil + ) + } + + func persistCheckpoint( + snapshot: Snapshot, + label: String?, + parentCheckpointId: UUID?, + baseCheckpointId: UUID?, + mergedFromWorkspaceId: UUID? = nil, + mergedFromCheckpointId: UUID? = nil, + rollbackSourceCheckpointId: UUID? = nil, + eventKind: CheckpointEvent.Kind, + comparisonSnapshot: Snapshot? = nil + ) async throws -> Checkpoint { + try await store.saveSnapshot(snapshot, workspaceId: workspaceId) + + let parentCheckpoint = parentCheckpointId.flatMap { id in + checkpoints.first(where: { $0.id == id }) + } + + let previousSnapshot: Snapshot? + if let comparisonSnapshot { + previousSnapshot = comparisonSnapshot + } else if let parentCheckpoint { + previousSnapshot = try? await store.loadSnapshot(id: parentCheckpoint.snapshotId, workspaceId: workspaceId) + } else { + previousSnapshot = nil + } + let summary = snapshot.summary(comparedTo: previousSnapshot) + + let previousCursor = parentCheckpoint?.mutationCursor ?? 0 + let currentCursor = latestMutationSequence() + let checkpoint = Checkpoint( + workspaceId: workspaceId, + label: label, + parentCheckpointId: parentCheckpointId, + baseCheckpointId: baseCheckpointId, + mergedFromWorkspaceId: mergedFromWorkspaceId, + mergedFromCheckpointId: mergedFromCheckpointId, + rollbackSourceCheckpointId: rollbackSourceCheckpointId, + firstMutationSequence: currentCursor > previousCursor ? previousCursor + 1 : nil, + lastMutationSequence: currentCursor > previousCursor ? currentCursor : nil, + mutationCursor: currentCursor, + snapshotId: snapshot.id, + summary: summary + ) + + try await store.saveCheckpoint(checkpoint) + + checkpoints.append(checkpoint) + checkpoints.sort(by: checkpointSort) + headCheckpointId = checkpoint.id + + emitCheckpointEvent(CheckpointEvent(kind: eventKind, checkpoint: checkpoint)) + return checkpoint + } + + func appendMutation( + kind: MutationRecord.Kind, + touchedPaths: [WorkspacePath], + fileChanges: [FileEdit.FileChange], + diff: TextDiff? + ) async throws { + let mutation = MutationRecord( + sequence: nextMutationSequence, + workspaceId: workspaceId, + kind: kind, + touchedPaths: Array(Set(touchedPaths)).sorted(), + fileChanges: fileChanges.sorted(by: fileChangeSort), + diff: diff + ) + + nextMutationSequence += 1 + mutations.append(mutation) + mutations.sort { $0.sequence < $1.sequence } + try await store.appendMutation(mutation) + } + + func latestMutationSequence() -> Int { + mutations.map(\.sequence).max() ?? 0 + } + + func mutationRecords() async throws -> [MutationRecord] { + try await ensureLoaded() + return mutations + } + + func encodedJSONString(for value: T, prettyPrinted: Bool) throws -> String { + let encoder = JSONEncoder() + if prettyPrinted { + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + } + + var data = try encoder.encode(value) + data.append(Data("\n".utf8)) + guard let string = String(data: data, encoding: .utf8) else { + throw WorkspaceError.mutationFailed("encoded JSON is not valid UTF-8") + } + return string + } + + func markApplied(_ fileChanges: [FileEdit.FileChange]) -> [FileEdit.FileChange] { + fileChanges.map { change in + var copy = change + copy.status = .applied + return copy + } + } + + func untrackedRestore(_ snapshot: Snapshot) async throws { + try await Snapshot.restore(snapshot, to: filesystem) + } + + func removeCheckpointWatcher(id: UUID) { + checkpointWatchers.removeValue(forKey: id) + if checkpointWatchers.isEmpty { + checkpointPollingTask?.cancel() + checkpointPollingTask = nil + } + } + + func ensureCheckpointPolling() { + guard checkpointPollingTask == nil else { + return + } + checkpointPollingTask = Task { [weak self] in + while !Task.isCancelled { + try? await Task.sleep(for: .milliseconds(500)) + await self?.pollCheckpointEvents() + } + } + } + + func pollCheckpointEvents() async { + guard !checkpointWatchers.isEmpty else { + return + } + guard let loaded = try? await store.listCheckpoints(workspaceId: workspaceId) else { + return + } + + let newCheckpoints = loaded.filter { cp in + !checkpoints.contains(where: { $0.id == cp.id }) + } + + if !newCheckpoints.isEmpty { + checkpoints.append(contentsOf: newCheckpoints) + checkpoints.sort(by: checkpointSort) + headCheckpointId = checkpoints.last?.id + + if let refreshedMutations = try? await store.listMutationRecords(workspaceId: workspaceId) { + mutations = refreshedMutations.sorted { $0.sequence < $1.sequence } + nextMutationSequence = (mutations.last?.sequence ?? 0) + 1 + } + } + + for checkpoint in newCheckpoints.sorted(by: checkpointSort) { + emitCheckpointEvent(CheckpointEvent(kind: checkpoint.inferredEventKind, checkpoint: checkpoint)) + } + } + + func emitCheckpointEvent(_ event: CheckpointEvent) { + guard !checkpointWatchers.isEmpty else { + return + } + + for id in Array(checkpointWatchers.keys) { + guard var watcher = checkpointWatchers[id] else { + continue + } + if watcher.deliveredCheckpointIds.insert(event.checkpoint.id).inserted { + watcher.continuation.yield(event) + checkpointWatchers[id] = watcher + } + } + } + + func snapshotDelta(from original: Snapshot.Entry, to updated: Snapshot.Entry) -> SnapshotDelta { + var delta = SnapshotDelta() + collectDelta(from: original, to: updated, into: &delta) + delta.fileChanges.sort(by: fileChangeSort) + return delta + } + + func collectDelta( + from original: Snapshot.Entry, + to updated: Snapshot.Entry, + into delta: inout SnapshotDelta + ) { + switch (original, updated) { + case (.missing, .missing): + return + case (.missing, _): + collectCreated(updated, into: &delta) + case (_, .missing): + collectDeleted(original, into: &delta) + case let (.file(lhs), .file(rhs)): + if lhs.data != rhs.data || lhs.permissions != rhs.permissions { + delta.touchedPaths.insert(rhs.path) + let diff = lhs.data != rhs.data ? utf8TextFileDiff(oldData: lhs.data, newData: rhs.data) : nil + delta.fileChanges.append( + FileEdit.FileChange( + path: rhs.path, + kind: .file, + effect: .modified, + status: .applied, + diff: diff + ) + ) + } + case let (.symlink(lhs), .symlink(rhs)): + if lhs.target != rhs.target || lhs.permissions != rhs.permissions { + delta.touchedPaths.insert(rhs.path) + delta.fileChanges.append( + FileEdit.FileChange( + path: rhs.path, + kind: .symlink, + effect: .modified, + status: .applied, + diff: nil + ) + ) + } + case let (.directory(lhs), .directory(rhs)): + if lhs.permissions != rhs.permissions { + delta.touchedPaths.insert(rhs.path) + } + + let lhsChildren = Dictionary(uniqueKeysWithValues: lhs.children.map { ($0.path.basename, $0) }) + let rhsChildren = Dictionary(uniqueKeysWithValues: rhs.children.map { ($0.path.basename, $0) }) + let names = Set(lhsChildren.keys).union(rhsChildren.keys).sorted() + + for name in names { + switch (lhsChildren[name], rhsChildren[name]) { + case let (lhs?, rhs?): + collectDelta(from: lhs, to: rhs, into: &delta) + case let (lhs?, nil): + collectDeleted(lhs, into: &delta) + case let (nil, rhs?): + collectCreated(rhs, into: &delta) + case (nil, nil): + break + } + } + default: + collectDeleted(original, into: &delta) + collectCreated(updated, into: &delta) + } + } + + func collectCreated(_ entry: Snapshot.Entry, into delta: inout SnapshotDelta) { + switch entry { + case let .missing(entry): + delta.touchedPaths.insert(entry.path) + case let .file(entry): + delta.touchedPaths.insert(entry.path) + delta.fileChanges.append( + FileEdit.FileChange( + path: entry.path, + kind: .file, + effect: .created, + status: .applied, + diff: utf8TextFileDiff(oldData: Data(), newData: entry.data) + ) + ) + case let .directory(entry): + delta.touchedPaths.insert(entry.path) + for child in entry.children { + collectCreated(child, into: &delta) + } + case let .symlink(entry): + delta.touchedPaths.insert(entry.path) + delta.fileChanges.append( + FileEdit.FileChange( + path: entry.path, + kind: .symlink, + effect: .created, + status: .applied, + diff: nil + ) + ) + } + } + + func collectDeleted(_ entry: Snapshot.Entry, into delta: inout SnapshotDelta) { + switch entry { + case let .missing(entry): + delta.touchedPaths.insert(entry.path) + case let .file(entry): + delta.touchedPaths.insert(entry.path) + delta.fileChanges.append( + FileEdit.FileChange( + path: entry.path, + kind: .file, + effect: .deleted, + status: .applied, + diff: utf8TextFileDiff(oldData: entry.data, newData: Data()) + ) + ) + case let .directory(entry): + delta.touchedPaths.insert(entry.path) + for child in entry.children { + collectDeleted(child, into: &delta) + } + case let .symlink(entry): + delta.touchedPaths.insert(entry.path) + delta.fileChanges.append( + FileEdit.FileChange( + path: entry.path, + kind: .symlink, + effect: .deleted, + status: .applied, + diff: nil + ) + ) + } + } + + func utf8TextFileDiff(oldData: Data, newData: Data) -> TextDiff? { + guard let oldString = String(data: oldData, encoding: .utf8), + let newString = String(data: newData, encoding: .utf8) + else { + return nil + } + let diff = TextDiff.lineBased(from: oldString, to: newString) + return diff.hunks.isEmpty ? nil : diff + } + + func checkpointSort(lhs: Checkpoint, rhs: Checkpoint) -> Bool { + if lhs.createdAt == rhs.createdAt { + return lhs.id.uuidString < rhs.id.uuidString + } + return lhs.createdAt < rhs.createdAt + } + + func fileChangeSort(lhs: FileEdit.FileChange, rhs: FileEdit.FileChange) -> Bool { + if lhs.path == rhs.path { + return lhs.effect.rawValue < rhs.effect.rawValue + } + return lhs.path < rhs.path + } +} diff --git a/Sources/Workspace/Workspace.swift b/Sources/Workspace/Workspace.swift index 7e81d4d..286055b 100644 --- a/Sources/Workspace/Workspace.swift +++ b/Sources/Workspace/Workspace.swift @@ -2,7 +2,38 @@ import Foundation /// A high-level API for reading, editing, and summarizing a workspace. public actor Workspace { - private let filesystem: any FileSystem + /// Where to persist checkpoint and snapshot data. + public enum Storage: Sendable { + /// In-memory storage that does not survive process exit. + case inMemory + /// File-backed JSON storage rooted at `url`. + /// + /// Mutation log appends are serialized through a `mutations.lock` sidecar using an advisory lock + /// where the OS supports it. Multiple processes should still treat the directory as a single writer + /// domain when the underlying filesystem does not honor advisory locks. + case directory(at: URL) + } + + struct CheckpointWatcher { + var deliveredCheckpointIds: Set + var continuation: AsyncStream.Continuation + } + + public nonisolated let workspaceId: UUID + + let filesystem: any FileSystem + let store: any CheckpointStore + let baseCheckpointId: UUID? + + var loadTask: Task? + var didLoadStoreState = false + var checkpoints: [Checkpoint] = [] + var mutations: [MutationRecord] = [] + var headCheckpointId: UUID? + var nextMutationSequence = 1 + var checkpointWatchers: [UUID: CheckpointWatcher] = [:] + var checkpointPollingTask: Task? + private var watchers: [UUID: WatchedChangeStream] = [:] private struct WatchedChangeStream { @@ -11,9 +42,44 @@ public actor Workspace { var continuation: AsyncStream.Continuation } + /// Creates an in-memory workspace suitable for tests and ephemeral work. + public init() { + self.init(filesystem: InMemoryFilesystem()) + } + /// Creates a workspace backed by `filesystem`. - public init(filesystem: any FileSystem) { + public init( + workspaceId: UUID = UUID(), + filesystem: any FileSystem, + storage: Storage = .inMemory + ) { + self.init( + workspaceId: workspaceId, + filesystem: filesystem, + store: Self.makeStore(for: storage), + baseCheckpointId: nil + ) + } + + init( + workspaceId: UUID = UUID(), + filesystem: any FileSystem, + store: any CheckpointStore, + baseCheckpointId: UUID? = nil + ) { + self.workspaceId = workspaceId self.filesystem = filesystem + self.store = store + self.baseCheckpointId = baseCheckpointId + } + + static func makeStore(for storage: Storage) -> any CheckpointStore { + switch storage { + case .inMemory: + InMemoryCheckpointStore() + case .directory(at: let url): + FileCheckpointStore(rootDirectory: url) + } } /// Reads raw file contents from the workspace. @@ -23,8 +89,13 @@ public actor Workspace { /// Writes raw file data to the workspace, replacing any existing contents. public func writeData(_ data: Data, to path: WorkspacePath) async throws { - let events = try await plannedWriteEvents(path: path, data: data, append: false, on: filesystem) - try await filesystem.writeFile(path: path, data: data, append: false) + try await ensureLoaded() + try await performBinaryWrite(data: data, path: path) + } + + func untrackedWriteData(_ data: Data, to path: WorkspacePath, append: Bool = false) async throws { + let events = try await plannedWriteEvents(path: path, data: data, append: append, on: filesystem) + try await filesystem.writeFile(path: path, data: data, append: append) emit(events) } @@ -39,14 +110,29 @@ public actor Workspace { /// Writes UTF-8 text to a file, replacing any existing contents. public func writeFile(_ path: WorkspacePath, content: String) async throws { - let data = Data(content.utf8) - let events = try await plannedWriteEvents(path: path, data: data, append: false, on: filesystem) - try await filesystem.writeFile(path: path, data: data, append: false) - emit(events) + try await performDirectEdit( + kind: .writeFile, + edit: .writeFile(path: path, content: content) + ) { + try await self.untrackedWriteFile(path, content: content) + } + } + + func untrackedWriteFile(_ path: WorkspacePath, content: String) async throws { + try await untrackedWriteData(Data(content.utf8), to: path, append: false) } /// Appends UTF-8 text to a file. public func appendFile(_ path: WorkspacePath, content: String) async throws { + try await performDirectEdit( + kind: .appendFile, + edit: .appendFile(path: path, content: content) + ) { + try await self.untrackedAppendFile(path, content: content) + } + } + + func untrackedAppendFile(_ path: WorkspacePath, content: String) async throws { let data = Data(content.utf8) let events = try await plannedWriteEvents(path: path, data: data, append: true, on: filesystem) try await filesystem.writeFile(path: path, data: data, append: true) @@ -65,15 +151,13 @@ public actor Workspace { /// Encodes a value as JSON and writes it to a file. public func writeJSON(_ value: T, to path: WorkspacePath, prettyPrinted: Bool = true) async throws { - let encoder = JSONEncoder() - if prettyPrinted { - encoder.outputFormatting = [.prettyPrinted, .sortedKeys] - } - var data = try encoder.encode(value) - data.append(Data("\n".utf8)) - let events = try await plannedWriteEvents(path: path, data: data, append: false, on: filesystem) - try await filesystem.writeFile(path: path, data: data, append: false) - emit(events) + let content = try encodedJSONString(for: value, prettyPrinted: prettyPrinted) + try await performDirectEdit( + kind: .writeJSON, + edit: .writeFile(path: path, content: content) + ) { + try await self.untrackedWriteFile(path, content: content) + } } /// Watches for future changes affecting `path`. @@ -126,6 +210,15 @@ public actor Workspace { /// Creates a directory at `path`. public func createDirectory(at path: WorkspacePath, recursive: Bool = true) async throws { + try await performDirectEdit( + kind: .createDirectory, + edit: .createDirectory(path: path, recursive: recursive) + ) { + try await self.untrackedCreateDirectory(at: path, recursive: recursive) + } + } + + func untrackedCreateDirectory(at path: WorkspacePath, recursive: Bool = true) async throws { let events = try await plannedDirectoryCreationEvents(path: path, recursive: recursive, on: filesystem) try await filesystem.createDirectory(path: path, recursive: recursive) emit(events) @@ -133,6 +226,15 @@ public actor Workspace { /// Removes the entry at `path`. public func removeItem(at path: WorkspacePath, recursive: Bool = true) async throws { + try await performDirectEdit( + kind: .removeItem, + edit: .delete(path: path, recursive: recursive) + ) { + try await self.untrackedRemoveItem(at: path, recursive: recursive) + } + } + + func untrackedRemoveItem(at path: WorkspacePath, recursive: Bool = true) async throws { let events = try await plannedDeletionEvents(at: path, on: filesystem) try await filesystem.remove(path: path, recursive: recursive) emit(events) @@ -141,6 +243,17 @@ public actor Workspace { /// Copies an entry from `source` to `destination`. public func copyItem(from source: WorkspacePath, to destination: WorkspacePath, recursive: Bool = true) async throws + { + try await performDirectEdit( + kind: .copyItem, + edit: .copy(from: source, to: destination, recursive: recursive) + ) { + try await self.untrackedCopyItem(from: source, to: destination, recursive: recursive) + } + } + + func untrackedCopyItem(from source: WorkspacePath, to destination: WorkspacePath, recursive: Bool = true) + async throws { let events = try await plannedTransferEvents( from: source, @@ -158,6 +271,15 @@ public actor Workspace { /// Moves or renames an entry from `source` to `destination`. public func moveItem(from source: WorkspacePath, to destination: WorkspacePath) async throws { + try await performDirectEdit( + kind: .moveItem, + edit: .move(from: source, to: destination) + ) { + try await self.untrackedMoveItem(from: source, to: destination) + } + } + + func untrackedMoveItem(from source: WorkspacePath, to destination: WorkspacePath) async throws { let events = try await plannedTransferEvents( from: source, to: destination, @@ -194,7 +316,18 @@ public actor Workspace { /// Restores `snapshot` onto the workspace, removing any extra entries beneath the /// snapshot's root so that the workspace exactly matches the captured tree. public func restoreSnapshot(_ snapshot: Snapshot) async throws { - try await Snapshot.restore(snapshot, to: filesystem) + try await ensureLoaded() + let previousSnapshot = try await captureSnapshot() + try await untrackedRestore(snapshot) + let restoredSnapshot = try await captureSnapshot() + let delta = snapshotDelta(from: previousSnapshot.entry, to: restoredSnapshot.entry) + let topDiff: TextDiff? = delta.fileChanges.count == 1 ? delta.fileChanges[0].diff : nil + try await appendMutation( + kind: .restoreSnapshot, + touchedPaths: Array(delta.touchedPaths).sorted(), + fileChanges: delta.fileChanges, + diff: topDiff + ) } private struct PlannedReplacement { @@ -228,6 +361,31 @@ public actor Workspace { public func applyReplacement( _ request: ReplacementRequest, failurePolicy: MutationFailurePolicy = .rollback + ) async throws -> ReplacementResult { + try await ensureLoaded() + let result = try await untrackedApplyReplacement(request, failurePolicy: failurePolicy) + if !result.changes.isEmpty || !result.failures.isEmpty { + try await appendMutation( + kind: .applyReplacement, + touchedPaths: result.touchedPaths, + fileChanges: result.changes.map { + FileEdit.FileChange( + path: $0.path, + kind: .file, + effect: .modified, + status: $0.status, + diff: $0.diff + ) + }, + diff: result.changes.count == 1 ? result.changes[0].diff : nil + ) + } + return result + } + + func untrackedApplyReplacement( + _ request: ReplacementRequest, + failurePolicy: MutationFailurePolicy = .rollback ) async throws -> ReplacementResult { let plannedChanges = try await plannedReplacementChanges(for: request) var changes = plannedChanges.map(\.change) @@ -319,6 +477,29 @@ public actor Workspace { public func applyEdits( _ edits: [FileEdit], failurePolicy: MutationFailurePolicy = .rollback + ) async throws -> FileEdit.BatchResult { + try await ensureLoaded() + let result = try await untrackedApplyEdits(edits, failurePolicy: failurePolicy) + if !edits.isEmpty { + let topDiff: TextDiff? = + if result.edits.count == 1, let single = result.edits.first, single.fileChanges.count == 1 { + single.fileChanges[0].diff + } else { + nil + } + try await appendMutation( + kind: .applyEdits, + touchedPaths: result.touchedPaths, + fileChanges: result.edits.flatMap(\.fileChanges), + diff: topDiff + ) + } + return result + } + + func untrackedApplyEdits( + _ edits: [FileEdit], + failurePolicy: MutationFailurePolicy = .rollback ) async throws -> FileEdit.BatchResult { let touchedPaths = canonicalizedTouchedPaths(for: edits) let plannedEdits = try await planBatchEdits(edits) @@ -1253,6 +1434,20 @@ public actor Workspace { try await target.createSymlink(path: path, target: symlinkTarget) try await target.setPermissions(path: path, permissions: permissions) case let .directory(path, permissions, children): + if path.isRoot { + if await target.exists(path: path) { + let entries = try await target.listDirectory(path: path) + for entry in entries { + try await target.remove(path: path.appending(entry.name), recursive: true) + } + } + try await target.setPermissions(path: path, permissions: permissions) + for child in children { + try await restore(child, on: target) + } + return + } + if await target.exists(path: path) { try await target.remove(path: path, recursive: true) } diff --git a/Sources/Workspace/WorkspaceReading.swift b/Sources/Workspace/WorkspaceReading.swift deleted file mode 100644 index d5c4a3f..0000000 --- a/Sources/Workspace/WorkspaceReading.swift +++ /dev/null @@ -1,88 +0,0 @@ -import Foundation - -/// Read-only view over a ``Workspace``. -/// -/// ``History/shared`` and ``History/Session/workspace`` are exposed as `any WorkspaceReading` so that -/// callers cannot accidentally bypass mutation tracking by writing through them. Any code that needs to -/// **mutate** a workspace tracked by ``History`` must go through the corresponding ``History`` or -/// ``History/Session`` API (for example ``History/writeFile(_:content:)`` or -/// ``History/Session/applyEdits(_:failurePolicy:)``). -/// -/// All conformances are expected to be safe to share across actors and concurrency domains; the protocol -/// therefore refines `Sendable`. ``Workspace`` itself conforms. -public protocol WorkspaceReading: Sendable { - /// Reads raw file contents from the workspace. - func readData(from path: WorkspacePath) async throws -> Data - - /// Reads a UTF-8 file from the workspace. - func readFile(_ path: WorkspacePath) async throws -> String - - /// Returns whether an entry exists at `path`. - func exists(_ path: WorkspacePath) async -> Bool - - /// Returns metadata for the entry at `path`. - func fileInfo(at path: WorkspacePath) async throws -> FileInfo - - /// Lists the direct children of the directory at `path`. - func listDirectory(at path: WorkspacePath) async throws -> [DirectoryEntry] - - /// Expands a glob pattern relative to `currentDirectory`. - func glob(_ pattern: String, currentDirectory: WorkspacePath) async throws -> [WorkspacePath] - - /// Builds a recursive tree representation for the entry at `path`. - func walkTree(_ path: WorkspacePath, maxDepth: Int?) async throws -> FileTree - - /// Summarizes the subtree rooted at `path`. - func summarizeTree(_ path: WorkspacePath, maxDepth: Int?) async throws -> FileTreeSummary - - /// Captures a durable snapshot of the subtree rooted at `path`. - func captureSnapshot(at path: WorkspacePath) async throws -> Snapshot - - /// Returns a preview of a replacement request without mutating the workspace. - func previewReplacement(_ request: ReplacementRequest) async throws -> ReplacementResult - - /// Returns a preview of a batch of edits without mutating the workspace. - func previewEdits(_ edits: [FileEdit]) async throws -> FileEdit.BatchResult - - /// Watches for future changes affecting `path`. - func watchChanges(at path: WorkspacePath, recursive: Bool) async -> AsyncStream -} - -extension WorkspaceReading { - /// Reads and decodes JSON from a UTF-8 file. - public func readJSON(_ type: T.Type = T.self, from path: WorkspacePath) async throws -> T { - let data = try await readData(from: path) - do { - return try JSONDecoder().decode(T.self, from: data) - } catch { - throw WorkspaceError.decodingFailed(path, underlying: String(describing: error)) - } - } - - /// Convenience overload that globs from the workspace root. - public func glob(_ pattern: String) async throws -> [WorkspacePath] { - try await glob(pattern, currentDirectory: .root) - } - - /// Convenience overload that walks the entire subtree at `path`. - public func walkTree(_ path: WorkspacePath) async throws -> FileTree { - try await walkTree(path, maxDepth: nil) - } - - /// Convenience overload that summarizes the entire subtree at `path`. - public func summarizeTree(_ path: WorkspacePath) async throws -> FileTreeSummary { - try await summarizeTree(path, maxDepth: nil) - } - - /// Convenience overload that captures a snapshot of the workspace root. - public func captureSnapshot() async throws -> Snapshot { - try await captureSnapshot(at: .root) - } - - /// Convenience overload that watches recursively. - public func watchChanges(at path: WorkspacePath) async -> AsyncStream { - await watchChanges(at: path, recursive: true) - } -} - -extension Workspace: WorkspaceReading {} diff --git a/Tests/WorkspaceTests/CheckpointStoreTests.swift b/Tests/WorkspaceTests/CheckpointStoreTests.swift index 332bd06..d88c1a0 100644 --- a/Tests/WorkspaceTests/CheckpointStoreTests.swift +++ b/Tests/WorkspaceTests/CheckpointStoreTests.swift @@ -10,7 +10,7 @@ struct CheckpointStoreTests { let workspaceA = UUID() let workspaceB = UUID() let snapshotId = UUID() - let summary = History.Checkpoint.Summary(changeCount: 0, touchedPaths: [], hasTextDiffs: false) + let summary = Checkpoint.Summary(changeCount: 0, touchedPaths: [], hasTextDiffs: false) let snapshot = Snapshot( id: snapshotId, @@ -20,15 +20,12 @@ struct CheckpointStoreTests { ) ) - let cpEarly = History.Checkpoint( + let cpEarly = Checkpoint( id: UUID(), workspaceId: workspaceA, - sessionId: nil, - scope: .shared, label: "a", createdAt: Date(timeIntervalSince1970: 100), parentCheckpointId: nil, - baseSharedCheckpointId: nil, firstMutationSequence: nil, lastMutationSequence: nil, mutationCursor: 0, @@ -36,15 +33,12 @@ struct CheckpointStoreTests { summary: summary ) - let cpLate = History.Checkpoint( + let cpLate = Checkpoint( id: UUID(), workspaceId: workspaceA, - sessionId: nil, - scope: .shared, label: "b", createdAt: Date(timeIntervalSince1970: 200), parentCheckpointId: cpEarly.id, - baseSharedCheckpointId: nil, firstMutationSequence: 1, lastMutationSequence: 1, mutationCursor: 1, @@ -70,8 +64,6 @@ struct CheckpointStoreTests { let m3 = MutationRecord( sequence: 3, workspaceId: workspaceA, - sessionId: nil, - scope: .shared, kind: .writeFile, touchedPaths: ["/x"], fileChanges: [] @@ -79,8 +71,6 @@ struct CheckpointStoreTests { let m1 = MutationRecord( sequence: 1, workspaceId: workspaceA, - sessionId: nil, - scope: .shared, kind: .writeFile, touchedPaths: ["/y"], fileChanges: [] @@ -103,7 +93,7 @@ struct CheckpointStoreTests { let workspaceId = UUID() let snapshotId = UUID() - let summary = History.Checkpoint.Summary(changeCount: 1, touchedPaths: ["/f"], hasTextDiffs: true) + let summary = Checkpoint.Summary(changeCount: 1, touchedPaths: ["/f"], hasTextDiffs: true) let snapshot = Snapshot( id: snapshotId, @@ -113,13 +103,10 @@ struct CheckpointStoreTests { ) ) - let checkpoint = History.Checkpoint( + let checkpoint = Checkpoint( workspaceId: workspaceId, - sessionId: nil, - scope: .shared, label: "disk", parentCheckpointId: nil, - baseSharedCheckpointId: nil, firstMutationSequence: 1, lastMutationSequence: 2, mutationCursor: 2, @@ -130,8 +117,6 @@ struct CheckpointStoreTests { let mutation = MutationRecord( sequence: 1, workspaceId: workspaceId, - sessionId: nil, - scope: .shared, kind: .writeFile, touchedPaths: ["/f"], fileChanges: [] @@ -189,17 +174,14 @@ struct CheckpointStoreTests { let workspaceId = UUID() let sharedId = UUID() let snapshotId = UUID() - let summary = History.Checkpoint.Summary(changeCount: 0, touchedPaths: [], hasTextDiffs: false) + let summary = Checkpoint.Summary(changeCount: 0, touchedPaths: [], hasTextDiffs: false) - let first = History.Checkpoint( + let first = Checkpoint( id: sharedId, workspaceId: workspaceId, - sessionId: nil, - scope: .shared, label: "first", createdAt: Date(timeIntervalSince1970: 50), parentCheckpointId: nil, - baseSharedCheckpointId: nil, firstMutationSequence: nil, lastMutationSequence: nil, mutationCursor: 0, @@ -232,8 +214,6 @@ struct CheckpointStoreTests { let m2 = MutationRecord( sequence: 2, workspaceId: workspaceId, - sessionId: nil, - scope: .shared, kind: .writeFile, touchedPaths: ["/b"], fileChanges: [] @@ -241,8 +221,6 @@ struct CheckpointStoreTests { let m1 = MutationRecord( sequence: 1, workspaceId: workspaceId, - sessionId: nil, - scope: .shared, kind: .writeFile, touchedPaths: ["/a"], fileChanges: [] @@ -259,8 +237,6 @@ struct CheckpointStoreTests { let m3 = MutationRecord( sequence: 3, workspaceId: workspaceId, - sessionId: nil, - scope: .shared, kind: .appendFile, touchedPaths: ["/c"], fileChanges: [] @@ -279,16 +255,13 @@ struct CheckpointStoreTests { let workspaceId = UUID() let checkpointId = UUID() let snapshotId = UUID() - let summary = History.Checkpoint.Summary(changeCount: 0, touchedPaths: [], hasTextDiffs: false) + let summary = Checkpoint.Summary(changeCount: 0, touchedPaths: [], hasTextDiffs: false) - let first = History.Checkpoint( + let first = Checkpoint( id: checkpointId, workspaceId: workspaceId, - sessionId: nil, - scope: .shared, label: "v1", parentCheckpointId: nil, - baseSharedCheckpointId: nil, firstMutationSequence: nil, lastMutationSequence: nil, mutationCursor: 0, @@ -346,8 +319,6 @@ struct CheckpointStoreTests { let appended = MutationRecord( sequence: 1, workspaceId: workspaceId, - sessionId: nil, - scope: .shared, kind: .writeFile, touchedPaths: ["/x"], fileChanges: [] @@ -378,8 +349,6 @@ struct CheckpointStoreTests { let mutation = MutationRecord( sequence: sequence, workspaceId: workspaceId, - sessionId: nil, - scope: .shared, kind: .writeFile, touchedPaths: [path], fileChanges: [] diff --git a/Tests/WorkspaceTests/CheckpointTests.swift b/Tests/WorkspaceTests/CheckpointTests.swift new file mode 100644 index 0000000..3e45d4a --- /dev/null +++ b/Tests/WorkspaceTests/CheckpointTests.swift @@ -0,0 +1,126 @@ +import Foundation +import Testing +@testable import Workspace + +@Suite("Checkpoint") +struct CheckpointTests { + @Test + func `Checkpoint inferredEventKind classifies created rollback and merge`() async throws { + let workspaceId = UUID() + let snapshotId = UUID() + let summary = Checkpoint.Summary(changeCount: 0, touchedPaths: [], hasTextDiffs: false) + + let created = Checkpoint( + workspaceId: workspaceId, + label: nil, + parentCheckpointId: nil, + baseCheckpointId: nil, + firstMutationSequence: nil, + lastMutationSequence: nil, + mutationCursor: 0, + snapshotId: snapshotId, + summary: summary + ) + #expect(created.inferredEventKind == .created) + + let rolledBack = Checkpoint( + workspaceId: workspaceId, + label: nil, + parentCheckpointId: nil, + baseCheckpointId: nil, + rollbackSourceCheckpointId: UUID(), + firstMutationSequence: nil, + lastMutationSequence: nil, + mutationCursor: 0, + snapshotId: snapshotId, + summary: summary + ) + #expect(rolledBack.inferredEventKind == .rolledBack) + + let merged = Checkpoint( + workspaceId: workspaceId, + label: nil, + parentCheckpointId: nil, + baseCheckpointId: nil, + mergedFromWorkspaceId: UUID(), + mergedFromCheckpointId: UUID(), + firstMutationSequence: nil, + lastMutationSequence: nil, + mutationCursor: 0, + snapshotId: snapshotId, + summary: summary + ) + #expect(merged.inferredEventKind == .merged) + } + + @Test + func `CheckpointEvent and MutationRecord roundtrip through Codable`() async throws { + let workspaceId = UUID() + let summary = Checkpoint.Summary(changeCount: 0, touchedPaths: [], hasTextDiffs: false) + let checkpoint = Checkpoint( + workspaceId: workspaceId, + label: "x", + parentCheckpointId: nil, + baseCheckpointId: nil, + firstMutationSequence: nil, + lastMutationSequence: nil, + mutationCursor: 0, + snapshotId: UUID(), + summary: summary + ) + + let encoder = JSONEncoder() + let decoder = JSONDecoder() + + let event = CheckpointEvent(kind: .created, checkpoint: checkpoint) + #expect(try decoder.decode(CheckpointEvent.self, from: encoder.encode(event)) == event) + + let kinds: [MutationRecord.Kind] = [ + .writeFile, + .appendFile, + .writeData, + .writeJSON, + .createDirectory, + .removeItem, + .copyItem, + .moveItem, + .applyEdits, + .applyReplacement, + .restoreSnapshot, + .mergeWorkspace, + ] + + for kind in kinds { + let mutation = MutationRecord( + sequence: 1, + workspaceId: workspaceId, + kind: kind, + touchedPaths: ["/a.txt"], + fileChanges: [] + ) + let decoded = try decoder.decode(MutationRecord.self, from: encoder.encode(mutation)) + #expect(decoded == mutation) + } + } + + @Test + func `WorkspaceError descriptions are stable and include identifiers`() async throws { + let checkpointId = UUID() + let snapshotId = UUID() + let workspaceId = UUID() + let headId = UUID() + + let cases: [(WorkspaceError, String)] = [ + (.checkpointNotFound(checkpointId), checkpointId.uuidString), + (.snapshotNotFound(snapshotId), snapshotId.uuidString), + (.mergeConflict(parentWorkspaceId: workspaceId, expectedBase: nil, actualHead: headId), workspaceId.uuidString), + (.mergeConflict(parentWorkspaceId: workspaceId, expectedBase: nil, actualHead: headId), headId.uuidString), + (.mutationFailed("boom"), "boom"), + ] + + for (error, needle) in cases { + let description = String(describing: error) + #expect(description.contains(needle), "missing '\(needle)' in: \(description)") + } + } +} diff --git a/Tests/WorkspaceTests/HistoryTests.swift b/Tests/WorkspaceTests/HistoryTests.swift deleted file mode 100644 index 4e7c583..0000000 --- a/Tests/WorkspaceTests/HistoryTests.swift +++ /dev/null @@ -1,1009 +0,0 @@ -import Foundation -import Testing -@testable import Workspace - -private actor CheckpointEventRecorder { - private var events: [CheckpointEvent] = [] - - func append(_ event: CheckpointEvent) { - events.append(event) - } - - func snapshot() -> [CheckpointEvent] { - events - } -} - -private func startCheckpointRecording( - _ stream: AsyncStream, - into recorder: CheckpointEventRecorder -) -> Task { - Task { - for await event in stream { - await recorder.append(event) - } - } -} - -private func waitForCheckpointEvents( - _ expectedCount: Int, - recorder: CheckpointEventRecorder, - timeout: Duration = .seconds(1) -) async throws -> [CheckpointEvent] { - let clock = ContinuousClock() - let deadline = clock.now.advanced(by: timeout) - - while clock.now < deadline { - let snapshot = await recorder.snapshot() - if snapshot.count >= expectedCount { - return Array(snapshot.prefix(expectedCount)) - } - try await Task.sleep(for: .milliseconds(10)) - } - - return await recorder.snapshot() -} - -/// Delegates to ``InMemoryCheckpointStore`` but can force ``loadSnapshot`` to return nil for specific ids (exercises ``HistoryError/snapshotNotFound``). -private actor FlakySnapshotCheckpointStore: CheckpointStore { - private let base = InMemoryCheckpointStore() - private var snapshotIdsReturningNil: Set = [] - - func breakLoadingSnapshot(id: UUID) { - snapshotIdsReturningNil.insert(id) - } - - func saveCheckpoint(_ checkpoint: History.Checkpoint) async throws { - try await base.saveCheckpoint(checkpoint) - } - - func loadCheckpoint(id: UUID, workspaceId: UUID) async throws -> History.Checkpoint? { - try await base.loadCheckpoint(id: id, workspaceId: workspaceId) - } - - func listCheckpoints(workspaceId: UUID) async throws -> [History.Checkpoint] { - try await base.listCheckpoints(workspaceId: workspaceId) - } - - func saveSnapshot(_ snapshot: Snapshot, workspaceId: UUID) async throws { - try await base.saveSnapshot(snapshot, workspaceId: workspaceId) - } - - func loadSnapshot(id: UUID, workspaceId: UUID) async throws -> Snapshot? { - if snapshotIdsReturningNil.contains(id) { - return nil - } - return try await base.loadSnapshot(id: id, workspaceId: workspaceId) - } - - func appendMutation(_ mutation: MutationRecord) async throws { - try await base.appendMutation(mutation) - } - - func listMutationRecords(workspaceId: UUID) async throws -> [MutationRecord] { - try await base.listMutationRecords(workspaceId: workspaceId) - } -} - -@Suite("History") -struct HistoryTests { - @Test - func `session checkpoints persist mutation ranges and shared workspace remains unchanged`() async throws { - let workspaceId = UUID() - let history = History( - workspaceId: workspaceId, - filesystem: InMemoryFilesystem() - ) - - let session = try await history.createSession() - try await session.writeFile("/note.txt", content: "one") - try await session.appendFile("/note.txt", content: " two") - try await session.createDirectory(at: "/docs") - - let checkpoint = try await session.createCheckpoint(label: "draft") - let mutations = try await session.mutationRecords() - let storeCheckpoints = try await history.listCheckpoints(scope: .session, sessionId: session.id) - - #expect(checkpoint.scope == .session) - #expect(checkpoint.sessionId == session.id) - #expect(checkpoint.firstMutationSequence == 1) - #expect(checkpoint.lastMutationSequence == 3) - #expect(checkpoint.mutationCursor == 3) - #expect(checkpoint.label == "draft") - #expect(mutations.map(\.kind) == [.writeFile, .appendFile, .createDirectory]) - #expect(mutations[0].diff?.hunks.isEmpty == false) - #expect(mutations[1].diff?.hunks.isEmpty == false) - #expect(mutations[2].diff == nil) - #expect(storeCheckpoints == [checkpoint]) - #expect(!(await history.shared.exists("/note.txt"))) - } - - @Test - func `empty checkpoints are persisted and session rollback creates a rollback checkpoint`() async throws { - let history = History(filesystem: InMemoryFilesystem()) - - let session = try await history.createSession() - let emptyCheckpoint = try await session.createCheckpoint(label: "start") - - try await session.writeFile("/note.txt", content: "draft") - let rollbackCheckpoint = try await session.rollback(to: emptyCheckpoint.id, label: "undo") - - #expect(emptyCheckpoint.firstMutationSequence == nil) - #expect(emptyCheckpoint.lastMutationSequence == nil) - #expect(emptyCheckpoint.mutationCursor == 0) - #expect(!(await session.workspace.exists("/note.txt"))) - #expect(rollbackCheckpoint.scope == .session) - #expect(rollbackCheckpoint.rollbackSourceCheckpointId == emptyCheckpoint.id) - #expect(rollbackCheckpoint.sessionId == session.id) - #expect((try await session.listCheckpoints()).count == 2) - } - - @Test - func `shared rollback restores the shared tree and emits a rollback event`() async throws { - let history = History(filesystem: InMemoryFilesystem()) - - try await history.writeFile("/shared.txt", content: "one") - let checkpoint = try await history.createCheckpoint(label: "v1") - try await history.writeFile("/shared.txt", content: "two") - - let recorder = CheckpointEventRecorder() - let stream = try await history.watchCheckpointEvents() - let task = startCheckpointRecording(stream, into: recorder) - defer { task.cancel() } - - let rollbackCheckpoint = try await history.rollback(to: checkpoint.id, label: "revert") - let events = try await waitForCheckpointEvents(1, recorder: recorder) - - #expect(try await history.shared.readFile("/shared.txt") == "one") - #expect(rollbackCheckpoint.scope == .shared) - #expect(rollbackCheckpoint.rollbackSourceCheckpointId == checkpoint.id) - #expect(events.count == 1) - #expect(events[0].kind == .rolledBack) - #expect(events[0].checkpoint == rollbackCheckpoint) - } - - @Test - func `sessions checkpoint independently and publish conflicts when shared head has advanced`() async throws { - let history = History(filesystem: InMemoryFilesystem()) - - let sessionA = try await history.createSession() - let sessionB = try await history.createSession() - - try await sessionA.writeFile("/note.txt", content: "alpha") - try await sessionB.writeFile("/note.txt", content: "beta") - - let checkpointA = try await sessionA.createCheckpoint(label: "a1") - let checkpointB = try await sessionB.createCheckpoint(label: "b1") - let published = try await sessionA.publish(label: "publish a") - - #expect(checkpointA.scope == .session) - #expect(checkpointB.scope == .session) - #expect(checkpointA.sessionId == sessionA.id) - #expect(checkpointB.sessionId == sessionB.id) - #expect(checkpointA.baseSharedCheckpointId == checkpointB.baseSharedCheckpointId) - #expect(published.scope == .shared) - #expect(published.originSessionId == sessionA.id) - #expect(published.sessionId == sessionA.id) - #expect(try await history.shared.readFile("/note.txt") == "alpha") - #expect(try await sessionA.baseSharedCheckpointId() == published.id) - - do { - _ = try await sessionB.publish(label: "publish b") - Issue.record("expected publish conflict") - } catch let error as HistoryError { - switch error { - case let .publishConflict(sessionId, expectedBaseSharedCheckpointId, actualSharedCheckpointId): - #expect(sessionId == sessionB.id) - #expect(expectedBaseSharedCheckpointId == checkpointB.baseSharedCheckpointId) - #expect(actualSharedCheckpointId == published.id) - default: - Issue.record("unexpected history error: \(error)") - } - } - } - - @Test - func `checkpoint summary tracks file changes and text diffs`() async throws { - let history = History(filesystem: InMemoryFilesystem()) - - let session = try await history.createSession() - try await session.writeFile("/a.txt", content: "hello") - try await session.writeFile("/b.txt", content: "world") - try await session.createDirectory(at: "/docs") - - let checkpoint = try await session.createCheckpoint(label: "initial") - - #expect(checkpoint.summary.changeCount == 3) - #expect(checkpoint.summary.touchedPaths.contains("/a.txt")) - #expect(checkpoint.summary.touchedPaths.contains("/b.txt")) - #expect(checkpoint.summary.touchedPaths.contains("/docs")) - #expect(checkpoint.summary.hasTextDiffs == true) - - try await session.writeFile("/a.txt", content: "updated") - let second = try await session.createCheckpoint(label: "update") - - #expect(second.summary.changeCount == 1) - #expect(second.summary.touchedPaths == [WorkspacePath("/a.txt")]) - #expect(second.summary.hasTextDiffs == true) - } - - @Test - func `history metadata and file-backed store roundtrip through Codable`() async throws { - let root = try makeTempDirectory() - defer { removeTempDirectory(root) } - - let workspaceId = UUID() - let history = History( - workspaceId: workspaceId, - filesystem: InMemoryFilesystem(), - storage: .directory(at: root) - ) - - let session = try await history.createSession() - try await session.writeFile("/note.txt", content: "hello") - let checkpoint = try await session.createCheckpoint(label: "draft") - let mutation = try #require((try await session.mutationRecords()).first) - let reloaded = History( - workspaceId: workspaceId, - filesystem: InMemoryFilesystem(), - storage: .directory(at: root) - ) - - let encoder = JSONEncoder() - let decoder = JSONDecoder() - - let decodedCheckpoint = try decoder.decode(History.Checkpoint.self, from: encoder.encode(checkpoint)) - let decodedMutation = try decoder.decode(MutationRecord.self, from: encoder.encode(mutation)) - let decodedEvent = try decoder.decode( - CheckpointEvent.self, - from: encoder.encode(CheckpointEvent(kind: .created, checkpoint: checkpoint)) - ) - - #expect(decodedCheckpoint == checkpoint) - #expect(decodedMutation == mutation) - #expect(decodedEvent == CheckpointEvent(kind: .created, checkpoint: checkpoint)) - #expect(try await reloaded.checkpoint(id: checkpoint.id) == checkpoint) - #expect(try await reloaded.mutationRecords() == [mutation]) - } - - @Test - func `file backed history reloads checkpoint snapshot artifact`() async throws { - let root = try makeTempDirectory() - defer { removeTempDirectory(root) } - - let workspaceId = UUID() - let history = History( - workspaceId: workspaceId, - filesystem: InMemoryFilesystem(), - storage: .directory(at: root) - ) - - try await history.writeFile("/note.txt", content: "checkpoint") - let checkpoint = try await history.createCheckpoint(label: "saved") - try await history.writeFile("/note.txt", content: "current") - - let reloaded = History( - workspaceId: workspaceId, - filesystem: InMemoryFilesystem(), - storage: .directory(at: root) - ) - let persistedCheckpoint = try #require(try await reloaded.checkpoint(id: checkpoint.id)) - let snapshot = try await reloaded.snapshot(for: persistedCheckpoint) - let restored = InMemoryFilesystem() - - try await Snapshot.restore(snapshot, to: restored) - - #expect(try await history.shared.readFile("/note.txt") == "current") - #expect(try await restored.readFile(path: "/note.txt") == Data("checkpoint".utf8)) - #expect(snapshot.summary(comparedTo: nil) == checkpoint.summary) - } - - @Test - func `session creation and checkpointing work with mounted shared filesystems`() async throws { - let mountedWorkspace = InMemoryFilesystem() - try await mountedWorkspace.writeFile(path: "/file.txt", data: Data("shared".utf8), append: false) - - let history = History( - filesystem: MountableFilesystem( - base: InMemoryFilesystem(), - mounts: [.init(mountPoint: "/workspace", filesystem: mountedWorkspace)] - ) - ) - - let session = try await history.createSession() - let original = try await session.workspace.readFile("/workspace/file.txt") - try await session.writeFile("/workspace/file.txt", content: "session") - let checkpoint = try await session.createCheckpoint(label: "mounted") - - #expect(original == "shared") - #expect(try await session.workspace.readFile("/workspace/file.txt") == "session") - #expect(try await history.shared.readFile("/workspace/file.txt") == "shared") - #expect(checkpoint.scope == .session) - #expect(checkpoint.sessionId == session.id) - } - - @Test - func `convenience History init defaults to in-memory shared workspace`() async throws { - let history = History() - - try await history.writeFile("/note.txt", content: "hello") - let checkpoint = try await history.createCheckpoint(label: "initial") - - #expect(checkpoint.scope == .shared) - #expect(try await history.shared.readFile("/note.txt") == "hello") - #expect(try await history.mutationRecords(scope: .shared).map(\.kind) == [.writeFile]) - } - - @Test - func `mutationRecords filters by shared scope and snapshot(for:) loads checkpoint contents`() async throws { - let history = History() - let session = try await history.createSession() - try await session.writeFile("/session.txt", content: "session") - - try await history.writeFile("/shared.txt", content: "shared-1") - let checkpoint = try await history.createCheckpoint(label: "shared") - try await history.writeFile("/shared.txt", content: "shared-2") - - let sharedMutations = try await history.mutationRecords(scope: .shared) - let sessionMutations = try await history.mutationRecords(scope: .session, sessionId: session.id) - let snapshot = try await history.snapshot(for: checkpoint) - - #expect(sharedMutations.map(\.kind) == [.writeFile, .writeFile]) - #expect(sharedMutations.allSatisfy { $0.scope == .shared }) - #expect(sessionMutations.map(\.kind) == [.writeFile]) - #expect(sessionMutations.allSatisfy { $0.sessionId == session.id }) - #expect(snapshot.id == checkpoint.snapshotId) - - let restored = Workspace(filesystem: InMemoryFilesystem()) - try await restored.restoreSnapshot(snapshot) - #expect(try await restored.readFile("/shared.txt") == "shared-1") - #expect(!(await restored.exists("/session.txt"))) - } - - @Test - func `rollback throws checkpointNotFound and rejects session checkpoints from shared scope`() async throws { - let history = History() - try await history.writeFile("/shared.txt", content: "v1") - let sharedCheckpoint = try await history.createCheckpoint(label: "shared-v1") - - let session = try await history.createSession() - try await session.writeFile("/note.txt", content: "draft") - let sessionCheckpoint = try await session.createCheckpoint(label: "session-v1") - - let bogusId = UUID() - do { - _ = try await history.rollback(to: bogusId) - Issue.record("expected checkpointNotFound") - } catch let error as HistoryError { - guard case let .checkpointNotFound(id) = error else { - Issue.record("unexpected error: \(error)") - return - } - #expect(id == bogusId) - } - - do { - _ = try await history.rollback(to: sessionCheckpoint.id) - Issue.record("expected checkpointScopeMismatch") - } catch let error as HistoryError { - guard case let .checkpointScopeMismatch(expected, actual) = error else { - Issue.record("unexpected error: \(error)") - return - } - #expect(expected == .shared) - #expect(actual == .session) - } - - let rolled = try await history.rollback(to: sharedCheckpoint.id) - #expect(rolled.rollbackSourceCheckpointId == sharedCheckpoint.id) - } - - @Test - func `Storage directory(at:) round-trips checkpoints across History instances`() async throws { - let root = try makeTempDirectory() - defer { removeTempDirectory(root) } - - let workspaceId = UUID() - let history = History( - workspaceId: workspaceId, - filesystem: InMemoryFilesystem(), - storage: .directory(at: root) - ) - try await history.writeFile("/state.json", content: "{}") - let checkpoint = try await history.createCheckpoint(label: "checkpoint-a") - - let reopened = History( - workspaceId: workspaceId, - filesystem: InMemoryFilesystem(), - storage: .directory(at: root) - ) - let reloaded = try #require(try await reopened.checkpoint(id: checkpoint.id)) - let snapshot = try await reopened.snapshot(for: reloaded) - - #expect(reloaded == checkpoint) - #expect(snapshot.entry.path == .root) - } - - @Test - func `shared mutations record append writeData writeJSON and tree operations`() async throws { - let history = History(filesystem: InMemoryFilesystem()) - - try await history.writeFile("/base.txt", content: "x") - try await history.appendFile("/base.txt", content: "y") - try await history.writeData(Data([0, 1, 2]), to: "/bin.dat") - try await history.writeJSON(["a": 1], to: "/config.json", prettyPrinted: false) - try await history.createDirectory(at: "/nested/deep", recursive: true) - try await history.writeFile("/nested/deep/a.txt", content: "a") - try await history.copyItem(from: "/nested/deep/a.txt", to: "/nested/deep/b.txt") - try await history.moveItem(from: "/nested/deep/b.txt", to: "/moved.txt") - try await history.removeItem(at: "/nested", recursive: true) - - let kinds = try await history.mutationRecords(scope: .shared).map(\.kind) - #expect( - kinds == [ - .writeFile, - .appendFile, - .writeData, - .writeJSON, - .createDirectory, - .writeFile, - .copyItem, - .moveItem, - .removeItem, - ] - ) - #expect(try await history.shared.readFile("/base.txt") == "xy") - #expect(try await history.shared.readFile("/moved.txt") == "a") - } - - @Test - func `shared applyEdits and applyReplacement append mutation records`() async throws { - let history = History(filesystem: InMemoryFilesystem()) - - let batch = try await history.applyEdits( - [ - .writeFile(path: "/a.txt", content: "line\n"), - .writeFile(path: "/b.txt", content: "other\n"), - ] - ) - #expect(batch.edits.count == 2) - - try await history.writeFile("/replace.txt", content: "hello old world") - let replacement = try await history.applyReplacement( - ReplacementRequest(pattern: "*.txt", search: "old", replacement: "new") - ) - #expect(replacement.changes.count == 1) - - let mutations = try await history.mutationRecords(scope: .shared).map(\.kind) - #expect(mutations == [.applyEdits, .writeFile, .applyReplacement]) - #expect(try await history.shared.readFile("/replace.txt") == "hello new world") - } - - @Test - func `session rollback to shared checkpoint rewires base and rejects other session checkpoints`() async throws { - let history = History(filesystem: InMemoryFilesystem()) - try await history.writeFile("/shared.txt", content: "v1") - let sharedCp = try await history.createCheckpoint(label: "shared-head") - - let sessionA = try await history.createSession() - let sessionB = try await history.createSession() - try await sessionA.writeFile("/a.txt", content: "a") - try await sessionB.writeFile("/b.txt", content: "b") - let bCheckpoint = try await sessionB.createCheckpoint(label: "b-only") - - let rolled = try await sessionA.rollback(to: sharedCp.id, label: "to-shared") - #expect(try await sessionA.baseSharedCheckpointId() == sharedCp.id) - #expect(!(await sessionA.workspace.exists("/a.txt"))) - #expect(rolled.rollbackSourceCheckpointId == sharedCp.id) - - do { - _ = try await sessionA.rollback(to: bCheckpoint.id) - Issue.record("expected checkpointSessionMismatch") - } catch let error as HistoryError { - guard case let .checkpointSessionMismatch(checkpointId, expectedSessionId, actualSessionId) = error else { - Issue.record("unexpected error: \(error)") - return - } - #expect(checkpointId == bCheckpoint.id) - #expect(expectedSessionId == sessionA.id) - #expect(actualSessionId == sessionB.id) - } - } - - @Test - func `watchCheckpointEvents emits created for new shared checkpoints after subscribing`() async throws { - let history = History(filesystem: InMemoryFilesystem()) - - let recorder = CheckpointEventRecorder() - let stream = try await history.watchCheckpointEvents() - let task = startCheckpointRecording(stream, into: recorder) - defer { task.cancel() } - - try await history.writeFile("/tracked.txt", content: "v1") - let checkpoint = try await history.createCheckpoint(label: "snap") - - let events = try await waitForCheckpointEvents(1, recorder: recorder) - #expect(events.count == 1) - #expect(events[0].kind == .created) - #expect(events[0].checkpoint.id == checkpoint.id) - #expect(events[0].checkpoint.label == "snap") - } - - @Test - func `watchCheckpointEvents emits published when a session head is published`() async throws { - let history = History(filesystem: InMemoryFilesystem()) - let session = try await history.createSession() - try await session.writeFile("/pub.txt", content: "content") - - let recorder = CheckpointEventRecorder() - let stream = try await history.watchCheckpointEvents() - let task = startCheckpointRecording(stream, into: recorder) - defer { task.cancel() } - - let published = try await session.publish(label: "out") - - let events = try await waitForCheckpointEvents(1, recorder: recorder) - #expect(events.count == 1) - #expect(events[0].kind == .published) - #expect(events[0].checkpoint.id == published.id) - #expect(events[0].checkpoint.originSessionId == session.id) - } - - @Test - func `publishSessionHead throws sessionNotFound for an unknown session id`() async throws { - let history = History() - let unknown = UUID() - do { - _ = try await history.publishSessionHead(sessionId: unknown) - Issue.record("expected sessionNotFound") - } catch let error as HistoryError { - guard case let .sessionNotFound(id) = error else { - Issue.record("unexpected error: \(error)") - return - } - #expect(id == unknown) - } - } - - @Test - func `shared applyEdits with an empty batch does not append a mutation`() async throws { - let history = History() - try await history.writeFile("/x.txt", content: "x") - let before = try await history.mutationRecords(scope: .shared).count - - _ = try await history.applyEdits([]) - - let after = try await history.mutationRecords(scope: .shared).count - #expect(after == before) - } - - @Test - func `session applyEdits and applyReplacement record session scoped mutations`() async throws { - let history = History() - let session = try await history.createSession() - - _ = try await session.applyEdits( - [ - .writeFile(path: "/batch.txt", content: "a\n"), - ] - ) - try await session.writeFile("/replace.txt", content: "find me") - _ = try await session.applyReplacement( - ReplacementRequest(pattern: "*.txt", search: "find", replacement: "found") - ) - - let kinds = try await history.mutationRecords(scope: .session, sessionId: session.id).map(\.kind) - #expect(kinds == [.applyEdits, .writeFile, .applyReplacement]) - #expect(try await session.workspace.readFile("/replace.txt") == "found me") - } - - @Test - func `History init with an existing workspace uses it as shared`() async throws { - let fs = InMemoryFilesystem() - let workspace = Workspace(filesystem: fs) - try await workspace.writeFile("/seed.txt", content: "seed") - - let history = History(workspace: workspace) - - #expect(try await history.shared.readFile("/seed.txt") == "seed") - try await history.writeFile("/more.txt", content: "more") - #expect(try await workspace.readFile("/more.txt") == "more") - } - - @Test - func `listCheckpoints with scope shared excludes session checkpoints`() async throws { - let history = History() - try await history.writeFile("/s.txt", content: "s") - let sharedCP = try await history.createCheckpoint(label: "shared-only") - - let session = try await history.createSession() - try await session.writeFile("/sess.txt", content: "sess") - let sessionCP = try await session.createCheckpoint(label: "sess-only") - - let sharedOnly = try await history.listCheckpoints(scope: .shared) - let forSession = try await history.listCheckpoints(scope: .session, sessionId: session.id) - - #expect(sharedOnly.contains(where: { $0.id == sharedCP.id })) - #expect(sharedOnly.contains(where: { $0.id == sessionCP.id }) == false) - #expect(forSession == [sessionCP]) - } - - @Test - func `checkpoint id returns nil when no checkpoint exists`() async throws { - let history = History() - let missing = try await history.checkpoint(id: UUID()) - #expect(missing == nil) - } - - @Test - func `publish with no session edits still creates a published shared checkpoint`() async throws { - let history = History() - try await history.writeFile("/base.txt", content: "base") - let session = try await history.createSession() - - let published = try await session.publish(label: "noop") - - #expect(published.scope == .shared) - #expect(published.originSessionId == session.id) - #expect(published.inferredEventKind == .published) - - let publishMutations = try await history.mutationRecords(scope: .shared).filter { $0.kind == .publishSessionHead } - #expect(publishMutations.isEmpty) - - #expect(try await history.shared.readFile("/base.txt") == "base") - } - - @Test - func `session writeData and writeJSON append session mutations`() async throws { - let history = History() - let session = try await history.createSession() - - try await session.writeData(Data([9, 8]), to: "/raw.bin") - try await session.writeJSON(["k": true], to: "/cfg.json", prettyPrinted: true) - - let kinds = try await history.mutationRecords(scope: .session, sessionId: session.id).map(\.kind) - #expect(kinds == [.writeData, .writeJSON]) - } - - @Test - func `snapshot for checkpoint throws snapshotNotFound when store drops the artifact`() async throws { - let store = FlakySnapshotCheckpointStore() - let workspace = Workspace(filesystem: InMemoryFilesystem()) - let history = History(workspace: workspace, store: store) - - try await history.writeFile("/a.txt", content: "a") - let checkpoint = try await history.createCheckpoint(label: "has-snapshot") - - await store.breakLoadingSnapshot(id: checkpoint.snapshotId) - - do { - _ = try await history.snapshot(for: checkpoint) - Issue.record("expected snapshotNotFound") - } catch let error as HistoryError { - guard case let .snapshotNotFound(id) = error else { - Issue.record("unexpected error: \(error)") - return - } - #expect(id == checkpoint.snapshotId) - } - } - - @Test - func `shared rollback throws snapshotNotFound when snapshot artifact is missing`() async throws { - let store = FlakySnapshotCheckpointStore() - let history = History(workspace: Workspace(filesystem: InMemoryFilesystem()), store: store) - - try await history.writeFile("/x.txt", content: "x") - let checkpoint = try await history.createCheckpoint(label: "v1") - try await history.writeFile("/x.txt", content: "y") - - await store.breakLoadingSnapshot(id: checkpoint.snapshotId) - - do { - _ = try await history.rollback(to: checkpoint.id) - Issue.record("expected snapshotNotFound") - } catch let error as HistoryError { - guard case let .snapshotNotFound(id) = error else { - Issue.record("unexpected error: \(error)") - return - } - #expect(id == checkpoint.snapshotId) - } - } - - @Test - func `watchCheckpointEvents receives checkpoints created by another History sharing the store`() async throws { - let store = InMemoryCheckpointStore() - let workspaceId = UUID() - - let observer = History(workspaceId: workspaceId, filesystem: InMemoryFilesystem(), store: store) - let producer = History(workspaceId: workspaceId, filesystem: InMemoryFilesystem(), store: store) - - let recorder = CheckpointEventRecorder() - let stream = try await observer.watchCheckpointEvents() - let task = startCheckpointRecording(stream, into: recorder) - defer { task.cancel() } - - try await producer.writeFile("/remote.txt", content: "r") - let created = try await producer.createCheckpoint(label: "other-instance") - - let events = try await waitForCheckpointEvents(1, recorder: recorder, timeout: .seconds(2)) - #expect(events.count >= 1) - let match = try #require(events.first(where: { $0.checkpoint.id == created.id })) - #expect(match.kind == .created) - } - - @Test - func `observed checkpoints from another instance update the shared head and mutation cursor`() async throws { - let store = InMemoryCheckpointStore() - let workspaceId = UUID() - let sharedFilesystem = InMemoryFilesystem() - - let observer = History(workspaceId: workspaceId, filesystem: sharedFilesystem, store: store) - let producer = History(workspaceId: workspaceId, filesystem: sharedFilesystem, store: store) - - let recorder = CheckpointEventRecorder() - let stream = try await observer.watchCheckpointEvents() - let task = startCheckpointRecording(stream, into: recorder) - defer { task.cancel() } - - try await producer.writeFile("/remote.txt", content: "r") - let producerCheckpoint = try await producer.createCheckpoint(label: "from-producer") - - _ = try await waitForCheckpointEvents(1, recorder: recorder, timeout: .seconds(2)) - - try await observer.writeFile("/observer.txt", content: "o") - let observerCheckpoint = try await observer.createCheckpoint(label: "from-observer") - - #expect(observerCheckpoint.parentCheckpointId == producerCheckpoint.id) - - let storedMutations = try await store.listMutationRecords(workspaceId: workspaceId) - let sequences = storedMutations.map(\.sequence) - #expect(sequences.count == Set(sequences).count) - } - - @Test - func `session removeItem copyItem and moveItem record session mutations`() async throws { - let history = History() - let session = try await history.createSession() - - try await session.writeFile("/a.txt", content: "a") - try await session.createDirectory(at: "/d", recursive: true) - try await session.writeFile("/d/inner.txt", content: "inner") - try await session.copyItem(from: "/a.txt", to: "/d/copy.txt") - try await session.moveItem(from: "/d/copy.txt", to: "/moved.txt") - try await session.removeItem(at: "/moved.txt") - - let kinds = try await history.mutationRecords(scope: .session, sessionId: session.id).map(\.kind) - #expect( - kinds == [ - .writeFile, - .createDirectory, - .writeFile, - .copyItem, - .moveItem, - .removeItem, - ] - ) - } - - @Test - func `shared writeData to an existing file is unchanged when bytes match`() async throws { - let history = History() - let data = Data([7, 7, 7]) - - try await history.writeData(data, to: "/bin.dat") - try await history.writeData(data, to: "/bin.dat") - - let mutations = try await history.mutationRecords(scope: .shared) - #expect(mutations.count == 2) - #expect(mutations.allSatisfy { $0.kind == .writeData }) - let effects = mutations.flatMap(\.fileChanges).map(\.effect) - #expect(effects == [.created, .unchanged]) - } - - @Test - func `shared writeData targets an existing directory before overwriting with file contents`() async throws { - let history = History() - try await history.createDirectory(at: "/bucket", recursive: true) - - try await history.writeData(Data("blob".utf8), to: "/bucket") - - let mutation = try #require((try await history.mutationRecords(scope: .shared)).last) - #expect(mutation.kind == .writeData) - let change = try #require(mutation.fileChanges.first) - #expect(change.kind == .directory) - #expect(change.effect == .unchanged) - } - - @Test - func `publish records a publishSessionHead mutation when session replaces a file with a directory`() async throws { - let history = History() - try await history.writeFile("/shape.txt", content: "was-a-file") - - let session = try await history.createSession() - try await session.removeItem(at: "/shape.txt") - try await session.createDirectory(at: "/shape.txt", recursive: true) - try await session.writeFile("/shape.txt/nested.txt", content: "now-tree") - - _ = try await session.publish(label: "restructure") - - let publishRows = try await history.mutationRecords(scope: .shared).filter { $0.kind == .publishSessionHead } - #expect(publishRows.count == 1) - #expect(try await history.shared.readFile("/shape.txt/nested.txt") == "now-tree") - } - - @Test - func `session listCheckpoints includes shared checkpoints and session scoped rows`() async throws { - let history = History() - try await history.writeFile("/shared-only.txt", content: "s") - let sharedCP = try await history.createCheckpoint(label: "on-shared") - - let session = try await history.createSession() - try await session.writeFile("/local.txt", content: "l") - let sessionCP = try await session.createCheckpoint(label: "on-session") - - let visible = try await session.listCheckpoints() - #expect(visible.contains(where: { $0.id == sharedCP.id && $0.scope == .shared })) - #expect(visible.contains(where: { $0.id == sessionCP.id && $0.scope == .session })) - } - - @Test - func `session applyEdits with an empty batch does not append mutations`() async throws { - let history = History() - let session = try await history.createSession() - try await session.writeFile("/z.txt", content: "z") - - let before = try await history.mutationRecords(scope: .session, sessionId: session.id).count - _ = try await session.applyEdits([]) - let after = try await history.mutationRecords(scope: .session, sessionId: session.id).count - - #expect(after == before) - } - - @Test - func `multiple watchCheckpointEvents subscriptions share one polling loop`() async throws { - let history = History(filesystem: InMemoryFilesystem()) - - _ = try await history.watchCheckpointEvents() - _ = try await history.watchCheckpointEvents() - - try await history.writeFile("/multi-watch.txt", content: "mw") - let checkpoint = try await history.createCheckpoint(label: "mw") - - #expect(checkpoint.label == "mw") - } - - @Test - func `cancelling a checkpoint stream allows a new watch to receive subsequent events`() async throws { - let history = History() - - let firstStream = try await history.watchCheckpointEvents() - let consumer = Task { - for await _ in firstStream {} - } - try await Task.sleep(for: .milliseconds(30)) - consumer.cancel() - try await Task.sleep(for: .milliseconds(120)) - - let recorder = CheckpointEventRecorder() - let secondStream = try await history.watchCheckpointEvents() - let recording = startCheckpointRecording(secondStream, into: recorder) - defer { recording.cancel() } - - try await history.writeFile("/after-resubscribe.txt", content: "ar") - let checkpoint = try await history.createCheckpoint(label: "resubscribe") - - let events = try await waitForCheckpointEvents(1, recorder: recorder) - #expect(events.contains(where: { $0.checkpoint.id == checkpoint.id && $0.kind == .created })) - } - - @Test - func `applyEdits with one batch touching multiple files clears the top-level mutation diff`() async throws { - let history = History() - try await history.writeFile("/a.txt", content: "alpha\n") - try await history.writeFile("/b.txt", content: "bravo\n") - - let beforeCount = try await history.mutationRecords(scope: .shared).count - - let result = try await history.applyEdits([ - .writeFile(path: "/a.txt", content: "alpha-2\n"), - .writeFile(path: "/b.txt", content: "bravo-2\n") - ]) - #expect(result.edits.count == 2) - - let after = try await history.mutationRecords(scope: .shared) - #expect(after.count == beforeCount + 1) - let mutation = try #require(after.last) - #expect(mutation.kind == .applyEdits) - #expect(mutation.diff == nil) - #expect(mutation.fileChanges.count == 2) - #expect(mutation.fileChanges.allSatisfy { $0.diff != nil }) - } - - @Test - func `applyEdits with one batch touching one file keeps the top-level mutation diff`() async throws { - let history = History() - try await history.writeFile("/only.txt", content: "first\n") - - let result = try await history.applyEdits([ - .writeFile(path: "/only.txt", content: "second\n") - ]) - #expect(result.edits.count == 1) - - let mutation = try #require((try await history.mutationRecords(scope: .shared)).last) - #expect(mutation.kind == .applyEdits) - #expect(mutation.fileChanges.count == 1) - #expect(mutation.diff != nil) - #expect(mutation.diff == mutation.fileChanges[0].diff) - } - - @Test - func `publishSessionHead emits per-file UTF-8 diffs in the recorded mutation`() async throws { - let history = History() - try await history.writeFile("/notes.txt", content: "one\n") - try await history.writeFile("/keep.txt", content: "keep\n") - - let session = try await history.createSession() - try await session.writeFile("/notes.txt", content: "one\ntwo\n") - try await session.writeFile("/new.txt", content: "fresh\n") - - _ = try await session.publish(label: "publish-with-diffs") - - let publishMutation = try #require( - (try await history.mutationRecords(scope: .shared)) - .last(where: { $0.kind == .publishSessionHead }) - ) - - let changesByPath = Dictionary(uniqueKeysWithValues: publishMutation.fileChanges.map { ($0.path, $0) }) - let modified = try #require(changesByPath["/notes.txt"]) - #expect(modified.effect == .modified) - #expect(modified.diff != nil) - #expect(modified.diff?.hunks.isEmpty == false) - - let created = try #require(changesByPath["/new.txt"]) - #expect(created.effect == .created) - #expect(created.diff != nil) - #expect(created.diff?.hunks.isEmpty == false) - - // Top-level diff is set only when there is exactly one file change in the delta. - #expect(publishMutation.diff == nil) - } - - @Test - func `publishSessionHead emits a delete diff when the session removes a UTF-8 file`() async throws { - let history = History() - try await history.writeFile("/gone.txt", content: "going-away\n") - - let session = try await history.createSession() - try await session.removeItem(at: "/gone.txt") - - _ = try await session.publish(label: "publish-with-deletion") - - let publishMutation = try #require( - (try await history.mutationRecords(scope: .shared)) - .last(where: { $0.kind == .publishSessionHead }) - ) - let deleted = try #require(publishMutation.fileChanges.first { $0.path == "/gone.txt" }) - #expect(deleted.effect == .deleted) - #expect(deleted.diff != nil) - #expect(deleted.diff?.hunks.isEmpty == false) - - // Single-change delta -> top-level diff equals the only file change's diff. - #expect(publishMutation.fileChanges.count == 1) - #expect(publishMutation.diff == deleted.diff) - } - - // MARK: - Helpers - - private func makeTempDirectory() throws -> URL { - let base = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true) - let url = base.appendingPathComponent("HistoryTests-\(UUID().uuidString)", isDirectory: true) - try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true) - return url - } - - private func removeTempDirectory(_ url: URL) { - try? FileManager.default.removeItem(at: url) - } -} diff --git a/Tests/WorkspaceTests/HistoryTypesTests.swift b/Tests/WorkspaceTests/HistoryTypesTests.swift deleted file mode 100644 index 84496de..0000000 --- a/Tests/WorkspaceTests/HistoryTypesTests.swift +++ /dev/null @@ -1,165 +0,0 @@ -import Foundation -import Testing -@testable import Workspace - -@Suite("HistoryTypes") -struct HistoryTypesTests { - @Test - func `Checkpoint inferredEventKind classifies created, rolledBack, and published`() async throws { - let workspaceId = UUID() - let snapshotId = UUID() - let summary = History.Checkpoint.Summary(changeCount: 0, touchedPaths: [], hasTextDiffs: false) - - let created = History.Checkpoint( - workspaceId: workspaceId, - sessionId: nil, - scope: .shared, - label: nil, - parentCheckpointId: nil, - baseSharedCheckpointId: nil, - firstMutationSequence: nil, - lastMutationSequence: nil, - mutationCursor: 0, - originSessionId: nil, - rollbackSourceCheckpointId: nil, - snapshotId: snapshotId, - summary: summary - ) - #expect(created.inferredEventKind == .created) - - let rolledBack = History.Checkpoint( - workspaceId: workspaceId, - sessionId: nil, - scope: .shared, - label: nil, - parentCheckpointId: nil, - baseSharedCheckpointId: nil, - firstMutationSequence: nil, - lastMutationSequence: nil, - mutationCursor: 0, - originSessionId: nil, - rollbackSourceCheckpointId: UUID(), - snapshotId: snapshotId, - summary: summary - ) - #expect(rolledBack.inferredEventKind == .rolledBack) - - let published = History.Checkpoint( - workspaceId: workspaceId, - sessionId: UUID(), - scope: .shared, - label: nil, - parentCheckpointId: nil, - baseSharedCheckpointId: nil, - firstMutationSequence: nil, - lastMutationSequence: nil, - mutationCursor: 0, - originSessionId: UUID(), - rollbackSourceCheckpointId: nil, - snapshotId: snapshotId, - summary: summary - ) - #expect(published.inferredEventKind == .published) - - let sessionWithOrigin = History.Checkpoint( - workspaceId: workspaceId, - sessionId: UUID(), - scope: .session, - label: nil, - parentCheckpointId: nil, - baseSharedCheckpointId: UUID(), - firstMutationSequence: nil, - lastMutationSequence: nil, - mutationCursor: 0, - originSessionId: UUID(), - rollbackSourceCheckpointId: nil, - snapshotId: snapshotId, - summary: summary - ) - #expect(sessionWithOrigin.inferredEventKind == .created) - } - - @Test - func `CheckpointEvent scope mirrors checkpoint scope`() async throws { - let workspaceId = UUID() - let snapshotId = UUID() - let summary = History.Checkpoint.Summary(changeCount: 0, touchedPaths: [], hasTextDiffs: false) - let checkpoint = History.Checkpoint( - workspaceId: workspaceId, - sessionId: UUID(), - scope: .session, - label: "x", - parentCheckpointId: nil, - baseSharedCheckpointId: nil, - firstMutationSequence: nil, - lastMutationSequence: nil, - mutationCursor: 0, - snapshotId: snapshotId, - summary: summary - ) - let event = CheckpointEvent(kind: .created, checkpoint: checkpoint) - #expect(event.scope == .session) - #expect(event.checkpoint.scope == event.scope) - } - - @Test - func `HistoryError descriptions are stable and include identifiers`() async throws { - let sessionId = UUID() - let checkpointId = UUID() - let snapshotId = UUID() - let originSession = UUID() - - let cases: [(HistoryError, String)] = [ - (.sessionNotFound(sessionId), sessionId.uuidString), - (.checkpointNotFound(checkpointId), checkpointId.uuidString), - (.snapshotNotFound(snapshotId), snapshotId.uuidString), - (.checkpointScopeMismatch(expected: .shared, actual: .session), "shared"), - (.checkpointScopeMismatch(expected: .shared, actual: .session), "session"), - (.checkpointSessionMismatch(checkpointId: checkpointId, expectedSessionId: sessionId, actualSessionId: originSession), checkpointId.uuidString), - (.publishConflict(sessionId: sessionId, expectedBaseSharedCheckpointId: nil, actualSharedCheckpointId: checkpointId), sessionId.uuidString), - (.mutationFailed("boom"), "boom"), - ] - - for (error, needle) in cases { - let description = String(describing: error) - #expect(description.contains(needle), "missing '\(needle)' in: \(description)") - } - } - - @Test - func `MutationRecord roundtrips through Codable for representative kinds`() async throws { - let workspaceId = UUID() - let sessionId = UUID() - let encoder = JSONEncoder() - let decoder = JSONDecoder() - - let kinds: [MutationRecord.Kind] = [ - .writeFile, - .appendFile, - .writeData, - .writeJSON, - .createDirectory, - .removeItem, - .copyItem, - .moveItem, - .applyEdits, - .applyReplacement, - .publishSessionHead, - ] - - for kind in kinds { - let original = MutationRecord( - sequence: 1, - workspaceId: workspaceId, - sessionId: sessionId, - scope: .shared, - kind: kind, - touchedPaths: ["/a.txt"], - fileChanges: [] - ) - let decoded = try decoder.decode(MutationRecord.self, from: encoder.encode(original)) - #expect(decoded == original) - #expect(decoded.kind == kind) - } - } -} diff --git a/Tests/WorkspaceTests/WorkspaceCheckpointTests.swift b/Tests/WorkspaceTests/WorkspaceCheckpointTests.swift new file mode 100644 index 0000000..9eab27a --- /dev/null +++ b/Tests/WorkspaceTests/WorkspaceCheckpointTests.swift @@ -0,0 +1,370 @@ +import Foundation +import Testing +@testable import Workspace + +private actor CheckpointEventRecorder { + private var events: [CheckpointEvent] = [] + + func append(_ event: CheckpointEvent) { + events.append(event) + } + + func snapshot() -> [CheckpointEvent] { + events + } +} + +private func startCheckpointRecording( + _ stream: AsyncStream, + into recorder: CheckpointEventRecorder +) -> Task { + Task { + for await event in stream { + await recorder.append(event) + } + } +} + +private func waitForCheckpointEvents( + _ expectedCount: Int, + recorder: CheckpointEventRecorder, + timeout: Duration = .seconds(1) +) async throws -> [CheckpointEvent] { + let clock = ContinuousClock() + let deadline = clock.now.advanced(by: timeout) + + while clock.now < deadline { + let snapshot = await recorder.snapshot() + if snapshot.count >= expectedCount { + return Array(snapshot.prefix(expectedCount)) + } + try await Task.sleep(for: .milliseconds(10)) + } + + return await recorder.snapshot() +} + +private actor FlakySnapshotCheckpointStore: CheckpointStore { + private let base = InMemoryCheckpointStore() + private var snapshotIdsReturningNil: Set = [] + + func breakLoadingSnapshot(id: UUID) { + snapshotIdsReturningNil.insert(id) + } + + func saveCheckpoint(_ checkpoint: Checkpoint) async throws { + try await base.saveCheckpoint(checkpoint) + } + + func loadCheckpoint(id: UUID, workspaceId: UUID) async throws -> Checkpoint? { + try await base.loadCheckpoint(id: id, workspaceId: workspaceId) + } + + func listCheckpoints(workspaceId: UUID) async throws -> [Checkpoint] { + try await base.listCheckpoints(workspaceId: workspaceId) + } + + func saveSnapshot(_ snapshot: Snapshot, workspaceId: UUID) async throws { + try await base.saveSnapshot(snapshot, workspaceId: workspaceId) + } + + func loadSnapshot(id: UUID, workspaceId: UUID) async throws -> Snapshot? { + if snapshotIdsReturningNil.contains(id) { + return nil + } + return try await base.loadSnapshot(id: id, workspaceId: workspaceId) + } + + func appendMutation(_ mutation: MutationRecord) async throws { + try await base.appendMutation(mutation) + } + + func listMutationRecords(workspaceId: UUID) async throws -> [MutationRecord] { + try await base.listMutationRecords(workspaceId: workspaceId) + } +} + +@Suite("Workspace checkpoints") +struct WorkspaceCheckpointTests { + @Test + func `tracked writes persist mutation ranges and checkpoint summaries`() async throws { + let workspace = Workspace(filesystem: InMemoryFilesystem()) + + try await workspace.writeFile("/note.txt", content: "one") + try await workspace.appendFile("/note.txt", content: " two") + try await workspace.createDirectory(at: "/docs") + + let checkpoint = try await workspace.createCheckpoint(label: "draft") + let mutations = try await workspace.mutationRecords() + + #expect(checkpoint.firstMutationSequence == 1) + #expect(checkpoint.lastMutationSequence == 3) + #expect(checkpoint.mutationCursor == 3) + #expect(checkpoint.label == "draft") + #expect(checkpoint.summary.changeCount == 2) + #expect(checkpoint.summary.touchedPaths.contains("/note.txt")) + #expect(checkpoint.summary.touchedPaths.contains("/docs")) + #expect(checkpoint.summary.hasTextDiffs == true) + #expect(mutations.map(\.kind) == [.writeFile, .appendFile, .createDirectory]) + #expect(mutations[0].diff?.hunks.isEmpty == false) + #expect(mutations[1].diff?.hunks.isEmpty == false) + #expect(mutations[2].diff == nil) + #expect(try await workspace.readFile("/note.txt") == "one two") + } + + @Test + func `rollback restores the tree and emits a rollback event`() async throws { + let workspace = Workspace(filesystem: InMemoryFilesystem()) + + try await workspace.writeFile("/shared.txt", content: "one") + let checkpoint = try await workspace.createCheckpoint(label: "v1") + try await workspace.writeFile("/shared.txt", content: "two") + + let recorder = CheckpointEventRecorder() + let stream = try await workspace.watchCheckpointEvents() + let task = startCheckpointRecording(stream, into: recorder) + defer { task.cancel() } + + let rollbackCheckpoint = try await workspace.rollback(to: checkpoint.id, label: "revert") + let events = try await waitForCheckpointEvents(1, recorder: recorder) + + #expect(try await workspace.readFile("/shared.txt") == "one") + #expect(rollbackCheckpoint.rollbackSourceCheckpointId == checkpoint.id) + #expect(events.count == 1) + #expect(events[0].kind == .rolledBack) + #expect(events[0].checkpoint == rollbackCheckpoint) + } + + @Test + func `branch writes are isolated and merge creates one merge checkpoint`() async throws { + let workspace = Workspace(filesystem: InMemoryFilesystem()) + try await workspace.writeFile("/note.txt", content: "base") + let base = try await workspace.createCheckpoint(label: "base") + + let branch = try await workspace.branch(label: "branch start") + try await branch.writeFile("/note.txt", content: "branch") + try await branch.writeFile("/new.txt", content: "new") + let branchHead = try await branch.createCheckpoint(label: "branch head") + + #expect(try await workspace.readFile("/note.txt") == "base") + #expect(!(await workspace.exists("/new.txt"))) + + let branchCheckpoints = try await branch.listCheckpoints() + #expect(branchCheckpoints.count == 2) + #expect(branchCheckpoints[0].baseCheckpointId == base.id) + #expect(branchHead.parentCheckpointId == branchCheckpoints[0].id) + #expect((try await workspace.listCheckpoints()).map(\.id) == [base.id]) + + let merged = try await workspace.merge(branch, label: "merge branch") + let mutations = try await workspace.mutationRecords() + + #expect(try await workspace.readFile("/note.txt") == "branch") + #expect(try await workspace.readFile("/new.txt") == "new") + #expect(merged.parentCheckpointId == base.id) + #expect(merged.mergedFromWorkspaceId == branch.workspaceId) + #expect(merged.mergedFromCheckpointId == branchHead.id) + #expect(merged.summary.touchedPaths.contains("/note.txt")) + #expect(merged.summary.touchedPaths.contains("/new.txt")) + #expect(Array(mutations.map(\.kind).suffix(1)) == [.mergeWorkspace]) + } + + @Test + func `merge conflicts when parent head advanced after branch`() async throws { + let workspace = Workspace(filesystem: InMemoryFilesystem()) + try await workspace.writeFile("/note.txt", content: "base") + let base = try await workspace.createCheckpoint(label: "base") + + let branch = try await workspace.branch() + try await branch.writeFile("/note.txt", content: "branch") + + try await workspace.writeFile("/note.txt", content: "main") + let advanced = try await workspace.createCheckpoint(label: "main") + + do { + _ = try await workspace.merge(branch) + Issue.record("expected merge conflict") + } catch let error as WorkspaceError { + guard case let .mergeConflict(parentWorkspaceId, expectedBase, actualHead) = error else { + Issue.record("unexpected workspace error: \(error)") + return + } + #expect(parentWorkspaceId == workspace.workspaceId) + #expect(expectedBase == base.id) + #expect(actualHead == advanced.id) + } + } + + @Test + func `file backed storage reloads checkpoint snapshot artifacts`() async throws { + let root = try makeTempDirectory() + defer { removeTempDirectory(root) } + + let workspaceId = UUID() + let workspace = Workspace( + workspaceId: workspaceId, + filesystem: InMemoryFilesystem(), + storage: .directory(at: root) + ) + + try await workspace.writeFile("/note.txt", content: "checkpoint") + let checkpoint = try await workspace.createCheckpoint(label: "saved") + try await workspace.writeFile("/note.txt", content: "current") + + let reloaded = Workspace( + workspaceId: workspaceId, + filesystem: InMemoryFilesystem(), + storage: .directory(at: root) + ) + let persistedCheckpoint = try #require(try await reloaded.checkpoint(id: checkpoint.id)) + let snapshot = try await reloaded.snapshot(for: persistedCheckpoint) + let restored = InMemoryFilesystem() + + try await Snapshot.restore(snapshot, to: restored) + + #expect(try await workspace.readFile("/note.txt") == "current") + #expect(try await restored.readFile(path: "/note.txt") == Data("checkpoint".utf8)) + #expect(snapshot.summary(comparedTo: nil) == checkpoint.summary) + } + + @Test + func `all public write surfaces append mutation records`() async throws { + let workspace = Workspace(filesystem: InMemoryFilesystem()) + + try await workspace.writeFile("/base.txt", content: "x") + try await workspace.appendFile("/base.txt", content: "y") + try await workspace.writeData(Data([0, 1, 2]), to: "/bin.dat") + try await workspace.writeJSON(["a": 1], to: "/config.json", prettyPrinted: false) + try await workspace.createDirectory(at: "/nested/deep", recursive: true) + try await workspace.writeFile("/nested/deep/a.txt", content: "a") + try await workspace.copyItem(from: "/nested/deep/a.txt", to: "/nested/deep/b.txt") + try await workspace.moveItem(from: "/nested/deep/b.txt", to: "/moved.txt") + try await workspace.removeItem(at: "/nested", recursive: true) + + _ = try await workspace.applyEdits([ + .writeFile(path: "/batch.txt", content: "line\n"), + ]) + try await workspace.writeFile("/replace.txt", content: "hello old world") + _ = try await workspace.applyReplacement( + ReplacementRequest(pattern: "*.txt", search: "old", replacement: "new") + ) + + let beforeSnapshot = try await workspace.captureSnapshot() + try await workspace.writeFile("/temporary.txt", content: "gone") + try await workspace.restoreSnapshot(beforeSnapshot) + + let mutations = try await workspace.mutationRecords() + let kinds = mutations.map(\.kind) + #expect( + kinds == [ + .writeFile, + .appendFile, + .writeData, + .writeJSON, + .createDirectory, + .writeFile, + .copyItem, + .moveItem, + .removeItem, + .applyEdits, + .writeFile, + .applyReplacement, + .writeFile, + .restoreSnapshot, + ] + ) + #expect(try await workspace.readFile("/base.txt") == "xy") + #expect(try await workspace.readFile("/moved.txt") == "a") + #expect(try await workspace.readFile("/replace.txt") == "hello new world") + #expect(!(await workspace.exists("/temporary.txt"))) + } + + @Test + func `snapshot and rollback errors use WorkspaceError`() async throws { + let store = FlakySnapshotCheckpointStore() + let workspace = Workspace(filesystem: InMemoryFilesystem(), store: store) + + try await workspace.writeFile("/note.txt", content: "v1") + let checkpoint = try await workspace.createCheckpoint(label: "has-snapshot") + + await store.breakLoadingSnapshot(id: checkpoint.snapshotId) + + do { + _ = try await workspace.snapshot(for: checkpoint) + Issue.record("expected snapshotNotFound") + } catch let error as WorkspaceError { + guard case let .snapshotNotFound(id) = error else { + Issue.record("unexpected error: \(error)") + return + } + #expect(id == checkpoint.snapshotId) + } + + do { + _ = try await workspace.rollback(to: UUID()) + Issue.record("expected checkpointNotFound") + } catch let error as WorkspaceError { + guard case .checkpointNotFound = error else { + Issue.record("unexpected error: \(error)") + return + } + } + } + + @Test + func `watchCheckpointEvents receives checkpoints from another workspace sharing the store`() async throws { + let store = InMemoryCheckpointStore() + let workspaceId = UUID() + + let observer = Workspace(workspaceId: workspaceId, filesystem: InMemoryFilesystem(), store: store) + let producer = Workspace(workspaceId: workspaceId, filesystem: InMemoryFilesystem(), store: store) + + let recorder = CheckpointEventRecorder() + let stream = try await observer.watchCheckpointEvents() + let task = startCheckpointRecording(stream, into: recorder) + defer { task.cancel() } + + try await producer.writeFile("/remote.txt", content: "remote") + let created = try await producer.createCheckpoint(label: "other-instance") + + let events = try await waitForCheckpointEvents(1, recorder: recorder, timeout: .seconds(2)) + #expect(events.count == 1) + #expect(events[0].kind == .created) + #expect(events[0].checkpoint.id == created.id) + + try await observer.writeFile("/local.txt", content: "local") + let observerCheckpoint = try await observer.createCheckpoint(label: "from-observer") + #expect(observerCheckpoint.parentCheckpointId == created.id) + } + + @Test + func `merge mutation includes per-file text diffs`() async throws { + let workspace = Workspace() + try await workspace.writeFile("/a.txt", content: "a1\n") + try await workspace.writeFile("/b.txt", content: "b1\n") + _ = try await workspace.createCheckpoint(label: "base") + + let branch = try await workspace.branch() + try await branch.writeFile("/a.txt", content: "a2\n") + try await branch.writeFile("/b.txt", content: "b2\n") + + _ = try await workspace.merge(branch, label: "merge") + let mutations = try await workspace.mutationRecords() + let mergeMutation = try #require(mutations.last) + + #expect(mergeMutation.kind == .mergeWorkspace) + #expect(mergeMutation.fileChanges.map(\.path) == ["/a.txt", "/b.txt"]) + #expect(mergeMutation.fileChanges.allSatisfy { $0.diff?.hunks.isEmpty == false }) + #expect(mergeMutation.diff == nil) + } + + private func makeTempDirectory() throws -> URL { + let base = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true) + let url = base.appendingPathComponent("WorkspaceCheckpointTests-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true) + return url + } + + private func removeTempDirectory(_ url: URL) { + try? FileManager.default.removeItem(at: url) + } +} From c92582cfebcaca1bbde3af116ad857925183e8ea Mon Sep 17 00:00:00 2001 From: Zac White Date: Sun, 19 Apr 2026 13:49:58 -0700 Subject: [PATCH 12/14] Add workspace internals coverage --- .../WorkspaceInternalsTests.swift | 267 ++++++++++++++++++ 1 file changed, 267 insertions(+) create mode 100644 Tests/WorkspaceTests/WorkspaceInternalsTests.swift diff --git a/Tests/WorkspaceTests/WorkspaceInternalsTests.swift b/Tests/WorkspaceTests/WorkspaceInternalsTests.swift new file mode 100644 index 0000000..29287b4 --- /dev/null +++ b/Tests/WorkspaceTests/WorkspaceInternalsTests.swift @@ -0,0 +1,267 @@ +import Foundation +import Testing +@testable import Workspace + +private actor CountingCheckpointStore: CheckpointStore { + private let checkpoints: [Checkpoint] + private let snapshots: [UUID: Snapshot] + private let mutations: [MutationRecord] + private var checkpointListCount = 0 + private var mutationListCount = 0 + + init( + checkpoints: [Checkpoint] = [], + snapshots: [UUID: Snapshot] = [:], + mutations: [MutationRecord] = [] + ) { + self.checkpoints = checkpoints + self.snapshots = snapshots + self.mutations = mutations + } + + func saveCheckpoint(_ checkpoint: Checkpoint) async throws {} + + func loadCheckpoint(id: UUID, workspaceId: UUID) async throws -> Checkpoint? { + checkpoints.first { $0.id == id && $0.workspaceId == workspaceId } + } + + func listCheckpoints(workspaceId: UUID) async throws -> [Checkpoint] { + checkpointListCount += 1 + try await Task.sleep(for: .milliseconds(20)) + return checkpoints.filter { $0.workspaceId == workspaceId } + } + + func saveSnapshot(_ snapshot: Snapshot, workspaceId: UUID) async throws {} + + func loadSnapshot(id: UUID, workspaceId: UUID) async throws -> Snapshot? { + snapshots[id] + } + + func appendMutation(_ mutation: MutationRecord) async throws {} + + func listMutationRecords(workspaceId: UUID) async throws -> [MutationRecord] { + mutationListCount += 1 + try await Task.sleep(for: .milliseconds(20)) + return mutations.filter { $0.workspaceId == workspaceId } + } + + func listCounts() -> (checkpoints: Int, mutations: Int) { + (checkpointListCount, mutationListCount) + } +} + +@Suite("Workspace internals") +struct WorkspaceInternalsTests { + @Test + func `ensureLoaded coalesces concurrent store loads`() async throws { + let workspaceId = UUID() + let store = CountingCheckpointStore() + let workspace = Workspace( + workspaceId: workspaceId, + filesystem: InMemoryFilesystem(), + store: store + ) + + try await withThrowingTaskGroup(of: Void.self) { group in + for _ in 0..<8 { + group.addTask { + try await workspace.ensureLoaded() + } + } + try await group.waitForAll() + } + + let counts = await store.listCounts() + #expect(counts.checkpoints == 1) + #expect(counts.mutations == 1) + } + + @Test + func `appendMutation canonicalizes touched paths and file changes`() async throws { + let workspace = Workspace() + try await workspace.ensureLoaded() + + try await workspace.appendMutation( + kind: .applyEdits, + touchedPaths: ["/b.txt", "/a.txt", "/b.txt"], + fileChanges: [ + FileEdit.FileChange(path: "/b.txt", kind: .file, effect: .modified), + FileEdit.FileChange(path: "/a.txt", kind: .file, effect: .created), + FileEdit.FileChange(path: "/a.txt", kind: .file, effect: .deleted), + ], + diff: nil + ) + + let mutation = try #require(try await workspace.mutationRecords().first) + #expect(mutation.sequence == 1) + #expect(mutation.touchedPaths == ["/a.txt", "/b.txt"]) + #expect(mutation.fileChanges.map(\.path) == ["/a.txt", "/a.txt", "/b.txt"]) + #expect(mutation.fileChanges.map(\.effect) == [.created, .deleted, .modified]) + } + + @Test + func `pollCheckpointEvents refreshes mutation cursor before the next local checkpoint`() async throws { + let store = InMemoryCheckpointStore() + let workspaceId = UUID() + let observer = Workspace(workspaceId: workspaceId, filesystem: InMemoryFilesystem(), store: store) + let producer = Workspace(workspaceId: workspaceId, filesystem: InMemoryFilesystem(), store: store) + + let stream = try await observer.watchCheckpointEvents() + let task = Task { + for await _ in stream {} + } + defer { task.cancel() } + + try await producer.writeFile("/remote.txt", content: "remote") + let remote = try await producer.createCheckpoint(label: "remote") + + await observer.pollCheckpointEvents() + try await observer.writeFile("/local.txt", content: "local") + let local = try await observer.createCheckpoint(label: "local") + let localMutation = try #require(try await observer.mutationRecords().last) + + #expect(local.parentCheckpointId == remote.id) + #expect(local.firstMutationSequence == 2) + #expect(local.lastMutationSequence == 2) + #expect(localMutation.sequence == 2) + } + + @Test + func `snapshotDelta captures type changes symlink updates and directory permission changes`() async throws { + let workspace = Workspace() + let original = Snapshot.Entry.directory( + Snapshot.Directory( + path: .root, + permissions: .defaultDirectory, + children: [ + .file(Snapshot.File(path: "/node", data: Data("old\n".utf8), permissions: .defaultFile)), + .symlink(Snapshot.Symlink(path: "/link", target: "/node", permissions: .defaultFile)), + .directory(Snapshot.Directory(path: "/docs", permissions: POSIXPermissions(0o755), children: [])), + ] + ) + ) + let updated = Snapshot.Entry.directory( + Snapshot.Directory( + path: .root, + permissions: .defaultDirectory, + children: [ + .directory( + Snapshot.Directory( + path: "/node", + permissions: .defaultDirectory, + children: [ + .file( + Snapshot.File( + path: "/node/child.txt", + data: Data("new\n".utf8), + permissions: .defaultFile + ) + ), + ] + ) + ), + .symlink(Snapshot.Symlink(path: "/link", target: "/node/child.txt", permissions: .defaultFile)), + .directory(Snapshot.Directory(path: "/docs", permissions: POSIXPermissions(0o700), children: [])), + ] + ) + ) + + let delta = await workspace.snapshotDelta(from: original, to: updated) + let changesByPath = Dictionary(uniqueKeysWithValues: delta.fileChanges.map { ($0.path, $0) }) + + #expect(delta.hasChanges) + #expect(delta.touchedPaths == ["/docs", "/link", "/node", "/node/child.txt"]) + #expect(delta.fileChanges.map(\.path) == ["/link", "/node", "/node/child.txt"]) + #expect(changesByPath["/link"]?.kind == .symlink) + #expect(changesByPath["/link"]?.effect == .modified) + #expect(changesByPath["/link"]?.diff == nil) + #expect(changesByPath["/node"]?.effect == .deleted) + #expect(changesByPath["/node"]?.diff?.hunks.isEmpty == false) + #expect(changesByPath["/node/child.txt"]?.effect == .created) + #expect(changesByPath["/node/child.txt"]?.diff?.hunks.isEmpty == false) + } + + @Test + func `snapshotDelta captures created and deleted symlinks without text diffs`() async throws { + let workspace = Workspace() + let original = Snapshot.Entry.directory( + Snapshot.Directory( + path: .root, + permissions: .defaultDirectory, + children: [ + .file(Snapshot.File(path: "/target.txt", data: Data("target\n".utf8), permissions: .defaultFile)), + .symlink(Snapshot.Symlink(path: "/deleted-link", target: "/target.txt", permissions: .defaultFile)), + ] + ) + ) + let updated = Snapshot.Entry.directory( + Snapshot.Directory( + path: .root, + permissions: .defaultDirectory, + children: [ + .file(Snapshot.File(path: "/target.txt", data: Data("target\n".utf8), permissions: .defaultFile)), + .symlink(Snapshot.Symlink(path: "/created-link", target: "/target.txt", permissions: .defaultFile)), + ] + ) + ) + + let delta = await workspace.snapshotDelta(from: original, to: updated) + let changesByPath = Dictionary(uniqueKeysWithValues: delta.fileChanges.map { ($0.path, $0) }) + + #expect(delta.touchedPaths == ["/created-link", "/deleted-link"]) + #expect(delta.fileChanges.map(\.path) == ["/created-link", "/deleted-link"]) + #expect(changesByPath["/created-link"]?.kind == .symlink) + #expect(changesByPath["/created-link"]?.effect == .created) + #expect(changesByPath["/created-link"]?.diff == nil) + #expect(changesByPath["/deleted-link"]?.kind == .symlink) + #expect(changesByPath["/deleted-link"]?.effect == .deleted) + #expect(changesByPath["/deleted-link"]?.diff == nil) + } + + @Test + func `performBinaryWrite through an existing symlink records symlink metadata and updates target`() async throws { + let filesystem = InMemoryFilesystem() + try await filesystem.writeFile(path: "/target.bin", data: Data([1, 2, 3]), append: false) + try await filesystem.createSymlink(path: "/alias.bin", target: "/target.bin") + let workspace = Workspace(filesystem: filesystem) + + try await workspace.writeData(Data([4, 5, 6]), to: "/alias.bin") + try await workspace.writeData(Data([4, 5, 6]), to: "/alias.bin") + + let mutations = try await workspace.mutationRecords() + let first = try #require(mutations.first) + let second = try #require(mutations.last) + + #expect(try await filesystem.readFile(path: "/target.bin") == Data([4, 5, 6])) + #expect(try await filesystem.readSymlink(path: "/alias.bin") == "/target.bin") + #expect(first.kind == .writeData) + #expect(first.touchedPaths == ["/alias.bin"]) + #expect(first.fileChanges.count == 1) + #expect(first.fileChanges[0].path == "/alias.bin") + #expect(first.fileChanges[0].kind == .symlink) + #expect(first.fileChanges[0].effect == .modified) + #expect(first.fileChanges[0].status == .applied) + #expect(first.fileChanges[0].diff == nil) + #expect(second.sequence == 2) + #expect(second.fileChanges[0].kind == .symlink) + #expect(second.fileChanges[0].effect == .unchanged) + } + + @Test + func `untrackedRestore restores root snapshots without appending a mutation`() async throws { + let workspace = Workspace() + + try await workspace.writeFile("/kept.txt", content: "one") + let snapshot = try await workspace.captureSnapshot() + try await workspace.writeFile("/extra.txt", content: "two") + let before = try await workspace.mutationRecords().count + + try await workspace.untrackedRestore(snapshot) + let after = try await workspace.mutationRecords().count + + #expect(before == 2) + #expect(after == before) + #expect(try await workspace.readFile("/kept.txt") == "one") + #expect(!(await workspace.exists("/extra.txt"))) + } +} From ba52f3d52de160bbc988258417106493477efbb5 Mon Sep 17 00:00:00 2001 From: Zac White Date: Wed, 22 Apr 2026 22:24:20 -0700 Subject: [PATCH 13/14] Implemented several fixes --- README.md | 4 +- Sources/Workspace/CheckpointStore.swift | 165 +++++++++++++++--- Sources/Workspace/Workspace+Branching.swift | 14 +- Sources/Workspace/Workspace+Checkpoints.swift | 2 + Sources/Workspace/Workspace+Internals.swift | 59 +++++-- Sources/Workspace/Workspace.swift | 9 +- .../WorkspaceTests/CheckpointStoreTests.swift | 53 ++++-- .../WorkspaceCheckpointTests.swift | 2 +- .../WorkspaceInternalsTests.swift | 4 +- 9 files changed, 249 insertions(+), 63 deletions(-) diff --git a/README.md b/README.md index 6fc330d..6161d26 100644 --- a/README.md +++ b/README.md @@ -87,7 +87,7 @@ let workspace = Workspace( ) ``` -`Storage.directory(at:)` writes checkpoint, snapshot, and mutation JSON under the supplied URL. Mutation log appends are serialized through a `mutations.lock` sidecar using `flock` where the OS supports it; coordinating multiple hosts or network disks that do not honor `flock` may still require application-level synchronization. +`Storage.directory(at:)` writes checkpoint and snapshot JSON plus a `mutations.jsonl` append log (one JSON record per line) under `//`. A legacy `mutations.json` array is migrated to JSONL on first access. The store assigns monotonic `sequence` numbers while holding `mutations.lock` (advisory `flock` where the OS supports it), so multiple `Workspace` instances that share a `workspaceId` and store do not collide on mutation sequence. The current checkpoint head is derived from the parent-id graph (unparented tips), not only from `createdAt` ordering, which reduces surprises when wall clocks differ between processes. Listing or loading mutations still reads the full log; very long histories may need application-level rotation. Coordinating multiple hosts or network disks that do not honor `flock` may still require extra synchronization. ### Reads @@ -201,7 +201,7 @@ Public `restoreSnapshot(_:)` is also tracked as a workspace mutation. Checkpoint ### Branch And Merge -Branches are isolated `Workspace` actors cloned from the parent's current snapshot. They share the checkpoint store but not the filesystem, watchers, or mutation sequence. +Branches are isolated `Workspace` actors cloned from the parent's current snapshot. They share the checkpoint store but not the filesystem, watchers, or mutation sequence. By default `branch()` materializes the snapshot into a new `InMemoryFilesystem`; pass `filesystem:` to use another implementation (for example a `ReadWriteFilesystem` if the branch should live on disk). ```swift try await workspace.writeFile("/readme.txt", content: "base") diff --git a/Sources/Workspace/CheckpointStore.swift b/Sources/Workspace/CheckpointStore.swift index b076cfd..a7cbb1a 100644 --- a/Sources/Workspace/CheckpointStore.swift +++ b/Sources/Workspace/CheckpointStore.swift @@ -7,13 +7,18 @@ import Glibc #endif /// Persistence for workspace checkpoints, snapshots, and mutation logs. +/// +/// The store is the **source of truth** for `MutationRecord.sequence` values. Callers may pass +/// any placeholder sequence; ``appendMutation(_:)`` returns the record with the next persisted +/// monotonic number for that workspace, serialized under the mutations lock. protocol CheckpointStore: AnyObject, Sendable { func saveCheckpoint(_ checkpoint: Checkpoint) async throws func loadCheckpoint(id: UUID, workspaceId: UUID) async throws -> Checkpoint? func listCheckpoints(workspaceId: UUID) async throws -> [Checkpoint] func saveSnapshot(_ snapshot: Snapshot, workspaceId: UUID) async throws func loadSnapshot(id: UUID, workspaceId: UUID) async throws -> Snapshot? - func appendMutation(_ mutation: MutationRecord) async throws + @discardableResult + func appendMutation(_ mutation: MutationRecord) async throws -> MutationRecord func listMutationRecords(workspaceId: UUID) async throws -> [MutationRecord] } @@ -56,8 +61,15 @@ actor InMemoryCheckpointStore: CheckpointStore { snapshotsByWorkspace[workspaceId]?[id] } - func appendMutation(_ mutation: MutationRecord) async throws { - mutationsByWorkspace[mutation.workspaceId, default: []].append(mutation) + @discardableResult + func appendMutation(_ mutation: MutationRecord) async throws -> MutationRecord { + var list = mutationsByWorkspace[mutation.workspaceId, default: []] + let next = (list.map(\.sequence).max() ?? 0) + 1 + var record = mutation + record.sequence = next + list.append(record) + mutationsByWorkspace[mutation.workspaceId] = list + return record } func listMutationRecords(workspaceId: UUID) async throws -> [MutationRecord] { @@ -67,17 +79,22 @@ actor InMemoryCheckpointStore: CheckpointStore { /// A JSON file-backed checkpoint store. /// -/// Mutation log writes (`mutations.json`) are serialized through a persistent sidecar lockfile -/// (`mutations.lock`) using an advisory exclusive lock when supported by the platform (`flock`), -/// so concurrent ``FileCheckpointStore`` instances within the same process and across cooperating -/// processes do not lose appends. Checkpoint and snapshot writes are per-artifact atomic replaces. -/// Coordinating writers on network filesystems that do not honor `flock` may still require -/// application-level serialization. +/// Mutations are stored as one JSON line per record in `mutations.jsonl` (append-friendly under +/// the lock) so each append rewrites a tiny tail instead of the entire log. A legacy +/// `mutations.json` array is migrated to JSONL on the next read or append. Writes are +/// synchronized through a persistent sidecar lockfile (`mutations.lock`) with an advisory +/// exclusive lock when the platform supports it (`flock`), so concurrent ``FileCheckpointStore`` +/// instances in the same process and across cooperating processes do not lose appends. Checkpoint +/// and snapshot writes are per-artifact atomic replaces. Coordinating writers on network +/// filesystems that do not honor `flock` may still require application-level serialization. actor FileCheckpointStore: CheckpointStore { private let rootDirectory: URL private let fileManager: FileManager private let encoder: JSONEncoder private let decoder: JSONDecoder + private let compactEncoder: JSONEncoder + + private var listCheckpointsCache: [UUID: (cacheKey: String, checkpoints: [Checkpoint])] = [:] init(rootDirectory: URL, fileManager: FileManager = .default) { self.rootDirectory = rootDirectory.standardizedFileURL @@ -87,9 +104,11 @@ actor FileCheckpointStore: CheckpointStore { encoder.outputFormatting = [.prettyPrinted, .sortedKeys] self.encoder = encoder self.decoder = JSONDecoder() + self.compactEncoder = JSONEncoder() } func saveCheckpoint(_ checkpoint: Checkpoint) async throws { + listCheckpointsCache[checkpoint.workspaceId] = nil try ensureWorkspaceDirectories(for: checkpoint.workspaceId) try write(checkpoint, to: checkpointURL(id: checkpoint.id, workspaceId: checkpoint.workspaceId)) } @@ -105,10 +124,15 @@ actor FileCheckpointStore: CheckpointStore { func listCheckpoints(workspaceId: UUID) async throws -> [Checkpoint] { let directoryURL = checkpointsDirectoryURL(workspaceId: workspaceId) guard fileManager.fileExists(atPath: directoryURL.path) else { + listCheckpointsCache[workspaceId] = nil return [] } + if let key = try checkpointsDirectoryCacheKey(at: directoryURL), + let entry = listCheckpointsCache[workspaceId], entry.cacheKey == key { + return entry.checkpoints + } - return try fileManager + let result = try fileManager .contentsOfDirectory( at: directoryURL, includingPropertiesForKeys: nil, @@ -122,6 +146,10 @@ actor FileCheckpointStore: CheckpointStore { } return $0.createdAt < $1.createdAt } + if let key = try checkpointsDirectoryCacheKey(at: directoryURL) { + listCheckpointsCache[workspaceId] = (key, result) + } + return result } func saveSnapshot(_ snapshot: Snapshot, workspaceId: UUID) async throws { @@ -137,38 +165,112 @@ actor FileCheckpointStore: CheckpointStore { return try read(Snapshot.self, from: url) } - func appendMutation(_ mutation: MutationRecord) async throws { + @discardableResult + func appendMutation(_ mutation: MutationRecord) async throws -> MutationRecord { try ensureWorkspaceDirectories(for: mutation.workspaceId) - let url = mutationsURL(workspaceId: mutation.workspaceId) + let jsonl = mutationsJsonlURL(workspaceId: mutation.workspaceId) + let legacy = legacyMutationsArrayURL(workspaceId: mutation.workspaceId) let lockURL = mutationsLockURL(workspaceId: mutation.workspaceId) - try Self.withMutationsExclusiveLock(at: lockURL) { - var records = try loadMutations(from: url) - records.append(mutation) - try write(records.sorted(by: { $0.sequence < $1.sequence }), to: url) + return try Self.withMutationsExclusiveLock(at: lockURL) { + let existing = try loadAllMutations(jsonl: jsonl, legacy: legacy) + let next = (existing.map(\.sequence).max() ?? 0) + 1 + var record = mutation + record.sequence = next + + if fileManager.fileExists(atPath: jsonl.path) { + try appendJSONLLine(encode: record, to: jsonl) + } else { + try writeAllMutationsAsJSONL([record], to: jsonl) + } + return record } } func listMutationRecords(workspaceId: UUID) async throws -> [MutationRecord] { - let url = mutationsURL(workspaceId: workspaceId) - guard fileManager.fileExists(atPath: url.path) else { + let jsonl = mutationsJsonlURL(workspaceId: workspaceId) + let legacy = legacyMutationsArrayURL(workspaceId: workspaceId) + guard fileManager.fileExists(atPath: jsonl.path) || fileManager.fileExists(atPath: legacy.path) else { return [] } try ensureWorkspaceDirectories(for: workspaceId) let lockURL = mutationsLockURL(workspaceId: workspaceId) return try Self.withMutationsExclusiveLock(at: lockURL) { - try loadMutations(from: url).sorted(by: { $0.sequence < $1.sequence }) + try loadAllMutations(jsonl: jsonl, legacy: legacy) + .sorted { $0.sequence < $1.sequence } } } - private func loadMutations(from url: URL) throws -> [MutationRecord] { - guard fileManager.fileExists(atPath: url.path) else { - return [] + private func loadAllMutations(jsonl: URL, legacy: URL) throws -> [MutationRecord] { + if fileManager.fileExists(atPath: jsonl.path) { + if fileManager.fileExists(atPath: legacy.path) { + try? fileManager.removeItem(at: legacy) + } + return try loadMutationsFromJSONL(at: jsonl) } + if fileManager.fileExists(atPath: legacy.path) { + let data = try Data(contentsOf: legacy) + if data.isEmpty { return [] } + let records = try decoder.decode([MutationRecord].self, from: data) + if !records.isEmpty { + try fileManager.removeItem(at: legacy) + try writeAllMutationsAsJSONL(records, to: jsonl) + } + return records + } + return [] + } + + private func loadMutationsFromJSONL(at url: URL) throws -> [MutationRecord] { let data = try Data(contentsOf: url) - if data.isEmpty { - return [] + if data.isEmpty { return [] } + let text = String(data: data, encoding: .utf8) ?? "" + var out: [MutationRecord] = [] + out.reserveCapacity(64) + for line in text.split(whereSeparator: \.isNewline) { + if line.isEmpty { continue } + out.append(try decoder.decode(MutationRecord.self, from: Data(String(line).utf8))) + } + return out + } + + private func writeAllMutationsAsJSONL(_ records: [MutationRecord], to url: URL) throws { + if records.isEmpty { + if fileManager.fileExists(atPath: url.path) { + try fileManager.removeItem(at: url) + } + return + } + var data = Data() + for record in records.sorted(by: { $0.sequence < $1.sequence }) { + var line = try compactEncoder.encode(record) + line.append(Data("\n".utf8)) + data.append(line) + } + try data.write(to: url, options: .atomic) + } + + private func appendJSONLLine(encode record: MutationRecord, to url: URL) throws { + var line = try compactEncoder.encode(record) + line.append(Data("\n".utf8)) + if fileManager.fileExists(atPath: url.path) { + let h = try FileHandle(forWritingTo: url) + defer { try? h.close() } + try h.seekToEnd() + try h.write(contentsOf: line) + } else { + try line.write(to: url, options: .atomic) } - return try decoder.decode([MutationRecord].self, from: data) + } + + private func checkpointsDirectoryCacheKey(at url: URL) throws -> String? { + let files = try fileManager.contentsOfDirectory( + at: url, + includingPropertiesForKeys: nil, + options: [.skipsHiddenFiles] + ) + let count = files.filter { $0.pathExtension == "json" }.count + let mtime = try url.resourceValues(forKeys: [.contentModificationDateKey]).contentModificationDate + return "\(mtime?.timeIntervalSince1970 ?? 0)-\(count)" } private func ensureWorkspaceDirectories(for workspaceId: UUID) throws { @@ -197,15 +299,20 @@ actor FileCheckpointStore: CheckpointStore { snapshotsDirectoryURL(workspaceId: workspaceId).appendingPathComponent("\(id.uuidString).json", isDirectory: false) } - private func mutationsURL(workspaceId: UUID) -> URL { + private func mutationsJsonlURL(workspaceId: UUID) -> URL { + workspaceDirectoryURL(workspaceId: workspaceId).appendingPathComponent("mutations.jsonl", isDirectory: false) + } + + private func legacyMutationsArrayURL(workspaceId: UUID) -> URL { workspaceDirectoryURL(workspaceId: workspaceId).appendingPathComponent("mutations.json", isDirectory: false) } /// A persistent sidecar lockfile used by ``withMutationsExclusiveLock(at:_:)``. /// - /// `mutations.json` itself is atomically replaced on every append, which would invalidate any - /// `flock` taken on its file descriptor (the on-disk inode changes at each rename). We therefore - /// take the advisory lock on this stable sidecar that nobody renames or unlinks. + /// The mutations log is append-only; we still take the lock on a **stable** sidecar so the lock + /// is not taken on a file that is unlinked/renamed by atomic write helpers in other + /// subsystems. Mutations are written to `mutations.jsonl` (or created there after migrating + /// from a legacy `mutations.json` array on first access). private func mutationsLockURL(workspaceId: UUID) -> URL { workspaceDirectoryURL(workspaceId: workspaceId).appendingPathComponent("mutations.lock", isDirectory: false) } diff --git a/Sources/Workspace/Workspace+Branching.swift b/Sources/Workspace/Workspace+Branching.swift index 38d534a..1be6729 100644 --- a/Sources/Workspace/Workspace+Branching.swift +++ b/Sources/Workspace/Workspace+Branching.swift @@ -5,12 +5,20 @@ extension Workspace { /// /// Branches share only the checkpoint store with their parent. Filesystem state, watchers, and mutation /// sequences are isolated to the returned workspace. - public func branch(label: String? = nil) async throws -> Workspace { + /// - Parameters: + /// - label: Optional label for the branch's seed checkpoint. + /// - filesystem: Filesystem the branch will use; defaults to a new `InMemoryFilesystem` when + /// you omit this value. + public func branch( + label: String? = nil, + filesystem: (any FileSystem)? = nil + ) async throws -> Workspace { try await ensureLoaded() + try await reconcileCheckpointsWithStore() let baseCheckpointId = headCheckpointId let snapshot = try await captureSnapshot() - let branchFilesystem = InMemoryFilesystem() + let branchFilesystem = filesystem ?? InMemoryFilesystem() try await Snapshot.restore(snapshot, to: branchFilesystem) let branch = Workspace( @@ -30,6 +38,8 @@ extension Workspace { /// Merges another workspace into this workspace when this workspace still points at the other's base. public func merge(_ other: Workspace, label: String? = nil) async throws -> Checkpoint { try await ensureLoaded() + try await reconcileCheckpointsWithStore() + try await other.reconcileCheckpointsWithStore() let expectedBase = await other.mergeBaseCheckpointId() guard headCheckpointId == expectedBase else { diff --git a/Sources/Workspace/Workspace+Checkpoints.swift b/Sources/Workspace/Workspace+Checkpoints.swift index a46d53f..41b01f9 100644 --- a/Sources/Workspace/Workspace+Checkpoints.swift +++ b/Sources/Workspace/Workspace+Checkpoints.swift @@ -4,6 +4,7 @@ extension Workspace { /// Creates a checkpoint for the workspace's current filesystem state. public func createCheckpoint(label: String? = nil) async throws -> Checkpoint { try await ensureLoaded() + try await reconcileCheckpointsWithStore() let snapshot = try await captureSnapshot() return try await persistCheckpoint( snapshot: snapshot, @@ -18,6 +19,7 @@ extension Workspace { /// Restores the workspace to a prior checkpoint and records the rollback as a new checkpoint. public func rollback(to checkpointId: UUID, label: String? = nil) async throws -> Checkpoint { try await ensureLoaded() + try await reconcileCheckpointsWithStore() let checkpoint = try checkpointOrThrow(id: checkpointId) let targetSnapshot = try await loadSnapshotOrThrow(id: checkpoint.snapshotId) try await untrackedRestore(targetSnapshot) diff --git a/Sources/Workspace/Workspace+Internals.swift b/Sources/Workspace/Workspace+Internals.swift index 1b2e274..e3b1ed4 100644 --- a/Sources/Workspace/Workspace+Internals.swift +++ b/Sources/Workspace/Workspace+Internals.swift @@ -34,11 +34,46 @@ extension Workspace { func loadStoreState() async throws { checkpoints = try await store.listCheckpoints(workspaceId: workspaceId) mutations = try await store.listMutationRecords(workspaceId: workspaceId) - nextMutationSequence = (mutations.last?.sequence ?? 0) + 1 - headCheckpointId = checkpoints.sorted(by: checkpointSort).last?.id + nextMutationSequence = (mutations.map(\.sequence).max() ?? 0) + 1 + headCheckpointId = Self.lineageHeadId(in: checkpoints) didLoadStoreState = true } + /// Merges in-memory checkpoint state with the shared store, then recomputes the head from the + /// parent chain (leaves) instead of wall-clock order alone, so a remote checkpoint does not + /// silently reorder lineage when `createdAt` is skewed. + func reconcileCheckpointsWithStore() async throws { + try await ensureLoaded() + let fromStore = try await store.listCheckpoints(workspaceId: workspaceId) + checkpoints = fromStore.sorted(by: checkpointSort) + headCheckpointId = Self.lineageHeadId(in: checkpoints) + } + + /// Picks the current checkpoint "tip": checkpoints whose ids never appear as + /// ``Checkpoint/parentCheckpointId`` (DAG leaves). If the graph is forked, the leaf with the + /// latest `(createdAt, id)` is used. + static func lineageHeadId(in checkpoints: [Checkpoint]) -> UUID? { + guard !checkpoints.isEmpty else { + return nil + } + let parentReferences = Set(checkpoints.compactMap(\.parentCheckpointId)) + let tips = checkpoints.filter { !parentReferences.contains($0.id) } + if tips.isEmpty { + return checkpoints.max(by: orderCheckpoints)?.id + } + if tips.count == 1 { + return tips[0].id + } + return tips.max(by: orderCheckpoints)?.id + } + + private static func orderCheckpoints(_ lhs: Checkpoint, _ rhs: Checkpoint) -> Bool { + if lhs.createdAt == rhs.createdAt { + return lhs.id.uuidString < rhs.id.uuidString + } + return lhs.createdAt < rhs.createdAt + } + func checkpointOrThrow(id: UUID) throws -> Checkpoint { guard let checkpoint = checkpoints.first(where: { $0.id == id }) else { throw WorkspaceError.checkpointNotFound(id) @@ -168,19 +203,18 @@ extension Workspace { fileChanges: [FileEdit.FileChange], diff: TextDiff? ) async throws { - let mutation = MutationRecord( - sequence: nextMutationSequence, + let provisional = MutationRecord( + sequence: 0, workspaceId: workspaceId, kind: kind, touchedPaths: Array(Set(touchedPaths)).sorted(), fileChanges: fileChanges.sorted(by: fileChangeSort), diff: diff ) - - nextMutationSequence += 1 - mutations.append(mutation) + let persisted = try await store.appendMutation(provisional) + mutations.append(persisted) mutations.sort { $0.sequence < $1.sequence } - try await store.appendMutation(mutation) + nextMutationSequence = (mutations.map(\.sequence).max() ?? 0) + 1 } func latestMutationSequence() -> Int { @@ -253,11 +287,11 @@ extension Workspace { if !newCheckpoints.isEmpty { checkpoints.append(contentsOf: newCheckpoints) checkpoints.sort(by: checkpointSort) - headCheckpointId = checkpoints.last?.id + headCheckpointId = Self.lineageHeadId(in: checkpoints) if let refreshedMutations = try? await store.listMutationRecords(workspaceId: workspaceId) { mutations = refreshedMutations.sorted { $0.sequence < $1.sequence } - nextMutationSequence = (mutations.last?.sequence ?? 0) + 1 + nextMutationSequence = (mutations.map(\.sequence).max() ?? 0) + 1 } } @@ -434,10 +468,7 @@ extension Workspace { } func checkpointSort(lhs: Checkpoint, rhs: Checkpoint) -> Bool { - if lhs.createdAt == rhs.createdAt { - return lhs.id.uuidString < rhs.id.uuidString - } - return lhs.createdAt < rhs.createdAt + Self.orderCheckpoints(lhs, rhs) } func fileChangeSort(lhs: FileEdit.FileChange, rhs: FileEdit.FileChange) -> Bool { diff --git a/Sources/Workspace/Workspace.swift b/Sources/Workspace/Workspace.swift index 286055b..112f686 100644 --- a/Sources/Workspace/Workspace.swift +++ b/Sources/Workspace/Workspace.swift @@ -8,9 +8,12 @@ public actor Workspace { case inMemory /// File-backed JSON storage rooted at `url`. /// - /// Mutation log appends are serialized through a `mutations.lock` sidecar using an advisory lock - /// where the OS supports it. Multiple processes should still treat the directory as a single writer - /// domain when the underlying filesystem does not honor advisory locks. + /// Checkpoints and snapshots are individual JSON files; mutations are recorded in + /// `mutations.jsonl` (append-friendly, one record per line). A legacy `mutations.json` array is + /// migrated automatically. The store assigns monotonic `MutationRecord.sequence` values under + /// `mutations.lock` using an advisory lock where the OS supports it, so writers sharing a + /// `workspaceId` do not reuse sequence numbers. Multiple processes should still treat the + /// directory as a single-writer domain when the underlying filesystem does not honor advisory locks. case directory(at: URL) } diff --git a/Tests/WorkspaceTests/CheckpointStoreTests.swift b/Tests/WorkspaceTests/CheckpointStoreTests.swift index d88c1a0..00b03bd 100644 --- a/Tests/WorkspaceTests/CheckpointStoreTests.swift +++ b/Tests/WorkspaceTests/CheckpointStoreTests.swift @@ -79,7 +79,7 @@ struct CheckpointStoreTests { try await store.appendMutation(m1) let mutations = try await store.listMutationRecords(workspaceId: workspaceA) - #expect(mutations.map(\.sequence) == [1, 3]) + #expect(mutations.map(\.sequence) == [1, 2]) let reloadedSnapshot = try await store.loadSnapshot(id: snapshotId, workspaceId: workspaceA) #expect(reloadedSnapshot == snapshot) @@ -232,7 +232,7 @@ struct CheckpointStoreTests { let storeB = FileCheckpointStore(rootDirectory: root) let merged = try await storeB.listMutationRecords(workspaceId: workspaceId) #expect(merged.map(\.sequence) == [1, 2]) - #expect(merged.map(\.touchedPaths) == [["/a"], ["/b"]]) + #expect(merged.map(\.touchedPaths) == [["/b"], ["/a"]]) let m3 = MutationRecord( sequence: 3, @@ -296,10 +296,41 @@ struct CheckpointStoreTests { #expect(mutations.isEmpty) let workspaceRoot = root.appendingPathComponent(workspaceId.uuidString, isDirectory: true) - let mutationsFile = workspaceRoot.appendingPathComponent("mutations.json") + let mutationsFile = workspaceRoot.appendingPathComponent("mutations.jsonl") #expect(!FileManager.default.fileExists(atPath: mutationsFile.path)) } + @Test + func `FileCheckpointStore migrates legacy mutations json array to jsonl`() async throws { + let root = try makeTempDirectory() + defer { removeTempDirectory(root) } + + let workspaceId = UUID() + let workspaceRoot = root.appendingPathComponent(workspaceId.uuidString, isDirectory: true) + try FileManager.default.createDirectory(at: workspaceRoot, withIntermediateDirectories: true) + let legacyURL = workspaceRoot.appendingPathComponent("mutations.json", isDirectory: false) + let jsonlURL = workspaceRoot.appendingPathComponent("mutations.jsonl", isDirectory: false) + + let stored = MutationRecord( + sequence: 99, + workspaceId: workspaceId, + kind: .writeFile, + touchedPaths: ["/legacy.txt"], + fileChanges: [] + ) + let data = try JSONEncoder().encode([stored]) + try data.write(to: legacyURL) + + let store = FileCheckpointStore(rootDirectory: root) + let loaded = try await store.listMutationRecords(workspaceId: workspaceId) + #expect(loaded.count == 1) + #expect(loaded[0].touchedPaths == ["/legacy.txt"]) + #expect(loaded[0].sequence == 99) + + #expect(!FileManager.default.fileExists(atPath: legacyURL.path)) + #expect(FileManager.default.fileExists(atPath: jsonlURL.path)) + } + @Test func `FileCheckpointStore listMutationRecords treats an empty mutations file as no records`() async throws { let root = try makeTempDirectory() @@ -308,7 +339,7 @@ struct CheckpointStoreTests { let workspaceId = UUID() let workspaceRoot = root.appendingPathComponent(workspaceId.uuidString, isDirectory: true) try FileManager.default.createDirectory(at: workspaceRoot, withIntermediateDirectories: true) - let mutationsFile = workspaceRoot.appendingPathComponent("mutations.json") + let mutationsFile = workspaceRoot.appendingPathComponent("mutations.jsonl") try Data().write(to: mutationsFile) let store = FileCheckpointStore(rootDirectory: root) @@ -317,16 +348,17 @@ struct CheckpointStoreTests { #expect(mutations.isEmpty) let appended = MutationRecord( - sequence: 1, + sequence: 0, workspaceId: workspaceId, kind: .writeFile, touchedPaths: ["/x"], fileChanges: [] ) - try await store.appendMutation(appended) + let written = try await store.appendMutation(appended) let after = try await store.listMutationRecords(workspaceId: workspaceId) - #expect(after == [appended]) + #expect(after == [written]) + #expect(written.sequence == 1) } @Test @@ -344,10 +376,9 @@ struct CheckpointStoreTests { let store = stores[writerIndex] group.addTask { for stepIndex in 0.. URL { diff --git a/Tests/WorkspaceTests/WorkspaceCheckpointTests.swift b/Tests/WorkspaceTests/WorkspaceCheckpointTests.swift index 9eab27a..e859d53 100644 --- a/Tests/WorkspaceTests/WorkspaceCheckpointTests.swift +++ b/Tests/WorkspaceTests/WorkspaceCheckpointTests.swift @@ -75,7 +75,7 @@ private actor FlakySnapshotCheckpointStore: CheckpointStore { return try await base.loadSnapshot(id: id, workspaceId: workspaceId) } - func appendMutation(_ mutation: MutationRecord) async throws { + func appendMutation(_ mutation: MutationRecord) async throws -> MutationRecord { try await base.appendMutation(mutation) } diff --git a/Tests/WorkspaceTests/WorkspaceInternalsTests.swift b/Tests/WorkspaceTests/WorkspaceInternalsTests.swift index 29287b4..0b5cd44 100644 --- a/Tests/WorkspaceTests/WorkspaceInternalsTests.swift +++ b/Tests/WorkspaceTests/WorkspaceInternalsTests.swift @@ -37,7 +37,9 @@ private actor CountingCheckpointStore: CheckpointStore { snapshots[id] } - func appendMutation(_ mutation: MutationRecord) async throws {} + func appendMutation(_ mutation: MutationRecord) async throws -> MutationRecord { + mutation + } func listMutationRecords(workspaceId: UUID) async throws -> [MutationRecord] { mutationListCount += 1 From cc7532a4f07c0d15af82252afcc6da3895449767 Mon Sep 17 00:00:00 2001 From: Zac White Date: Wed, 22 Apr 2026 22:24:29 -0700 Subject: [PATCH 14/14] Fixed some minor issues --- README.md | 1 + Sources/Workspace/CheckpointStore.swift | 1 - Sources/Workspace/Workspace+Checkpoints.swift | 8 ++++-- Sources/Workspace/Workspace+Internals.swift | 27 ++++++++++--------- Sources/Workspace/Workspace.swift | 8 +++++- .../WorkspaceInternalsTests.swift | 15 +++++++++++ 6 files changed, 44 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index 6161d26..40319b7 100644 --- a/README.md +++ b/README.md @@ -283,6 +283,7 @@ let workspace = Workspace(filesystem: filesystem) - Reads do not load checkpoint state. Writes, checkpoint calls, rollback, branch, and merge do. - All checkpoint reads share the workspace actor barrier with file I/O, so they serialize behind in-flight writes. +- `writeJSON` ends the file with a single trailing newline; `readJSON` decodes the value as usual. Checkpoint event polling (when using `watchCheckpointEvents`) uses `Workspace.checkpointEventPollInterval` (500 ms by default; shorten in tests to reduce wait time). - `.inMemory` storage still records one mutation per write in memory, including old-content capture for text diffs. - Branches created with `.directory(at:)` share the same storage directory as the parent but are partitioned by `workspaceId`. - `walkTree` and `summarizeTree` return stable path ordering, which is useful for deterministic tool output. diff --git a/Sources/Workspace/CheckpointStore.swift b/Sources/Workspace/CheckpointStore.swift index a7cbb1a..4ab9609 100644 --- a/Sources/Workspace/CheckpointStore.swift +++ b/Sources/Workspace/CheckpointStore.swift @@ -192,7 +192,6 @@ actor FileCheckpointStore: CheckpointStore { guard fileManager.fileExists(atPath: jsonl.path) || fileManager.fileExists(atPath: legacy.path) else { return [] } - try ensureWorkspaceDirectories(for: workspaceId) let lockURL = mutationsLockURL(workspaceId: workspaceId) return try Self.withMutationsExclusiveLock(at: lockURL) { try loadAllMutations(jsonl: jsonl, legacy: legacy) diff --git a/Sources/Workspace/Workspace+Checkpoints.swift b/Sources/Workspace/Workspace+Checkpoints.swift index 41b01f9..def4594 100644 --- a/Sources/Workspace/Workspace+Checkpoints.swift +++ b/Sources/Workspace/Workspace+Checkpoints.swift @@ -21,7 +21,10 @@ extension Workspace { try await ensureLoaded() try await reconcileCheckpointsWithStore() let checkpoint = try checkpointOrThrow(id: checkpointId) - let targetSnapshot = try await loadSnapshotOrThrow(id: checkpoint.snapshotId) + let targetSnapshot = try await loadSnapshotOrThrow( + id: checkpoint.snapshotId, + workspaceId: checkpoint.workspaceId + ) try await untrackedRestore(targetSnapshot) let restoredSnapshot = try await captureSnapshot() return try await persistCheckpoint( @@ -53,7 +56,8 @@ extension Workspace { } /// Watches for checkpoint events, including checkpoints created by other workspace instances - /// with the same `workspaceId` and shared store. + /// with the same `workspaceId` and shared store. Polling uses ``Workspace/checkpointEventPollInterval`` + /// (500 ms by default) while the stream is active; lower it in tests to reduce wait time. public func watchCheckpointEvents() async throws -> AsyncStream { try await ensureLoaded() diff --git a/Sources/Workspace/Workspace+Internals.swift b/Sources/Workspace/Workspace+Internals.swift index e3b1ed4..bb99eb6 100644 --- a/Sources/Workspace/Workspace+Internals.swift +++ b/Sources/Workspace/Workspace+Internals.swift @@ -108,19 +108,16 @@ extension Workspace { } func performBinaryWrite(data: Data, path: WorkspacePath) async throws { - try await ensureLoaded() - let effect: FileEdit.Effect let kind: FileTree.Kind if await exists(path) { let info = try await fileInfo(at: path) - kind = info.kind if info.kind == .directory { - effect = .unchanged - } else { - effect = try await readData(from: path) == data ? .unchanged : .modified + throw WorkspaceError.unsupported("cannot write data to a directory: \(path)") } + kind = info.kind + effect = try await readData(from: path) == data ? .unchanged : .modified } else { kind = .file effect = .created @@ -226,6 +223,10 @@ extension Workspace { return mutations } + /// Encodes `value` to bytes and a UTF-8 `String` with a **single trailing newline** for + /// line-oriented / POSIX-friendly file endings. Decoding via ``Workspace/readJSON(from:)`` + /// still works because the JSON comes first; keep this in mind if you read raw bytes with a + /// different decoder. func encodedJSONString(for value: T, prettyPrinted: Bool) throws -> String { let encoder = JSONEncoder() if prettyPrinted { @@ -265,9 +266,10 @@ extension Workspace { return } checkpointPollingTask = Task { [weak self] in - while !Task.isCancelled { - try? await Task.sleep(for: .milliseconds(500)) - await self?.pollCheckpointEvents() + while !Task.isCancelled, let workspace = self { + let interval = await workspace.checkpointEventPollInterval + try? await Task.sleep(for: interval) + await workspace.pollCheckpointEvents() } } } @@ -309,10 +311,11 @@ extension Workspace { guard var watcher = checkpointWatchers[id] else { continue } - if watcher.deliveredCheckpointIds.insert(event.checkpoint.id).inserted { - watcher.continuation.yield(event) - checkpointWatchers[id] = watcher + guard watcher.deliveredCheckpointIds.insert(event.checkpoint.id).inserted else { + continue } + watcher.continuation.yield(event) + checkpointWatchers[id] = watcher } } diff --git a/Sources/Workspace/Workspace.swift b/Sources/Workspace/Workspace.swift index 112f686..4459a68 100644 --- a/Sources/Workspace/Workspace.swift +++ b/Sources/Workspace/Workspace.swift @@ -37,6 +37,11 @@ public actor Workspace { var checkpointWatchers: [UUID: CheckpointWatcher] = [:] var checkpointPollingTask: Task? + /// Sleep interval between polling the shared store for new checkpoints when + /// ``watchCheckpointEvents()`` has active subscribers. Tests may set a short value to reduce + /// wall-clock wait time. + public var checkpointEventPollInterval: Duration = .milliseconds(500) + private var watchers: [UUID: WatchedChangeStream] = [:] private struct WatchedChangeStream { @@ -152,7 +157,8 @@ public actor Workspace { } } - /// Encodes a value as JSON and writes it to a file. + /// Encodes a value as JSON and writes it to a file. The on-disk file includes a single trailing + /// newline after the JSON for line-oriented tools; ``readJSON`` still decodes the value correctly. public func writeJSON(_ value: T, to path: WorkspacePath, prettyPrinted: Bool = true) async throws { let content = try encodedJSONString(for: value, prettyPrinted: prettyPrinted) try await performDirectEdit( diff --git a/Tests/WorkspaceTests/WorkspaceInternalsTests.swift b/Tests/WorkspaceTests/WorkspaceInternalsTests.swift index 0b5cd44..79b3d87 100644 --- a/Tests/WorkspaceTests/WorkspaceInternalsTests.swift +++ b/Tests/WorkspaceTests/WorkspaceInternalsTests.swift @@ -220,6 +220,21 @@ struct WorkspaceInternalsTests { #expect(changesByPath["/deleted-link"]?.diff == nil) } + @Test + func `writeData to a directory path throws with unsupported`() async throws { + let workspace = Workspace() + try await workspace.createDirectory(at: "/out", recursive: true) + do { + try await workspace.writeData(Data([1]), to: "/out") + Issue.record("expected error when writing to directory") + } catch let error as WorkspaceError { + guard case .unsupported = error else { + Issue.record("unexpected workspace error: \(error)") + return + } + } + } + @Test func `performBinaryWrite through an existing symlink records symlink metadata and updates target`() async throws { let filesystem = InMemoryFilesystem()