ralph-agent-loop 0.4.0

A Rust CLI for managing AI agent loops with a structured JSON task queue
Documentation
//! Shared helpers for machine command handlers.
//!
//! Responsibilities:
//! - Build shared machine documents reused across handlers.
//! - Centralize queue-path and config-safety shaping for machine responses.
//! - Reuse small queue/done helper semantics across machine subcommands.
//! - Convert operator-facing resume decisions into machine contract payloads.
//!
//! Not handled here:
//! - Clap argument definitions.
//! - JSON stdout/stderr emission.
//! - Queue/task/run command routing.
//!
//! Invariants/assumptions:
//! - Machine config documents remain versioned through `crate::contracts` constants.
//! - Done-queue omission semantics match the existing machine/read-only behavior.
//! - Resume previews must be read-only and never mutate persisted session state.

use std::path::Path;

use anyhow::Result;

use crate::config;
use crate::contracts::{
    GitPublishMode, GitRevertMode, MACHINE_CONFIG_RESOLVE_VERSION,
    MACHINE_WORKSPACE_OVERVIEW_VERSION, MachineConfigResolveDocument, MachineConfigSafetySummary,
    MachineQueuePaths, MachineQueueReadDocument, MachineResumeDecision,
    MachineWorkspaceOverviewDocument, QueueFile,
};
use crate::queue;
use crate::queue::operations::{RunnableSelectionOptions, queue_runnability_report};
use crate::session::{ResumeBehavior, ResumeDecisionMode, ResumeReason, ResumeScope, ResumeStatus};

pub(super) fn build_config_resolve_document(
    resolved: &config::Resolved,
    repo_trusted: bool,
    dirty_repo: bool,
    resume_preview: Option<MachineResumeDecision>,
) -> MachineConfigResolveDocument {
    MachineConfigResolveDocument {
        version: MACHINE_CONFIG_RESOLVE_VERSION,
        paths: queue_paths(resolved),
        safety: MachineConfigSafetySummary {
            repo_trusted,
            dirty_repo,
            git_publish_mode: resolved
                .config
                .agent
                .effective_git_publish_mode()
                .unwrap_or(GitPublishMode::Off),
            approval_mode: resolved.config.agent.effective_approval_mode(),
            ci_gate_enabled: resolved.config.agent.ci_gate_enabled(),
            git_revert_mode: resolved
                .config
                .agent
                .git_revert_mode
                .unwrap_or(GitRevertMode::Ask),
            parallel_configured: resolved.config.parallel.workers.is_some(),
            execution_interactivity: "noninteractive_streaming".to_string(),
            interactive_approval_supported: false,
        },
        config: resolved.config.clone(),
        resume_preview,
    }
}

pub(super) fn machine_safety_context(resolved: &config::Resolved) -> Result<(bool, bool)> {
    let repo_trust = config::load_repo_trust(&resolved.repo_root)?;
    let dirty_repo = crate::git::status_porcelain(&resolved.repo_root)
        .map(|status| !status.trim().is_empty())
        .unwrap_or(false);
    Ok((repo_trust.is_trusted(), dirty_repo))
}

pub(super) fn build_queue_read_document(
    resolved: &config::Resolved,
) -> Result<MachineQueueReadDocument> {
    let active = queue::load_queue(&resolved.queue_path)?;
    let done = queue::load_queue_or_default(&resolved.done_path)?;
    let done_ref = done_queue_ref(&done, &resolved.done_path);
    let options = RunnableSelectionOptions::new(false, true);
    let runnability = queue_runnability_report(&active, done_ref, options)?;
    let next_runnable_task_id =
        queue::operations::next_runnable_task(&active, done_ref).map(|task| task.id.clone());

    Ok(MachineQueueReadDocument {
        version: crate::contracts::MACHINE_QUEUE_READ_VERSION,
        paths: queue_paths(resolved),
        active,
        done,
        next_runnable_task_id,
        runnability: serde_json::to_value(runnability)?,
    })
}

pub(super) fn build_workspace_overview_document(
    resolved: &config::Resolved,
    repo_trusted: bool,
    dirty_repo: bool,
    resume_preview: Option<MachineResumeDecision>,
) -> Result<MachineWorkspaceOverviewDocument> {
    Ok(MachineWorkspaceOverviewDocument {
        version: MACHINE_WORKSPACE_OVERVIEW_VERSION,
        queue: build_queue_read_document(resolved)?,
        config: build_config_resolve_document(resolved, repo_trusted, dirty_repo, resume_preview),
    })
}

pub(super) fn build_resume_preview(
    resolved: &config::Resolved,
    explicit_task_id: Option<&str>,
    auto_resume: bool,
    non_interactive: bool,
    announce_missing_session: bool,
) -> anyhow::Result<Option<MachineResumeDecision>> {
    let queue_file = crate::queue::load_queue(&resolved.queue_path)?;
    let resolution = crate::session::resolve_run_session_decision(
        &resolved.repo_root.join(".ralph/cache"),
        &queue_file,
        crate::session::RunSessionDecisionOptions {
            timeout_hours: resolved.config.agent.session_timeout_hours,
            behavior: if auto_resume {
                ResumeBehavior::AutoResume
            } else {
                ResumeBehavior::Prompt
            },
            non_interactive,
            explicit_task_id,
            announce_missing_session,
            mode: ResumeDecisionMode::Preview,
        },
    )?;

    Ok(resolution
        .decision
        .as_ref()
        .map(machine_resume_decision_from_runtime))
}

