spb/zyquo-agent Public MIT
The autonomous agent that actually operates your Mac — plans, runs real commands, verifies its own work.
Swift 94.7%
Shell 4.1%
Python 0.7%
Makefile 0.5%
1//2// WorkspaceManager.swift3// Zyquo Agent4//5// Author: Simon-Pierre Boucher6// Mail: contact@spboucher.ai7//8// One task = one working directory under9// ~/Library/Application Support/ZyquoAgent/Workspaces/<slug>-<shortid>/.10// The workspace is the shell tool's cwd and the file tools' root; escaping11// it requires explicit user approval (PolicyEngine). The manager tracks12// every file the agent creates or modifies — via explicit notes from the13// file tools plus a baseline-mtime refresh scan that also catches files14// shell commands touched — powers the Files tab's created/modified badges,15// and snapshots agent-touched files into .zyquo/checkpoints/ on demand.16// Internal bookkeeping (offloaded outputs, scripts, MEMORY.md later,17// checkpoints) lives in the workspace's `.zyquo/` folder, which is excluded18// from tracking and listings.19//2021import Foundation2223/// How a tracked file relates to the workspace baseline.24enum WorkspaceFileStatus: String, Codable, Sendable {25 case created26 case modified27}2829/// One agent-touched file, for the Files tab and checkpoints.30struct WorkspaceFileEntry: Codable, Identifiable, Sendable {31 /// Workspace-relative path (also the stable identity).32 var path: String33 var status: WorkspaceFileStatus34 var firstTouched: Date35 var lastTouched: Date3637 var id: String { path }38}3940/// Errors surfaced with enough context to fix the problem.41struct WorkspaceError: Error, Sendable, CustomStringConvertible {42 var description: String43}4445/// Actor: tracking state mutates from tool calls and refresh scans.46actor WorkspaceManager {47 /// The workspace directory (the agent's cwd / file-tool root).48 nonisolated let root: URL49 /// `.zyquo/` — internal files, never tracked or listed.50 nonisolated var internalDirectory: URL { root.appendingPathComponent(".zyquo") }51 nonisolated var checkpointsDirectory: URL { internalDirectory.appendingPathComponent("checkpoints") }52 nonisolated var outputsDirectory: URL { internalDirectory.appendingPathComponent("outputs") }5354 /// mtimes of everything present when the workspace was opened —55 /// anything newer or absent from this map is agent-created/modified.56 private var baseline: [String: Date]57 private var tracked: [String: WorkspaceFileEntry] = [:]5859 private static let stateFileName = "state.json"6061 // MARK: Creation6263 /// Creates a fresh workspace `<slug>-<shortid>/` for a new task.64 init(taskTitle: String, persistence: PersistenceService = .shared) throws {65 let slug = Self.slug(from: taskTitle)66 let shortID = UUID().uuidString.replacingOccurrences(of: "-", with: "").prefix(6).lowercased()67 let directory = persistence.workspacesDirectory.appendingPathComponent("\(slug)-\(shortID)")68 try Self.prepare(directory: directory)69 self.root = directory70 self.baseline = [:]71 Self.persist(72 state: PersistedState(baseline: [:], tracked: []),73 to: directory.appendingPathComponent(".zyquo").appendingPathComponent(Self.stateFileName)74 )75 }7677 /// Reattaches an existing workspace (reopening a past task keeps its78 /// files, tracking state, and checkpoints intact).79 init(existingAt directory: URL) throws {80 guard FileManager.default.fileExists(atPath: directory.path) else {81 throw WorkspaceError(description: "No workspace at \(directory.path).")82 }83 self.root = directory84 let stateURL = directory.appendingPathComponent(".zyquo").appendingPathComponent(Self.stateFileName)85 if let data = try? Data(contentsOf: stateURL),86 let state = try? Self.decoder.decode(PersistedState.self, from: data) {87 self.baseline = state.baseline88 self.tracked = Dictionary(uniqueKeysWithValues: state.tracked.map { ($0.path, $0) })89 } else {90 // No saved state: baseline = current content, nothing tracked yet.91 self.baseline = Self.scanMTimes(under: directory)92 }93 }9495 /// A throwaway workspace under the system temp dir — for tests and the96 /// Phase 7 evaluation harness (never pollutes the user's Workspaces/).97 static func scratch(label: String = "scratch") throws -> WorkspaceManager {98 let directory = FileManager.default.temporaryDirectory99 .appendingPathComponent("ZyquoAgent-\(label)-\(UUID().uuidString.prefix(8))")100 try prepare(directory: directory)101 return try WorkspaceManager(existingAt: directory)102 }103104 private static func prepare(directory: URL) throws {105 let fm = FileManager.default106 try fm.createDirectory(at: directory, withIntermediateDirectories: true)107 try fm.createDirectory(at: directory.appendingPathComponent(".zyquo"), withIntermediateDirectories: true)108 }109110 // MARK: File tracking111112 /// Called by file tools right after a write. `existedBefore` disambiguates113 /// created vs. modified for files the baseline has never seen.114 func noteFileTouched(_ url: URL, existedBefore: Bool) {115 guard let relative = relativePath(of: url) else { return } // outside or .zyquo — not tracked116 let now = Date()117 if var entry = tracked[relative] {118 entry.lastTouched = now119 tracked[relative] = entry120 } else {121 let status: WorkspaceFileStatus = (baseline[relative] != nil || existedBefore) ? .modified : .created122 tracked[relative] = WorkspaceFileEntry(path: relative, status: status, firstTouched: now, lastTouched: now)123 }124 persistState()125 }126127 /// Rescans the workspace against the baseline, catching files created or128 /// modified by shell commands (which cannot self-report like file tools).129 /// Call after tool execution steps and before rendering the Files tab.130 func refreshScan() {131 let current = Self.scanMTimes(under: root)132 let now = Date()133 for (path, mtime) in current {134 if let baselineMTime = baseline[path] {135 if mtime > baselineMTime, tracked[path] == nil {136 tracked[path] = WorkspaceFileEntry(path: path, status: .modified, firstTouched: now, lastTouched: mtime)137 } else if mtime > baselineMTime, var entry = tracked[path], mtime > entry.lastTouched {138 entry.lastTouched = mtime139 tracked[path] = entry140 }141 } else if tracked[path] == nil {142 tracked[path] = WorkspaceFileEntry(path: path, status: .created, firstTouched: now, lastTouched: mtime)143 }144 }145 // Files that disappeared are dropped from tracking.146 tracked = tracked.filter { current[$0.key] != nil }147 persistState()148 }149150 /// Agent-touched files with their created/modified badges, for the UI.151 func files() -> [WorkspaceFileEntry] {152 tracked.values.sorted { $0.path.localizedStandardCompare($1.path) == .orderedAscending }153 }154155 // MARK: Checkpoints156157 /// Copies every agent-touched file into `.zyquo/checkpoints/<n>-<label>/`158 /// (preserving relative paths) and returns the checkpoint folder.159 func checkpoint(label: String) throws -> URL {160 refreshScan()161 let fm = FileManager.default162 try fm.createDirectory(at: checkpointsDirectory, withIntermediateDirectories: true)163 let existing = (try? fm.contentsOfDirectory(atPath: checkpointsDirectory.path))?.count ?? 0164 let folder = checkpointsDirectory.appendingPathComponent("\(existing + 1)-\(Self.slug(from: label))")165 try fm.createDirectory(at: folder, withIntermediateDirectories: true)166167 for entry in tracked.values {168 let source = root.appendingPathComponent(entry.path)169 guard fm.fileExists(atPath: source.path) else { continue }170 let destination = folder.appendingPathComponent(entry.path)171 try fm.createDirectory(at: destination.deletingLastPathComponent(), withIntermediateDirectories: true)172 if fm.fileExists(atPath: destination.path) {173 try fm.removeItem(at: destination)174 }175 try fm.copyItem(at: source, to: destination)176 }177 return folder178 }179180 /// Existing checkpoint folders, oldest first.181 func checkpoints() -> [URL] {182 let contents = (try? FileManager.default.contentsOfDirectory(183 at: checkpointsDirectory,184 includingPropertiesForKeys: nil185 )) ?? []186 return contents.sorted { $0.lastPathComponent.localizedStandardCompare($1.lastPathComponent) == .orderedAscending }187 }188189 // MARK: Helpers190191 /// Workspace-relative path, or nil for anything outside or under .zyquo/.192 private func relativePath(of url: URL) -> String? {193 let path = (url.path as NSString).standardizingPath194 let rootPath = (root.path as NSString).standardizingPath195 guard path.hasPrefix(rootPath + "/") else { return nil }196 let relative = String(path.dropFirst(rootPath.count + 1))197 guard !relative.hasPrefix(".zyquo") else { return nil }198 return relative199 }200201 /// All regular files under the workspace (excluding .zyquo/ and .git/),202 /// keyed by relative path, valued by modification date.203 private static func scanMTimes(under root: URL) -> [String: Date] {204 var result: [String: Date] = [:]205 let fm = FileManager.default206 guard let enumerator = fm.enumerator(207 at: root,208 includingPropertiesForKeys: [.isRegularFileKey, .contentModificationDateKey],209 options: []210 ) else { return result }211 let rootPath = (root.path as NSString).standardizingPath212 while let entry = enumerator.nextObject() as? URL {213 let entryName = entry.lastPathComponent214 if entryName == ".zyquo" || entryName == ".git" {215 enumerator.skipDescendants()216 continue217 }218 guard let values = try? entry.resourceValues(forKeys: [.isRegularFileKey, .contentModificationDateKey]),219 values.isRegularFile == true else { continue }220 let path = (entry.path as NSString).standardizingPath221 guard path.hasPrefix(rootPath + "/") else { continue }222 result[String(path.dropFirst(rootPath.count + 1))] = values.contentModificationDate ?? .distantPast223 }224 return result225 }226227 /// Filesystem-safe slug: lowercase alphanumerics and dashes, ≤ 40 chars.228 static func slug(from title: String) -> String {229 var slug = ""230 var lastWasDash = true231 for scalar in title.lowercased().unicodeScalars {232 if CharacterSet.alphanumerics.contains(scalar), scalar.isASCII {233 slug.append(Character(scalar))234 lastWasDash = false235 } else if !lastWasDash {236 slug.append("-")237 lastWasDash = true238 }239 if slug.count >= 40 { break }240 }241 while slug.hasSuffix("-") { slug.removeLast() }242 return slug.isEmpty ? "task" : slug243 }244245 // MARK: State persistence (.zyquo/state.json)246247 private struct PersistedState: Codable {248 var baseline: [String: Date]249 var tracked: [WorkspaceFileEntry]250 }251252 private static let encoder: JSONEncoder = {253 let encoder = JSONEncoder()254 encoder.outputFormatting = [.prettyPrinted, .sortedKeys]255 encoder.dateEncodingStrategy = .iso8601256 return encoder257 }()258259 private static let decoder: JSONDecoder = {260 let decoder = JSONDecoder()261 decoder.dateDecodingStrategy = .iso8601262 return decoder263 }()264265 private func persistState() {266 Self.persist(267 state: PersistedState(baseline: baseline, tracked: Array(tracked.values)),268 to: internalDirectory.appendingPathComponent(Self.stateFileName)269 )270 }271272 /// Nonisolated worker shared by the isolated `persistState()` and the273 /// initializers (which run outside actor isolation).274 private static func persist(state: PersistedState, to url: URL) {275 do {276 try FileManager.default.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true)277 try encoder.encode(state).write(to: url, options: .atomic)278 } catch {279 FileHandle.standardError.write(Data("WorkspaceManager state save failed: \(error)\n".utf8))280 }281 }282}283