Skip to main content

aven_core/
api.rs

1mod change_sets;
2pub use change_sets::{
3    ChangeOp, ChangeSetError, ChangeSetOutcome, ChangeTarget, CreatedTaskRef, NoteReceipt,
4};
5mod epics;
6pub use epics::{EpicProgress, EpicSummary};
7mod task_list;
8pub use task_list::{TaskList, TaskListDirection, TaskListFacet, TaskListQuery, TaskListSort};
9
10use std::fmt;
11use std::path::{Path, PathBuf};
12
13use anyhow::Error as InternalError;
14use chrono::{Days, LocalResult, NaiveDate, NaiveTime, TimeZone, Utc, Weekday};
15
16pub use crate::attachments::AttachmentBytesState as AttachmentAvailability;
17use crate::choices::{TaskPriority, TaskSource, TaskStatus};
18use crate::db::Database;
19use crate::ids::{MetadataFieldId, ProjectId, TaskId, WorkspaceId};
20use crate::metadata::{MetadataFieldUsage, TaskMetadataInput, TaskMetadataValue};
21use crate::operations::{
22    ConsumerTaskMutation as InternalConsumerTaskMutation, CreateRecurrenceSeriesParams,
23    RecurrenceSeriesDraft, RecurrenceTemplateUpdate as InternalRecurrenceTemplateUpdate,
24    TaskCreationOptions, TaskDraft, TaskUpdate as InternalTaskUpdate,
25    UpdateRecurrenceTemplateParams,
26};
27use crate::query::{
28    MAX_RECURRENCE_HISTORY_LIMIT, RecurrenceCounts as InternalRecurrenceCounts,
29    RecurrenceHistoryEntry as InternalRecurrenceHistoryEntry,
30    RecurrenceSeriesDetail as InternalRecurrenceSeriesDetail,
31    RecurrenceSeriesSummary as InternalRecurrenceSeriesSummary, SortDirection,
32    TaskAvailabilityFilter, TaskFilters, TaskListItem, TaskQueryMode, TaskSearchQuery, TaskSort,
33};
34pub use crate::query::{RecurrenceHistoryKind, SearchMatchedField};
35pub use crate::queue::{QueueBand, QueueDateKind, QueueReason};
36pub use crate::recurrence::{
37    RecurrenceDuePolicy, RecurrenceFrequency, RecurrenceOutcome, RecurrenceProjectionState,
38    RecurrenceSeriesState,
39};
40use crate::recurrence::{
41    RecurrenceRule as InternalRecurrenceRule, RecurrenceSchedule, RecurrenceSeriesId, TimeZoneId,
42    WeekdaySet,
43};
44use crate::task_fields::TaskField;
45use crate::types::{Project, RecurrenceOccurrence, RecurrenceSeries, Task};
46use crate::undo::TaskUndoSnapshot;
47use crate::workspaces::Workspace;
48
49#[derive(Clone)]
50pub struct Store {
51    database: Database,
52    #[cfg(test)]
53    fail_queue_reads: std::sync::Arc<std::sync::atomic::AtomicBool>,
54}
55
56impl Store {
57    pub async fn open(path: impl AsRef<Path>) -> Result<Self, Error> {
58        let database = Database::open(path.as_ref())
59            .await
60            .map_err(Error::database_open)?;
61        Ok(Self {
62            database,
63            #[cfg(test)]
64            fail_queue_reads: Default::default(),
65        })
66    }
67
68    /// See [`Database::close`].
69    pub async fn close(&self) {
70        self.database.close().await;
71    }
72
73    pub fn initialize_storage(&self) -> Result<StorageLayout, Error> {
74        let root = crate::attachments::default_blob_dir(self.database.path());
75        let objects = root.join("objects").join("sha256");
76        let trash = root.join("trash");
77        let previews = root.join("cache").join("previews");
78        for directory in [&objects, &trash, &previews] {
79            std::fs::create_dir_all(directory)
80                .map_err(|error| Error::from_internal(error.into()))?;
81        }
82        Ok(StorageLayout {
83            root,
84            staging: objects.clone(),
85            objects,
86            trash,
87            previews,
88        })
89    }
90
91    pub async fn list_workspaces(&self) -> Result<Vec<WorkspaceRecord>, Error> {
92        self.database
93            .list_workspaces()
94            .await
95            .map(|workspaces| workspaces.into_iter().map(WorkspaceRecord::from).collect())
96            .map_err(Error::from_internal)
97    }
98
99    pub async fn queue_state(&self) -> Result<QueueState, Error> {
100        let selected = self
101            .database
102            .restore_queue_workspace()
103            .await
104            .map_err(Error::from_internal)?;
105        self.queue_state_for(selected).await
106    }
107
108    pub async fn select_queue_workspace(
109        &self,
110        workspace_id: &WorkspaceId,
111    ) -> Result<QueueState, Error> {
112        let selected = self
113            .database
114            .select_queue_workspace(workspace_id)
115            .await
116            .map_err(Error::from_internal)?;
117        self.queue_state_for(selected).await
118    }
119
120    async fn queue_state_for(&self, selected: Workspace) -> Result<QueueState, Error> {
121        #[cfg(test)]
122        if self
123            .fail_queue_reads
124            .load(std::sync::atomic::Ordering::SeqCst)
125        {
126            return Err(Error::from_internal(sqlx::Error::PoolClosed.into()));
127        }
128        let workspaces = self
129            .database
130            .list_workspace_open_summaries()
131            .await
132            .map_err(Error::from_internal)?
133            .into_iter()
134            .map(|summary| WorkspaceQueueSummary {
135                workspace: summary.workspace.into(),
136                open_task_count: summary.open_task_count,
137            })
138            .collect();
139        let projects = self
140            .database
141            .list_projects(&selected.id, None)
142            .await
143            .map_err(Error::from_internal)?
144            .into_iter()
145            .map(ProjectRecord::from)
146            .collect();
147        let labels = self
148            .database
149            .list_labels(&selected.id, None)
150            .await
151            .map_err(Error::from_internal)?;
152        let queue = self.queue_report(&selected.id).await?;
153        Ok(QueueState {
154            selected_workspace: selected.into(),
155            workspaces,
156            projects,
157            labels,
158            queue,
159        })
160    }
161
162    pub async fn search_tasks(
163        &self,
164        workspace_id: &WorkspaceId,
165        text: &str,
166    ) -> Result<Vec<TaskSearchResult>, Error> {
167        self.workspace(workspace_id).await?;
168        let text = text.trim();
169        if text.is_empty() {
170            return Ok(Vec::new());
171        }
172        if text.chars().count() > 256 {
173            return Err(Error::new(
174                ErrorCode::Validation,
175                "search query is too long".to_string(),
176            ));
177        }
178        self.database
179            .search_task_items(
180                workspace_id,
181                TaskSearchQuery {
182                    text: text.to_string(),
183                    project: None,
184                    metadata: Vec::new(),
185                    has_metadata: Vec::new(),
186                    missing_metadata: Vec::new(),
187                    include_deleted: false,
188                    limit: 100,
189                },
190            )
191            .await
192            .map(|results| {
193                results
194                    .into_iter()
195                    .map(|result| TaskSearchResult {
196                        task: TaskListRow::from(result.item),
197                        matched_field: result.matched_field,
198                        snippet: result.snippet,
199                    })
200                    .collect()
201            })
202            .map_err(Error::from_internal)
203    }
204
205    pub async fn task_detail(
206        &self,
207        workspace_id: &WorkspaceId,
208        task_id: &TaskId,
209    ) -> Result<TaskDetail, Error> {
210        let workspace = self.workspace(workspace_id).await?;
211        let mut connection = self
212            .database
213            .acquire_reader()
214            .await
215            .map_err(Error::from_internal)?;
216        let task = crate::refs::get_task_in_workspace(&mut connection, &workspace, task_id)
217            .await
218            .map_err(Error::from_internal)?;
219        drop(connection);
220        let mut detail = self
221            .database
222            .task_detail(&task)
223            .await
224            .map_err(Error::from_internal)?;
225        let blob_dir = crate::attachments::default_blob_dir(self.database.path());
226        let present = detail
227            .item
228            .attachments
229            .iter()
230            .enumerate()
231            .filter(|(_, attachment)| {
232                attachment.bytes_state == crate::attachments::AttachmentBytesState::Present
233            })
234            .map(|(index, attachment)| (index, attachment.sha256.clone()))
235            .collect::<Vec<_>>();
236        let missing = crate::attachments::run_blocking(move || {
237            let mut missing = Vec::new();
238            for (index, sha256) in present {
239                if crate::operations::local_object_missing(&blob_dir, &sha256)? {
240                    missing.push(index);
241                }
242            }
243            Ok(missing)
244        })
245        .await
246        .map_err(Error::from_internal)?;
247        for index in missing {
248            let attachment = &mut detail.item.attachments[index];
249            attachment.bytes_state = crate::attachments::AttachmentBytesState::Unavailable;
250            attachment.has_blob = false;
251        }
252        TaskDetail::from_detail(detail, workspace)
253    }
254
255    /// Returns an owned, bounded snapshot without exposing object storage paths.
256    pub async fn attachment_bytes(
257        &self,
258        workspace_id: &WorkspaceId,
259        task_id: &TaskId,
260        attachment_id: &str,
261    ) -> Result<AttachmentRead, Error> {
262        use crate::attachments::{MAX_BLOB_BYTES, default_blob_dir, object_path, sha256_hex};
263        use std::io::Read;
264
265        crate::attachments::validate_attachment_id(attachment_id).map_err(Error::from_internal)?;
266        let workspace = self.workspace(workspace_id).await?;
267        let lease = match self
268            .database
269            .acquire_live_attachment_read_lease(&workspace, attachment_id)
270            .await
271        {
272            Ok(lease) => lease,
273            Err(error) => {
274                return match error.downcast_ref::<crate::operations::AttachmentReadFailure>() {
275                    Some(crate::operations::AttachmentReadFailure::Invalidated) => {
276                        Ok(AttachmentRead::Invalidated)
277                    }
278                    Some(crate::operations::AttachmentReadFailure::Unavailable) => {
279                        Ok(AttachmentRead::Unavailable)
280                    }
281                    None => Err(Error::from_internal(error)),
282                };
283            }
284        };
285        let result = if lease.task_id != task_id.as_str() {
286            Ok(AttachmentRead::Invalidated)
287        } else {
288            let path = object_path(&default_blob_dir(self.database.path()), &lease.sha256);
289            match path {
290                Err(error) => Err(error),
291                Ok(path) => {
292                    let expected_size = lease.byte_size;
293                    let expected_hash = lease.sha256.clone();
294                    crate::attachments::run_preview(move || {
295                        let file = match std::fs::File::open(path) {
296                            Ok(file) => file,
297                            Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
298                                return Ok(AttachmentRead::Missing);
299                            }
300                            Err(error) => return Err(error.into()),
301                        };
302                        let mut bytes = Vec::new();
303                        file.take(MAX_BLOB_BYTES as u64 + 1)
304                            .read_to_end(&mut bytes)?;
305                        if bytes.len() > MAX_BLOB_BYTES
306                            || bytes.len() as i64 != expected_size
307                            || sha256_hex(&bytes) != expected_hash
308                        {
309                            return Ok(AttachmentRead::Corrupt);
310                        }
311                        Ok(AttachmentRead::Bytes { bytes })
312                    })
313                    .await
314                }
315            }
316        };
317        self.database
318            .release_attachment_lease(&lease.lease_id)
319            .await
320            .map_err(Error::from_internal)?;
321        result.map_err(Error::from_internal)
322    }
323
324    pub async fn capture_queue_task(
325        &self,
326        workspace_id: &WorkspaceId,
327        input: TaskCapture,
328    ) -> Result<TaskCaptureResult, Error> {
329        let draft = input.into_draft()?;
330        let workspace = self.workspace(workspace_id).await?;
331        let outcome = self
332            .database
333            .create_task_with_options(
334                &workspace,
335                draft,
336                TaskCreationOptions::for_consumer_capture(),
337            )
338            .await
339            .map_err(Error::from_internal)?;
340        let expected = outcome.undo_snapshot.ok_or_else(|| {
341            Error::new(
342                ErrorCode::Internal,
343                "capture undo state is unavailable".to_string(),
344            )
345        })?;
346        let undo_token = serde_json::to_string(&CaptureUndoToken {
347            workspace_id: workspace_id.clone(),
348            task_id: outcome.task.id.clone(),
349            expected,
350        })
351        .map_err(|error| Error::from_internal(error.into()))?;
352        let state = self.queue_state_for(workspace).await.ok();
353        let display_ref = state
354            .as_ref()
355            .and_then(|state| {
356                state
357                    .queue
358                    .tasks
359                    .iter()
360                    .find(|task| task.id == outcome.task.id)
361            })
362            .map(|task| task.display_ref.clone())
363            .unwrap_or_else(|| outcome.task.id.to_string());
364        Ok(TaskCaptureResult {
365            task_id: outcome.task.id,
366            display_ref,
367            undo_token,
368            state,
369        })
370    }
371
372    pub async fn undo_queue_capture(&self, undo_token: &str) -> Result<Option<QueueState>, Error> {
373        let token: CaptureUndoToken = serde_json::from_str(undo_token).map_err(|_| {
374            Error::new(
375                ErrorCode::Validation,
376                "invalid capture undo token".to_string(),
377            )
378        })?;
379        let workspace = self.workspace(&token.workspace_id).await?;
380        self.database
381            .undo_queue_capture(&workspace, &token.task_id, &token.expected)
382            .await
383            .map_err(Error::from_internal)?;
384        Ok(self.queue_state_for(workspace).await.ok())
385    }
386
387    /// Returns the committed status and its conditional Undo receipt without a read-model refresh.
388    pub async fn update_detail_status(
389        &self,
390        workspace_id: &WorkspaceId,
391        task_id: &TaskId,
392        status: TaskStatus,
393    ) -> Result<DetailStatusReceipt, Error> {
394        let workspace = self.workspace(workspace_id).await?;
395        let mutation = InternalConsumerTaskMutation::DetailStatus {
396            status: status.as_str().to_string(),
397            expected_version: None,
398        };
399        let outcome = self
400            .database
401            .mutate_consumer_task(&workspace, task_id, &mutation)
402            .await
403            .map_err(Error::from_internal)?;
404        let status = TaskStatus::parse(&outcome.after.status)
405            .expect("persisted task status is validated by core mutations");
406        let mutation = InternalConsumerTaskMutation::DetailStatus {
407            status: status.as_str().to_string(),
408            expected_version: outcome.field_version,
409        };
410        let undo_token = outcome.changed.then(|| {
411            serde_json::to_string(&MutationUndoToken {
412                workspace_id: workspace_id.clone(),
413                task_id: task_id.clone(),
414                mutation,
415                before: outcome.before,
416                expected: outcome.after,
417            })
418            .expect("Undo snapshots contain only JSON-serializable values")
419        });
420        Ok(DetailStatusReceipt { status, undo_token })
421    }
422
423    pub async fn undo_detail_status(&self, undo_token: &str) -> Result<(), Error> {
424        let token: MutationUndoToken = serde_json::from_str(undo_token).map_err(|_| {
425            Error::new(
426                ErrorCode::Validation,
427                "invalid detail status undo token".to_string(),
428            )
429        })?;
430        if !matches!(
431            token.mutation,
432            InternalConsumerTaskMutation::DetailStatus { .. }
433        ) {
434            return Err(Error::new(
435                ErrorCode::Validation,
436                "invalid detail status undo token".to_string(),
437            ));
438        }
439        self.undo_consumer_mutation(token).await
440    }
441
442    /// Sets the synchronized soft-deletion field and returns a conditional Undo receipt.
443    pub async fn set_task_deleted(
444        &self,
445        workspace_id: &WorkspaceId,
446        task_id: &TaskId,
447        deleted: bool,
448    ) -> Result<TaskDeletionReceipt, Error> {
449        let workspace = self.workspace(workspace_id).await?;
450        let mutation = InternalConsumerTaskMutation::DeletedState {
451            deleted,
452            expected_version: None,
453        };
454        let outcome = self
455            .database
456            .mutate_consumer_task(&workspace, task_id, &mutation)
457            .await
458            .map_err(Error::from_internal)?;
459        let deleted = outcome.after.deleted;
460        let mutation = InternalConsumerTaskMutation::DeletedState {
461            deleted,
462            expected_version: outcome.field_version,
463        };
464        let undo_token = outcome.changed.then(|| {
465            serde_json::to_string(&MutationUndoToken {
466                workspace_id: workspace_id.clone(),
467                task_id: task_id.clone(),
468                mutation,
469                before: outcome.before,
470                expected: outcome.after,
471            })
472            .expect("Undo snapshots contain only JSON-serializable values")
473        });
474        Ok(TaskDeletionReceipt {
475            deleted,
476            undo_token,
477        })
478    }
479
480    pub async fn undo_task_deletion(&self, undo_token: &str) -> Result<(), Error> {
481        let token: MutationUndoToken = serde_json::from_str(undo_token).map_err(|_| {
482            Error::new(
483                ErrorCode::Validation,
484                "invalid task deletion undo token".to_string(),
485            )
486        })?;
487        if !matches!(
488            token.mutation,
489            InternalConsumerTaskMutation::DeletedState { .. }
490        ) {
491            return Err(Error::new(
492                ErrorCode::Validation,
493                "invalid task deletion undo token".to_string(),
494            ));
495        }
496        self.undo_consumer_mutation(token).await
497    }
498
499    async fn undo_consumer_mutation(&self, token: MutationUndoToken) -> Result<(), Error> {
500        let workspace = self.workspace(&token.workspace_id).await?;
501        self.database
502            .undo_consumer_task_mutation(
503                &workspace,
504                &token.task_id,
505                &token.mutation,
506                &token.before,
507                &token.expected,
508            )
509            .await
510            .map_err(Error::from_internal)?;
511        Ok(())
512    }
513
514    pub async fn mutate_queue_task(
515        &self,
516        workspace_id: &WorkspaceId,
517        task_id: &TaskId,
518        input: QueueMutation,
519    ) -> Result<QueueMutationResult, Error> {
520        let workspace = self.workspace(workspace_id).await?;
521        let mutation = input.into_internal()?;
522        let outcome = self
523            .database
524            .mutate_consumer_task(&workspace, task_id, &mutation)
525            .await
526            .map_err(Error::from_internal)?;
527        if !outcome.changed {
528            return Err(Error::new(
529                ErrorCode::GenerationConflict,
530                "quick action did not change the task".to_string(),
531            ));
532        }
533        let undo_token = serde_json::to_string(&MutationUndoToken {
534            workspace_id: workspace_id.clone(),
535            task_id: task_id.clone(),
536            mutation,
537            before: outcome.before,
538            expected: outcome.after,
539        })
540        .map_err(|error| Error::from_internal(error.into()))?;
541        Ok(QueueMutationResult {
542            task_id: task_id.clone(),
543            undo_token,
544            state: self.queue_state_for(workspace).await.ok(),
545        })
546    }
547
548    pub async fn undo_queue_mutation(&self, undo_token: &str) -> Result<Option<QueueState>, Error> {
549        let token: MutationUndoToken = serde_json::from_str(undo_token).map_err(|_| {
550            Error::new(
551                ErrorCode::Validation,
552                "invalid quick action undo token".to_string(),
553            )
554        })?;
555        let workspace = self.workspace(&token.workspace_id).await?;
556        self.database
557            .undo_consumer_task_mutation(
558                &workspace,
559                &token.task_id,
560                &token.mutation,
561                &token.before,
562                &token.expected,
563            )
564            .await
565            .map_err(Error::from_internal)?;
566        Ok(self.queue_state_for(workspace).await.ok())
567    }
568
569    pub async fn resolve_workspace(&self, name_or_key: &str) -> Result<WorkspaceRecord, Error> {
570        self.database
571            .find_workspace(name_or_key)
572            .await
573            .map_err(Error::from_internal)?
574            .map(WorkspaceRecord::from)
575            .ok_or_else(|| {
576                Error::new(
577                    ErrorCode::NotFound,
578                    format!("workspace not found: {name_or_key}"),
579                )
580            })
581    }
582
583    pub async fn list_metadata_fields(
584        &self,
585        workspace_id: &WorkspaceId,
586    ) -> Result<Vec<MetadataFieldRecord>, Error> {
587        self.workspace(workspace_id).await?;
588        self.database
589            .list_metadata_fields(workspace_id)
590            .await
591            .map(|fields| fields.into_iter().map(Into::into).collect())
592            .map_err(Error::from_internal)
593    }
594
595    pub async fn rename_metadata_field(
596        &self,
597        workspace_id: &WorkspaceId,
598        key: &str,
599        new_key: &str,
600    ) -> Result<MetadataFieldRecord, Error> {
601        let workspace = self.workspace(workspace_id).await?;
602        let field = self
603            .database
604            .rename_metadata_field(&workspace, key, new_key)
605            .await
606            .map_err(Error::from_internal)?;
607        let fields = self.list_metadata_fields(workspace_id).await?;
608        fields
609            .into_iter()
610            .find(|candidate| candidate.id == field.id)
611            .ok_or_else(|| Error::new(ErrorCode::NotFound, "metadata field not found".to_string()))
612    }
613
614    pub async fn create_task(
615        &self,
616        workspace_id: &WorkspaceId,
617        input: CreateTask,
618    ) -> Result<TaskRecord, Error> {
619        validate_project(&input.project)?;
620        validate_optional_date("available_at", input.available_at.as_deref())?;
621        validate_optional_date("due_on", input.due_on.as_deref())?;
622        let workspace = self.workspace(workspace_id).await?;
623        let outcome = self
624            .database
625            .create_task(
626                &workspace,
627                TaskDraft {
628                    title: input.title,
629                    description: input.description,
630                    project: Some(input.project),
631                    status: input.status.as_str().to_string(),
632                    priority: input.priority.as_str().to_string(),
633                    source: TaskSource::Api,
634                    labels: Vec::new(),
635                    metadata: input
636                        .metadata
637                        .into_iter()
638                        .map(TaskMetadataInput::from)
639                        .collect(),
640                    available_at: input.available_at,
641                    due_on: input.due_on,
642                    is_epic: false,
643                },
644            )
645            .await
646            .map_err(Error::from_internal)?;
647        let metadata = self
648            .database
649            .task_metadata(workspace_id, &outcome.task.id)
650            .await
651            .map_err(Error::from_internal)?;
652        Ok(TaskRecord::with_metadata(outcome.task, metadata))
653    }
654
655    pub async fn task_edit_context(
656        &self,
657        workspace_id: &WorkspaceId,
658        task_id: &TaskId,
659    ) -> Result<TaskEditContext, Error> {
660        let detail = self.task_detail(workspace_id, task_id).await?;
661        let projects = self
662            .database
663            .list_projects(workspace_id, None)
664            .await
665            .map_err(Error::from_internal)?
666            .into_iter()
667            .map(ProjectRecord::from)
668            .collect();
669        let labels = self
670            .database
671            .list_labels(workspace_id, None)
672            .await
673            .map_err(Error::from_internal)?;
674        Ok(TaskEditContext {
675            detail,
676            projects,
677            labels,
678        })
679    }
680
681    pub async fn edit_task(
682        &self,
683        workspace_id: &WorkspaceId,
684        task_id: &TaskId,
685        input: TaskEdit,
686    ) -> Result<bool, Error> {
687        let (project_id, update) = input.into_update()?;
688        let workspace = self.workspace(workspace_id).await?;
689        self.database
690            .edit_consumer_task(&workspace, task_id, project_id.as_ref(), update)
691            .await
692            .map_err(Error::from_internal)
693    }
694
695    pub async fn update_task(
696        &self,
697        workspace_id: &WorkspaceId,
698        task_id: &TaskId,
699        input: UpdateTask,
700    ) -> Result<TaskUpdateResult, Error> {
701        if let Some(project) = input.project.as_deref() {
702            validate_project(project)?;
703        }
704        validate_date_update("available_at", &input.available_at)?;
705        validate_date_update("due_on", &input.due_on)?;
706        let workspace = self.workspace(workspace_id).await?;
707        self.fetch_task(workspace_id, task_id).await?;
708        let outcome = self
709            .database
710            .update_task(
711                &workspace,
712                task_id,
713                InternalTaskUpdate {
714                    title: input.title,
715                    description: input.description,
716                    project: input.project,
717                    status: input.status.map(|status| status.as_str().to_string()),
718                    priority: input.priority.map(|priority| priority.as_str().to_string()),
719                    available_at: input.available_at.into_internal(),
720                    due_on: input.due_on.into_internal(),
721                    set_metadata: input
722                        .set_metadata
723                        .into_iter()
724                        .map(TaskMetadataInput::from)
725                        .collect(),
726                    remove_metadata: input.remove_metadata,
727                    ..InternalTaskUpdate::default()
728                },
729            )
730            .await
731            .map_err(Error::from_internal)?;
732        let metadata = self
733            .database
734            .task_metadata(workspace_id, task_id)
735            .await
736            .map_err(Error::from_internal)?;
737        let related = self
738            .database
739            .task_related_links(workspace_id, task_id)
740            .await
741            .map_err(Error::from_internal)?;
742        Ok(TaskUpdateResult {
743            task: TaskRecord::with_metadata_and_related(outcome.task, metadata, related),
744            changed: outcome.changed,
745        })
746    }
747
748    pub async fn list_tasks(&self, workspace_id: &WorkspaceId) -> Result<Vec<TaskRecord>, Error> {
749        self.workspace(workspace_id).await?;
750        let filters = TaskFilters {
751            exclude_epics: true,
752            ..TaskFilters::default()
753        };
754        self.database
755            .list_base_tasks(workspace_id, filters, TaskSort::Created, SortDirection::Asc)
756            .await
757            .map(|tasks| tasks.into_iter().map(TaskRecord::from).collect())
758            .map_err(Error::from_internal)
759    }
760
761    pub async fn queue_report(&self, workspace_id: &WorkspaceId) -> Result<QueueReport, Error> {
762        self.workspace(workspace_id).await?;
763        let tasks = self
764            .database
765            .list_task_summary_items(
766                workspace_id,
767                TaskFilters {
768                    hide_done: true,
769                    availability: TaskAvailabilityFilter::Available,
770                    ..TaskFilters::default()
771                },
772                TaskQueryMode::RankedQueue,
773                TaskSort::Created,
774                SortDirection::Asc,
775                None,
776            )
777            .await
778            .map_err(Error::from_internal)?
779            .into_iter()
780            .map(QueueTaskSummary::from)
781            .collect();
782        let unresolved_conflict_count = self
783            .database
784            .unresolved_task_conflict_count_in_workspace(workspace_id)
785            .await
786            .map_err(Error::from_internal)?
787            .clamp(0, i64::from(u32::MAX)) as u32;
788        Ok(QueueReport {
789            tasks,
790            unresolved_conflict_count,
791        })
792    }
793
794    pub async fn fetch_task(
795        &self,
796        workspace_id: &WorkspaceId,
797        task_id: &TaskId,
798    ) -> Result<TaskRecord, Error> {
799        let workspace = self.workspace(workspace_id).await?;
800        let mut connection = self
801            .database
802            .acquire_reader()
803            .await
804            .map_err(Error::from_internal)?;
805        let task = crate::refs::get_task_in_workspace(&mut connection, &workspace, task_id)
806            .await
807            .map_err(Error::from_internal)?;
808        drop(connection);
809        let metadata = self
810            .database
811            .task_metadata(workspace_id, task_id)
812            .await
813            .map_err(Error::from_internal)?;
814        let related = self
815            .database
816            .task_related_links(workspace_id, task_id)
817            .await
818            .map_err(Error::from_internal)?;
819        Ok(TaskRecord::with_metadata_and_related(
820            task, metadata, related,
821        ))
822    }
823
824    pub async fn add_related_task(
825        &self,
826        workspace_id: &WorkspaceId,
827        task_id: &TaskId,
828        related_task_id: &TaskId,
829    ) -> Result<RelatedMutationResult, Error> {
830        let workspace = self.workspace(workspace_id).await?;
831        self.database
832            .add_task_related_link(&workspace, task_id, related_task_id)
833            .await
834            .map(|outcome| RelatedMutationResult {
835                changed: outcome.changed,
836            })
837            .map_err(Error::from_internal)
838    }
839
840    pub async fn remove_related_task(
841        &self,
842        workspace_id: &WorkspaceId,
843        task_id: &TaskId,
844        related_task_id: &TaskId,
845    ) -> Result<RelatedMutationResult, Error> {
846        let workspace = self.workspace(workspace_id).await?;
847        self.database
848            .remove_task_related_link(&workspace, task_id, related_task_id)
849            .await
850            .map(|outcome| RelatedMutationResult {
851                changed: outcome.changed,
852            })
853            .map_err(Error::from_internal)
854    }
855
856    pub async fn create_recurrence_series(
857        &self,
858        workspace_id: &WorkspaceId,
859        input: CreateRecurrenceSeries,
860    ) -> Result<RecurrenceCreateResult, Error> {
861        validate_project(&input.project)?;
862        let workspace = self.workspace(workspace_id).await?;
863        let schedule = input.schedule.into_internal()?;
864        let outcome = self
865            .database
866            .create_recurrence_series(
867                &workspace,
868                CreateRecurrenceSeriesParams::new(RecurrenceSeriesDraft {
869                    title: input.title,
870                    description: input.description,
871                    project: input.project,
872                    priority: input.priority.as_str().to_string(),
873                    initial_status: input.initial_status.as_str().to_string(),
874                    labels: input.labels,
875                    metadata: input.metadata.into_iter().map(Into::into).collect(),
876                    schedule,
877                }),
878            )
879            .await
880            .map_err(Error::from_internal)?;
881        let metadata = self
882            .database
883            .task_metadata(workspace_id, &outcome.task.id)
884            .await
885            .map_err(Error::from_internal)?;
886        Ok(RecurrenceCreateResult {
887            series: RecurrenceSeriesRecord::from(outcome.series),
888            series_ref: outcome.series_ref,
889            occurrence: RecurrenceOccurrenceRecord::from(outcome.occurrence),
890            task: TaskRecord::with_metadata(outcome.task, metadata),
891        })
892    }
893
894    pub async fn update_recurrence_template(
895        &self,
896        workspace_id: &WorkspaceId,
897        series_id: &RecurrenceSeriesId,
898        input: UpdateRecurrenceTemplate,
899    ) -> Result<RecurrenceTemplateUpdateResult, Error> {
900        if let Some(project) = input.project.as_deref() {
901            validate_project(project)?;
902        }
903        let workspace = self.workspace(workspace_id).await?;
904        self.database
905            .update_recurrence_template(
906                &workspace,
907                series_id,
908                UpdateRecurrenceTemplateParams::new(InternalRecurrenceTemplateUpdate {
909                    title: input.title,
910                    description: input.description,
911                    project: input.project,
912                    priority: input.priority.map(|value| value.as_str().to_string()),
913                    initial_status: input.initial_status.map(|value| value.as_str().to_string()),
914                    labels: input.labels,
915                    set_metadata: input.set_metadata.into_iter().map(Into::into).collect(),
916                    remove_metadata: input.remove_metadata,
917                    available_local_time: input.available_local_time.into_internal()?,
918                    due_policy: input.due_policy,
919                }),
920            )
921            .await
922            .map(|outcome| RecurrenceTemplateUpdateResult {
923                series: RecurrenceSeriesRecord::from(outcome.series),
924                changed: outcome.changed,
925            })
926            .map_err(Error::from_internal)
927    }
928
929    pub async fn resolve_recurrence_ref(
930        &self,
931        workspace_id: &WorkspaceId,
932        input: &str,
933    ) -> Result<RecurrenceRefResolution, Error> {
934        let workspace = self.workspace(workspace_id).await?;
935        let series = self
936            .database
937            .resolve_recurrence_ref(&workspace, input)
938            .await
939            .map_err(Error::from_internal)?;
940        let series_ref = self
941            .database
942            .recurrence_series_ref(workspace_id, &series.id)
943            .await
944            .map_err(Error::from_internal)?;
945        Ok(RecurrenceRefResolution {
946            series_id: series.id,
947            series_ref,
948        })
949    }
950
951    pub async fn list_recurrence_series(
952        &self,
953        workspace_id: &WorkspaceId,
954    ) -> Result<Vec<RecurrenceSeriesSummary>, Error> {
955        self.workspace(workspace_id).await?;
956        self.database
957            .list_recurrence_series(workspace_id)
958            .await
959            .map(|values| values.into_iter().map(Into::into).collect())
960            .map_err(Error::from_internal)
961    }
962
963    pub async fn show_recurrence_series(
964        &self,
965        workspace_id: &WorkspaceId,
966        input: &str,
967    ) -> Result<RecurrenceSeriesDetail, Error> {
968        let resolution = self.resolve_recurrence_ref(workspace_id, input).await?;
969        self.database
970            .recurrence_series_detail(workspace_id, &resolution.series_id)
971            .await
972            .map(Into::into)
973            .map_err(Error::from_internal)
974    }
975
976    pub async fn recurrence_history(
977        &self,
978        workspace_id: &WorkspaceId,
979        input: &str,
980        offset: usize,
981        limit: usize,
982    ) -> Result<RecurrenceHistoryPage, Error> {
983        if limit == 0 || limit > MAX_RECURRENCE_HISTORY_LIMIT {
984            return Err(Error::new(
985                ErrorCode::Validation,
986                format!(
987                    "recurrence history limit must be between 1 and {MAX_RECURRENCE_HISTORY_LIMIT}"
988                ),
989            ));
990        }
991        let resolution = self.resolve_recurrence_ref(workspace_id, input).await?;
992        self.database
993            .recurrence_history(workspace_id, &resolution.series_id, offset, limit)
994            .await
995            .map(|page| RecurrenceHistoryPage {
996                series_ref: page.series_ref,
997                items: page.items.into_iter().map(Into::into).collect(),
998                offset: page.offset,
999                limit: page.limit,
1000                total: page.total,
1001                has_more: page.has_more,
1002            })
1003            .map_err(Error::from_internal)
1004    }
1005
1006    pub async fn resolve_recurrence_occurrence(
1007        &self,
1008        workspace_id: &WorkspaceId,
1009        task_id: &TaskId,
1010        outcome: RecurrenceOutcome,
1011    ) -> Result<RecurrenceResolveResult, Error> {
1012        let workspace = self.workspace(workspace_id).await?;
1013        self.database
1014            .resolve_recurrence_occurrence(&workspace, task_id, outcome)
1015            .await
1016            .map(|value| RecurrenceResolveResult {
1017                series: value.series.into(),
1018                occurrence: value.resolved.into(),
1019                task: value.task.into(),
1020                successor: value.successor.map(Into::into),
1021            })
1022            .map_err(Error::from_internal)
1023    }
1024
1025    pub async fn complete_recurrence_occurrence(
1026        &self,
1027        workspace_id: &WorkspaceId,
1028        task_id: &TaskId,
1029    ) -> Result<RecurrenceResolveResult, Error> {
1030        self.resolve_recurrence_occurrence(workspace_id, task_id, RecurrenceOutcome::Completed)
1031            .await
1032    }
1033
1034    pub async fn skip_recurrence_occurrence(
1035        &self,
1036        workspace_id: &WorkspaceId,
1037        task_id: &TaskId,
1038    ) -> Result<RecurrenceResolveResult, Error> {
1039        self.resolve_recurrence_occurrence(workspace_id, task_id, RecurrenceOutcome::Skipped)
1040            .await
1041    }
1042
1043    pub async fn pause_recurrence_series(
1044        &self,
1045        workspace_id: &WorkspaceId,
1046        series_id: &RecurrenceSeriesId,
1047    ) -> Result<RecurrenceStateResult, Error> {
1048        let workspace = self.workspace(workspace_id).await?;
1049        self.database
1050            .pause_recurrence_series(&workspace, series_id)
1051            .await
1052            .map(Into::into)
1053            .map_err(Error::from_internal)
1054    }
1055
1056    pub async fn resume_recurrence_series(
1057        &self,
1058        workspace_id: &WorkspaceId,
1059        series_id: &RecurrenceSeriesId,
1060    ) -> Result<RecurrenceStateResult, Error> {
1061        let workspace = self.workspace(workspace_id).await?;
1062        let at = crate::ids::now_utc();
1063        self.database
1064            .resume_recurrence_series(&workspace, series_id, at)
1065            .await
1066            .map(Into::into)
1067            .map_err(Error::from_internal)
1068    }
1069
1070    pub async fn stop_recurrence_series(
1071        &self,
1072        workspace_id: &WorkspaceId,
1073        series_id: &RecurrenceSeriesId,
1074        skip_current: bool,
1075    ) -> Result<RecurrenceStateResult, Error> {
1076        let workspace = self.workspace(workspace_id).await?;
1077        self.database
1078            .stop_recurrence_series(&workspace, series_id, skip_current)
1079            .await
1080            .map(Into::into)
1081            .map_err(Error::from_internal)
1082    }
1083
1084    pub async fn recurrence_task_report(
1085        &self,
1086        workspace_id: &WorkspaceId,
1087        expand_recurring: bool,
1088    ) -> Result<Vec<TaskSummary>, Error> {
1089        self.workspace(workspace_id).await?;
1090        self.database
1091            .list_task_items(
1092                workspace_id,
1093                TaskFilters {
1094                    exclude_epics: true,
1095                    expand_recurring,
1096                    ..TaskFilters::default()
1097                },
1098                TaskQueryMode::Flat,
1099                TaskSort::Created,
1100                SortDirection::Asc,
1101            )
1102            .await
1103            .map(|items| items.into_iter().map(TaskSummary::from).collect())
1104            .map_err(Error::from_internal)
1105    }
1106
1107    pub async fn list_conflicts(
1108        &self,
1109        workspace_id: &WorkspaceId,
1110    ) -> Result<Vec<ConflictSummary>, Error> {
1111        let workspace = self.workspace(workspace_id).await?;
1112        self.database
1113            .list_conflicts(&workspace, None, None)
1114            .await
1115            .map_err(Error::from_internal)?
1116            .into_iter()
1117            // Summaries identify tasks with typed task fields; series template
1118            // conflicts use their own fields and resolution path.
1119            .filter(|conflict| {
1120                !conflict.recurrence_series && !conflict.field.starts_with("metadata:")
1121            })
1122            .map(|conflict| {
1123                Ok(ConflictSummary {
1124                    task_id: conflict.task_id,
1125                    task_title: conflict.title,
1126                    project_key: conflict.project_key,
1127                    project_prefix: conflict.project_prefix,
1128                    field: ConflictField::from_task_field(TaskField::parse_or_unknown(
1129                        &conflict.field,
1130                    )?),
1131                })
1132            })
1133            .collect::<Result<Vec<_>, InternalError>>()
1134            .map_err(Error::from_internal)
1135    }
1136
1137    pub async fn inspect_conflicts(
1138        &self,
1139        workspace_id: &WorkspaceId,
1140        task_id: &TaskId,
1141    ) -> Result<Vec<Conflict>, Error> {
1142        let workspace = self.workspace(workspace_id).await?;
1143        let details = self
1144            .database
1145            .task_conflicts(&workspace, task_id, None)
1146            .await
1147            .map_err(Error::from_internal)?;
1148        let mut connection = self
1149            .database
1150            .acquire_reader()
1151            .await
1152            .map_err(Error::from_internal)?;
1153        let mut conflicts = Vec::with_capacity(details.len());
1154        for detail in details {
1155            if detail.field.starts_with("metadata:") {
1156                continue;
1157            }
1158            let field = TaskField::parse_or_unknown(&detail.field).map_err(Error::from_internal)?;
1159            let local_value = crate::query::conflict_display_value(
1160                &mut connection,
1161                workspace_id,
1162                field.as_str(),
1163                &detail.local_value,
1164            )
1165            .await
1166            .map_err(Error::from_internal)?;
1167            let remote_value = crate::query::conflict_display_value(
1168                &mut connection,
1169                workspace_id,
1170                field.as_str(),
1171                &detail.remote_value,
1172            )
1173            .await
1174            .map_err(Error::from_internal)?;
1175            conflicts.push(Conflict {
1176                task_id: task_id.clone(),
1177                field: ConflictField::from_task_field(field),
1178                local_value: bounded_conflict_display_value(local_value),
1179                remote_value: bounded_conflict_display_value(remote_value),
1180                variant_a: detail.variant_a,
1181                variant_b: detail.variant_b,
1182            });
1183        }
1184        Ok(conflicts)
1185    }
1186
1187    pub async fn resolve_conflict(
1188        &self,
1189        workspace_id: &WorkspaceId,
1190        task_id: &TaskId,
1191        field: ConflictField,
1192        variant_a: String,
1193        variant_b: String,
1194        resolution: ConflictResolution,
1195    ) -> Result<TaskRecord, Error> {
1196        let workspace = self.workspace(workspace_id).await?;
1197        let field_name = field.as_str();
1198        let resolution = match &resolution {
1199            ConflictResolution::Local => crate::operations::ConflictResolutionValue::Local,
1200            ConflictResolution::Remote => crate::operations::ConflictResolutionValue::Remote,
1201            ConflictResolution::Explicit(value) => {
1202                crate::operations::ConflictResolutionValue::Explicit(value)
1203            }
1204        };
1205        let mut connection = self
1206            .database
1207            .acquire_writer()
1208            .await
1209            .map_err(Error::from_internal)?;
1210        crate::operations::resolve_conflict_transaction(
1211            &mut connection,
1212            &workspace,
1213            task_id,
1214            field_name,
1215            crate::operations::ExpectedConflictIdentity {
1216                variant_a: &variant_a,
1217                variant_b: &variant_b,
1218            },
1219            resolution,
1220        )
1221        .await
1222        .map(|outcome| TaskRecord::from(outcome.task))
1223        .map_err(Error::from_internal)
1224    }
1225
1226    async fn workspace(&self, workspace_id: &WorkspaceId) -> Result<Workspace, Error> {
1227        self.database
1228            .workspace_for_id(workspace_id)
1229            .await
1230            .map_err(|error| {
1231                let error = Error::from_internal(error);
1232                if error.code == ErrorCode::NotFound {
1233                    Error::new(
1234                        ErrorCode::NotFound,
1235                        format!("workspace not found: {workspace_id}"),
1236                    )
1237                } else {
1238                    error
1239                }
1240            })
1241    }
1242}
1243
1244fn validate_project(project: &str) -> Result<(), Error> {
1245    if crate::projects::normalize_key(project).is_empty() {
1246        return Err(Error::new(
1247            ErrorCode::Validation,
1248            "project must contain at least one letter or number".to_string(),
1249        ));
1250    }
1251    Ok(())
1252}
1253
1254fn validate_optional_date(field: &str, value: Option<&str>) -> Result<(), Error> {
1255    let Some(value) = value else {
1256        return Ok(());
1257    };
1258    if value.is_empty() {
1259        return Err(Error::new(
1260            ErrorCode::Validation,
1261            format!("{field} must be absent or contain a date"),
1262        ));
1263    }
1264    let result = match field {
1265        "available_at" => crate::time_validation::validate_available_at_value(value),
1266        "due_on" => crate::time_validation::validate_due_on_value(value),
1267        _ => unreachable!("consumer API validates only task dates"),
1268    };
1269    result.map_err(|error| Error::new(ErrorCode::Validation, error.to_string()))
1270}
1271
1272fn validate_date_update(field: &str, update: &OptionalDateUpdate) -> Result<(), Error> {
1273    match update {
1274        OptionalDateUpdate::Unchanged | OptionalDateUpdate::Clear => Ok(()),
1275        OptionalDateUpdate::Set(value) => validate_optional_date(field, Some(value)),
1276    }
1277}
1278
1279fn parse_date(field: &str, value: &str) -> Result<NaiveDate, Error> {
1280    NaiveDate::parse_from_str(value, "%Y-%m-%d").map_err(|_| {
1281        Error::new(
1282            ErrorCode::Validation,
1283            format!("{field} must use YYYY-MM-DD"),
1284        )
1285    })
1286}
1287
1288fn parse_local_time(field: &str, value: &str) -> Result<NaiveTime, Error> {
1289    NaiveTime::parse_from_str(value, "%H:%M")
1290        .or_else(|_| NaiveTime::parse_from_str(value, "%H:%M:%S"))
1291        .map_err(|_| {
1292            Error::new(
1293                ErrorCode::Validation,
1294                format!("{field} must use HH:MM or HH:MM:SS"),
1295            )
1296        })
1297}
1298
1299#[derive(Debug, Clone, PartialEq, Eq)]
1300pub struct StorageLayout {
1301    pub root: PathBuf,
1302    pub objects: PathBuf,
1303    pub staging: PathBuf,
1304    pub trash: PathBuf,
1305    pub previews: PathBuf,
1306}
1307
1308#[derive(Debug, Clone, PartialEq, Eq)]
1309pub struct WorkspaceRecord {
1310    pub id: WorkspaceId,
1311    pub key: String,
1312    pub name: String,
1313}
1314
1315impl From<Workspace> for WorkspaceRecord {
1316    fn from(workspace: Workspace) -> Self {
1317        Self {
1318            id: workspace.id,
1319            key: workspace.key,
1320            name: workspace.name,
1321        }
1322    }
1323}
1324
1325#[derive(Debug, Clone, PartialEq, Eq)]
1326pub struct WorkspaceQueueSummary {
1327    pub workspace: WorkspaceRecord,
1328    pub open_task_count: u32,
1329}
1330
1331#[derive(Debug, Clone, PartialEq, Eq)]
1332pub struct ProjectRecord {
1333    pub id: ProjectId,
1334    pub key: String,
1335    pub name: String,
1336}
1337
1338impl From<Project> for ProjectRecord {
1339    fn from(project: Project) -> Self {
1340        Self {
1341            id: project.id,
1342            key: project.key,
1343            name: project.name,
1344        }
1345    }
1346}
1347
1348#[derive(Debug, Clone, PartialEq, Eq)]
1349pub struct QueueState {
1350    pub selected_workspace: WorkspaceRecord,
1351    pub workspaces: Vec<WorkspaceQueueSummary>,
1352    pub projects: Vec<ProjectRecord>,
1353    pub labels: Vec<String>,
1354    pub queue: QueueReport,
1355}
1356
1357#[derive(Debug, Clone, PartialEq, Eq)]
1358pub struct TaskListRow {
1359    pub id: TaskId,
1360    pub title: String,
1361    pub display_ref: String,
1362    pub project_key: String,
1363    pub status: TaskStatus,
1364    pub priority: TaskPriority,
1365    pub due_on: Option<String>,
1366    pub available_at: Option<String>,
1367    pub created_at: String,
1368    pub updated_at: String,
1369    pub is_epic: bool,
1370}
1371
1372impl From<TaskListItem> for TaskListRow {
1373    fn from(item: TaskListItem) -> Self {
1374        Self {
1375            id: item.task.id,
1376            title: item.task.title,
1377            display_ref: item.display_ref,
1378            project_key: item.task.project_key,
1379            status: item.task.status,
1380            priority: item.task.priority,
1381            due_on: item.task.due_on,
1382            available_at: item.task.available_at,
1383            created_at: item.task.created_at,
1384            updated_at: item.task.updated_at,
1385            is_epic: item.task.is_epic,
1386        }
1387    }
1388}
1389
1390#[derive(Debug, Clone, PartialEq, Eq)]
1391pub struct TaskSearchResult {
1392    pub task: TaskListRow,
1393    pub matched_field: SearchMatchedField,
1394    pub snippet: Option<String>,
1395}
1396
1397pub use crate::query::TaskNote;
1398
1399#[derive(Debug, Clone, PartialEq, Eq)]
1400pub struct TaskLink {
1401    pub task_id: TaskId,
1402    pub display_ref: String,
1403    pub title: String,
1404    pub project_key: Option<String>,
1405    pub status: TaskStatus,
1406    pub deleted: bool,
1407    pub unresolved: bool,
1408}
1409
1410#[derive(Debug, Clone, PartialEq, Eq)]
1411pub enum AttachmentRead {
1412    Bytes { bytes: Vec<u8> },
1413    Unavailable,
1414    Missing,
1415    Corrupt,
1416    Invalidated,
1417}
1418
1419#[derive(Debug, Clone, PartialEq, Eq)]
1420pub struct TaskAttachment {
1421    pub attachment_id: String,
1422    pub media_type: String,
1423    pub byte_size: i64,
1424    pub filename: Option<String>,
1425    pub alt_text: Option<String>,
1426    pub width: Option<i64>,
1427    pub height: Option<i64>,
1428    pub availability: AttachmentAvailability,
1429    pub has_blob: bool,
1430}
1431
1432#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1433pub enum TaskActivityKind {
1434    Created,
1435    Title,
1436    Description,
1437    Status,
1438    Priority,
1439    Project,
1440    Deletion,
1441    Availability,
1442    DueDate,
1443    Epic,
1444    Attachment,
1445    Metadata,
1446    Label,
1447    Note,
1448    Blocker,
1449    Related,
1450    Conflict,
1451    Other,
1452}
1453
1454#[derive(Debug, Clone, PartialEq, Eq)]
1455pub struct TaskActivity {
1456    pub change_id: String,
1457    pub created_at: String,
1458    pub kind: TaskActivityKind,
1459    pub summary: String,
1460    /// RFC 3339 UTC instant a task was deferred to, for availability changes
1461    /// and availability conflict resolutions that set one. Clients format it
1462    /// for display.
1463    pub available_at: Option<String>,
1464    pub anchors_queue_idle: bool,
1465}
1466
1467#[derive(Debug, Clone, PartialEq, Eq)]
1468pub struct TaskDetail {
1469    pub id: TaskId,
1470    pub workspace_id: WorkspaceId,
1471    pub workspace_key: String,
1472    pub workspace_name: String,
1473    pub title: String,
1474    pub description: String,
1475    pub display_ref: String,
1476    pub project_key: String,
1477    pub project_name: String,
1478    pub status: TaskStatus,
1479    pub priority: TaskPriority,
1480    pub created_at: String,
1481    pub updated_at: String,
1482    pub available_at: Option<String>,
1483    pub due_on: Option<String>,
1484    pub deleted: bool,
1485    pub is_epic: bool,
1486    pub labels: Vec<String>,
1487    pub notes: Vec<TaskNote>,
1488    pub activity: Vec<TaskActivity>,
1489    pub blocked_by: Vec<TaskLink>,
1490    pub blocks: Vec<TaskLink>,
1491    pub related: Vec<TaskLink>,
1492    pub epic_parent: Option<TaskLink>,
1493    pub epic_children: Vec<TaskLink>,
1494    pub epic_done_count: u32,
1495    pub epic_total_count: u32,
1496    pub epic_progress: Option<EpicProgress>,
1497    pub attachments: Vec<TaskAttachment>,
1498    pub unresolved_conflict_count: u32,
1499    pub unresolved_conflict_fields: Vec<ConflictField>,
1500}
1501
1502impl TaskDetail {
1503    fn from_detail(detail: crate::query::TaskDetail, workspace: Workspace) -> Result<Self, Error> {
1504        let item = detail.item;
1505        let anchor = item.queue_idle_activity_index();
1506        let activity = item
1507            .activity
1508            .iter()
1509            .enumerate()
1510            .map(|(index, action)| TaskActivity {
1511                change_id: action.change_id.clone(),
1512                created_at: action.created_at.clone(),
1513                kind: match action.verb.as_str() {
1514                    "create" => TaskActivityKind::Created,
1515                    "title" => TaskActivityKind::Title,
1516                    "details" => TaskActivityKind::Description,
1517                    "status" => TaskActivityKind::Status,
1518                    "priority" => TaskActivityKind::Priority,
1519                    "project" => TaskActivityKind::Project,
1520                    "delete" => TaskActivityKind::Deletion,
1521                    "availability" => TaskActivityKind::Availability,
1522                    "due date" => TaskActivityKind::DueDate,
1523                    "epic" => TaskActivityKind::Epic,
1524                    "attachment" => TaskActivityKind::Attachment,
1525                    "metadata" => TaskActivityKind::Metadata,
1526                    "label" => TaskActivityKind::Label,
1527                    "note" => TaskActivityKind::Note,
1528                    "blocker" => TaskActivityKind::Blocker,
1529                    "related link" => TaskActivityKind::Related,
1530                    "conflict" => TaskActivityKind::Conflict,
1531                    _ => TaskActivityKind::Other,
1532                },
1533                summary: action.task_activity_summary(&item.task.title),
1534                available_at: action.task_activity_available_at(),
1535                anchors_queue_idle: anchor == Some(index),
1536            })
1537            .collect();
1538        let task = item.task;
1539        let blocked_by = detail
1540            .dependencies
1541            .depends_on
1542            .into_iter()
1543            .map(task_link_from_dependency)
1544            .collect();
1545        let blocks = detail
1546            .dependencies
1547            .blocks
1548            .into_iter()
1549            .map(task_link_from_dependency)
1550            .collect();
1551        let related = detail
1552            .related
1553            .into_iter()
1554            .map(|link| TaskLink {
1555                task_id: link.task_id,
1556                display_ref: link.display_ref,
1557                title: link.title,
1558                project_key: None,
1559                status: link.status,
1560                deleted: link.deleted,
1561                unresolved: false,
1562            })
1563            .collect();
1564        let epic_parent = item.epic_parent.map(task_link_from_enriched).transpose()?;
1565        let epic_children = item
1566            .epic_children
1567            .into_iter()
1568            .map(task_link_from_enriched)
1569            .collect::<Result<Vec<_>, _>>()?;
1570        let epic_progress = item.epic_rollup.clone().map(EpicProgress::from);
1571        let (epic_done_count, epic_total_count) = item
1572            .epic_rollup
1573            .map(|rollup| {
1574                (
1575                    rollup.done.min(u32::MAX as usize) as u32,
1576                    rollup.total.min(u32::MAX as usize) as u32,
1577                )
1578            })
1579            .unwrap_or_default();
1580        let attachments = item
1581            .attachments
1582            .into_iter()
1583            .map(|attachment| TaskAttachment {
1584                attachment_id: attachment.attachment_id,
1585                media_type: attachment.media_type,
1586                byte_size: attachment.byte_size,
1587                filename: attachment.filename,
1588                alt_text: attachment.alt_text,
1589                width: attachment.width,
1590                height: attachment.height,
1591                availability: attachment.bytes_state,
1592                has_blob: attachment.has_blob,
1593            })
1594            .collect();
1595        Ok(Self {
1596            id: task.id,
1597            workspace_id: task.workspace_id,
1598            workspace_key: workspace.key,
1599            workspace_name: workspace.name,
1600            title: task.title,
1601            description: task.description,
1602            display_ref: item.display_ref,
1603            project_key: task.project_key,
1604            project_name: detail.project_name,
1605            status: task.status,
1606            priority: task.priority,
1607            created_at: task.created_at,
1608            updated_at: task.updated_at,
1609            available_at: task.available_at,
1610            due_on: task.due_on,
1611            deleted: task.deleted,
1612            is_epic: task.is_epic,
1613            labels: item.labels,
1614            notes: detail.notes,
1615            activity,
1616            blocked_by,
1617            blocks,
1618            related,
1619            epic_parent,
1620            epic_children,
1621            epic_done_count,
1622            epic_total_count,
1623            epic_progress,
1624            attachments,
1625            unresolved_conflict_count: detail.conflicts.len().min(u32::MAX as usize) as u32,
1626            unresolved_conflict_fields: detail
1627                .conflicts
1628                .into_iter()
1629                .filter(|conflict| !conflict.field.starts_with("metadata:"))
1630                .map(|conflict| {
1631                    TaskField::parse_or_unknown(&conflict.field)
1632                        .map(ConflictField::from_task_field)
1633                        .map_err(Error::from_internal)
1634                })
1635                .collect::<Result<Vec<_>, _>>()?,
1636        })
1637    }
1638}
1639
1640fn task_link_from_dependency(link: crate::query::TaskDependencyItem) -> TaskLink {
1641    TaskLink {
1642        task_id: link.task.id,
1643        display_ref: link.display_ref,
1644        title: link.task.title,
1645        project_key: Some(link.task.project_key),
1646        status: link.task.status,
1647        deleted: link.task.deleted,
1648        unresolved: link.unresolved,
1649    }
1650}
1651
1652fn task_link_from_enriched(link: crate::query::TaskDependencyLink) -> Result<TaskLink, Error> {
1653    let status =
1654        TaskStatus::parse(&link.status).map_err(|error| Error::from_internal(error.into()))?;
1655    Ok(TaskLink {
1656        task_id: link.task_id,
1657        display_ref: link.display_ref,
1658        title: link.title,
1659        project_key: None,
1660        status,
1661        deleted: false,
1662        unresolved: link.unresolved,
1663    })
1664}
1665
1666#[derive(Debug, Clone, PartialEq, Eq)]
1667pub struct TaskCapture {
1668    pub title: String,
1669    pub description: String,
1670    pub project: Option<String>,
1671    pub status: TaskStatus,
1672    pub priority: TaskPriority,
1673    pub source: TaskSource,
1674    pub due_on: Option<String>,
1675    pub labels: Vec<String>,
1676}
1677
1678#[derive(Debug, Clone, PartialEq, Eq)]
1679pub struct TaskCaptureResult {
1680    pub task_id: TaskId,
1681    pub display_ref: String,
1682    pub undo_token: String,
1683    /// Optional refreshed projection. Absence does not change the committed receipt.
1684    pub state: Option<QueueState>,
1685}
1686
1687#[derive(serde::Serialize, serde::Deserialize)]
1688struct CaptureUndoToken {
1689    workspace_id: WorkspaceId,
1690    task_id: TaskId,
1691    expected: TaskUndoSnapshot,
1692}
1693
1694#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1695pub enum QueueMutationKind {
1696    Start,
1697    Done,
1698    Snooze,
1699    SetPriority,
1700}
1701
1702#[derive(Debug, Clone, PartialEq, Eq)]
1703pub struct QueueMutation {
1704    pub kind: QueueMutationKind,
1705    pub priority: Option<TaskPriority>,
1706    pub local_date: Option<String>,
1707    pub time_zone: Option<String>,
1708}
1709
1710impl QueueMutation {
1711    fn into_internal(self) -> Result<InternalConsumerTaskMutation, Error> {
1712        match self.kind {
1713            QueueMutationKind::Start => Ok(InternalConsumerTaskMutation::Start),
1714            QueueMutationKind::Done => Ok(InternalConsumerTaskMutation::Done),
1715            QueueMutationKind::SetPriority => self
1716                .priority
1717                .map(|priority| InternalConsumerTaskMutation::SetPriority {
1718                    priority: priority.as_str().to_string(),
1719                })
1720                .ok_or_else(|| {
1721                    Error::new(
1722                        ErrorCode::Validation,
1723                        "priority is required for this quick action".to_string(),
1724                    )
1725                }),
1726            QueueMutationKind::Snooze => {
1727                let local_date = self.local_date.ok_or_else(|| {
1728                    Error::new(
1729                        ErrorCode::Validation,
1730                        "local date is required for snooze".to_string(),
1731                    )
1732                })?;
1733                let time_zone = self.time_zone.ok_or_else(|| {
1734                    Error::new(
1735                        ErrorCode::Validation,
1736                        "time zone is required for snooze".to_string(),
1737                    )
1738                })?;
1739                Ok(InternalConsumerTaskMutation::Snooze {
1740                    available_at: tomorrow_start(&local_date, &time_zone)?,
1741                })
1742            }
1743        }
1744    }
1745}
1746
1747#[derive(Debug, Clone, PartialEq, Eq)]
1748pub struct QueueMutationResult {
1749    pub task_id: TaskId,
1750    pub undo_token: String,
1751    /// Optional refreshed projection. Absence does not change the committed receipt.
1752    pub state: Option<QueueState>,
1753}
1754
1755#[derive(Debug, Clone, PartialEq, Eq)]
1756pub struct DetailStatusReceipt {
1757    pub status: TaskStatus,
1758    pub undo_token: Option<String>,
1759}
1760
1761#[derive(Debug, Clone, PartialEq, Eq)]
1762pub struct TaskDeletionReceipt {
1763    pub deleted: bool,
1764    pub undo_token: Option<String>,
1765}
1766
1767#[derive(serde::Serialize, serde::Deserialize)]
1768struct MutationUndoToken {
1769    workspace_id: WorkspaceId,
1770    task_id: TaskId,
1771    mutation: InternalConsumerTaskMutation,
1772    before: TaskUndoSnapshot,
1773    expected: TaskUndoSnapshot,
1774}
1775
1776fn tomorrow_start(local_date: &str, time_zone: &str) -> Result<String, Error> {
1777    let date = NaiveDate::parse_from_str(local_date, "%Y-%m-%d")
1778        .map_err(|_| Error::new(ErrorCode::Validation, "invalid local date".to_string()))?
1779        .checked_add_days(Days::new(1))
1780        .ok_or_else(|| Error::new(ErrorCode::Validation, "invalid local date".to_string()))?;
1781    let zone = time_zone
1782        .parse::<chrono_tz::Tz>()
1783        .map_err(|_| Error::new(ErrorCode::Validation, "invalid IANA time zone".to_string()))?;
1784    let mut local = date.and_time(NaiveTime::MIN);
1785    let resolved = loop {
1786        match zone.from_local_datetime(&local) {
1787            LocalResult::Single(value) => break value,
1788            LocalResult::Ambiguous(first, second) => break first.min(second),
1789            LocalResult::None => {
1790                local = local
1791                    .checked_add_signed(chrono::Duration::minutes(1))
1792                    .ok_or_else(|| {
1793                        Error::new(ErrorCode::Validation, "invalid local date".to_string())
1794                    })?;
1795                if local.date() != date {
1796                    return Err(Error::new(
1797                        ErrorCode::Validation,
1798                        "time zone has no valid instant on the next local day".to_string(),
1799                    ));
1800                }
1801            }
1802        }
1803    };
1804    Ok(resolved
1805        .with_timezone(&Utc)
1806        .format("%Y-%m-%dT%H:%M:%SZ")
1807        .to_string())
1808}
1809
1810#[derive(Debug, Clone, PartialEq, Eq)]
1811pub struct MetadataFieldRecord {
1812    pub id: MetadataFieldId,
1813    pub workspace_id: WorkspaceId,
1814    pub key: String,
1815    pub task_count: usize,
1816    pub series_count: usize,
1817}
1818
1819impl From<MetadataFieldUsage> for MetadataFieldRecord {
1820    fn from(value: MetadataFieldUsage) -> Self {
1821        Self {
1822            id: value.field.id,
1823            workspace_id: value.field.workspace_id,
1824            key: value.field.key,
1825            task_count: value.task_count,
1826            series_count: value.series_count,
1827        }
1828    }
1829}
1830
1831#[derive(Debug, Clone, PartialEq, Eq)]
1832pub struct MetadataValueRecord {
1833    pub field_id: MetadataFieldId,
1834    pub key: String,
1835    pub value: String,
1836}
1837
1838impl From<TaskMetadataValue> for MetadataValueRecord {
1839    fn from(value: TaskMetadataValue) -> Self {
1840        Self {
1841            field_id: value.field_id,
1842            key: value.key,
1843            value: value.value,
1844        }
1845    }
1846}
1847
1848#[derive(Debug, Clone, PartialEq, Eq)]
1849pub struct MetadataInput {
1850    pub key: String,
1851    pub value: String,
1852}
1853
1854impl From<MetadataInput> for TaskMetadataInput {
1855    fn from(input: MetadataInput) -> Self {
1856        Self {
1857            expected_field_id: None,
1858            key: input.key,
1859            value: input.value,
1860        }
1861    }
1862}
1863
1864#[derive(Debug, Clone, PartialEq, Eq)]
1865pub struct CreateTask {
1866    pub title: String,
1867    pub description: String,
1868    pub project: String,
1869    pub status: TaskStatus,
1870    pub priority: TaskPriority,
1871    pub metadata: Vec<MetadataInput>,
1872    pub available_at: Option<String>,
1873    pub due_on: Option<String>,
1874}
1875
1876#[derive(Debug, Clone, PartialEq, Eq)]
1877pub struct TaskEditContext {
1878    pub detail: TaskDetail,
1879    pub projects: Vec<ProjectRecord>,
1880    pub labels: Vec<String>,
1881}
1882
1883#[derive(Debug, Clone, PartialEq, Eq, Default)]
1884pub struct TaskEdit {
1885    pub title: Option<String>,
1886    pub description: Option<String>,
1887    pub project_id: Option<ProjectId>,
1888    pub status: Option<TaskStatus>,
1889    pub priority: Option<TaskPriority>,
1890    pub add_labels: Vec<String>,
1891    pub remove_labels: Vec<String>,
1892    pub available_at: OptionalDateUpdate,
1893    pub due_on: OptionalDateUpdate,
1894}
1895
1896#[derive(Debug, Clone, PartialEq, Eq, Default)]
1897pub struct UpdateTask {
1898    pub title: Option<String>,
1899    pub description: Option<String>,
1900    pub project: Option<String>,
1901    pub status: Option<TaskStatus>,
1902    pub priority: Option<TaskPriority>,
1903    pub set_metadata: Vec<MetadataInput>,
1904    pub remove_metadata: Vec<String>,
1905    pub available_at: OptionalDateUpdate,
1906    pub due_on: OptionalDateUpdate,
1907}
1908
1909#[derive(Debug, Clone, PartialEq, Eq, Default)]
1910pub enum OptionalDateUpdate {
1911    #[default]
1912    Unchanged,
1913    Set(String),
1914    Clear,
1915}
1916
1917impl OptionalDateUpdate {
1918    fn into_internal(self) -> Option<Option<String>> {
1919        match self {
1920            Self::Unchanged => None,
1921            Self::Set(value) => Some(Some(value)),
1922            Self::Clear => Some(None),
1923        }
1924    }
1925}
1926
1927#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1928pub enum RecurrenceWeekday {
1929    Monday,
1930    Tuesday,
1931    Wednesday,
1932    Thursday,
1933    Friday,
1934    Saturday,
1935    Sunday,
1936}
1937
1938impl From<RecurrenceWeekday> for Weekday {
1939    fn from(value: RecurrenceWeekday) -> Self {
1940        match value {
1941            RecurrenceWeekday::Monday => Self::Mon,
1942            RecurrenceWeekday::Tuesday => Self::Tue,
1943            RecurrenceWeekday::Wednesday => Self::Wed,
1944            RecurrenceWeekday::Thursday => Self::Thu,
1945            RecurrenceWeekday::Friday => Self::Fri,
1946            RecurrenceWeekday::Saturday => Self::Sat,
1947            RecurrenceWeekday::Sunday => Self::Sun,
1948        }
1949    }
1950}
1951
1952impl From<Weekday> for RecurrenceWeekday {
1953    fn from(value: Weekday) -> Self {
1954        match value {
1955            Weekday::Mon => Self::Monday,
1956            Weekday::Tue => Self::Tuesday,
1957            Weekday::Wed => Self::Wednesday,
1958            Weekday::Thu => Self::Thursday,
1959            Weekday::Fri => Self::Friday,
1960            Weekday::Sat => Self::Saturday,
1961            Weekday::Sun => Self::Sunday,
1962        }
1963    }
1964}
1965
1966#[derive(Debug, Clone, PartialEq, Eq)]
1967pub struct RecurrenceRule {
1968    pub frequency: RecurrenceFrequency,
1969    pub interval: u32,
1970    pub weekdays: Vec<RecurrenceWeekday>,
1971}
1972
1973impl RecurrenceRule {
1974    fn into_internal(self) -> Result<InternalRecurrenceRule, Error> {
1975        InternalRecurrenceRule::new(
1976            self.frequency,
1977            self.interval,
1978            WeekdaySet::from_weekdays(self.weekdays.into_iter().map(Into::into)),
1979        )
1980        .map_err(|error| Error::new(ErrorCode::Validation, error.to_string()))
1981    }
1982}
1983
1984impl From<InternalRecurrenceRule> for RecurrenceRule {
1985    fn from(value: InternalRecurrenceRule) -> Self {
1986        Self {
1987            frequency: value.frequency(),
1988            interval: value.interval(),
1989            weekdays: value.weekdays_set().iter().map(Into::into).collect(),
1990        }
1991    }
1992}
1993
1994#[derive(Debug, Clone, PartialEq, Eq)]
1995pub struct RecurrenceScheduleInput {
1996    pub rule: RecurrenceRule,
1997    pub timezone: String,
1998    pub start_on: String,
1999    pub available_local_time: Option<String>,
2000    pub due_policy: RecurrenceDuePolicy,
2001}
2002
2003impl RecurrenceScheduleInput {
2004    fn into_internal(self) -> Result<RecurrenceSchedule, Error> {
2005        let timezone = self
2006            .timezone
2007            .parse::<TimeZoneId>()
2008            .map_err(|error| Error::new(ErrorCode::Validation, error.to_string()))?;
2009        let start_on = parse_date("start_on", &self.start_on)?;
2010        let available_local_time = self
2011            .available_local_time
2012            .map(|value| parse_local_time("available_local_time", &value))
2013            .transpose()?;
2014        Ok(RecurrenceSchedule::new(
2015            self.rule.into_internal()?,
2016            timezone,
2017            start_on,
2018            available_local_time,
2019            self.due_policy,
2020        ))
2021    }
2022}
2023
2024#[derive(Debug, Clone, PartialEq, Eq)]
2025pub struct CreateRecurrenceSeries {
2026    pub title: String,
2027    pub description: String,
2028    pub project: String,
2029    pub priority: TaskPriority,
2030    pub initial_status: TaskStatus,
2031    pub labels: Vec<String>,
2032    pub metadata: Vec<MetadataInput>,
2033    pub schedule: RecurrenceScheduleInput,
2034}
2035
2036#[derive(Debug, Clone, PartialEq, Eq, Default)]
2037pub enum OptionalLocalTimeUpdate {
2038    #[default]
2039    Unchanged,
2040    Set(String),
2041    Clear,
2042}
2043
2044impl OptionalLocalTimeUpdate {
2045    fn into_internal(self) -> Result<Option<Option<NaiveTime>>, Error> {
2046        match self {
2047            Self::Unchanged => Ok(None),
2048            Self::Set(value) => Ok(Some(Some(parse_local_time(
2049                "available_local_time",
2050                &value,
2051            )?))),
2052            Self::Clear => Ok(Some(None)),
2053        }
2054    }
2055}
2056
2057#[derive(Debug, Clone, PartialEq, Eq, Default)]
2058pub struct UpdateRecurrenceTemplate {
2059    pub title: Option<String>,
2060    pub description: Option<String>,
2061    pub project: Option<String>,
2062    pub priority: Option<TaskPriority>,
2063    pub initial_status: Option<TaskStatus>,
2064    pub labels: Option<Vec<String>>,
2065    pub set_metadata: Vec<MetadataInput>,
2066    pub remove_metadata: Vec<String>,
2067    pub available_local_time: OptionalLocalTimeUpdate,
2068    pub due_policy: Option<RecurrenceDuePolicy>,
2069}
2070
2071#[derive(Debug, Clone, PartialEq, Eq)]
2072pub struct RecurrenceSeriesRecord {
2073    pub workspace_id: WorkspaceId,
2074    pub id: RecurrenceSeriesId,
2075    pub title: String,
2076    pub description: String,
2077    pub project_id: ProjectId,
2078    pub priority: TaskPriority,
2079    pub initial_status: TaskStatus,
2080    pub rule: RecurrenceRule,
2081    pub timezone: String,
2082    pub start_on: String,
2083    pub available_local_time: Option<String>,
2084    pub due_policy: RecurrenceDuePolicy,
2085    pub state: RecurrenceSeriesState,
2086    pub stopped_at: Option<String>,
2087    pub created_at: String,
2088    pub updated_at: String,
2089}
2090
2091impl From<RecurrenceSeries> for RecurrenceSeriesRecord {
2092    fn from(value: RecurrenceSeries) -> Self {
2093        Self {
2094            workspace_id: value.workspace_id,
2095            id: value.id,
2096            title: value.title,
2097            description: value.description,
2098            project_id: value.project_id,
2099            priority: value.priority,
2100            initial_status: value.initial_status,
2101            rule: value.rule.into(),
2102            timezone: value.timezone.to_string(),
2103            start_on: value.start_on.to_string(),
2104            available_local_time: value
2105                .available_local_time
2106                .map(|time| time.format("%H:%M:%S").to_string()),
2107            due_policy: value.due_policy,
2108            state: value.state,
2109            stopped_at: value.stopped_at,
2110            created_at: value.created_at,
2111            updated_at: value.updated_at,
2112        }
2113    }
2114}
2115
2116#[derive(Debug, Clone, PartialEq, Eq)]
2117pub struct RecurrenceOccurrenceRecord {
2118    pub series_id: RecurrenceSeriesId,
2119    pub slot_on: String,
2120    pub task_id: Option<TaskId>,
2121    pub outcome: Option<RecurrenceOutcome>,
2122    pub resolved_at: Option<String>,
2123    pub projection_state: RecurrenceProjectionState,
2124    pub archived_at: Option<String>,
2125}
2126
2127impl From<RecurrenceOccurrence> for RecurrenceOccurrenceRecord {
2128    fn from(value: RecurrenceOccurrence) -> Self {
2129        Self {
2130            series_id: value.series_id,
2131            slot_on: value.slot_on.to_string(),
2132            task_id: value.task_id,
2133            outcome: value.outcome,
2134            resolved_at: value.resolved_at,
2135            projection_state: value.projection_state,
2136            archived_at: value.archived_at,
2137        }
2138    }
2139}
2140
2141#[derive(Debug, Clone, PartialEq, Eq)]
2142pub struct RecurrenceCreateResult {
2143    pub series: RecurrenceSeriesRecord,
2144    pub series_ref: String,
2145    pub occurrence: RecurrenceOccurrenceRecord,
2146    pub task: TaskRecord,
2147}
2148
2149#[derive(Debug, Clone, PartialEq, Eq)]
2150pub struct RecurrenceTemplateUpdateResult {
2151    pub series: RecurrenceSeriesRecord,
2152    pub changed: bool,
2153}
2154
2155#[derive(Debug, Clone, PartialEq, Eq)]
2156pub struct RecurrenceResolveResult {
2157    pub series: RecurrenceSeriesRecord,
2158    pub occurrence: RecurrenceOccurrenceRecord,
2159    pub task: TaskRecord,
2160    pub successor: Option<TaskRecord>,
2161}
2162
2163#[derive(Debug, Clone, PartialEq, Eq)]
2164pub struct RecurrenceStateResult {
2165    pub series: RecurrenceSeriesRecord,
2166    pub occurrence: Option<RecurrenceOccurrenceRecord>,
2167}
2168
2169impl From<crate::operations::RecurrenceStateOutcome> for RecurrenceStateResult {
2170    fn from(value: crate::operations::RecurrenceStateOutcome) -> Self {
2171        Self {
2172            series: value.series.into(),
2173            occurrence: value.occurrence.map(Into::into),
2174        }
2175    }
2176}
2177
2178#[derive(Debug, Clone, PartialEq, Eq)]
2179pub struct RecurrenceRefResolution {
2180    pub series_id: RecurrenceSeriesId,
2181    pub series_ref: String,
2182}
2183
2184#[derive(Debug, Clone, PartialEq, Eq, Default)]
2185pub struct RecurrenceCounts {
2186    pub completed: u64,
2187    pub skipped: u64,
2188    pub missed: u64,
2189    pub pause_intervals: u64,
2190    pub latest_slot_on: Option<String>,
2191    pub latest_outcome: Option<RecurrenceOutcome>,
2192}
2193
2194impl From<InternalRecurrenceCounts> for RecurrenceCounts {
2195    fn from(value: InternalRecurrenceCounts) -> Self {
2196        Self {
2197            completed: value.completed as u64,
2198            skipped: value.skipped as u64,
2199            missed: value.missed as u64,
2200            pause_intervals: value.pause_intervals as u64,
2201            latest_slot_on: value.latest_slot_on,
2202            latest_outcome: value.latest_outcome,
2203        }
2204    }
2205}
2206
2207#[derive(Debug, Clone, PartialEq, Eq)]
2208pub struct RecurrenceSeriesSummary {
2209    pub series: RecurrenceSeriesRecord,
2210    pub series_ref: String,
2211    pub rule_label: String,
2212    pub current_slot_on: Option<String>,
2213    pub current_task_ref: Option<String>,
2214    pub counts: RecurrenceCounts,
2215}
2216
2217impl From<InternalRecurrenceSeriesSummary> for RecurrenceSeriesSummary {
2218    fn from(value: InternalRecurrenceSeriesSummary) -> Self {
2219        Self {
2220            series: value.series.into(),
2221            series_ref: value.series_ref,
2222            rule_label: value.rule_label,
2223            current_slot_on: value.current_slot_on,
2224            current_task_ref: value.current_task_ref,
2225            counts: value.counts.into(),
2226        }
2227    }
2228}
2229
2230#[derive(Debug, Clone, PartialEq, Eq)]
2231pub struct RecurrenceSeriesConflict {
2232    pub field: String,
2233    pub variant_a: String,
2234    pub local_value: String,
2235    pub variant_b: String,
2236    pub remote_value: String,
2237}
2238
2239#[derive(Debug, Clone, PartialEq, Eq)]
2240pub struct RecurrenceSeriesDetail {
2241    pub series: RecurrenceSeriesRecord,
2242    pub labels: Vec<String>,
2243    pub metadata: Vec<MetadataValueRecord>,
2244    pub summary: RecurrenceSeriesSummary,
2245    pub current_occurrence: Option<RecurrenceOccurrenceRecord>,
2246    pub lifecycle_conflicts: Vec<RecurrenceSeriesConflict>,
2247}
2248
2249impl From<InternalRecurrenceSeriesDetail> for RecurrenceSeriesDetail {
2250    fn from(value: InternalRecurrenceSeriesDetail) -> Self {
2251        Self {
2252            series: value.series.into(),
2253            labels: value.labels,
2254            metadata: value.metadata.into_iter().map(Into::into).collect(),
2255            summary: value.summary.into(),
2256            current_occurrence: value.current_occurrence.map(Into::into),
2257            lifecycle_conflicts: value
2258                .lifecycle_conflicts
2259                .into_iter()
2260                .map(|conflict| RecurrenceSeriesConflict {
2261                    field: conflict.field,
2262                    variant_a: conflict.variant_a,
2263                    local_value: conflict.local_value,
2264                    variant_b: conflict.variant_b,
2265                    remote_value: conflict.remote_value,
2266                })
2267                .collect(),
2268        }
2269    }
2270}
2271
2272#[derive(Debug, Clone, PartialEq, Eq)]
2273pub struct RecurrenceHistoryRow {
2274    pub kind: RecurrenceHistoryKind,
2275    pub slot_on: Option<String>,
2276    pub interval_started_at: Option<String>,
2277    pub interval_ended_at: Option<String>,
2278    pub task_id: Option<TaskId>,
2279    pub task_ref: Option<String>,
2280    pub openable: bool,
2281    pub archived_projection: bool,
2282    pub resolved_at: Option<String>,
2283}
2284
2285impl From<InternalRecurrenceHistoryEntry> for RecurrenceHistoryRow {
2286    fn from(value: InternalRecurrenceHistoryEntry) -> Self {
2287        Self {
2288            kind: value.kind,
2289            slot_on: value.slot_on,
2290            interval_started_at: value.interval_started_at,
2291            interval_ended_at: value.interval_ended_at,
2292            task_id: value.task_id,
2293            task_ref: value.task_ref,
2294            openable: value.openable,
2295            archived_projection: value.archived_projection,
2296            resolved_at: value.resolved_at,
2297        }
2298    }
2299}
2300
2301#[derive(Debug, Clone, PartialEq, Eq)]
2302pub struct RecurrenceHistoryPage {
2303    pub series_ref: String,
2304    pub items: Vec<RecurrenceHistoryRow>,
2305    pub offset: usize,
2306    pub limit: usize,
2307    pub total: usize,
2308    pub has_more: bool,
2309}
2310
2311#[derive(Debug, Clone, PartialEq, Eq)]
2312pub struct TaskRecurrenceSummary {
2313    pub series_id: RecurrenceSeriesId,
2314    pub series_ref: String,
2315    pub slot_on: String,
2316    pub rule_label: String,
2317    pub timezone: String,
2318    pub lifecycle: RecurrenceSeriesState,
2319    pub outcome: Option<RecurrenceOutcome>,
2320    pub projection_state: RecurrenceProjectionState,
2321}
2322
2323#[derive(Debug, Clone, PartialEq, Eq)]
2324pub struct RecurrenceTaskGroup {
2325    pub series_id: RecurrenceSeriesId,
2326    pub series_ref: String,
2327    pub counts: RecurrenceCounts,
2328}
2329
2330#[derive(Debug, Clone, PartialEq, Eq)]
2331pub enum QueueDate {
2332    Due { on: String, kind: QueueDateKind },
2333    Deferred { at: String },
2334}
2335
2336#[derive(Debug, Clone, PartialEq, Eq)]
2337pub struct QueueLabelSummary {
2338    pub first: String,
2339    pub remaining_count: u32,
2340}
2341
2342#[derive(Debug, Clone, PartialEq, Eq)]
2343pub struct QueueTaskSummary {
2344    pub id: TaskId,
2345    pub title: String,
2346    pub display_ref: String,
2347    pub project_key: String,
2348    pub status: TaskStatus,
2349    pub priority: TaskPriority,
2350    pub due_on: Option<String>,
2351    pub band: QueueBand,
2352    pub reason: Option<QueueReason>,
2353    pub date: Option<QueueDate>,
2354    pub label: Option<QueueLabelSummary>,
2355    pub is_epic: bool,
2356    pub live_attachment_count: u32,
2357    pub has_conflict: bool,
2358    pub unresolved_blocker_count: i64,
2359}
2360
2361impl From<TaskListItem> for QueueTaskSummary {
2362    fn from(value: TaskListItem) -> Self {
2363        let date = if value.queue.has_deferred_date {
2364            value
2365                .task
2366                .available_at
2367                .clone()
2368                .map(|at| QueueDate::Deferred { at })
2369        } else {
2370            value.queue.date_kind.and_then(|kind| {
2371                value
2372                    .task
2373                    .due_on
2374                    .clone()
2375                    .map(|on| QueueDate::Due { on, kind })
2376            })
2377        };
2378        let label = value
2379            .labels
2380            .first()
2381            .cloned()
2382            .map(|first| QueueLabelSummary {
2383                first,
2384                remaining_count: value.labels.len().saturating_sub(1).min(u32::MAX as usize) as u32,
2385            });
2386        Self {
2387            id: value.task.id,
2388            title: value.task.title,
2389            display_ref: value.display_ref,
2390            project_key: value.task.project_key,
2391            status: value.task.status,
2392            priority: value.task.priority,
2393            due_on: value.task.due_on,
2394            band: value.queue.band,
2395            reason: value.queue.reason,
2396            date,
2397            label,
2398            is_epic: value.task.is_epic,
2399            live_attachment_count: value.live_attachment_count,
2400            has_conflict: value.has_conflict,
2401            unresolved_blocker_count: value.unresolved_blocker_count,
2402        }
2403    }
2404}
2405
2406#[derive(Debug, Clone, PartialEq, Eq)]
2407pub struct QueueReport {
2408    pub tasks: Vec<QueueTaskSummary>,
2409    pub unresolved_conflict_count: u32,
2410}
2411
2412#[derive(Debug, Clone, PartialEq, Eq)]
2413pub struct TaskSummary {
2414    pub task: TaskRecord,
2415    pub display_ref: String,
2416    pub recurrence: Option<TaskRecurrenceSummary>,
2417    pub recurrence_group: Option<RecurrenceTaskGroup>,
2418}
2419
2420impl From<TaskListItem> for TaskSummary {
2421    fn from(value: TaskListItem) -> Self {
2422        Self {
2423            task: value.task.into(),
2424            display_ref: value.display_ref,
2425            recurrence: value.recurrence.map(|summary| TaskRecurrenceSummary {
2426                series_id: summary.series_id,
2427                series_ref: summary.series_ref,
2428                slot_on: summary.slot_on,
2429                rule_label: summary.rule_label,
2430                timezone: summary.timezone,
2431                lifecycle: summary.lifecycle,
2432                outcome: summary.outcome,
2433                projection_state: summary.projection_state,
2434            }),
2435            recurrence_group: value.recurrence_group.map(|group| RecurrenceTaskGroup {
2436                series_id: group.series_id,
2437                series_ref: group.series_ref,
2438                counts: group.counts.into(),
2439            }),
2440        }
2441    }
2442}
2443
2444#[derive(Debug, Clone, PartialEq, Eq)]
2445pub struct RelatedTaskRecord {
2446    pub task_id: TaskId,
2447    pub display_ref: String,
2448    pub title: String,
2449    pub status: TaskStatus,
2450    pub priority: TaskPriority,
2451    pub deleted: bool,
2452    pub linked_at: String,
2453}
2454
2455impl From<crate::query::TaskRelatedLink> for RelatedTaskRecord {
2456    fn from(value: crate::query::TaskRelatedLink) -> Self {
2457        Self {
2458            task_id: value.task_id,
2459            display_ref: value.display_ref,
2460            title: value.title,
2461            status: value.status,
2462            priority: value.priority,
2463            deleted: value.deleted,
2464            linked_at: value.linked_at,
2465        }
2466    }
2467}
2468
2469#[derive(Debug, Clone, PartialEq, Eq)]
2470pub struct RelatedMutationResult {
2471    pub changed: bool,
2472}
2473
2474#[derive(Debug, Clone, PartialEq, Eq)]
2475pub struct TaskRecord {
2476    pub id: TaskId,
2477    pub workspace_id: WorkspaceId,
2478    pub title: String,
2479    pub description: String,
2480    pub project_id: ProjectId,
2481    pub project_key: String,
2482    pub project_prefix: String,
2483    pub status: TaskStatus,
2484    pub priority: TaskPriority,
2485    pub created_at: String,
2486    pub updated_at: String,
2487    pub available_at: Option<String>,
2488    pub due_on: Option<String>,
2489    pub metadata: Vec<MetadataValueRecord>,
2490    pub related: Vec<RelatedTaskRecord>,
2491}
2492
2493impl From<Task> for TaskRecord {
2494    fn from(task: Task) -> Self {
2495        Self {
2496            id: task.id,
2497            workspace_id: task.workspace_id,
2498            title: task.title,
2499            description: task.description,
2500            project_id: task.project_id,
2501            project_key: task.project_key,
2502            project_prefix: task.project_prefix,
2503            status: task.status,
2504            priority: task.priority,
2505            created_at: task.created_at,
2506            updated_at: task.updated_at,
2507            available_at: task.available_at,
2508            due_on: task.due_on,
2509            metadata: Vec::new(),
2510            related: Vec::new(),
2511        }
2512    }
2513}
2514
2515impl TaskRecord {
2516    fn with_metadata(task: Task, metadata: Vec<TaskMetadataValue>) -> Self {
2517        Self::with_metadata_and_related(task, metadata, Vec::new())
2518    }
2519
2520    fn with_metadata_and_related(
2521        task: Task,
2522        metadata: Vec<TaskMetadataValue>,
2523        related: Vec<crate::query::TaskRelatedLink>,
2524    ) -> Self {
2525        let mut record = Self::from(task);
2526        record.metadata = metadata.into_iter().map(Into::into).collect();
2527        record.related = related.into_iter().map(Into::into).collect();
2528        record
2529    }
2530}
2531
2532#[derive(Debug, Clone, PartialEq, Eq)]
2533pub struct TaskUpdateResult {
2534    pub task: TaskRecord,
2535    pub changed: bool,
2536}
2537
2538#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2539pub enum ConflictField {
2540    Title,
2541    Description,
2542    Project,
2543    Status,
2544    Priority,
2545    AvailableAt,
2546    DueOn,
2547    Deleted,
2548    IsEpic,
2549}
2550
2551impl ConflictField {
2552    pub const fn as_str(self) -> &'static str {
2553        match self {
2554            Self::Title => "title",
2555            Self::Description => "description",
2556            Self::Project => "project",
2557            Self::Status => "status",
2558            Self::Priority => "priority",
2559            Self::AvailableAt => "available_at",
2560            Self::DueOn => "due_on",
2561            Self::Deleted => "deleted",
2562            Self::IsEpic => "is_epic",
2563        }
2564    }
2565
2566    fn from_task_field(field: TaskField) -> Self {
2567        match field {
2568            TaskField::Title => Self::Title,
2569            TaskField::Description => Self::Description,
2570            TaskField::Project => Self::Project,
2571            TaskField::Status => Self::Status,
2572            TaskField::Priority => Self::Priority,
2573            TaskField::AvailableAt => Self::AvailableAt,
2574            TaskField::DueOn => Self::DueOn,
2575            TaskField::Deleted => Self::Deleted,
2576            TaskField::IsEpic => Self::IsEpic,
2577        }
2578    }
2579}
2580
2581#[derive(Debug, Clone, PartialEq, Eq)]
2582pub struct ConflictSummary {
2583    pub task_id: TaskId,
2584    pub task_title: String,
2585    pub project_key: String,
2586    pub project_prefix: String,
2587    pub field: ConflictField,
2588}
2589
2590#[derive(Debug, Clone, PartialEq, Eq)]
2591pub struct Conflict {
2592    pub task_id: TaskId,
2593    pub field: ConflictField,
2594    pub local_value: String,
2595    pub remote_value: String,
2596    pub variant_a: String,
2597    pub variant_b: String,
2598}
2599
2600#[derive(Debug, Clone, PartialEq, Eq)]
2601pub enum ConflictResolution {
2602    Local,
2603    Remote,
2604    Explicit(String),
2605}
2606
2607fn bounded_conflict_display_value(value: String) -> String {
2608    const MAX_CHARACTERS: usize = 512;
2609    let mut characters = value.chars();
2610    let preview: String = characters.by_ref().take(MAX_CHARACTERS).collect();
2611    if characters.next().is_some() {
2612        format!("{preview}…")
2613    } else {
2614        preview
2615    }
2616}
2617
2618#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2619pub enum ErrorCode {
2620    SyncCompatibility,
2621    Validation,
2622    NotFound,
2623    OpenConflict,
2624    GenerationConflict,
2625    Database,
2626    Internal,
2627}
2628
2629impl ErrorCode {
2630    pub const fn as_str(self) -> &'static str {
2631        match self {
2632            Self::SyncCompatibility => "sync_compatibility",
2633            Self::Validation => "validation",
2634            Self::NotFound => "not_found",
2635            Self::OpenConflict => "open_conflict",
2636            Self::GenerationConflict => "generation_conflict",
2637            Self::Database => "database",
2638            Self::Internal => "internal",
2639        }
2640    }
2641}
2642
2643#[derive(Debug, Clone, PartialEq, Eq)]
2644pub struct Error {
2645    pub code: ErrorCode,
2646    pub message: String,
2647}
2648
2649impl Error {
2650    fn new(code: ErrorCode, message: String) -> Self {
2651        Self { code, message }
2652    }
2653
2654    fn database_open(error: InternalError) -> Self {
2655        Self::new(ErrorCode::Database, error.to_string())
2656    }
2657
2658    fn from_internal(error: InternalError) -> Self {
2659        if error
2660            .downcast_ref::<crate::sync::protocol::SyncCompatibilityError>()
2661            .is_some()
2662            || error
2663                .downcast_ref::<crate::sync::protocol::SharedOperationCompatibilityError>()
2664                .is_some()
2665        {
2666            return Self::new(ErrorCode::SyncCompatibility, error.to_string());
2667        }
2668        let code = error
2669            .chain()
2670            .find_map(|cause| cause.downcast_ref::<crate::error::CoreError>())
2671            .map(|error| match error.kind() {
2672                crate::error::ErrorKind::Validation => ErrorCode::Validation,
2673                crate::error::ErrorKind::NotFound => ErrorCode::NotFound,
2674                crate::error::ErrorKind::OpenConflict => ErrorCode::OpenConflict,
2675                crate::error::ErrorKind::GenerationConflict => ErrorCode::GenerationConflict,
2676            })
2677            .unwrap_or_else(|| {
2678                if error
2679                    .chain()
2680                    .filter_map(|cause| cause.downcast_ref::<sqlx::Error>())
2681                    .any(|error| matches!(error, sqlx::Error::RowNotFound))
2682                {
2683                    ErrorCode::NotFound
2684                } else if error
2685                    .chain()
2686                    .any(|cause| cause.downcast_ref::<sqlx::Error>().is_some())
2687                {
2688                    ErrorCode::Database
2689                } else {
2690                    ErrorCode::Internal
2691                }
2692            });
2693        Self::new(code, error.to_string())
2694    }
2695}
2696
2697impl fmt::Display for Error {
2698    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
2699        write!(formatter, "{}: {}", self.code.as_str(), self.message)
2700    }
2701}
2702
2703impl std::error::Error for Error {}
2704
2705impl TaskCapture {
2706    fn into_draft(self) -> Result<TaskDraft, Error> {
2707        let title = self.title.trim();
2708        if title.is_empty() {
2709            return Err(Error::new(
2710                ErrorCode::Validation,
2711                "task title is required".into(),
2712            ));
2713        }
2714        validate_optional_date("due_on", self.due_on.as_deref())?;
2715        let project = self
2716            .project
2717            .ok_or_else(|| Error::new(ErrorCode::Validation, "project is required".into()))?;
2718        Ok(TaskDraft {
2719            title: title.to_string(),
2720            description: self.description,
2721            project: Some(project),
2722            status: self.status.as_str().to_string(),
2723            priority: self.priority.as_str().to_string(),
2724            source: self.source,
2725            labels: self.labels,
2726            metadata: Vec::new(),
2727            available_at: None,
2728            due_on: self.due_on,
2729            is_epic: false,
2730        })
2731    }
2732}
2733
2734impl TaskEdit {
2735    fn into_update(self) -> Result<(Option<ProjectId>, InternalTaskUpdate), Error> {
2736        let title = self.title.map(|title| title.trim().to_string());
2737        if let Some(title) = title.as_deref() {
2738            if title.is_empty() {
2739                return Err(Error::new(
2740                    ErrorCode::Validation,
2741                    "title must not be empty".into(),
2742                ));
2743            }
2744            if title.chars().any(|character| {
2745                matches!(
2746                    character,
2747                    '\n' | '\r' | '\u{000B}' | '\u{000C}' | '\u{0085}' | '\u{2028}' | '\u{2029}'
2748                )
2749            }) {
2750                return Err(Error::new(
2751                    ErrorCode::Validation,
2752                    "title must be a single line".into(),
2753                ));
2754            }
2755        }
2756        validate_date_update("available_at", &self.available_at)?;
2757        validate_date_update("due_on", &self.due_on)?;
2758        Ok((
2759            self.project_id,
2760            InternalTaskUpdate {
2761                title,
2762                description: self.description,
2763                status: self.status.map(|v| v.as_str().to_string()),
2764                priority: self.priority.map(|v| v.as_str().to_string()),
2765                available_at: self.available_at.into_internal(),
2766                due_on: self.due_on.into_internal(),
2767                add_labels: self.add_labels,
2768                remove_labels: self.remove_labels,
2769                ..InternalTaskUpdate::default()
2770            },
2771        ))
2772    }
2773}
2774
2775#[cfg(test)]
2776mod tests {
2777    use super::*;
2778
2779    #[tokio::test]
2780    async fn queue_capture_receipt_survives_post_commit_read_failure() {
2781        let directory = tempfile::tempdir().unwrap();
2782        let store = Store::open(directory.path().join("capture.sqlite"))
2783            .await
2784            .unwrap();
2785        let workspace = store.resolve_workspace("default").await.unwrap();
2786        store
2787            .create_task(
2788                &workspace.id,
2789                CreateTask {
2790                    title: "Seed".into(),
2791                    description: String::new(),
2792                    project: "ios".into(),
2793                    status: TaskStatus::Done,
2794                    priority: TaskPriority::None,
2795                    metadata: Vec::new(),
2796                    available_at: None,
2797                    due_on: None,
2798                },
2799            )
2800            .await
2801            .unwrap();
2802        store
2803            .fail_queue_reads
2804            .store(true, std::sync::atomic::Ordering::SeqCst);
2805        let result = store
2806            .capture_queue_task(
2807                &workspace.id,
2808                TaskCapture {
2809                    title: "Captured".into(),
2810                    description: String::new(),
2811                    project: Some("ios".into()),
2812                    status: TaskStatus::Inbox,
2813                    priority: TaskPriority::None,
2814                    source: TaskSource::Ios,
2815                    due_on: None,
2816                    labels: Vec::new(),
2817                },
2818            )
2819            .await;
2820        let tasks = store.list_tasks(&workspace.id).await.unwrap();
2821        assert_eq!(
2822            tasks.iter().filter(|task| task.title == "Captured").count(),
2823            1
2824        );
2825        assert!(
2826            result.is_ok(),
2827            "committed capture lost its receipt: {result:?}"
2828        );
2829        let captured = result.unwrap();
2830        assert!(captured.state.is_none());
2831        assert_eq!(captured.display_ref, captured.task_id.to_string());
2832        assert!(!captured.undo_token.is_empty());
2833        let mutation_target = store
2834            .capture_queue_task(
2835                &workspace.id,
2836                TaskCapture {
2837                    title: "Mutation target".into(),
2838                    description: String::new(),
2839                    project: Some("ios".into()),
2840                    status: TaskStatus::Inbox,
2841                    priority: TaskPriority::None,
2842                    source: TaskSource::Ios,
2843                    due_on: None,
2844                    labels: Vec::new(),
2845                },
2846            )
2847            .await
2848            .unwrap();
2849        // Mutations can invalidate capture undo even after their values are restored.
2850        let changed = store
2851            .mutate_queue_task(
2852                &workspace.id,
2853                &mutation_target.task_id,
2854                QueueMutation {
2855                    kind: QueueMutationKind::SetPriority,
2856                    priority: Some(TaskPriority::High),
2857                    local_date: None,
2858                    time_zone: None,
2859                },
2860            )
2861            .await
2862            .unwrap();
2863        assert_eq!(changed.task_id, mutation_target.task_id);
2864        assert!(changed.state.is_none());
2865        assert_eq!(
2866            store
2867                .task_detail(&workspace.id, &mutation_target.task_id)
2868                .await
2869                .unwrap()
2870                .priority,
2871            TaskPriority::High
2872        );
2873        assert!(
2874            store
2875                .undo_queue_mutation(&changed.undo_token)
2876                .await
2877                .unwrap()
2878                .is_none()
2879        );
2880        assert_eq!(
2881            store
2882                .task_detail(&workspace.id, &mutation_target.task_id)
2883                .await
2884                .unwrap()
2885                .priority,
2886            TaskPriority::None
2887        );
2888        assert!(
2889            store
2890                .undo_queue_capture(&captured.undo_token)
2891                .await
2892                .unwrap()
2893                .is_none()
2894        );
2895        store
2896            .fail_queue_reads
2897            .store(false, std::sync::atomic::Ordering::SeqCst);
2898        let state = store.queue_state().await.unwrap();
2899        assert!(
2900            state
2901                .queue
2902                .tasks
2903                .iter()
2904                .all(|task| task.id != captured.task_id)
2905        );
2906    }
2907
2908    #[test]
2909    fn internal_errors_map_to_distinct_error_codes() {
2910        assert_eq!(
2911            Error::from_internal(
2912                crate::error::CoreError::generation_conflict(
2913                    "error recurrence-generation-conflict slot=2026-08-01 field=task"
2914                )
2915                .into()
2916            )
2917            .code,
2918            ErrorCode::GenerationConflict
2919        );
2920        assert_eq!(
2921            Error::from_internal(
2922                crate::error::CoreError::not_found("error recurrence-series-not-found").into()
2923            )
2924            .code,
2925            ErrorCode::NotFound
2926        );
2927        assert_eq!(
2928            Error::from_internal(anyhow::anyhow!("unexpected invariant")).code,
2929            ErrorCode::Internal
2930        );
2931        assert_eq!(
2932            Error::from_internal(sqlx::Error::PoolClosed.into()).code,
2933            ErrorCode::Database
2934        );
2935    }
2936}