Skip to main content

agent_orchestrator/
task_ops.rs

1use crate::config::LoopMode;
2use crate::config_load::build_execution_plan_for_project;
3use crate::config_load::{now_ts, read_active_config};
4use crate::db::open_conn;
5use crate::dto::{CreateRunStepPayload, CreateTaskPayload, TaskSummary, UNASSIGNED_QA_FILE_PATH};
6use crate::task_repository::{SqliteTaskRepository, TaskQueryRepository};
7use crate::ticket::{collect_target_files, collect_target_files_from_active_tickets};
8use anyhow::{Context, Result};
9use chrono::Utc;
10use rusqlite::params;
11use uuid::Uuid;
12
13#[derive(Debug, Clone, Copy, PartialEq, Eq)]
14enum TargetSeedStrategy {
15    Explicit,
16    ActiveTickets,
17    QaDirectoryScan,
18    SyntheticAnchor,
19}
20
21#[derive(Debug, Clone, PartialEq, Eq)]
22struct ResolvedTaskTargets {
23    persisted_target_files: Vec<String>,
24    task_item_paths: Vec<String>,
25}
26
27fn execution_plan_requires_item_targets(plan: &crate::config::TaskExecutionPlan) -> bool {
28    plan.steps
29        .iter()
30        .any(|step| step.enabled && step.resolved_scope() == crate::config::StepScope::Item)
31}
32
33fn select_target_seed_strategy(
34    explicit_targets: Option<&Vec<String>>,
35    plan: &crate::config::TaskExecutionPlan,
36) -> TargetSeedStrategy {
37    if explicit_targets.is_some() {
38        TargetSeedStrategy::Explicit
39    } else if !execution_plan_requires_item_targets(plan) {
40        TargetSeedStrategy::SyntheticAnchor
41    } else if plan.step_by_id("qa").is_none() && plan.step_by_id("ticket_scan").is_some() {
42        TargetSeedStrategy::ActiveTickets
43    } else {
44        TargetSeedStrategy::QaDirectoryScan
45    }
46}
47
48fn resolve_task_targets(
49    workspace: &crate::config::ResolvedWorkspace,
50    plan: &crate::config::TaskExecutionPlan,
51    explicit_targets: Option<Vec<String>>,
52) -> Result<ResolvedTaskTargets> {
53    let requires_item_targets = execution_plan_requires_item_targets(plan);
54    match select_target_seed_strategy(explicit_targets.as_ref(), plan) {
55        TargetSeedStrategy::Explicit => {
56            let validated = collect_target_files(
57                &workspace.root_path,
58                &workspace.qa_targets,
59                explicit_targets,
60            )?;
61            if requires_item_targets {
62                if validated.is_empty() {
63                    anyhow::bail!("no valid --target-file entries found");
64                }
65                Ok(ResolvedTaskTargets {
66                    persisted_target_files: validated.clone(),
67                    task_item_paths: validated,
68                })
69            } else {
70                match validated.len() {
71                    0 => anyhow::bail!("no valid --target-file entries found"),
72                    1 => Ok(ResolvedTaskTargets {
73                        persisted_target_files: validated.clone(),
74                        task_item_paths: validated,
75                    }),
76                    _ => anyhow::bail!("task-scoped workflow accepts at most one --target-file"),
77                }
78            }
79        }
80        TargetSeedStrategy::ActiveTickets => {
81            let mut targets = collect_target_files_from_active_tickets(
82                &workspace.root_path,
83                &workspace.ticket_dir,
84            )?;
85            if targets.is_empty() {
86                targets.push(UNASSIGNED_QA_FILE_PATH.to_string());
87            }
88            Ok(ResolvedTaskTargets {
89                persisted_target_files: targets.clone(),
90                task_item_paths: targets,
91            })
92        }
93        TargetSeedStrategy::QaDirectoryScan => {
94            let targets = collect_target_files(&workspace.root_path, &workspace.qa_targets, None)?;
95            if targets.is_empty() {
96                anyhow::bail!("No QA/Security markdown files found for item-scoped workflow");
97            }
98            Ok(ResolvedTaskTargets {
99                persisted_target_files: targets.clone(),
100                task_item_paths: targets,
101            })
102        }
103        TargetSeedStrategy::SyntheticAnchor => Ok(ResolvedTaskTargets {
104            persisted_target_files: Vec::new(),
105            task_item_paths: vec![UNASSIGNED_QA_FILE_PATH.to_string()],
106        }),
107    }
108}
109
110/// Creates a task, its execution plan snapshot, and initial task items.
111pub fn create_task_impl(
112    state: &crate::state::InnerState,
113    payload: CreateTaskPayload,
114) -> Result<TaskSummary> {
115    let active = read_active_config(state)?;
116
117    let project_id = payload
118        .project_id
119        .clone()
120        .unwrap_or_else(|| crate::config::DEFAULT_PROJECT_ID.to_string());
121    let project = active
122        .projects
123        .get(&project_id)
124        .with_context(|| format!("project not found: {}", project_id))?;
125
126    let workspace_id = if let Some(workspace_id) = payload.workspace_id.clone() {
127        workspace_id
128    } else {
129        resolve_default_resource_id(&project.workspaces, "workspace")?
130    };
131    let workspace = project
132        .workspaces
133        .get(&workspace_id)
134        .cloned()
135        .with_context(|| {
136            format!(
137                "workspace not found: {} in project '{}'",
138                workspace_id, project_id
139            )
140        })?;
141
142    let workflow_id = if let Some(workflow_id) = payload.workflow_id.clone() {
143        workflow_id
144    } else {
145        resolve_default_resource_id(&project.workflows, "workflow")?
146    };
147    let workflow = project
148        .workflows
149        .get(&workflow_id)
150        .cloned()
151        .with_context(|| {
152            format!(
153                "workflow not found: {} in project '{}'",
154                workflow_id, project_id
155            )
156        })?;
157
158    let execution_plan =
159        build_execution_plan_for_project(&active.config, &workflow, &workflow_id, &project_id)?;
160    let execution_plan_json =
161        serde_json::to_string(&execution_plan).context("serialize execution plan")?;
162    let loop_mode = match execution_plan.loop_policy.mode {
163        LoopMode::Once => "once",
164        LoopMode::Fixed => "fixed",
165        LoopMode::Infinite => "infinite",
166    };
167
168    // FR-090: Validate step_filter against execution plan
169    let step_filter_json = if let Some(ref filter) = payload.step_filter {
170        if !filter.is_empty() {
171            let known_ids: std::collections::HashSet<&str> =
172                execution_plan.steps.iter().map(|s| s.id.as_str()).collect();
173            for id in filter {
174                if !known_ids.contains(id.as_str()) {
175                    anyhow::bail!(
176                        "unknown step id '{}' in --step filter; available steps: {}",
177                        id,
178                        known_ids.into_iter().collect::<Vec<_>>().join(", ")
179                    );
180                }
181            }
182            serde_json::to_string(filter).unwrap_or_default()
183        } else {
184            String::new()
185        }
186    } else {
187        String::new()
188    };
189
190    // FR-090: Serialize initial_vars
191    let initial_vars_json = if let Some(ref vars) = payload.initial_vars {
192        if !vars.is_empty() {
193            serde_json::to_string(vars).unwrap_or_default()
194        } else {
195            String::new()
196        }
197    } else {
198        String::new()
199    };
200
201    let resolved_targets = resolve_task_targets(&workspace, &execution_plan, payload.target_files)?;
202
203    let task_id = Uuid::new_v4().to_string();
204    let created_at = now_ts();
205    let task_name = payload
206        .name
207        .unwrap_or_else(|| format!("QA Sprint {}", Utc::now().format("%Y-%m-%d %H:%M:%S")));
208    let goal = payload
209        .goal
210        .unwrap_or_else(|| "Automated QA workflow with fix and resume".to_string());
211
212    let conn = open_conn(&state.db_path)?;
213    let tx = conn.unchecked_transaction()?;
214    tx.execute(
215        "INSERT INTO tasks (id, name, status, started_at, completed_at, goal, target_files_json, mode, project_id, workspace_id, workflow_id, workspace_root, qa_targets_json, ticket_dir, execution_plan_json, loop_mode, current_cycle, init_done, resume_token, created_at, updated_at, parent_task_id, spawn_reason, spawn_depth, step_filter_json, initial_vars_json) VALUES (?1, ?2, 'created', NULL, NULL, ?3, ?4, '', ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, 0, 0, NULL, ?13, ?13, ?14, ?15, 0, ?16, ?17)",
216        params![
217            task_id,
218            task_name,
219            goal,
220            serde_json::to_string(&resolved_targets.persisted_target_files)?,
221            project_id,
222            workspace_id,
223            workflow_id,
224            workspace.root_path.to_string_lossy().to_string(),
225            serde_json::to_string(&workspace.qa_targets)?,
226            workspace.ticket_dir,
227            execution_plan_json,
228            loop_mode,
229            created_at,
230            payload.parent_task_id,
231            payload.spawn_reason,
232            step_filter_json,
233            initial_vars_json,
234        ],
235    )?;
236
237    for (idx, path) in resolved_targets.task_item_paths.iter().enumerate() {
238        let item_id = Uuid::new_v4().to_string();
239        tx.execute(
240            "INSERT INTO task_items (id, task_id, order_no, qa_file_path, status, ticket_files_json, ticket_content_json, fix_required, fixed, last_error, started_at, completed_at, created_at, updated_at) VALUES (?1, ?2, ?3, ?4, 'pending', '[]', '[]', 0, 0, '', NULL, NULL, ?5, ?5)",
241            params![item_id, task_id, (idx as i64) + 1, path, created_at],
242        )?;
243    }
244    tx.commit()?;
245
246    let repo = SqliteTaskRepository::new(state.db_path.clone());
247    let mut summary = repo.load_task_summary(&task_id)?;
248    let (total, finished, failed) = repo.load_task_item_counts(&task_id)?;
249    summary.total_items = total;
250    summary.finished_items = finished;
251    summary.failed_items = failed;
252    Ok(summary)
253}
254
255/// FR-090 Phase 3: Create a task from a direct step template + agent capability
256/// without requiring a pre-defined workflow.
257pub fn create_run_step_task(
258    state: &crate::state::InnerState,
259    payload: CreateRunStepPayload,
260) -> Result<TaskSummary> {
261    use crate::config::{
262        StepBehavior, TaskExecutionPlan, TaskExecutionStep, WorkflowFinalizeConfig,
263        WorkflowLoopConfig,
264    };
265
266    let active = read_active_config(state)?;
267
268    let project_id = payload
269        .project_id
270        .unwrap_or_else(|| crate::config::DEFAULT_PROJECT_ID.to_string());
271    let project = active
272        .projects
273        .get(&project_id)
274        .with_context(|| format!("project not found: {}", project_id))?;
275
276    let workspace_id = if let Some(ws) = payload.workspace_id {
277        ws
278    } else {
279        resolve_default_resource_id(&project.workspaces, "workspace")?
280    };
281    let workspace = project
282        .workspaces
283        .get(&workspace_id)
284        .cloned()
285        .with_context(|| format!("workspace not found: {}", workspace_id))?;
286
287    // Validate template exists
288    if !project.step_templates.contains_key(&payload.template) {
289        anyhow::bail!(
290            "step template '{}' not found in project '{}'; available templates: {}",
291            payload.template,
292            project_id,
293            project
294                .step_templates
295                .keys()
296                .cloned()
297                .collect::<Vec<_>>()
298                .join(", ")
299        );
300    }
301
302    // Validate at least one agent has the capability
303    let has_cap = project.agents.values().any(|a| {
304        a.capabilities
305            .iter()
306            .any(|c| c == &payload.agent_capability)
307    });
308    if !has_cap {
309        anyhow::bail!(
310            "no agent in project '{}' has capability '{}'",
311            project_id,
312            payload.agent_capability,
313        );
314    }
315
316    // Build single-step execution plan
317    let step = TaskExecutionStep {
318        id: payload.template.clone(),
319        required_capability: Some(payload.agent_capability),
320        template: Some(payload.template.clone()),
321        execution_profile: payload.execution_profile,
322        builtin: None,
323        enabled: true,
324        repeatable: false,
325        is_guard: false,
326        cost_preference: None,
327        prehook: None,
328        tty: false,
329        outputs: Vec::new(),
330        pipe_to: None,
331        command: None,
332        chain_steps: Vec::new(),
333        scope: None,
334        behavior: StepBehavior::default(),
335        max_parallel: None,
336        stagger_delay_ms: None,
337        timeout_secs: None,
338        stall_timeout_secs: None,
339        item_select_config: None,
340        store_inputs: Vec::new(),
341        store_outputs: Vec::new(),
342        step_vars: None,
343    };
344
345    let execution_plan = TaskExecutionPlan {
346        steps: vec![step],
347        loop_policy: WorkflowLoopConfig {
348            mode: LoopMode::Once,
349            ..Default::default()
350        },
351        finalize: WorkflowFinalizeConfig::default(),
352        max_parallel: None,
353        stagger_delay_ms: None,
354        item_isolation: None,
355    };
356
357    let execution_plan_json =
358        serde_json::to_string(&execution_plan).context("serialize execution plan")?;
359
360    let initial_vars_json = if let Some(ref vars) = payload.initial_vars {
361        if !vars.is_empty() {
362            serde_json::to_string(vars).unwrap_or_default()
363        } else {
364            String::new()
365        }
366    } else {
367        String::new()
368    };
369
370    let resolved_targets = resolve_task_targets(&workspace, &execution_plan, payload.target_files)?;
371
372    let task_id = Uuid::new_v4().to_string();
373    let created_at = now_ts();
374    let task_name = format!("run:{}", payload.template);
375    let goal = format!("Direct step execution: {}", payload.template);
376    let workflow_id = format!("_ephemeral:{}", payload.template);
377
378    let conn = open_conn(&state.db_path)?;
379    let tx = conn.unchecked_transaction()?;
380    tx.execute(
381        "INSERT INTO tasks (id, name, status, started_at, completed_at, goal, target_files_json, mode, project_id, workspace_id, workflow_id, workspace_root, qa_targets_json, ticket_dir, execution_plan_json, loop_mode, current_cycle, init_done, resume_token, created_at, updated_at, parent_task_id, spawn_reason, spawn_depth, step_filter_json, initial_vars_json) VALUES (?1, ?2, 'created', NULL, NULL, ?3, ?4, '', ?5, ?6, ?7, ?8, ?9, ?10, ?11, 'once', 0, 0, NULL, ?12, ?12, NULL, NULL, 0, '', ?13)",
382        params![
383            task_id,
384            task_name,
385            goal,
386            serde_json::to_string(&resolved_targets.persisted_target_files)?,
387            project_id,
388            workspace_id,
389            workflow_id,
390            workspace.root_path.to_string_lossy().to_string(),
391            serde_json::to_string(&workspace.qa_targets)?,
392            workspace.ticket_dir,
393            execution_plan_json,
394            created_at,
395            initial_vars_json,
396        ],
397    )?;
398
399    for (idx, path) in resolved_targets.task_item_paths.iter().enumerate() {
400        let item_id = Uuid::new_v4().to_string();
401        tx.execute(
402            "INSERT INTO task_items (id, task_id, order_no, qa_file_path, status, ticket_files_json, ticket_content_json, fix_required, fixed, last_error, started_at, completed_at, created_at, updated_at) VALUES (?1, ?2, ?3, ?4, 'pending', '[]', '[]', 0, 0, '', NULL, NULL, ?5, ?5)",
403            params![item_id, task_id, (idx as i64) + 1, path, created_at],
404        )?;
405    }
406    tx.commit()?;
407
408    let repo = SqliteTaskRepository::new(state.db_path.clone());
409    let mut summary = repo.load_task_summary(&task_id)?;
410    let (total, finished, failed) = repo.load_task_item_counts(&task_id)?;
411    summary.total_items = total;
412    summary.finished_items = finished;
413    summary.failed_items = failed;
414    Ok(summary)
415}
416
417fn resolve_default_resource_id<T>(
418    entries: &std::collections::HashMap<String, T>,
419    resource_kind: &str,
420) -> Result<String> {
421    if entries.is_empty() {
422        anyhow::bail!("project has no {}s configured", resource_kind);
423    }
424    if entries.len() == 1 {
425        return Ok(entries.keys().next().cloned().unwrap_or_default());
426    }
427    if entries.contains_key("default") {
428        return Ok("default".to_string());
429    }
430    anyhow::bail!(
431        "multiple {}s exist in project; specify --{} explicitly",
432        resource_kind,
433        resource_kind
434    )
435}
436
437/// Resets one failed task item back to the pending state and returns its parent task id.
438///
439/// Accepts an exact task-item ID or a unique prefix (same behaviour as
440/// `resolve_task_id` for tasks).
441pub fn reset_task_item_for_retry(
442    state: &crate::state::InnerState,
443    task_item_id: &str,
444) -> Result<String> {
445    let conn = open_conn(&state.db_path)?;
446    let resolved_id = resolve_task_item_id(&conn, task_item_id)?;
447    let task_id: String = conn.query_row(
448        "SELECT task_id FROM task_items WHERE id = ?1",
449        params![resolved_id],
450        |row| row.get(0),
451    )?;
452    let tx = conn.unchecked_transaction()?;
453    tx.execute(
454        "UPDATE task_items SET status = 'pending', ticket_files_json = '[]', ticket_content_json = '[]', fix_required = 0, fixed = 0, last_error = '', started_at = NULL, completed_at = NULL, updated_at = ?2 WHERE id = ?1",
455        params![resolved_id, now_ts()],
456    )?;
457    // Clear old command runs so compensation doesn't re-finalize with stale results
458    tx.execute(
459        "DELETE FROM command_runs WHERE task_item_id = ?1",
460        params![resolved_id],
461    )?;
462    tx.commit()?;
463    Ok(task_id)
464}
465
466/// Resolve a task-item ID from an exact match or unique prefix.
467fn resolve_task_item_id(conn: &rusqlite::Connection, id_or_prefix: &str) -> Result<String> {
468    use rusqlite::OptionalExtension;
469    let exact: Option<String> = conn
470        .query_row(
471            "SELECT id FROM task_items WHERE id = ?1",
472            params![id_or_prefix],
473            |row| row.get(0),
474        )
475        .optional()?;
476    if let Some(id) = exact {
477        return Ok(id);
478    }
479    let pattern = format!("{}%", id_or_prefix);
480    let mut stmt = conn.prepare("SELECT id FROM task_items WHERE id LIKE ?1")?;
481    let matches: Vec<String> = stmt
482        .query_map(params![pattern], |row| row.get(0))?
483        .collect::<std::result::Result<Vec<_>, _>>()?;
484    match matches.len() {
485        1 => Ok(matches
486            .into_iter()
487            .next()
488            .ok_or_else(|| anyhow::anyhow!("unexpected empty matches"))?),
489        0 => anyhow::bail!("task item not found: {}", id_or_prefix),
490        _ => anyhow::bail!(
491            "multiple task items match prefix '{}': {:?}",
492            id_or_prefix,
493            matches
494        ),
495    }
496}
497
498/// Service-layer wrapper around [`create_task_impl`] with error classification.
499///
500/// This exists so that core modules (trigger_engine, service/resource) can
501/// create tasks without depending on the `orchestrator-scheduler` service layer.
502pub fn create_task_as_service(
503    state: &crate::state::InnerState,
504    payload: CreateTaskPayload,
505) -> crate::error::Result<TaskSummary> {
506    create_task_impl(state, payload)
507        .map_err(|err| crate::error::classify_task_error("task.create", err))
508}
509
510#[cfg(test)]
511mod tests {
512    use super::*;
513    use crate::config::{
514        LoopMode, ProjectConfig, ResolvedProject, SafetyConfig, StepBehavior, WorkflowConfig,
515        WorkflowFinalizeConfig, WorkflowLoopConfig, WorkflowLoopGuardConfig, WorkflowStepConfig,
516    };
517    use crate::dto::CreateTaskPayload;
518    use crate::state::update_config_runtime;
519    use crate::test_utils::TestState;
520    use std::collections::HashMap;
521
522    fn make_workflow(steps: Vec<WorkflowStepConfig>) -> WorkflowConfig {
523        WorkflowConfig {
524            steps,
525            execution: Default::default(),
526            loop_policy: WorkflowLoopConfig {
527                mode: LoopMode::Once,
528                guard: WorkflowLoopGuardConfig {
529                    enabled: false,
530                    stop_when_no_unresolved: false,
531                    max_cycles: None,
532                    agent_template: None,
533                },
534                convergence_expr: None,
535            },
536            finalize: WorkflowFinalizeConfig { rules: vec![] },
537            qa: None,
538            fix: None,
539            retest: None,
540            dynamic_steps: vec![],
541            adaptive: None,
542            safety: SafetyConfig::default(),
543            max_parallel: None,
544            stagger_delay_ms: None,
545            item_isolation: None,
546        }
547    }
548
549    fn make_step(
550        id: &str,
551        builtin: Option<&str>,
552        required_capability: Option<&str>,
553    ) -> WorkflowStepConfig {
554        WorkflowStepConfig {
555            id: id.to_string(),
556            description: None,
557            builtin: builtin.map(str::to_string),
558            required_capability: required_capability.map(str::to_string),
559            template: None,
560            execution_profile: None,
561            enabled: true,
562            repeatable: false,
563            is_guard: false,
564            cost_preference: None,
565            prehook: None,
566            tty: false,
567            outputs: Vec::new(),
568            pipe_to: None,
569            command: None,
570            chain_steps: vec![],
571            scope: None,
572            behavior: StepBehavior::default(),
573            max_parallel: None,
574            stagger_delay_ms: None,
575            timeout_secs: None,
576            stall_timeout_secs: None,
577            item_select_config: None,
578            store_inputs: vec![],
579            store_outputs: vec![],
580            step_vars: None,
581        }
582    }
583
584    fn task_only_workflow() -> WorkflowConfig {
585        make_workflow(vec![make_step("self_test", Some("self_test"), None)])
586    }
587
588    fn ticket_seed_workflow() -> WorkflowConfig {
589        make_workflow(vec![make_step("ticket_scan", Some("ticket_scan"), None)])
590    }
591
592    fn load_task_storage(
593        state: &crate::state::InnerState,
594        task_id: &str,
595    ) -> (Vec<String>, Vec<String>) {
596        let conn = open_conn(&state.db_path).expect("open task storage database");
597        let target_files_json: String = conn
598            .query_row(
599                "SELECT target_files_json FROM tasks WHERE id = ?1",
600                params![task_id],
601                |row| row.get(0),
602            )
603            .expect("load serialized target_files");
604        let target_files = serde_json::from_str::<Vec<String>>(&target_files_json)
605            .expect("deserialize target_files");
606        let mut stmt = conn
607            .prepare("SELECT qa_file_path FROM task_items WHERE task_id = ?1 ORDER BY order_no")
608            .expect("prepare task item query");
609        let item_paths = stmt
610            .query_map(params![task_id], |row| row.get::<_, String>(0))
611            .expect("query task item paths")
612            .collect::<rusqlite::Result<Vec<_>>>()
613            .expect("collect task item paths");
614        (target_files, item_paths)
615    }
616
617    #[test]
618    fn create_task_with_defaults() {
619        let mut ts = TestState::new();
620        let state = ts.build();
621
622        // Create a QA file so target_files is non-empty
623        let active = crate::config_load::read_active_config(&state).expect("read active config");
624        let ws = active
625            .workspaces
626            .get("default")
627            .expect("default workspace should exist");
628        let qa_dir = &ws.qa_targets[0];
629        let qa_path = ws.root_path.join(qa_dir);
630        std::fs::create_dir_all(&qa_path).ok();
631        std::fs::write(qa_path.join("test-qa.md"), "# QA Test\n").expect("write qa file");
632        drop(active);
633
634        let payload = CreateTaskPayload {
635            name: None,
636            goal: None,
637            project_id: None,
638            workspace_id: None,
639            workflow_id: None,
640            target_files: None,
641            parent_task_id: None,
642            spawn_reason: None,
643            step_filter: None,
644            initial_vars: None,
645        };
646        let result = create_task_impl(&state, payload);
647        assert!(
648            result.is_ok(),
649            "create_task_impl should succeed: {:?}",
650            result.err()
651        );
652        let summary = result.expect("create_task_impl should produce summary");
653        assert_eq!(summary.status, "created");
654        assert!(!summary.id.is_empty());
655        assert!(summary.name.starts_with("QA Sprint"));
656        assert_eq!(summary.goal, "Automated QA workflow with fix and resume");
657        assert_eq!(summary.workspace_id, "default");
658        assert_eq!(summary.workflow_id, "basic");
659        assert!(summary.total_items >= 1);
660    }
661
662    #[test]
663    fn create_task_with_custom_name_and_goal() {
664        let mut ts = TestState::new();
665        let state = ts.build();
666
667        let active = crate::config_load::read_active_config(&state).expect("read active config");
668        let ws = active
669            .workspaces
670            .get("default")
671            .expect("default workspace should exist");
672        let qa_dir = &ws.qa_targets[0];
673        let qa_path = ws.root_path.join(qa_dir);
674        std::fs::create_dir_all(&qa_path).ok();
675        std::fs::write(qa_path.join("custom-qa.md"), "# Custom QA\n")
676            .expect("write custom qa file");
677        drop(active);
678
679        let payload = CreateTaskPayload {
680            name: Some("My Custom Task".to_string()),
681            goal: Some("Custom goal description".to_string()),
682            project_id: None,
683            workspace_id: None,
684            workflow_id: None,
685            target_files: None,
686            parent_task_id: None,
687            spawn_reason: None,
688            step_filter: None,
689            initial_vars: None,
690        };
691        let result = create_task_impl(&state, payload).expect("create custom task");
692        assert_eq!(result.name, "My Custom Task");
693        assert_eq!(result.goal, "Custom goal description");
694    }
695
696    #[test]
697    fn create_task_with_nonexistent_workspace_fails() {
698        let mut ts = TestState::new();
699        let state = ts.build();
700
701        let payload = CreateTaskPayload {
702            name: None,
703            goal: None,
704            project_id: None,
705            workspace_id: Some("nonexistent-ws".to_string()),
706            workflow_id: None,
707            target_files: None,
708            parent_task_id: None,
709            spawn_reason: None,
710            step_filter: None,
711            initial_vars: None,
712        };
713        let result = create_task_impl(&state, payload);
714        assert!(result.is_err());
715        let err = result.expect_err("operation should fail").to_string();
716        assert!(
717            err.contains("workspace not found"),
718            "unexpected error: {}",
719            err
720        );
721    }
722
723    #[test]
724    fn create_task_with_nonexistent_workflow_fails() {
725        let mut ts = TestState::new();
726        let state = ts.build();
727
728        let payload = CreateTaskPayload {
729            name: None,
730            goal: None,
731            project_id: None,
732            workspace_id: None,
733            workflow_id: Some("nonexistent-wf".to_string()),
734            target_files: None,
735            parent_task_id: None,
736            spawn_reason: None,
737            step_filter: None,
738            initial_vars: None,
739        };
740        let result = create_task_impl(&state, payload);
741        assert!(result.is_err());
742        let err = result.expect_err("operation should fail").to_string();
743        assert!(
744            err.contains("workflow not found"),
745            "unexpected error: {}",
746            err
747        );
748    }
749
750    #[test]
751    fn create_task_item_scoped_workflow_with_no_qa_files_fails() {
752        let mut ts = TestState::new();
753        let state = ts.build();
754
755        // Don't create any qa files - the qa_targets dirs exist but are empty
756        let payload = CreateTaskPayload {
757            name: None,
758            goal: None,
759            project_id: None,
760            workspace_id: None,
761            workflow_id: None,
762            target_files: None,
763            parent_task_id: None,
764            spawn_reason: None,
765            step_filter: None,
766            initial_vars: None,
767        };
768        let result = create_task_impl(&state, payload);
769        assert!(result.is_err());
770        let err = result.expect_err("operation should fail").to_string();
771        assert!(
772            err.contains("No QA/Security markdown files found for item-scoped workflow"),
773            "unexpected error: {}",
774            err
775        );
776    }
777
778    #[test]
779    fn create_task_with_explicit_target_files() {
780        let mut ts = TestState::new();
781        let state = ts.build();
782
783        // Create target files
784        let active = crate::config_load::read_active_config(&state).expect("read active config");
785        let ws = active
786            .workspaces
787            .get("default")
788            .expect("default workspace should exist");
789        let qa_dir = &ws.qa_targets[0];
790        let qa_path = ws.root_path.join(qa_dir);
791        std::fs::create_dir_all(&qa_path).ok();
792        let file1 = qa_path.join("file1.md");
793        let file2 = qa_path.join("file2.md");
794        std::fs::write(&file1, "# File 1\n").expect("write file1");
795        std::fs::write(&file2, "# File 2\n").expect("write file2");
796        let rel1 = format!("{}/file1.md", qa_dir);
797        let rel2 = format!("{}/file2.md", qa_dir);
798        drop(active);
799
800        let payload = CreateTaskPayload {
801            name: Some("Targeted".to_string()),
802            goal: None,
803            project_id: None,
804            workspace_id: None,
805            workflow_id: None,
806            target_files: Some(vec![rel1, rel2]),
807            parent_task_id: None,
808            spawn_reason: None,
809            step_filter: None,
810            initial_vars: None,
811        };
812        let result = create_task_impl(&state, payload).expect("create targeted task");
813        assert_eq!(result.total_items, 2, "should have 2 task items");
814        let (target_files, item_paths) = load_task_storage(&state, &result.id);
815        assert_eq!(target_files.len(), 2);
816        assert_eq!(item_paths.len(), 2);
817    }
818
819    #[test]
820    fn create_task_item_scoped_workflow_with_explicit_non_markdown_target_succeeds() {
821        let mut ts = TestState::new();
822        let state = ts.build();
823
824        let active = crate::config_load::read_active_config(&state).expect("read active config");
825        let ws = active
826            .workspaces
827            .get("default")
828            .expect("default workspace should exist");
829        let src_path = ws.root_path.join("src");
830        std::fs::create_dir_all(&src_path).ok();
831        std::fs::write(src_path.join("lib.rs"), "fn main() {}\n").expect("write lib.rs");
832        drop(active);
833
834        let payload = CreateTaskPayload {
835            name: Some("Targeted Source".to_string()),
836            goal: None,
837            project_id: None,
838            workspace_id: None,
839            workflow_id: None,
840            target_files: Some(vec!["src/lib.rs".to_string()]),
841            parent_task_id: None,
842            spawn_reason: None,
843            step_filter: None,
844            initial_vars: None,
845        };
846        let result = create_task_impl(&state, payload).expect("create source task");
847        assert_eq!(result.total_items, 1);
848        let (target_files, item_paths) = load_task_storage(&state, &result.id);
849        assert_eq!(target_files, vec!["src/lib.rs".to_string()]);
850        assert_eq!(item_paths, vec!["src/lib.rs".to_string()]);
851    }
852
853    #[test]
854    fn create_task_task_scoped_workflow_without_qa_files_uses_synthetic_anchor() {
855        let mut ts = TestState::new().with_workflow("task_only", task_only_workflow());
856        let state = ts.build();
857
858        let payload = CreateTaskPayload {
859            name: Some("Task Only".to_string()),
860            goal: None,
861            project_id: None,
862            workspace_id: None,
863            workflow_id: Some("task_only".to_string()),
864            target_files: None,
865            parent_task_id: None,
866            spawn_reason: None,
867            step_filter: None,
868            initial_vars: None,
869        };
870        let result = create_task_impl(&state, payload).expect("create task-scoped task");
871        assert_eq!(result.total_items, 1);
872        let (target_files, item_paths) = load_task_storage(&state, &result.id);
873        assert!(target_files.is_empty());
874        assert_eq!(item_paths, vec![UNASSIGNED_QA_FILE_PATH.to_string()]);
875    }
876
877    #[test]
878    fn create_task_task_scoped_workflow_with_single_explicit_target_succeeds() {
879        let mut ts = TestState::new().with_workflow("task_only", task_only_workflow());
880        let state = ts.build();
881
882        let active = crate::config_load::read_active_config(&state).expect("read active config");
883        let ws = active
884            .workspaces
885            .get("default")
886            .expect("default workspace should exist");
887        let src_path = ws.root_path.join("src");
888        std::fs::create_dir_all(&src_path).ok();
889        std::fs::write(src_path.join("lib.rs"), "fn main() {}\n").expect("write lib.rs");
890        drop(active);
891
892        let payload = CreateTaskPayload {
893            name: Some("Task Only Target".to_string()),
894            goal: None,
895            project_id: None,
896            workspace_id: None,
897            workflow_id: Some("task_only".to_string()),
898            target_files: Some(vec!["src/lib.rs".to_string()]),
899            parent_task_id: None,
900            spawn_reason: None,
901            step_filter: None,
902            initial_vars: None,
903        };
904        let result = create_task_impl(&state, payload).expect("create task-only targeted task");
905        assert_eq!(result.total_items, 1);
906        let (target_files, item_paths) = load_task_storage(&state, &result.id);
907        assert_eq!(target_files, vec!["src/lib.rs".to_string()]);
908        assert_eq!(item_paths, vec!["src/lib.rs".to_string()]);
909    }
910
911    #[test]
912    fn create_task_with_empty_project_rejects_missing_workspace() {
913        let mut ts = TestState::new().with_workflow("task_only", task_only_workflow());
914        let state = ts.build();
915
916        update_config_runtime(&state, |current| {
917            let mut next = current.clone();
918            std::sync::Arc::make_mut(&mut next.active_config)
919                .config
920                .projects
921                .insert(
922                    "proj-a".to_string(),
923                    ProjectConfig {
924                        description: None,
925                        workspaces: HashMap::new(),
926                        agents: HashMap::new(),
927                        workflows: HashMap::new(),
928                        step_templates: HashMap::new(),
929                        env_stores: HashMap::new(),
930                        secret_stores: HashMap::new(),
931                        execution_profiles: HashMap::new(),
932                        triggers: HashMap::new(),
933                    },
934                );
935            std::sync::Arc::make_mut(&mut next.active_config)
936                .projects
937                .insert(
938                    "proj-a".to_string(),
939                    ResolvedProject {
940                        workspaces: HashMap::new(),
941                        agents: HashMap::new(),
942                        workflows: HashMap::new(),
943                        step_templates: HashMap::new(),
944                        env_stores: HashMap::new(),
945                        secret_stores: HashMap::new(),
946                        execution_profiles: HashMap::new(),
947                    },
948                );
949            (next, ())
950        });
951
952        let payload = CreateTaskPayload {
953            name: Some("Project Strict".to_string()),
954            goal: None,
955            project_id: Some("proj-a".to_string()),
956            workspace_id: Some("default".to_string()),
957            workflow_id: Some("task_only".to_string()),
958            target_files: None,
959            parent_task_id: None,
960            spawn_reason: None,
961            step_filter: None,
962            initial_vars: None,
963        };
964        let err = create_task_impl(&state, payload).unwrap_err();
965        assert!(
966            err.to_string().contains("workspace not found"),
967            "expected workspace-not-found error, got: {err}"
968        );
969    }
970
971    #[test]
972    fn create_task_task_scoped_workflow_with_multiple_explicit_targets_fails() {
973        let mut ts = TestState::new().with_workflow("task_only", task_only_workflow());
974        let state = ts.build();
975
976        let active = crate::config_load::read_active_config(&state).expect("read active config");
977        let ws = active
978            .workspaces
979            .get("default")
980            .expect("default workspace should exist");
981        let src_path = ws.root_path.join("src");
982        std::fs::create_dir_all(&src_path).ok();
983        std::fs::write(src_path.join("a.rs"), "fn a() {}\n").expect("write a.rs");
984        std::fs::write(src_path.join("b.rs"), "fn b() {}\n").expect("write b.rs");
985        drop(active);
986
987        let payload = CreateTaskPayload {
988            name: Some("Task Only Multi".to_string()),
989            goal: None,
990            project_id: None,
991            workspace_id: None,
992            workflow_id: Some("task_only".to_string()),
993            target_files: Some(vec!["src/a.rs".to_string(), "src/b.rs".to_string()]),
994            parent_task_id: None,
995            spawn_reason: None,
996            step_filter: None,
997            initial_vars: None,
998        };
999        let result = create_task_impl(&state, payload);
1000        assert!(result.is_err());
1001        assert!(
1002            result
1003                .expect_err("operation should fail")
1004                .to_string()
1005                .contains("task-scoped workflow accepts at most one --target-file")
1006        );
1007    }
1008
1009    #[test]
1010    fn create_task_ticket_seed_workflow_without_active_tickets_uses_unassigned() {
1011        let mut ts = TestState::new().with_workflow("ticket_only", ticket_seed_workflow());
1012        let state = ts.build();
1013
1014        let payload = CreateTaskPayload {
1015            name: Some("Ticket Seed Empty".to_string()),
1016            goal: None,
1017            project_id: None,
1018            workspace_id: None,
1019            workflow_id: Some("ticket_only".to_string()),
1020            target_files: None,
1021            parent_task_id: None,
1022            spawn_reason: None,
1023            step_filter: None,
1024            initial_vars: None,
1025        };
1026        let result = create_task_impl(&state, payload).expect("create ticket seed empty task");
1027        assert_eq!(result.total_items, 1);
1028        let (target_files, item_paths) = load_task_storage(&state, &result.id);
1029        assert_eq!(target_files, vec![UNASSIGNED_QA_FILE_PATH.to_string()]);
1030        assert_eq!(item_paths, vec![UNASSIGNED_QA_FILE_PATH.to_string()]);
1031    }
1032
1033    #[test]
1034    fn create_task_ticket_seed_workflow_with_active_tickets_uses_ticket_targets() {
1035        let mut ts = TestState::new().with_workflow("ticket_only", ticket_seed_workflow());
1036        let state = ts.build();
1037
1038        let active = crate::config_load::read_active_config(&state).expect("read active config");
1039        let ws = active
1040            .workspaces
1041            .get("default")
1042            .expect("default workspace should exist");
1043        let qa_dir = ws.root_path.join("docs/qa");
1044        std::fs::create_dir_all(&qa_dir).ok();
1045        std::fs::write(qa_dir.join("from_ticket.md"), "# From Ticket\n")
1046            .expect("write qa target from ticket");
1047        let ticket_dir = ws.root_path.join(&ws.ticket_dir);
1048        std::fs::write(
1049            ticket_dir.join("active_ticket.md"),
1050            "**Status**: OPEN\n**QA Document**: `docs/qa/from_ticket.md`\n",
1051        )
1052        .expect("write active ticket file");
1053        drop(active);
1054
1055        let payload = CreateTaskPayload {
1056            name: Some("Ticket Seed".to_string()),
1057            goal: None,
1058            project_id: None,
1059            workspace_id: None,
1060            workflow_id: Some("ticket_only".to_string()),
1061            target_files: None,
1062            parent_task_id: None,
1063            spawn_reason: None,
1064            step_filter: None,
1065            initial_vars: None,
1066        };
1067        let result = create_task_impl(&state, payload).expect("create ticket-seed task");
1068        assert_eq!(result.total_items, 1);
1069        let (target_files, item_paths) = load_task_storage(&state, &result.id);
1070        assert_eq!(target_files, vec!["docs/qa/from_ticket.md".to_string()]);
1071        assert_eq!(item_paths, vec!["docs/qa/from_ticket.md".to_string()]);
1072    }
1073
1074    #[test]
1075    fn create_multiple_tasks_get_unique_ids() {
1076        let mut ts = TestState::new();
1077        let state = ts.build();
1078
1079        let active = crate::config_load::read_active_config(&state).expect("read active config");
1080        let ws = active
1081            .workspaces
1082            .get("default")
1083            .expect("default workspace should exist");
1084        let qa_dir = &ws.qa_targets[0];
1085        let qa_path = ws.root_path.join(qa_dir);
1086        std::fs::create_dir_all(&qa_path).ok();
1087        std::fs::write(qa_path.join("multi.md"), "# Multi\n").expect("write multi qa file");
1088        drop(active);
1089
1090        let payload1 = CreateTaskPayload {
1091            name: Some("Task 1".to_string()),
1092            goal: None,
1093            project_id: None,
1094            workspace_id: None,
1095            workflow_id: None,
1096            target_files: None,
1097            parent_task_id: None,
1098            spawn_reason: None,
1099            step_filter: None,
1100            initial_vars: None,
1101        };
1102        let payload2 = CreateTaskPayload {
1103            name: Some("Task 2".to_string()),
1104            goal: None,
1105            project_id: None,
1106            workspace_id: None,
1107            workflow_id: None,
1108            target_files: None,
1109            parent_task_id: None,
1110            spawn_reason: None,
1111            step_filter: None,
1112            initial_vars: None,
1113        };
1114        let t1 = create_task_impl(&state, payload1).expect("create first task");
1115        let t2 = create_task_impl(&state, payload2).expect("create second task");
1116        assert_ne!(t1.id, t2.id, "tasks should have unique ids");
1117    }
1118
1119    #[test]
1120    fn reset_task_item_for_retry_resets_fields() {
1121        let mut ts = TestState::new();
1122        let state = ts.build();
1123
1124        // Create a task first
1125        let active = crate::config_load::read_active_config(&state).expect("read active config");
1126        let ws = active
1127            .workspaces
1128            .get("default")
1129            .expect("default workspace should exist");
1130        let qa_dir = &ws.qa_targets[0];
1131        let qa_path = ws.root_path.join(qa_dir);
1132        std::fs::create_dir_all(&qa_path).ok();
1133        std::fs::write(qa_path.join("retry.md"), "# Retry\n").expect("write retry qa file");
1134        drop(active);
1135
1136        let payload = CreateTaskPayload {
1137            name: Some("Retry Task".to_string()),
1138            goal: None,
1139            project_id: None,
1140            workspace_id: None,
1141            workflow_id: None,
1142            target_files: None,
1143            parent_task_id: None,
1144            spawn_reason: None,
1145            step_filter: None,
1146            initial_vars: None,
1147        };
1148        let task = create_task_impl(&state, payload).expect("create retry task");
1149
1150        // Get an item id
1151        let conn = open_conn(&state.db_path).expect("open retry task database");
1152        let item_id: String = conn
1153            .query_row(
1154                "SELECT id FROM task_items WHERE task_id = ?1 LIMIT 1",
1155                params![task.id],
1156                |row| row.get(0),
1157            )
1158            .expect("load task item id");
1159
1160        // Update item to simulate completed/failed state
1161        conn.execute(
1162            "UPDATE task_items SET status = 'failed', fix_required = 1, last_error = 'some error', started_at = '2024-01-01', completed_at = '2024-01-01' WHERE id = ?1",
1163            params![item_id],
1164        )
1165        .expect("seed failed task item state");
1166
1167        // Reset it
1168        let returned_task_id =
1169            reset_task_item_for_retry(&state, &item_id).expect("reset task item for retry");
1170        assert_eq!(returned_task_id, task.id);
1171
1172        // Verify reset
1173        let (status, fix_required, last_error, started_at, completed_at): (
1174            String,
1175            i64,
1176            String,
1177            Option<String>,
1178            Option<String>,
1179        ) = conn
1180            .query_row(
1181                "SELECT status, fix_required, last_error, started_at, completed_at FROM task_items WHERE id = ?1",
1182                params![item_id],
1183                |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?, row.get(4)?)),
1184            )
1185            .expect("reload reset task item");
1186        assert_eq!(status, "pending");
1187        assert_eq!(fix_required, 0);
1188        assert_eq!(last_error, "");
1189        assert!(started_at.is_none());
1190        assert!(completed_at.is_none());
1191    }
1192
1193    #[test]
1194    fn reset_task_item_for_retry_nonexistent_item_fails() {
1195        let mut ts = TestState::new();
1196        let state = ts.build();
1197        let result = reset_task_item_for_retry(&state, "nonexistent-item-id");
1198        assert!(result.is_err(), "should fail for nonexistent item");
1199    }
1200}