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