marver 0.0.9

A TUI workspace for AI agent sessions: tmux orchestration, git worktree management, and repo control in one place.
Documentation
//! Core domain types.
//!
//! The task state machine lives here. See `ARCHITECTURE.md` §4 for the agreed
//! lifecycle; [`TaskState::can_transition_to`] is the executable copy of it.

use std::fmt;
use std::path::PathBuf;

use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};

/// Where a task is in its lifecycle.
///
/// ```text
/// queued ──▶ running ⟷ blocked ──▶ awaiting-review ──▶ committed
///               ▲                          │
///               └────────── reject ────────┘
///
/// any non-terminal state ──▶ cancelled
/// queued | running | blocked ──▶ failed
/// ```
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum TaskState {
    /// Created, waiting for a concurrency slot.
    Queued,
    /// Agent is working.
    Running,
    /// Agent needs the user. Entered via the Claude Code `Notification` hook.
    Blocked,
    /// Agent finished; the diff needs review. Entered via the `Stop` hook.
    AwaitingReview,
    /// Changes committed locally. Terminal.
    Committed,
    /// The agent crashed, the session died, or setup never completed. Terminal.
    ///
    /// Retrying means creating a new task: the session behind a failed task is
    /// gone, so there is nothing to resume into.
    Failed,
    /// Abandoned by the user. Terminal.
    Cancelled,
}

impl TaskState {
    /// Every state, in lifecycle order. Useful for filters and exhaustive tests.
    pub const ALL: &'static [TaskState] = &[
        Self::Queued,
        Self::Running,
        Self::Blocked,
        Self::AwaitingReview,
        Self::Committed,
        Self::Failed,
        Self::Cancelled,
    ];

    pub fn as_str(self) -> &'static str {
        match self {
            Self::Queued => "queued",
            Self::Running => "running",
            Self::Blocked => "blocked",
            Self::AwaitingReview => "awaiting-review",
            Self::Committed => "committed",
            Self::Failed => "failed",
            Self::Cancelled => "cancelled",
        }
    }

    pub fn parse(s: &str) -> Option<Self> {
        Some(match s {
            "queued" => Self::Queued,
            "running" => Self::Running,
            "blocked" => Self::Blocked,
            "awaiting-review" => Self::AwaitingReview,
            "committed" => Self::Committed,
            "failed" => Self::Failed,
            "cancelled" => Self::Cancelled,
            _ => return None,
        })
    }

    /// Every state a task may legally move to from here.
    pub fn allowed_next(self) -> &'static [TaskState] {
        match self {
            Self::Queued => &[Self::Running, Self::Failed, Self::Cancelled],
            // Blocked and finished are both reachable while running.
            Self::Running => &[
                Self::Blocked,
                Self::AwaitingReview,
                Self::Failed,
                Self::Cancelled,
            ],
            // Unblocking may return to running, but it may also go straight to
            // review: Claude Code emits no hook when the user answers a
            // permission prompt, so the next thing marver hears from a blocked
            // agent is often the `Stop` that says it finished.
            Self::Blocked => &[
                Self::Running,
                Self::AwaitingReview,
                Self::Failed,
                Self::Cancelled,
            ],
            // Accept commits; reject resumes the same live session. Failure is
            // not reachable here — the agent has already finished its work.
            Self::AwaitingReview => &[Self::Committed, Self::Running, Self::Cancelled],
            Self::Committed => &[],
            Self::Failed => &[],
            Self::Cancelled => &[],
        }
    }

    pub fn can_transition_to(self, next: TaskState) -> bool {
        self.allowed_next().contains(&next)
    }

    /// Terminal states have no outgoing transitions.
    pub fn is_terminal(self) -> bool {
        self.allowed_next().is_empty()
    }
}

impl fmt::Display for TaskState {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        f.write_str(self.as_str())
    }
}

/// Why a task is blocked.
///
/// Each maps to a Claude Code `notification_type`, so all three are reported
/// rather than inferred.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum BlockedKind {
    /// Waiting on a permission prompt (`permission_prompt`).
    PermissionPrompt,
    /// Asked the user something (`elicitation_dialog`, `agent_needs_input`).
    Question,
    /// Went idle waiting for input (`idle_prompt`).
    Silence,
}

impl BlockedKind {
    pub fn as_str(self) -> &'static str {
        match self {
            Self::PermissionPrompt => "permission-prompt",
            Self::Question => "question",
            Self::Silence => "silence",
        }
    }

    pub fn parse(s: &str) -> Option<Self> {
        Some(match s {
            "permission-prompt" => Self::PermissionPrompt,
            "question" => Self::Question,
            "silence" => Self::Silence,
            _ => return None,
        })
    }
}

impl fmt::Display for BlockedKind {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        f.write_str(self.as_str())
    }
}

/// A git repository discovered under the scan root.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Repo {
    pub id: i64,
    /// Absolute path to the repository working directory.
    pub path: PathBuf,
    /// Directory name, used for display and worktree naming.
    pub name: String,
    /// Hidden from repo pickers without being forgotten.
    pub ignored: bool,
    pub discovered_at: DateTime<Utc>,
    /// Updated on every scan that still finds it; lets us spot vanished repos.
    pub last_seen_at: DateTime<Utc>,
}

