Skip to main content

sloop/domain/
work.rs

1use serde::{Deserialize, Serialize};
2
3use crate::domain::ticket::TicketState;
4use crate::outcome::Outcome;
5
6const NEEDS_REVIEW_REASON: &str = "needs-review";
7
8#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
9pub struct OwnerId(pub String);
10
11#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
12pub struct SourceVersion(pub String);
13
14#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
15pub struct TicketRef {
16    pub id: String,
17    pub source: String,
18    pub source_ref: Option<String>,
19}
20
21#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
22pub struct ExecutionHints {
23    pub worktree: Option<String>,
24    pub trigger_id: Option<String>,
25    pub target: Option<String>,
26    pub model: Option<String>,
27    pub effort: Option<String>,
28    pub flow: Option<String>,
29}
30
31#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
32pub struct WorkTicket {
33    pub id: String,
34    pub project_id: String,
35    pub name: String,
36    pub body: String,
37    pub state: WorkTicketState,
38    pub blocked_by: Vec<String>,
39    pub attempts: u32,
40    pub hints: ExecutionHints,
41    pub version: SourceVersion,
42}
43
44#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
45#[serde(rename_all = "snake_case")]
46pub enum WorkTicketState {
47    Ready,
48    Blocked,
49    Held { reason: String },
50    Claimed { by: OwnerId },
51    Done,
52    Failed,
53}
54
55impl WorkTicketState {
56    pub fn from_ticket_state(
57        state: TicketState,
58        blocked: bool,
59        held_reason: String,
60        owner: OwnerId,
61    ) -> Self {
62        match state {
63            TicketState::Ready if blocked => Self::Blocked,
64            TicketState::Ready => Self::Ready,
65            TicketState::Held => Self::Held {
66                reason: held_reason,
67            },
68            TicketState::Claimed => Self::Claimed { by: owner },
69            TicketState::Merged => Self::Done,
70            TicketState::Failed => Self::Failed,
71            TicketState::NeedsReview => Self::Held {
72                reason: NEEDS_REVIEW_REASON.into(),
73            },
74        }
75    }
76
77    pub fn to_ticket_state(&self) -> TicketState {
78        match self {
79            Self::Ready | Self::Blocked => TicketState::Ready,
80            Self::Held { reason } if reason == NEEDS_REVIEW_REASON => TicketState::NeedsReview,
81            Self::Held { .. } => TicketState::Held,
82            Self::Claimed { .. } => TicketState::Claimed,
83            Self::Done => TicketState::Merged,
84            Self::Failed => TicketState::Failed,
85        }
86    }
87}
88
89#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
90#[serde(rename_all = "snake_case")]
91pub enum Disposition {
92    Complete,
93    /// Return work to ready. A timestamp also reactivates the consumed local
94    /// request at that deadline; `None` releases without scheduling a rerun.
95    Retry {
96        not_before_ms: Option<i64>,
97    },
98    Park {
99        reason: String,
100    },
101    Abandon,
102}
103
104#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
105pub struct WorkOutcome {
106    pub ticket_id: String,
107    pub owner: OwnerId,
108    #[serde(with = "outcome_serde")]
109    pub verdict: Outcome,
110    pub branch: Option<String>,
111    pub commit_count: u32,
112    pub attempt: u32,
113    pub finished_at_ms: i64,
114}
115
116mod outcome_serde {
117    use serde::{Deserialize, Deserializer, Serializer, de::Error};
118
119    use crate::outcome::Outcome;
120
121    pub fn serialize<S>(outcome: &Outcome, serializer: S) -> Result<S::Ok, S::Error>
122    where
123        S: Serializer,
124    {
125        serializer.serialize_str(outcome.as_str())
126    }
127
128    pub fn deserialize<'de, D>(deserializer: D) -> Result<Outcome, D::Error>
129    where
130        D: Deserializer<'de>,
131    {
132        let value = String::deserialize(deserializer)?;
133        match value.as_str() {
134            "merged" => Ok(Outcome::Merged),
135            "failed" => Ok(Outcome::Failed),
136            "needs_review" => Ok(Outcome::NeedsReview),
137            "cancelled" => Ok(Outcome::Cancelled),
138            "rate_limited" => Ok(Outcome::RateLimited),
139            "orphaned" => Ok(Outcome::Orphaned),
140            _ => Err(D::Error::unknown_variant(
141                &value,
142                &[
143                    "merged",
144                    "failed",
145                    "needs_review",
146                    "cancelled",
147                    "rate_limited",
148                    "orphaned",
149                ],
150            )),
151        }
152    }
153}
154
155#[cfg(test)]
156mod tests {
157    use super::*;
158
159    #[test]
160    fn ticket_states_round_trip_through_work_ticket_states() {
161        let owner = OwnerId("daemon-1".into());
162        for (state, expected) in [
163            (TicketState::Ready, WorkTicketState::Ready),
164            (
165                TicketState::Held,
166                WorkTicketState::Held {
167                    reason: "operator".into(),
168                },
169            ),
170            (
171                TicketState::Claimed,
172                WorkTicketState::Claimed { by: owner.clone() },
173            ),
174            (TicketState::Merged, WorkTicketState::Done),
175            (TicketState::Failed, WorkTicketState::Failed),
176            (
177                TicketState::NeedsReview,
178                WorkTicketState::Held {
179                    reason: NEEDS_REVIEW_REASON.into(),
180                },
181            ),
182        ] {
183            let work_state =
184                WorkTicketState::from_ticket_state(state, false, "operator".into(), owner.clone());
185            assert_eq!(work_state, expected);
186            assert_eq!(work_state.to_ticket_state(), state);
187        }
188    }
189
190    #[test]
191    fn blocked_is_the_portable_view_of_a_blocked_ready_ticket() {
192        let state = WorkTicketState::from_ticket_state(
193            TicketState::Ready,
194            true,
195            String::new(),
196            OwnerId(String::new()),
197        );
198
199        assert_eq!(state, WorkTicketState::Blocked);
200        assert_eq!(state.to_ticket_state(), TicketState::Ready);
201    }
202
203    #[test]
204    fn work_outcomes_round_trip_all_verdicts_through_json() {
205        for verdict in [
206            Outcome::Merged,
207            Outcome::Failed,
208            Outcome::NeedsReview,
209            Outcome::Cancelled,
210            Outcome::RateLimited,
211            Outcome::Orphaned,
212        ] {
213            let outcome = WorkOutcome {
214                ticket_id: "T1".into(),
215                owner: OwnerId("daemon-1".into()),
216                verdict,
217                branch: Some("sloop/t1".into()),
218                commit_count: 2,
219                attempt: 1,
220                finished_at_ms: 42,
221            };
222            let json = serde_json::to_string(&outcome).unwrap();
223            let restored: WorkOutcome = serde_json::from_str(&json).unwrap();
224
225            assert_eq!(restored, outcome);
226        }
227    }
228}