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_AMEND_SCHEMA: &str = "leanctx.hive.task-amend/v1";
104pub const TASK_FIELDS_SCHEMA: &str = "leanctx.hive.task-fields/v1";
106
107pub const MIN_TITLE_CHARS: usize = 5;
108pub const MAX_TITLE_CHARS: usize = 180;
109pub const MAX_GOAL_CHARS: usize = 2000;
110pub const MAX_ACCEPTANCE_CHARS: usize = 2000;
111pub const MAX_SUMMARY_CHARS: usize = 500;
113pub const MAX_REASON_CHARS: usize = 280;
114pub const MAX_VERIFY_REASON_CHARS: usize = 2000;
116pub const MAX_TASK_LABELS: usize = 16;
117pub const MAX_EVIDENCE: usize = 20;
118pub const MAX_EVIDENCE_REF_CHARS: usize = 512;
119pub const MAX_VIEW_UPDATES: usize = 20;
121pub const MAX_TASKS_PAGE: usize = 100;
122pub const MIN_CLAIM_MINUTES: u32 = 5;
125pub const MAX_CLAIM_MINUTES: u32 = 15;
126
127#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
129#[serde(rename_all = "snake_case")]
130pub enum TaskState {
131 Proposed,
133 Open,
134 Claimed,
135 Submitted,
137 Done,
138 Rejected,
140 Cancelled,
141 Failed,
143 #[serde(other)]
144 Unknown,
145}
146
147#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
151#[serde(rename_all = "snake_case")]
152pub enum TaskLifecycle {
153 Queued,
154 Claimed,
155 Running,
156 Submitted,
158 Completed,
159 Failed,
160 Cancelled,
161 #[serde(other)]
162 Unknown,
163}
164
165#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
168#[serde(rename_all = "snake_case")]
169pub enum TaskOutcome {
170 #[default]
171 Completed,
172 Failed,
173}
174
175#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
177#[serde(rename_all = "snake_case")]
178pub enum TaskOriginKind {
179 Person,
181 Agent,
183 Contradiction,
185 Question,
187 #[serde(other)]
189 Unknown,
190}
191
192#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
196#[serde(deny_unknown_fields)]
197pub struct TaskOriginV1 {
198 pub kind: TaskOriginKind,
199 #[serde(default, skip_serializing_if = "Vec::is_empty")]
200 pub facts: Vec<String>,
201 #[serde(default, skip_serializing_if = "Option::is_none")]
202 pub message: Option<String>,
203}
204
205impl TaskOriginV1 {
206 pub fn validate_from_engine(&self) -> Result<(), ValidationError> {
208 if self.message.is_some() {
209 return Err(ValidationError::new("engines do not create question tasks"));
210 }
211 match self.kind {
212 TaskOriginKind::Agent if self.facts.is_empty() => Ok(()),
213 TaskOriginKind::Agent => Err(ValidationError::new("an agent task names no facts")),
214 TaskOriginKind::Contradiction => {
215 if self.facts.len() != 2 || self.facts[0] == self.facts[1] {
216 return Err(ValidationError::new(
217 "a contradiction names exactly two distinct facts",
218 ));
219 }
220 self.facts
221 .iter()
222 .try_for_each(|id| validate_id(id, "facts"))
223 }
224 _ => Err(ValidationError::new(
225 "engines propose agent or contradiction tasks only",
226 )),
227 }
228 }
229}
230
231#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
233pub struct TaskActorV1 {
234 pub kind: TaskActorKind,
235 pub label: String,
237 #[serde(default, skip_serializing_if = "Option::is_none")]
239 pub agent: Option<String>,
240 #[serde(default, skip_serializing_if = "Option::is_none")]
242 pub owner: Option<String>,
243 #[serde(default, skip_serializing_if = "Option::is_none")]
245 pub model: Option<String>,
246 pub mine: bool,
248 #[serde(default, skip_serializing_if = "Option::is_none")]
250 pub profile_id: Option<String>,
251}
252
253#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
254#[serde(rename_all = "snake_case")]
255pub enum TaskActorKind {
256 Person,
257 Instance,
258 #[serde(other)]
259 Unknown,
260}
261
262#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
270#[serde(deny_unknown_fields)]
271pub struct EvidenceV1 {
272 pub kind: EvidenceKind,
273 #[serde(default, skip_serializing_if = "Option::is_none")]
274 pub reference: Option<String>,
275 #[serde(default, skip_serializing_if = "Option::is_none")]
276 pub anchor: Option<CodeAnchorV1>,
277}
278
279#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
280#[serde(rename_all = "snake_case")]
281pub enum EvidenceKind {
282 Receipt,
283 Commit,
284 CiRun,
285 FileAnchor,
286 Url,
287 Fact,
288 #[serde(other)]
289 Unknown,
290}
291
292impl EvidenceV1 {
293 pub fn validate(&self) -> Result<(), ValidationError> {
294 let refuse = || ValidationError::new(format!("evidence does not fit kind {:?}", self.kind));
295 if self.kind == EvidenceKind::FileAnchor {
296 return match (&self.reference, &self.anchor) {
297 (None, Some(anchor)) => anchor.validate(),
298 _ => Err(refuse()),
299 };
300 }
301 if self.anchor.is_some() {
302 return Err(refuse());
303 }
304 let reference = self.reference.as_deref().ok_or_else(refuse)?;
305 validate_text(
306 reference,
307 MAX_EVIDENCE_REF_CHARS,
308 "evidence.reference",
309 false,
310 )?;
311 let https = |r: &str| r.starts_with("https://") && !r.contains(char::is_whitespace);
312 let ok = match self.kind {
313 EvidenceKind::Receipt => validate_hex64(reference, "evidence.reference").is_ok(),
314 EvidenceKind::Commit => {
315 (7..=64).contains(&reference.len())
316 && reference
317 .bytes()
318 .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
319 }
320 EvidenceKind::CiRun => https(reference) || validate_id(reference, "ci_run").is_ok(),
321 EvidenceKind::Url => https(reference),
322 EvidenceKind::Fact => validate_id(reference, "fact").is_ok(),
323 EvidenceKind::FileAnchor | EvidenceKind::Unknown => false,
324 };
325 if ok { Ok(()) } else { Err(refuse()) }
326 }
327}
328
329fn validate_evidence(evidence: &[EvidenceV1]) -> Result<(), ValidationError> {
330 if evidence.is_empty() || evidence.len() > MAX_EVIDENCE {
331 return Err(ValidationError::new(format!(
332 "a result carries 1-{MAX_EVIDENCE} pieces of evidence"
333 )));
334 }
335 for (i, item) in evidence.iter().enumerate() {
336 item.validate()?;
337 if evidence[..i].contains(item) {
338 return Err(ValidationError::new("evidence repeats"));
339 }
340 }
341 Ok(())
342}
343
344fn validate_title(title: &str) -> Result<(), ValidationError> {
345 validate_text(title, MAX_TITLE_CHARS, "title", false)?;
346 if title.trim().chars().count() < MIN_TITLE_CHARS {
347 return Err(ValidationError::new(format!(
348 "title must have at least {MIN_TITLE_CHARS} characters"
349 )));
350 }
351 Ok(())
352}
353
354pub fn validate_role(role: &str) -> Result<(), ValidationError> {
356 let bytes = role.as_bytes();
357 if !bytes.is_empty()
358 && bytes.len() <= 64
359 && bytes[0].is_ascii_lowercase()
360 && bytes
361 .iter()
362 .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || *b == b'-')
363 {
364 Ok(())
365 } else {
366 Err(ValidationError::new(
367 "role must be 1-64 characters of [a-z0-9-] starting with a letter",
368 ))
369 }
370}
371
372fn text_required(plain: bool) -> Result<(), ValidationError> {
374 if plain {
375 Ok(())
376 } else {
377 Err(ValidationError::new("text is required"))
378 }
379}
380
381#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
383#[serde(deny_unknown_fields)]
384pub struct ClaimRefV1 {
385 pub lease_id: String,
386 pub fencing_token: u64,
387}
388
389impl ClaimRefV1 {
390 pub fn validate(&self) -> Result<(), ValidationError> {
391 validate_id(&self.lease_id, "lease_id")?;
392 if self.fencing_token == 0 {
393 return Err(ValidationError::new("fencing tokens start at 1"));
394 }
395 Ok(())
396 }
397}
398
399fn validate_minutes(minutes: u32) -> Result<(), ValidationError> {
400 if (MIN_CLAIM_MINUTES..=MAX_CLAIM_MINUTES).contains(&minutes) {
401 Ok(())
402 } else {
403 Err(ValidationError::new(format!(
404 "minutes must be {MIN_CLAIM_MINUTES}-{MAX_CLAIM_MINUTES}"
405 )))
406 }
407}
408
409#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
411#[serde(deny_unknown_fields)]
412pub struct TaskProposeRequestV1 {
413 pub schema: String,
414 #[serde(default, skip_serializing_if = "Option::is_none")]
415 pub title: Option<String>,
416 #[serde(default, skip_serializing_if = "Option::is_none")]
417 pub goal: Option<String>,
418 #[serde(default, skip_serializing_if = "Option::is_none")]
420 pub acceptance: Option<String>,
421 #[serde(default, skip_serializing_if = "Option::is_none")]
423 pub role: Option<String>,
424 pub origin: TaskOriginV1,
425 pub session_ref: String,
427}
428
429impl TaskProposeRequestV1 {
430 pub fn validate(&self) -> Result<(), ValidationError> {
431 require_schema(&self.schema, TASK_PROPOSE_SCHEMA)?;
432 self.origin.validate_from_engine()?;
433 validate_id(&self.session_ref, "session_ref")?;
434 if let Some(role) = &self.role {
435 validate_role(role)?;
436 }
437 let plain = self.title.is_some() || self.goal.is_some() || self.acceptance.is_some();
438 text_required(plain)?;
439 if plain {
440 validate_title(self.title.as_deref().unwrap_or_default())?;
441 if let Some(goal) = &self.goal {
442 validate_text(goal, MAX_GOAL_CHARS, "goal", true)?;
443 }
444 validate_text(
445 self.acceptance.as_deref().unwrap_or_default(),
446 MAX_ACCEPTANCE_CHARS,
447 "acceptance",
448 true,
449 )?;
450 }
451 Ok(())
452 }
453}
454
455#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
457#[serde(deny_unknown_fields)]
458pub struct TaskClaimRequestV1 {
459 pub schema: String,
460 pub agent: String,
462 pub session_ref: String,
463 pub minutes: u32,
464 #[serde(default, skip_serializing_if = "Option::is_none")]
468 pub optin_id: Option<String>,
469}
470
471impl TaskClaimRequestV1 {
472 pub fn validate(&self) -> Result<(), ValidationError> {
473 require_schema(&self.schema, TASK_CLAIM_REQUEST_SCHEMA)?;
474 validate_id(&self.agent, "agent")?;
475 validate_id(&self.session_ref, "session_ref")?;
476 if let Some(optin) = &self.optin_id {
477 validate_id(optin, "optin_id")?;
478 }
479 validate_minutes(self.minutes)
480 }
481}
482
483#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
486#[serde(deny_unknown_fields)]
487pub struct TaskClaimRenewRequestV1 {
488 pub schema: String,
489 pub claim: ClaimRefV1,
490 pub minutes: u32,
491}
492
493impl TaskClaimRenewRequestV1 {
494 pub fn validate(&self) -> Result<(), ValidationError> {
495 require_schema(&self.schema, TASK_CLAIM_RENEW_SCHEMA)?;
496 self.claim.validate()?;
497 validate_minutes(self.minutes)
498 }
499}
500
501#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
503pub struct TaskClaimV1 {
504 pub holder: TaskActorV1,
505 pub since: UtcTimestamp,
506 pub until: UtcTimestamp,
507}
508
509#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
511pub struct TaskClaimAckV1 {
512 pub schema: String,
513 pub number: u64,
514 pub claim: ClaimRefV1,
515 pub until: UtcTimestamp,
516}
517
518#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
520#[serde(deny_unknown_fields)]
521pub struct TaskReleaseRequestV1 {
522 pub schema: String,
523 pub claim: ClaimRefV1,
524 #[serde(default, skip_serializing_if = "Option::is_none")]
526 pub reason: Option<String>,
527}
528
529impl TaskReleaseRequestV1 {
530 pub fn validate(&self) -> Result<(), ValidationError> {
531 require_schema(&self.schema, TASK_RELEASE_SCHEMA)?;
532 self.claim.validate()?;
533 if let Some(reason) = &self.reason {
534 validate_text(reason, MAX_REASON_CHARS, "reason", false)?;
535 }
536 Ok(())
537 }
538}
539
540#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
544#[serde(deny_unknown_fields)]
545pub struct UsageV1 {
546 pub tokens: u64,
547 #[serde(default, skip_serializing_if = "Option::is_none")]
548 pub usd_micros: Option<u64>,
549}
550
551#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
553#[serde(deny_unknown_fields)]
554pub struct TaskUpdateRequestV1 {
555 pub schema: String,
556 pub claim: ClaimRefV1,
557 #[serde(default, skip_serializing_if = "Option::is_none")]
558 pub summary: Option<String>,
559 #[serde(default, skip_serializing_if = "Option::is_none")]
560 pub usage: Option<UsageV1>,
561}
562
563impl TaskUpdateRequestV1 {
564 pub fn validate(&self) -> Result<(), ValidationError> {
565 require_schema(&self.schema, TASK_UPDATE_SCHEMA)?;
566 self.claim.validate()?;
567 text_required(self.summary.is_some())?;
568 if let Some(summary) = &self.summary {
569 validate_text(summary, MAX_SUMMARY_CHARS, "summary", false)?;
570 }
571 Ok(())
572 }
573}
574
575#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
581#[serde(deny_unknown_fields)]
582pub struct TaskResultRequestV1 {
583 pub schema: String,
584 pub claim: ClaimRefV1,
585 #[serde(default, skip_serializing_if = "Option::is_none")]
586 pub summary: Option<String>,
587 #[serde(default, skip_serializing_if = "Vec::is_empty")]
588 pub evidence: Vec<EvidenceV1>,
589 #[serde(default, skip_serializing_if = "Option::is_none")]
590 pub draft: Option<DraftProposalV1>,
591 #[serde(default, skip_serializing_if = "Option::is_none")]
592 pub usage: Option<UsageV1>,
593 #[serde(default, skip_serializing_if = "Option::is_none")]
596 pub outcome: Option<TaskOutcome>,
597 #[serde(default, skip_serializing_if = "Option::is_none")]
602 pub gate_record: Option<super::gate::G6RecordV1>,
603}
604
605impl TaskResultRequestV1 {
606 pub fn validate(&self) -> Result<(), ValidationError> {
607 require_schema(&self.schema, TASK_RESULT_SCHEMA)?;
608 self.claim.validate()?;
609 let plain = self.summary.is_some() || !self.evidence.is_empty();
610 text_required(plain)?;
611 if plain {
612 validate_text(
613 self.summary.as_deref().unwrap_or_default(),
614 MAX_SUMMARY_CHARS,
615 "summary",
616 false,
617 )?;
618 validate_evidence(&self.evidence)?;
619 }
620 if let Some(draft) = &self.draft {
621 if self.outcome == Some(TaskOutcome::Failed) {
622 return Err(ValidationError::new("a failed result brings no draft"));
623 }
624 draft.validate()?;
625 }
626 if let Some(record) = &self.gate_record {
627 record.validate()?;
628 if !record.ready_for_hive() || self.outcome == Some(TaskOutcome::Failed) {
629 return Err(ValidationError::new(
630 "a result carries only a G6 record whose Instance checks hold, on a completed result",
631 ));
632 }
633 }
634 Ok(())
635 }
636}
637
638#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
640pub struct TaskAckV1 {
641 pub schema: String,
642 pub number: u64,
643 pub state: TaskState,
644}
645
646#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
649#[serde(rename_all = "snake_case")]
650pub enum TaskVerdict {
651 Accept,
652 Reject,
653 #[serde(other)]
654 Unknown,
655}
656
657#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
661#[serde(deny_unknown_fields)]
662pub struct TaskVerifyRequestV1 {
663 pub schema: String,
664 pub worker_id: String,
665 pub verdict: TaskVerdict,
666 #[serde(default, skip_serializing_if = "Option::is_none")]
667 pub reason: Option<String>,
668}
669
670fn validate_worker_id(worker_id: &str) -> Result<(), ValidationError> {
671 let worker_ok = (1..=128).contains(&worker_id.len())
672 && worker_id
673 .bytes()
674 .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'_' | b':' | b'-'));
675 if worker_ok {
676 Ok(())
677 } else {
678 Err(ValidationError::new(
679 "worker_id is 1-128 of [A-Za-z0-9._:-]",
680 ))
681 }
682}
683
684impl TaskVerifyRequestV1 {
685 pub fn validate(&self) -> Result<(), ValidationError> {
686 require_schema(&self.schema, TASK_VERIFY_SCHEMA)?;
687 validate_worker_id(&self.worker_id)?;
688 match (self.verdict, &self.reason) {
689 (TaskVerdict::Unknown, _) => Err(ValidationError::new("verdict is accept or reject")),
690 (TaskVerdict::Reject, None) => Err(ValidationError::new("a reject names its reason")),
691 (_, Some(reason)) => validate_text(reason, MAX_VERIFY_REASON_CHARS, "reason", false),
692 (TaskVerdict::Accept, None) => Ok(()),
693 }
694 }
695}
696
697#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
699pub struct TaskVerificationV1 {
700 pub verdict: TaskVerdict,
701 pub by: TaskActorV1,
702 pub at: UtcTimestamp,
703 #[serde(default, skip_serializing_if = "Option::is_none")]
704 pub reason: Option<String>,
705}
706
707#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
720#[serde(deny_unknown_fields)]
721pub struct TaskAmendRequestV1 {
722 pub schema: String,
723 pub worker_id: String,
724 #[serde(default, skip_serializing_if = "Option::is_none")]
725 pub summary: Option<String>,
726 #[serde(default, skip_serializing_if = "Vec::is_empty")]
727 pub evidence: Vec<EvidenceV1>,
728 #[serde(default, skip_serializing_if = "Option::is_none")]
730 pub merge_commit: Option<String>,
731 #[serde(default, skip_serializing_if = "Option::is_none")]
732 pub reason: Option<String>,
733}
734
735impl TaskAmendRequestV1 {
736 pub fn validate(&self) -> Result<(), ValidationError> {
737 require_schema(&self.schema, TASK_AMEND_SCHEMA)?;
738 validate_worker_id(&self.worker_id)?;
739 if let Some(reason) = &self.reason {
740 validate_text(reason, MAX_REASON_CHARS, "reason", false)?;
741 }
742 match &self.merge_commit {
743 Some(commit) => {
744 if !self.evidence.is_empty() || self.summary.is_some() {
745 return Err(ValidationError::new(
746 "a merge record carries only merge_commit (no evidence, no summary)",
747 ));
748 }
749 merge_evidence(commit).validate()
750 }
751 None => {
752 if let Some(summary) = &self.summary {
753 validate_text(summary, MAX_SUMMARY_CHARS, "summary", false)?;
754 }
755 validate_evidence(&self.evidence)
756 }
757 }
758 }
759}
760
761pub fn merge_evidence(commit: &str) -> EvidenceV1 {
763 EvidenceV1 {
764 kind: EvidenceKind::Commit,
765 reference: Some(commit.to_owned()),
766 anchor: None,
767 }
768}
769
770#[derive(Debug, Clone, Copy, PartialEq, Eq)]
772pub enum AmendRefusal {
773 NotAmendable,
776 RecordMergeOnly,
778 NotMergedYet,
781 MergeRecorded,
784}
785
786impl AmendRefusal {
787 pub fn code(self) -> &'static str {
788 match self {
789 Self::NotAmendable => "not_amendable",
790 Self::RecordMergeOnly => "record_merge_only",
791 Self::NotMergedYet => "not_merged_yet",
792 Self::MergeRecorded => "merge_recorded",
793 }
794 }
795}
796
797pub fn amend_rule(task: &TaskV1, request: &TaskAmendRequestV1) -> Result<(), AmendRefusal> {
803 let result = task.result.as_ref().ok_or(AmendRefusal::NotAmendable)?;
804 match (task.state, &request.merge_commit) {
805 (TaskState::Submitted, None) => Ok(()),
806 (TaskState::Submitted, Some(_)) => Err(AmendRefusal::NotMergedYet),
807 (TaskState::Done, Some(_)) if result.merge.is_some() => Err(AmendRefusal::MergeRecorded),
808 (TaskState::Done, Some(_)) => Ok(()),
809 (TaskState::Done, None) => Err(AmendRefusal::RecordMergeOnly),
810 _ => Err(AmendRefusal::NotAmendable),
811 }
812}
813
814#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
818pub struct AmendCaller {
819 pub submitter: bool,
820 pub reviewer: bool,
821 pub person: bool,
822 pub queen: bool,
823}
824
825#[derive(Debug, Clone, Copy, PartialEq, Eq)]
827pub enum AmendForbidden {
828 NotSubmitter,
830 NotMerger,
833}
834
835impl AmendForbidden {
836 pub fn code(self) -> &'static str {
837 match self {
838 Self::NotSubmitter => "not_submitter",
839 Self::NotMerger => "not_merger",
840 }
841 }
842}
843
844pub fn may_amend(request: &TaskAmendRequestV1, caller: AmendCaller) -> Result<(), AmendForbidden> {
848 if caller.submitter {
849 return Ok(());
850 }
851 if request.merge_commit.is_none() {
852 return Err(AmendForbidden::NotSubmitter);
853 }
854 if caller.reviewer {
855 return Err(AmendForbidden::NotMerger);
856 }
857 if caller.person || caller.queen {
858 Ok(())
859 } else {
860 Err(AmendForbidden::NotSubmitter)
861 }
862}
863
864#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
866pub struct TaskMergeV1 {
867 pub commit: String,
868 pub by: TaskActorV1,
869 pub at: UtcTimestamp,
870}
871
872#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
875#[serde(rename_all = "snake_case")]
876pub enum TaskUpdateKind {
877 Progress,
878 Verification,
879 Amendment,
882 #[serde(other)]
883 Unknown,
884}
885
886#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, Serialize, Deserialize)]
888#[serde(rename_all = "snake_case")]
889pub enum TaskPriority {
890 High,
891 #[default]
892 Normal,
893}
894
895#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
899#[serde(deny_unknown_fields)]
900pub struct TaskFieldsRequestV1 {
901 pub schema: String,
902 pub base_version: u32,
903 #[serde(default, skip_serializing_if = "Option::is_none")]
904 pub priority: Option<TaskPriority>,
905 #[serde(default, skip_serializing_if = "Option::is_none")]
906 pub labels: Option<Vec<String>>,
907}
908
909impl TaskFieldsRequestV1 {
910 pub fn validate(&self) -> Result<(), ValidationError> {
911 require_schema(&self.schema, TASK_FIELDS_SCHEMA)?;
912 if self.priority.is_none() && self.labels.is_none() {
913 return Err(ValidationError::new("set priority, labels or both"));
914 }
915 if let Some(labels) = &self.labels {
916 validate_labels(labels)?;
917 }
918 Ok(())
919 }
920}
921
922pub fn validate_labels(labels: &[String]) -> Result<(), ValidationError> {
924 if labels.len() > MAX_TASK_LABELS {
925 return Err(ValidationError::new("at most 16 labels"));
926 }
927 let mut seen = std::collections::BTreeSet::new();
928 for label in labels {
929 let ok = (1..=32).contains(&label.len())
930 && label
931 .bytes()
932 .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-');
933 if !ok {
934 return Err(ValidationError::new("a label is 1-32 of [a-z0-9-]"));
935 }
936 if !seen.insert(label) {
937 return Err(ValidationError::new("labels are unique"));
938 }
939 }
940 Ok(())
941}
942
943#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
945pub struct TaskUpdateV1 {
946 pub by: TaskActorV1,
947 pub at: UtcTimestamp,
948 #[serde(default, skip_serializing_if = "Option::is_none")]
949 pub summary: Option<String>,
950 #[serde(default, skip_serializing_if = "Option::is_none")]
951 pub usage: Option<UsageV1>,
952 #[serde(default, skip_serializing_if = "Option::is_none")]
954 pub kind: Option<TaskUpdateKind>,
955 #[serde(default, skip_serializing_if = "Option::is_none")]
957 pub verdict: Option<TaskVerdict>,
958 #[serde(default, skip_serializing_if = "Vec::is_empty")]
961 pub amended_from: Vec<EvidenceViewV1>,
962 #[serde(default, skip_serializing_if = "Vec::is_empty")]
965 pub amended_to: Vec<EvidenceViewV1>,
966}
967
968#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
971pub struct EvidenceViewV1 {
972 pub kind: EvidenceKind,
973 #[serde(default, skip_serializing_if = "Option::is_none")]
974 pub reference: Option<String>,
975 #[serde(default, skip_serializing_if = "Option::is_none")]
976 pub anchor: Option<CodeAnchorV1>,
977 pub verified: bool,
978}
979
980#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
982pub struct TaskResultV1 {
983 pub by: TaskActorV1,
984 pub at: UtcTimestamp,
985 pub proposal: u64,
988 #[serde(default, skip_serializing_if = "Option::is_none")]
990 pub seq: Option<u64>,
991 #[serde(default, skip_serializing_if = "Option::is_none")]
992 pub summary: Option<String>,
993 #[serde(default, skip_serializing_if = "Vec::is_empty")]
994 pub evidence: Vec<EvidenceViewV1>,
995 #[serde(default, skip_serializing_if = "Option::is_none")]
996 pub usage: Option<UsageV1>,
997 #[serde(default, skip_serializing_if = "Option::is_none")]
999 pub merge: Option<TaskMergeV1>,
1000}
1001
1002#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1004#[serde(tag = "kind", rename_all = "snake_case")]
1005pub enum DecidedByV1 {
1006 Person {
1007 label: String,
1008 },
1009 Threshold,
1010 #[serde(other)]
1011 Unknown,
1012}
1013
1014#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1015#[serde(rename_all = "snake_case")]
1016pub enum TaskDecisionOutcome {
1017 Accepted,
1018 Declined,
1019 #[serde(other)]
1020 Unknown,
1021}
1022
1023#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1025pub struct TaskDecisionV1 {
1026 pub outcome: TaskDecisionOutcome,
1027 pub decided_by: DecidedByV1,
1028 pub at: UtcTimestamp,
1029 #[serde(default, skip_serializing_if = "Option::is_none")]
1030 pub reason: Option<String>,
1031}
1032
1033#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1035pub struct TaskV1 {
1036 pub schema: String,
1037 pub vault_id: String,
1038 pub number: u64,
1039 pub state: TaskState,
1040 #[serde(default, skip_serializing_if = "Option::is_none")]
1042 pub lifecycle: Option<TaskLifecycle>,
1043 #[serde(default, skip_serializing_if = "Option::is_none")]
1045 pub title: Option<String>,
1046 #[serde(default, skip_serializing_if = "Option::is_none")]
1047 pub goal: Option<String>,
1048 #[serde(default, skip_serializing_if = "Option::is_none")]
1049 pub acceptance: Option<String>,
1050 #[serde(default, skip_serializing_if = "Option::is_none")]
1051 pub role: Option<String>,
1052 pub origin: TaskOriginV1,
1053 pub created_by: TaskActorV1,
1054 pub created_at: UtcTimestamp,
1055 #[serde(default, skip_serializing_if = "Option::is_none")]
1057 pub approved_by: Option<TaskActorV1>,
1058 #[serde(default, skip_serializing_if = "Option::is_none")]
1059 pub claim: Option<TaskClaimV1>,
1060 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1062 pub updates: Vec<TaskUpdateV1>,
1063 #[serde(default, skip_serializing_if = "Option::is_none")]
1064 pub result: Option<TaskResultV1>,
1065 #[serde(default, skip_serializing_if = "Option::is_none")]
1066 pub decision: Option<TaskDecisionV1>,
1067 #[serde(default, skip_serializing_if = "Option::is_none")]
1069 pub verified_by: Option<TaskActorV1>,
1070 #[serde(default, skip_serializing_if = "Option::is_none")]
1071 pub verified_at: Option<UtcTimestamp>,
1072 #[serde(default, skip_serializing_if = "Option::is_none")]
1074 pub verification: Option<TaskVerificationV1>,
1075 #[serde(default, skip_serializing_if = "Option::is_none")]
1077 pub priority: Option<TaskPriority>,
1078 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1079 pub labels: Vec<String>,
1080 #[serde(default, skip_serializing_if = "Option::is_none")]
1083 pub fields_version: Option<u32>,
1084 #[serde(default, skip_serializing_if = "Option::is_none")]
1087 pub process: Option<super::gate::TaskProcessV1>,
1088}
1089
1090impl TaskV1 {
1091 pub fn validate(&self) -> Result<(), ValidationError> {
1092 require_schema(&self.schema, TASK_SCHEMA)?;
1093 super::validate_vault_id(&self.vault_id)?;
1094 if self.number == 0 {
1095 return Err(ValidationError::new("task numbers start at 1"));
1096 }
1097 if (self.state == TaskState::Claimed) != self.claim.is_some() {
1098 return Err(ValidationError::new(
1099 "a task carries a claim exactly while it is claimed",
1100 ));
1101 }
1102 if self.updates.len() > MAX_VIEW_UPDATES {
1103 return Err(ValidationError::new("too many updates in a task view"));
1104 }
1105 if let Some(approver) = &self.approved_by
1106 && approver.kind != TaskActorKind::Person
1107 {
1108 return Err(ValidationError::new("only a person approves a task"));
1109 }
1110 if matches!(self.state, TaskState::Submitted | TaskState::Done) && self.result.is_none() {
1111 return Err(ValidationError::new(
1112 "a submitted or done task carries its result",
1113 ));
1114 }
1115 Ok(())
1116 }
1117}
1118
1119#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1121pub struct TaskSummaryV1 {
1122 pub number: u64,
1123 pub state: TaskState,
1124 #[serde(default, skip_serializing_if = "Option::is_none")]
1126 pub lifecycle: Option<TaskLifecycle>,
1127 #[serde(default, skip_serializing_if = "Option::is_none")]
1128 pub title: Option<String>,
1129 #[serde(default, skip_serializing_if = "Option::is_none")]
1130 pub role: Option<String>,
1131 pub origin: TaskOriginKind,
1132 pub updated_at: UtcTimestamp,
1133 pub mine: bool,
1135 #[serde(default, skip_serializing_if = "Option::is_none")]
1137 pub priority: Option<TaskPriority>,
1138 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1139 pub labels: Vec<String>,
1140 #[serde(default, skip_serializing_if = "Option::is_none")]
1141 pub fields_version: Option<u32>,
1142}
1143
1144#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1146pub struct TaskListV1 {
1147 pub schema: String,
1148 pub vault_id: String,
1149 pub tasks: Vec<TaskSummaryV1>,
1150}
1151
1152impl TaskListV1 {
1153 pub fn validate(&self) -> Result<(), ValidationError> {
1154 require_schema(&self.schema, TASK_LIST_SCHEMA)?;
1155 super::validate_vault_id(&self.vault_id)?;
1156 if self.tasks.len() > MAX_TASKS_PAGE {
1157 return Err(ValidationError::new("too many tasks in one page"));
1158 }
1159 Ok(())
1160 }
1161}
1162
1163#[cfg(test)]
1164mod tests {
1165 use super::*;
1166
1167 fn propose() -> TaskProposeRequestV1 {
1168 TaskProposeRequestV1 {
1169 schema: TASK_PROPOSE_SCHEMA.into(),
1170 title: Some("Explain the retry budget".into()),
1171 goal: None,
1172 acceptance: Some("A fact with an anchor on retry.rs".into()),
1173 role: None,
1174 origin: TaskOriginV1 {
1175 kind: TaskOriginKind::Agent,
1176 facts: Vec::new(),
1177 message: None,
1178 },
1179 session_ref: "s-1".into(),
1180 }
1181 }
1182
1183 fn claim() -> ClaimRefV1 {
1184 ClaimRefV1 {
1185 lease_id: "lease-1".into(),
1186 fencing_token: 3,
1187 }
1188 }
1189
1190 #[test]
1191 fn an_unknown_task_origin_parses_as_unknown() {
1192 let origin: TaskOriginV1 = serde_json::from_value(serde_json::json!({
1193 "kind": "quarantine_timeout", "facts": ["f-1"],
1194 }))
1195 .unwrap();
1196 assert_eq!(origin.kind, TaskOriginKind::Unknown);
1197 }
1198
1199 #[test]
1200 fn engines_propose_only_agent_or_contradiction_tasks() {
1201 assert!(propose().validate().is_ok());
1202 let mut person = propose();
1203 person.origin.kind = TaskOriginKind::Person;
1204 assert!(person.validate().is_err());
1205 let mut contradiction = propose();
1206 contradiction.origin = TaskOriginV1 {
1207 kind: TaskOriginKind::Contradiction,
1208 facts: vec!["f-1".into(), "f-1".into()],
1209 message: None,
1210 };
1211 assert!(contradiction.validate().is_err());
1212 contradiction.origin.facts[1] = "f-2".into();
1213 assert!(contradiction.validate().is_ok());
1214 }
1215
1216 #[test]
1217 fn a_result_needs_evidence_that_fits_its_kind() {
1218 let mut result = TaskResultRequestV1 {
1219 schema: TASK_RESULT_SCHEMA.into(),
1220 claim: claim(),
1221 summary: Some("Done".into()),
1222 evidence: Vec::new(),
1223 draft: None,
1224 usage: None,
1225 outcome: None,
1226 gate_record: None,
1227 };
1228 assert!(result.validate().is_err(), "no evidence");
1229 let r = |kind, reference: &str| EvidenceV1 {
1230 kind,
1231 reference: Some(reference.into()),
1232 anchor: None,
1233 };
1234 for (evidence, ok) in [
1235 (r(EvidenceKind::Commit, "9f01cf32a3"), true),
1236 (r(EvidenceKind::Commit, "main"), false),
1237 (r(EvidenceKind::Url, "http://example.com"), false),
1238 (r(EvidenceKind::Receipt, &"a".repeat(64)), true),
1239 (r(EvidenceKind::FileAnchor, "src/retry.rs"), false),
1240 ] {
1241 result.evidence = vec![evidence.clone()];
1242 assert_eq!(result.validate().is_ok(), ok, "{evidence:?}");
1243 }
1244 result.claim.fencing_token = 0;
1245 result.evidence = vec![r(EvidenceKind::Commit, "9f01cf32a3")];
1246 assert!(result.validate().is_err(), "fencing tokens start at 1");
1247 }
1248
1249 #[test]
1250 fn summaries_are_one_line() {
1251 let update = |summary: &str| TaskUpdateRequestV1 {
1252 schema: TASK_UPDATE_SCHEMA.into(),
1253 claim: claim(),
1254 summary: Some(summary.into()),
1255 usage: None,
1256 };
1257 assert!(update("Reproduced the timeout").validate().is_ok());
1258 assert!(update("line one\nline two").validate().is_err());
1259 assert!(
1260 update(&"x".repeat(MAX_SUMMARY_CHARS + 1))
1261 .validate()
1262 .is_err()
1263 );
1264 }
1265
1266 #[test]
1267 fn decided_by_is_a_person_or_the_threshold() {
1268 let person: DecidedByV1 =
1269 serde_json::from_value(serde_json::json!({"kind": "person", "label": "Ada"})).unwrap();
1270 assert_eq!(
1271 person,
1272 DecidedByV1::Person {
1273 label: "Ada".into()
1274 }
1275 );
1276 let threshold: DecidedByV1 =
1277 serde_json::from_value(serde_json::json!({"kind": "threshold"})).unwrap();
1278 assert_eq!(threshold, DecidedByV1::Threshold);
1279 }
1280
1281 fn fixture(name: &str) -> serde_json::Value {
1282 let path = format!("{}/tests/fixtures/hive/{name}", env!("CARGO_MANIFEST_DIR"));
1283 serde_json::from_slice(&std::fs::read(path).unwrap()).unwrap()
1284 }
1285
1286 #[test]
1289 fn a_failed_result_names_its_outcome_and_brings_no_draft() {
1290 let mut result: TaskResultRequestV1 =
1291 serde_json::from_value(fixture("task_result.request.json")).unwrap();
1292 assert_eq!(result.outcome, None);
1293 assert!(
1294 serde_json::to_value(&result)
1295 .unwrap()
1296 .get("outcome")
1297 .is_none(),
1298 "a completed result sends no outcome"
1299 );
1300 result.outcome = Some(TaskOutcome::Failed);
1301 assert!(result.validate().is_ok());
1302 assert_eq!(serde_json::to_value(&result).unwrap()["outcome"], "failed");
1303 result.draft =
1304 Some(serde_json::from_value(fixture("proposal.import.request.json")).unwrap());
1305 assert_eq!(
1306 result.validate().unwrap_err().to_string(),
1307 "a failed result brings no draft"
1308 );
1309 result.outcome = Some(TaskOutcome::Completed);
1310 assert!(
1311 result.validate().is_ok(),
1312 "a completed result may bring one"
1313 );
1314 }
1315
1316 #[test]
1320 fn task_views_of_both_lines_parse() {
1321 let old: TaskV1 = serde_json::from_value(fixture("task.done.response.json")).unwrap();
1322 assert_eq!(old.lifecycle, None);
1323 assert_eq!(old.result.as_ref().unwrap().seq, None);
1324 assert!(
1325 serde_json::to_value(&old)
1326 .unwrap()
1327 .get("lifecycle")
1328 .is_none()
1329 );
1330
1331 let mut v5 = fixture("task.done.response.json");
1332 v5["state"] = "failed".into();
1333 v5["lifecycle"] = "failed".into();
1334 v5["result"]["proposal"] = 0.into();
1335 v5["result"]["seq"] = 42.into();
1336 let v5: TaskV1 = serde_json::from_value(v5).unwrap();
1337 assert_eq!(v5.state, TaskState::Failed);
1338 assert_eq!(v5.lifecycle, Some(TaskLifecycle::Failed));
1339 let result = v5.result.as_ref().unwrap();
1340 assert_eq!((result.proposal, result.seq), (0, Some(42)));
1341 assert!(v5.validate().is_ok());
1342
1343 let mut list = fixture("task_list.response.json");
1344 list["tasks"][0]["lifecycle"] = "paused_someday".into();
1345 let list: TaskListV1 = serde_json::from_value(list).unwrap();
1346 assert_eq!(list.tasks[0].lifecycle, Some(TaskLifecycle::Unknown));
1347 let state: TaskState = serde_json::from_value("someday".into()).unwrap();
1348 assert_eq!(state, TaskState::Unknown);
1349 }
1350
1351 fn v2_route(name: &str) -> serde_json::Value {
1352 let path = format!(
1353 "{}/tests/fixtures/hive-v2/routes/{name}.json",
1354 env!("CARGO_MANIFEST_DIR")
1355 );
1356 serde_json::from_str(&std::fs::read_to_string(path).unwrap()).unwrap()
1357 }
1358
1359 #[test]
1363 fn the_c0_2_2_task_shapes_hold() {
1364 for name in ["task_verify.request", "task_verify.reject.request"] {
1365 let request: TaskVerifyRequestV1 = serde_json::from_value(v2_route(name)).unwrap();
1366 request.validate().unwrap_or_else(|e| panic!("{name}: {e}"));
1367 assert_eq!(serde_json::to_value(&request).unwrap(), v2_route(name));
1368 }
1369 let mut reject: TaskVerifyRequestV1 =
1370 serde_json::from_value(v2_route("task_verify.reject.request")).unwrap();
1371 reject.reason = None;
1372 assert_eq!(
1373 reject.validate().unwrap_err().to_string(),
1374 "a reject names its reason"
1375 );
1376 for (name, state) in [
1377 ("task_verify.response", TaskState::Done),
1378 ("task_verify.reject.response", TaskState::Open),
1379 ("task_result.submitted.response", TaskState::Submitted),
1380 ] {
1381 let ack: TaskAckV1 = serde_json::from_value(v2_route(name)).unwrap();
1382 assert_eq!(ack.state, state, "{name}");
1383 }
1384 let fields: TaskFieldsRequestV1 =
1385 serde_json::from_value(v2_route("task_fields.request")).unwrap();
1386 fields.validate().unwrap();
1387 assert_eq!(fields.priority, Some(TaskPriority::High));
1388 let empty = TaskFieldsRequestV1 {
1389 labels: None,
1390 priority: None,
1391 ..fields.clone()
1392 };
1393 assert!(empty.validate().is_err());
1394 let bad = TaskFieldsRequestV1 {
1395 labels: Some(vec!["Docs".into()]),
1396 ..fields
1397 };
1398 assert!(bad.validate().is_err());
1399 }
1400}