use std::fmt;
use std::path::PathBuf;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum TaskState {
Queued,
Running,
Blocked,
AwaitingReview,
Committed,
Failed,
Cancelled,
}
impl TaskState {
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,
})
}
pub fn allowed_next(self) -> &'static [TaskState] {
match self {
Self::Queued => &[Self::Running, Self::Failed, Self::Cancelled],
Self::Running => &[
Self::Blocked,
Self::AwaitingReview,
Self::Failed,
Self::Cancelled,
],
Self::Blocked => &[
Self::Running,
Self::AwaitingReview,
Self::Failed,
Self::Cancelled,
],
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)
}
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())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum BlockedKind {
PermissionPrompt,
Question,
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())
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Repo {
pub id: i64,
pub path: PathBuf,
pub name: String,
pub ignored: bool,
pub discovered_at: DateTime<Utc>,
pub last_seen_at: DateTime<Utc>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Task {
pub id: i64,
pub title: String,
pub prompt: String,
pub state: TaskState,
pub blocked_kind: Option<BlockedKind>,
pub blocked_reason: Option<String>,
pub failure_reason: Option<String>,
pub workspace_dir: PathBuf,
pub session_name: Option<String>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TaskRepo {
pub task_id: i64,
pub repo_id: i64,
pub worktree_path: Option<PathBuf>,
pub branch: Option<String>,
pub base_ref: Option<String>,
}
impl TaskRepo {
pub fn is_provisioned(&self) -> bool {
self.worktree_path.is_some()
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Event {
pub id: i64,
pub task_id: Option<i64>,
pub kind: String,
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() {
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"
);
}
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");
}
}
}