diff --git a/crucible/src/plan/starlark.rs b/crucible/src/plan/starlark.rs index 92360b00..10ff3bf7 100644 --- a/crucible/src/plan/starlark.rs +++ b/crucible/src/plan/starlark.rs @@ -67,6 +67,8 @@ struct CompileContext { /// Every task a DSL constructor built, keyed by name. A constructed-but-dropped task /// silently never runs, so it is a compile error. constructed_tasks: BTreeMap, + /// Tasks with `otherwise = True`, expanded in `workflow()`. + otherwise: BTreeSet, /// `session(...)` declarations by name, with the declaring site. sessions: BTreeMap, /// Declared sessions bound to at least one task. @@ -312,6 +314,7 @@ impl CompileState { prompt_files: BTreeSet::new(), total_prompt_bytes: 0, constructed_tasks: BTreeMap::new(), + otherwise: BTreeSet::new(), sessions: BTreeMap::new(), bound_sessions: BTreeSet::new(), string_session_refs: BTreeMap::new(), @@ -529,6 +532,7 @@ fn known_kwargs(function: &str) -> &'static [&'static str] { "emits_files", "when", "answers", + "otherwise", ], "skill" => &[ "name", @@ -552,6 +556,7 @@ fn known_kwargs(function: &str) -> &'static [&'static str] { "emits_files", "when", "answers", + "otherwise", ], "command" => &[ "name", @@ -570,6 +575,7 @@ fn known_kwargs(function: &str) -> &'static [&'static str] { "emits_files", "when", "answers", + "otherwise", ], "evaluate" => &[ "name", @@ -590,6 +596,7 @@ fn known_kwargs(function: &str) -> &'static [&'static str] { "emits_files", "when", "answers", + "otherwise", ], "top_k" => &["name", "k", "direction", "depends_on", "required"], "route" => &[ @@ -603,6 +610,7 @@ fn known_kwargs(function: &str) -> &'static [&'static str] { "stage", "when", "answers", + "otherwise", ], "noul" => &["ask", "drop"], "choice" => &["ask", "options", "drop"], @@ -638,7 +646,11 @@ fn constructor( } }; let tasks = match take_value(&mut named, "tasks")? { - Value::List(tasks) => task_list("workflow", tasks)?, + Value::List(tasks) => { + let mut tasks = task_list("workflow", tasks)?; + expand_otherwise(&mut tasks, state)?; + tasks + } _ => return Err(CompileError::TasksNotList), }; let result = take_optional_task_name(&mut named, "result")?; @@ -746,14 +758,14 @@ fn constructor( model, effort, }; - dsl_task(&mut named, name, kind, session.map(|decl| decl.name))? + dsl_task(&mut named, state, name, kind, session.map(|decl| decl.name))? } "command" => { let name = take_declared_name(&mut named, "name")?; let kind = TaskKind::Command { command: take_string(&mut named, "run")?, }; - dsl_task(&mut named, name, kind, None)? + dsl_task(&mut named, state, name, kind, None)? } "evaluate" => { let name = take_declared_name(&mut named, "name")?; @@ -762,7 +774,7 @@ fn constructor( threshold: take_optional_number(&mut named, "threshold")?, direction: take_optional_direction(&mut named, "direction")?, }; - dsl_task(&mut named, name, kind, None)? + dsl_task(&mut named, state, name, kind, None)? } "report" => Task { name: take_declared_name(&mut named, "name")?, @@ -854,7 +866,7 @@ fn constructor( over: None, max_fanout: None, revise: None, - when: take_when(&mut named)?, + when: take_when(&mut named, state, &name)?, name, } } @@ -1080,10 +1092,12 @@ fn engine_task( fn dsl_task( named: &mut BTreeMap, + state: &CompileState, name: TaskName, kind: TaskKind, session: Option, ) -> Result { + let when = take_when(named, state, &name)?; let task = Task { name, task: kind, @@ -1098,7 +1112,7 @@ fn dsl_task( emits_files: take_emitted_files(named)?, over: take_over(named)?, max_fanout: take_optional_fanout(named)?, - when: take_when(named)?, + when, revise: take_revise(named)?, }; check_fanout(&task)?; @@ -1252,19 +1266,40 @@ fn take_questions(named: &mut BTreeMap) -> Result) -> Result> { +/// `when = gate.area` with `answers = [...]` or `otherwise = True`. A noul's `answers` defaults +/// to `"yes"`. +fn take_when( + named: &mut BTreeMap, + state: &CompileState, + task: &TaskName, +) -> Result> { let answers = take_labels(named, "answers")?; + let otherwise = take_bool_default(named, "otherwise", false)?; let asked = match named.remove("when") { None | Some(Value::None) => { - return match answers { - Some(_) => Err(CompileError::AnswersWithoutWhen), - None => Ok(None), + return match (answers, otherwise) { + (Some(_), _) => Err(CompileError::AnswersWithoutWhen), + (None, true) => Err(CompileError::OtherwiseWithoutWhen), + (None, false) => Ok(None), }; } Some(Value::Answer(asked)) => asked, Some(_) => return Err(CompileError::WhenNotAnAnswer), }; + if otherwise { + if answers.is_some() { + return Err(CompileError::OtherwiseWithAnswers); + } + state.context_mut().otherwise.insert(task.0.clone()); + return Ok(Some(When { + task: asked.task, + question: asked.question, + is: Vec::new(), + })); + } + if answers.as_ref().is_some_and(Vec::is_empty) { + return Err(CompileError::EmptyAnswers); + } let is = match (answers, &asked.asked.kind) { (Some(answers), _) => answers, (None, QuestionKind::Noul) => vec![identifier("answers", Label::new(NOUL_YES))?], @@ -1297,6 +1332,64 @@ fn take_when(named: &mut BTreeMap) -> Result> { })) } +/// Replace each `otherwise` with the labels no other `when` lists. +fn expand_otherwise(tasks: &mut [Task], state: &CompileState) -> Result<()> { + let context = state.context_mut(); + if context.otherwise.is_empty() { + return Ok(()); + } + let pending = |task: &Task| context.otherwise.contains(&task.name.0); + let mut listed: BTreeMap<(TaskName, QuestionId), BTreeSet