rho-coding-agent 1.26.0

A lightweight agent harness inspired by Pi
Documentation
use super::{
    FrozenWorkflow, NodeId, NodeResetReason, NodeState, NodeTerminalState, RunLifecycle,
    WorkflowError, WorkflowOutcome, WorkflowResult, WorkflowState,
};

pub(crate) fn validate_transition(
    node: &NodeId,
    from: &NodeState,
    to: &NodeState,
) -> WorkflowResult<()> {
    let allowed = matches!(
        (from, to),
        (NodeState::Pending, NodeState::Ready)
            | (
                NodeState::Pending,
                NodeState::Terminal {
                    outcome: NodeTerminalState::Skipped
                        | NodeTerminalState::Blocked
                        | NodeTerminalState::Cancellation
                }
            )
            | (NodeState::Ready, NodeState::Running { .. })
            | (
                NodeState::Ready,
                NodeState::Terminal {
                    outcome: NodeTerminalState::Cancellation
                }
            )
            | (NodeState::Running { .. }, NodeState::Terminal { .. })
    );
    if allowed {
        Ok(())
    } else {
        Err(WorkflowError::IllegalTransition {
            node: node.clone(),
            from: format!("{from:?}"),
            to: format!("{to:?}"),
        })
    }
}

pub(crate) fn validate_reset_transition(
    node: &NodeId,
    from: &NodeState,
    reason: NodeResetReason,
    target: &NodeState,
) -> WorkflowResult<()> {
    let allowed = matches!(
        (from, reason, target),
        (
            NodeState::Running { .. },
            NodeResetReason::InterruptedRecovery,
            NodeState::Ready
        ) | (
            NodeState::Terminal {
                outcome: NodeTerminalState::Cancellation
            },
            NodeResetReason::CleanCancellation,
            NodeState::Pending | NodeState::Ready
        )
    );
    if allowed {
        Ok(())
    } else {
        Err(WorkflowError::IllegalTransition {
            node: node.clone(),
            from: format!("{from:?}"),
            to: format!("{target:?} ({reason:?})"),
        })
    }
}

pub(crate) fn derive_workflow_outcome(
    workflow: &FrozenWorkflow,
    state: &WorkflowState,
) -> Option<WorkflowOutcome> {
    if state.lifecycle != RunLifecycle::Completed {
        return None;
    }
    let required = workflow
        .graph
        .nodes
        .values()
        .filter(|node| !node.allow_failure);
    let mut outcomes = Vec::new();
    for node in required {
        let outcome = state.nodes.get(&node.id).and_then(NodeState::terminal)?;
        outcomes.push(outcome);
    }
    let mut cancellation = state.nodes.values().any(|node| {
        matches!(
            node,
            NodeState::Terminal {
                outcome: NodeTerminalState::Cancellation
            }
        )
    });
    let mut denial = false;
    let mut failure = false;
    let mut blocked = false;
    for outcome in outcomes {
        match outcome {
            NodeTerminalState::Cancellation => cancellation = true,
            NodeTerminalState::Denial => denial = true,
            NodeTerminalState::Failure => failure = true,
            NodeTerminalState::Blocked => blocked = true,
            NodeTerminalState::Success | NodeTerminalState::Skipped => {}
        }
    }
    Some(if cancellation {
        WorkflowOutcome::Cancellation
    } else if denial {
        WorkflowOutcome::Denial
    } else if failure {
        WorkflowOutcome::Failure
    } else if blocked {
        WorkflowOutcome::Blocked
    } else {
        WorkflowOutcome::Success
    })
}

pub(crate) fn validate_lifecycle_transition(
    workflow: &FrozenWorkflow,
    state: &WorkflowState,
    target: RunLifecycle,
) -> WorkflowResult<Option<WorkflowOutcome>> {
    let allowed = state.lifecycle == target
        || matches!(
            (state.lifecycle, target),
            (RunLifecycle::Planned, RunLifecycle::Running)
                | (
                    RunLifecycle::Running,
                    RunLifecycle::Cancelling | RunLifecycle::NeedsRecovery
                )
                | (
                    RunLifecycle::Cancelling,
                    RunLifecycle::Completed | RunLifecycle::Running | RunLifecycle::NeedsRecovery
                )
                | (RunLifecycle::NeedsRecovery, RunLifecycle::Running)
                | (RunLifecycle::Completed, RunLifecycle::Running)
                | (RunLifecycle::Running, RunLifecycle::Completed)
        );
    if !allowed {
        return Err(WorkflowError::Scheduler(format!(
            "illegal workflow lifecycle transition from {:?} to {target:?}",
            state.lifecycle
        )));
    }
    if target != RunLifecycle::Completed {
        return Ok(None);
    }
    if state.nodes.values().any(|node| node.terminal().is_none()) {
        return Err(WorkflowError::Scheduler(
            "completed workflow contains non-terminal nodes".to_owned(),
        ));
    }
    let mut completed = state.clone();
    completed.lifecycle = RunLifecycle::Completed;
    derive_workflow_outcome(workflow, &completed)
        .map(Some)
        .ok_or_else(|| WorkflowError::Scheduler("completed workflow has no outcome".to_owned()))
}

#[cfg(test)]
#[path = "transition_tests.rs"]
mod tests;