// // WorkspaceManager.swift // Zyquo Agent // // Author: Simon-Pierre Boucher // Mail: contact@spboucher.ai // // One task = one working directory under // ~/Library/Application Support/ZyquoAgent/Workspaces/-/. // The workspace is the shell tool's cwd and the file tools' root; escaping // it requires explicit user approval (PolicyEngine). The manager tracks // every file the agent creates or modifies — via explicit notes from the // file tools plus a baseline-mtime refresh scan that also catches files // shell commands touched — powers the Files tab's created/modified badges, // and snapshots agent-touched files into .zyquo/checkpoints/ on demand. // Internal bookkeeping (offloaded outputs, scripts, MEMORY.md later, // checkpoints) lives in the workspace's `.zyquo/` folder, which is excluded // from tracking and listings. // import Foundation /// How a tracked file relates to the workspace baseline. enum WorkspaceFileStatus: String, Codable, Sendable { case created case modified } /// One agent-touched file, for the Files tab and checkpoints. struct WorkspaceFileEntry: Codable, Identifiable, Sendable { /// Workspace-relative path (also the stable identity). var path: String var status: WorkspaceFileStatus var firstTouched: Date var lastTouched: Date var id: String { path } } /// Errors surfaced with enough context to fix the problem. struct WorkspaceError: Error, Sendable, CustomStringConvertible { var description: String } /// Actor: tracking state mutates from tool calls and refresh scans. actor WorkspaceManager { /// The workspace directory (the agent's cwd / file-tool root). nonisolated let root: URL /// `.zyquo/` — internal files, never tracked or listed. nonisolated var internalDirectory: URL { root.appendingPathComponent(".zyquo") } nonisolated var checkpointsDirectory: URL { internalDirectory.appendingPathComponent("checkpoints") } nonisolated var outputsDirectory: URL { internalDirectory.appendingPathComponent("outputs") } /// mtimes of everything present when the workspace was opened — /// anything newer or absent from this map is agent-created/modified. private var baseline: [String: Date] private var tracked: [String: WorkspaceFileEntry] = [:] private static let stateFileName = "state.json" // MARK: Creation /// Creates a fresh workspace `-/` for a new task. init(taskTitle: String, persistence: PersistenceService = .shared) throws { let slug = Self.slug(from: taskTitle) let shortID = UUID().uuidString.replacingOccurrences(of: "-", with: "").prefix(6).lowercased() let directory = persistence.workspacesDirectory.appendingPathComponent("\(slug)-\(shortID)") try Self.prepare(directory: directory) self.root = directory self.baseline = [:] Self.persist( state: PersistedState(baseline: [:], tracked: []), to: directory.appendingPathComponent(".zyquo").appendingPathComponent(Self.stateFileName) ) } /// Reattaches an existing workspace (reopening a past task keeps its /// files, tracking state, and checkpoints intact). init(existingAt directory: URL) throws { guard FileManager.default.fileExists(atPath: directory.path) else { throw WorkspaceError(description: "No workspace at \(directory.path).") } self.root = directory let stateURL = directory.appendingPathComponent(".zyquo").appendingPathComponent(Self.stateFileName) if let data = try? Data(contentsOf: stateURL), let state = try? Self.decoder.decode(PersistedState.self, from: data) { self.baseline = state.baseline self.tracked = Dictionary(uniqueKeysWithValues: state.tracked.map { ($0.path, $0) }) } else { // No saved state: baseline = current content, nothing tracked yet. self.baseline = Self.scanMTimes(under: directory) } } /// A throwaway workspace under the system temp dir — for tests and the /// Phase 7 evaluation harness (never pollutes the user's Workspaces/). static func scratch(label: String = "scratch") throws -> WorkspaceManager { let directory = FileManager.default.temporaryDirectory .appendingPathComponent("ZyquoAgent-\(label)-\(UUID().uuidString.prefix(8))") try prepare(directory: directory) return try WorkspaceManager(existingAt: directory) } private static func prepare(directory: URL) throws { let fm = FileManager.default try fm.createDirectory(at: directory, withIntermediateDirectories: true) try fm.createDirectory(at: directory.appendingPathComponent(".zyquo"), withIntermediateDirectories: true) } // MARK: File tracking /// Called by file tools right after a write. `existedBefore` disambiguates /// created vs. modified for files the baseline has never seen. func noteFileTouched(_ url: URL, existedBefore: Bool) { guard let relative = relativePath(of: url) else { return } // outside or .zyquo — not tracked let now = Date() if var entry = tracked[relative] { entry.lastTouched = now tracked[relative] = entry } else { let status: WorkspaceFileStatus = (baseline[relative] != nil || existedBefore) ? .modified : .created tracked[relative] = WorkspaceFileEntry(path: relative, status: status, firstTouched: now, lastTouched: now) } persistState() } /// Rescans the workspace against the baseline, catching files created or /// modified by shell commands (which cannot self-report like file tools). /// Call after tool execution steps and before rendering the Files tab. func refreshScan() { let current = Self.scanMTimes(under: root) let now = Date() for (path, mtime) in current { if let baselineMTime = baseline[path] { if mtime > baselineMTime, tracked[path] == nil { tracked[path] = WorkspaceFileEntry(path: path, status: .modified, firstTouched: now, lastTouched: mtime) } else if mtime > baselineMTime, var entry = tracked[path], mtime > entry.lastTouched { entry.lastTouched = mtime tracked[path] = entry } } else if tracked[path] == nil { tracked[path] = WorkspaceFileEntry(path: path, status: .created, firstTouched: now, lastTouched: mtime) } } // Files that disappeared are dropped from tracking. tracked = tracked.filter { current[$0.key] != nil } persistState() } /// Agent-touched files with their created/modified badges, for the UI. func files() -> [WorkspaceFileEntry] { tracked.values.sorted { $0.path.localizedStandardCompare($1.path) == .orderedAscending } } // MARK: Checkpoints /// Copies every agent-touched file into `.zyquo/checkpoints/-