loopflow 0.12.19

Run steps and flows with coding agents
Documentation
use std::path::Path;
use std::sync::Arc;

use anyhow::{bail, Context};

use crate::durable::WorkRef;
use crate::lf::SessionCommand;
use crate::ops::human_session::{FlowDecision, OpenMode, SessionKind, SessionRecord, SessionState};
use crate::store::{open_store, storage_config_from_env, Store};

pub fn run(command: &SessionCommand) -> anyhow::Result<()> {
    tokio::runtime::Runtime::new()?.block_on(run_async(command))
}

async fn run_async(command: &SessionCommand) -> anyhow::Result<()> {
    match command {
        SessionCommand::List { json, all } => {
            let store = open_shared_store().await?;
            list(&store, *json, *all).await
        }
        SessionCommand::Open {
            id,
            json,
            replace,
            try_open,
        } => {
            let mode = if *replace {
                OpenMode::Replace
            } else if *try_open {
                OpenMode::Try
            } else {
                OpenMode::Refuse
            };
            open(id, *json, mode).await
        }
        SessionCommand::Complete { id } => complete(id).await,
        SessionCommand::Ready { summary } => {
            let text = required_text(summary, "ready summary")?;
            let store = open_shared_store().await?;
            crate::ops::human_session::mark_ready(&store, &text).await?;
            println!("Session is ready for human action.");
            Ok(())
        }
        SessionCommand::Approve { id, summary } => {
            decide_flow(id, FlowDecision::Approve, summary, "approval summary").await
        }
        SessionCommand::Iterate { id, direction } => {
            decide_flow(id, FlowDecision::Iterate, direction, "iteration direction").await
        }
        SessionCommand::ServeFlow {
            task_id,
            flow,
            node_id,
            skill,
            iteration,
        } => {
            let store = open_shared_store().await?;
            crate::ops::human_session::serve_flow(
                store,
                task_id.clone(),
                flow.clone(),
                node_id.clone(),
                skill.clone(),
                *iteration,
            )
            .await
        }
        SessionCommand::ServeAsk { id } => crate::ops::human_session::serve_ask(id).await,
        SessionCommand::StopRun { run_id } => crate::ops::human_session::stop_run(run_id),
    }
}

async fn list(store: &Arc<Store>, json: bool, all: bool) -> anyhow::Result<()> {
    let mut sessions = crate::ops::human_session::list(store).await?;
    sessions = scope_to_repo(sessions, all);
    if json {
        println!("{}", serde_json::to_string_pretty(&sessions)?);
    } else if sessions.is_empty() {
        println!("No Sessions.");
    } else {
        for session in sessions {
            println!(
                "{}  {:<7} {}  {}",
                session.id,
                match session.state {
                    SessionState::Waiting => "waiting",
                    SessionState::Active => "active",
                    SessionState::Ready => "ready",
                    SessionState::Closed => "closed",
                },
                session.work.as_ref().map(WorkRef::id).unwrap_or("run"),
                session.title
            );
        }
    }
    Ok(())
}

async fn open(id: &str, json: bool, mode: OpenMode) -> anyhow::Result<()> {
    let store = open_shared_store().await?;
    let session = crate::ops::human_session::open(&store, id, mode, !json).await?;
    if json {
        println!("{}", serde_json::to_string_pretty(&session)?);
    }
    Ok(())
}

async fn complete(id: &str) -> anyhow::Result<()> {
    let store = open_shared_store().await?;
    let session = crate::ops::human_session::complete(&store, id).await?;
    match session.kind {
        SessionKind::Interactive => println!(
            "Session {} completed; its provider history remains resumable.",
            session.id
        ),
        SessionKind::Ask => println!(
            "Ask session completed: {}",
            session
                .ready_summary
                .expect("completed Ask Session has a ready summary")
        ),
        SessionKind::Flow => unreachable!("FlowStep Sessions cannot complete"),
    }
    Ok(())
}

async fn decide_flow(
    id: &str,
    decision: FlowDecision,
    args: &[String],
    label: &str,
) -> anyhow::Result<()> {
    let text = required_text(args, label)?;
    let store = open_shared_store().await?;
    crate::ops::human_session::decide(&store, id, decision, &text).await?;
    println!(
        "{}",
        match decision {
            FlowDecision::Approve => "Task FlowStep approved; the Task may continue.",
            FlowDecision::Iterate => {
                "Task FlowStep returned to autonomous work for another iteration."
            }
        }
    );
    Ok(())
}

fn scope_to_repo(sessions: Vec<SessionRecord>, all: bool) -> Vec<SessionRecord> {
    if all {
        return sessions;
    }
    let Some(scope) = crate::repository::CanonicalRepo::current() else {
        return sessions;
    };
    sessions
        .into_iter()
        .filter(|session| scope.contains(Path::new(&session.cwd)))
        .collect()
}

fn required_text(args: &[String], label: &str) -> anyhow::Result<String> {
    let text = args.join(" ").trim().to_string();
    if text.is_empty() {
        bail!("{label} cannot be empty");
    }
    Ok(text)
}

async fn open_shared_store() -> anyhow::Result<Arc<Store>> {
    let config = storage_config_from_env().context("resolve the shared Loopflow store")?;
    Ok(Arc::new(
        open_store(&config)
            .await
            .context("open the shared Loopflow store")?,
    ))
}

#[cfg(test)]
mod tests {
    use super::scope_to_repo;

    #[test]
    fn an_empty_session_list_stays_empty() {
        assert!(scope_to_repo(Vec::new(), true).is_empty());
    }
}