Skip to main content

lean_ctx_tasks/
lib.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Local project tasks: the Hive task model
3//! ([`TaskV1`](lean_ctx_protocol::hive::tasks::TaskV1), [`TaskState`]) kept
4//! per project on this machine, so tasks work without Hive (owner decision
5//! 2026-11-04: everything a project has works locally in the Instance; Hive
6//! only adds sharing).
7//!
8//! ```text
9//! proposed --accept--> open --claim--> claimed --submit--> submitted --verify accept--> done
10//!    |                  ^ ^               |  |                  |
11//!    +--decline--> rejected +--release----+  +--submit failed--> failed
12//!                           +--------------------- verify reject ---+
13//! proposed/open/claimed --stop--> cancelled
14//! ```
15//!
16//! The rules ([`board`]):
17//! - anyone (an agent or a person) may accept or decline a proposal, the
18//!   proposer included;
19//! - only an agent claims, the proposer included; a claim lasts its minutes
20//!   and is renewed by the holder's claim again or update; an expired claim
21//!   opens the task again;
22//! - the holder updates, submits or releases; a person may release too;
23//! - a result is verified by a person or by another agent, never by the
24//!   agent that submitted it; `reject` opens the task again;
25//! - a person (never an agent) stops a proposed, open or claimed task, with a reason;
26//! - every transition is recorded with who and when, on the task and in the
27//!   store's journal ([`store`]).
28//!
29//! Text rules (lengths, one-line summaries, evidence kinds) are Hive's: each
30//! write is checked with the matching Hive request type's `validate`.
31//!
32//! This crate never posts on the bus or decides whether a project is linked
33//! to a vault: every write returns its [`Change`]s, and the caller announces
34//! them (lean-ctx-hive `tasks::local`).
35
36pub mod board;
37pub mod store;
38
39#[cfg(test)]
40mod tests;
41
42use serde::{Deserialize, Serialize};
43
44use lean_ctx_protocol::UtcTimestamp;
45use lean_ctx_protocol::hive::tasks::{TaskActorKind, TaskActorV1, TaskState};
46
47pub use board::{Amendment, Board, LocalTask, Proposal, SharedRef, Submission, Transition};
48pub use store::Store;
49
50/// The `vault_id` every local task carries (it lives in no vault).
51pub const LOCAL_VAULT: &str = "local";
52/// Claim length when the caller names none.
53pub const DEFAULT_CLAIM_MINUTES: u32 = 60;
54pub const MIN_CLAIM_MINUTES: u32 = 5;
55/// No keeper renews a local claim: the holder renews it by working on it.
56pub const MAX_CLAIM_MINUTES: u32 = 240;
57
58/// Who acts on a task.
59#[derive(Debug, Clone, PartialEq, Eq)]
60pub enum Actor {
61    /// A bus agent (its id) and how it is shown.
62    Agent { id: String, label: Option<String> },
63    /// A person on this machine (Hive Desktop, the CLI).
64    Person { label: String },
65}
66
67impl Actor {
68    pub fn agent(id: impl Into<String>) -> Self {
69        Self::Agent {
70            id: id.into(),
71            label: None,
72        }
73    }
74
75    pub fn person(label: impl Into<String>) -> Self {
76        Self::Person {
77            label: label.into(),
78        }
79    }
80
81    /// The bus agent id, for an agent.
82    pub fn agent_id(&self) -> Option<&str> {
83        match self {
84            Self::Agent { id, .. } => Some(id),
85            Self::Person { .. } => None,
86        }
87    }
88
89    /// The actor as a task view names it (`mine` is the reader's, set when
90    /// a view is rendered).
91    pub fn view(&self) -> TaskActorV1 {
92        match self {
93            Self::Agent { id, label } => TaskActorV1 {
94                kind: TaskActorKind::Instance,
95                label: label.clone().unwrap_or_else(|| id.clone()),
96                agent: Some(id.clone()),
97                owner: None,
98                model: None,
99                mine: false,
100                profile_id: None,
101            },
102            Self::Person { label } => TaskActorV1 {
103                kind: TaskActorKind::Person,
104                label: label.clone(),
105                agent: None,
106                owner: None,
107                model: None,
108                mine: false,
109                profile_id: None,
110            },
111        }
112    }
113
114    /// Whether `actor` (a view) is this actor.
115    fn is(&self, actor: &TaskActorV1) -> bool {
116        match self {
117            Self::Agent { id, .. } => actor.agent.as_deref() == Some(id.as_str()),
118            Self::Person { .. } => actor.kind == TaskActorKind::Person,
119        }
120    }
121
122    /// Whether `actor` is this actor by name too: a person by its label
123    /// (two people on one machine stay apart).
124    fn is_exactly(&self, actor: &TaskActorV1) -> bool {
125        match self {
126            Self::Agent { .. } => self.is(actor),
127            Self::Person { label } => actor.kind == TaskActorKind::Person && actor.label == *label,
128        }
129    }
130}
131
132/// What happened to a task; the bus key names it (`task:<n>:<event>`).
133#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
134#[serde(rename_all = "snake_case")]
135pub enum TaskEvent {
136    Proposed,
137    Accepted,
138    Declined,
139    Claimed,
140    Updated,
141    Released,
142    Submitted,
143    Failed,
144    Verified,
145    Rejected,
146    Stopped,
147    /// The submitter amended its result (no state change).
148    Amended,
149    /// Brought into a Hive vault (no state change, no bus notice).
150    Shared,
151    /// Priority and/or labels set (no state change, no bus notice).
152    Fields,
153}
154
155impl TaskEvent {
156    pub fn as_str(self) -> &'static str {
157        match self {
158            Self::Proposed => "proposed",
159            Self::Accepted => "accepted",
160            Self::Declined => "declined",
161            Self::Claimed => "claimed",
162            Self::Updated => "updated",
163            Self::Released => "released",
164            Self::Submitted => "submitted",
165            Self::Failed => "failed",
166            Self::Verified => "verified",
167            Self::Rejected => "rejected",
168            Self::Stopped => "stopped",
169            Self::Amended => "amended",
170            Self::Shared => "shared",
171            Self::Fields => "fields",
172        }
173    }
174}
175
176/// One recorded transition, as a write returns it for its notices.
177#[derive(Debug, Clone, PartialEq, Eq)]
178pub struct Change {
179    /// The store's journal sequence of this transition (unique per store).
180    pub seq: u64,
181    pub number: u64,
182    pub event: TaskEvent,
183    pub state: TaskState,
184    /// The agent the event is about when it must not be told: the worker
185    /// whose result waits for a check.
186    pub worker: Option<String>,
187}
188
189/// Why a write was refused. `reason` codes follow Hive's (`task_claimed`,
190/// `task_not_open`, `task_closed`, `not_submitted`, `self_verify`, …).
191#[derive(Debug, Clone, PartialEq, Eq)]
192pub enum TaskError {
193    NotFound(u64),
194    /// The task's state does not allow the write.
195    InvalidState {
196        number: u64,
197        reason: &'static str,
198    },
199    /// The actor may not make the write.
200    Forbidden {
201        number: u64,
202        reason: &'static str,
203    },
204    /// The request is malformed (text rules, missing fields).
205    Invalid(String),
206    /// The store could not be read, locked or written.
207    Store(String),
208}
209
210impl TaskError {
211    /// A stable code: `not_found`, `invalid_state`, `forbidden`,
212    /// `invalid_argument`, `unavailable`.
213    pub fn code(&self) -> &'static str {
214        match self {
215            Self::NotFound(_) => "not_found",
216            Self::InvalidState { .. } => "invalid_state",
217            Self::Forbidden { .. } => "forbidden",
218            Self::Invalid(_) => "invalid_argument",
219            Self::Store(_) => "unavailable",
220        }
221    }
222
223    /// The Hive-style reason, for state and right refusals.
224    pub fn reason(&self) -> Option<&'static str> {
225        match self {
226            Self::InvalidState { reason, .. } | Self::Forbidden { reason, .. } => Some(reason),
227            _ => None,
228        }
229    }
230}
231
232impl std::fmt::Display for TaskError {
233    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
234        match self {
235            Self::NotFound(n) => write!(f, "task #{n} does not exist"),
236            Self::InvalidState { number, reason } | Self::Forbidden { number, reason } => {
237                write!(f, "task #{number}: {}", describe(reason))
238            }
239            Self::Invalid(message) | Self::Store(message) => f.write_str(message),
240        }
241    }
242}
243
244impl std::error::Error for TaskError {}
245
246fn describe(reason: &str) -> &'static str {
247    match reason {
248        "task_claimed" => "another agent holds its claim (task_claimed)",
249        "task_not_open" => "it is not open (task_not_open)",
250        "not_proposed" => "it is not a proposal (not_proposed)",
251        "not_claimed" => "it is not claimed (not_claimed)",
252        "task_closed" => "it is closed (task_closed)",
253        "not_submitted" => "no result waits for a check (not_submitted)",
254        "self_verify" => {
255            "the agent that submitted the result never checks it; a person or another agent does (self_verify)"
256        }
257        "not_holder" => "only the agent holding its claim does that (not_holder)",
258        "agents_claim" => "only an agent claims a task (agents_claim)",
259        "task_submitted" => "its result waits for a check; verify it instead (task_submitted)",
260        "agents_stop" => "only a person stops a task (agents_stop)",
261        "already_shared" => "it was already brought into a vault (already_shared)",
262        "too_many_tasks" => "the project holds too many unfinished tasks (too_many_tasks)",
263        "not_submitter" => {
264            "only the agent that submitted its result corrects it; a merge record also a person or a Queen session (not_submitter)"
265        }
266        "not_merger" => {
267            "the reviewer never records the merge; the submitter, a person or a Queen session does (not_merger)"
268        }
269        "not_amendable" => "it has no submitted or done result to amend (not_amendable)",
270        "record_merge_only" => {
271            "it is done: it only takes its merge record, --merge <sha> (record_merge_only)"
272        }
273        "not_merged_yet" => {
274            "its result waits for a check; a merge is recorded once it is done (not_merged_yet)"
275        }
276        "merge_recorded" => "its merge is already recorded (merge_recorded)",
277        _ => "refused",
278    }
279}
280
281/// Whole-second UTC, as Hive writes task times.
282pub fn timestamp(at: chrono::DateTime<chrono::Utc>) -> UtcTimestamp {
283    UtcTimestamp::new(at.format("%Y-%m-%dT%H:%M:%SZ").to_string())
284        .unwrap_or_else(|_| unreachable_timestamp())
285}
286
287#[cold]
288fn unreachable_timestamp() -> UtcTimestamp {
289    // `%Y-%m-%dT%H:%M:%SZ` of a chrono time is always canonical.
290    UtcTimestamp::new("1970-01-01T00:00:00Z").unwrap_or_else(|_| unreachable!())
291}