1use std::fmt;
6use std::path::PathBuf;
7
8use chrono::{DateTime, Utc};
9use serde::{Deserialize, Serialize};
10
11#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
26#[serde(rename_all = "kebab-case")]
27pub enum TaskState {
28 Queued,
30 Running,
32 Blocked,
34 AwaitingReview,
36 Paused,
38 Committed,
40 Failed,
43 Cancelled,
45}
46
47impl TaskState {
48 pub const ALL: &'static [TaskState] = &[
51 Self::Queued,
52 Self::Running,
53 Self::Blocked,
54 Self::AwaitingReview,
55 Self::Paused,
56 Self::Committed,
57 Self::Failed,
58 Self::Cancelled,
59 ];
60
61 pub fn as_str(self) -> &'static str {
62 match self {
63 Self::Queued => "queued",
64 Self::Running => "running",
65 Self::Blocked => "blocked",
66 Self::AwaitingReview => "awaiting-review",
67 Self::Paused => "paused",
68 Self::Committed => "committed",
69 Self::Failed => "failed",
70 Self::Cancelled => "cancelled",
71 }
72 }
73
74 pub fn parse(s: &str) -> Option<Self> {
75 Some(match s {
76 "queued" => Self::Queued,
77 "running" => Self::Running,
78 "blocked" => Self::Blocked,
79 "awaiting-review" => Self::AwaitingReview,
80 "paused" => Self::Paused,
81 "committed" => Self::Committed,
82 "failed" => Self::Failed,
83 "cancelled" => Self::Cancelled,
84 _ => return None,
85 })
86 }
87
88 pub fn allowed_next(self) -> &'static [TaskState] {
90 match self {
91 Self::Queued => &[Self::Running, Self::Paused, Self::Failed, Self::Cancelled],
92 Self::Running => &[
94 Self::Blocked,
95 Self::AwaitingReview,
96 Self::Paused,
97 Self::Failed,
98 Self::Cancelled,
99 ],
100 Self::Blocked => &[
105 Self::Running,
106 Self::AwaitingReview,
107 Self::Paused,
108 Self::Failed,
109 Self::Cancelled,
110 ],
111 Self::AwaitingReview => &[Self::Committed, Self::Running, Self::Cancelled],
113 Self::Paused => &[Self::Queued, Self::Running, Self::Failed, Self::Cancelled],
115 Self::Committed => &[],
116 Self::Failed => &[],
117 Self::Cancelled => &[],
118 }
119 }
120
121 pub fn can_transition_to(self, next: TaskState) -> bool {
122 self.allowed_next().contains(&next)
123 }
124
125 pub fn is_terminal(self) -> bool {
127 self.allowed_next().is_empty()
128 }
129}
130
131impl fmt::Display for TaskState {
132 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
133 f.write_str(self.as_str())
134 }
135}
136
137#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
139#[serde(rename_all = "kebab-case")]
140pub enum BlockedKind {
141 PermissionPrompt,
143 Question,
145 Silence,
147}
148
149impl BlockedKind {
150 pub fn as_str(self) -> &'static str {
151 match self {
152 Self::PermissionPrompt => "permission-prompt",
153 Self::Question => "question",
154 Self::Silence => "silence",
155 }
156 }
157
158 pub fn parse(s: &str) -> Option<Self> {
159 Some(match s {
160 "permission-prompt" => Self::PermissionPrompt,
161 "question" => Self::Question,
162 "silence" => Self::Silence,
163 _ => return None,
164 })
165 }
166}
167
168impl fmt::Display for BlockedKind {
169 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
170 f.write_str(self.as_str())
171 }
172}
173
174#[derive(Debug, Clone, PartialEq, Eq)]
176pub struct Repo {
177 pub id: i64,
178 pub path: PathBuf,
180 pub name: String,
182 pub ignored: bool,
184 pub discovered_at: DateTime<Utc>,
185 pub last_seen_at: DateTime<Utc>,
187}
188
189#[derive(Debug, Clone, PartialEq, Eq)]
191pub struct Task {
192 pub id: i64,
193 pub title: String,
194 pub prompt: String,
196 pub state: TaskState,
197 pub blocked_kind: Option<BlockedKind>,
199 pub blocked_reason: Option<String>,
200 pub failure_reason: Option<String>,
202 pub workspace_dir: PathBuf,
204 pub session_name: Option<String>,
206 pub created_at: DateTime<Utc>,
207 pub updated_at: DateTime<Utc>,
208 pub usage: TaskUsage,
210 pub archived_at: Option<DateTime<Utc>>,
212}
213
214impl Task {
215 pub fn is_archived(&self) -> bool {
216 self.archived_at.is_some()
217 }
218}
219
220#[derive(Debug, Clone, Default, PartialEq, Eq)]
222pub struct TaskUsage {
223 pub model: Option<String>,
225 pub context_tokens: Option<u64>,
227 pub output_tokens: Option<u64>,
229 pub transcript_offset: u64,
231 pub transcript_path: Option<PathBuf>,
233}
234
235impl TaskUsage {
236 pub fn is_known(&self) -> bool {
238 self.model.is_some() || self.context_tokens.is_some() || self.output_tokens.is_some()
239 }
240}
241
242#[derive(Debug, Clone, PartialEq, Eq)]
244pub struct TaskRepo {
245 pub task_id: i64,
246 pub repo_id: i64,
247 pub worktree_path: Option<PathBuf>,
249 pub branch: Option<String>,
251 pub base_ref: Option<String>,
253}
254
255impl TaskRepo {
256 pub fn is_provisioned(&self) -> bool {
258 self.worktree_path.is_some()
259 }
260}
261
262#[derive(Debug, Clone, PartialEq, Eq)]
264pub struct Todo {
265 pub id: i64,
266 pub task_id: Option<i64>,
268 pub text: String,
269 pub done: bool,
270 pub created_at: DateTime<Utc>,
271}
272
273impl Todo {
274 pub fn is_global(&self) -> bool {
275 self.task_id.is_none()
276 }
277}
278
279#[derive(Debug, Clone, Copy, PartialEq, Eq)]
281pub enum TodoScope {
282 Global,
284 Task(i64),
286}
287
288impl TodoScope {
289 pub fn task_id(self) -> Option<i64> {
290 match self {
291 Self::Global => None,
292 Self::Task(id) => Some(id),
293 }
294 }
295}
296
297#[derive(Debug, Clone, PartialEq, Eq)]
299pub struct Event {
300 pub id: i64,
301 pub task_id: Option<i64>,
303 pub kind: String,
305 pub payload: serde_json::Value,
307 pub created_at: DateTime<Utc>,
308}
309
310#[cfg(test)]
311mod tests {
312 use super::*;
313
314 #[test]
315 fn state_strings_round_trip() {
316 for &state in TaskState::ALL {
317 assert_eq!(TaskState::parse(state.as_str()), Some(state));
318 }
319 assert_eq!(TaskState::parse("nonsense"), None);
320 }
321
322 #[test]
323 fn blocked_kind_strings_round_trip() {
324 for kind in [
325 BlockedKind::PermissionPrompt,
326 BlockedKind::Question,
327 BlockedKind::Silence,
328 ] {
329 assert_eq!(BlockedKind::parse(kind.as_str()), Some(kind));
330 }
331 assert_eq!(BlockedKind::parse("nonsense"), None);
332 }
333
334 #[test]
335 fn happy_path_is_walkable() {
336 let path = [
337 TaskState::Queued,
338 TaskState::Running,
339 TaskState::AwaitingReview,
340 TaskState::Committed,
341 ];
342 for pair in path.windows(2) {
343 assert!(
344 pair[0].can_transition_to(pair[1]),
345 "{} should reach {}",
346 pair[0],
347 pair[1]
348 );
349 }
350 }
351
352 #[test]
353 fn blocking_round_trips_through_running() {
354 assert!(TaskState::Running.can_transition_to(TaskState::Blocked));
355 assert!(TaskState::Blocked.can_transition_to(TaskState::Running));
356 }
357
358 #[test]
359 fn a_blocked_agent_can_finish_without_being_seen_to_resume() {
360 assert!(TaskState::Blocked.can_transition_to(TaskState::AwaitingReview));
363 }
364
365 #[test]
366 fn rejection_resumes_the_same_session() {
367 assert!(TaskState::AwaitingReview.can_transition_to(TaskState::Running));
368 }
369
370 #[test]
371 fn only_the_three_end_states_are_terminal() {
372 let terminal = [
373 TaskState::Committed,
374 TaskState::Failed,
375 TaskState::Cancelled,
376 ];
377 for &state in TaskState::ALL {
378 assert_eq!(
379 state.is_terminal(),
380 terminal.contains(&state),
381 "{state} has the wrong terminality"
382 );
383 }
384 }
385
386 #[test]
387 fn anything_unfinished_can_be_cancelled() {
388 for &state in TaskState::ALL {
389 if state.is_terminal() {
390 continue;
391 }
392 assert!(
393 state.can_transition_to(TaskState::Cancelled),
394 "{state} should be cancellable"
395 );
396 }
397 }
398
399 #[test]
400 fn failure_is_reachable_only_while_work_is_outstanding() {
401 for &state in &[TaskState::Queued, TaskState::Running, TaskState::Blocked] {
402 assert!(
403 state.can_transition_to(TaskState::Failed),
404 "{state} should be able to fail"
405 );
406 }
407 assert!(!TaskState::AwaitingReview.can_transition_to(TaskState::Failed));
410 }
411
412 #[test]
413 fn terminal_states_never_resume() {
414 for &state in TaskState::ALL {
415 if !state.is_terminal() {
416 continue;
417 }
418 for &next in TaskState::ALL {
419 assert!(
420 !state.can_transition_to(next),
421 "{state} should not reach {next}"
422 );
423 }
424 }
425 }
426
427 #[test]
428 fn queued_cannot_skip_running() {
429 assert!(!TaskState::Queued.can_transition_to(TaskState::AwaitingReview));
430 assert!(!TaskState::Queued.can_transition_to(TaskState::Committed));
431 assert!(!TaskState::Queued.can_transition_to(TaskState::Blocked));
432 }
433
434 #[test]
435 fn no_state_transitions_to_itself() {
436 for &state in TaskState::ALL {
437 assert!(!state.can_transition_to(state), "{state} loops on itself");
438 }
439 }
440}