1use std::fmt;
7use std::path::PathBuf;
8use std::time::Duration;
9
10use async_trait::async_trait;
11
12use crate::domain::work::{
13 Disposition, OwnerId, SourceVersion, TicketRef, WorkOutcome, WorkTicket,
14};
15
16use crate::frontmatter::Frontmatter;
17
18pub(crate) mod exec;
19pub mod local;
20pub(crate) mod markdown;
21pub mod trigger;
22
23use exec::ExecTicketSource;
24use markdown::MarkdownTicketSource;
25
26#[derive(Debug, Clone)]
27pub(crate) struct AuthoredTicket {
28 pub frontmatter: Frontmatter,
29 pub body: String,
30 pub source: String,
31 pub source_ref: String,
32 pub file_path: Option<PathBuf>,
33 pub original_content: Option<String>,
34 pub validation_error: Option<String>,
35}
36
37#[derive(Debug, Clone, PartialEq, Eq)]
38pub(crate) struct TicketFeedError(String);
39
40impl TicketFeedError {
41 pub(crate) fn new(message: impl Into<String>) -> Self {
42 Self(message.into())
43 }
44}
45
46impl fmt::Display for TicketFeedError {
47 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
48 formatter.write_str(&self.0)
49 }
50}
51
52impl std::error::Error for TicketFeedError {}
53
54#[derive(Clone)]
58pub(crate) enum TicketFeeder {
59 Markdown(MarkdownTicketSource),
60 Exec(ExecTicketSource),
61}
62
63impl TicketFeeder {
64 pub(crate) fn markdown(root: impl Into<PathBuf>, ticket_dir: impl Into<PathBuf>) -> Self {
65 Self::Markdown(MarkdownTicketSource::new(root, ticket_dir))
66 }
67
68 pub(crate) fn exec(root: impl Into<PathBuf>, argv: Vec<String>) -> Self {
69 Self::Exec(ExecTicketSource::new(root, argv))
70 }
71
72 pub(crate) fn pull(&self) -> Result<Vec<AuthoredTicket>, TicketFeedError> {
73 match self {
74 Self::Markdown(source) => source.pull(),
75 Self::Exec(source) => source.pull(),
76 }
77 }
78
79 pub(crate) fn supports_authoring(&self) -> bool {
80 matches!(self, Self::Markdown(_))
81 }
82
83 pub(crate) fn exec_reporter(&self) -> Option<ExecTicketSource> {
84 match self {
85 Self::Markdown(_) => None,
86 Self::Exec(source) => Some(source.clone()),
87 }
88 }
89}
90
91#[derive(Debug, Clone, Copy, PartialEq, Eq)]
92pub enum ClaimStrength {
93 Atomic,
94 Optimistic,
95 LocalOnly,
96}
97
98#[derive(Debug, Clone, PartialEq, Eq)]
99#[allow(clippy::large_enum_variant)]
100pub enum ClaimResult {
101 Claimed { ticket: WorkTicket },
102 Lost { held_by: Option<OwnerId> },
103}
104
105#[derive(Debug, Clone, PartialEq, Eq)]
106pub struct ActiveClaim {
107 pub ticket: TicketRef,
108 pub owner: OwnerId,
109}
110
111#[derive(Debug, Clone, PartialEq, Eq)]
112pub enum SourceError {
113 Unavailable { retry_after: Option<Duration> },
114 Rejected { message: String },
115 Corrupt { message: String },
116}
117
118impl fmt::Display for SourceError {
119 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
120 match self {
121 Self::Unavailable { retry_after } => match retry_after {
122 Some(retry_after) => write!(
123 formatter,
124 "work source is unavailable; retry after {} seconds",
125 retry_after.as_secs()
126 ),
127 None => formatter.write_str("work source is unavailable"),
128 },
129 Self::Rejected { message } | Self::Corrupt { message } => formatter.write_str(message),
130 }
131 }
132}
133
134impl std::error::Error for SourceError {}
135
136#[async_trait]
145pub trait WorkState: Send + Sync {
146 fn claim_strength(&self) -> ClaimStrength;
150
151 async fn pull_ready(&self) -> Result<Vec<WorkTicket>, SourceError>;
154
155 async fn active_claims(&self) -> Result<Vec<ActiveClaim>, SourceError>;
158
159 async fn claim(
162 &self,
163 ticket: &TicketRef,
164 owner: &OwnerId,
165 ttl: Duration,
166 ) -> Result<ClaimResult, SourceError>;
167
168 async fn renew(&self, ticket: &TicketRef, owner: &OwnerId) -> Result<ClaimResult, SourceError>;
170
171 async fn release(
174 &self,
175 ticket: &TicketRef,
176 owner: &OwnerId,
177 disposition: Disposition,
178 ) -> Result<(), SourceError>;
179
180 async fn push_outcome(&self, outcome: &WorkOutcome) -> Result<(), SourceError>;
182}
183
184#[async_trait]
185pub trait WorkStateAuthor: Send + Sync {
186 async fn post(&self, ticket: &WorkTicket) -> Result<TicketRef, SourceError>;
189
190 async fn update(
193 &self,
194 ticket: &TicketRef,
195 content: &WorkTicket,
196 expected: &SourceVersion,
197 ) -> Result<SourceVersion, SourceError>;
198}
199
200#[cfg(test)]
201mod tests {
202 use std::sync::Mutex;
203
204 use crate::domain::work::{ExecutionHints, WorkTicketState};
205
206 use super::*;
207
208 struct FakeWorkState {
209 ticket: Mutex<WorkTicket>,
210 outcomes: Mutex<Vec<WorkOutcome>>,
211 }
212
213 #[async_trait]
214 impl WorkState for FakeWorkState {
215 fn claim_strength(&self) -> ClaimStrength {
216 ClaimStrength::Atomic
217 }
218
219 async fn pull_ready(&self) -> Result<Vec<WorkTicket>, SourceError> {
220 let ticket = self.ticket.lock().unwrap().clone();
221 Ok((ticket.state == WorkTicketState::Ready)
222 .then_some(ticket)
223 .into_iter()
224 .collect())
225 }
226
227 async fn active_claims(&self) -> Result<Vec<ActiveClaim>, SourceError> {
228 let ticket = self.ticket.lock().unwrap();
229 let WorkTicketState::Claimed { by } = &ticket.state else {
230 return Ok(Vec::new());
231 };
232 Ok(vec![ActiveClaim {
233 ticket: ticket_ref(&ticket),
234 owner: by.clone(),
235 }])
236 }
237
238 async fn claim(
239 &self,
240 ticket: &TicketRef,
241 owner: &OwnerId,
242 _ttl: Duration,
243 ) -> Result<ClaimResult, SourceError> {
244 let mut stored = self.ticket.lock().unwrap();
245 if stored.id != ticket.id || stored.state != WorkTicketState::Ready {
246 return Ok(ClaimResult::Lost { held_by: None });
247 }
248
249 stored.attempts += 1;
250 stored.state = WorkTicketState::Claimed { by: owner.clone() };
251 Ok(ClaimResult::Claimed {
252 ticket: stored.clone(),
253 })
254 }
255
256 async fn renew(
257 &self,
258 ticket: &TicketRef,
259 owner: &OwnerId,
260 ) -> Result<ClaimResult, SourceError> {
261 let stored = self.ticket.lock().unwrap();
262 if stored.id == ticket.id
263 && stored.state == (WorkTicketState::Claimed { by: owner.clone() })
264 {
265 return Ok(ClaimResult::Claimed {
266 ticket: stored.clone(),
267 });
268 }
269
270 Ok(ClaimResult::Lost { held_by: None })
271 }
272
273 async fn release(
274 &self,
275 _ticket: &TicketRef,
276 _owner: &OwnerId,
277 disposition: Disposition,
278 ) -> Result<(), SourceError> {
279 let mut stored = self.ticket.lock().unwrap();
280 stored.state = match disposition {
281 Disposition::Complete => WorkTicketState::Done,
282 Disposition::Retry { .. } => WorkTicketState::Ready,
283 Disposition::Park { reason } => WorkTicketState::Held { reason },
284 Disposition::Abandon => WorkTicketState::Failed,
285 };
286 Ok(())
287 }
288
289 async fn push_outcome(&self, outcome: &WorkOutcome) -> Result<(), SourceError> {
290 self.outcomes.lock().unwrap().push(outcome.clone());
291 Ok(())
292 }
293 }
294
295 #[async_trait]
296 impl WorkStateAuthor for FakeWorkState {
297 async fn post(&self, ticket: &WorkTicket) -> Result<TicketRef, SourceError> {
298 *self.ticket.lock().unwrap() = ticket.clone();
299 Ok(ticket_ref(ticket))
300 }
301
302 async fn update(
303 &self,
304 ticket: &TicketRef,
305 content: &WorkTicket,
306 expected: &SourceVersion,
307 ) -> Result<SourceVersion, SourceError> {
308 let mut stored = self.ticket.lock().unwrap();
309 if stored.id != ticket.id || &stored.version != expected {
310 return Err(SourceError::Rejected {
311 message: "version conflict".into(),
312 });
313 }
314
315 let version = SourceVersion(format!("{}-next", expected.0));
316 *stored = content.clone();
317 stored.version = version.clone();
318 Ok(version)
319 }
320 }
321
322 fn work_ticket() -> WorkTicket {
323 WorkTicket {
324 id: "T1".into(),
325 project_id: "P1".into(),
326 name: "test ticket".into(),
327 body: String::new(),
328 state: WorkTicketState::Ready,
329 blocked_by: Vec::new(),
330 attempts: 0,
331 hints: ExecutionHints {
332 worktree: None,
333 trigger_id: None,
334 target: None,
335 model: None,
336 effort: None,
337 flow: None,
338 },
339 version: SourceVersion("1".into()),
340 }
341 }
342
343 fn ticket_ref(ticket: &WorkTicket) -> TicketRef {
344 TicketRef {
345 id: ticket.id.clone(),
346 source: "memory".into(),
347 source_ref: None,
348 }
349 }
350
351 #[test]
352 fn work_state_is_object_safe() {
353 let _: Option<Box<dyn WorkState>> = None;
354 }
355
356 #[tokio::test]
357 async fn fake_implements_work_state_traits() {
358 let ticket = work_ticket();
359 let ticket_ref = ticket_ref(&ticket);
360 let fake = FakeWorkState {
361 ticket: Mutex::new(ticket.clone()),
362 outcomes: Mutex::new(Vec::new()),
363 };
364 let author: &dyn WorkStateAuthor = &fake;
365 let version = author
366 .update(&ticket_ref, &ticket, &ticket.version)
367 .await
368 .unwrap();
369 assert_eq!(version, SourceVersion("1-next".into()));
370
371 let source: Box<dyn WorkState> = Box::new(fake);
372
373 assert_eq!(source.claim_strength(), ClaimStrength::Atomic);
374 let result = source
375 .claim(
376 &ticket_ref,
377 &OwnerId("daemon-1".into()),
378 Duration::from_secs(60),
379 )
380 .await
381 .unwrap();
382
383 assert!(matches!(
384 result,
385 ClaimResult::Claimed {
386 ticket: WorkTicket { attempts: 1, .. }
387 }
388 ));
389 }
390}