mod context;
mod draft;
mod run;
mod schema;
mod validate;
pub use context::{
TaskContextError, TaskContextSlice, task_context_slice, task_context_slice_for_run,
};
pub use draft::draft_task_dag;
pub use run::{TaskEventError, apply_task_event, initial_run, ready_tasks};
pub use schema::{
RetryPolicy, TaskContextItem, TaskDag, TaskEvent, TaskNode, TaskOperator, TaskRisk, TaskRun,
TaskState,
};
pub use validate::{DagValidationError, validate_dag};
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn validate_returns_stable_topological_order() {
let dag = TaskDag {
id: "task.test".to_string(),
title: "test".to_string(),
nodes: vec![
node("c", &["a", "b"]),
node("b", &["a"]),
node("a", &[]),
node("d", &["a"]),
],
};
assert_eq!(validate_dag(&dag).unwrap(), ["a", "b", "c", "d"]);
}
#[test]
fn validate_rejects_duplicate_ids() {
let dag = TaskDag {
id: "task.dup".to_string(),
title: "dup".to_string(),
nodes: vec![node("a", &[]), node("a", &[])],
};
assert_eq!(
validate_dag(&dag),
Err(DagValidationError::DuplicateNodeId("a".to_string()))
);
}
#[test]
fn validate_rejects_missing_dependencies() {
let dag = TaskDag {
id: "task.missing".to_string(),
title: "missing".to_string(),
nodes: vec![node("a", &["missing"])],
};
assert_eq!(
validate_dag(&dag),
Err(DagValidationError::MissingDependency {
node_id: "a".to_string(),
dependency: "missing".to_string(),
})
);
}
#[test]
fn validate_rejects_cycles() {
let dag = TaskDag {
id: "task.cycle".to_string(),
title: "cycle".to_string(),
nodes: vec![node("a", &["b"]), node("b", &["a"])],
};
assert_eq!(
validate_dag(&dag),
Err(DagValidationError::Cycle {
nodes: vec!["a".to_string(), "b".to_string()],
})
);
}
#[test]
fn draft_task_dag_is_valid_and_receipt_backed() {
let dag = draft_task_dag(
"Fix retry policy",
&[
TaskContextItem {
id: "fn.retry".to_string(),
title: "retry".to_string(),
kind: "function".to_string(),
source: "src/retry.rs:10-30".to_string(),
},
TaskContextItem {
id: "doc.retry-policy".to_string(),
title: "Retry Policy".to_string(),
kind: "doc".to_string(),
source: "docs/retry.md".to_string(),
},
],
);
assert_eq!(dag.id, "task.fix-retry-policy");
assert_eq!(
validate_dag(&dag).unwrap(),
[
"context",
"inspect-doc-retry-policy",
"inspect-fn-retry",
"decompose",
"change",
"verify",
"review",
"approval"
]
);
let change = dag.nodes.iter().find(|node| node.id == "change").unwrap();
assert_eq!(change.operator, TaskOperator::Edit);
assert_eq!(change.risk, TaskRisk::High);
assert_eq!(change.tools, ["edit"]);
assert!(
change
.required_checks
.contains(&"compile_or_typecheck".to_string())
);
assert_eq!(change.receipts, ["docs/retry.md", "src/retry.rs:10-30"]);
let inspect = dag
.nodes
.iter()
.find(|node| node.id == "inspect-fn-retry")
.unwrap();
assert_eq!(inspect.depends_on, ["context"]);
assert_eq!(inspect.tools, ["read", "get_node"]);
assert_eq!(inspect.risk, TaskRisk::Low);
}
#[test]
fn draft_doc_only_task_uses_proposal_operator() {
let dag = draft_task_dag(
"Write workflow runbook",
&[TaskContextItem {
id: "doc.runbook".to_string(),
title: "Runbook".to_string(),
kind: "doc".to_string(),
source: "docs/runbook.md".to_string(),
}],
);
let change = dag.nodes.iter().find(|node| node.id == "change").unwrap();
assert_eq!(change.operator, TaskOperator::ProposeDoc);
assert_eq!(change.tools, ["propose", "sigil-diff"]);
assert!(
change
.required_checks
.contains(&"sigil_validation".to_string())
);
}
#[test]
fn draft_task_dag_batches_large_context_packs() {
let context = (0..30)
.map(|n| TaskContextItem {
id: format!("fn.item_{n}"),
title: format!("item {n}"),
kind: "function".to_string(),
source: format!("src/item.rs:{n}"),
})
.collect::<Vec<_>>();
let dag = draft_task_dag("Fix large context task", &context);
assert!(validate_dag(&dag).is_ok());
assert_eq!(dag.nodes.len(), 31);
let remaining = dag
.nodes
.iter()
.find(|node| node.id == "inspect-remaining-context")
.unwrap();
assert_eq!(remaining.inputs.len(), 6);
assert_eq!(remaining.receipts.len(), 6);
assert_eq!(remaining.depends_on, ["context"]);
}
#[test]
fn task_context_slice_resolves_task_inputs_to_context_items() {
let context = vec![
TaskContextItem {
id: "fn.retry".to_string(),
title: "retry".to_string(),
kind: "function".to_string(),
source: "src/retry.rs:10-30".to_string(),
},
TaskContextItem {
id: "doc.retry-policy".to_string(),
title: "Retry Policy".to_string(),
kind: "doc".to_string(),
source: "docs/retry.md".to_string(),
},
];
let dag = draft_task_dag("Fix retry policy", &context);
let slice = task_context_slice(&dag, "inspect-fn-retry", &context).unwrap();
assert_eq!(slice.task_id, "inspect-fn-retry");
assert_eq!(slice.operator, TaskOperator::ReadContext);
assert_eq!(slice.context.len(), 1);
assert_eq!(slice.context[0].id, "fn.retry");
assert_eq!(slice.artifact_inputs, Vec::<String>::new());
assert_eq!(slice.tools, ["read", "get_node"]);
assert_eq!(slice.receipts, ["src/retry.rs:10-30"]);
}
#[test]
fn task_context_slice_keeps_artifact_inputs_explicit() {
let context = vec![TaskContextItem {
id: "doc.runbook".to_string(),
title: "Runbook".to_string(),
kind: "doc".to_string(),
source: "docs/runbook.md".to_string(),
}];
let dag = draft_task_dag("Write workflow runbook", &context);
let slice = task_context_slice(&dag, "change", &context).unwrap();
assert_eq!(slice.operator, TaskOperator::ProposeDoc);
assert!(slice.context.is_empty());
assert_eq!(slice.artifact_inputs, ["work_items"]);
assert_eq!(slice.receipts, ["docs/runbook.md"]);
assert_eq!(slice.required_checks, ["sigil_validation"]);
}
#[test]
fn task_context_slice_infers_dependency_outputs_when_inputs_are_omitted() {
let dag = TaskDag {
id: "task.template".to_string(),
title: "template task".to_string(),
nodes: vec![
TaskNode {
id: "context".to_string(),
title: "Gather context".to_string(),
operator: TaskOperator::ReadContext,
outputs: vec!["context_pack".to_string()],
required_checks: vec!["receipt_coverage".to_string()],
tools: vec!["focus".to_string()],
risk: TaskRisk::Low,
..task_node_defaults()
},
TaskNode {
id: "risk-review".to_string(),
title: "Review risk".to_string(),
operator: TaskOperator::Review,
depends_on: vec!["context".to_string()],
required_checks: vec!["risk_checked".to_string()],
tools: vec!["neighbors".to_string(), "paths".to_string()],
risk: TaskRisk::Medium,
..task_node_defaults()
},
],
};
let slice = task_context_slice(&dag, "risk-review", &[]).unwrap();
assert_eq!(slice.inputs, Vec::<String>::new());
assert_eq!(slice.artifact_inputs, ["context_pack"]);
assert!(slice.context.is_empty());
assert_eq!(slice.tools, ["neighbors", "paths"]);
assert_eq!(slice.required_checks, ["risk_checked"]);
}
#[test]
fn explicit_task_inputs_override_dependency_output_inference() {
let context = vec![TaskContextItem {
id: "doc.runbook".to_string(),
title: "Runbook".to_string(),
kind: "doc".to_string(),
source: "docs/runbook.md".to_string(),
}];
let dag = TaskDag {
id: "task.explicit".to_string(),
title: "explicit task".to_string(),
nodes: vec![
TaskNode {
id: "context".to_string(),
title: "Gather context".to_string(),
operator: TaskOperator::ReadContext,
outputs: vec!["context_pack".to_string()],
required_checks: vec!["receipt_coverage".to_string()],
tools: vec!["focus".to_string()],
risk: TaskRisk::Low,
..task_node_defaults()
},
TaskNode {
id: "inspect-doc".to_string(),
title: "Inspect doc".to_string(),
operator: TaskOperator::ReadContext,
depends_on: vec!["context".to_string()],
inputs: vec!["doc.runbook".to_string()],
required_checks: vec!["receipt_read".to_string()],
tools: vec!["read".to_string()],
risk: TaskRisk::Low,
..task_node_defaults()
},
],
};
let slice = task_context_slice(&dag, "inspect-doc", &context).unwrap();
assert_eq!(slice.context.len(), 1);
assert_eq!(slice.context[0].id, "doc.runbook");
assert!(slice.artifact_inputs.is_empty());
}
#[test]
fn task_context_slice_rejects_unknown_task() {
let dag = TaskDag {
id: "task.unknown".to_string(),
title: "unknown".to_string(),
nodes: vec![node("a", &[])],
};
assert_eq!(
task_context_slice(&dag, "missing", &[]),
Err(TaskContextError::UnknownTask("missing".to_string()))
);
}
#[test]
fn task_context_slice_for_run_marks_ready_tasks() {
let context = vec![TaskContextItem {
id: "doc.runbook".to_string(),
title: "Runbook".to_string(),
kind: "doc".to_string(),
source: "docs/runbook.md".to_string(),
}];
let dag = draft_task_dag("Write workflow runbook", &context);
let run = initial_run(&dag).unwrap();
let run = apply_task_event(&dag, &run, event("context", TaskState::Running)).unwrap();
let run = apply_task_event(&dag, &run, event("context", TaskState::Passed)).unwrap();
let slice =
task_context_slice_for_run(&dag, &run, "inspect-doc-runbook", &context).unwrap();
assert_eq!(slice.run_state, Some(TaskState::Ready));
assert_eq!(slice.ready, Some(true));
assert!(slice.blocked_by.is_empty());
assert_eq!(slice.context[0].id, "doc.runbook");
}
#[test]
fn task_context_slice_for_run_reports_blocked_dependencies() {
let context = vec![TaskContextItem {
id: "doc.runbook".to_string(),
title: "Runbook".to_string(),
kind: "doc".to_string(),
source: "docs/runbook.md".to_string(),
}];
let dag = draft_task_dag("Write workflow runbook", &context);
let run = initial_run(&dag).unwrap();
let slice = task_context_slice_for_run(&dag, &run, "change", &context).unwrap();
assert_eq!(slice.run_state, Some(TaskState::Pending));
assert_eq!(slice.ready, Some(false));
assert_eq!(slice.blocked_by, ["decompose"]);
assert_eq!(slice.artifact_inputs, ["work_items"]);
}
#[test]
fn initial_run_marks_dependency_free_tasks_ready() {
let dag = TaskDag {
id: "task.ready".to_string(),
title: "ready".to_string(),
nodes: vec![node("a", &[]), node("b", &["a"]), node("c", &[])],
};
let run = initial_run(&dag).unwrap();
assert_eq!(ready_tasks(&dag, &run).unwrap(), ["a", "c"]);
assert_eq!(run.states["a"], TaskState::Ready);
assert_eq!(run.states["b"], TaskState::Pending);
assert_eq!(run.states["c"], TaskState::Ready);
}
#[test]
fn task_events_advance_ready_tasks_deterministically() {
let dag = TaskDag {
id: "task.run".to_string(),
title: "run".to_string(),
nodes: vec![node("a", &[]), node("b", &["a"])],
};
let run = initial_run(&dag).unwrap();
let run = apply_task_event(&dag, &run, event("a", TaskState::Running)).unwrap();
assert!(ready_tasks(&dag, &run).unwrap().is_empty());
assert_eq!(run.states["a"], TaskState::Running);
let run = apply_task_event(&dag, &run, event("a", TaskState::Passed)).unwrap();
assert_eq!(ready_tasks(&dag, &run).unwrap(), ["b"]);
assert_eq!(run.states["a"], TaskState::Passed);
assert_eq!(run.states["b"], TaskState::Ready);
assert_eq!(run.events.len(), 2);
}
#[test]
fn task_events_reject_running_before_dependencies_pass() {
let dag = TaskDag {
id: "task.blocked".to_string(),
title: "blocked".to_string(),
nodes: vec![node("a", &[]), node("b", &["a"])],
};
let run = initial_run(&dag).unwrap();
assert_eq!(
apply_task_event(&dag, &run, event("b", TaskState::Running)),
Err(TaskEventError::DependenciesNotPassed {
task_id: "b".to_string(),
blocked_by: vec!["a".to_string()],
})
);
}
#[test]
fn task_events_reject_running_from_non_ready_state() {
let dag = TaskDag {
id: "task.invalid-transition".to_string(),
title: "invalid transition".to_string(),
nodes: vec![node("a", &[])],
};
let mut run = initial_run(&dag).unwrap();
run.states.insert("a".to_string(), TaskState::Blocked);
assert_eq!(
apply_task_event(&dag, &run, event("a", TaskState::Running)),
Err(TaskEventError::InvalidTransition {
task_id: "a".to_string(),
from: TaskState::Blocked,
to: TaskState::Running,
})
);
}
#[test]
fn task_events_reject_terminal_task_changes() {
let dag = TaskDag {
id: "task.terminal".to_string(),
title: "terminal".to_string(),
nodes: vec![node("a", &[])],
};
let run = initial_run(&dag).unwrap();
let run = apply_task_event(&dag, &run, event("a", TaskState::Running)).unwrap();
let run = apply_task_event(&dag, &run, event("a", TaskState::Passed)).unwrap();
assert_eq!(
apply_task_event(&dag, &run, event("a", TaskState::Running)),
Err(TaskEventError::TerminalTask {
task_id: "a".to_string(),
state: TaskState::Passed,
})
);
}
fn node(id: &str, depends_on: &[&str]) -> TaskNode {
TaskNode {
id: id.to_string(),
title: id.to_string(),
operator: TaskOperator::Check,
state: TaskState::Pending,
depends_on: depends_on
.iter()
.map(std::string::ToString::to_string)
.collect(),
inputs: Vec::new(),
outputs: Vec::new(),
required_checks: Vec::new(),
tools: Vec::new(),
retry: RetryPolicy::default(),
risk: TaskRisk::Medium,
receipts: Vec::new(),
}
}
fn task_node_defaults() -> TaskNode {
node("placeholder", &[])
}
fn event(task_id: &str, to: TaskState) -> TaskEvent {
TaskEvent {
task_id: task_id.to_string(),
to,
evidence: Vec::new(),
note: None,
}
}
}