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)]
21pub struct ResourceRemoval {
23 pub kind: String,
25 pub project_id: String,
27 pub name: String,
29}
30
31pub 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
45pub 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
66pub 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
107pub 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
117pub 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
196pub fn enforce_deletion_guards(
198 conn: &rusqlite::Connection,
199 previous: &OrchestratorConfig,
200 candidate: &OrchestratorConfig,
201) -> Result<()> {
202 let mut removals = Vec::new();
203 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
244pub 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 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}