use serde::{Deserialize, Serialize};
use crate::role_profile::{ScopeKeyword, ScopeSpec};
use crate::{Caveats, CountBound, Scope};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(deny_unknown_fields)]
pub struct Plan {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub goal: Option<String>,
#[serde(default)]
pub aggregation: Aggregation,
#[serde(default, rename = "subtask")]
pub subtasks: Vec<Subtask>,
}
impl Plan {
pub fn from_toml_str(s: &str) -> Result<Self, toml::de::Error> {
toml::from_str(s)
}
pub fn to_toml_string(&self) -> Result<String, toml::ser::Error> {
toml::to_string_pretty(self)
}
#[must_use]
pub fn subtask(&self, id: &str) -> Option<&Subtask> {
self.subtasks.iter().find(|s| s.id == id)
}
#[must_use]
pub fn roots(&self) -> Vec<&Subtask> {
self.subtasks
.iter()
.filter(|s| s.parent.is_none())
.collect()
}
#[must_use]
pub fn children(&self, id: &str) -> Vec<&Subtask> {
self.subtasks
.iter()
.filter(|s| s.parent.as_deref() == Some(id))
.collect()
}
#[must_use]
pub fn leaves(&self) -> Vec<&Subtask> {
let parented: std::collections::HashSet<&str> = self
.subtasks
.iter()
.filter_map(|s| s.parent.as_deref())
.collect();
self.subtasks
.iter()
.filter(|s| !parented.contains(s.id.as_str()))
.collect()
}
#[must_use]
pub fn next_ready_leaf(&self) -> Option<&Subtask> {
self.leaves().into_iter().find(|s| {
s.status == SubtaskStatus::Pending
&& s.deps
.iter()
.all(|d| matches!(self.subtask(d).map(|t| t.status), Some(SubtaskStatus::Done)))
})
}
#[must_use]
pub fn next_dispatch(&self, parent: &Caveats) -> Option<(String, CrewTask)> {
self.next_ready_leaf()
.map(|s| (s.id.clone(), s.to_crew_task(parent)))
}
pub fn mark(&mut self, id: &str, status: SubtaskStatus, result: Option<String>) {
if let Some(s) = self.subtasks.iter_mut().find(|s| s.id == id) {
s.status = status;
if result.is_some() {
s.result = result;
}
}
}
pub fn set_instruction(&mut self, id: &str, instruction: &str) {
if let Some(s) = self.subtasks.iter_mut().find(|s| s.id == id) {
s.instruction = instruction.to_string();
}
}
pub fn clear_context(&mut self, id: &str) {
if let Some(s) = self.subtasks.iter_mut().find(|s| s.id == id) {
s.context.clear();
}
}
#[must_use]
pub fn is_complete(&self) -> bool {
self.leaves()
.iter()
.all(|s| s.status == SubtaskStatus::Done)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Subtask {
pub id: String,
pub instruction: String,
#[serde(default)]
pub deps: Vec<String>,
#[serde(default)]
pub parallel_ok: bool,
#[serde(default)]
pub context: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub verify: Option<String>,
#[serde(default)]
pub status: SubtaskStatus,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub result: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub parent: Option<String>,
#[serde(default)]
pub caveat_policy: CaveatPolicy,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CrewTask {
pub goal: String,
pub caveats: Caveats,
pub context: Vec<String>,
pub verify: Option<String>,
}
impl Subtask {
#[must_use]
pub fn to_crew_task(&self, parent: &Caveats) -> CrewTask {
CrewTask {
goal: self.instruction.clone(),
caveats: parent.meet(&self.caveat_policy.to_caveats()),
context: self.context.clone(),
verify: self.verify.clone(),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "lowercase")]
pub enum Aggregation {
#[default]
Concat,
LastWins,
Reduce,
Custom,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "lowercase")]
pub enum SubtaskStatus {
#[default]
Pending,
Running,
Done,
Failed,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct CaveatPolicy {
#[serde(default = "denied_axis")]
pub fs_read: ScopeSpec,
#[serde(default = "denied_axis")]
pub fs_write: ScopeSpec,
#[serde(default = "denied_axis")]
pub exec: ScopeSpec,
#[serde(default = "denied_axis")]
pub net: ScopeSpec,
#[serde(default)]
pub max_calls: Option<u64>,
}
fn denied_axis() -> ScopeSpec {
ScopeSpec::Keyword(ScopeKeyword::None)
}
impl Default for CaveatPolicy {
fn default() -> Self {
Self {
fs_read: denied_axis(),
fs_write: denied_axis(),
exec: denied_axis(),
net: denied_axis(),
max_calls: None,
}
}
}
impl CaveatPolicy {
#[must_use]
pub fn to_caveats(&self) -> Caveats {
Caveats {
fs_read: self.fs_read.to_scope(),
fs_write: self.fs_write.to_scope(),
exec: self.exec.to_scope(),
net: self.net.to_scope(),
max_calls: match self.max_calls {
Some(n) => CountBound::AtMost(n),
None => CountBound::Unlimited,
},
valid_for_generation: Scope::All,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
const DENIED: ScopeSpec = ScopeSpec::Keyword(ScopeKeyword::None);
#[test]
fn bare_subtask_fragment_parses_without_a_goal() {
let toml = r#"
[[subtask]]
id = "s1"
instruction = "do the first thing"
[[subtask]]
id = "s2"
instruction = "do the second thing"
deps = ["s1"]
"#;
let plan = Plan::from_toml_str(toml).unwrap();
assert!(plan.goal.is_none());
assert_eq!(plan.subtasks.len(), 2);
assert_eq!(plan.subtasks[1].deps, vec!["s1".to_string()]);
}
#[test]
fn omitted_caveat_policy_denies_every_axis() {
let toml = r#"
[[subtask]]
id = "s1"
instruction = "untrusted, model-proposed"
"#;
let plan = Plan::from_toml_str(toml).unwrap();
let pol = &plan.subtasks[0].caveat_policy;
assert_eq!(pol.fs_read, DENIED);
assert_eq!(pol.fs_write, DENIED);
assert_eq!(pol.exec, DENIED);
assert_eq!(pol.net, DENIED);
assert_eq!(*pol, CaveatPolicy::default());
}
#[test]
fn default_policy_lowers_to_a_fully_denied_caveats() {
let cav = CaveatPolicy::default().to_caveats();
assert_eq!(cav.fs_read, Scope::none());
assert_eq!(cav.fs_write, Scope::none());
assert_eq!(cav.exec, Scope::none());
assert_eq!(cav.net, Scope::none());
}
#[test]
fn explicit_policy_is_honored_and_lowers_correctly() {
let toml = r#"
[[subtask]]
id = "s1"
instruction = "scoped"
[subtask.caveat_policy]
fs_read = "all"
fs_write = ["src/", "Cargo.toml"]
exec = ["cargo"]
net = "none"
max_calls = 40
"#;
let plan = Plan::from_toml_str(toml).unwrap();
let cav = plan.subtasks[0].caveat_policy.to_caveats();
assert_eq!(cav.fs_read, Scope::All);
assert_eq!(
cav.fs_write,
Scope::only(["src/".to_string(), "Cargo.toml".to_string()])
);
assert_eq!(cav.exec, Scope::only(["cargo".to_string()]));
assert_eq!(cav.net, Scope::none());
assert_eq!(cav.max_calls, CountBound::AtMost(40));
}
#[test]
fn status_defaults_to_pending() {
let toml = r#"
[[subtask]]
id = "s1"
instruction = "x"
"#;
let plan = Plan::from_toml_str(toml).unwrap();
assert_eq!(plan.subtasks[0].status, SubtaskStatus::Pending);
assert!(plan.subtasks[0].result.is_none());
}
#[test]
fn plan_round_trips_through_toml() {
let plan = Plan {
goal: Some("ship the thing".to_string()),
aggregation: Aggregation::Concat,
subtasks: vec![Subtask {
id: "s1".to_string(),
instruction: "write the module".to_string(),
deps: vec![],
parallel_ok: true,
context: vec!["src/lib.rs".to_string()],
verify: Some("cargo test -p x".to_string()),
caveat_policy: CaveatPolicy {
fs_write: ScopeSpec::Items(vec!["src/".to_string()]),
..CaveatPolicy::default()
},
status: SubtaskStatus::Done,
result: Some("done".to_string()),
parent: Some("epic".to_string()),
}],
};
let text = plan.to_toml_string().unwrap();
let back = Plan::from_toml_str(&text).unwrap();
assert_eq!(back, plan);
}
#[test]
fn full_plan_with_aggregation_parses() {
let toml = r#"
goal = "refactor the parser"
aggregation = "lastwins"
[[subtask]]
id = "s1"
instruction = "x"
status = "running"
"#;
let plan = Plan::from_toml_str(toml).unwrap();
assert_eq!(plan.goal.as_deref(), Some("refactor the parser"));
assert_eq!(plan.aggregation, Aggregation::LastWins);
assert_eq!(plan.subtasks[0].status, SubtaskStatus::Running);
}
#[test]
fn unknown_field_is_rejected() {
let toml = r#"
[[subtask]]
id = "s1"
instruction = "x"
bogus_field = "should fail"
"#;
assert!(Plan::from_toml_str(toml).is_err());
}
#[test]
fn empty_input_is_an_empty_plan() {
let plan = Plan::from_toml_str("").unwrap();
assert!(plan.goal.is_none());
assert!(plan.subtasks.is_empty());
assert_eq!(plan.aggregation, Aggregation::Concat);
}
#[test]
fn parent_pointers_build_a_tree() {
let toml = r#"
[[subtask]]
id = "epic"
instruction = "the big task"
[[subtask]]
id = "a"
instruction = "sub-task a"
parent = "epic"
[[subtask]]
id = "b"
instruction = "sub-task b"
parent = "epic"
[[subtask]]
id = "solo"
instruction = "a top-level leaf"
"#;
let plan = Plan::from_toml_str(toml).unwrap();
let ids = |v: Vec<&Subtask>| v.iter().map(|s| s.id.clone()).collect::<Vec<_>>();
assert_eq!(ids(plan.roots()), vec!["epic", "solo"]);
assert_eq!(ids(plan.children("epic")), vec!["a", "b"]);
assert_eq!(ids(plan.leaves()), vec!["a", "b", "solo"]);
assert_eq!(plan.subtask("a").unwrap().parent.as_deref(), Some("epic"));
}
#[test]
fn flat_plan_has_every_subtask_as_a_leaf() {
let plan = Plan::from_toml_str(
"[[subtask]]\nid=\"s1\"\ninstruction=\"x\"\n[[subtask]]\nid=\"s2\"\ninstruction=\"y\"\n",
)
.unwrap();
assert_eq!(plan.leaves().len(), 2);
assert_eq!(plan.roots().len(), 2);
}
#[test]
fn next_ready_leaf_is_the_execution_cursor() {
let toml = r#"
[[subtask]]
id = "epic"
instruction = "branch"
[[subtask]]
id = "a"
instruction = "first leaf"
parent = "epic"
status = "done"
[[subtask]]
id = "b"
instruction = "second leaf"
parent = "epic"
deps = ["a"]
[[subtask]]
id = "c"
instruction = "third leaf"
parent = "epic"
deps = ["b"]
"#;
let plan = Plan::from_toml_str(toml).unwrap();
assert_eq!(plan.next_ready_leaf().expect("b ready").id, "b");
}
#[test]
fn next_ready_leaf_none_when_all_done_or_blocked() {
let toml = r#"
[[subtask]]
id = "a"
instruction = "done"
status = "done"
[[subtask]]
id = "b"
instruction = "blocked"
deps = ["never_exists"]
"#;
let plan = Plan::from_toml_str(toml).unwrap();
assert!(plan.next_ready_leaf().is_none());
}
#[test]
fn parent_defaults_none_and_fragment_stays_valid() {
let frag = Plan::from_toml_str(
"[[subtask]]\nid=\"leaf\"\ninstruction=\"x\"\nparent=\"outside_the_slice\"\n",
)
.unwrap();
assert_eq!(
frag.subtasks[0].parent.as_deref(),
Some("outside_the_slice")
);
let root = Plan::from_toml_str("[[subtask]]\nid=\"r\"\ninstruction=\"y\"\n").unwrap();
assert!(root.subtasks[0].parent.is_none());
assert_eq!(root.roots().len(), 1);
}
#[test]
fn to_crew_task_projects_goal_context_and_attenuated_caveats() {
let toml = r#"
[[subtask]]
id = "leaf"
instruction = "write the module"
context = ["src/lib.rs"]
[subtask.caveat_policy]
fs_write = ["src/"]
"#;
let plan = Plan::from_toml_str(toml).unwrap();
let task = plan.subtasks[0].to_crew_task(&Caveats::top());
assert_eq!(task.goal, "write the module");
assert_eq!(task.context, vec!["src/lib.rs".to_string()]);
assert_eq!(task.caveats.fs_write, Scope::only(["src/".to_string()]));
assert_eq!(task.caveats.fs_read, Scope::none());
assert_eq!(task.caveats.exec, Scope::none());
assert_eq!(task.caveats.net, Scope::none());
}
#[test]
fn to_crew_task_never_widens_past_the_parent() {
let toml = r#"
[[subtask]]
id = "leaf"
instruction = "x"
[subtask.caveat_policy]
fs_read = "all"
"#;
let plan = Plan::from_toml_str(toml).unwrap();
let parent = Caveats {
fs_read: Scope::only(["a/".to_string()]),
..Caveats::top()
};
let task = plan.subtasks[0].to_crew_task(&parent);
assert_eq!(task.caveats.fs_read, Scope::only(["a/".to_string()]));
}
#[test]
fn plan_is_a_drivable_execution_state_machine() {
let toml = r#"
[[subtask]]
id = "epic"
instruction = "branch"
[[subtask]]
id = "a"
instruction = "step a"
parent = "epic"
[[subtask]]
id = "b"
instruction = "step b"
parent = "epic"
deps = ["a"]
[[subtask]]
id = "c"
instruction = "step c"
parent = "epic"
deps = ["b"]
"#;
let mut plan = Plan::from_toml_str(toml).unwrap();
let top = Caveats::top();
let mut order = Vec::new();
while let Some((id, task)) = plan.next_dispatch(&top) {
assert!(!task.goal.is_empty());
plan.mark(&id, SubtaskStatus::Running, None);
plan.mark(&id, SubtaskStatus::Done, Some(format!("ran {id}")));
order.push(id);
}
assert_eq!(order, vec!["a", "b", "c"]);
assert!(plan.is_complete());
assert_eq!(plan.subtask("a").unwrap().result.as_deref(), Some("ran a"));
}
#[test]
fn a_failed_leaf_blocks_its_dependents_and_stops_the_run() {
let toml = r#"
[[subtask]]
id = "a"
instruction = "x"
[[subtask]]
id = "b"
instruction = "y"
deps = ["a"]
"#;
let mut plan = Plan::from_toml_str(toml).unwrap();
let top = Caveats::top();
let (id, _task) = plan.next_dispatch(&top).expect("a is ready");
assert_eq!(id, "a");
plan.mark(&id, SubtaskStatus::Failed, Some("boom".into()));
assert!(plan.next_dispatch(&top).is_none());
assert!(!plan.is_complete());
}
#[test]
fn mark_is_a_noop_for_an_absent_id_and_empty_plan_is_complete() {
let mut plan = Plan::from_toml_str("[[subtask]]\nid=\"a\"\ninstruction=\"x\"\n").unwrap();
plan.mark("nope", SubtaskStatus::Done, Some("ignored".into()));
assert_eq!(plan.subtask("a").unwrap().status, SubtaskStatus::Pending);
assert!(Plan::from_toml_str("").unwrap().is_complete());
}
}