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