// // TaskStore.swift // Zyquo Agent // // Author: Simon-Pierre Boucher // Mail: contact@spboucher.ai // // CRUD + persistence for AgentTasks. Each task is saved as its own JSON // document under `Tasks/.json` in the app data folder (small atomic // writes even when a task embeds a long step history). Provides the sidebar // grouping (Pinned/Today/Yesterday/Previous 7 Days/Older) and full-text // search across titles and message history. // import Combine import Foundation @MainActor final class TaskStore: ObservableObject { @Published private(set) var tasks: [AgentTask] = [] @Published var selectedID: AgentTask.ID? @Published var searchText = "" /// Prompt handed from the empty state to the next opened task's input /// bar (consumed once by the detail view). @Published var pendingDraft: String? private let persistence: PersistenceService private static let tasksSubdirectory = "Tasks" init(persistence: PersistenceService = .shared) { self.persistence = persistence try? FileManager.default.createDirectory( at: tasksDirectory, withIntermediateDirectories: true ) loadAll() } private var tasksDirectory: URL { persistence.rootDirectory.appendingPathComponent(Self.tasksSubdirectory) } // MARK: - CRUD /// Creates a new task and selects it. @discardableResult func newTask(model: AIModel?, safetyMode: SafetyMode = .guarded, personaID: UUID? = nil) -> AgentTask { var task = AgentTask( title: "New Task", modelID: model?.id ?? "", providerID: model?.provider ?? .anthropic, safetyMode: safetyMode ) task.personaID = personaID tasks.insert(task, at: 0) save(task) selectedID = task.id return task } /// Imports task records (Settings › Advanced). Existing IDs are skipped /// so re-importing an export never duplicates; imported tasks arrive with /// any active status normalized to idle. Returns how many were added. @discardableResult func importTasks(_ imported: [AgentTask]) -> Int { var added = 0 let existing = Set(tasks.map(\.id)) for var task in imported where !existing.contains(task.id) { if task.status.isActive { task.status = .idle } tasks.append(task) save(task) added += 1 } tasks.sort { $0.updatedAt > $1.updatedAt } return added } func task(id: AgentTask.ID) -> AgentTask? { tasks.first { $0.id == id } } /// Replaces the stored task and persists it; bumps `updatedAt`. func update(_ task: AgentTask, touch: Bool = true) { var task = task if touch { task.updatedAt = Date() } guard let index = tasks.firstIndex(where: { $0.id == task.id }) else { return } tasks[index] = task save(task) } func rename(_ id: AgentTask.ID, to title: String) { guard var task = task(id: id) else { return } let trimmed = title.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { return } task.title = trimmed update(task) } func togglePin(_ id: AgentTask.ID) { guard var task = task(id: id) else { return } task.pinned.toggle() update(task, touch: false) } func setStatus(_ status: AgentTaskStatus, for id: AgentTask.ID) { guard var task = task(id: id), task.status != status else { return } task.status = status update(task, touch: false) } func appendMessage(_ message: TaskMessage, to id: AgentTask.ID) { guard var task = task(id: id) else { return } task.messages.append(message) update(task) } /// Deletes the task record and, when requested, its workspace folder. func delete(_ id: AgentTask.ID, deleteWorkspace: Bool) { guard let task = task(id: id) else { return } if deleteWorkspace, let workspace = task.workspaceURL { // Only ever remove directories that live under the managed // Workspaces/ root — never an arbitrary user path. let root = persistence.workspacesDirectory.standardizedFileURL.path let target = workspace.standardizedFileURL.path if target.hasPrefix(root + "/") { try? FileManager.default.removeItem(at: workspace) } } tasks.removeAll { $0.id == id } try? FileManager.default.removeItem(at: fileURL(for: id)) if selectedID == id { selectedID = nil } } // MARK: - Sidebar grouping & search struct SidebarGroup: Identifiable { var id: String var title: String var tasks: [AgentTask] } /// Tasks matching the search text (title + message history), newest first. var filteredTasks: [AgentTask] { let query = searchText.trimmingCharacters(in: .whitespaces) let sorted = tasks.sorted { $0.updatedAt > $1.updatedAt } guard !query.isEmpty else { return sorted } return sorted.filter { task in if task.title.localizedCaseInsensitiveContains(query) { return true } return task.messages.contains { $0.text.localizedCaseInsensitiveContains(query) } } } /// Pinned / Today / Yesterday / Previous 7 Days / Older. var sidebarGroups: [SidebarGroup] { let calendar = Calendar.current let now = Date() var pinned: [AgentTask] = [] var today: [AgentTask] = [] var yesterday: [AgentTask] = [] var previousWeek: [AgentTask] = [] var older: [AgentTask] = [] for task in filteredTasks { if task.pinned { pinned.append(task) } else if calendar.isDateInToday(task.updatedAt) { today.append(task) } else if calendar.isDateInYesterday(task.updatedAt) { yesterday.append(task) } else if let days = calendar.dateComponents([.day], from: task.updatedAt, to: now).day, days < 7 { previousWeek.append(task) } else { older.append(task) } } return [ SidebarGroup(id: "pinned", title: "Pinned", tasks: pinned), SidebarGroup(id: "today", title: "Today", tasks: today), SidebarGroup(id: "yesterday", title: "Yesterday", tasks: yesterday), SidebarGroup(id: "week", title: "Previous 7 Days", tasks: previousWeek), SidebarGroup(id: "older", title: "Older", tasks: older), ].filter { !$0.tasks.isEmpty } } // MARK: - Persistence private func fileURL(for id: AgentTask.ID) -> URL { tasksDirectory.appendingPathComponent("\(id.uuidString).json") } private func save(_ task: AgentTask) { persistence.save(task, to: "\(Self.tasksSubdirectory)/\(task.id.uuidString).json") } private func loadAll() { let files = (try? FileManager.default.contentsOfDirectory( at: tasksDirectory, includingPropertiesForKeys: nil )) ?? [] var loaded: [AgentTask] = [] for file in files where file.pathExtension == "json" { if let task = persistence.load(AgentTask.self, from: "\(Self.tasksSubdirectory)/\(file.lastPathComponent)") { loaded.append(task) } } // A crash mid-run must not leave phantom "Running" pills behind. tasks = loaded .map { task in var task = task if task.status.isActive { task.status = .idle } return task } .sorted { $0.updatedAt > $1.updatedAt } } }