/// The unit of work: one agent, one session, one workspace directory.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Task {
    pub id: i64,
    pub title: String,
    /// What the agent was asked to do.
    pub prompt: String,
    pub state: TaskState,
    /// Set only while `state` is [`TaskState::Blocked`].
    pub blocked_kind: Option<BlockedKind>,
    pub blocked_reason: Option<String>,
    /// Set only while `state` is [`TaskState::Failed`].
    pub failure_reason: Option<String>,
    /// Parent directory holding this task's worktrees; the session's cwd.
    pub workspace_dir: PathBuf,
    /// tmux session name, once one exists.
    pub session_name: Option<String>,
    pub created_at: DateTime<Utc>,
    pub updated_at: DateTime<Utc>,
}

/// A repo a task targets, and the worktree made for it once provisioned.
///
/// A task has one per selected repo — usually one, occasionally several, all
/// sitting side by side under [`Task::workspace_dir`].
///
/// The worktree fields are `None` while the task is still queued: it has chosen
/// its repos but owns nothing on disk yet. They are set together at launch.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TaskRepo {
    pub task_id: i64,
    pub repo_id: i64,
    /// Absolute path to the worktree, once it exists.
    pub worktree_path: Option<PathBuf>,
    /// Branch created for this task in this repo, once provisioned.
    pub branch: Option<String>,
    /// What `branch` was cut from, as resolved at provision time.
    pub base_ref: Option<String>,
}

impl TaskRepo {
    /// Whether a worktree exists on disk for this pairing.
    pub fn is_provisioned(&self) -> bool {
        self.worktree_path.is_some()
    }
}

/// An append-only record of something that happened.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Event {
    pub id: i64,
    /// `None` for events not tied to a task, such as a repo scan.
    pub task_id: Option<i64>,
    /// Dotted identifier, e.g. `task.transition` or `hook.notification`.
    pub kind: String,
    /// Arbitrary JSON payload.
    pub payload: serde_json::Value,
    pub created_at: DateTime<Utc>,
}

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

    #[test]
    fn state_strings_round_trip() {
        for &state in TaskState::ALL {
            assert_eq!(TaskState::parse(state.as_str()), Some(state));
        }
        assert_eq!(TaskState::parse("nonsense"), None);
    }

    #[test]
    fn blocked_kind_strings_round_trip() {
        for kind in [
            BlockedKind::PermissionPrompt,
            BlockedKind::Question,
            BlockedKind::Silence,
        ] {
            assert_eq!(BlockedKind::parse(kind.as_str()), Some(kind));
        }
        assert_eq!(BlockedKind::parse("nonsense"), None);
    }

    #[test]
    fn happy_path_is_walkable() {
        let path = [
            TaskState::Queued,
            TaskState::Running,
            TaskState::AwaitingReview,
            TaskState::Committed,
        ];
        for pair in path.windows(2) {
            assert!(
                pair[0].can_transition_to(pair[1]),
                "{} should reach {}",
                pair[0],
                pair[1]
            );
        }
    }

    #[test]
    fn blocking_round_trips_through_running() {
        assert!(TaskState::Running.can_transition_to(TaskState::Blocked));
        assert!(TaskState::Blocked.can_transition_to(TaskState::Running));
    }

    #[test]
    fn a_blocked_agent_can_finish_without_being_seen_to_resume() {
        // Answering a permission prompt emits no hook, so the next thing marver
        // hears is the `Stop` that means "done". Requiring a visible return to
        // running strands the task, and `blocked` occupies a concurrency slot.
        assert!(TaskState::Blocked.can_transition_to(TaskState::AwaitingReview));
    }

    #[test]
    fn rejection_resumes_the_same_session() {
        assert!(TaskState::AwaitingReview.can_transition_to(TaskState::Running));
    }

    #[test]
    fn only_the_three_end_states_are_terminal() {
        let terminal = [
            TaskState::Committed,
            TaskState::Failed,
            TaskState::Cancelled,
        ];
        for &state in TaskState::ALL {
            assert_eq!(
                state.is_terminal(),
                terminal.contains(&state),
                "{state} has the wrong terminality"
            );
        }
    }

    #[test]
    fn anything_unfinished_can_be_cancelled() {
        for &state in TaskState::ALL {
            if state.is_terminal() {
                continue;
            }
            assert!(
                state.can_transition_to(TaskState::Cancelled),
                "{state} should be cancellable"
            );
        }
    }

    #[test]
    fn failure_is_reachable_only_while_work_is_outstanding() {
        for &state in &[TaskState::Queued, TaskState::Running, TaskState::Blocked] {
            assert!(
                state.can_transition_to(TaskState::Failed),
                "{state} should be able to fail"
            );
        }
        // The agent has already finished by this point; there is nothing left
        // to crash. Abandoning the result is a cancellation, not a failure.
        assert!(!TaskState::AwaitingReview.can_transition_to(TaskState::Failed));
    }

    #[test]
    fn terminal_states_never_resume() {
        for &state in TaskState::ALL {
            if !state.is_terminal() {
                continue;
            }
            for &next in TaskState::ALL {
                assert!(
                    !state.can_transition_to(next),
                    "{state} should not reach {next}"
                );
            }
        }
    }

    #[test]
    fn queued_cannot_skip_running() {
        assert!(!TaskState::Queued.can_transition_to(TaskState::AwaitingReview));
        assert!(!TaskState::Queued.can_transition_to(TaskState::Committed));
        assert!(!TaskState::Queued.can_transition_to(TaskState::Blocked));
    }

    #[test]
    fn no_state_transitions_to_itself() {
        for &state in TaskState::ALL {
            assert!(!state.can_transition_to(state), "{state} loops on itself");
        }
    }
}