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
110pub 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 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 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
255pub 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 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 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 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
437pub 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 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
466fn 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
498pub 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 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 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 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 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 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 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 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 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}