use crate::flow_dispatcher::{DispatchCtx, DispatchError, NodeOutcome};
use crate::flow_execution_event::{now_ms, FlowExecutionEvent};
use crate::ir_nodes::{IRDrillStep, IRHibernateStep, IRTrailStep};
pub fn drill_pix_subtree(
pix_ref: &str,
subtree_path: &str,
query: &str,
ctx: &DispatchCtx,
) -> String {
let key = format!("__pix_{pix_ref}_{subtree_path}");
if let Some(value) = ctx.let_bindings.get(&key) {
return value.clone();
}
format!("(drilled {pix_ref} path={subtree_path} query={query})")
}
pub fn trail_navigation(navigate_ref: &str, ctx: &DispatchCtx) -> String {
let key = format!("__navigate_{navigate_ref}_trail");
if let Some(value) = ctx.let_bindings.get(&key) {
return value.clone();
}
format!("(trail of {navigate_ref})")
}
pub async fn run_hibernate(
node: &IRHibernateStep,
ctx: &mut DispatchCtx,
) -> Result<NodeOutcome, DispatchError> {
if ctx.cancel.is_cancelled() {
return Err(DispatchError::UpstreamCancelled);
}
let step_index = ctx.step_counter;
ctx.step_counter += 1;
if !ctx.branch_path.is_empty() {
return Err(DispatchError::BackendError {
name: "hibernate".to_string(),
message: format!(
"hibernate '{}' appears inside a nested branch ({}); suspending \
a branch has no defined continuation shape yet, so it is \
refused rather than half-suspended. Place hibernate at \
top-level flow position.",
node.event_name,
ctx.branch_path_string()
),
});
}
let step_name = if node.event_name.is_empty() {
"Hibernate".to_string()
} else {
node.event_name.clone()
};
emit_step_start(ctx, &step_name, step_index, "hibernate")?;
Ok(NodeOutcome::Hibernated {
event_name: node.event_name.clone(),
timeout: node.timeout.clone(),
step_index,
})
}
pub async fn run_drill(
node: &IRDrillStep,
ctx: &mut DispatchCtx,
) -> Result<NodeOutcome, DispatchError> {
if ctx.cancel.is_cancelled() {
return Err(DispatchError::UpstreamCancelled);
}
let step_index = ctx.step_counter;
ctx.step_counter += 1;
let step_name = if node.output_name.is_empty() {
"Drill".to_string()
} else {
node.output_name.clone()
};
emit_step_start(ctx, &step_name, step_index, "drill")?;
let query = crate::exec_context::interpolate_vars(&node.query, &ctx.let_bindings);
let real = crate::flow_dispatcher::cognitive::resolve_pix_source("", &node.pix_ref, ctx)
.and_then(|source| crate::pix_navigator::index_markdown(&source).ok())
.and_then(|tree| {
let titles: Vec<&str> = node.subtree_path.split('.').collect();
let subtree_root = crate::pix_navigator::find_by_title_path(&tree, &titles)?;
let cfg = crate::pix_navigator::NavConfig::default();
let scorer = crate::pix_navigator::LexicalScorer::default();
let r = crate::pix_navigator::pix_drill(&tree, subtree_root, &query, &cfg, &scorer)?;
Some(
r.leaves
.iter()
.map(|l| l.content.as_str())
.collect::<Vec<_>>()
.join("\n\n---\n\n"),
)
});
let result =
real.unwrap_or_else(|| drill_pix_subtree(&node.pix_ref, &node.subtree_path, &query, ctx));
if !node.output_name.is_empty() {
ctx.let_bindings.insert(node.output_name.clone(), result.clone());
}
emit_step_complete(ctx, &step_name, step_index, &result, 0)?;
Ok(NodeOutcome::Completed {
output: result,
tokens_emitted: 0,
step_index,
})
}
pub async fn run_trail(
node: &IRTrailStep,
ctx: &mut DispatchCtx,
) -> Result<NodeOutcome, DispatchError> {
if ctx.cancel.is_cancelled() {
return Err(DispatchError::UpstreamCancelled);
}
let step_index = ctx.step_counter;
ctx.step_counter += 1;
let step_name = if node.navigate_ref.is_empty() {
"Trail".to_string()
} else {
node.navigate_ref.clone()
};
emit_step_start(ctx, &step_name, step_index, "trail")?;
let result = trail_navigation(&node.navigate_ref, ctx);
if !node.navigate_ref.is_empty() {
ctx.let_bindings
.insert(format!("{}_trail_walked", node.navigate_ref), result.clone());
}
emit_step_complete(ctx, &step_name, step_index, &result, 0)?;
Ok(NodeOutcome::Completed {
output: result,
tokens_emitted: 0,
step_index,
})
}
fn emit_step_start(
ctx: &mut DispatchCtx,
step_name: &str,
step_index: usize,
step_type: &str,
) -> Result<(), DispatchError> {
ctx.tx
.send(FlowExecutionEvent::StepStart {
step_name: step_name.to_string(),
step_index,
step_type: step_type.to_string(),
branch_path: ctx.branch_path_string(),
timestamp_ms: now_ms(),
})
.map_err(|_| DispatchError::ChannelClosed)
}
fn emit_step_complete(
ctx: &mut DispatchCtx,
step_name: &str,
step_index: usize,
full_output: &str,
tokens_output: u64,
) -> Result<(), DispatchError> {
ctx.tx
.send(FlowExecutionEvent::StepComplete {
step_name: step_name.to_string(),
step_index,
success: true,
full_output: full_output.to_string(),
tokens_input: 0,
tokens_output,
branch_path: ctx.branch_path_string(),
timestamp_ms: now_ms(),
})
.map_err(|_| DispatchError::ChannelClosed)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::cancel_token::CancellationFlag;
use tokio::sync::mpsc;
fn fresh_ctx() -> (
DispatchCtx,
mpsc::UnboundedReceiver<FlowExecutionEvent>,
) {
let (tx, rx) = mpsc::unbounded_channel();
let ctx = DispatchCtx::new(
"TestFlow",
"stub",
"",
CancellationFlag::new(),
tx,
);
(ctx, rx)
}
#[tokio::test]
async fn run_hibernate_returns_the_suspension_outcome() {
let (mut ctx, _rx) = fresh_ctx();
let node = IRHibernateStep {
node_type: "hibernate",
source_line: 7,
source_column: 3,
event_name: "user_action".into(),
timeout: "5m".into(),
};
match run_hibernate(&node, &mut ctx).await.unwrap() {
NodeOutcome::Hibernated {
event_name,
timeout,
..
} => {
assert_eq!(event_name, "user_action");
assert_eq!(timeout, "5m");
}
other => panic!("expected Hibernated, got {other:?}"),
}
}
#[tokio::test]
async fn run_hibernate_refuses_inside_a_branch() {
let (mut ctx, _rx) = fresh_ctx();
ctx.branch_path.push("par[0]".into());
let node = IRHibernateStep {
node_type: "hibernate",
source_line: 7,
source_column: 3,
event_name: "ev".into(),
timeout: String::new(),
};
let err = run_hibernate(&node, &mut ctx).await.err().expect("refuses");
match err {
DispatchError::BackendError { message, .. } => {
assert!(message.contains("no defined"), "{message}");
}
other => panic!("{other:?}"),
}
}
#[test]
fn drill_returns_stored_value_when_present() {
let (mut ctx, _rx) = fresh_ctx();
ctx.let_bindings
.insert("__pix_main_root.leaf".into(), "leaf-content".into());
assert_eq!(
drill_pix_subtree("main", "root.leaf", "q", &ctx),
"leaf-content"
);
}
#[test]
fn drill_returns_placeholder_when_not_stored() {
let (ctx, _rx) = fresh_ctx();
assert_eq!(
drill_pix_subtree("main", "root", "what", &ctx),
"(drilled main path=root query=what)"
);
}
#[test]
fn trail_returns_stored_value_when_present() {
let (mut ctx, _rx) = fresh_ctx();
ctx.let_bindings
.insert("__navigate_navA_trail".into(), "step1>step2>step3".into());
assert_eq!(trail_navigation("navA", &ctx), "step1>step2>step3");
}
#[test]
fn trail_returns_placeholder_when_not_stored() {
let (ctx, _rx) = fresh_ctx();
assert_eq!(trail_navigation("nav_missing", &ctx), "(trail of nav_missing)");
}
#[tokio::test]
async fn run_drill_binds_result_under_output_name() {
let (mut ctx, mut rx) = fresh_ctx();
ctx.let_bindings
.insert("__pix_law_corpus_civil.article23".into(), "Art. 23 text".into());
let node = IRDrillStep {
node_type: "drill",
source_line: 0,
source_column: 0,
pix_ref: "law_corpus".into(),
subtree_path: "civil.article23".into(),
query: "interpret".into(),
output_name: "article_text".into(),
};
run_drill(&node, &mut ctx).await.unwrap();
assert_eq!(ctx.let_bindings.get("article_text").unwrap(), "Art. 23 text");
let first = rx.try_recv().unwrap();
match first {
FlowExecutionEvent::StepStart { step_type, .. } => {
assert_eq!(step_type, "drill");
}
e => panic!("expected StepStart, got {e:?}"),
}
}
#[tokio::test]
async fn run_drill_real_navigates_subtree_when_source_in_scope() {
let (mut ctx, _rx) = fresh_ctx();
ctx.let_bindings.insert(
"__pix_ContractIndex_source".into(),
"# Liability\n## Limitation\nLiability is capped at the contract value.\n\
# Termination\n## Notice\nThirty days notice."
.into(),
);
let node = IRDrillStep {
node_type: "drill",
source_line: 0,
source_column: 0,
pix_ref: "ContractIndex".into(),
subtree_path: "liability.limitation".into(),
query: "cap on liability".into(),
output_name: "clause".into(),
};
run_drill(&node, &mut ctx).await.unwrap();
assert!(
ctx.let_bindings.get("clause").unwrap().contains("capped at the contract value"),
"drill should return the Limitation leaf content"
);
}
#[tokio::test]
async fn run_drill_placeholder_when_pix_not_seeded() {
let (mut ctx, _rx) = fresh_ctx();
let node = IRDrillStep {
node_type: "drill",
source_line: 0,
source_column: 0,
pix_ref: "unknown".into(),
subtree_path: "root".into(),
query: "q".into(),
output_name: "result".into(),
};
run_drill(&node, &mut ctx).await.unwrap();
assert_eq!(
ctx.let_bindings.get("result").unwrap(),
"(drilled unknown path=root query=q)"
);
}
#[tokio::test]
async fn run_trail_binds_under_canonical_key() {
let (mut ctx, mut rx) = fresh_ctx();
ctx.let_bindings
.insert("__navigate_search1_trail".into(), "n1->n2->n3".into());
let node = IRTrailStep {
node_type: "trail",
source_line: 0,
source_column: 0,
navigate_ref: "search1".into(),
};
run_trail(&node, &mut ctx).await.unwrap();
assert_eq!(
ctx.let_bindings.get("search1_trail_walked").unwrap(),
"n1->n2->n3"
);
let first = rx.try_recv().unwrap();
match first {
FlowExecutionEvent::StepStart { step_type, .. } => {
assert_eq!(step_type, "trail");
}
e => panic!("expected StepStart, got {e:?}"),
}
}
#[tokio::test]
async fn every_pix_handler_short_circuits_on_cancel() {
let cancel = CancellationFlag::new();
cancel.cancel();
let (tx, _rx) = mpsc::unbounded_channel();
let mut ctx = DispatchCtx::new("F", "stub", "", cancel, tx);
let h = IRHibernateStep {
node_type: "hibernate",
source_line: 0,
source_column: 0,
event_name: "e".into(),
timeout: "1s".into(),
};
assert!(matches!(
run_hibernate(&h, &mut ctx).await,
Err(DispatchError::UpstreamCancelled)
));
let d = IRDrillStep {
node_type: "drill",
source_line: 0,
source_column: 0,
pix_ref: "p".into(),
subtree_path: "s".into(),
query: "q".into(),
output_name: "o".into(),
};
assert!(matches!(
run_drill(&d, &mut ctx).await,
Err(DispatchError::UpstreamCancelled)
));
let t = IRTrailStep {
node_type: "trail",
source_line: 0,
source_column: 0,
navigate_ref: "n".into(),
};
assert!(matches!(
run_trail(&t, &mut ctx).await,
Err(DispatchError::UpstreamCancelled)
));
}
}