Skip to main content

agent_orchestrator/config_load/
build.rs

1use crate::config::{ActiveConfig, OrchestratorConfig, TaskExecutionPlan, WorkflowConfig};
2use crate::db::{
3    count_non_terminal_tasks_by_workflow, count_non_terminal_tasks_by_workspace,
4    list_non_terminal_tasks_by_workflow, list_non_terminal_tasks_by_workspace,
5};
6use crate::persistence::repository::{ConfigRepository, SqliteConfigRepository};
7use anyhow::{Context, Result};
8use std::path::Path;
9
10use super::{
11    ConfigSelfHealReport, apply_self_heal_pass, normalize_config,
12    normalize_step_execution_mode_recursive, resolve_and_validate_projects,
13    resolve_and_validate_workspaces, resolve_and_validate_workspaces_for_project,
14    serialize_config_snapshot, validate_agent_command_rules,
15    validate_agent_command_rules_for_project, validate_agent_env_store_refs,
16    validate_agent_env_store_refs_for_project, validate_execution_profiles_for_project,
17    validate_workflow_config, validate_workflow_config_with_agents,
18};
19
20#[derive(Debug, Clone, PartialEq, Eq)]
21/// One resource that would be removed by a config update.
22pub struct ResourceRemoval {
23    /// Resource kind being removed.
24    pub kind: String,
25    /// Project owning the removed resource.
26    pub project_id: String,
27    /// Resource name being removed.
28    pub name: String,
29}
30
31/// Builds the fully resolved active config for the whole workspace.
32pub fn build_active_config(data_dir: &Path, config: OrchestratorConfig) -> Result<ActiveConfig> {
33    let config = normalize_config(config);
34    let workspaces = resolve_and_validate_workspaces(data_dir, &config)?;
35    let projects = resolve_and_validate_projects(data_dir, &config)?;
36    validate_agent_env_store_refs(&config)?;
37    validate_agent_command_rules(&config)?;
38    Ok(ActiveConfig {
39        workspaces,
40        projects,
41        config,
42    })
43}
44
45/// Build active config validating only the target project's workspaces.
46/// Other projects are included in the result but not validated, allowing
47/// apply to succeed even if another project has broken paths.
48pub fn build_active_config_for_project(
49    data_dir: &Path,
50    config: OrchestratorConfig,
51    target_project: &str,
52) -> Result<ActiveConfig> {
53    let config = normalize_config(config);
54    let workspaces =
55        resolve_and_validate_workspaces_for_project(data_dir, &config, target_project)?;
56    let projects = resolve_and_validate_projects(data_dir, &config)?;
57    validate_agent_env_store_refs_for_project(&config, target_project)?;
58    validate_agent_command_rules_for_project(&config, target_project)?;
59    Ok(ActiveConfig {
60        workspaces,
61        projects,
62        config,
63    })
64}
65
66/// Attempts to build active config and persists a healed snapshot when self-heal succeeds.
67pub fn build_active_config_with_self_heal(
68    data_dir: &Path,
69    db_path: &Path,
70    config: OrchestratorConfig,
71) -> Result<(ActiveConfig, Option<ConfigSelfHealReport>)> {
72    match build_active_config(data_dir, config.clone()) {
73        Ok(active) => Ok((active, None)),
74        Err(error) => {
75            let original_error = error.to_string();
76            let maybe_healed = match apply_self_heal_pass(&config) {
77                Ok(result) => result,
78                Err(_) => anyhow::bail!(original_error),
79            };
80            let Some((healed_config, changes)) = maybe_healed else {
81                anyhow::bail!(original_error);
82            };
83
84            let healed_active = match build_active_config(data_dir, healed_config) {
85                Ok(active) => active,
86                Err(_) => anyhow::bail!(original_error),
87            };
88            let normalized = healed_active.config.clone();
89            let (yaml, json_raw) = serialize_config_snapshot(&normalized)?;
90            let (healed_version, healed_at) = SqliteConfigRepository::new(db_path)
91                .persist_self_heal_snapshot(&yaml, &json_raw, &original_error, &changes)
92                .context("failed to persist self-healed config")?;
93
94            Ok((
95                healed_active,
96                Some(ConfigSelfHealReport {
97                    original_error,
98                    healed_version,
99                    healed_at,
100                    changes,
101                }),
102            ))
103        }
104    }
105}
106
107/// Builds a validated execution plan for a workflow using global agent validation.
108pub fn build_execution_plan(
109    config: &OrchestratorConfig,
110    workflow: &WorkflowConfig,
111    workflow_id: &str,
112) -> Result<TaskExecutionPlan> {
113    validate_workflow_config(config, workflow, workflow_id)?;
114    build_execution_plan_inner(workflow)
115}
116
117/// Build an execution plan using project-scoped agents for validation.
118/// Strict project isolation: only project agents are used. If the project
119/// has no agents, validation proceeds with an empty agent set (and will
120/// fail if the workflow requires agent capabilities).
121pub fn build_execution_plan_for_project(
122    config: &OrchestratorConfig,
123    workflow: &WorkflowConfig,
124    workflow_id: &str,
125    project_id: &str,
126) -> Result<TaskExecutionPlan> {
127    let agents: std::collections::HashMap<String, &crate::config::AgentConfig> = config
128        .projects
129        .get(project_id)
130        .map(|project| project.agents.iter().map(|(k, v)| (k.clone(), v)).collect())
131        .unwrap_or_default();
132    validate_workflow_config_with_agents(&agents, workflow, workflow_id)?;
133    validate_execution_profiles_for_project(config, workflow, workflow_id, project_id)?;
134    build_execution_plan_inner(workflow)
135}
136
137fn build_execution_plan_inner(workflow: &WorkflowConfig) -> Result<TaskExecutionPlan> {
138    let mut steps = Vec::new();
139    for step in &workflow.steps {
140        if !step.enabled {
141            continue;
142        }
143        let normalized_step = task_step_from_workflow_step(step)?;
144        steps.push(normalized_step);
145    }
146    let loop_policy = workflow.loop_policy.clone();
147    Ok(TaskExecutionPlan {
148        steps,
149        loop_policy,
150        finalize: workflow.finalize.clone(),
151        max_parallel: workflow.max_parallel,
152        stagger_delay_ms: workflow.stagger_delay_ms,
153        item_isolation: workflow.item_isolation.clone(),
154    })
155}
156
157pub(crate) fn task_step_from_workflow_step(
158    step: &crate::config::WorkflowStepConfig,
159) -> Result<crate::config::TaskExecutionStep> {
160    let mut normalized = step.clone();
161    normalize_step_execution_mode_recursive(&mut normalized)?;
162
163    Ok(crate::config::TaskExecutionStep {
164        id: normalized.id.clone(),
165        required_capability: normalized.required_capability.clone(),
166        execution_profile: normalized.execution_profile.clone(),
167        builtin: normalized.builtin.clone(),
168        enabled: normalized.enabled,
169        repeatable: normalized.repeatable,
170        is_guard: normalized.is_guard,
171        cost_preference: normalized.cost_preference.clone(),
172        prehook: normalized.prehook.clone(),
173        tty: normalized.tty,
174        template: normalized.template.clone(),
175        outputs: normalized.outputs.clone(),
176        pipe_to: normalized.pipe_to.clone(),
177        command: normalized.command.clone(),
178        chain_steps: normalized
179            .chain_steps
180            .iter()
181            .map(task_step_from_workflow_step)
182            .collect::<Result<Vec<_>>>()?,
183        scope: normalized.scope,
184        behavior: normalized.behavior.clone(),
185        max_parallel: normalized.max_parallel,
186        stagger_delay_ms: normalized.stagger_delay_ms,
187        timeout_secs: normalized.timeout_secs,
188        stall_timeout_secs: normalized.stall_timeout_secs,
189        item_select_config: normalized.item_select_config.clone(),
190        store_inputs: normalized.store_inputs.clone(),
191        store_outputs: normalized.store_outputs.clone(),
192        step_vars: normalized.step_vars.clone(),
193    })
194}
195
196/// Enforces deletion guards for all resources removed between two config snapshots.
197pub fn enforce_deletion_guards(
198    conn: &rusqlite::Connection,
199    previous: &OrchestratorConfig,
200    candidate: &OrchestratorConfig,
201) -> Result<()> {
202    let mut removals = Vec::new();
203    // Check all projects for removed workspaces/workflows that still have tasks.
204    for (project_id, prev_project) in &previous.projects {
205        let candidate_project = candidate.projects.get(project_id);
206        let removed_workspaces: Vec<String> = prev_project
207            .workspaces
208            .keys()
209            .filter(|id| match candidate_project {
210                None => true,
211                Some(project) => !project.workspaces.contains_key(*id),
212            })
213            .cloned()
214            .collect();
215        for workspace_id in removed_workspaces {
216            removals.push(ResourceRemoval {
217                kind: "Workspace".to_string(),
218                project_id: project_id.clone(),
219                name: workspace_id,
220            });
221        }
222
223        let removed_workflows: Vec<String> = prev_project
224            .workflows
225            .keys()
226            .filter(|id| match candidate_project {
227                None => true,
228                Some(project) => !project.workflows.contains_key(*id),
229            })
230            .cloned()
231            .collect();
232        for workflow_id in removed_workflows {
233            removals.push(ResourceRemoval {
234                kind: "Workflow".to_string(),
235                project_id: project_id.clone(),
236                name: workflow_id,
237            });
238        }
239    }
240
241    enforce_deletion_guards_for_removals(conn, &removals)
242}
243
244/// Enforces deletion guards for an explicit list of removed resources.
245pub fn enforce_deletion_guards_for_removals(
246    conn: &rusqlite::Connection,
247    removals: &[ResourceRemoval],
248) -> Result<()> {
249    for removal in removals {
250        match removal.kind.as_str() {
251            "Workspace" => {
252                let task_count = count_non_terminal_tasks_by_workspace(
253                    conn,
254                    &removal.project_id,
255                    &removal.name,
256                )?;
257                if task_count > 0 {
258                    let blockers = list_non_terminal_tasks_by_workspace(
259                        conn,
260                        &removal.project_id,
261                        &removal.name,
262                        5,
263                    )?;
264                    anyhow::bail!(
265                        "{}",
266                        format_blocking_delete_error(
267                            "workspace",
268                            &removal.name,
269                            &removal.project_id,
270                            task_count,
271                            &blockers
272                        )
273                    );
274                }
275            }
276            "Workflow" => {
277                let task_count =
278                    count_non_terminal_tasks_by_workflow(conn, &removal.project_id, &removal.name)?;
279                if task_count > 0 {
280                    let blockers = list_non_terminal_tasks_by_workflow(
281                        conn,
282                        &removal.project_id,
283                        &removal.name,
284                        5,
285                    )?;
286                    anyhow::bail!(
287                        "{}",
288                        format_blocking_delete_error(
289                            "workflow",
290                            &removal.name,
291                            &removal.project_id,
292                            task_count,
293                            &blockers
294                        )
295                    );
296                }
297            }
298            _ => {}
299        }
300    }
301    Ok(())
302}
303
304fn format_blocking_delete_error(
305    kind: &str,
306    name: &str,
307    project_id: &str,
308    task_count: i64,
309    blockers: &[crate::db::TaskReference],
310) -> String {
311    let mut message = format!(
312        "[FAILED_PRECONDITION] apply/delete would remove {}/{} in project {}, but {} non-terminal task(s) still reference it",
313        kind, name, project_id, task_count
314    );
315    if !blockers.is_empty() {
316        message.push_str("\nblocking tasks:");
317        for blocker in blockers {
318            message.push_str(&format!(
319                "\n- {} status={}",
320                blocker.task_id, blocker.status
321            ));
322        }
323    }
324    message.push_str(&format!(
325        "\nsuggested fixes:\n- orchestrator task list --project {}\n- orchestrator task delete <task_id> --force\n- rerun without --prune if deletion is not intended",
326        project_id
327    ));
328    message
329}
330
331#[cfg(test)]
332mod tests {
333    use super::*;
334    use crate::config::{ExecutionMode, LoopMode, OrchestratorConfig};
335    use crate::config_load::persist_raw_config;
336    use crate::config_load::tests::{
337        make_builtin_step, make_command_step, make_config_with_default_project,
338        make_minimal_buildable_config, make_step, make_test_db, make_workflow,
339    };
340    #[allow(unused_imports)]
341    use std::collections::HashMap;
342
343    #[test]
344    fn build_active_config_with_self_heal_recovers_builtin_capability_conflict() {
345        let data_dir = std::env::current_dir().expect("cwd");
346        let (_temp_dir, db_path) = make_test_db();
347        let mut config = make_minimal_buildable_config();
348        let workflow = config
349            .projects
350            .get_mut(crate::config::DEFAULT_PROJECT_ID)
351            .expect("default project")
352            .workflows
353            .get_mut("basic")
354            .expect("missing basic workflow");
355        let step = workflow
356            .steps
357            .first_mut()
358            .expect("missing builtin self_test step");
359        step.required_capability = Some("self_test".to_string());
360        // Pass the invalid config directly to self-heal (not loaded from DB)
361        // because the CRD round-trip in load_config strips the invalid workflow
362        // during workflow_spec_to_config validation.
363        let invalid_config = config.clone();
364        persist_raw_config(&db_path, config.clone(), "test-seed").expect("seed config");
365
366        let direct_error = build_active_config(&data_dir, config)
367            .expect_err("invalid config should fail direct active config construction");
368        assert!(
369            direct_error
370                .to_string()
371                .contains("cannot define both builtin and required_capability")
372        );
373
374        let (active, report) =
375            build_active_config_with_self_heal(&data_dir, &db_path, invalid_config)
376                .expect("self-heal wrapper should recover");
377
378        assert!(
379            active
380                .projects
381                .get(crate::config::DEFAULT_PROJECT_ID)
382                .map(|p| p.workflows.contains_key("basic"))
383                .unwrap_or(false)
384        );
385        let report = report.expect("expected self-heal report");
386        assert!(
387            !report.changes.is_empty(),
388            "expected recorded self-heal changes"
389        );
390    }
391
392    #[test]
393    fn build_active_config_with_self_heal_persists_self_heal_version() {
394        let data_dir = std::env::current_dir().expect("cwd");
395        let (_temp_dir, db_path) = make_test_db();
396        let mut config = make_minimal_buildable_config();
397        let workflow = config
398            .projects
399            .get_mut(crate::config::DEFAULT_PROJECT_ID)
400            .expect("default project")
401            .workflows
402            .get_mut("basic")
403            .expect("missing basic workflow");
404        workflow
405            .steps
406            .first_mut()
407            .expect("basic workflow should have a step")
408            .required_capability = Some("self_test".to_string());
409        let invalid_config = config.clone();
410        let seeded = persist_raw_config(&db_path, config, "test-seed").expect("seed config");
411
412        let (_active, report) =
413            build_active_config_with_self_heal(&data_dir, &db_path, invalid_config)
414                .expect("self-heal wrapper should recover");
415
416        let report = report.expect("expected self-heal report");
417        assert_eq!(report.healed_version, seeded.version + 1);
418        let conn = crate::db::open_conn(&db_path).expect("open sqlite connection");
419        let latest_author: String = conn
420            .query_row(
421                "SELECT author FROM orchestrator_config_versions ORDER BY version DESC LIMIT 1",
422                [],
423                |row| row.get(0),
424            )
425            .expect("query latest config version author");
426        assert_eq!(latest_author, "self-heal");
427    }
428
429    #[test]
430    fn build_active_config_with_self_heal_persists_heal_log_entries() {
431        let data_dir = std::env::current_dir().expect("cwd");
432        let (_temp_dir, db_path) = make_test_db();
433        let mut config = make_minimal_buildable_config();
434        let workflow = config
435            .projects
436            .get_mut(crate::config::DEFAULT_PROJECT_ID)
437            .expect("default project")
438            .workflows
439            .get_mut("basic")
440            .expect("missing basic workflow");
441        workflow
442            .steps
443            .first_mut()
444            .expect("basic workflow should have a step")
445            .required_capability = Some("self_test".to_string());
446        let invalid_config = config.clone();
447        persist_raw_config(&db_path, config, "test-seed").expect("seed config");
448
449        let (_active, report) =
450            build_active_config_with_self_heal(&data_dir, &db_path, invalid_config)
451                .expect("self-heal wrapper should recover");
452
453        let report = report.expect("expected self-heal report");
454        let entries =
455            crate::config_load::query_heal_log_entries(&db_path, 10).expect("query heal log");
456        assert!(
457            !entries.is_empty(),
458            "heal log entries should be persisted during self-heal"
459        );
460        assert_eq!(entries[0].version, report.healed_version);
461        assert!(
462            entries[0]
463                .original_error
464                .contains("builtin and required_capability")
465        );
466    }
467
468    #[test]
469    fn build_active_config_with_self_heal_returns_original_error_for_unhealable_config() {
470        let data_dir = std::env::current_dir().expect("cwd");
471        let (_temp_dir, db_path) = make_test_db();
472        let mut config = make_minimal_buildable_config();
473        config
474            .projects
475            .get_mut(crate::config::DEFAULT_PROJECT_ID)
476            .expect("default project")
477            .workspaces
478            .get_mut("default")
479            .expect("default workspace")
480            .root_path = "fixtures/does-not-exist".to_string();
481        persist_raw_config(&db_path, config.clone(), "test-seed").expect("seed config");
482
483        let err = build_active_config_with_self_heal(&data_dir, &db_path, config)
484            .expect_err("unhealable config should still fail");
485
486        assert!(
487            err.to_string().contains("root_path not found"),
488            "expected original error to be preserved, got: {err}"
489        );
490        let conn = crate::db::open_conn(&db_path).expect("open sqlite connection");
491        let version_count: i64 = conn
492            .query_row(
493                "SELECT COUNT(*) FROM orchestrator_config_versions",
494                [],
495                |row| row.get(0),
496            )
497            .expect("count config versions");
498        assert_eq!(
499            version_count, 1,
500            "unhealable config must not persist new version"
501        );
502    }
503
504    #[test]
505    fn build_execution_plan_returns_only_enabled_steps() {
506        let workflow = make_workflow(vec![
507            make_builtin_step("self_test", "self_test", true),
508            make_step("qa", false),
509        ]);
510        let config = make_config_with_default_project();
511        let plan =
512            build_execution_plan(&config, &workflow, "test-wf").expect("build execution plan");
513        assert_eq!(plan.steps.len(), 1, "should only contain enabled steps");
514        assert_eq!(plan.steps[0].id, "self_test");
515    }
516
517    #[test]
518    fn build_execution_plan_copies_step_fields() {
519        let mut step = make_command_step("build", "cargo build");
520        step.repeatable = false;
521        step.tty = true;
522        step.outputs = vec!["result".to_string()];
523        step.pipe_to = Some("next_step".to_string());
524        step.cost_preference = Some(crate::config::CostPreference::Quality);
525        step.scope = Some(crate::config::StepScope::Task);
526        let workflow = make_workflow(vec![step]);
527        let config = make_config_with_default_project();
528        let plan =
529            build_execution_plan(&config, &workflow, "test-wf").expect("build execution plan");
530        let s = &plan.steps[0];
531        assert_eq!(s.id, "build");
532        assert_eq!(s.command.as_deref(), Some("cargo build"));
533        assert!(!s.repeatable);
534        assert!(s.tty);
535        assert_eq!(s.outputs, vec!["result"]);
536        assert_eq!(s.pipe_to.as_deref(), Some("next_step"));
537        assert_eq!(
538            s.cost_preference,
539            Some(crate::config::CostPreference::Quality)
540        );
541        assert_eq!(s.scope, Some(crate::config::StepScope::Task));
542    }
543
544    #[test]
545    fn build_execution_plan_includes_chain_steps() {
546        let mut step = make_step("smoke_chain", true);
547        step.chain_steps = vec![
548            make_command_step("sub1", "cargo build"),
549            make_command_step("sub2", "cargo test"),
550        ];
551        let workflow = make_workflow(vec![step]);
552        let config = make_config_with_default_project();
553        let plan =
554            build_execution_plan(&config, &workflow, "test-wf").expect("build execution plan");
555        assert_eq!(plan.steps[0].chain_steps.len(), 2);
556        assert_eq!(plan.steps[0].chain_steps[0].id, "sub1");
557        assert_eq!(plan.steps[0].chain_steps[1].id, "sub2");
558        assert_eq!(plan.steps[0].behavior.execution, ExecutionMode::Chain);
559        assert_eq!(
560            plan.steps[0].chain_steps[0].behavior.execution,
561            ExecutionMode::Builtin {
562                name: "sub1".to_string()
563            }
564        );
565    }
566
567    #[test]
568    fn build_execution_plan_copies_loop_policy() {
569        let mut workflow = make_workflow(vec![make_builtin_step("self_test", "self_test", true)]);
570        workflow.loop_policy.mode = LoopMode::Fixed;
571        workflow.loop_policy.guard.max_cycles = Some(3);
572        let config = make_config_with_default_project();
573        let plan =
574            build_execution_plan(&config, &workflow, "test-wf").expect("build execution plan");
575        assert!(matches!(plan.loop_policy.mode, LoopMode::Fixed));
576        assert_eq!(plan.loop_policy.guard.max_cycles, Some(3));
577    }
578
579    #[test]
580    fn build_execution_plan_copies_finalize_config() {
581        let mut workflow = make_workflow(vec![make_builtin_step("self_test", "self_test", true)]);
582        workflow.finalize = crate::config::default_workflow_finalize_config();
583        let config = make_config_with_default_project();
584        let plan =
585            build_execution_plan(&config, &workflow, "test-wf").expect("build execution plan");
586        assert!(
587            !plan.finalize.rules.is_empty(),
588            "finalize rules should be copied"
589        );
590    }
591
592    #[test]
593    fn build_execution_plan_fails_on_invalid_workflow() {
594        let workflow = make_workflow(vec![]);
595        let config = make_config_with_default_project();
596        let result = build_execution_plan(&config, &workflow, "test-wf");
597        assert!(result.is_err(), "should fail validation");
598    }
599
600    #[test]
601    fn build_execution_plan_rehydrates_builtin_execution_from_builtin_field() {
602        let mut step = make_builtin_step("self_test", "self_test", true);
603        step.behavior.execution = ExecutionMode::Agent;
604        let workflow = make_workflow(vec![step]);
605        let config = make_config_with_default_project();
606
607        let plan =
608            build_execution_plan(&config, &workflow, "test-wf").expect("build execution plan");
609
610        assert_eq!(
611            plan.steps[0].behavior.execution,
612            ExecutionMode::Builtin {
613                name: "self_test".to_string()
614            }
615        );
616        assert_eq!(plan.steps[0].required_capability, None);
617    }
618
619    #[test]
620    fn enforce_deletion_guards_allows_no_removals() {
621        let db_path = std::env::temp_dir().join(format!("test-guard-{}.db", uuid::Uuid::new_v4()));
622        crate::db::init_schema(&db_path).expect("init schema");
623        let conn = crate::db::open_conn(&db_path).expect("open db");
624        let config = OrchestratorConfig::default();
625        let result = enforce_deletion_guards(&conn, &config, &config);
626        assert!(result.is_ok());
627        std::fs::remove_file(&db_path).ok();
628    }
629
630    #[test]
631    fn enforce_deletion_guards_allows_removing_unused_workspace() {
632        use crate::config::WorkspaceConfig;
633        let db_path = std::env::temp_dir().join(format!("test-guard-{}.db", uuid::Uuid::new_v4()));
634        crate::db::init_schema(&db_path).expect("init schema");
635        let conn = crate::db::open_conn(&db_path).expect("open db");
636        let mut previous_workspaces = HashMap::new();
637        previous_workspaces.insert(
638            "ws-to-remove".to_string(),
639            WorkspaceConfig {
640                root_path: "/tmp".to_string(),
641                qa_targets: vec!["docs".to_string()],
642                ticket_dir: "tickets".to_string(),
643                self_referential: false,
644                health_policy: Default::default(),
645            },
646        );
647        let mut previous = OrchestratorConfig::default();
648        previous
649            .projects
650            .entry(crate::config::DEFAULT_PROJECT_ID.to_string())
651            .or_default()
652            .workspaces = previous_workspaces;
653        let candidate = OrchestratorConfig::default();
654        let result = enforce_deletion_guards(&conn, &previous, &candidate);
655        assert!(
656            result.is_ok(),
657            "removing unused workspace should be allowed"
658        );
659        std::fs::remove_file(&db_path).ok();
660    }
661
662    #[test]
663    fn enforce_deletion_guards_allows_removing_unused_workflow() {
664        let db_path = std::env::temp_dir().join(format!("test-guard-{}.db", uuid::Uuid::new_v4()));
665        crate::db::init_schema(&db_path).expect("init schema");
666        let conn = crate::db::open_conn(&db_path).expect("open db");
667        let mut previous_workflows = HashMap::new();
668        previous_workflows.insert("wf-to-remove".to_string(), make_workflow(vec![]));
669        let mut previous = OrchestratorConfig::default();
670        previous
671            .projects
672            .entry(crate::config::DEFAULT_PROJECT_ID.to_string())
673            .or_default()
674            .workflows = previous_workflows;
675        let candidate = OrchestratorConfig::default();
676        let result = enforce_deletion_guards(&conn, &previous, &candidate);
677        assert!(result.is_ok(), "removing unused workflow should be allowed");
678        std::fs::remove_file(&db_path).ok();
679    }
680
681    fn insert_task_reference(
682        conn: &rusqlite::Connection,
683        task_id: &str,
684        project_id: &str,
685        workspace_id: &str,
686        workflow_id: &str,
687        status: &str,
688    ) {
689        conn.execute(
690            "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)
691             VALUES (?1, 'test', ?2, NULL, NULL, 'goal', '[]', '', ?3, ?4, ?5, '/tmp', '[]', 'tickets', '{}', 'once', 0, 0, NULL, datetime('now'), datetime('now'), NULL, NULL, 0)",
692            rusqlite::params![task_id, status, project_id, workspace_id, workflow_id],
693        )
694        .expect("insert task reference");
695    }
696
697    #[test]
698    fn enforce_deletion_guards_blocks_same_project_non_terminal_workflow_tasks() {
699        let db_path = std::env::temp_dir().join(format!("test-guard-{}.db", uuid::Uuid::new_v4()));
700        crate::db::init_schema(&db_path).expect("init schema");
701        let conn = crate::db::open_conn(&db_path).expect("open db");
702        insert_task_reference(
703            &conn,
704            "task-running",
705            crate::config::DEFAULT_PROJECT_ID,
706            "default",
707            "wf-to-remove",
708            "running",
709        );
710
711        let mut previous = OrchestratorConfig::default();
712        previous
713            .projects
714            .entry(crate::config::DEFAULT_PROJECT_ID.to_string())
715            .or_default()
716            .workflows
717            .insert("wf-to-remove".to_string(), make_workflow(vec![]));
718        let candidate = OrchestratorConfig::default();
719
720        let error = enforce_deletion_guards(&conn, &previous, &candidate)
721            .expect_err("running task should block workflow deletion");
722        let message = error.to_string();
723        assert!(message.contains("workflow/wf-to-remove"));
724        assert!(message.contains("project default"));
725        assert!(message.contains("task-running status=running"));
726        std::fs::remove_file(&db_path).ok();
727    }
728
729    #[test]
730    fn enforce_deletion_guards_ignores_terminal_workflow_tasks() {
731        let db_path = std::env::temp_dir().join(format!("test-guard-{}.db", uuid::Uuid::new_v4()));
732        crate::db::init_schema(&db_path).expect("init schema");
733        let conn = crate::db::open_conn(&db_path).expect("open db");
734        insert_task_reference(
735            &conn,
736            "task-complete",
737            crate::config::DEFAULT_PROJECT_ID,
738            "default",
739            "wf-to-remove",
740            "completed",
741        );
742
743        let mut previous = OrchestratorConfig::default();
744        previous
745            .projects
746            .entry(crate::config::DEFAULT_PROJECT_ID.to_string())
747            .or_default()
748            .workflows
749            .insert("wf-to-remove".to_string(), make_workflow(vec![]));
750        let candidate = OrchestratorConfig::default();
751
752        let result = enforce_deletion_guards(&conn, &previous, &candidate);
753        assert!(
754            result.is_ok(),
755            "terminal task should not block workflow deletion"
756        );
757        std::fs::remove_file(&db_path).ok();
758    }
759
760    #[test]
761    fn enforce_deletion_guards_ignores_other_project_tasks_with_same_workflow_id() {
762        let db_path = std::env::temp_dir().join(format!("test-guard-{}.db", uuid::Uuid::new_v4()));
763        crate::db::init_schema(&db_path).expect("init schema");
764        let conn = crate::db::open_conn(&db_path).expect("open db");
765        insert_task_reference(
766            &conn,
767            "task-other-project",
768            "other-project",
769            "default",
770            "wf-to-remove",
771            "running",
772        );
773
774        let mut previous = OrchestratorConfig::default();
775        previous
776            .projects
777            .entry(crate::config::DEFAULT_PROJECT_ID.to_string())
778            .or_default()
779            .workflows
780            .insert("wf-to-remove".to_string(), make_workflow(vec![]));
781        let candidate = OrchestratorConfig::default();
782
783        let result = enforce_deletion_guards(&conn, &previous, &candidate);
784        assert!(
785            result.is_ok(),
786            "same workflow id in another project should not block"
787        );
788        std::fs::remove_file(&db_path).ok();
789    }
790}