pub(super) fn machine_resume_decision_from_runtime(
    decision: &crate::session::ResumeDecision,
) -> MachineResumeDecision {
    MachineResumeDecision {
        status: machine_resume_status(decision.status).to_string(),
        scope: machine_resume_scope(decision.scope).to_string(),
        reason: machine_resume_reason(decision.reason).to_string(),
        task_id: decision.task_id.clone(),
        message: decision.message.clone(),
        detail: decision.detail.clone(),
    }
}

fn machine_resume_status(status: ResumeStatus) -> &'static str {
    match status {
        ResumeStatus::ResumingSameSession => "resuming_same_session",
        ResumeStatus::FallingBackToFreshInvocation => "falling_back_to_fresh_invocation",
        ResumeStatus::RefusingToResume => "refusing_to_resume",
    }
}

fn machine_resume_scope(scope: ResumeScope) -> &'static str {
    match scope {
        ResumeScope::RunSession => "run_session",
        ResumeScope::ContinueSession => "continue_session",
    }
}

fn machine_resume_reason(reason: ResumeReason) -> &'static str {
    match reason {
        ResumeReason::NoSession => "no_session",
        ResumeReason::SessionValid => "session_valid",
        ResumeReason::SessionTimedOutConfirmed => "session_timed_out_confirmed",
        ResumeReason::SessionStale => "session_stale",
        ResumeReason::SessionDeclined => "session_declined",
        ResumeReason::ResumeConfirmationRequired => "resume_confirmation_required",
        ResumeReason::SessionTimedOutRequiresConfirmation => {
            "session_timed_out_requires_confirmation"
        }
        ResumeReason::ExplicitTaskSelectionOverridesSession => {
            "explicit_task_selection_overrides_session"
        }
        ResumeReason::ResumeTargetMissing => "resume_target_missing",
        ResumeReason::ResumeTargetTerminal => "resume_target_terminal",
        ResumeReason::RunnerSessionInvalid => "runner_session_invalid",
        ResumeReason::MissingRunnerSessionId => "missing_runner_session_id",
    }
}

pub(super) fn done_queue_ref<'a>(done: &'a QueueFile, done_path: &Path) -> Option<&'a QueueFile> {
    if done.tasks.is_empty() && !done_path.exists() {
        None
    } else {
        Some(done)
    }
}

pub(super) fn queue_paths(resolved: &config::Resolved) -> MachineQueuePaths {
    MachineQueuePaths {
        repo_root: resolved.repo_root.display().to_string(),
        queue_path: resolved.queue_path.display().to_string(),
        done_path: resolved.done_path.display().to_string(),
        project_config_path: resolved
            .project_config_path
            .as_ref()
            .map(|path| path.display().to_string()),
        global_config_path: resolved
            .global_config_path
            .as_ref()
            .map(|path| path.display().to_string()),
    }
}

pub(super) fn queue_max_dependency_depth(resolved: &config::Resolved) -> u8 {
    resolved.config.queue.max_dependency_depth.unwrap_or(10)
}

pub(crate) fn machine_queue_validate_command() -> &'static str {
    "ralph machine queue validate"
}

pub(crate) fn machine_queue_graph_command() -> &'static str {
    "ralph machine queue graph"
}

pub(crate) fn machine_queue_repair_command(dry_run: bool) -> &'static str {
    if dry_run {
        "ralph machine queue repair --dry-run"
    } else {
        "ralph machine queue repair"
    }
}

pub(crate) fn machine_queue_undo_dry_run_command() -> &'static str {
    "ralph machine queue undo --dry-run"
}

pub(crate) fn machine_queue_undo_restore_command() -> &'static str {
    "ralph machine queue undo --id <SNAPSHOT_ID>"
}

pub(crate) fn machine_task_mutate_command(dry_run: bool) -> &'static str {
    if dry_run {
        "ralph machine task mutate --dry-run --input <PATH>"
    } else {
        "ralph machine task mutate --input <PATH>"
    }
}

pub(crate) fn machine_task_decompose_command(write: bool, suffix: &'static str) -> String {
    if write {
        format!("ralph machine task decompose --write {suffix}")
    } else {
        format!("ralph machine task decompose {suffix}")
    }
}

pub(crate) fn machine_run_one_resume_command() -> &'static str {
    "ralph machine run one --resume"
}

pub(crate) fn machine_run_parallel_status_command() -> &'static str {
    "ralph machine run parallel-status"
}

pub(crate) fn machine_run_loop_command(parallel: bool, force: bool) -> &'static str {
    match (parallel, force) {
        (true, false) => "ralph machine run loop --resume --max-tasks 0 --parallel <N>",
        (true, true) => "ralph machine run loop --resume --max-tasks 0 --force --parallel <N>",
        (false, false) => "ralph machine run loop --resume --max-tasks 0",
        (false, true) => "ralph machine run loop --resume --max-tasks 0 --force",
    }
}

pub(crate) fn machine_doctor_report_command() -> &'static str {
    "ralph machine doctor report"
}