SPB Git

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%
12.2 KB · 283 lines swift
Raw Blame History
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