1use std::collections::BTreeMap;
13use std::path::PathBuf;
14
15use serde::{Deserialize, Deserializer, Serialize, Serializer, de};
16use serde_json::Value;
17
18pub const FLEET_PROTOCOL_VERSION: &str = "0.1.0";
19
20#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)]
22pub struct FleetRunId(pub String);
23
24impl From<String> for FleetRunId {
25 fn from(value: String) -> Self {
26 Self(value)
27 }
28}
29
30impl From<&str> for FleetRunId {
31 fn from(value: &str) -> Self {
32 Self(value.to_string())
33 }
34}
35
36#[derive(Debug, Clone, Serialize, Deserialize)]
38pub struct FleetRun {
39 pub id: FleetRunId,
40 pub name: String,
41 pub status: FleetRunStatus,
42 #[serde(default)]
43 pub task_specs: Vec<FleetTaskSpec>,
44 #[serde(default)]
45 pub worker_specs: Vec<FleetWorkerSpec>,
46 #[serde(default)]
47 pub labels: BTreeMap<String, String>,
48 #[serde(skip_serializing_if = "Option::is_none")]
49 pub security_policy: Option<FleetSecurityPolicy>,
50 pub created_at: String,
51 #[serde(skip_serializing_if = "Option::is_none")]
52 pub updated_at: Option<String>,
53 #[serde(skip_serializing_if = "Option::is_none")]
54 pub completed_at: Option<String>,
55}
56
57#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
59#[serde(rename_all = "snake_case")]
60pub enum FleetRunStatus {
61 Pending,
62 Queued,
63 Running,
64 Paused,
65 Completed,
66 Failed,
67 Cancelled,
68}
69
70#[derive(Debug, Clone, Serialize, Deserialize)]
72pub struct FleetTaskSpec {
73 pub id: String,
74 pub name: String,
75 #[serde(skip_serializing_if = "Option::is_none")]
76 pub description: Option<String>,
77 #[serde(skip_serializing_if = "Option::is_none")]
78 pub objective: Option<String>,
79 pub instructions: String,
80 #[serde(skip_serializing_if = "Option::is_none")]
81 pub worker: Option<FleetTaskWorkerProfile>,
82 #[serde(skip_serializing_if = "Option::is_none")]
83 pub workspace: Option<FleetWorkspaceRequirements>,
84 #[serde(default)]
85 #[serde(skip_serializing_if = "Vec::is_empty")]
86 pub input_files: Vec<PathBuf>,
87 #[serde(default)]
88 #[serde(skip_serializing_if = "Vec::is_empty")]
89 pub context: Vec<String>,
90 #[serde(skip_serializing_if = "Option::is_none")]
91 pub budget: Option<FleetTaskBudget>,
92 #[serde(default)]
93 #[serde(skip_serializing_if = "Vec::is_empty")]
94 pub tags: Vec<String>,
95 #[serde(default)]
96 pub expected_artifacts: Vec<FleetArtifactKind>,
97 #[serde(skip_serializing_if = "Option::is_none")]
98 pub scorer: Option<FleetScorerSpec>,
99 #[serde(skip_serializing_if = "Option::is_none")]
100 pub retry_policy: Option<FleetRetryPolicy>,
101 #[serde(skip_serializing_if = "Option::is_none")]
102 pub alert_policy: Option<FleetAlertPolicy>,
103 #[serde(default)]
104 pub timeout_seconds: Option<u64>,
105 #[serde(default)]
106 pub metadata: BTreeMap<String, Value>,
107}
108
109#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
111pub struct FleetTaskWorkerProfile {
112 #[serde(default, alias = "profile", skip_serializing_if = "Option::is_none")]
118 pub agent_profile: Option<String>,
119 #[serde(skip_serializing_if = "Option::is_none")]
120 pub role: Option<String>,
121 #[serde(default, skip_serializing_if = "Option::is_none")]
126 pub loadout: Option<String>,
127 #[serde(default, skip_serializing_if = "Option::is_none")]
129 pub model_class: Option<String>,
130 #[serde(default, skip_serializing_if = "Option::is_none")]
136 pub model: Option<String>,
137 #[serde(skip_serializing_if = "Option::is_none")]
138 pub tool_profile: Option<String>,
139 #[serde(default)]
140 #[serde(skip_serializing_if = "Vec::is_empty")]
141 pub tools: Vec<String>,
142 #[serde(default)]
143 #[serde(skip_serializing_if = "Vec::is_empty")]
144 pub capabilities: Vec<String>,
145}
146
147#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
149pub struct FleetWorkspaceRequirements {
150 #[serde(skip_serializing_if = "Option::is_none")]
151 pub root: Option<PathBuf>,
152 #[serde(default)]
153 #[serde(skip_serializing_if = "Vec::is_empty")]
154 pub required_files: Vec<PathBuf>,
155 #[serde(default)]
156 #[serde(skip_serializing_if = "Vec::is_empty")]
157 pub writable_paths: Vec<PathBuf>,
158 #[serde(skip_serializing_if = "Option::is_none")]
159 pub environment: Option<FleetEnvironmentRequirements>,
160}
161
162#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
164pub struct FleetEnvironmentRequirements {
165 #[serde(default)]
166 #[serde(skip_serializing_if = "Vec::is_empty")]
167 pub required: Vec<String>,
168 #[serde(default)]
169 #[serde(skip_serializing_if = "Vec::is_empty")]
170 pub allowlist: Vec<String>,
171}
172
173#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
175pub struct FleetTaskBudget {
176 #[serde(skip_serializing_if = "Option::is_none")]
177 pub max_tokens: Option<u64>,
178 #[serde(skip_serializing_if = "Option::is_none")]
179 pub max_tool_calls: Option<u32>,
180 #[serde(skip_serializing_if = "Option::is_none")]
181 pub max_seconds: Option<u64>,
182}
183
184#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
186pub struct FleetArtifactRef {
187 pub kind: FleetArtifactKind,
188 pub path: PathBuf,
189 #[serde(skip_serializing_if = "Option::is_none")]
190 pub checksum: Option<String>,
191 #[serde(skip_serializing_if = "Option::is_none")]
192 pub mime_type: Option<String>,
193 #[serde(default)]
194 pub size_bytes: Option<u64>,
195}
196
197#[derive(Debug, Clone, PartialEq, Eq)]
199pub enum FleetArtifactKind {
200 Log,
201 Patch,
202 TestResult,
203 Report,
204 Checkpoint,
205 Receipt,
206 Other(String),
207}
208
209impl FleetArtifactKind {
210 fn as_wire_str(&self) -> &str {
211 match self {
212 Self::Log => "log",
213 Self::Patch => "patch",
214 Self::TestResult => "test_result",
215 Self::Report => "report",
216 Self::Checkpoint => "checkpoint",
217 Self::Receipt => "receipt",
218 Self::Other(kind) => kind.as_str(),
219 }
220 }
221
222 fn from_wire_str(value: &str) -> Self {
223 match value {
224 "log" => Self::Log,
225 "patch" => Self::Patch,
226 "test_result" => Self::TestResult,
227 "report" => Self::Report,
228 "checkpoint" => Self::Checkpoint,
229 "receipt" => Self::Receipt,
230 other => Self::Other(other.to_string()),
231 }
232 }
233}
234
235impl Serialize for FleetArtifactKind {
236 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
237 where
238 S: Serializer,
239 {
240 serializer.serialize_str(self.as_wire_str())
241 }
242}
243
244impl<'de> Deserialize<'de> for FleetArtifactKind {
245 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
246 where
247 D: Deserializer<'de>,
248 {
249 let value = String::deserialize(deserializer)?;
250 Ok(Self::from_wire_str(&value))
251 }
252}
253
254#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
256#[serde(tag = "kind", rename_all = "snake_case")]
257pub enum FleetScorerSpec {
258 ExitCode,
259 FileExists {
260 path: PathBuf,
261 },
262 RegexMatch {
263 path: PathBuf,
264 pattern: String,
265 },
266 JsonPath {
267 path: PathBuf,
268 expression: String,
269 },
270 Command {
271 command: String,
272 #[serde(default)]
273 args: Vec<String>,
274 },
275 CodeWhaleVerifierPrompt {
276 prompt: String,
277 },
278 Manual,
279}
280
281#[derive(Debug, Clone, Serialize, Deserialize)]
283pub struct FleetWorkerSpec {
284 pub id: String,
285 pub name: String,
286 pub host: FleetHostSpec,
287 #[serde(default)]
288 #[serde(skip_serializing_if = "Option::is_none")]
289 pub trust_level: Option<FleetTrustLevel>,
290 #[serde(default)]
291 pub labels: BTreeMap<String, String>,
292 #[serde(default)]
293 pub capabilities: Vec<String>,
294 #[serde(skip_serializing_if = "Option::is_none")]
295 pub max_concurrent_tasks: Option<usize>,
296}
297
298#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
300#[serde(tag = "kind", rename_all = "snake_case")]
301pub enum FleetHostSpec {
302 Local,
303 Ssh {
304 host: String,
305 #[serde(skip_serializing_if = "Option::is_none")]
306 port: Option<u16>,
307 #[serde(skip_serializing_if = "Option::is_none")]
308 user: Option<String>,
309 #[serde(skip_serializing_if = "Option::is_none")]
310 identity: Option<PathBuf>,
311 #[serde(skip_serializing_if = "Option::is_none")]
313 known_hosts: Option<PathBuf>,
314 #[serde(skip_serializing_if = "Option::is_none")]
318 host_key_fingerprint: Option<String>,
319 #[serde(skip_serializing_if = "Option::is_none")]
320 working_directory: Option<PathBuf>,
321 #[serde(default)]
322 #[serde(skip_serializing_if = "Vec::is_empty")]
323 env_allowlist: Vec<String>,
324 #[serde(skip_serializing_if = "Option::is_none")]
325 codewhale_binary: Option<String>,
326 },
327 #[serde(alias = "container")]
328 #[serde(alias = "Container")]
329 Docker {
330 image: String,
331 #[serde(default)]
332 args: Vec<String>,
333 },
334}
335
336#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord, Default)]
344#[serde(rename_all = "snake_case")]
345pub enum FleetTrustLevel {
346 #[default]
349 Sandbox = 0,
350 Local = 1,
353 #[serde(alias = "remote-verified", alias = "remoteVerified")]
357 RemoteVerified = 2,
358 Operator = 3,
362}
363
364impl FleetTrustLevel {
365 #[must_use]
367 pub fn may_access_secrets(&self) -> bool {
368 matches!(self, Self::Operator | Self::RemoteVerified | Self::Local)
369 }
370
371 #[must_use]
373 pub fn may_write_workspace(&self) -> bool {
374 matches!(self, Self::Operator | Self::Local)
375 }
376
377 #[must_use]
379 pub fn may_access_network(&self) -> bool {
380 matches!(self, Self::Operator | Self::RemoteVerified | Self::Local)
381 }
382}
383
384#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
390pub struct FleetSecurityPolicy {
391 #[serde(default)]
393 pub default_trust_level: FleetTrustLevel,
394 #[serde(default)]
397 #[serde(skip_serializing_if = "Vec::is_empty")]
398 pub allowed_secrets: Vec<FleetSecretRef>,
399 #[serde(default)]
401 #[serde(skip_serializing_if = "Vec::is_empty")]
402 pub capability_grants: Vec<FleetCapabilityGrant>,
403 #[serde(default = "default_max_trust_level")]
406 pub max_trust_level: FleetTrustLevel,
407 #[serde(default)]
411 pub require_identity_verification: bool,
412 #[serde(default)]
417 pub allow_parallel_reads: bool,
418}
419
420fn default_max_trust_level() -> FleetTrustLevel {
421 FleetTrustLevel::Operator
422}
423
424impl Default for FleetSecurityPolicy {
425 fn default() -> Self {
426 Self {
427 default_trust_level: FleetTrustLevel::Sandbox,
428 allowed_secrets: Vec::new(),
429 capability_grants: Vec::new(),
430 max_trust_level: FleetTrustLevel::Operator,
431 require_identity_verification: false,
432 allow_parallel_reads: false,
433 }
434 }
435}
436
437#[derive(Debug, Clone, Serialize, PartialEq, Eq, Hash, PartialOrd, Ord)]
445pub struct FleetSecretRef {
446 pub key: String,
448 #[serde(skip_serializing_if = "Option::is_none")]
454 pub source: Option<String>,
455}
456
457impl FleetSecretRef {
458 #[must_use]
460 pub fn new(key: impl Into<String>) -> Self {
461 Self {
462 key: key.into(),
463 source: None,
464 }
465 }
466
467 #[must_use]
469 pub fn with_source(key: impl Into<String>, source: impl Into<String>) -> Self {
470 Self {
471 key: key.into(),
472 source: Some(source.into()),
473 }
474 }
475
476 #[must_use]
479 pub fn redacted(&self) -> String {
480 match &self.source {
481 Some(src) => format!("<secret:{}.{}>", src, self.key),
482 None => format!("<secret:{}>", self.key),
483 }
484 }
485}
486
487impl std::fmt::Display for FleetSecretRef {
488 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
489 write!(f, "{}", self.redacted())
490 }
491}
492
493impl From<&str> for FleetSecretRef {
494 fn from(key: &str) -> Self {
495 Self::new(key)
496 }
497}
498
499impl From<String> for FleetSecretRef {
500 fn from(key: String) -> Self {
501 Self::new(key)
502 }
503}
504
505impl<'de> Deserialize<'de> for FleetSecretRef {
506 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
507 where
508 D: Deserializer<'de>,
509 {
510 #[derive(Deserialize)]
511 #[serde(untagged)]
512 enum SecretRefWire {
513 Key(String),
514 Structured {
515 key: String,
516 #[serde(default)]
517 source: Option<String>,
518 },
519 }
520
521 match SecretRefWire::deserialize(deserializer)? {
522 SecretRefWire::Key(key) if !key.trim().is_empty() => Ok(FleetSecretRef::new(key)),
523 SecretRefWire::Key(_) => Err(de::Error::custom("secret ref key cannot be empty")),
524 SecretRefWire::Structured { key, source } if !key.trim().is_empty() => {
525 Ok(FleetSecretRef { key, source })
526 }
527 SecretRefWire::Structured { .. } => {
528 Err(de::Error::custom("secret ref key cannot be empty"))
529 }
530 }
531 }
532}
533
534#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
536#[serde(tag = "method", rename_all = "snake_case")]
537pub enum FleetWorkerAuth {
538 None,
540 SshKey {
542 identity: PathBuf,
545 #[serde(skip_serializing_if = "Option::is_none")]
547 known_hosts: Option<PathBuf>,
548 #[serde(skip_serializing_if = "Option::is_none")]
550 host_key_fingerprint: Option<String>,
551 #[serde(skip_serializing_if = "Option::is_none")]
553 user: Option<String>,
554 },
555 Token {
557 token_ref: FleetSecretRef,
559 },
560 Mtls {
562 cert_path: PathBuf,
564 key_ref: FleetSecretRef,
566 },
567}
568
569#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
576pub struct FleetCapabilityGrant {
577 pub capability: String,
580 #[serde(skip_serializing_if = "Option::is_none")]
583 pub scope: Option<String>,
584 #[serde(skip_serializing_if = "Option::is_none")]
586 pub reason: Option<String>,
587}
588
589#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
591#[serde(rename_all = "snake_case")]
592pub enum FleetWorkerStatus {
593 Unknown,
594 Online,
595 Busy,
596 Offline,
597 Unhealthy,
598 Draining,
599 Retired,
600}
601
602#[derive(Debug, Clone, Serialize, Deserialize)]
604pub struct FleetInboxEntry {
605 pub run_id: FleetRunId,
606 pub task_id: String,
607 pub priority: i32,
608 pub enqueued_at: String,
609 #[serde(default)]
610 pub lease_deadline: Option<String>,
611 #[serde(default)]
612 pub attempts: u32,
613}
614
615#[derive(Debug, Clone, Serialize, Deserialize)]
617pub struct FleetWorkerEvent {
618 pub seq: u64,
619 pub run_id: FleetRunId,
620 pub worker_id: String,
621 pub task_id: String,
622 pub timestamp: String,
623 #[serde(flatten)]
624 pub payload: FleetWorkerEventPayload,
625 #[serde(default)]
626 #[serde(skip_serializing_if = "BTreeMap::is_empty")]
627 pub extra: BTreeMap<String, Value>,
628}
629
630#[derive(Debug, Clone, Serialize, Deserialize)]
632#[serde(tag = "state", rename_all = "snake_case")]
633pub enum FleetWorkerEventPayload {
634 Queued,
635 Leased {
636 #[serde(skip_serializing_if = "Option::is_none")]
637 lease_expires_at: Option<String>,
638 },
639 Starting,
640 Running,
641 ModelWait {
642 #[serde(skip_serializing_if = "Option::is_none")]
643 model: Option<String>,
644 },
645 RunningTool {
646 tool: String,
647 #[serde(skip_serializing_if = "Option::is_none")]
648 call_id: Option<String>,
649 },
650 Heartbeat {
651 #[serde(default)]
652 #[serde(skip_serializing_if = "Option::is_none")]
653 cpu_percent: Option<f32>,
654 #[serde(default)]
655 #[serde(skip_serializing_if = "Option::is_none")]
656 memory_mb: Option<u64>,
657 },
658 Artifact(FleetArtifactRef),
659 Completed {
660 #[serde(default)]
661 #[serde(skip_serializing_if = "Option::is_none")]
662 exit_code: Option<i32>,
663 #[serde(skip_serializing_if = "Option::is_none")]
664 summary: Option<String>,
665 },
666 Failed {
667 reason: String,
668 #[serde(default)]
669 recoverable: bool,
670 },
671 Cancelled {
672 #[serde(skip_serializing_if = "Option::is_none")]
673 cancelled_by: Option<String>,
674 },
675 Interrupted {
676 #[serde(skip_serializing_if = "Option::is_none")]
677 signal: Option<String>,
678 },
679 Stale {
680 #[serde(skip_serializing_if = "Option::is_none")]
681 last_heartbeat_at: Option<String>,
682 },
683 Restarted {
684 #[serde(default)]
685 restart_count: u32,
686 },
687 Escalated {
688 channel: String,
689 #[serde(skip_serializing_if = "Option::is_none")]
690 alert_id: Option<String>,
691 },
692}
693
694#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
696pub struct FleetRetryPolicy {
697 #[serde(default = "default_retry_max_attempts")]
698 pub max_attempts: u32,
699 #[serde(default = "default_retry_initial_backoff_seconds")]
700 pub initial_backoff_seconds: u64,
701 #[serde(default = "default_retry_max_backoff_seconds")]
702 pub max_backoff_seconds: u64,
703 #[serde(default = "default_retry_backoff_multiplier")]
704 pub backoff_multiplier: u32,
705}
706
707impl Default for FleetRetryPolicy {
708 fn default() -> Self {
709 Self {
710 max_attempts: 3,
711 initial_backoff_seconds: 5,
712 max_backoff_seconds: 300,
713 backoff_multiplier: 2,
714 }
715 }
716}
717
718fn default_retry_max_attempts() -> u32 {
719 FleetRetryPolicy::default().max_attempts
720}
721
722fn default_retry_initial_backoff_seconds() -> u64 {
723 FleetRetryPolicy::default().initial_backoff_seconds
724}
725
726fn default_retry_max_backoff_seconds() -> u64 {
727 FleetRetryPolicy::default().max_backoff_seconds
728}
729
730fn default_retry_backoff_multiplier() -> u32 {
731 FleetRetryPolicy::default().backoff_multiplier
732}
733
734#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
736pub struct FleetAlertPolicy {
737 #[serde(default)]
738 #[serde(skip_serializing_if = "Vec::is_empty")]
739 pub events: Vec<FleetAlertEventClass>,
740 #[serde(default)]
741 pub channels: Vec<FleetAlertChannel>,
742 #[serde(default)]
743 pub after_attempts: Option<u32>,
744 #[serde(default)]
745 pub after_minutes_stale: Option<u64>,
746}
747
748#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord, Hash)]
749#[serde(rename_all = "snake_case")]
750pub enum FleetAlertEventClass {
751 Stale,
752 RestartExhausted,
753 NeedsHuman,
754 BudgetExceeded,
755 VerifierFailed,
756 RunCompleted,
757}
758
759#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
760#[serde(tag = "kind", rename_all = "snake_case")]
761pub enum FleetAlertChannel {
762 Slack {
763 #[serde(flatten)]
765 webhook: FleetAlertEndpoint,
766 },
767 Webhook {
768 #[serde(flatten)]
769 endpoint: FleetAlertEndpoint,
770 },
771 #[serde(alias = "pager_duty")]
772 #[serde(alias = "pagerduty")]
773 PagerDuty {
774 routing_key: String,
775 severity: String,
776 },
777}
778
779#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
785pub struct FleetAlertEndpoint {
786 #[serde(
788 alias = "webhook_url",
789 alias = "endpoint_url",
790 skip_serializing_if = "Option::is_none"
791 )]
792 pub url: Option<String>,
793 #[serde(
795 alias = "webhook_url_ref",
796 alias = "webhook_ref",
797 alias = "url_secret_ref",
798 skip_serializing_if = "Option::is_none"
799 )]
800 pub url_ref: Option<FleetSecretRef>,
801 #[serde(
803 alias = "secret",
804 alias = "webhook_secret",
805 alias = "signing_secret",
806 skip_serializing_if = "Option::is_none"
807 )]
808 pub secret_ref: Option<FleetSecretRef>,
809}
810
811impl FleetAlertEndpoint {
812 #[must_use]
814 pub fn inline(url: impl Into<String>) -> Self {
815 Self {
816 url: Some(url.into()),
817 url_ref: None,
818 secret_ref: None,
819 }
820 }
821
822 #[must_use]
824 pub fn from_secret(url_ref: FleetSecretRef) -> Self {
825 Self {
826 url: None,
827 url_ref: Some(url_ref),
828 secret_ref: None,
829 }
830 }
831
832 #[must_use]
834 pub fn redacted(&self) -> String {
835 self.url_ref
836 .as_ref()
837 .map_or_else(|| "<inline-url>".to_string(), |r| r.redacted())
838 }
839}
840
841#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
853pub struct FleetResolvedRoute {
854 pub provider_id: String,
856 pub provider_kind: String,
858 #[serde(default, skip_serializing_if = "Option::is_none")]
860 pub canonical_model: Option<String>,
861 pub wire_model_id: String,
863 pub protocol: String,
865 #[serde(default, skip_serializing_if = "Option::is_none")]
867 pub role: Option<String>,
868 #[serde(default, skip_serializing_if = "Option::is_none")]
870 pub loadout: Option<String>,
871 pub source: String,
873}
874
875#[derive(Debug, Clone, Serialize, Deserialize)]
877pub struct FleetReceipt {
878 pub run_id: FleetRunId,
879 pub task_id: String,
880 pub worker_id: String,
881 pub completed_at: String,
882 pub result: FleetTaskResult,
883 #[serde(skip_serializing_if = "Option::is_none")]
884 pub failure_kind: Option<FleetTaskFailureKind>,
885 #[serde(default)]
886 pub artifacts: Vec<FleetArtifactRef>,
887 #[serde(default)]
888 pub score: Option<FleetScore>,
889 #[serde(default, skip_serializing_if = "Option::is_none")]
894 pub resolved_route: Option<FleetResolvedRoute>,
895}
896
897#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
898#[serde(rename_all = "snake_case")]
899pub enum FleetTaskResult {
900 Pass,
901 Partial,
902 Fail,
903 Skip,
904 Timeout,
905}
906
907#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
909#[serde(rename_all = "snake_case")]
910pub enum FleetTaskFailureKind {
911 Transport,
912 Task,
913 Verifier,
914}
915
916#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
917pub struct FleetScore {
918 pub value: f64,
919 #[serde(skip_serializing_if = "Option::is_none")]
920 pub max: Option<f64>,
921 #[serde(skip_serializing_if = "Option::is_none")]
922 pub notes: Option<String>,
923}
924
925#[cfg(test)]
926mod tests {
927 use super::*;
928
929 #[test]
930 fn fleet_run_round_trip() {
931 let run = FleetRun {
932 id: FleetRunId::from("run-001"),
933 name: "dogfood smoke".to_string(),
934 status: FleetRunStatus::Running,
935 task_specs: vec![FleetTaskSpec {
936 id: "task-1".to_string(),
937 name: "lint".to_string(),
938 description: None,
939 objective: Some("Keep the workspace lint-clean".to_string()),
940 instructions: "run cargo clippy".to_string(),
941 worker: Some(FleetTaskWorkerProfile {
942 agent_profile: None,
943 role: Some("release-checker".to_string()),
944 loadout: None,
945 model_class: None,
946 model: None,
947 tool_profile: Some("read-only".to_string()),
948 tools: vec!["cargo".to_string()],
949 capabilities: vec!["rust".to_string()],
950 }),
951 workspace: Some(FleetWorkspaceRequirements {
952 root: Some(PathBuf::from(".")),
953 required_files: vec![PathBuf::from("Cargo.toml")],
954 writable_paths: vec![],
955 environment: Some(FleetEnvironmentRequirements {
956 required: vec!["PATH".to_string()],
957 allowlist: vec!["RUST_LOG".to_string()],
958 }),
959 }),
960 input_files: vec![PathBuf::from("crates/tui/src/main.rs")],
961 context: vec!["release gate".to_string()],
962 budget: Some(FleetTaskBudget {
963 max_tokens: Some(8000),
964 max_tool_calls: Some(20),
965 max_seconds: Some(300),
966 }),
967 tags: vec!["release".to_string()],
968 expected_artifacts: vec![FleetArtifactKind::Log],
969 scorer: Some(FleetScorerSpec::ExitCode),
970 retry_policy: Some(FleetRetryPolicy::default()),
971 alert_policy: None,
972 timeout_seconds: Some(300),
973 metadata: BTreeMap::new(),
974 }],
975 worker_specs: vec![],
976 labels: BTreeMap::new(),
977 security_policy: None,
978 created_at: "2026-06-12T17:00:00Z".to_string(),
979 updated_at: None,
980 completed_at: None,
981 };
982 let json = serde_json::to_string(&run).unwrap();
983 let back: FleetRun = serde_json::from_str(&json).unwrap();
984 assert_eq!(back.id, run.id);
985 assert_eq!(back.status, FleetRunStatus::Running);
986 assert_eq!(back.task_specs.len(), 1);
987 assert_eq!(
988 back.task_specs[0].worker.as_ref().unwrap().role.as_deref(),
989 Some("release-checker")
990 );
991 assert_eq!(
992 back.task_specs[0]
993 .workspace
994 .as_ref()
995 .unwrap()
996 .required_files,
997 vec![PathBuf::from("Cargo.toml")]
998 );
999 }
1000
1001 #[test]
1002 fn worker_profile_carries_agent_profile_and_loadout_intent() {
1003 let json = r#"{
1004 "profile": "adversarial_reviewer",
1005 "role": "reviewer",
1006 "loadout": "auto",
1007 "model_class": "balanced",
1008 "model": "deepseek-v4-pro",
1009 "tool_profile": "read-only",
1010 "tools": ["read_file"],
1011 "capabilities": ["rust"]
1012 }"#;
1013
1014 let profile: FleetTaskWorkerProfile = serde_json::from_str(json).unwrap();
1015
1016 assert_eq!(
1017 profile.agent_profile.as_deref(),
1018 Some("adversarial_reviewer")
1019 );
1020 assert_eq!(profile.role.as_deref(), Some("reviewer"));
1021 assert_eq!(profile.loadout.as_deref(), Some("auto"));
1022 assert_eq!(profile.model_class.as_deref(), Some("balanced"));
1023 assert_eq!(profile.model.as_deref(), Some("deepseek-v4-pro"));
1024 assert_eq!(profile.tool_profile.as_deref(), Some("read-only"));
1025
1026 let serialized = serde_json::to_value(&profile).unwrap();
1027 assert_eq!(serialized["agent_profile"], "adversarial_reviewer");
1028 assert_eq!(serialized["model"], "deepseek-v4-pro");
1029 assert!(serialized.get("profile").is_none());
1030 }
1031
1032 #[test]
1033 fn worker_event_lifecycle_round_trip() {
1034 let events = vec![
1035 FleetWorkerEvent {
1036 seq: 1,
1037 run_id: FleetRunId::from("run-002"),
1038 worker_id: "worker-a".to_string(),
1039 task_id: "task-1".to_string(),
1040 timestamp: "2026-06-12T17:01:00Z".to_string(),
1041 payload: FleetWorkerEventPayload::Queued,
1042 extra: BTreeMap::new(),
1043 },
1044 FleetWorkerEvent {
1045 seq: 2,
1046 run_id: FleetRunId::from("run-002"),
1047 worker_id: "worker-a".to_string(),
1048 task_id: "task-1".to_string(),
1049 timestamp: "2026-06-12T17:01:05Z".to_string(),
1050 payload: FleetWorkerEventPayload::RunningTool {
1051 tool: "bash".to_string(),
1052 call_id: Some("call-1".to_string()),
1053 },
1054 extra: BTreeMap::new(),
1055 },
1056 FleetWorkerEvent {
1057 seq: 3,
1058 run_id: FleetRunId::from("run-002"),
1059 worker_id: "worker-a".to_string(),
1060 task_id: "task-1".to_string(),
1061 timestamp: "2026-06-12T17:02:00Z".to_string(),
1062 payload: FleetWorkerEventPayload::Completed {
1063 exit_code: Some(0),
1064 summary: Some("ok".to_string()),
1065 },
1066 extra: BTreeMap::new(),
1067 },
1068 ];
1069 let json = serde_json::to_string(&events).unwrap();
1070 let back: Vec<FleetWorkerEvent> = serde_json::from_str(&json).unwrap();
1071 assert_eq!(back.len(), 3);
1072 assert!(matches!(back[0].payload, FleetWorkerEventPayload::Queued));
1073 assert!(matches!(
1074 back[2].payload,
1075 FleetWorkerEventPayload::Completed { .. }
1076 ));
1077 }
1078
1079 #[test]
1080 fn alert_policy_round_trip() {
1081 let policy = FleetAlertPolicy {
1082 events: vec![FleetAlertEventClass::Stale],
1083 channels: vec![FleetAlertChannel::Slack {
1084 webhook: FleetAlertEndpoint::inline("https://hooks.slack.com/test"),
1085 }],
1086 after_attempts: Some(2),
1087 after_minutes_stale: Some(10),
1088 };
1089 let json = serde_json::to_string(&policy).unwrap();
1090 assert!(json.contains("\"events\":[\"stale\"]"));
1091 assert!(json.contains("\"kind\":\"slack\""));
1092 let back: FleetAlertPolicy = serde_json::from_str(&json).unwrap();
1093 assert_eq!(back.events, vec![FleetAlertEventClass::Stale]);
1094 assert_eq!(back.after_attempts, Some(2));
1095 }
1096
1097 #[test]
1098 fn artifact_other_kind_round_trip() {
1099 let artifact = FleetArtifactRef {
1100 kind: FleetArtifactKind::Other("coverage.xml".to_string()),
1101 path: PathBuf::from("/tmp/coverage.xml"),
1102 checksum: Some("sha256:abc".to_string()),
1103 mime_type: Some("application/xml".to_string()),
1104 size_bytes: Some(1024),
1105 };
1106 let json = serde_json::to_string(&artifact).unwrap();
1107 let back: FleetArtifactRef = serde_json::from_str(&json).unwrap();
1108 assert_eq!(back.kind, artifact.kind);
1109 assert_eq!(back.size_bytes, Some(1024));
1110 }
1111
1112 #[test]
1113 fn ssh_host_spec_accepts_minimal_legacy_json() {
1114 let json = r#"{"kind":"ssh","host":"builder.example.test"}"#;
1115 let host: FleetHostSpec = serde_json::from_str(json).unwrap();
1116
1117 match host {
1118 FleetHostSpec::Ssh {
1119 host,
1120 port,
1121 user,
1122 identity,
1123 known_hosts,
1124 host_key_fingerprint,
1125 working_directory,
1126 env_allowlist,
1127 codewhale_binary,
1128 } => {
1129 assert_eq!(host, "builder.example.test");
1130 assert_eq!(port, None);
1131 assert_eq!(user, None);
1132 assert_eq!(identity, None);
1133 assert_eq!(known_hosts, None);
1134 assert_eq!(host_key_fingerprint, None);
1135 assert_eq!(working_directory, None);
1136 assert!(env_allowlist.is_empty());
1137 assert_eq!(codewhale_binary, None);
1138 }
1139 other => panic!("expected ssh host spec, got {other:?}"),
1140 }
1141 }
1142
1143 #[test]
1144 fn artifact_kind_uses_flat_string_json() {
1145 let known = serde_json::to_string(&FleetArtifactKind::TestResult).unwrap();
1146 assert_eq!(known, "\"test_result\"");
1147
1148 let custom =
1149 serde_json::to_string(&FleetArtifactKind::Other("coverage.xml".to_string())).unwrap();
1150 assert_eq!(custom, "\"coverage.xml\"");
1151
1152 let parsed: FleetArtifactKind = serde_json::from_str("\"coverage.xml\"").unwrap();
1153 assert_eq!(parsed, FleetArtifactKind::Other("coverage.xml".to_string()));
1154 }
1155
1156 #[test]
1157 fn retry_policy_missing_fields_use_nonzero_defaults() {
1158 let policy: FleetRetryPolicy = serde_json::from_value(serde_json::json!({})).unwrap();
1159 assert_eq!(policy, FleetRetryPolicy::default());
1160
1161 let policy: FleetRetryPolicy =
1162 serde_json::from_value(serde_json::json!({"max_attempts": 5})).unwrap();
1163 assert_eq!(policy.max_attempts, 5);
1164 assert_eq!(
1165 policy.initial_backoff_seconds,
1166 FleetRetryPolicy::default().initial_backoff_seconds
1167 );
1168 assert_eq!(
1169 policy.max_backoff_seconds,
1170 FleetRetryPolicy::default().max_backoff_seconds
1171 );
1172 assert_eq!(
1173 policy.backoff_multiplier,
1174 FleetRetryPolicy::default().backoff_multiplier
1175 );
1176 }
1177
1178 #[test]
1179 fn sparse_worker_events_omit_absent_optional_fields() {
1180 let heartbeat = FleetWorkerEventPayload::Heartbeat {
1181 cpu_percent: None,
1182 memory_mb: None,
1183 };
1184 let heartbeat_json = serde_json::to_value(&heartbeat).unwrap();
1185 assert_eq!(heartbeat_json, serde_json::json!({"state": "heartbeat"}));
1186
1187 let completed = FleetWorkerEventPayload::Completed {
1188 exit_code: None,
1189 summary: None,
1190 };
1191 let completed_json = serde_json::to_value(&completed).unwrap();
1192 assert_eq!(completed_json, serde_json::json!({"state": "completed"}));
1193 }
1194
1195 #[test]
1196 fn receipt_round_trip() {
1197 let receipt = FleetReceipt {
1198 run_id: FleetRunId::from("run-003"),
1199 task_id: "task-1".to_string(),
1200 worker_id: "worker-b".to_string(),
1201 completed_at: "2026-06-12T17:03:00Z".to_string(),
1202 result: FleetTaskResult::Pass,
1203 failure_kind: None,
1204 artifacts: vec![],
1205 score: Some(FleetScore {
1206 value: 0.95,
1207 max: Some(1.0),
1208 notes: None,
1209 }),
1210 resolved_route: None,
1211 };
1212 let json = serde_json::to_string(&receipt).unwrap();
1213 let back: FleetReceipt = serde_json::from_str(&json).unwrap();
1214 assert_eq!(back.result, FleetTaskResult::Pass);
1215 assert_eq!(back.score.as_ref().unwrap().value, 0.95);
1216 }
1217
1218 #[test]
1219 fn partial_receipt_records_failure_source_when_needed() {
1220 let receipt = FleetReceipt {
1221 run_id: FleetRunId::from("run-004"),
1222 task_id: "task-2".to_string(),
1223 worker_id: "worker-c".to_string(),
1224 completed_at: "2026-06-12T17:04:00Z".to_string(),
1225 result: FleetTaskResult::Partial,
1226 failure_kind: Some(FleetTaskFailureKind::Verifier),
1227 artifacts: vec![],
1228 score: Some(FleetScore {
1229 value: 0.5,
1230 max: Some(1.0),
1231 notes: Some("manual verification required".to_string()),
1232 }),
1233 resolved_route: None,
1234 };
1235
1236 let json = serde_json::to_string(&receipt).unwrap();
1237 assert!(json.contains("\"result\":\"partial\""));
1238 assert!(json.contains("\"failure_kind\":\"verifier\""));
1239 let back: FleetReceipt = serde_json::from_str(&json).unwrap();
1240 assert_eq!(back.result, FleetTaskResult::Partial);
1241 assert_eq!(back.failure_kind, Some(FleetTaskFailureKind::Verifier));
1242 }
1243
1244 #[test]
1245 fn ssh_host_spec_with_key_pinning_round_trip() {
1246 let spec = FleetHostSpec::Ssh {
1247 host: "builder.trusted.example.com".to_string(),
1248 port: Some(22),
1249 user: Some("codewhale".to_string()),
1250 identity: Some(PathBuf::from("~/.ssh/codewhale_fleet")),
1251 known_hosts: Some(PathBuf::from("~/.ssh/known_hosts")),
1252 host_key_fingerprint: Some("SHA256:aLGqZo1M6c...".to_string()),
1253 working_directory: Some(PathBuf::from("/srv/codewhale/work")),
1254 env_allowlist: vec!["CODEWHALE_PROFILE".to_string()],
1255 codewhale_binary: Some("/usr/local/bin/codewhale".to_string()),
1256 };
1257 let json = serde_json::to_string_pretty(&spec).unwrap();
1258 assert!(json.contains("\"known_hosts\""));
1259 assert!(json.contains("\"host_key_fingerprint\""));
1260 assert!(json.contains("SHA256:aLGqZo1M6c..."));
1261
1262 let back: FleetHostSpec = serde_json::from_str(&json).unwrap();
1263 match back {
1264 FleetHostSpec::Ssh {
1265 host,
1266 known_hosts,
1267 host_key_fingerprint,
1268 ..
1269 } => {
1270 assert_eq!(host, "builder.trusted.example.com");
1271 assert_eq!(known_hosts, Some(PathBuf::from("~/.ssh/known_hosts")));
1272 assert_eq!(
1273 host_key_fingerprint,
1274 Some("SHA256:aLGqZo1M6c...".to_string())
1275 );
1276 }
1277 other => panic!("expected ssh host spec, got {other:?}"),
1278 }
1279 }
1280
1281 #[test]
1282 fn secret_ref_redacted_never_exposes_value() {
1283 let ref_ = FleetSecretRef::new("DEEPSEEK_API_KEY");
1284 let redacted = ref_.redacted();
1285 assert!(redacted.contains("DEEPSEEK_API_KEY"));
1286 assert!(!redacted.contains("sk-"));
1287 assert!(redacted.contains("<secret:"));
1288
1289 let ref_ = FleetSecretRef::with_source("GH_TOKEN", "env");
1290 let redacted = ref_.redacted();
1291 assert!(redacted.contains("env.GH_TOKEN"));
1292 assert!(!redacted.contains("ghp_"));
1293 }
1294
1295 #[test]
1296 fn alert_endpoint_from_secret_round_trip() {
1297 let endpoint = FleetAlertEndpoint::from_secret(FleetSecretRef::new("SLACK_WEBHOOK"));
1298 let json = serde_json::to_string(&endpoint).unwrap();
1299 assert!(json.contains("SLACK_WEBHOOK"));
1300 assert!(!json.contains("hooks.slack.com"));
1301
1302 let back: FleetAlertEndpoint = serde_json::from_str(&json).unwrap();
1303 assert_eq!(back.url_ref.as_ref().unwrap().key, "SLACK_WEBHOOK");
1304 assert_eq!(back.url, None);
1305 }
1306
1307 #[test]
1308 fn secret_ref_accepts_legacy_string_wire_shape() {
1309 let ref_: FleetSecretRef = serde_json::from_str(r#""CODEWHALE_FLEET_TOKEN""#).unwrap();
1310 assert_eq!(ref_, FleetSecretRef::new("CODEWHALE_FLEET_TOKEN"));
1311
1312 let ref_: FleetSecretRef =
1313 serde_json::from_str(r#"{"key":"GH_TOKEN","source":"env"}"#).unwrap();
1314 assert_eq!(ref_, FleetSecretRef::with_source("GH_TOKEN", "env"));
1315 }
1316
1317 #[test]
1318 fn trust_level_accepts_hyphenated_remote_verified() {
1319 let trust: FleetTrustLevel = serde_json::from_str(r#""remote-verified""#).unwrap();
1320 assert_eq!(trust, FleetTrustLevel::RemoteVerified);
1321
1322 let canonical = serde_json::to_string(&trust).unwrap();
1323 assert_eq!(canonical, r#""remote_verified""#);
1324 }
1325
1326 #[test]
1327 fn alert_channel_accepts_legacy_webhook_fields() {
1328 let channel: FleetAlertChannel = serde_json::from_str(
1329 r#"{
1330 "kind": "slack",
1331 "webhook_url": "https://hooks.slack.com/test",
1332 "secret": "SLACK_SIGNING_SECRET"
1333 }"#,
1334 )
1335 .unwrap();
1336
1337 match channel {
1338 FleetAlertChannel::Slack { webhook } => {
1339 assert_eq!(webhook.url.as_deref(), Some("https://hooks.slack.com/test"));
1340 assert_eq!(
1341 webhook.secret_ref,
1342 Some(FleetSecretRef::new("SLACK_SIGNING_SECRET"))
1343 );
1344 }
1345 other => panic!("expected slack channel, got {other:?}"),
1346 }
1347 }
1348
1349 #[test]
1350 fn security_policy_defaults_are_conservative() {
1351 let policy = FleetSecurityPolicy::default();
1352 assert_eq!(policy.default_trust_level, FleetTrustLevel::Sandbox);
1353 assert!(policy.allowed_secrets.is_empty());
1354 assert!(policy.capability_grants.is_empty());
1355 assert_eq!(policy.max_trust_level, FleetTrustLevel::Operator);
1356 assert!(!policy.require_identity_verification);
1357 }
1358
1359 #[test]
1360 fn trust_level_ordinal_reflects_privilege() {
1361 assert!(FleetTrustLevel::Operator > FleetTrustLevel::RemoteVerified);
1362 assert!(FleetTrustLevel::RemoteVerified > FleetTrustLevel::Local);
1363 assert!(FleetTrustLevel::Local > FleetTrustLevel::Sandbox);
1364
1365 assert!(FleetTrustLevel::Operator.may_access_secrets());
1366 assert!(!FleetTrustLevel::Sandbox.may_access_secrets());
1367 assert!(!FleetTrustLevel::Sandbox.may_write_workspace());
1368 assert!(FleetTrustLevel::Operator.may_write_workspace());
1369 }
1370
1371 fn sample_receipt_with_route() -> FleetReceipt {
1372 FleetReceipt {
1373 run_id: FleetRunId::from("run-route"),
1374 task_id: "task-route".to_string(),
1375 worker_id: "worker-route".to_string(),
1376 completed_at: "2026-06-23T00:00:00Z".to_string(),
1377 result: FleetTaskResult::Pass,
1378 failure_kind: None,
1379 artifacts: vec![],
1380 score: None,
1381 resolved_route: Some(FleetResolvedRoute {
1382 provider_id: "deepseek".to_string(),
1383 provider_kind: "deepseek".to_string(),
1384 canonical_model: Some("deepseek-v4-pro".to_string()),
1385 wire_model_id: "deepseek-v4-pro".to_string(),
1386 protocol: "chat_completions".to_string(),
1387 role: Some("builder".to_string()),
1388 loadout: Some("auto".to_string()),
1389 source: "resolver".to_string(),
1390 }),
1391 }
1392 }
1393
1394 #[test]
1395 fn fleet_resolved_route_round_trips() {
1396 let receipt = sample_receipt_with_route();
1397 let json = serde_json::to_string(&receipt).unwrap();
1398 let back: FleetReceipt = serde_json::from_str(&json).unwrap();
1399 assert_eq!(back.resolved_route, receipt.resolved_route);
1400 let route = back.resolved_route.unwrap();
1401 assert_eq!(route.provider_id, "deepseek");
1402 assert_eq!(route.wire_model_id, "deepseek-v4-pro");
1403 assert_eq!(route.protocol, "chat_completions");
1404 assert_eq!(route.role.as_deref(), Some("builder"));
1405 assert_eq!(route.source, "resolver");
1406 }
1407
1408 #[test]
1409 fn fleet_receipt_without_resolved_route_still_deserializes() {
1410 let legacy = r#"{
1413 "run_id": "run-legacy",
1414 "task_id": "task-legacy",
1415 "worker_id": "worker-legacy",
1416 "completed_at": "2026-06-01T00:00:00Z",
1417 "result": "pass",
1418 "artifacts": [],
1419 "score": null
1420 }"#;
1421 let receipt: FleetReceipt = serde_json::from_str(legacy).unwrap();
1422 assert_eq!(receipt.task_id, "task-legacy");
1423 assert!(receipt.resolved_route.is_none());
1424 }
1425
1426 #[test]
1427 fn fleet_resolved_route_serialization_carries_no_secrets() {
1428 let receipt = sample_receipt_with_route();
1429 let route_json = serde_json::to_string(receipt.resolved_route.as_ref().unwrap()).unwrap();
1434 assert_no_secret_markers(&route_json);
1435 let receipt_json = serde_json::to_string(&receipt).unwrap();
1437 for needle in SECRET_KEY_MARKERS {
1438 assert!(
1439 !receipt_json.to_ascii_lowercase().contains(needle),
1440 "receipt JSON must not contain secret-key marker {needle:?}: {receipt_json}"
1441 );
1442 }
1443 }
1444
1445 const SECRET_KEY_MARKERS: &[&str] = &[
1448 "api_key",
1449 "apikey",
1450 "api-key",
1451 "authorization",
1452 "bearer ",
1453 "auth_token",
1454 "auth-token",
1455 "password",
1456 "credential",
1457 "sk-ant-",
1458 "sk-proj-",
1459 "sk-or-",
1460 "secret",
1461 ];
1462
1463 fn assert_no_secret_markers(json: &str) {
1464 let haystack = json.to_ascii_lowercase();
1465 for needle in SECRET_KEY_MARKERS {
1466 assert!(
1467 !haystack.contains(needle),
1468 "resolved-route JSON must not contain secret marker {needle:?}: {json}"
1469 );
1470 }
1471 }
1472}