diff --git a/.gitignore b/.gitignore index 1db32e6..54b3248 100644 --- a/.gitignore +++ b/.gitignore @@ -20,6 +20,9 @@ DerivedData/ *.swp *~ +# Large media originals (keep only compressed mp4) +*.mov + # IDE .idea/ .vscode/ diff --git a/CreedFlow/Sources/CreedFlow/Engine/DeploymentCoordinator.swift b/CreedFlow/Sources/CreedFlow/Engine/DeploymentCoordinator.swift index a13858b..f6d4908 100644 --- a/CreedFlow/Sources/CreedFlow/Engine/DeploymentCoordinator.swift +++ b/CreedFlow/Sources/CreedFlow/Engine/DeploymentCoordinator.swift @@ -145,6 +145,12 @@ extension Orchestrator { } deployment.port = port + // Persist port assignment before deploying + try await dbQueue.write { db in + var d = deployment + try d.update(db) + } + // Run actual local deployment _ = try await localDeployService.deploy( project: project, @@ -152,11 +158,11 @@ extension Orchestrator { port: port ) - // Mark deployment as successful - deployment.status = .success - deployment.completedAt = Date() + // Mark deployment as successful — re-read from DB to avoid stale overwrites try await dbQueue.write { db in - var d = deployment + guard var d = try Deployment.fetchOne(db, id: deployment.id) else { return } + d.status = .success + d.completedAt = Date() try d.update(db) } diff --git a/CreedFlow/Sources/CreedFlow/Engine/Orchestrator.swift b/CreedFlow/Sources/CreedFlow/Engine/Orchestrator.swift index 6b22e85..ee42c00 100644 --- a/CreedFlow/Sources/CreedFlow/Engine/Orchestrator.swift +++ b/CreedFlow/Sources/CreedFlow/Engine/Orchestrator.swift @@ -58,6 +58,7 @@ final class Orchestrator { private(set) var isRunning = false private(set) var activeRunners: [UUID: MultiBackendRunner] = [:] + private let runnersLock = NSLock() private var pollingTask: Task? init( @@ -173,12 +174,16 @@ final class Orchestrator { for backend in await backendRouter.allBackends { await backend.cancelAll() } + runnersLock.lock() activeRunners.removeAll() + runnersLock.unlock() } /// Get a runner for a specific task (for UI display of live output) func runner(for taskId: UUID) -> MultiBackendRunner? { - activeRunners[taskId] + runnersLock.lock() + defer { runnersLock.unlock() } + return activeRunners[taskId] } // MARK: - Private @@ -256,7 +261,9 @@ final class Orchestrator { continue } let runner = MultiBackendRunner(backend: backend, dbQueue: dbQueue) + runnersLock.lock() activeRunners[task.id] = runner + runnersLock.unlock() // Record selected backend immediately so UI shows it during in_progress let selectedBackend = backend.backendType.rawValue @@ -283,7 +290,9 @@ final class Orchestrator { defer { Task { [weak self] in await self?.scheduler.release(task: task) + self?.runnersLock.lock() self?.activeRunners.removeValue(forKey: task.id) + self?.runnersLock.unlock() } } diff --git a/CreedFlow/Sources/CreedFlow/Services/CLI/LMStudioBackend.swift b/CreedFlow/Sources/CreedFlow/Services/CLI/LMStudioBackend.swift index 0a7ee08..b102e0d 100644 --- a/CreedFlow/Sources/CreedFlow/Services/CLI/LMStudioBackend.swift +++ b/CreedFlow/Sources/CreedFlow/Services/CLI/LMStudioBackend.swift @@ -5,7 +5,7 @@ import Foundation actor LMStudioBackend: CLIBackend { nonisolated let backendType = CLIBackendType.lmstudio - private var activeRequests: [UUID: URLSessionDataTask] = [:] + private var activeTasks: Set = [] var isAvailable: Bool { get async { @@ -65,46 +65,19 @@ actor LMStudioBackend: CLIBackend { return (processId, AsyncThrowingStream { $0.finish(throwing: error) }) } - let stream = AsyncThrowingStream { continuation in - let task = URLSession.shared.dataTask(with: request) { data, response, error in - let elapsed = Int(Date().timeIntervalSince(startTime) * 1000) - - if let error = error { - continuation.yield(.error(error.localizedDescription)) - let result = CLIResult( - output: error.localizedDescription, - isError: true, - sessionId: nil, - model: "lmstudio:\(model)", - costUSD: nil, - durationMs: elapsed, - inputTokens: 0, - outputTokens: 0 - ) - continuation.yield(.result(result)) - continuation.finish() - return - } + activeTasks.insert(processId) + let capturedRequest = request - guard let data = data else { - continuation.yield(.error("No data received")) - let result = CLIResult( - output: "No data received", - isError: true, - sessionId: nil, - model: "lmstudio:\(model)", - costUSD: nil, - durationMs: elapsed, - inputTokens: 0, - outputTokens: 0 - ) - continuation.yield(.result(result)) - continuation.finish() - return + let stream = AsyncThrowingStream { continuation in + let asyncTask = Task { [weak self] in + defer { + Task { await self?.removeActiveTask(processId) } } - // Parse OpenAI-compatible response do { + let (data, _) = try await URLSession.shared.data(for: capturedRequest) + let elapsed = Int(Date().timeIntervalSince(startTime) * 1000) + guard let json = try JSONSerialization.jsonObject(with: data) as? [String: Any], let choices = json["choices"] as? [[String: Any]], let first = choices.first, @@ -113,81 +86,61 @@ actor LMStudioBackend: CLIBackend { let raw = String(data: data, encoding: .utf8) ?? "Unknown response" continuation.yield(.error("Failed to parse response: \(raw)")) let result = CLIResult( - output: raw, - isError: true, - sessionId: nil, - model: "lmstudio:\(model)", - costUSD: nil, - durationMs: elapsed, - inputTokens: 0, - outputTokens: 0 + output: raw, isError: true, sessionId: nil, + model: "lmstudio:\(model)", costUSD: nil, + durationMs: elapsed, inputTokens: 0, outputTokens: 0 ) continuation.yield(.result(result)) continuation.finish() return } - // Extract token usage if available let usage = json["usage"] as? [String: Any] let inputTokens = usage?["prompt_tokens"] as? Int ?? 0 let outputTokens = usage?["completion_tokens"] as? Int ?? 0 continuation.yield(.text(content)) let result = CLIResult( - output: content, - isError: false, - sessionId: nil, - model: "lmstudio:\(model)", - costUSD: nil, - durationMs: elapsed, - inputTokens: inputTokens, - outputTokens: outputTokens + output: content, isError: false, sessionId: nil, + model: "lmstudio:\(model)", costUSD: nil, + durationMs: elapsed, inputTokens: inputTokens, outputTokens: outputTokens ) continuation.yield(.result(result)) continuation.finish() } catch { - continuation.yield(.error("JSON parse error: \(error.localizedDescription)")) + let elapsed = Int(Date().timeIntervalSince(startTime) * 1000) + continuation.yield(.error(error.localizedDescription)) let result = CLIResult( - output: error.localizedDescription, - isError: true, - sessionId: nil, - model: "lmstudio:\(model)", - costUSD: nil, - durationMs: elapsed, - inputTokens: 0, - outputTokens: 0 + output: error.localizedDescription, isError: true, sessionId: nil, + model: "lmstudio:\(model)", costUSD: nil, + durationMs: elapsed, inputTokens: 0, outputTokens: 0 ) continuation.yield(.result(result)) continuation.finish() } } - self.activeRequests[processId] = task - task.resume() - continuation.onTermination = { @Sendable _ in - task.cancel() + asyncTask.cancel() } } return (processId, stream) } + private func removeActiveTask(_ id: UUID) { + activeTasks.remove(id) + } + func cancel(_ processId: UUID) async { - guard let task = activeRequests[processId] else { return } - task.cancel() - activeRequests.removeValue(forKey: processId) + activeTasks.remove(processId) } func cancelAll() async { - for (_, task) in activeRequests { - task.cancel() - } - activeRequests.removeAll() + activeTasks.removeAll() } func activeCount() -> Int { - activeRequests = activeRequests.filter { $0.value.state == .running } - return activeRequests.count + activeTasks.count } } diff --git a/CreedFlow/Sources/CreedFlow/Services/CLI/MultiBackendRunner.swift b/CreedFlow/Sources/CreedFlow/Services/CLI/MultiBackendRunner.swift index f7aa630..b614c29 100644 --- a/CreedFlow/Sources/CreedFlow/Services/CLI/MultiBackendRunner.swift +++ b/CreedFlow/Sources/CreedFlow/Services/CLI/MultiBackendRunner.swift @@ -37,11 +37,13 @@ final class MultiBackendRunner { /// Execute an agent task with full lifecycle management. /// - Parameter promptOverride: If provided, replaces the agent's default prompt (used by PromptRecommender / ChainExecutor). func execute(task: AgentTask, agent: any AgentProtocol, workingDirectory: String = "", promptOverride: String? = nil) async throws -> AgentResult { - isRunning = true - liveOutput = [] - defer { isRunning = false } + await MainActor.run { + isRunning = true + liveOutput = [] + } + defer { Task { @MainActor in self.isRunning = false } } - addOutputLine("Starting \(agent.agentType.rawValue) agent via \(backendType.rawValue) for: \(task.title)", type: .system) + await MainActor.run { self.addOutputLine("Starting \(agent.agentType.rawValue) agent via \(backendType.rawValue) for: \(task.title)", type: .system) } // Generate MCP config if agent needs MCP servers (Claude only) var mcpConfigPath: String? @@ -49,7 +51,7 @@ final class MultiBackendRunner { let generator = MCPConfigGenerator(dbQueue: dbQueue) mcpConfigPath = try generator.generateConfig(serverNames: serverNames) if let path = mcpConfigPath { - addOutputLine("MCP config: \(path)", type: .system) + await MainActor.run { self.addOutputLine("MCP config: \(path)", type: .system) } } } defer { @@ -147,7 +149,7 @@ final class MultiBackendRunner { // TaskGroup cancellation from group.cancelAll() — not a real error } catch let error as ClaudeError where error.localizedDescription.contains("timed out") { let errorMsg = "Task timed out after \(timeoutSeconds)s" - addOutputLine("Error: \(errorMsg)", type: .error) + await MainActor.run { self.addOutputLine("Error: \(errorMsg)", type: .error) } try await dbQueue.write { db in var updatedTask = task @@ -165,7 +167,7 @@ final class MultiBackendRunner { // Detect rate-limit signals in error output if let signal = RateLimitDetector.detect(in: errorMsg) { - addOutputLine("Rate limited: \(signal)", type: .error) + await MainActor.run { self.addOutputLine("Rate limited: \(signal)", type: .error) } try await dbQueue.write { db in var updatedTask = task @@ -180,7 +182,7 @@ final class MultiBackendRunner { throw RateLimitError(backendType: backendType, rawSignal: signal) } - addOutputLine("Error: \(errorMsg)", type: .error) + await MainActor.run { self.addOutputLine("Error: \(errorMsg)", type: .error) } try await dbQueue.write { db in var updatedTask = task @@ -249,6 +251,7 @@ final class MultiBackendRunner { return agentResult } + @MainActor private func addOutputLine(_ text: String, type: OutputLine.LineType) { liveOutput.append(OutputLine(text: text, type: type, timestamp: Date())) } diff --git a/CreedFlow/Sources/CreedFlow/Services/Chat/ProjectChatService.swift b/CreedFlow/Sources/CreedFlow/Services/Chat/ProjectChatService.swift index d07e449..7148a9f 100644 --- a/CreedFlow/Sources/CreedFlow/Services/Chat/ProjectChatService.swift +++ b/CreedFlow/Sources/CreedFlow/Services/Chat/ProjectChatService.swift @@ -6,6 +6,7 @@ private let logger = Logger(subsystem: "com.creedflow", category: "ProjectChatSe /// Manages chat conversations for a project — handles sending messages, /// streaming AI responses, and task proposal approval. +@MainActor @Observable final class ProjectChatService { private(set) var messages: [ProjectMessage] = [] @@ -30,8 +31,8 @@ final class ProjectChatService { self.history = ChatHistory(dbQueue: dbQueue) } - deinit { - observationTask?.cancel() + nonisolated deinit { + // observationTask will be cancelled when self is deallocated } // MARK: - Binding @@ -43,8 +44,9 @@ final class ProjectChatService { observationTask?.cancel() // Load project - Task { - self.project = try? await dbQueue.read { db in + let dbRef = dbQueue + Task { @MainActor [weak self] in + self?.project = try? await dbRef.read { db in try Project.fetchOne(db, id: projectId) } } @@ -57,13 +59,10 @@ final class ProjectChatService { .fetchAll(db) } - let db = dbQueue - observationTask = Task { [weak self] in + observationTask = Task { @MainActor [weak self] in do { - for try await msgs in observation.values(in: db) { - await MainActor.run { - self?.messages = msgs - } + for try await msgs in observation.values(in: dbRef) { + self?.messages = msgs } } catch { logger.error("Observation error: \(error.localizedDescription)") @@ -419,7 +418,7 @@ final class ProjectChatService { return nil } - private static func parseAgentType(_ raw: String) -> AgentTask.AgentType { + nonisolated private static func parseAgentType(_ raw: String) -> AgentTask.AgentType { switch raw.lowercased() { case "coder": return .coder case "devops": return .devops diff --git a/CreedFlow/Sources/CreedFlow/Services/Health/BackendHealthMonitor.swift b/CreedFlow/Sources/CreedFlow/Services/Health/BackendHealthMonitor.swift index ea9baea..d89eaf6 100644 --- a/CreedFlow/Sources/CreedFlow/Services/Health/BackendHealthMonitor.swift +++ b/CreedFlow/Sources/CreedFlow/Services/Health/BackendHealthMonitor.swift @@ -191,15 +191,16 @@ actor BackendHealthMonitor { } private func runWithTimeout(path: String, arguments: [String], timeout: TimeInterval) async throws -> String { - try await withThrowingTaskGroup(of: String.self) { group in + let process = Process() + process.executableURL = URL(fileURLWithPath: path) + process.arguments = arguments + process.environment = ProcessInfo.processInfo.environment + let pipe = Pipe() + process.standardOutput = pipe + process.standardError = Pipe() + + return try await withThrowingTaskGroup(of: String.self) { group in group.addTask { - let process = Process() - process.executableURL = URL(fileURLWithPath: path) - process.arguments = arguments - process.environment = ProcessInfo.processInfo.environment - let pipe = Pipe() - process.standardOutput = pipe - process.standardError = Pipe() try process.run() process.waitUntilExit() let data = pipe.fileHandleForReading.readDataToEndOfFile() @@ -211,8 +212,15 @@ actor BackendHealthMonitor { throw CancellationError() } + defer { + // Ensure process is always terminated on exit (timeout or success) + if process.isRunning { + process.terminate() + } + group.cancelAll() + } + let result = try await group.next()! - group.cancelAll() return result } } diff --git a/CreedFlow/Sources/CreedFlow/Services/Health/MCPHealthMonitor.swift b/CreedFlow/Sources/CreedFlow/Services/Health/MCPHealthMonitor.swift index 28cd9c5..87dd13d 100644 --- a/CreedFlow/Sources/CreedFlow/Services/Health/MCPHealthMonitor.swift +++ b/CreedFlow/Sources/CreedFlow/Services/Health/MCPHealthMonitor.swift @@ -71,28 +71,33 @@ actor MCPHealthMonitor { } // Spawn process and check if it survives for 1 second - do { - let process = Process() - process.executableURL = URL(fileURLWithPath: command) - process.arguments = config.decodedArguments - process.standardOutput = Pipe() - process.standardError = Pipe() - - // Set environment variables - var env = ProcessInfo.processInfo.environment - for (key, value) in config.decodedEnvironmentVars { - env[key] = value - } - process.environment = env + let process = Process() + process.executableURL = URL(fileURLWithPath: command) + process.arguments = config.decodedArguments + process.standardOutput = Pipe() + process.standardError = Pipe() + + // Set environment variables + var env = ProcessInfo.processInfo.environment + for (key, value) in config.decodedEnvironmentVars { + env[key] = value + } + process.environment = env + do { try process.run() + // Ensure process is always cleaned up, even on task cancellation + defer { + if process.isRunning { + process.terminate() + } + } + // Wait 1 second and check if still running try await Task.sleep(for: .seconds(1)) let isAlive = process.isRunning - process.terminate() - let elapsed = Int(Date().timeIntervalSince(start) * 1000) if isAlive { recordAndNotify(name, status: .healthy, startTime: start, responseTimeMs: elapsed) @@ -101,6 +106,10 @@ actor MCPHealthMonitor { error: "Process exited with code \(process.terminationStatus)") } } catch { + // Ensure process cleanup on error/cancellation + if process.isRunning { + process.terminate() + } recordAndNotify(name, status: .unhealthy, startTime: start, error: error.localizedDescription) } diff --git a/CreedFlow/Sources/CreedFlow/Services/Notifications/NotificationService.swift b/CreedFlow/Sources/CreedFlow/Services/Notifications/NotificationService.swift index 400cc94..a714f7f 100644 --- a/CreedFlow/Sources/CreedFlow/Services/Notifications/NotificationService.swift +++ b/CreedFlow/Sources/CreedFlow/Services/Notifications/NotificationService.swift @@ -21,7 +21,7 @@ actor NotificationService { title: String, message: String, metadata: String? = nil - ) { + ) async { let notification = AppNotification( category: category, severity: severity, @@ -30,8 +30,8 @@ actor NotificationService { metadata: metadata ) - // Persist to DB - try? dbQueue.write { db in + // Persist to DB (async to avoid blocking the actor's thread) + try? await dbQueue.write { db in var n = notification try n.insert(db) } @@ -51,8 +51,8 @@ actor NotificationService { } /// Mark a notification as read. - func markRead(_ id: UUID) { - try? dbQueue.write { db in + func markRead(_ id: UUID) async { + try? await dbQueue.write { db in guard var n = try AppNotification.fetchOne(db, id: id) else { return } n.isRead = true try n.update(db) @@ -60,8 +60,8 @@ actor NotificationService { } /// Mark all unread notifications as read. - func markAllRead() { - try? dbQueue.write { db in + func markAllRead() async { + try? await dbQueue.write { db in try db.execute( sql: "UPDATE appNotification SET isRead = 1 WHERE isRead = 0" ) @@ -69,8 +69,8 @@ actor NotificationService { } /// Dismiss a notification (hides from panel). - func dismiss(_ id: UUID) { - try? dbQueue.write { db in + func dismiss(_ id: UUID) async { + try? await dbQueue.write { db in guard var n = try AppNotification.fetchOne(db, id: id) else { return } n.isDismissed = true try n.update(db) @@ -78,23 +78,23 @@ actor NotificationService { } /// Permanently delete a single notification. - func deleteOne(_ id: UUID) { - try? dbQueue.write { db in + func deleteOne(_ id: UUID) async { + try? await dbQueue.write { db in _ = try AppNotification.deleteOne(db, id: id) } } /// Permanently delete all notifications. - func clearAll() { - try? dbQueue.write { db in + func clearAll() async { + try? await dbQueue.write { db in _ = try AppNotification.deleteAll(db) } } /// Remove notifications older than `pruneAfterDays`. - func pruneOld() { + func pruneOld() async { let cutoff = Calendar.current.date(byAdding: .day, value: -pruneAfterDays, to: Date()) ?? Date() - try? dbQueue.write { db in + try? await dbQueue.write { db in try db.execute( sql: "DELETE FROM appNotification WHERE createdAt < ?", arguments: [cutoff] diff --git a/CreedFlow/Sources/CreedFlow/Services/Notifications/NotificationViewModel.swift b/CreedFlow/Sources/CreedFlow/Services/Notifications/NotificationViewModel.swift index c82456a..caaa804 100644 --- a/CreedFlow/Sources/CreedFlow/Services/Notifications/NotificationViewModel.swift +++ b/CreedFlow/Sources/CreedFlow/Services/Notifications/NotificationViewModel.swift @@ -92,8 +92,10 @@ final class NotificationViewModel { } do { for try await (count, items) in observation.values(in: dbQueue) { - unreadCount = count - recentNotifications = items + await MainActor.run { + unreadCount = count + recentNotifications = items + } } } catch { // Observation stream ended — stale data but non-fatal diff --git a/CreedFlow/Sources/CreedFlow/Views/ContentView.swift b/CreedFlow/Sources/CreedFlow/Views/ContentView.swift index 49760c7..ab4fab5 100644 --- a/CreedFlow/Sources/CreedFlow/Views/ContentView.swift +++ b/CreedFlow/Sources/CreedFlow/Views/ContentView.swift @@ -201,8 +201,11 @@ public struct ContentView: View { } guard let db = appDatabase, let orchestrator else { // Fallback — should not happen in practice since orchestrator is set in .task + guard let fallbackDb = try? DatabaseQueue() else { + fatalError("Failed to create fallback DatabaseQueue for chat service") + } let fallback = ProjectChatService( - dbQueue: try! DatabaseQueue(), + dbQueue: fallbackDb, backendRouter: BackendRouter() ) return fallback diff --git a/CreedFlow/Sources/CreedFlow/Views/Notifications/NotificationToastOverlay.swift b/CreedFlow/Sources/CreedFlow/Views/Notifications/NotificationToastOverlay.swift index 7745074..0a8eb45 100644 --- a/CreedFlow/Sources/CreedFlow/Views/Notifications/NotificationToastOverlay.swift +++ b/CreedFlow/Sources/CreedFlow/Views/Notifications/NotificationToastOverlay.swift @@ -28,6 +28,7 @@ private struct ToastCard: View { let notification: AppNotification let onDismiss: () -> Void @State private var isVisible = true + @State private var autoDismissTask: Task? var body: some View { HStack(spacing: 10) { @@ -72,10 +73,14 @@ private struct ToastCard: View { } .onAppear { // Auto-dismiss after 5 seconds - Task { + autoDismissTask = Task { try? await Task.sleep(for: .seconds(5)) + guard !Task.isCancelled else { return } onDismiss() } } + .onDisappear { + autoDismissTask?.cancel() + } } } diff --git a/CreedFlow/Sources/CreedFlow/Views/Tasks/TaskDetailView.swift b/CreedFlow/Sources/CreedFlow/Views/Tasks/TaskDetailView.swift index 3a5c118..7e055c5 100644 --- a/CreedFlow/Sources/CreedFlow/Views/Tasks/TaskDetailView.swift +++ b/CreedFlow/Sources/CreedFlow/Views/Tasks/TaskDetailView.swift @@ -438,6 +438,11 @@ struct TaskDetailView: View { do { try await db.dbQueue.write { dbConn in guard var t = try AgentTask.fetchOne(dbConn, id: taskId) else { return } + // Only allow retry from terminal/revisable states + guard [.failed, .needsRevision, .cancelled].contains(t.status) else { + throw NSError(domain: "CreedFlow", code: 0, + userInfo: [NSLocalizedDescriptionKey: "Only failed, needs_revision, or cancelled tasks can be retried"]) + } t.status = .queued t.retryCount += 1 t.errorMessage = nil diff --git a/README.md b/README.md index deb52fe..217a5e1 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,10 @@ | ![Settings - Agents](screen/setting_agents.png) | ![Settings - MCP](screen/setting_mcp.png) | | **Agent Preferences** — Per-agent backend routing config | **MCP Servers** — 13 integrations (DALL-E, Figma, Runway...) | +### Demo + +https://github.com/fatihkan/creedflow/raw/main/screen/screen_record.mp4 + --- ## Download diff --git a/creedflow-desktop/src-tauri/Cargo.lock b/creedflow-desktop/src-tauri/Cargo.lock index 1c1b0ff..213074d 100644 --- a/creedflow-desktop/src-tauri/Cargo.lock +++ b/creedflow-desktop/src-tauri/Cargo.lock @@ -497,7 +497,7 @@ dependencies = [ [[package]] name = "creedflow-desktop" -version = "1.5.0" +version = "1.6.0" dependencies = [ "async-trait", "chrono", @@ -505,6 +505,7 @@ dependencies = [ "docx-rs", "env_logger", "hex", + "hmac", "libc", "log", "once_cell", @@ -659,6 +660,7 @@ checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer", "crypto-common", + "subtle", ] [[package]] @@ -1512,6 +1514,15 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + [[package]] name = "html5ever" version = "0.29.1" diff --git a/creedflow-desktop/src-tauri/Cargo.toml b/creedflow-desktop/src-tauri/Cargo.toml index 12824bd..9ac4b05 100644 --- a/creedflow-desktop/src-tauri/Cargo.toml +++ b/creedflow-desktop/src-tauri/Cargo.toml @@ -35,6 +35,7 @@ reqwest = { version = "0.12", features = ["json"] } async-trait = "0.1" dirs = "5" sha2 = "0.10" +hmac = "0.12" hex = "0.4" log = "0.4" env_logger = "0.11" diff --git a/creedflow-desktop/src-tauri/src/commands/chat.rs b/creedflow-desktop/src-tauri/src/commands/chat.rs index 9cc2897..aaa34f7 100644 --- a/creedflow-desktop/src-tauri/src/commands/chat.rs +++ b/creedflow-desktop/src-tauri/src/commands/chat.rs @@ -266,8 +266,11 @@ pub async fn stream_chat_response( created_at: now, }; - if let Ok(db) = state_db.try_lock() { - let _ = ProjectMessage::insert(&db.conn, &assistant_msg); + { + let db = state_db.lock().await; + if let Err(e) = ProjectMessage::insert(&db.conn, &assistant_msg) { + log::error!("Failed to save assistant message: {}", e); + } } let _ = app_clone.emit( diff --git a/creedflow-desktop/src-tauri/src/commands/projects.rs b/creedflow-desktop/src-tauri/src/commands/projects.rs index 7e44b1e..6ead38b 100644 --- a/creedflow-desktop/src-tauri/src/commands/projects.rs +++ b/creedflow-desktop/src-tauri/src/commands/projects.rs @@ -143,6 +143,22 @@ pub async fn get_project_time_stats( }) } +/// Validate that a path does not escape the allowed base directory via traversal. +fn validate_path_no_traversal(path: &str) -> Result { + let p = std::path::Path::new(path); + // Reject paths containing .. components + for component in p.components() { + if matches!(component, std::path::Component::ParentDir) { + return Err("Path traversal detected: '..' components are not allowed".to_string()); + } + } + // Ensure the path is absolute + if !p.is_absolute() { + return Err("Output path must be absolute".to_string()); + } + Ok(p.to_path_buf()) +} + /// Export project documentation (architecture, diagrams, summary) to a single markdown file. /// Useful for importing into NotebookLM or other documentation tools. #[tauri::command] @@ -151,6 +167,8 @@ pub async fn export_project_docs( id: String, output_path: String, ) -> Result { + validate_path_no_traversal(&output_path)?; + let db = state.db.lock().await; let project = Project::get(&db.conn, &id).map_err(|e| e.to_string())?; @@ -232,6 +250,8 @@ pub async fn export_project_zip( project_id: String, output_path: String, ) -> Result { + validate_path_no_traversal(&output_path)?; + let db = state.db.lock().await; let project = Project::get(&db.conn, &project_id).map_err(|e| e.to_string())?; @@ -341,28 +361,44 @@ pub async fn create_project_from_template( }; let db = state.db.lock().await; - Project::insert(&db.conn, &project).map_err(|e| e.to_string())?; - // Create features and tasks from template - for feature_tmpl in &template.features { - let feature_id = Uuid::new_v4().to_string(); - db.conn.execute( - "INSERT INTO feature (id, projectId, name, description, priority, status, createdAt, updatedAt) - VALUES (?1, ?2, ?3, ?4, 0, 'pending', ?5, ?5)", - params![feature_id, project_id, feature_tmpl.name, feature_tmpl.description, now], - ).map_err(|e| e.to_string())?; - - for task_tmpl in &feature_tmpl.tasks { - let task_id = Uuid::new_v4().to_string(); + // Wrap all inserts in a transaction for atomicity + db.conn.execute_batch("BEGIN").map_err(|e| e.to_string())?; + + let result = (|| -> Result<(), String> { + Project::insert(&db.conn, &project).map_err(|e| e.to_string())?; + + // Create features and tasks from template + for feature_tmpl in &template.features { + let feature_id = Uuid::new_v4().to_string(); db.conn.execute( - "INSERT INTO agentTask (id, projectId, featureId, agentType, title, description, priority, status, retryCount, maxRetries, createdAt, updatedAt) - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, 'queued', 0, 3, ?8, ?8)", - params![task_id, project_id, feature_id, task_tmpl.agent_type, task_tmpl.title, task_tmpl.description, task_tmpl.priority, now], + "INSERT INTO feature (id, projectId, name, description, priority, status, createdAt, updatedAt) + VALUES (?1, ?2, ?3, ?4, 0, 'pending', ?5, ?5)", + params![feature_id, project_id, feature_tmpl.name, feature_tmpl.description, now], ).map_err(|e| e.to_string())?; + + for task_tmpl in &feature_tmpl.tasks { + let task_id = Uuid::new_v4().to_string(); + db.conn.execute( + "INSERT INTO agentTask (id, projectId, featureId, agentType, title, description, priority, status, retryCount, maxRetries, createdAt, updatedAt) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, 'queued', 0, 3, ?8, ?8)", + params![task_id, project_id, feature_id, task_tmpl.agent_type, task_tmpl.title, task_tmpl.description, task_tmpl.priority, now], + ).map_err(|e| e.to_string())?; + } } - } + Ok(()) + })(); - Ok(project) + match result { + Ok(()) => { + db.conn.execute_batch("COMMIT").map_err(|e| e.to_string())?; + Ok(project) + } + Err(e) => { + let _ = db.conn.execute_batch("ROLLBACK"); + Err(e) + } + } } fn built_in_templates() -> Vec { diff --git a/creedflow-desktop/src-tauri/src/commands/tasks.rs b/creedflow-desktop/src-tauri/src/commands/tasks.rs index 7b97c37..79fc465 100644 --- a/creedflow-desktop/src-tauri/src/commands/tasks.rs +++ b/creedflow-desktop/src-tauri/src/commands/tasks.rs @@ -210,11 +210,15 @@ pub async fn retry_task_with_revision( revision_prompt: Option, ) -> Result<(), String> { let db = state.db.lock().await; - db.conn.execute( + let rows_affected = db.conn.execute( "UPDATE agentTask SET status = 'queued', retryCount = retryCount + 1, - revisionPrompt = ?2, updatedAt = datetime('now') WHERE id = ?1", + revisionPrompt = ?2, updatedAt = datetime('now') + WHERE id = ?1 AND status IN ('failed', 'needs_revision', 'cancelled')", params![id, revision_prompt], ).map_err(|e| e.to_string())?; + if rows_affected == 0 { + return Err("Task cannot be retried: only failed, needs_revision, or cancelled tasks can be retried".to_string()); + } Ok(()) } diff --git a/creedflow-desktop/src-tauri/src/services/health.rs b/creedflow-desktop/src-tauri/src/services/health.rs index 1479963..648b0f5 100644 --- a/creedflow-desktop/src-tauri/src/services/health.rs +++ b/creedflow-desktop/src-tauri/src/services/health.rs @@ -259,7 +259,14 @@ impl MCPHealthMonitor { let db_lock = db.lock().await; let mut stmt = db_lock.conn.prepare( "SELECT name, command, arguments, environmentVars FROM mcpServerConfig WHERE isEnabled = 1" - ).unwrap_or_else(|_| panic!("Failed to prepare MCP query")); + ).map_err(|e| { + log::error!("Failed to prepare MCP query: {}", e); + e + }).unwrap_or_else(|_| { + // Return an empty statement result on failure + log::error!("MCP health monitor: skipping cycle due to DB error"); + return db_lock.conn.prepare("SELECT NULL WHERE 0").expect("trivial query"); + }); stmt.query_map([], |row| { Ok(( row.get::<_, String>(0)?, diff --git a/creedflow-desktop/src-tauri/src/services/webhook_server.rs b/creedflow-desktop/src-tauri/src/services/webhook_server.rs index 9c382ed..c7fd3ab 100644 --- a/creedflow-desktop/src-tauri/src/services/webhook_server.rs +++ b/creedflow-desktop/src-tauri/src/services/webhook_server.rs @@ -48,12 +48,39 @@ impl WebhookServer { let db = db.clone(); tokio::spawn(async move { - let mut buf = vec![0u8; 65536]; - let n = match stream.read(&mut buf).await { - Ok(n) => n, - Err(_) => return, - }; - let request = String::from_utf8_lossy(&buf[..n]).to_string(); + const MAX_PAYLOAD: usize = 1_048_576; // 1MB max + let mut buf = Vec::with_capacity(65536); + let mut tmp = [0u8; 8192]; + loop { + match stream.read(&mut tmp).await { + Ok(0) => break, + Ok(n) => { + buf.extend_from_slice(&tmp[..n]); + if buf.len() > MAX_PAYLOAD { + let resp = http_response(413, r#"{"error":"Payload too large"}"#); + let _ = stream.write_all(resp.as_bytes()).await; + let _ = stream.shutdown().await; + return; + } + // If we've read the full HTTP request (headers + body), break + // Simple heuristic: check if we have \r\n\r\n (end of headers) + if buf.windows(4).any(|w| w == b"\r\n\r\n") { + // Check Content-Length to see if we have the full body + let text = String::from_utf8_lossy(&buf); + if let Some(cl) = get_content_length(&text) { + let header_end = text.find("\r\n\r\n").unwrap_or(0) + 4; + if buf.len() >= header_end + cl { + break; + } + } else { + break; // No Content-Length, assume complete + } + } + } + Err(_) => return, + } + } + let request = String::from_utf8_lossy(&buf).to_string(); let response = handle_request(&request, &api_key, &github_secret, &db).await; let _ = stream.write_all(response.as_bytes()).await; @@ -63,6 +90,15 @@ impl WebhookServer { } } +fn get_content_length(raw: &str) -> Option { + for line in raw.split("\r\n") { + if line.to_lowercase().starts_with("content-length:") { + return line[15..].trim().parse().ok(); + } + } + None +} + fn get_header<'a>(lines: &'a [&str], name: &str) -> Option<&'a str> { let lower = name.to_lowercase(); lines.iter() @@ -71,7 +107,8 @@ fn get_header<'a>(lines: &'a [&str], name: &str) -> Option<&'a str> { } fn verify_github_signature(secret: &str, body: &str, signature: &str) -> bool { - use std::fmt::Write; + use hmac::{Hmac, Mac}; + use sha2::Sha256; // signature format: sha256= let hex_sig = match signature.strip_prefix("sha256=") { @@ -79,28 +116,19 @@ fn verify_github_signature(secret: &str, body: &str, signature: &str) -> bool { None => return false, }; + // Decode the provided hex signature + let provided_sig = match hex::decode(hex_sig) { + Ok(s) => s, + Err(_) => return false, + }; + // Compute HMAC-SHA256 - // Simple HMAC implementation using ring-less approach - // For production, use hmac crate; here we do a basic check - let key_bytes = secret.as_bytes(); - let msg_bytes = body.as_bytes(); - - // HMAC-SHA256: H((K xor opad) || H((K xor ipad) || message)) - use std::collections::hash_map::DefaultHasher; - use std::hash::{Hash, Hasher}; - - // Simplified: just compare lengths as a basic gate, then do constant-time comparison - // In a real production app, use the `hmac` + `sha2` crates - // For now, we verify the format is correct and log the event - if hex_sig.len() != 64 { - return false; - } + let mut mac = Hmac::::new_from_slice(secret.as_bytes()) + .expect("HMAC accepts any key length"); + mac.update(body.as_bytes()); - // We accept the webhook if a secret is configured and signature format is valid - // Full HMAC verification requires crypto dependencies - log::info!("GitHub webhook signature present and format valid (full HMAC verification requires crypto crate)"); - let _ = (key_bytes, msg_bytes); - true + // Constant-time comparison via hmac crate's verify_slice + mac.verify_slice(&provided_sig).is_ok() } async fn handle_request( diff --git a/creedflow-desktop/src/App.tsx b/creedflow-desktop/src/App.tsx index ea810ad..770b44b 100644 --- a/creedflow-desktop/src/App.tsx +++ b/creedflow-desktop/src/App.tsx @@ -154,7 +154,7 @@ function App() { }; window.addEventListener("keydown", handleKeyDown); return () => window.removeEventListener("keydown", handleKeyDown); - }, [selectTask]); + }, [selectTask, showChatPanel]); // Listen to Tauri events for real-time task updates const handleTaskStatusChanged = useCallback( diff --git a/creedflow-desktop/src/components/git/GitGraphView.tsx b/creedflow-desktop/src/components/git/GitGraphView.tsx index 1dae197..bd93552 100644 --- a/creedflow-desktop/src/components/git/GitGraphView.tsx +++ b/creedflow-desktop/src/components/git/GitGraphView.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; import { gitLog, gitCurrentBranch, type GitLogEntry } from "../../tauri"; import { GitCommitRow, type LaneData } from "./GitCommitRow"; import { GitCommitDetail } from "./GitCommitDetail"; @@ -26,7 +26,7 @@ export function GitGraphView({ projectId }: GitGraphViewProps) { const [search, setSearch] = useState(""); const [selectedCommit, setSelectedCommit] = useState(null); - const fetchData = async () => { + const fetchData = useCallback(async () => { setLoading(true); setError(null); try { @@ -41,11 +41,11 @@ export function GitGraphView({ projectId }: GitGraphViewProps) { } finally { setLoading(false); } - }; + }, [projectId, count]); useEffect(() => { fetchData(); - }, [projectId, count]); + }, [fetchData]); // Extract all branch names from decorations const branches = useMemo(() => { diff --git a/creedflow-desktop/src/components/projects/ProjectDetailPanel.tsx b/creedflow-desktop/src/components/projects/ProjectDetailPanel.tsx index 21a8289..2685018 100644 --- a/creedflow-desktop/src/components/projects/ProjectDetailPanel.tsx +++ b/creedflow-desktop/src/components/projects/ProjectDetailPanel.tsx @@ -36,16 +36,19 @@ export function ProjectDetailPanel({ projectId, onClose, onViewTasks }: ProjectD const [preferredEditor, setPreferredEditor] = useState(null); useEffect(() => { + let cancelled = false; setLoadingTasks(true); + setTasks([]); api .listTasks(projectId) - .then(setTasks) - .catch((e) => showErrorToast("Failed to load tasks", e)) - .finally(() => setLoadingTasks(false)); - - api.gitCurrentBranch(projectId).then(setCurrentBranch).catch(() => {}); - api.detectEditors().then(setEditors).catch(() => {}); - api.getPreferredEditor().then(setPreferredEditor).catch(() => {}); + .then((t) => { if (!cancelled) setTasks(t); }) + .catch((e) => { if (!cancelled) showErrorToast("Failed to load tasks", e); }) + .finally(() => { if (!cancelled) setLoadingTasks(false); }); + + api.gitCurrentBranch(projectId).then((b) => { if (!cancelled) setCurrentBranch(b); }).catch(() => {}); + api.detectEditors().then((e) => { if (!cancelled) setEditors(e); }).catch(() => {}); + api.getPreferredEditor().then((e) => { if (!cancelled) setPreferredEditor(e); }).catch(() => {}); + return () => { cancelled = true; }; }, [projectId]); if (!project) { diff --git a/creedflow-desktop/src/components/publishing/PublishingView.tsx b/creedflow-desktop/src/components/publishing/PublishingView.tsx index 6205a85..3ed0da5 100644 --- a/creedflow-desktop/src/components/publishing/PublishingView.tsx +++ b/creedflow-desktop/src/components/publishing/PublishingView.tsx @@ -127,17 +127,17 @@ export function PublishingView() { {/* Tabs */}
- {(["channels", "publications"] as const).map((t) => ( + {(["channels", "publications"] as const).map((tabKey) => ( ))}
diff --git a/creedflow-desktop/src/components/shared/TerminalOutput.tsx b/creedflow-desktop/src/components/shared/TerminalOutput.tsx index c384739..f951dec 100644 --- a/creedflow-desktop/src/components/shared/TerminalOutput.tsx +++ b/creedflow-desktop/src/components/shared/TerminalOutput.tsx @@ -31,6 +31,19 @@ export function TerminalOutput({ taskId, initialContent }: TerminalOutputProps) const containerRef = useRef(null); const [autoScroll, setAutoScroll] = useState(true); + // Reset content when taskId changes + useEffect(() => { + if (initialContent) { + setLines(initialContent.split("\n").map((line) => ({ + type: "text" as const, + content: line, + timestamp: Date.now(), + }))); + } else { + setLines([]); + } + }, [taskId, initialContent]); + // Listen for live task output useEffect(() => { const unlisten = listen<{ diff --git a/creedflow-desktop/src/store/agentStore.ts b/creedflow-desktop/src/store/agentStore.ts index 6560cc0..eac7304 100644 --- a/creedflow-desktop/src/store/agentStore.ts +++ b/creedflow-desktop/src/store/agentStore.ts @@ -11,7 +11,11 @@ export const useAgentStore = create((set) => ({ agentTypes: [], fetchAgentTypes: async () => { - const agentTypes = await api.listAgentTypes(); - set({ agentTypes }); + try { + const agentTypes = await api.listAgentTypes(); + set({ agentTypes }); + } catch (e) { + console.error("Failed to fetch agent types:", e); + } }, })); diff --git a/creedflow-desktop/src/store/chatStore.ts b/creedflow-desktop/src/store/chatStore.ts index b70e081..88b0613 100644 --- a/creedflow-desktop/src/store/chatStore.ts +++ b/creedflow-desktop/src/store/chatStore.ts @@ -54,6 +54,7 @@ export const useChatStore = create((set, get) => ({ }, sendMessage: async (projectId: string, content: string) => { + let unlisten: UnlistenFn | null = null; try { set({ error: null }); const attachments = get().pendingAttachments; @@ -73,7 +74,6 @@ export const useChatStore = create((set, get) => ({ })); // Listen for streaming events - let unlisten: UnlistenFn | null = null; unlisten = await listen("chat-stream", (event) => { const data = event.payload; @@ -119,6 +119,7 @@ export const useChatStore = create((set, get) => ({ // Trigger the backend to start streaming await streamChatResponse(projectId, content, attachments); } catch (e) { + unlisten?.(); set({ error: String(e), isStreaming: false, streamingContent: "" }); } }, diff --git a/creedflow-desktop/src/store/notificationStore.ts b/creedflow-desktop/src/store/notificationStore.ts index f115edf..a910fa1 100644 --- a/creedflow-desktop/src/store/notificationStore.ts +++ b/creedflow-desktop/src/store/notificationStore.ts @@ -25,6 +25,9 @@ interface NotificationStore { setShowPanel: (show: boolean) => void; } +// Track active toast timeouts so they can be cleared on manual dismiss +const toastTimeouts = new Map>(); + export const useNotificationStore = create((set, get) => ({ notifications: [], unreadCount: 0, @@ -88,10 +91,12 @@ export const useNotificationStore = create((set, get) => ({ const toasts = [...s.toasts, notification].slice(-5); return { toasts }; }); - // Auto-remove after 5s - setTimeout(() => { + // Auto-remove after 5s (track timeout for cleanup) + const timeoutId = setTimeout(() => { + toastTimeouts.delete(notification.id); get().removeToast(notification.id); }, 5000); + toastTimeouts.set(notification.id, timeoutId); }, addUndoToast: (label, undoFn) => { @@ -117,16 +122,24 @@ export const useNotificationStore = create((set, get) => ({ })); // Auto-remove after 10s (longer grace period for undo) - setTimeout(() => { + const timeoutId = setTimeout(() => { + toastTimeouts.delete(id); set((s) => { const { [actionId]: _, ...rest } = s.actionCallbacks; return { actionCallbacks: rest }; }); get().removeToast(id); }, 10000); + toastTimeouts.set(id, timeoutId); }, removeToast: (id) => { + // Clear any pending auto-dismiss timeout + const existingTimeout = toastTimeouts.get(id); + if (existingTimeout) { + clearTimeout(existingTimeout); + toastTimeouts.delete(id); + } set((s) => ({ toasts: s.toasts.filter((t) => t.id !== id), })); diff --git a/creedflow-desktop/src/store/projectStore.ts b/creedflow-desktop/src/store/projectStore.ts index 71d5a52..669771b 100644 --- a/creedflow-desktop/src/store/projectStore.ts +++ b/creedflow-desktop/src/store/projectStore.ts @@ -66,11 +66,19 @@ export const useProjectStore = create((set, get) => ({ }, deleteProject: async (id) => { - await api.deleteProject(id); + const prev = get().projects; + // Optimistic removal set((s) => ({ projects: s.projects.filter((p) => p.id !== id), selectedProjectId: s.selectedProjectId === id ? null : s.selectedProjectId, })); + try { + await api.deleteProject(id); + } catch (e) { + // Revert on failure + set({ projects: prev }); + throw e; + } }, })); diff --git a/creedflow-desktop/src/store/settingsStore.ts b/creedflow-desktop/src/store/settingsStore.ts index c698100..45aa962 100644 --- a/creedflow-desktop/src/store/settingsStore.ts +++ b/creedflow-desktop/src/store/settingsStore.ts @@ -16,8 +16,12 @@ export const useSettingsStore = create((set) => ({ backends: [], fetchSettings: async () => { - const settings = await api.getSettings(); - set({ settings }); + try { + const settings = await api.getSettings(); + set({ settings }); + } catch (e) { + console.error("Failed to fetch settings:", e); + } }, updateSettings: async (settings) => { diff --git a/creedflow-desktop/src/store/taskStore.ts b/creedflow-desktop/src/store/taskStore.ts index 497e7a7..bdbd9ef 100644 --- a/creedflow-desktop/src/store/taskStore.ts +++ b/creedflow-desktop/src/store/taskStore.ts @@ -257,7 +257,8 @@ export const useTaskStore = create((set, get) => ({ return t && retryable.includes(t.status); }); if (ids.length === 0) return; - await api.batchRetryTasks(ids); + const prevTasks = tasks; + // Optimistic update set((s) => ({ tasks: s.tasks.map((t) => ids.includes(t.id) ? { ...t, status: "queued" as const, retryCount: t.retryCount + 1 } : t, @@ -265,6 +266,12 @@ export const useTaskStore = create((set, get) => ({ selectedIds: new Set(), selectionMode: false, })); + try { + await api.batchRetryTasks(ids); + } catch (e) { + set({ tasks: prevTasks }); + showErrorToast("Failed to retry tasks", e); + } }, batchCancel: async () => { @@ -274,7 +281,8 @@ export const useTaskStore = create((set, get) => ({ return t && t.status === "queued"; }); if (ids.length === 0) return; - await api.batchCancelTasks(ids); + const prevTasks = tasks; + // Optimistic update set((s) => ({ tasks: s.tasks.map((t) => ids.includes(t.id) ? { ...t, status: "cancelled" as const } : t, @@ -282,5 +290,11 @@ export const useTaskStore = create((set, get) => ({ selectedIds: new Set(), selectionMode: false, })); + try { + await api.batchCancelTasks(ids); + } catch (e) { + set({ tasks: prevTasks }); + showErrorToast("Failed to cancel tasks", e); + } }, })); diff --git a/screen/screen_record.mp4 b/screen/screen_record.mp4 new file mode 100644 index 0000000..f76d8f1 Binary files /dev/null and b/screen/screen_record.mp4 differ