1use serde::{Deserialize, Serialize};
83
84use super::anchor::CodeAnchorV1;
85use super::knowledge::DraftProposalV1;
86use super::sealed::SealedBlobV1;
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";
100
101pub const SEALED_TASK_CONTENT_SCHEMA: &str = "leanctx.hive.sealed-task/v1";
103pub const SEALED_TASK_UPDATE_SCHEMA: &str = "leanctx.hive.sealed-task-update/v1";
105pub const SEALED_TASK_RESULT_SCHEMA: &str = "leanctx.hive.sealed-task-result/v1";
107
108pub const MIN_TITLE_CHARS: usize = 5;
109pub const MAX_TITLE_CHARS: usize = 180;
110pub const MAX_GOAL_CHARS: usize = 2000;
111pub const MAX_ACCEPTANCE_CHARS: usize = 2000;
112pub const MAX_SUMMARY_CHARS: usize = 500;
114pub const MAX_REASON_CHARS: usize = 280;
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 Completed,
155 Failed,
156 Cancelled,
157 #[serde(other)]
158 Unknown,
159}
160
161#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
164#[serde(rename_all = "snake_case")]
165pub enum TaskOutcome {
166 #[default]
167 Completed,
168 Failed,
169}
170
171#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
173#[serde(rename_all = "snake_case")]
174pub enum TaskOriginKind {
175 Person,
177 Agent,
179 Contradiction,
181 Question,
183 QuarantineTimeout,
186 #[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 one_of(plain: bool, sealed: Option<&SealedBlobV1>, schema: &str) -> Result<(), ValidationError> {
372 match (plain, sealed) {
373 (true, None) => Ok(()),
374 (false, Some(blob)) => {
375 blob.validate()?;
376 if blob.content_schema == schema {
377 Ok(())
378 } else {
379 Err(ValidationError::new(format!(
380 "sealed content must be {schema}"
381 )))
382 }
383 }
384 (true, Some(_)) => Err(ValidationError::new("sealed writes carry no plaintext")),
385 (false, None) => Err(ValidationError::new("text or sealed content is required")),
386 }
387}
388
389#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
391#[serde(deny_unknown_fields)]
392pub struct ClaimRefV1 {
393 pub lease_id: String,
394 pub fencing_token: u64,
395}
396
397impl ClaimRefV1 {
398 pub fn validate(&self) -> Result<(), ValidationError> {
399 validate_id(&self.lease_id, "lease_id")?;
400 if self.fencing_token == 0 {
401 return Err(ValidationError::new("fencing tokens start at 1"));
402 }
403 Ok(())
404 }
405}
406
407fn validate_minutes(minutes: u32) -> Result<(), ValidationError> {
408 if (MIN_CLAIM_MINUTES..=MAX_CLAIM_MINUTES).contains(&minutes) {
409 Ok(())
410 } else {
411 Err(ValidationError::new(format!(
412 "minutes must be {MIN_CLAIM_MINUTES}-{MAX_CLAIM_MINUTES}"
413 )))
414 }
415}
416
417#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
419#[serde(deny_unknown_fields)]
420pub struct TaskProposeRequestV1 {
421 pub schema: String,
422 #[serde(default, skip_serializing_if = "Option::is_none")]
423 pub title: Option<String>,
424 #[serde(default, skip_serializing_if = "Option::is_none")]
425 pub goal: Option<String>,
426 #[serde(default, skip_serializing_if = "Option::is_none")]
428 pub acceptance: Option<String>,
429 #[serde(default, skip_serializing_if = "Option::is_none")]
431 pub role: Option<String>,
432 pub origin: TaskOriginV1,
433 pub session_ref: String,
435 #[serde(default, skip_serializing_if = "Option::is_none")]
436 pub sealed: Option<SealedBlobV1>,
437}
438
439impl TaskProposeRequestV1 {
440 pub fn validate(&self) -> Result<(), ValidationError> {
441 require_schema(&self.schema, TASK_PROPOSE_SCHEMA)?;
442 self.origin.validate_from_engine()?;
443 validate_id(&self.session_ref, "session_ref")?;
444 if let Some(role) = &self.role {
445 validate_role(role)?;
446 }
447 let plain = self.title.is_some() || self.goal.is_some() || self.acceptance.is_some();
448 one_of(plain, self.sealed.as_ref(), SEALED_TASK_CONTENT_SCHEMA)?;
449 if plain {
450 validate_title(self.title.as_deref().unwrap_or_default())?;
451 if let Some(goal) = &self.goal {
452 validate_text(goal, MAX_GOAL_CHARS, "goal", true)?;
453 }
454 validate_text(
455 self.acceptance.as_deref().unwrap_or_default(),
456 MAX_ACCEPTANCE_CHARS,
457 "acceptance",
458 true,
459 )?;
460 }
461 Ok(())
462 }
463}
464
465#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
467#[serde(deny_unknown_fields)]
468pub struct TaskClaimRequestV1 {
469 pub schema: String,
470 pub agent: String,
472 pub session_ref: String,
473 pub minutes: u32,
474 #[serde(default, skip_serializing_if = "Option::is_none")]
478 pub optin_id: Option<String>,
479}
480
481impl TaskClaimRequestV1 {
482 pub fn validate(&self) -> Result<(), ValidationError> {
483 require_schema(&self.schema, TASK_CLAIM_REQUEST_SCHEMA)?;
484 validate_id(&self.agent, "agent")?;
485 validate_id(&self.session_ref, "session_ref")?;
486 if let Some(optin) = &self.optin_id {
487 validate_id(optin, "optin_id")?;
488 }
489 validate_minutes(self.minutes)
490 }
491}
492
493#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
496#[serde(deny_unknown_fields)]
497pub struct TaskClaimRenewRequestV1 {
498 pub schema: String,
499 pub claim: ClaimRefV1,
500 pub minutes: u32,
501}
502
503impl TaskClaimRenewRequestV1 {
504 pub fn validate(&self) -> Result<(), ValidationError> {
505 require_schema(&self.schema, TASK_CLAIM_RENEW_SCHEMA)?;
506 self.claim.validate()?;
507 validate_minutes(self.minutes)
508 }
509}
510
511#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
513pub struct TaskClaimV1 {
514 pub holder: TaskActorV1,
515 pub since: UtcTimestamp,
516 pub until: UtcTimestamp,
517}
518
519#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
521pub struct TaskClaimAckV1 {
522 pub schema: String,
523 pub number: u64,
524 pub claim: ClaimRefV1,
525 pub until: UtcTimestamp,
526}
527
528#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
530#[serde(deny_unknown_fields)]
531pub struct TaskReleaseRequestV1 {
532 pub schema: String,
533 pub claim: ClaimRefV1,
534 #[serde(default, skip_serializing_if = "Option::is_none")]
536 pub reason: Option<String>,
537}
538
539impl TaskReleaseRequestV1 {
540 pub fn validate(&self) -> Result<(), ValidationError> {
541 require_schema(&self.schema, TASK_RELEASE_SCHEMA)?;
542 self.claim.validate()?;
543 if let Some(reason) = &self.reason {
544 validate_text(reason, MAX_REASON_CHARS, "reason", false)?;
545 }
546 Ok(())
547 }
548}
549
550#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
554#[serde(deny_unknown_fields)]
555pub struct UsageV1 {
556 pub tokens: u64,
557 #[serde(default, skip_serializing_if = "Option::is_none")]
558 pub usd_micros: Option<u64>,
559}
560
561#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
563#[serde(deny_unknown_fields)]
564pub struct TaskUpdateRequestV1 {
565 pub schema: String,
566 pub claim: ClaimRefV1,
567 #[serde(default, skip_serializing_if = "Option::is_none")]
568 pub summary: Option<String>,
569 #[serde(default, skip_serializing_if = "Option::is_none")]
570 pub sealed: Option<SealedBlobV1>,
571 #[serde(default, skip_serializing_if = "Option::is_none")]
572 pub usage: Option<UsageV1>,
573}
574
575impl TaskUpdateRequestV1 {
576 pub fn validate(&self) -> Result<(), ValidationError> {
577 require_schema(&self.schema, TASK_UPDATE_SCHEMA)?;
578 self.claim.validate()?;
579 one_of(
580 self.summary.is_some(),
581 self.sealed.as_ref(),
582 SEALED_TASK_UPDATE_SCHEMA,
583 )?;
584 if let Some(summary) = &self.summary {
585 validate_text(summary, MAX_SUMMARY_CHARS, "summary", false)?;
586 }
587 Ok(())
588 }
589}
590
591#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
597#[serde(deny_unknown_fields)]
598pub struct TaskResultRequestV1 {
599 pub schema: String,
600 pub claim: ClaimRefV1,
601 #[serde(default, skip_serializing_if = "Option::is_none")]
602 pub summary: Option<String>,
603 #[serde(default, skip_serializing_if = "Vec::is_empty")]
604 pub evidence: Vec<EvidenceV1>,
605 #[serde(default, skip_serializing_if = "Option::is_none")]
606 pub draft: Option<DraftProposalV1>,
607 #[serde(default, skip_serializing_if = "Option::is_none")]
608 pub sealed: Option<SealedBlobV1>,
609 #[serde(default, skip_serializing_if = "Option::is_none")]
610 pub usage: Option<UsageV1>,
611 #[serde(default, skip_serializing_if = "Option::is_none")]
614 pub outcome: Option<TaskOutcome>,
615}
616
617impl TaskResultRequestV1 {
618 pub fn validate(&self) -> Result<(), ValidationError> {
619 require_schema(&self.schema, TASK_RESULT_SCHEMA)?;
620 self.claim.validate()?;
621 let plain = self.summary.is_some() || !self.evidence.is_empty();
622 one_of(plain, self.sealed.as_ref(), SEALED_TASK_RESULT_SCHEMA)?;
623 if plain {
624 validate_text(
625 self.summary.as_deref().unwrap_or_default(),
626 MAX_SUMMARY_CHARS,
627 "summary",
628 false,
629 )?;
630 validate_evidence(&self.evidence)?;
631 }
632 if let Some(draft) = &self.draft {
633 if self.outcome == Some(TaskOutcome::Failed) {
634 return Err(ValidationError::new("a failed result brings no draft"));
635 }
636 draft.validate()?;
637 if draft.context_patch.sealed.is_some() != self.sealed.is_some() {
638 return Err(ValidationError::new(
639 "a result and its draft are both sealed or both in the clear",
640 ));
641 }
642 }
643 Ok(())
644 }
645}
646
647#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
649pub struct TaskAckV1 {
650 pub schema: String,
651 pub number: u64,
652 pub state: TaskState,
653}
654
655#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
657pub struct TaskUpdateV1 {
658 pub by: TaskActorV1,
659 pub at: UtcTimestamp,
660 #[serde(default, skip_serializing_if = "Option::is_none")]
661 pub summary: Option<String>,
662 #[serde(default, skip_serializing_if = "Option::is_none")]
663 pub sealed: Option<SealedBlobV1>,
664 #[serde(default, skip_serializing_if = "Option::is_none")]
665 pub usage: Option<UsageV1>,
666}
667
668#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
671pub struct EvidenceViewV1 {
672 pub kind: EvidenceKind,
673 #[serde(default, skip_serializing_if = "Option::is_none")]
674 pub reference: Option<String>,
675 #[serde(default, skip_serializing_if = "Option::is_none")]
676 pub anchor: Option<CodeAnchorV1>,
677 pub verified: bool,
678}
679
680#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
682pub struct TaskResultV1 {
683 pub by: TaskActorV1,
684 pub at: UtcTimestamp,
685 pub proposal: u64,
688 #[serde(default, skip_serializing_if = "Option::is_none")]
690 pub seq: Option<u64>,
691 #[serde(default, skip_serializing_if = "Option::is_none")]
692 pub summary: Option<String>,
693 #[serde(default, skip_serializing_if = "Vec::is_empty")]
694 pub evidence: Vec<EvidenceViewV1>,
695 #[serde(default, skip_serializing_if = "Option::is_none")]
696 pub sealed: Option<SealedBlobV1>,
697 #[serde(default, skip_serializing_if = "Option::is_none")]
698 pub usage: Option<UsageV1>,
699}
700
701#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
703#[serde(tag = "kind", rename_all = "snake_case")]
704pub enum DecidedByV1 {
705 Person {
706 label: String,
707 },
708 Threshold,
709 #[serde(other)]
710 Unknown,
711}
712
713#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
714#[serde(rename_all = "snake_case")]
715pub enum TaskDecisionOutcome {
716 Accepted,
717 Declined,
718 #[serde(other)]
719 Unknown,
720}
721
722#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
724pub struct TaskDecisionV1 {
725 pub outcome: TaskDecisionOutcome,
726 pub decided_by: DecidedByV1,
727 pub at: UtcTimestamp,
728 #[serde(default, skip_serializing_if = "Option::is_none")]
729 pub reason: Option<String>,
730}
731
732#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
734pub struct TaskV1 {
735 pub schema: String,
736 pub vault_id: String,
737 pub number: u64,
738 pub state: TaskState,
739 #[serde(default, skip_serializing_if = "Option::is_none")]
741 pub lifecycle: Option<TaskLifecycle>,
742 #[serde(default, skip_serializing_if = "Option::is_none")]
744 pub title: Option<String>,
745 #[serde(default, skip_serializing_if = "Option::is_none")]
746 pub goal: Option<String>,
747 #[serde(default, skip_serializing_if = "Option::is_none")]
748 pub acceptance: Option<String>,
749 #[serde(default, skip_serializing_if = "Option::is_none")]
750 pub sealed: Option<SealedBlobV1>,
751 #[serde(default, skip_serializing_if = "Option::is_none")]
752 pub role: Option<String>,
753 pub origin: TaskOriginV1,
754 pub created_by: TaskActorV1,
755 pub created_at: UtcTimestamp,
756 #[serde(default, skip_serializing_if = "Option::is_none")]
758 pub approved_by: Option<TaskActorV1>,
759 #[serde(default, skip_serializing_if = "Option::is_none")]
760 pub claim: Option<TaskClaimV1>,
761 #[serde(default, skip_serializing_if = "Vec::is_empty")]
763 pub updates: Vec<TaskUpdateV1>,
764 #[serde(default, skip_serializing_if = "Option::is_none")]
765 pub result: Option<TaskResultV1>,
766 #[serde(default, skip_serializing_if = "Option::is_none")]
767 pub decision: Option<TaskDecisionV1>,
768}
769
770impl TaskV1 {
771 pub fn validate(&self) -> Result<(), ValidationError> {
772 require_schema(&self.schema, TASK_SCHEMA)?;
773 super::validate_vault_id(&self.vault_id)?;
774 if self.number == 0 {
775 return Err(ValidationError::new("task numbers start at 1"));
776 }
777 if (self.state == TaskState::Claimed) != self.claim.is_some() {
778 return Err(ValidationError::new(
779 "a task carries a claim exactly while it is claimed",
780 ));
781 }
782 if self.updates.len() > MAX_VIEW_UPDATES {
783 return Err(ValidationError::new("too many updates in a task view"));
784 }
785 if let Some(approver) = &self.approved_by
786 && approver.kind != TaskActorKind::Person
787 {
788 return Err(ValidationError::new("only a person approves a task"));
789 }
790 if matches!(self.state, TaskState::Submitted | TaskState::Done) && self.result.is_none() {
791 return Err(ValidationError::new(
792 "a submitted or done task carries its result",
793 ));
794 }
795 Ok(())
796 }
797}
798
799#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
801pub struct TaskSummaryV1 {
802 pub number: u64,
803 pub state: TaskState,
804 #[serde(default, skip_serializing_if = "Option::is_none")]
806 pub lifecycle: Option<TaskLifecycle>,
807 #[serde(default, skip_serializing_if = "Option::is_none")]
808 pub title: Option<String>,
809 #[serde(default, skip_serializing_if = "Option::is_none")]
810 pub role: Option<String>,
811 pub origin: TaskOriginKind,
812 pub updated_at: UtcTimestamp,
813 pub mine: bool,
815}
816
817#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
819pub struct TaskListV1 {
820 pub schema: String,
821 pub vault_id: String,
822 pub tasks: Vec<TaskSummaryV1>,
823}
824
825impl TaskListV1 {
826 pub fn validate(&self) -> Result<(), ValidationError> {
827 require_schema(&self.schema, TASK_LIST_SCHEMA)?;
828 super::validate_vault_id(&self.vault_id)?;
829 if self.tasks.len() > MAX_TASKS_PAGE {
830 return Err(ValidationError::new("too many tasks in one page"));
831 }
832 Ok(())
833 }
834}
835
836#[cfg(test)]
837mod tests {
838 use super::*;
839
840 fn propose() -> TaskProposeRequestV1 {
841 TaskProposeRequestV1 {
842 schema: TASK_PROPOSE_SCHEMA.into(),
843 title: Some("Explain the retry budget".into()),
844 goal: None,
845 acceptance: Some("A fact with an anchor on retry.rs".into()),
846 role: None,
847 origin: TaskOriginV1 {
848 kind: TaskOriginKind::Agent,
849 facts: Vec::new(),
850 message: None,
851 },
852 session_ref: "s-1".into(),
853 sealed: None,
854 }
855 }
856
857 fn claim() -> ClaimRefV1 {
858 ClaimRefV1 {
859 lease_id: "lease-1".into(),
860 fencing_token: 3,
861 }
862 }
863
864 #[test]
865 fn engines_propose_only_agent_or_contradiction_tasks() {
866 assert!(propose().validate().is_ok());
867 let mut person = propose();
868 person.origin.kind = TaskOriginKind::Person;
869 assert!(person.validate().is_err());
870 let mut contradiction = propose();
871 contradiction.origin = TaskOriginV1 {
872 kind: TaskOriginKind::Contradiction,
873 facts: vec!["f-1".into(), "f-1".into()],
874 message: None,
875 };
876 assert!(contradiction.validate().is_err());
877 contradiction.origin.facts[1] = "f-2".into();
878 assert!(contradiction.validate().is_ok());
879 }
880
881 #[test]
882 fn a_result_needs_evidence_that_fits_its_kind() {
883 let mut result = TaskResultRequestV1 {
884 schema: TASK_RESULT_SCHEMA.into(),
885 claim: claim(),
886 summary: Some("Done".into()),
887 evidence: Vec::new(),
888 draft: None,
889 sealed: None,
890 usage: None,
891 outcome: None,
892 };
893 assert!(result.validate().is_err(), "no evidence");
894 let r = |kind, reference: &str| EvidenceV1 {
895 kind,
896 reference: Some(reference.into()),
897 anchor: None,
898 };
899 for (evidence, ok) in [
900 (r(EvidenceKind::Commit, "9f01cf32a3"), true),
901 (r(EvidenceKind::Commit, "main"), false),
902 (r(EvidenceKind::Url, "http://example.com"), false),
903 (r(EvidenceKind::Receipt, &"a".repeat(64)), true),
904 (r(EvidenceKind::FileAnchor, "src/retry.rs"), false),
905 ] {
906 result.evidence = vec![evidence.clone()];
907 assert_eq!(result.validate().is_ok(), ok, "{evidence:?}");
908 }
909 result.claim.fencing_token = 0;
910 result.evidence = vec![r(EvidenceKind::Commit, "9f01cf32a3")];
911 assert!(result.validate().is_err(), "fencing tokens start at 1");
912 }
913
914 #[test]
915 fn summaries_are_one_line() {
916 let update = |summary: &str| TaskUpdateRequestV1 {
917 schema: TASK_UPDATE_SCHEMA.into(),
918 claim: claim(),
919 summary: Some(summary.into()),
920 sealed: None,
921 usage: None,
922 };
923 assert!(update("Reproduced the timeout").validate().is_ok());
924 assert!(update("line one\nline two").validate().is_err());
925 assert!(
926 update(&"x".repeat(MAX_SUMMARY_CHARS + 1))
927 .validate()
928 .is_err()
929 );
930 }
931
932 #[test]
933 fn decided_by_is_a_person_or_the_threshold() {
934 let person: DecidedByV1 =
935 serde_json::from_value(serde_json::json!({"kind": "person", "label": "Ada"})).unwrap();
936 assert_eq!(
937 person,
938 DecidedByV1::Person {
939 label: "Ada".into()
940 }
941 );
942 let threshold: DecidedByV1 =
943 serde_json::from_value(serde_json::json!({"kind": "threshold"})).unwrap();
944 assert_eq!(threshold, DecidedByV1::Threshold);
945 }
946
947 fn fixture(name: &str) -> serde_json::Value {
948 let path = format!("{}/tests/fixtures/hive/{name}", env!("CARGO_MANIFEST_DIR"));
949 serde_json::from_slice(&std::fs::read(path).unwrap()).unwrap()
950 }
951
952 #[test]
955 fn a_failed_result_names_its_outcome_and_brings_no_draft() {
956 let mut result: TaskResultRequestV1 =
957 serde_json::from_value(fixture("task_result.request.json")).unwrap();
958 assert_eq!(result.outcome, None);
959 assert!(
960 serde_json::to_value(&result)
961 .unwrap()
962 .get("outcome")
963 .is_none(),
964 "a completed result sends no outcome"
965 );
966 result.outcome = Some(TaskOutcome::Failed);
967 assert!(result.validate().is_ok());
968 assert_eq!(serde_json::to_value(&result).unwrap()["outcome"], "failed");
969 result.draft =
970 Some(serde_json::from_value(fixture("proposal.import.request.json")).unwrap());
971 assert_eq!(
972 result.validate().unwrap_err().to_string(),
973 "a failed result brings no draft"
974 );
975 result.outcome = Some(TaskOutcome::Completed);
976 assert!(
977 result.validate().is_ok(),
978 "a completed result may bring one"
979 );
980 }
981
982 #[test]
986 fn task_views_of_both_lines_parse() {
987 let old: TaskV1 = serde_json::from_value(fixture("task.done.response.json")).unwrap();
988 assert_eq!(old.lifecycle, None);
989 assert_eq!(old.result.as_ref().unwrap().seq, None);
990 assert!(
991 serde_json::to_value(&old)
992 .unwrap()
993 .get("lifecycle")
994 .is_none()
995 );
996
997 let mut v5 = fixture("task.done.response.json");
998 v5["state"] = "failed".into();
999 v5["lifecycle"] = "failed".into();
1000 v5["result"]["proposal"] = 0.into();
1001 v5["result"]["seq"] = 42.into();
1002 let v5: TaskV1 = serde_json::from_value(v5).unwrap();
1003 assert_eq!(v5.state, TaskState::Failed);
1004 assert_eq!(v5.lifecycle, Some(TaskLifecycle::Failed));
1005 let result = v5.result.as_ref().unwrap();
1006 assert_eq!((result.proposal, result.seq), (0, Some(42)));
1007 assert!(v5.validate().is_ok());
1008
1009 let mut list = fixture("task_list.response.json");
1010 list["tasks"][0]["lifecycle"] = "paused_someday".into();
1011 let list: TaskListV1 = serde_json::from_value(list).unwrap();
1012 assert_eq!(list.tasks[0].lifecycle, Some(TaskLifecycle::Unknown));
1013 let state: TaskState = serde_json::from_value("someday".into()).unwrap();
1014 assert_eq!(state, TaskState::Unknown);
1015 }
1016}