1use std::collections::BTreeMap;
6
7use chrono::{DateTime, Duration, Utc};
8use serde::{Deserialize, Serialize};
9
10use lean_ctx_protocol::UtcTimestamp;
11use lean_ctx_protocol::hive::tasks::{
12 AmendCaller, ClaimRefV1, EvidenceV1, EvidenceViewV1, MAX_VIEW_UPDATES, TASK_AMEND_SCHEMA,
13 TASK_PROPOSE_SCHEMA, TASK_RELEASE_SCHEMA, TASK_RESULT_SCHEMA, TASK_SCHEMA, TASK_UPDATE_SCHEMA,
14 TASK_VERIFY_SCHEMA, TaskAmendRequestV1, TaskClaimV1, TaskLifecycle, TaskMergeV1,
15 TaskOriginKind, TaskOriginV1, TaskOutcome, TaskPriority, TaskProposeRequestV1,
16 TaskReleaseRequestV1, TaskResultRequestV1, TaskResultV1, TaskState, TaskUpdateKind,
17 TaskUpdateRequestV1, TaskUpdateV1, TaskV1, TaskVerdict, TaskVerificationV1,
18 TaskVerifyRequestV1, UsageV1, amend_rule, may_amend, merge_evidence, validate_labels,
19};
20
21use crate::{
22 Actor, Change, DEFAULT_CLAIM_MINUTES, LOCAL_VAULT, MAX_CLAIM_MINUTES, MIN_CLAIM_MINUTES,
23 TaskError, TaskEvent, timestamp,
24};
25
26pub const BOARD_SCHEMA: &str = "leanctx.local-tasks/v1";
27pub const MAX_TASKS: usize = 2000;
29pub const MAX_TRANSITIONS: usize = 50;
31
32#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
34pub struct Transition {
35 pub seq: u64,
36 pub number: u64,
37 pub event: TaskEvent,
38 pub from: TaskState,
39 pub to: TaskState,
40 pub by: lean_ctx_protocol::hive::tasks::TaskActorV1,
41 pub at: UtcTimestamp,
42 #[serde(default, skip_serializing_if = "Option::is_none")]
43 pub reason: Option<String>,
44 #[serde(default, skip_serializing_if = "Option::is_none")]
47 pub worker: Option<String>,
48}
49
50impl From<&Transition> for Change {
51 fn from(t: &Transition) -> Self {
52 Self {
53 seq: t.seq,
54 number: t.number,
55 event: t.event,
56 state: t.to,
57 worker: t.worker.clone(),
58 }
59 }
60}
61
62#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
64pub struct SharedRef {
65 pub vault: String,
67 pub number: u64,
69 pub at: UtcTimestamp,
70}
71
72#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
74pub struct LocalTask {
75 pub task: TaskV1,
76 #[serde(default)]
78 pub transitions: Vec<Transition>,
79 #[serde(default, skip_serializing_if = "Option::is_none")]
81 pub claim_minutes: Option<u32>,
82 #[serde(default, skip_serializing_if = "Option::is_none")]
83 pub shared: Option<SharedRef>,
84}
85
86#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
88pub struct Board {
89 pub schema: String,
90 pub next_number: u64,
92 pub seq: u64,
94 #[serde(default)]
95 pub tasks: BTreeMap<u64, LocalTask>,
96 #[serde(skip)]
98 pub(crate) pending: Vec<Transition>,
99 #[serde(skip)]
101 pub(crate) changes: Vec<Change>,
102}
103
104impl Default for Board {
105 fn default() -> Self {
106 Self {
107 schema: BOARD_SCHEMA.to_owned(),
108 next_number: 1,
109 seq: 0,
110 tasks: BTreeMap::new(),
111 pending: Vec::new(),
112 changes: Vec::new(),
113 }
114 }
115}
116
117#[derive(Debug, Clone, Default, PartialEq, Eq)]
119pub struct Proposal {
120 pub title: String,
121 pub goal: Option<String>,
122 pub acceptance: String,
123 pub role: Option<String>,
124}
125
126#[derive(Debug, Clone, PartialEq, Eq)]
128pub struct Submission {
129 pub summary: String,
130 pub evidence: Vec<EvidenceV1>,
131 pub usage: Option<UsageV1>,
132 pub outcome: TaskOutcome,
133}
134
135#[derive(Debug, Clone, PartialEq, Eq, Default)]
139pub struct Amendment {
140 pub summary: Option<String>,
141 pub evidence: Vec<EvidenceV1>,
142 pub merge_commit: Option<String>,
143 pub reason: Option<String>,
144 pub queen: bool,
148}
149
150fn closed(state: TaskState) -> bool {
151 matches!(
152 state,
153 TaskState::Done
154 | TaskState::Rejected
155 | TaskState::Cancelled
156 | TaskState::Failed
157 | TaskState::Unknown
158 )
159}
160
161fn local_claim() -> ClaimRefV1 {
164 ClaimRefV1 {
165 lease_id: "local".to_owned(),
166 fencing_token: 1,
167 }
168}
169
170fn invalid(error: impl std::fmt::Display) -> TaskError {
171 TaskError::Invalid(error.to_string())
172}
173
174fn check_reason(reason: Option<&str>) -> Result<Option<String>, TaskError> {
175 let reason = reason.map(str::trim).filter(|r| !r.is_empty());
176 TaskReleaseRequestV1 {
177 schema: TASK_RELEASE_SCHEMA.to_owned(),
178 claim: local_claim(),
179 reason: reason.map(str::to_owned),
180 }
181 .validate()
182 .map_err(invalid)?;
183 Ok(reason.map(str::to_owned))
184}
185
186fn lifecycle(task: &TaskV1) -> Option<TaskLifecycle> {
187 Some(match task.state {
188 TaskState::Open => TaskLifecycle::Queued,
189 TaskState::Claimed => {
190 let since = task.claim.as_ref().map(|c| c.since.as_str());
191 let reported = task.updates.iter().any(|u| {
192 u.kind != Some(TaskUpdateKind::Verification) && Some(u.at.as_str()) >= since
193 });
194 if reported {
195 TaskLifecycle::Running
196 } else {
197 TaskLifecycle::Claimed
198 }
199 }
200 TaskState::Submitted => TaskLifecycle::Submitted,
201 TaskState::Done => TaskLifecycle::Completed,
202 TaskState::Failed => TaskLifecycle::Failed,
203 TaskState::Cancelled => TaskLifecycle::Cancelled,
204 TaskState::Proposed | TaskState::Rejected | TaskState::Unknown => return None,
205 })
206}
207
208fn push_update(task: &mut TaskV1, update: TaskUpdateV1) {
209 task.updates.push(update);
210 let excess = task.updates.len().saturating_sub(MAX_VIEW_UPDATES);
211 task.updates.drain(..excess);
212}
213
214impl Board {
215 fn task_mut(&mut self, number: u64) -> Result<&mut LocalTask, TaskError> {
216 self.tasks
217 .get_mut(&number)
218 .ok_or(TaskError::NotFound(number))
219 }
220
221 fn record(
223 &mut self,
224 number: u64,
225 event: TaskEvent,
226 from: TaskState,
227 by: &Actor,
228 at: DateTime<Utc>,
229 reason: Option<String>,
230 ) -> Change {
231 self.seq += 1;
232 let seq = self.seq;
233 let transition = Transition {
234 seq,
235 number,
236 event,
237 from,
238 to: TaskState::Unknown,
239 by: by.view(),
240 at: timestamp(at),
241 reason,
242 worker: None,
243 };
244 let mut change = Change {
245 seq,
246 number,
247 event,
248 state: TaskState::Unknown,
249 worker: None,
250 };
251 if let Some(local) = self.tasks.get_mut(&number) {
252 local.task.lifecycle = lifecycle(&local.task);
253 let mut transition = transition;
254 transition.to = local.task.state;
255 change.state = local.task.state;
256 if event == TaskEvent::Submitted {
257 change.worker = local.task.result.as_ref().and_then(|r| r.by.agent.clone());
258 transition.worker.clone_from(&change.worker);
259 }
260 local.transitions.push(transition.clone());
261 let excess = local.transitions.len().saturating_sub(MAX_TRANSITIONS);
262 local.transitions.drain(..excess);
263 self.pending.push(transition);
264 self.changes.push(change.clone());
265 }
266 change
267 }
268
269 pub fn expire(&mut self, now: DateTime<Utc>) -> Vec<Change> {
271 let now_text = timestamp(now);
272 let due: Vec<(u64, String)> = self
273 .tasks
274 .iter()
275 .filter_map(|(n, local)| {
276 let claim = local.task.claim.as_ref()?;
277 (local.task.state == TaskState::Claimed && claim.until.as_str() < now_text.as_str())
278 .then(|| (*n, claim.holder.agent.clone().unwrap_or_default()))
279 })
280 .collect();
281 due.into_iter()
282 .map(|(number, holder)| {
283 if let Some(local) = self.tasks.get_mut(&number) {
284 local.task.state = TaskState::Open;
285 local.task.claim = None;
286 local.claim_minutes = None;
287 }
288 self.record(
289 number,
290 TaskEvent::Released,
291 TaskState::Claimed,
292 &Actor::agent(holder),
293 now,
294 Some("claim expired".to_owned()),
295 )
296 })
297 .collect()
298 }
299
300 pub fn has_expired(&self, now: DateTime<Utc>) -> bool {
302 let now = timestamp(now);
303 self.tasks.values().any(|local| {
304 local.task.state == TaskState::Claimed
305 && local
306 .task
307 .claim
308 .as_ref()
309 .is_some_and(|c| c.until.as_str() < now.as_str())
310 })
311 }
312
313 pub fn propose(
315 &mut self,
316 by: &Actor,
317 proposal: Proposal,
318 now: DateTime<Utc>,
319 ) -> Result<Change, TaskError> {
320 let clean = |s: Option<String>| s.map(|s| s.trim().to_owned()).filter(|s| !s.is_empty());
321 let request = TaskProposeRequestV1 {
322 schema: TASK_PROPOSE_SCHEMA.to_owned(),
323 title: Some(proposal.title.trim().to_owned()),
324 goal: clean(proposal.goal),
325 acceptance: Some(proposal.acceptance.trim().to_owned()),
326 role: clean(proposal.role),
327 origin: TaskOriginV1 {
328 kind: TaskOriginKind::Agent,
329 facts: Vec::new(),
330 message: None,
331 },
332 session_ref: "local".to_owned(),
333 };
334 request.validate().map_err(invalid)?;
335 self.make_room()?;
336 let number = self.next_number;
337 self.next_number += 1;
338 let origin_kind = match by {
339 Actor::Agent { .. } => TaskOriginKind::Agent,
340 Actor::Person { .. } => TaskOriginKind::Person,
341 };
342 let task = TaskV1 {
343 schema: TASK_SCHEMA.to_owned(),
344 vault_id: LOCAL_VAULT.to_owned(),
345 number,
346 state: TaskState::Proposed,
347 lifecycle: None,
348 title: request.title,
349 goal: request.goal,
350 acceptance: request.acceptance,
351 role: request.role,
352 origin: TaskOriginV1 {
353 kind: origin_kind,
354 facts: Vec::new(),
355 message: None,
356 },
357 created_by: by.view(),
358 created_at: timestamp(now),
359 approved_by: None,
360 claim: None,
361 updates: Vec::new(),
362 result: None,
363 decision: None,
364 verified_by: None,
365 verified_at: None,
366 verification: None,
367 priority: None,
368 labels: Vec::new(),
369 fields_version: None,
370 process: None,
371 };
372 self.tasks.insert(
373 number,
374 LocalTask {
375 task,
376 transitions: Vec::new(),
377 claim_minutes: None,
378 shared: None,
379 },
380 );
381 Ok(self.record(
382 number,
383 TaskEvent::Proposed,
384 TaskState::Proposed,
385 by,
386 now,
387 None,
388 ))
389 }
390
391 fn make_room(&mut self) -> Result<(), TaskError> {
394 while self.tasks.len() >= MAX_TASKS {
395 let oldest = self
396 .tasks
397 .iter()
398 .find(|(_, local)| closed(local.task.state))
399 .map(|(n, _)| *n);
400 match oldest {
401 Some(n) => {
402 self.tasks.remove(&n);
403 }
404 None => {
405 return Err(TaskError::InvalidState {
406 number: self.next_number,
407 reason: "too_many_tasks",
408 });
409 }
410 }
411 }
412 Ok(())
413 }
414
415 pub fn decide(
417 &mut self,
418 number: u64,
419 by: &Actor,
420 accept: bool,
421 reason: Option<&str>,
422 now: DateTime<Utc>,
423 ) -> Result<Change, TaskError> {
424 let reason = check_reason(reason)?;
425 let local = self.task_mut(number)?;
426 if local.task.state != TaskState::Proposed {
427 return Err(TaskError::InvalidState {
428 number,
429 reason: if closed(local.task.state) {
430 "task_closed"
431 } else {
432 "not_proposed"
433 },
434 });
435 }
436 let event = if accept {
437 local.task.state = TaskState::Open;
438 local.task.approved_by = Some(by.view());
439 TaskEvent::Accepted
440 } else {
441 local.task.state = TaskState::Rejected;
442 TaskEvent::Declined
443 };
444 Ok(self.record(number, event, TaskState::Proposed, by, now, reason))
445 }
446
447 pub fn claim(
450 &mut self,
451 number: u64,
452 by: &Actor,
453 minutes: Option<u32>,
454 now: DateTime<Utc>,
455 ) -> Result<Option<Change>, TaskError> {
456 if by.agent_id().is_none() {
457 return Err(TaskError::Forbidden {
458 number,
459 reason: "agents_claim",
460 });
461 }
462 let minutes = minutes.unwrap_or(DEFAULT_CLAIM_MINUTES);
463 if !(MIN_CLAIM_MINUTES..=MAX_CLAIM_MINUTES).contains(&minutes) {
464 return Err(TaskError::Invalid(format!(
465 "minutes must be {MIN_CLAIM_MINUTES}-{MAX_CLAIM_MINUTES}"
466 )));
467 }
468 let local = self.task_mut(number)?;
469 let until = timestamp(now + Duration::minutes(i64::from(minutes)));
470 match local.task.state {
471 TaskState::Open => {}
472 TaskState::Claimed => {
473 let claim = local.task.claim.as_mut().ok_or(TaskError::InvalidState {
474 number,
475 reason: "not_claimed",
476 })?;
477 if !by.is(&claim.holder) {
478 return Err(TaskError::InvalidState {
479 number,
480 reason: "task_claimed",
481 });
482 }
483 claim.until = until;
484 local.claim_minutes = Some(minutes);
485 return Ok(None);
486 }
487 state if closed(state) => {
488 return Err(TaskError::InvalidState {
489 number,
490 reason: "task_closed",
491 });
492 }
493 _ => {
494 return Err(TaskError::InvalidState {
495 number,
496 reason: "task_not_open",
497 });
498 }
499 }
500 local.task.state = TaskState::Claimed;
501 local.task.claim = Some(TaskClaimV1 {
502 holder: by.view(),
503 since: timestamp(now),
504 until,
505 });
506 local.claim_minutes = Some(minutes);
507 Ok(Some(self.record(
508 number,
509 TaskEvent::Claimed,
510 TaskState::Open,
511 by,
512 now,
513 None,
514 )))
515 }
516
517 fn held(&mut self, number: u64, by: &Actor) -> Result<&mut LocalTask, TaskError> {
519 let local = self.task_mut(number)?;
520 match local.task.state {
521 TaskState::Claimed => {}
522 state if closed(state) => {
523 return Err(TaskError::InvalidState {
524 number,
525 reason: "task_closed",
526 });
527 }
528 _ => {
529 return Err(TaskError::InvalidState {
530 number,
531 reason: "not_claimed",
532 });
533 }
534 }
535 let holder = local.task.claim.as_ref().map(|c| &c.holder);
536 if by.agent_id().is_none() || !holder.is_some_and(|h| by.is(h)) {
537 return Err(TaskError::Forbidden {
538 number,
539 reason: "not_holder",
540 });
541 }
542 Ok(local)
543 }
544
545 pub fn update(
547 &mut self,
548 number: u64,
549 by: &Actor,
550 summary: &str,
551 usage: Option<UsageV1>,
552 now: DateTime<Utc>,
553 ) -> Result<Change, TaskError> {
554 TaskUpdateRequestV1 {
555 schema: TASK_UPDATE_SCHEMA.to_owned(),
556 claim: local_claim(),
557 summary: Some(summary.trim().to_owned()),
558 usage,
559 }
560 .validate()
561 .map_err(invalid)?;
562 let local = self.held(number, by)?;
563 let minutes = local.claim_minutes.unwrap_or(DEFAULT_CLAIM_MINUTES);
564 if let Some(claim) = local.task.claim.as_mut() {
565 claim.until = timestamp(now + Duration::minutes(i64::from(minutes)));
566 }
567 push_update(
568 &mut local.task,
569 TaskUpdateV1 {
570 by: by.view(),
571 at: timestamp(now),
572 summary: Some(summary.trim().to_owned()),
573 usage,
574 kind: Some(TaskUpdateKind::Progress),
575 verdict: None,
576 amended_from: Vec::new(),
577 amended_to: Vec::new(),
578 },
579 );
580 Ok(self.record(
581 number,
582 TaskEvent::Updated,
583 TaskState::Claimed,
584 by,
585 now,
586 None,
587 ))
588 }
589
590 pub fn submit(
592 &mut self,
593 number: u64,
594 by: &Actor,
595 submission: Submission,
596 now: DateTime<Utc>,
597 ) -> Result<Change, TaskError> {
598 let summary = submission.summary.trim().to_owned();
599 TaskResultRequestV1 {
600 schema: TASK_RESULT_SCHEMA.to_owned(),
601 claim: local_claim(),
602 summary: Some(summary.clone()),
603 evidence: submission.evidence.clone(),
604 draft: None,
605 usage: submission.usage,
606 outcome: Some(submission.outcome),
607 gate_record: None,
608 }
609 .validate()
610 .map_err(invalid)?;
611 let local = self.held(number, by)?;
612 local.task.claim = None;
613 local.claim_minutes = None;
614 local.task.result = Some(TaskResultV1 {
615 by: by.view(),
616 at: timestamp(now),
617 proposal: 0,
618 seq: None,
619 summary: Some(summary),
620 evidence: submission
621 .evidence
622 .into_iter()
623 .map(|e| EvidenceViewV1 {
624 kind: e.kind,
625 reference: e.reference,
626 anchor: e.anchor,
627 verified: false,
628 })
629 .collect(),
630 usage: submission.usage,
631 merge: None,
632 });
633 let event = match submission.outcome {
634 TaskOutcome::Completed => {
635 local.task.state = TaskState::Submitted;
636 TaskEvent::Submitted
637 }
638 TaskOutcome::Failed => {
639 local.task.state = TaskState::Failed;
640 TaskEvent::Failed
641 }
642 };
643 Ok(self.record(number, event, TaskState::Claimed, by, now, None))
644 }
645
646 pub fn amend(
653 &mut self,
654 number: u64,
655 by: &Actor,
656 amendment: Amendment,
657 now: DateTime<Utc>,
658 ) -> Result<Change, TaskError> {
659 let reason = check_reason(amendment.reason.as_deref())?;
660 let summary = amendment
661 .summary
662 .map(|s| s.trim().to_owned())
663 .filter(|s| !s.is_empty());
664 let request = TaskAmendRequestV1 {
665 schema: TASK_AMEND_SCHEMA.to_owned(),
666 worker_id: by.agent_id().unwrap_or("person").to_owned(),
667 summary: summary.clone(),
668 evidence: amendment.evidence,
669 merge_commit: amendment.merge_commit,
670 reason: reason.clone(),
671 };
672 request.validate().map_err(invalid)?;
673 let local = self.task_mut(number)?;
674 let caller = AmendCaller {
675 submitter: by.agent_id().is_some()
677 && local.task.result.as_ref().is_some_and(|r| by.is(&r.by)),
678 reviewer: local
679 .task
680 .verified_by
681 .as_ref()
682 .is_some_and(|reviewer| by.is_exactly(reviewer)),
683 person: by.agent_id().is_none(),
684 queen: amendment.queen,
685 };
686 may_amend(&request, caller).map_err(|refusal| TaskError::Forbidden {
687 number,
688 reason: refusal.code(),
689 })?;
690 amend_rule(&local.task, &request).map_err(|refusal| TaskError::InvalidState {
691 number,
692 reason: refusal.code(),
693 })?;
694 let state = local.task.state;
695 let Some(result) = local.task.result.as_mut() else {
696 return Err(TaskError::InvalidState {
697 number,
698 reason: "not_amendable",
699 });
700 };
701 let view = |e: EvidenceV1| EvidenceViewV1 {
702 kind: e.kind,
703 reference: e.reference,
704 anchor: e.anchor,
705 verified: false,
706 };
707 let (before, after) = if let Some(commit) = request.merge_commit {
708 result.merge = Some(TaskMergeV1 {
709 commit: commit.clone(),
710 by: by.view(),
711 at: timestamp(now),
712 });
713 (result.evidence.clone(), vec![view(merge_evidence(&commit))])
714 } else {
715 let after: Vec<EvidenceViewV1> = request
717 .evidence
718 .into_iter()
719 .map(|e| {
720 result
721 .evidence
722 .iter()
723 .find(|v| {
724 v.kind == e.kind && v.reference == e.reference && v.anchor == e.anchor
725 })
726 .cloned()
727 .unwrap_or_else(|| view(e))
728 })
729 .collect();
730 if summary.is_some() {
731 result.summary.clone_from(&summary);
732 }
733 (
734 std::mem::replace(&mut result.evidence, after.clone()),
735 after,
736 )
737 };
738 push_update(
739 &mut local.task,
740 TaskUpdateV1 {
741 by: by.view(),
742 at: timestamp(now),
743 summary: summary.or(reason.clone()),
744 usage: None,
745 kind: Some(TaskUpdateKind::Amendment),
746 verdict: None,
747 amended_from: before,
748 amended_to: after,
749 },
750 );
751 Ok(self.record(number, TaskEvent::Amended, state, by, now, reason))
752 }
753
754 pub fn release(
756 &mut self,
757 number: u64,
758 by: &Actor,
759 reason: Option<&str>,
760 now: DateTime<Utc>,
761 ) -> Result<Change, TaskError> {
762 let reason = check_reason(reason)?;
763 let local = if by.agent_id().is_none() {
764 let local = self.task_mut(number)?;
765 if local.task.state != TaskState::Claimed {
766 return Err(TaskError::InvalidState {
767 number,
768 reason: if closed(local.task.state) {
769 "task_closed"
770 } else {
771 "not_claimed"
772 },
773 });
774 }
775 local
776 } else {
777 self.held(number, by)?
778 };
779 local.task.state = TaskState::Open;
780 local.task.claim = None;
781 local.claim_minutes = None;
782 Ok(self.record(
783 number,
784 TaskEvent::Released,
785 TaskState::Claimed,
786 by,
787 now,
788 reason,
789 ))
790 }
791
792 pub fn verify(
795 &mut self,
796 number: u64,
797 by: &Actor,
798 verdict: TaskVerdict,
799 reason: Option<&str>,
800 now: DateTime<Utc>,
801 ) -> Result<Change, TaskError> {
802 let reason = reason.map(str::trim).filter(|r| !r.is_empty());
803 TaskVerifyRequestV1 {
804 schema: TASK_VERIFY_SCHEMA.to_owned(),
805 worker_id: by.agent_id().unwrap_or("person").to_owned(),
806 verdict,
807 reason: reason.map(str::to_owned),
808 }
809 .validate()
810 .map_err(invalid)?;
811 let local = self.task_mut(number)?;
812 if local.task.state != TaskState::Submitted {
813 return Err(TaskError::InvalidState {
814 number,
815 reason: if closed(local.task.state) {
816 "task_closed"
817 } else {
818 "not_submitted"
819 },
820 });
821 }
822 let worker = local.task.result.as_ref().and_then(|r| r.by.agent.clone());
823 if let Some(agent) = by.agent_id()
824 && worker.as_deref() == Some(agent)
825 {
826 return Err(TaskError::Forbidden {
827 number,
828 reason: "self_verify",
829 });
830 }
831 let at = timestamp(now);
832 local.task.verification = Some(TaskVerificationV1 {
833 verdict,
834 by: by.view(),
835 at: at.clone(),
836 reason: reason.map(str::to_owned),
837 });
838 push_update(
839 &mut local.task,
840 TaskUpdateV1 {
841 by: by.view(),
842 at: at.clone(),
843 summary: reason.map(str::to_owned),
844 usage: None,
845 kind: Some(TaskUpdateKind::Verification),
846 verdict: Some(verdict),
847 amended_from: Vec::new(),
848 amended_to: Vec::new(),
849 },
850 );
851 let event = if verdict == TaskVerdict::Accept {
852 local.task.state = TaskState::Done;
853 local.task.verified_by = Some(by.view());
854 local.task.verified_at = Some(at);
855 TaskEvent::Verified
856 } else {
857 local.task.state = TaskState::Open;
858 TaskEvent::Rejected
859 };
860 Ok(self.record(
861 number,
862 event,
863 TaskState::Submitted,
864 by,
865 now,
866 reason.map(str::to_owned),
867 ))
868 }
869
870 pub fn stop(
874 &mut self,
875 number: u64,
876 by: &Actor,
877 reason: Option<&str>,
878 now: DateTime<Utc>,
879 ) -> Result<Change, TaskError> {
880 let reason = check_reason(reason)?;
881 if reason.is_none() {
882 return Err(TaskError::Invalid(
883 "stopping a task needs a reason".to_owned(),
884 ));
885 }
886 if by.agent_id().is_some() {
887 return Err(TaskError::Forbidden {
888 number,
889 reason: "agents_stop",
890 });
891 }
892 let local = self.task_mut(number)?;
893 let from = local.task.state;
894 if !matches!(
895 from,
896 TaskState::Proposed | TaskState::Open | TaskState::Claimed
897 ) {
898 return Err(TaskError::InvalidState {
899 number,
900 reason: if closed(from) {
901 "task_closed"
902 } else {
903 "task_submitted"
904 },
905 });
906 }
907 local.task.state = TaskState::Cancelled;
908 local.task.claim = None;
909 local.claim_minutes = None;
910 Ok(self.record(number, TaskEvent::Stopped, from, by, now, reason))
911 }
912
913 pub fn mark_shared(
915 &mut self,
916 number: u64,
917 by: &Actor,
918 vault: &str,
919 vault_number: u64,
920 now: DateTime<Utc>,
921 ) -> Result<Change, TaskError> {
922 if vault.trim().is_empty() || vault_number == 0 {
923 return Err(TaskError::Invalid(
924 "a vault address and its task number are required".to_owned(),
925 ));
926 }
927 let local = self.task_mut(number)?;
928 if local.shared.is_some() {
929 return Err(TaskError::InvalidState {
930 number,
931 reason: "already_shared",
932 });
933 }
934 local.shared = Some(SharedRef {
935 vault: vault.trim().to_owned(),
936 number: vault_number,
937 at: timestamp(now),
938 });
939 let state = local.task.state;
940 Ok(self.record(
941 number,
942 TaskEvent::Shared,
943 state,
944 by,
945 now,
946 Some(format!("{}#{vault_number}", vault.trim())),
947 ))
948 }
949
950 pub fn set_fields(
955 &mut self,
956 number: u64,
957 by: &Actor,
958 priority: Option<TaskPriority>,
959 labels: Option<Vec<String>>,
960 now: DateTime<Utc>,
961 ) -> Result<Change, TaskError> {
962 if priority.is_none() && labels.is_none() {
963 return Err(TaskError::Invalid(
964 "set a priority, labels or both".to_owned(),
965 ));
966 }
967 if let Some(labels) = &labels {
968 validate_labels(labels).map_err(|e| TaskError::Invalid(format!("labels: {e}")))?;
969 }
970 let local = self.task_mut(number)?;
971 let state = local.task.state;
972 if closed(state) {
973 return Err(TaskError::InvalidState {
974 number,
975 reason: "task_closed",
976 });
977 }
978 if let Some(priority) = priority {
979 local.task.priority = Some(priority);
980 }
981 if let Some(labels) = labels {
982 local.task.labels = labels;
983 }
984 local.task.fields_version = Some(local.task.fields_version.unwrap_or(0) + 1);
985 let reason = format!(
986 "priority {}, labels {}",
987 match local.task.priority.unwrap_or_default() {
988 TaskPriority::High => "high",
989 TaskPriority::Normal => "normal",
990 },
991 if local.task.labels.is_empty() {
992 "none".to_owned()
993 } else {
994 local.task.labels.join(",")
995 }
996 );
997 Ok(self.record(number, TaskEvent::Fields, state, by, now, Some(reason)))
998 }
999}