Skip to main content

lean_ctx_tasks/
board.rs

1// SPDX-License-Identifier: Apache-2.0
2//! The project's tasks in memory and every transition on them. Pure: the
3//! time comes in, nothing is read or written here ([`crate::store`] does).
4
5use 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";
27/// Tasks a project keeps; the oldest closed ones drop first.
28pub const MAX_TASKS: usize = 2000;
29/// Transitions a task keeps, newest last.
30pub const MAX_TRANSITIONS: usize = 50;
31
32/// One transition: who moved the task from which state to which, and when.
33#[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    /// The worker of a submitted result (its notice is not sent to it), so
45    /// a notice re-sent from the journal reads as the first one.
46    #[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/// Where a local task was brought into a Hive vault.
63#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
64pub struct SharedRef {
65    /// The vault address `ws/vault`.
66    pub vault: String,
67    /// The task's number in that vault.
68    pub number: u64,
69    pub at: UtcTimestamp,
70}
71
72/// A local task: Hive's task view plus its transitions.
73#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
74pub struct LocalTask {
75    pub task: TaskV1,
76    /// Newest last, at most [`MAX_TRANSITIONS`].
77    #[serde(default)]
78    pub transitions: Vec<Transition>,
79    /// The length of the current claim (its renewals keep it).
80    #[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/// All tasks of one project.
87#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
88pub struct Board {
89    pub schema: String,
90    /// The number the next task gets.
91    pub next_number: u64,
92    /// The last journal sequence written.
93    pub seq: u64,
94    #[serde(default)]
95    pub tasks: BTreeMap<u64, LocalTask>,
96    /// Transitions of the write in progress (journaled by the store).
97    #[serde(skip)]
98    pub(crate) pending: Vec<Transition>,
99    /// Their changes, for the caller's notices.
100    #[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/// What a proposal says.
118#[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/// What a result says.
127#[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/// What an amendment of a result says: on a submitted task the whole new
136/// evidence list and a new summary or none (the summary stays); on a done
137/// task only `merge_commit` (the merge record).
138#[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    /// The caller checked that this process runs for a registered Queen
145    /// session (`lean_ctx_hive::queens::this_session_is_queen`): it may
146    /// record a merge like a person.
147    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
161/// The claim reference the Hive request checks need (local claims have no
162/// lease; the holder is the claim).
163fn 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    /// Record a transition of `number` (already applied to the task).
222    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    /// Open claims whose time ran out open their task again.
270    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    /// Whether a claim ran out by `now` (a read shows it open).
301    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    /// Propose a task (state `proposed`); anyone may.
314    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    /// Keep at most [`MAX_TASKS`]: drop the oldest closed tasks, refuse
392    /// when every task is still unfinished.
393    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    /// Accept (`open`) or decline (`rejected`) a proposal; anyone may.
416    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    /// Claim an open task, or renew the caller's own claim. Only agents
448    /// claim. `None` when the call only renewed (no transition).
449    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    /// The task, claimed by `by`.
518    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    /// The holder reports one line of progress; renews the claim.
546    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    /// The holder's result: `submitted` (waits for a check), or `failed`.
591    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    /// A result is corrected without a claim (a submit ended it), by
647    /// [`amend_rule`] and [`may_amend`]: on `submitted` the submitter
648    /// replaces summary and evidence; on `done` the submitter, a person or a
649    /// Queen session (never the reviewer)
650    /// records the merge once. The update `kind: amendment` keeps old → new
651    /// (`amended_from`, `amended_to`). Refused before anything changes.
652    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            // Only an agent submits; a person is never the submitter.
676            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            // An item kept from before keeps its view (and `verified`).
716            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    /// Give a claimed task back (`open`): its holder, or a person.
755    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    /// A check of a submitted result: `accept` → `done`, `reject` (with a
793    /// reason) → `open`. Never by the agent that submitted it.
794    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    /// Stop a proposed, open or claimed task, with a reason: a person only
871    /// (an agent is refused `agents_stop`). The task ends in the
872    /// `cancelled` state (Hive's state name).
873    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    /// Mark a task as brought into a Hive vault (journaled; no notice).
914    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    /// Set a task's priority and/or labels (C0 2.2 E47 on this machine):
951    /// `fields_version` counts every such write. Who may do this is the
952    /// caller's rule (a person or a registered Queen session); a closed
953    /// task keeps its fields (`task_closed`).
954    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}