1use serde::{Deserialize, Serialize};
84
85use super::anchor::CodeAnchorV1;
86use super::knowledge::DraftProposalV1;
87use super::{require_schema, validate_hex64, validate_id, validate_text};
88use crate::{UtcTimestamp, ValidationError};
89
90pub const TASK_SCHEMA: &str = "leanctx.hive.task/v1";
91pub const TASK_LIST_SCHEMA: &str = "leanctx.hive.task-list/v1";
92pub const TASK_ACK_SCHEMA: &str = "leanctx.hive.task-ack/v1";
93pub const TASK_PROPOSE_SCHEMA: &str = "leanctx.hive.task-propose/v1";
94pub const TASK_CLAIM_REQUEST_SCHEMA: &str = "leanctx.hive.task-claim-request/v1";
95pub const TASK_CLAIM_RENEW_SCHEMA: &str = "leanctx.hive.task-claim-renew/v1";
96pub const TASK_CLAIM_ACK_SCHEMA: &str = "leanctx.hive.task-claim/v1";
97pub const TASK_RELEASE_SCHEMA: &str = "leanctx.hive.task-release/v1";
98pub const TASK_UPDATE_SCHEMA: &str = "leanctx.hive.task-update/v1";
99pub const TASK_RESULT_SCHEMA: &str = "leanctx.hive.task-result/v1";
100pub const TASK_VERIFY_SCHEMA: &str = "leanctx.hive.task-verify/v1";
102pub const TASK_FIELDS_SCHEMA: &str = "leanctx.hive.task-fields/v1";
104
105pub const MIN_TITLE_CHARS: usize = 5;
106pub const MAX_TITLE_CHARS: usize = 180;
107pub const MAX_GOAL_CHARS: usize = 2000;
108pub const MAX_ACCEPTANCE_CHARS: usize = 2000;
109pub const MAX_SUMMARY_CHARS: usize = 500;
111pub const MAX_REASON_CHARS: usize = 280;
112pub const MAX_VERIFY_REASON_CHARS: usize = 2000;
114pub const MAX_TASK_LABELS: usize = 16;
115pub const MAX_EVIDENCE: usize = 20;
116pub const MAX_EVIDENCE_REF_CHARS: usize = 512;
117pub const MAX_VIEW_UPDATES: usize = 20;
119pub const MAX_TASKS_PAGE: usize = 100;
120pub const MIN_CLAIM_MINUTES: u32 = 5;
123pub const MAX_CLAIM_MINUTES: u32 = 15;
124
125#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
127#[serde(rename_all = "snake_case")]
128pub enum TaskState {
129 Proposed,
131 Open,
132 Claimed,
133 Submitted,
135 Done,
136 Rejected,
138 Cancelled,
139 Failed,
141 #[serde(other)]
142 Unknown,
143}
144
145#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
149#[serde(rename_all = "snake_case")]
150pub enum TaskLifecycle {
151 Queued,
152 Claimed,
153 Running,
154 Submitted,
156 Completed,
157 Failed,
158 Cancelled,
159 #[serde(other)]
160 Unknown,
161}
162
163#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
166#[serde(rename_all = "snake_case")]
167pub enum TaskOutcome {
168 #[default]
169 Completed,
170 Failed,
171}
172
173#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
175#[serde(rename_all = "snake_case")]
176pub enum TaskOriginKind {
177 Person,
179 Agent,
181 Contradiction,
183 Question,
185 #[serde(other)]
187 Unknown,
188}
189
190#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
194#[serde(deny_unknown_fields)]
195pub struct TaskOriginV1 {
196 pub kind: TaskOriginKind,
197 #[serde(default, skip_serializing_if = "Vec::is_empty")]
198 pub facts: Vec<String>,
199 #[serde(default, skip_serializing_if = "Option::is_none")]
200 pub message: Option<String>,
201}
202
203impl TaskOriginV1 {
204 pub fn validate_from_engine(&self) -> Result<(), ValidationError> {
206 if self.message.is_some() {
207 return Err(ValidationError::new("engines do not create question tasks"));
208 }
209 match self.kind {
210 TaskOriginKind::Agent if self.facts.is_empty() => Ok(()),
211 TaskOriginKind::Agent => Err(ValidationError::new("an agent task names no facts")),
212 TaskOriginKind::Contradiction => {
213 if self.facts.len() != 2 || self.facts[0] == self.facts[1] {
214 return Err(ValidationError::new(
215 "a contradiction names exactly two distinct facts",
216 ));
217 }
218 self.facts
219 .iter()
220 .try_for_each(|id| validate_id(id, "facts"))
221 }
222 _ => Err(ValidationError::new(
223 "engines propose agent or contradiction tasks only",
224 )),
225 }
226 }
227}
228
229#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
231pub struct TaskActorV1 {
232 pub kind: TaskActorKind,
233 pub label: String,
235 #[serde(default, skip_serializing_if = "Option::is_none")]
237 pub agent: Option<String>,
238 #[serde(default, skip_serializing_if = "Option::is_none")]
240 pub owner: Option<String>,
241 #[serde(default, skip_serializing_if = "Option::is_none")]
243 pub model: Option<String>,
244 pub mine: bool,
246 #[serde(default, skip_serializing_if = "Option::is_none")]
248 pub profile_id: Option<String>,
249}
250
251#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
252#[serde(rename_all = "snake_case")]
253pub enum TaskActorKind {
254 Person,
255 Instance,
256 #[serde(other)]
257 Unknown,
258}
259
260#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
268#[serde(deny_unknown_fields)]
269pub struct EvidenceV1 {
270 pub kind: EvidenceKind,
271 #[serde(default, skip_serializing_if = "Option::is_none")]
272 pub reference: Option<String>,
273 #[serde(default, skip_serializing_if = "Option::is_none")]
274 pub anchor: Option<CodeAnchorV1>,
275}
276
277#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
278#[serde(rename_all = "snake_case")]
279pub enum EvidenceKind {
280 Receipt,
281 Commit,
282 CiRun,
283 FileAnchor,
284 Url,
285 Fact,
286 #[serde(other)]
287 Unknown,
288}
289
290impl EvidenceV1 {
291 pub fn validate(&self) -> Result<(), ValidationError> {
292 let refuse = || ValidationError::new(format!("evidence does not fit kind {:?}", self.kind));
293 if self.kind == EvidenceKind::FileAnchor {
294 return match (&self.reference, &self.anchor) {
295 (None, Some(anchor)) => anchor.validate(),
296 _ => Err(refuse()),
297 };
298 }
299 if self.anchor.is_some() {
300 return Err(refuse());
301 }
302 let reference = self.reference.as_deref().ok_or_else(refuse)?;
303 validate_text(
304 reference,
305 MAX_EVIDENCE_REF_CHARS,
306 "evidence.reference",
307 false,
308 )?;
309 let https = |r: &str| r.starts_with("https://") && !r.contains(char::is_whitespace);
310 let ok = match self.kind {
311 EvidenceKind::Receipt => validate_hex64(reference, "evidence.reference").is_ok(),
312 EvidenceKind::Commit => {
313 (7..=64).contains(&reference.len())
314 && reference
315 .bytes()
316 .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
317 }
318 EvidenceKind::CiRun => https(reference) || validate_id(reference, "ci_run").is_ok(),
319 EvidenceKind::Url => https(reference),
320 EvidenceKind::Fact => validate_id(reference, "fact").is_ok(),
321 EvidenceKind::FileAnchor | EvidenceKind::Unknown => false,
322 };
323 if ok { Ok(()) } else { Err(refuse()) }
324 }
325}
326
327fn validate_evidence(evidence: &[EvidenceV1]) -> Result<(), ValidationError> {
328 if evidence.is_empty() || evidence.len() > MAX_EVIDENCE {
329 return Err(ValidationError::new(format!(
330 "a result carries 1-{MAX_EVIDENCE} pieces of evidence"
331 )));
332 }
333 for (i, item) in evidence.iter().enumerate() {
334 item.validate()?;
335 if evidence[..i].contains(item) {
336 return Err(ValidationError::new("evidence repeats"));
337 }
338 }
339 Ok(())
340}
341
342fn validate_title(title: &str) -> Result<(), ValidationError> {
343 validate_text(title, MAX_TITLE_CHARS, "title", false)?;
344 if title.trim().chars().count() < MIN_TITLE_CHARS {
345 return Err(ValidationError::new(format!(
346 "title must have at least {MIN_TITLE_CHARS} characters"
347 )));
348 }
349 Ok(())
350}
351
352pub fn validate_role(role: &str) -> Result<(), ValidationError> {
354 let bytes = role.as_bytes();
355 if !bytes.is_empty()
356 && bytes.len() <= 64
357 && bytes[0].is_ascii_lowercase()
358 && bytes
359 .iter()
360 .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || *b == b'-')
361 {
362 Ok(())
363 } else {
364 Err(ValidationError::new(
365 "role must be 1-64 characters of [a-z0-9-] starting with a letter",
366 ))
367 }
368}
369
370fn text_required(plain: bool) -> Result<(), ValidationError> {
372 if plain {
373 Ok(())
374 } else {
375 Err(ValidationError::new("text is required"))
376 }
377}
378
379#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
381#[serde(deny_unknown_fields)]
382pub struct ClaimRefV1 {
383 pub lease_id: String,
384 pub fencing_token: u64,
385}
386
387impl ClaimRefV1 {
388 pub fn validate(&self) -> Result<(), ValidationError> {
389 validate_id(&self.lease_id, "lease_id")?;
390 if self.fencing_token == 0 {
391 return Err(ValidationError::new("fencing tokens start at 1"));
392 }
393 Ok(())
394 }
395}
396
397fn validate_minutes(minutes: u32) -> Result<(), ValidationError> {
398 if (MIN_CLAIM_MINUTES..=MAX_CLAIM_MINUTES).contains(&minutes) {
399 Ok(())
400 } else {
401 Err(ValidationError::new(format!(
402 "minutes must be {MIN_CLAIM_MINUTES}-{MAX_CLAIM_MINUTES}"
403 )))
404 }
405}
406
407#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
409#[serde(deny_unknown_fields)]
410pub struct TaskProposeRequestV1 {
411 pub schema: String,
412 #[serde(default, skip_serializing_if = "Option::is_none")]
413 pub title: Option<String>,
414 #[serde(default, skip_serializing_if = "Option::is_none")]
415 pub goal: Option<String>,
416 #[serde(default, skip_serializing_if = "Option::is_none")]
418 pub acceptance: Option<String>,
419 #[serde(default, skip_serializing_if = "Option::is_none")]
421 pub role: Option<String>,
422 pub origin: TaskOriginV1,
423 pub session_ref: String,
425}
426
427impl TaskProposeRequestV1 {
428 pub fn validate(&self) -> Result<(), ValidationError> {
429 require_schema(&self.schema, TASK_PROPOSE_SCHEMA)?;
430 self.origin.validate_from_engine()?;
431 validate_id(&self.session_ref, "session_ref")?;
432 if let Some(role) = &self.role {
433 validate_role(role)?;
434 }
435 let plain = self.title.is_some() || self.goal.is_some() || self.acceptance.is_some();
436 text_required(plain)?;
437 if plain {
438 validate_title(self.title.as_deref().unwrap_or_default())?;
439 if let Some(goal) = &self.goal {
440 validate_text(goal, MAX_GOAL_CHARS, "goal", true)?;
441 }
442 validate_text(
443 self.acceptance.as_deref().unwrap_or_default(),
444 MAX_ACCEPTANCE_CHARS,
445 "acceptance",
446 true,
447 )?;
448 }
449 Ok(())
450 }
451}
452
453#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
455#[serde(deny_unknown_fields)]
456pub struct TaskClaimRequestV1 {
457 pub schema: String,
458 pub agent: String,
460 pub session_ref: String,
461 pub minutes: u32,
462 #[serde(default, skip_serializing_if = "Option::is_none")]
466 pub optin_id: Option<String>,
467}
468
469impl TaskClaimRequestV1 {
470 pub fn validate(&self) -> Result<(), ValidationError> {
471 require_schema(&self.schema, TASK_CLAIM_REQUEST_SCHEMA)?;
472 validate_id(&self.agent, "agent")?;
473 validate_id(&self.session_ref, "session_ref")?;
474 if let Some(optin) = &self.optin_id {
475 validate_id(optin, "optin_id")?;
476 }
477 validate_minutes(self.minutes)
478 }
479}
480
481#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
484#[serde(deny_unknown_fields)]
485pub struct TaskClaimRenewRequestV1 {
486 pub schema: String,
487 pub claim: ClaimRefV1,
488 pub minutes: u32,
489}
490
491impl TaskClaimRenewRequestV1 {
492 pub fn validate(&self) -> Result<(), ValidationError> {
493 require_schema(&self.schema, TASK_CLAIM_RENEW_SCHEMA)?;
494 self.claim.validate()?;
495 validate_minutes(self.minutes)
496 }
497}
498
499#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
501pub struct TaskClaimV1 {
502 pub holder: TaskActorV1,
503 pub since: UtcTimestamp,
504 pub until: UtcTimestamp,
505}
506
507#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
509pub struct TaskClaimAckV1 {
510 pub schema: String,
511 pub number: u64,
512 pub claim: ClaimRefV1,
513 pub until: UtcTimestamp,
514}
515
516#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
518#[serde(deny_unknown_fields)]
519pub struct TaskReleaseRequestV1 {
520 pub schema: String,
521 pub claim: ClaimRefV1,
522 #[serde(default, skip_serializing_if = "Option::is_none")]
524 pub reason: Option<String>,
525}
526
527impl TaskReleaseRequestV1 {
528 pub fn validate(&self) -> Result<(), ValidationError> {
529 require_schema(&self.schema, TASK_RELEASE_SCHEMA)?;
530 self.claim.validate()?;
531 if let Some(reason) = &self.reason {
532 validate_text(reason, MAX_REASON_CHARS, "reason", false)?;
533 }
534 Ok(())
535 }
536}
537
538#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
542#[serde(deny_unknown_fields)]
543pub struct UsageV1 {
544 pub tokens: u64,
545 #[serde(default, skip_serializing_if = "Option::is_none")]
546 pub usd_micros: Option<u64>,
547}
548
549#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
551#[serde(deny_unknown_fields)]
552pub struct TaskUpdateRequestV1 {
553 pub schema: String,
554 pub claim: ClaimRefV1,
555 #[serde(default, skip_serializing_if = "Option::is_none")]
556 pub summary: Option<String>,
557 #[serde(default, skip_serializing_if = "Option::is_none")]
558 pub usage: Option<UsageV1>,
559}
560
561impl TaskUpdateRequestV1 {
562 pub fn validate(&self) -> Result<(), ValidationError> {
563 require_schema(&self.schema, TASK_UPDATE_SCHEMA)?;
564 self.claim.validate()?;
565 text_required(self.summary.is_some())?;
566 if let Some(summary) = &self.summary {
567 validate_text(summary, MAX_SUMMARY_CHARS, "summary", false)?;
568 }
569 Ok(())
570 }
571}
572
573#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
579#[serde(deny_unknown_fields)]
580pub struct TaskResultRequestV1 {
581 pub schema: String,
582 pub claim: ClaimRefV1,
583 #[serde(default, skip_serializing_if = "Option::is_none")]
584 pub summary: Option<String>,
585 #[serde(default, skip_serializing_if = "Vec::is_empty")]
586 pub evidence: Vec<EvidenceV1>,
587 #[serde(default, skip_serializing_if = "Option::is_none")]
588 pub draft: Option<DraftProposalV1>,
589 #[serde(default, skip_serializing_if = "Option::is_none")]
590 pub usage: Option<UsageV1>,
591 #[serde(default, skip_serializing_if = "Option::is_none")]
594 pub outcome: Option<TaskOutcome>,
595}
596
597impl TaskResultRequestV1 {
598 pub fn validate(&self) -> Result<(), ValidationError> {
599 require_schema(&self.schema, TASK_RESULT_SCHEMA)?;
600 self.claim.validate()?;
601 let plain = self.summary.is_some() || !self.evidence.is_empty();
602 text_required(plain)?;
603 if plain {
604 validate_text(
605 self.summary.as_deref().unwrap_or_default(),
606 MAX_SUMMARY_CHARS,
607 "summary",
608 false,
609 )?;
610 validate_evidence(&self.evidence)?;
611 }
612 if let Some(draft) = &self.draft {
613 if self.outcome == Some(TaskOutcome::Failed) {
614 return Err(ValidationError::new("a failed result brings no draft"));
615 }
616 draft.validate()?;
617 }
618 Ok(())
619 }
620}
621
622#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
624pub struct TaskAckV1 {
625 pub schema: String,
626 pub number: u64,
627 pub state: TaskState,
628}
629
630#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
633#[serde(rename_all = "snake_case")]
634pub enum TaskVerdict {
635 Accept,
636 Reject,
637 #[serde(other)]
638 Unknown,
639}
640
641#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
645#[serde(deny_unknown_fields)]
646pub struct TaskVerifyRequestV1 {
647 pub schema: String,
648 pub worker_id: String,
649 pub verdict: TaskVerdict,
650 #[serde(default, skip_serializing_if = "Option::is_none")]
651 pub reason: Option<String>,
652}
653
654impl TaskVerifyRequestV1 {
655 pub fn validate(&self) -> Result<(), ValidationError> {
656 require_schema(&self.schema, TASK_VERIFY_SCHEMA)?;
657 let worker_ok = (1..=128).contains(&self.worker_id.len())
658 && self
659 .worker_id
660 .bytes()
661 .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'_' | b':' | b'-'));
662 if !worker_ok {
663 return Err(ValidationError::new(
664 "worker_id is 1-128 of [A-Za-z0-9._:-]",
665 ));
666 }
667 match (self.verdict, &self.reason) {
668 (TaskVerdict::Unknown, _) => Err(ValidationError::new("verdict is accept or reject")),
669 (TaskVerdict::Reject, None) => Err(ValidationError::new("a reject names its reason")),
670 (_, Some(reason)) => validate_text(reason, MAX_VERIFY_REASON_CHARS, "reason", false),
671 (TaskVerdict::Accept, None) => Ok(()),
672 }
673 }
674}
675
676#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
678pub struct TaskVerificationV1 {
679 pub verdict: TaskVerdict,
680 pub by: TaskActorV1,
681 pub at: UtcTimestamp,
682 #[serde(default, skip_serializing_if = "Option::is_none")]
683 pub reason: Option<String>,
684}
685
686#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
689#[serde(rename_all = "snake_case")]
690pub enum TaskUpdateKind {
691 Progress,
692 Verification,
693 #[serde(other)]
694 Unknown,
695}
696
697#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, Serialize, Deserialize)]
699#[serde(rename_all = "snake_case")]
700pub enum TaskPriority {
701 High,
702 #[default]
703 Normal,
704}
705
706#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
710#[serde(deny_unknown_fields)]
711pub struct TaskFieldsRequestV1 {
712 pub schema: String,
713 pub base_version: u32,
714 #[serde(default, skip_serializing_if = "Option::is_none")]
715 pub priority: Option<TaskPriority>,
716 #[serde(default, skip_serializing_if = "Option::is_none")]
717 pub labels: Option<Vec<String>>,
718}
719
720impl TaskFieldsRequestV1 {
721 pub fn validate(&self) -> Result<(), ValidationError> {
722 require_schema(&self.schema, TASK_FIELDS_SCHEMA)?;
723 if self.priority.is_none() && self.labels.is_none() {
724 return Err(ValidationError::new("set priority, labels or both"));
725 }
726 if let Some(labels) = &self.labels {
727 validate_labels(labels)?;
728 }
729 Ok(())
730 }
731}
732
733pub fn validate_labels(labels: &[String]) -> Result<(), ValidationError> {
735 if labels.len() > MAX_TASK_LABELS {
736 return Err(ValidationError::new("at most 16 labels"));
737 }
738 let mut seen = std::collections::BTreeSet::new();
739 for label in labels {
740 let ok = (1..=32).contains(&label.len())
741 && label
742 .bytes()
743 .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-');
744 if !ok {
745 return Err(ValidationError::new("a label is 1-32 of [a-z0-9-]"));
746 }
747 if !seen.insert(label) {
748 return Err(ValidationError::new("labels are unique"));
749 }
750 }
751 Ok(())
752}
753
754#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
756pub struct TaskUpdateV1 {
757 pub by: TaskActorV1,
758 pub at: UtcTimestamp,
759 #[serde(default, skip_serializing_if = "Option::is_none")]
760 pub summary: Option<String>,
761 #[serde(default, skip_serializing_if = "Option::is_none")]
762 pub usage: Option<UsageV1>,
763 #[serde(default, skip_serializing_if = "Option::is_none")]
765 pub kind: Option<TaskUpdateKind>,
766 #[serde(default, skip_serializing_if = "Option::is_none")]
768 pub verdict: Option<TaskVerdict>,
769}
770
771#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
774pub struct EvidenceViewV1 {
775 pub kind: EvidenceKind,
776 #[serde(default, skip_serializing_if = "Option::is_none")]
777 pub reference: Option<String>,
778 #[serde(default, skip_serializing_if = "Option::is_none")]
779 pub anchor: Option<CodeAnchorV1>,
780 pub verified: bool,
781}
782
783#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
785pub struct TaskResultV1 {
786 pub by: TaskActorV1,
787 pub at: UtcTimestamp,
788 pub proposal: u64,
791 #[serde(default, skip_serializing_if = "Option::is_none")]
793 pub seq: Option<u64>,
794 #[serde(default, skip_serializing_if = "Option::is_none")]
795 pub summary: Option<String>,
796 #[serde(default, skip_serializing_if = "Vec::is_empty")]
797 pub evidence: Vec<EvidenceViewV1>,
798 #[serde(default, skip_serializing_if = "Option::is_none")]
799 pub usage: Option<UsageV1>,
800}
801
802#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
804#[serde(tag = "kind", rename_all = "snake_case")]
805pub enum DecidedByV1 {
806 Person {
807 label: String,
808 },
809 Threshold,
810 #[serde(other)]
811 Unknown,
812}
813
814#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
815#[serde(rename_all = "snake_case")]
816pub enum TaskDecisionOutcome {
817 Accepted,
818 Declined,
819 #[serde(other)]
820 Unknown,
821}
822
823#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
825pub struct TaskDecisionV1 {
826 pub outcome: TaskDecisionOutcome,
827 pub decided_by: DecidedByV1,
828 pub at: UtcTimestamp,
829 #[serde(default, skip_serializing_if = "Option::is_none")]
830 pub reason: Option<String>,
831}
832
833#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
835pub struct TaskV1 {
836 pub schema: String,
837 pub vault_id: String,
838 pub number: u64,
839 pub state: TaskState,
840 #[serde(default, skip_serializing_if = "Option::is_none")]
842 pub lifecycle: Option<TaskLifecycle>,
843 #[serde(default, skip_serializing_if = "Option::is_none")]
845 pub title: Option<String>,
846 #[serde(default, skip_serializing_if = "Option::is_none")]
847 pub goal: Option<String>,
848 #[serde(default, skip_serializing_if = "Option::is_none")]
849 pub acceptance: Option<String>,
850 #[serde(default, skip_serializing_if = "Option::is_none")]
851 pub role: Option<String>,
852 pub origin: TaskOriginV1,
853 pub created_by: TaskActorV1,
854 pub created_at: UtcTimestamp,
855 #[serde(default, skip_serializing_if = "Option::is_none")]
857 pub approved_by: Option<TaskActorV1>,
858 #[serde(default, skip_serializing_if = "Option::is_none")]
859 pub claim: Option<TaskClaimV1>,
860 #[serde(default, skip_serializing_if = "Vec::is_empty")]
862 pub updates: Vec<TaskUpdateV1>,
863 #[serde(default, skip_serializing_if = "Option::is_none")]
864 pub result: Option<TaskResultV1>,
865 #[serde(default, skip_serializing_if = "Option::is_none")]
866 pub decision: Option<TaskDecisionV1>,
867 #[serde(default, skip_serializing_if = "Option::is_none")]
869 pub verified_by: Option<TaskActorV1>,
870 #[serde(default, skip_serializing_if = "Option::is_none")]
871 pub verified_at: Option<UtcTimestamp>,
872 #[serde(default, skip_serializing_if = "Option::is_none")]
874 pub verification: Option<TaskVerificationV1>,
875 #[serde(default, skip_serializing_if = "Option::is_none")]
877 pub priority: Option<TaskPriority>,
878 #[serde(default, skip_serializing_if = "Vec::is_empty")]
879 pub labels: Vec<String>,
880 #[serde(default, skip_serializing_if = "Option::is_none")]
883 pub fields_version: Option<u32>,
884}
885
886impl TaskV1 {
887 pub fn validate(&self) -> Result<(), ValidationError> {
888 require_schema(&self.schema, TASK_SCHEMA)?;
889 super::validate_vault_id(&self.vault_id)?;
890 if self.number == 0 {
891 return Err(ValidationError::new("task numbers start at 1"));
892 }
893 if (self.state == TaskState::Claimed) != self.claim.is_some() {
894 return Err(ValidationError::new(
895 "a task carries a claim exactly while it is claimed",
896 ));
897 }
898 if self.updates.len() > MAX_VIEW_UPDATES {
899 return Err(ValidationError::new("too many updates in a task view"));
900 }
901 if let Some(approver) = &self.approved_by
902 && approver.kind != TaskActorKind::Person
903 {
904 return Err(ValidationError::new("only a person approves a task"));
905 }
906 if matches!(self.state, TaskState::Submitted | TaskState::Done) && self.result.is_none() {
907 return Err(ValidationError::new(
908 "a submitted or done task carries its result",
909 ));
910 }
911 Ok(())
912 }
913}
914
915#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
917pub struct TaskSummaryV1 {
918 pub number: u64,
919 pub state: TaskState,
920 #[serde(default, skip_serializing_if = "Option::is_none")]
922 pub lifecycle: Option<TaskLifecycle>,
923 #[serde(default, skip_serializing_if = "Option::is_none")]
924 pub title: Option<String>,
925 #[serde(default, skip_serializing_if = "Option::is_none")]
926 pub role: Option<String>,
927 pub origin: TaskOriginKind,
928 pub updated_at: UtcTimestamp,
929 pub mine: bool,
931 #[serde(default, skip_serializing_if = "Option::is_none")]
933 pub priority: Option<TaskPriority>,
934 #[serde(default, skip_serializing_if = "Vec::is_empty")]
935 pub labels: Vec<String>,
936 #[serde(default, skip_serializing_if = "Option::is_none")]
937 pub fields_version: Option<u32>,
938}
939
940#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
942pub struct TaskListV1 {
943 pub schema: String,
944 pub vault_id: String,
945 pub tasks: Vec<TaskSummaryV1>,
946}
947
948impl TaskListV1 {
949 pub fn validate(&self) -> Result<(), ValidationError> {
950 require_schema(&self.schema, TASK_LIST_SCHEMA)?;
951 super::validate_vault_id(&self.vault_id)?;
952 if self.tasks.len() > MAX_TASKS_PAGE {
953 return Err(ValidationError::new("too many tasks in one page"));
954 }
955 Ok(())
956 }
957}
958
959#[cfg(test)]
960mod tests {
961 use super::*;
962
963 fn propose() -> TaskProposeRequestV1 {
964 TaskProposeRequestV1 {
965 schema: TASK_PROPOSE_SCHEMA.into(),
966 title: Some("Explain the retry budget".into()),
967 goal: None,
968 acceptance: Some("A fact with an anchor on retry.rs".into()),
969 role: None,
970 origin: TaskOriginV1 {
971 kind: TaskOriginKind::Agent,
972 facts: Vec::new(),
973 message: None,
974 },
975 session_ref: "s-1".into(),
976 }
977 }
978
979 fn claim() -> ClaimRefV1 {
980 ClaimRefV1 {
981 lease_id: "lease-1".into(),
982 fencing_token: 3,
983 }
984 }
985
986 #[test]
987 fn an_unknown_task_origin_parses_as_unknown() {
988 let origin: TaskOriginV1 = serde_json::from_value(serde_json::json!({
989 "kind": "quarantine_timeout", "facts": ["f-1"],
990 }))
991 .unwrap();
992 assert_eq!(origin.kind, TaskOriginKind::Unknown);
993 }
994
995 #[test]
996 fn engines_propose_only_agent_or_contradiction_tasks() {
997 assert!(propose().validate().is_ok());
998 let mut person = propose();
999 person.origin.kind = TaskOriginKind::Person;
1000 assert!(person.validate().is_err());
1001 let mut contradiction = propose();
1002 contradiction.origin = TaskOriginV1 {
1003 kind: TaskOriginKind::Contradiction,
1004 facts: vec!["f-1".into(), "f-1".into()],
1005 message: None,
1006 };
1007 assert!(contradiction.validate().is_err());
1008 contradiction.origin.facts[1] = "f-2".into();
1009 assert!(contradiction.validate().is_ok());
1010 }
1011
1012 #[test]
1013 fn a_result_needs_evidence_that_fits_its_kind() {
1014 let mut result = TaskResultRequestV1 {
1015 schema: TASK_RESULT_SCHEMA.into(),
1016 claim: claim(),
1017 summary: Some("Done".into()),
1018 evidence: Vec::new(),
1019 draft: None,
1020 usage: None,
1021 outcome: None,
1022 };
1023 assert!(result.validate().is_err(), "no evidence");
1024 let r = |kind, reference: &str| EvidenceV1 {
1025 kind,
1026 reference: Some(reference.into()),
1027 anchor: None,
1028 };
1029 for (evidence, ok) in [
1030 (r(EvidenceKind::Commit, "9f01cf32a3"), true),
1031 (r(EvidenceKind::Commit, "main"), false),
1032 (r(EvidenceKind::Url, "http://example.com"), false),
1033 (r(EvidenceKind::Receipt, &"a".repeat(64)), true),
1034 (r(EvidenceKind::FileAnchor, "src/retry.rs"), false),
1035 ] {
1036 result.evidence = vec![evidence.clone()];
1037 assert_eq!(result.validate().is_ok(), ok, "{evidence:?}");
1038 }
1039 result.claim.fencing_token = 0;
1040 result.evidence = vec![r(EvidenceKind::Commit, "9f01cf32a3")];
1041 assert!(result.validate().is_err(), "fencing tokens start at 1");
1042 }
1043
1044 #[test]
1045 fn summaries_are_one_line() {
1046 let update = |summary: &str| TaskUpdateRequestV1 {
1047 schema: TASK_UPDATE_SCHEMA.into(),
1048 claim: claim(),
1049 summary: Some(summary.into()),
1050 usage: None,
1051 };
1052 assert!(update("Reproduced the timeout").validate().is_ok());
1053 assert!(update("line one\nline two").validate().is_err());
1054 assert!(
1055 update(&"x".repeat(MAX_SUMMARY_CHARS + 1))
1056 .validate()
1057 .is_err()
1058 );
1059 }
1060
1061 #[test]
1062 fn decided_by_is_a_person_or_the_threshold() {
1063 let person: DecidedByV1 =
1064 serde_json::from_value(serde_json::json!({"kind": "person", "label": "Ada"})).unwrap();
1065 assert_eq!(
1066 person,
1067 DecidedByV1::Person {
1068 label: "Ada".into()
1069 }
1070 );
1071 let threshold: DecidedByV1 =
1072 serde_json::from_value(serde_json::json!({"kind": "threshold"})).unwrap();
1073 assert_eq!(threshold, DecidedByV1::Threshold);
1074 }
1075
1076 fn fixture(name: &str) -> serde_json::Value {
1077 let path = format!("{}/tests/fixtures/hive/{name}", env!("CARGO_MANIFEST_DIR"));
1078 serde_json::from_slice(&std::fs::read(path).unwrap()).unwrap()
1079 }
1080
1081 #[test]
1084 fn a_failed_result_names_its_outcome_and_brings_no_draft() {
1085 let mut result: TaskResultRequestV1 =
1086 serde_json::from_value(fixture("task_result.request.json")).unwrap();
1087 assert_eq!(result.outcome, None);
1088 assert!(
1089 serde_json::to_value(&result)
1090 .unwrap()
1091 .get("outcome")
1092 .is_none(),
1093 "a completed result sends no outcome"
1094 );
1095 result.outcome = Some(TaskOutcome::Failed);
1096 assert!(result.validate().is_ok());
1097 assert_eq!(serde_json::to_value(&result).unwrap()["outcome"], "failed");
1098 result.draft =
1099 Some(serde_json::from_value(fixture("proposal.import.request.json")).unwrap());
1100 assert_eq!(
1101 result.validate().unwrap_err().to_string(),
1102 "a failed result brings no draft"
1103 );
1104 result.outcome = Some(TaskOutcome::Completed);
1105 assert!(
1106 result.validate().is_ok(),
1107 "a completed result may bring one"
1108 );
1109 }
1110
1111 #[test]
1115 fn task_views_of_both_lines_parse() {
1116 let old: TaskV1 = serde_json::from_value(fixture("task.done.response.json")).unwrap();
1117 assert_eq!(old.lifecycle, None);
1118 assert_eq!(old.result.as_ref().unwrap().seq, None);
1119 assert!(
1120 serde_json::to_value(&old)
1121 .unwrap()
1122 .get("lifecycle")
1123 .is_none()
1124 );
1125
1126 let mut v5 = fixture("task.done.response.json");
1127 v5["state"] = "failed".into();
1128 v5["lifecycle"] = "failed".into();
1129 v5["result"]["proposal"] = 0.into();
1130 v5["result"]["seq"] = 42.into();
1131 let v5: TaskV1 = serde_json::from_value(v5).unwrap();
1132 assert_eq!(v5.state, TaskState::Failed);
1133 assert_eq!(v5.lifecycle, Some(TaskLifecycle::Failed));
1134 let result = v5.result.as_ref().unwrap();
1135 assert_eq!((result.proposal, result.seq), (0, Some(42)));
1136 assert!(v5.validate().is_ok());
1137
1138 let mut list = fixture("task_list.response.json");
1139 list["tasks"][0]["lifecycle"] = "paused_someday".into();
1140 let list: TaskListV1 = serde_json::from_value(list).unwrap();
1141 assert_eq!(list.tasks[0].lifecycle, Some(TaskLifecycle::Unknown));
1142 let state: TaskState = serde_json::from_value("someday".into()).unwrap();
1143 assert_eq!(state, TaskState::Unknown);
1144 }
1145
1146 fn v2_route(name: &str) -> serde_json::Value {
1147 let path = format!(
1148 "{}/tests/fixtures/hive-v2/routes/{name}.json",
1149 env!("CARGO_MANIFEST_DIR")
1150 );
1151 serde_json::from_str(&std::fs::read_to_string(path).unwrap()).unwrap()
1152 }
1153
1154 #[test]
1158 fn the_c0_2_2_task_shapes_hold() {
1159 for name in ["task_verify.request", "task_verify.reject.request"] {
1160 let request: TaskVerifyRequestV1 = serde_json::from_value(v2_route(name)).unwrap();
1161 request.validate().unwrap_or_else(|e| panic!("{name}: {e}"));
1162 assert_eq!(serde_json::to_value(&request).unwrap(), v2_route(name));
1163 }
1164 let mut reject: TaskVerifyRequestV1 =
1165 serde_json::from_value(v2_route("task_verify.reject.request")).unwrap();
1166 reject.reason = None;
1167 assert_eq!(
1168 reject.validate().unwrap_err().to_string(),
1169 "a reject names its reason"
1170 );
1171 for (name, state) in [
1172 ("task_verify.response", TaskState::Done),
1173 ("task_verify.reject.response", TaskState::Open),
1174 ("task_result.submitted.response", TaskState::Submitted),
1175 ] {
1176 let ack: TaskAckV1 = serde_json::from_value(v2_route(name)).unwrap();
1177 assert_eq!(ack.state, state, "{name}");
1178 }
1179 let fields: TaskFieldsRequestV1 =
1180 serde_json::from_value(v2_route("task_fields.request")).unwrap();
1181 fields.validate().unwrap();
1182 assert_eq!(fields.priority, Some(TaskPriority::High));
1183 let empty = TaskFieldsRequestV1 {
1184 labels: None,
1185 priority: None,
1186 ..fields.clone()
1187 };
1188 assert!(empty.validate().is_err());
1189 let bad = TaskFieldsRequestV1 {
1190 labels: Some(vec!["Docs".into()]),
1191 ..fields
1192 };
1193 assert!(bad.validate().is_err());
1194 }
1195}