1use std::collections::BTreeMap;
13use std::path::PathBuf;
14
15use serde::{Deserialize, Deserializer, Serialize, Serializer, de};
16use serde_json::Value;
17
18use super::Status;
19
20pub const FLEET_PROTOCOL_VERSION: &str = "0.1.0";
21
22#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)]
24pub struct FleetRunId(pub String);
25
26impl From<String> for FleetRunId {
27 fn from(value: String) -> Self {
28 Self(value)
29 }
30}
31
32impl From<&str> for FleetRunId {
33 fn from(value: &str) -> Self {
34 Self(value.to_string())
35 }
36}
37
38#[derive(Debug, Clone, Serialize, Deserialize)]
40pub struct FleetRun {
41 pub id: FleetRunId,
42 pub name: String,
43 pub status: FleetRunStatus,
44 #[serde(default, skip_serializing_if = "Option::is_none")]
50 pub target: Option<FleetRuntimeTarget>,
51 #[serde(default, skip_serializing_if = "Option::is_none")]
57 pub workflow: Option<FleetWorkflowDescriptor>,
58 #[serde(default, skip_serializing_if = "Vec::is_empty")]
60 pub roles: Vec<String>,
61 #[serde(default, skip_serializing_if = "Option::is_none")]
66 pub max_workers: Option<usize>,
67 #[serde(default)]
68 pub task_specs: Vec<FleetTaskSpec>,
69 #[serde(default)]
70 pub worker_specs: Vec<FleetWorkerSpec>,
71 #[serde(default)]
72 pub labels: BTreeMap<String, String>,
73 #[serde(skip_serializing_if = "Option::is_none")]
78 pub security_policy: Option<FleetSecurityPolicy>,
79 pub created_at: String,
80 #[serde(skip_serializing_if = "Option::is_none")]
81 pub updated_at: Option<String>,
82 #[serde(skip_serializing_if = "Option::is_none")]
83 pub completed_at: Option<String>,
84}
85
86#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
92#[serde(rename_all = "snake_case")]
93pub enum FleetRuntimeTarget {
94 ThisComputer,
95 AnotherComputer,
96 Cloud,
97}
98
99#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
105#[serde(rename_all = "snake_case")]
106pub enum FleetWorkflowKind {
107 Parallel,
108}
109
110#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
112pub struct FleetWorkflowDescriptor {
113 pub id: String,
114 pub kind: FleetWorkflowKind,
115}
116
117#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
124pub struct FleetRuntimeEvent {
125 pub cursor: String,
126 pub event: String,
127 pub run_id: FleetRunId,
128 #[serde(default, skip_serializing_if = "Option::is_none")]
129 pub worker_id: Option<String>,
130 #[serde(default, skip_serializing_if = "Option::is_none")]
131 pub task_id: Option<String>,
132 #[serde(default, skip_serializing_if = "Option::is_none")]
133 pub timestamp: Option<String>,
134 #[serde(default, skip_serializing_if = "Option::is_none")]
135 pub worker_seq: Option<u64>,
136 #[serde(default)]
137 pub payload: Value,
138}
139
140#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
142pub struct FleetEventReplay {
143 pub run_id: FleetRunId,
144 pub events: Vec<FleetRuntimeEvent>,
145 #[serde(default)]
146 pub has_more: bool,
147 #[serde(default)]
149 pub history_truncated: bool,
150 #[serde(default, skip_serializing_if = "Option::is_none")]
151 pub next_cursor: Option<String>,
152}
153
154#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
156#[serde(rename_all = "snake_case")]
157pub enum FleetRunStatus {
158 Pending,
159 Queued,
160 Running,
161 Paused,
162 Completed,
163 Failed,
164 Cancelled,
165}
166
167impl Status for FleetRunStatus {
168 fn is_terminal(&self) -> bool {
169 matches!(self, Self::Completed | Self::Failed | Self::Cancelled)
170 }
171 fn is_active(&self) -> bool {
172 matches!(self, Self::Pending | Self::Queued | Self::Running)
173 }
174 fn is_paused(&self) -> bool {
175 matches!(self, Self::Paused)
176 }
177}
178
179#[derive(Debug, Clone, Serialize, Deserialize)]
181pub struct FleetTaskSpec {
182 pub id: String,
183 pub name: String,
184 #[serde(skip_serializing_if = "Option::is_none")]
185 pub description: Option<String>,
186 #[serde(skip_serializing_if = "Option::is_none")]
187 pub objective: Option<String>,
188 pub instructions: String,
189 #[serde(skip_serializing_if = "Option::is_none")]
190 pub worker: Option<FleetTaskWorkerProfile>,
191 #[serde(skip_serializing_if = "Option::is_none")]
192 pub workspace: Option<FleetWorkspaceRequirements>,
193 #[serde(default)]
194 #[serde(skip_serializing_if = "Vec::is_empty")]
195 pub input_files: Vec<PathBuf>,
196 #[serde(default)]
197 #[serde(skip_serializing_if = "Vec::is_empty")]
198 pub context: Vec<String>,
199 #[serde(skip_serializing_if = "Option::is_none")]
200 pub budget: Option<FleetTaskBudget>,
201 #[serde(default)]
202 #[serde(skip_serializing_if = "Vec::is_empty")]
203 pub tags: Vec<String>,
204 #[serde(default)]
205 pub expected_artifacts: Vec<FleetArtifactKind>,
206 #[serde(skip_serializing_if = "Option::is_none")]
207 pub scorer: Option<FleetScorerSpec>,
208 #[serde(skip_serializing_if = "Option::is_none")]
209 pub retry_policy: Option<FleetRetryPolicy>,
210 #[serde(skip_serializing_if = "Option::is_none")]
211 pub alert_policy: Option<FleetAlertPolicy>,
212 #[serde(default)]
213 pub timeout_seconds: Option<u64>,
214 #[serde(default)]
215 pub metadata: BTreeMap<String, Value>,
216}
217
218#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
220pub struct FleetTaskWorkerProfile {
221 #[serde(default, alias = "profile", skip_serializing_if = "Option::is_none")]
228 pub agent_profile: Option<String>,
229 #[serde(skip_serializing_if = "Option::is_none")]
230 pub role: Option<String>,
231 #[serde(default, skip_serializing_if = "Option::is_none")]
236 pub loadout: Option<String>,
237 #[serde(default, skip_serializing_if = "Option::is_none")]
239 pub model_class: Option<String>,
240 #[serde(default, skip_serializing_if = "Option::is_none")]
246 pub model: Option<String>,
247 #[serde(skip_serializing_if = "Option::is_none")]
248 pub tool_profile: Option<String>,
249 #[serde(default)]
250 #[serde(skip_serializing_if = "Vec::is_empty")]
251 pub tools: Vec<String>,
252 #[serde(default)]
253 #[serde(skip_serializing_if = "Vec::is_empty")]
254 pub capabilities: Vec<String>,
255}
256
257#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
259pub struct FleetWorkspaceRequirements {
260 #[serde(skip_serializing_if = "Option::is_none")]
261 pub root: Option<PathBuf>,
262 #[serde(default)]
263 #[serde(skip_serializing_if = "Vec::is_empty")]
264 pub required_files: Vec<PathBuf>,
265 #[serde(default)]
266 #[serde(skip_serializing_if = "Vec::is_empty")]
267 pub writable_paths: Vec<PathBuf>,
268 #[serde(skip_serializing_if = "Option::is_none")]
269 pub environment: Option<FleetEnvironmentRequirements>,
270}
271
272#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
274pub struct FleetEnvironmentRequirements {
275 #[serde(default)]
276 #[serde(skip_serializing_if = "Vec::is_empty")]
277 pub required: Vec<String>,
278 #[serde(default)]
279 #[serde(skip_serializing_if = "Vec::is_empty")]
280 pub allowlist: Vec<String>,
281}
282
283#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
285pub struct FleetTaskBudget {
286 #[serde(skip_serializing_if = "Option::is_none")]
287 pub max_tokens: Option<u64>,
288 #[serde(skip_serializing_if = "Option::is_none")]
290 pub max_steps: Option<u32>,
291 #[serde(skip_serializing_if = "Option::is_none")]
292 pub max_tool_calls: Option<u32>,
293 #[serde(skip_serializing_if = "Option::is_none")]
294 pub max_seconds: Option<u64>,
295}
296
297#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
299pub struct FleetArtifactRef {
300 pub kind: FleetArtifactKind,
301 pub path: PathBuf,
302 #[serde(skip_serializing_if = "Option::is_none")]
303 pub checksum: Option<String>,
304 #[serde(skip_serializing_if = "Option::is_none")]
305 pub mime_type: Option<String>,
306 #[serde(default)]
307 pub size_bytes: Option<u64>,
308}
309
310#[derive(Debug, Clone, PartialEq, Eq)]
312pub enum FleetArtifactKind {
313 Log,
314 Patch,
315 TestResult,
316 Report,
317 Checkpoint,
318 Receipt,
319 Other(String),
320}
321
322impl FleetArtifactKind {
323 fn as_wire_str(&self) -> &str {
324 match self {
325 Self::Log => "log",
326 Self::Patch => "patch",
327 Self::TestResult => "test_result",
328 Self::Report => "report",
329 Self::Checkpoint => "checkpoint",
330 Self::Receipt => "receipt",
331 Self::Other(kind) => kind.as_str(),
332 }
333 }
334
335 fn from_wire_str(value: &str) -> Self {
336 match value {
337 "log" => Self::Log,
338 "patch" => Self::Patch,
339 "test_result" => Self::TestResult,
340 "report" => Self::Report,
341 "checkpoint" => Self::Checkpoint,
342 "receipt" => Self::Receipt,
343 other => Self::Other(other.to_string()),
344 }
345 }
346}
347
348impl Serialize for FleetArtifactKind {
349 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
350 where
351 S: Serializer,
352 {
353 serializer.serialize_str(self.as_wire_str())
354 }
355}
356
357impl<'de> Deserialize<'de> for FleetArtifactKind {
358 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
359 where
360 D: Deserializer<'de>,
361 {
362 let value = String::deserialize(deserializer)?;
363 Ok(Self::from_wire_str(&value))
364 }
365}
366
367#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
369#[serde(tag = "kind", rename_all = "snake_case")]
370pub enum FleetScorerSpec {
371 ExitCode,
372 FileExists {
373 path: PathBuf,
374 },
375 RegexMatch {
376 path: PathBuf,
377 pattern: String,
378 },
379 JsonPath {
380 path: PathBuf,
381 expression: String,
382 },
383 Command {
384 command: String,
385 #[serde(default)]
386 args: Vec<String>,
387 },
388 CodeWhaleVerifierPrompt {
389 prompt: String,
390 },
391 Manual,
392}
393
394#[derive(Debug, Clone, Serialize, Deserialize)]
396pub struct FleetWorkerSpec {
397 pub id: String,
398 pub name: String,
399 pub host: FleetHostSpec,
400 #[serde(default)]
403 #[serde(skip_serializing_if = "Option::is_none")]
404 pub trust_level: Option<FleetTrustLevel>,
405 #[serde(default)]
406 pub labels: BTreeMap<String, String>,
407 #[serde(default)]
408 pub capabilities: Vec<String>,
409 #[serde(skip_serializing_if = "Option::is_none")]
410 pub max_concurrent_tasks: Option<usize>,
411}
412
413#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
415#[serde(tag = "kind", rename_all = "snake_case")]
416pub enum FleetHostSpec {
417 Local,
418 Ssh {
419 host: String,
420 #[serde(skip_serializing_if = "Option::is_none")]
421 port: Option<u16>,
422 #[serde(skip_serializing_if = "Option::is_none")]
423 user: Option<String>,
424 #[serde(skip_serializing_if = "Option::is_none")]
425 identity: Option<PathBuf>,
426 #[serde(skip_serializing_if = "Option::is_none")]
428 known_hosts: Option<PathBuf>,
429 #[serde(skip_serializing_if = "Option::is_none")]
433 host_key_fingerprint: Option<String>,
434 #[serde(skip_serializing_if = "Option::is_none")]
435 working_directory: Option<PathBuf>,
436 #[serde(default)]
437 #[serde(skip_serializing_if = "Vec::is_empty")]
438 env_allowlist: Vec<String>,
439 #[serde(skip_serializing_if = "Option::is_none")]
440 codewhale_binary: Option<String>,
441 },
442 #[serde(alias = "container")]
443 #[serde(alias = "Container")]
444 Docker {
445 image: String,
446 #[serde(default)]
447 args: Vec<String>,
448 },
449}
450
451#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord, Default)]
459#[serde(rename_all = "snake_case")]
460pub enum FleetTrustLevel {
461 #[default]
464 Sandbox = 0,
465 Local = 1,
468 #[serde(alias = "remote-verified", alias = "remoteVerified")]
472 RemoteVerified = 2,
473 Operator = 3,
477}
478
479impl FleetTrustLevel {
480 #[must_use]
482 pub fn may_access_secrets(&self) -> bool {
483 matches!(self, Self::Operator | Self::RemoteVerified | Self::Local)
484 }
485
486 #[must_use]
488 pub fn may_write_workspace(&self) -> bool {
489 matches!(self, Self::Operator | Self::Local)
490 }
491
492 #[must_use]
494 pub fn may_access_network(&self) -> bool {
495 matches!(self, Self::Operator | Self::RemoteVerified | Self::Local)
496 }
497}
498
499#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
505pub struct FleetSecurityPolicy {
506 #[serde(default)]
508 pub default_trust_level: FleetTrustLevel,
509 #[serde(default)]
512 #[serde(skip_serializing_if = "Vec::is_empty")]
513 pub allowed_secrets: Vec<FleetSecretRef>,
514 #[serde(default)]
516 #[serde(skip_serializing_if = "Vec::is_empty")]
517 pub capability_grants: Vec<FleetCapabilityGrant>,
518 #[serde(default = "default_max_trust_level")]
521 pub max_trust_level: FleetTrustLevel,
522 #[serde(default)]
526 pub require_identity_verification: bool,
527 #[serde(default)]
532 pub allow_parallel_reads: bool,
533}
534
535fn default_max_trust_level() -> FleetTrustLevel {
536 FleetTrustLevel::Operator
537}
538
539impl Default for FleetSecurityPolicy {
540 fn default() -> Self {
541 Self {
542 default_trust_level: FleetTrustLevel::Sandbox,
543 allowed_secrets: Vec::new(),
544 capability_grants: Vec::new(),
545 max_trust_level: FleetTrustLevel::Operator,
546 require_identity_verification: false,
547 allow_parallel_reads: false,
548 }
549 }
550}
551
552#[derive(Debug, Clone, Serialize, PartialEq, Eq, Hash, PartialOrd, Ord)]
560pub struct FleetSecretRef {
561 pub key: String,
563 #[serde(skip_serializing_if = "Option::is_none")]
569 pub source: Option<String>,
570}
571
572impl FleetSecretRef {
573 #[must_use]
575 pub fn new(key: impl Into<String>) -> Self {
576 Self {
577 key: key.into(),
578 source: None,
579 }
580 }
581
582 #[must_use]
584 pub fn with_source(key: impl Into<String>, source: impl Into<String>) -> Self {
585 Self {
586 key: key.into(),
587 source: Some(source.into()),
588 }
589 }
590
591 #[must_use]
594 pub fn redacted(&self) -> String {
595 match &self.source {
596 Some(src) => format!("<secret:{}.{}>", src, self.key),
597 None => format!("<secret:{}>", self.key),
598 }
599 }
600}
601
602impl std::fmt::Display for FleetSecretRef {
603 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
604 write!(f, "{}", self.redacted())
605 }
606}
607
608impl From<&str> for FleetSecretRef {
609 fn from(key: &str) -> Self {
610 Self::new(key)
611 }
612}
613
614impl From<String> for FleetSecretRef {
615 fn from(key: String) -> Self {
616 Self::new(key)
617 }
618}
619
620impl<'de> Deserialize<'de> for FleetSecretRef {
621 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
622 where
623 D: Deserializer<'de>,
624 {
625 #[derive(Deserialize)]
626 #[serde(untagged)]
627 enum SecretRefWire {
628 Key(String),
629 Structured {
630 key: String,
631 #[serde(default)]
632 source: Option<String>,
633 },
634 }
635
636 match SecretRefWire::deserialize(deserializer)? {
637 SecretRefWire::Key(key) if !key.trim().is_empty() => Ok(FleetSecretRef::new(key)),
638 SecretRefWire::Key(_) => Err(de::Error::custom("secret ref key cannot be empty")),
639 SecretRefWire::Structured { key, source } if !key.trim().is_empty() => {
640 Ok(FleetSecretRef { key, source })
641 }
642 SecretRefWire::Structured { .. } => {
643 Err(de::Error::custom("secret ref key cannot be empty"))
644 }
645 }
646 }
647}
648
649#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
651#[serde(tag = "method", rename_all = "snake_case")]
652pub enum FleetWorkerAuth {
653 None,
655 SshKey {
657 identity: PathBuf,
660 #[serde(skip_serializing_if = "Option::is_none")]
662 known_hosts: Option<PathBuf>,
663 #[serde(skip_serializing_if = "Option::is_none")]
665 host_key_fingerprint: Option<String>,
666 #[serde(skip_serializing_if = "Option::is_none")]
668 user: Option<String>,
669 },
670 Token {
672 token_ref: FleetSecretRef,
674 },
675 Mtls {
677 cert_path: PathBuf,
679 key_ref: FleetSecretRef,
681 },
682}
683
684#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
691pub struct FleetCapabilityGrant {
692 pub capability: String,
695 #[serde(skip_serializing_if = "Option::is_none")]
698 pub scope: Option<String>,
699 #[serde(skip_serializing_if = "Option::is_none")]
701 pub reason: Option<String>,
702}
703
704#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
706#[serde(rename_all = "snake_case")]
707pub enum FleetWorkerStatus {
708 Unknown,
709 Online,
710 Busy,
711 Offline,
712 Unhealthy,
713 Draining,
714 Retired,
715}
716
717impl Status for FleetWorkerStatus {
718 fn is_terminal(&self) -> bool {
719 matches!(self, Self::Retired)
720 }
721 fn is_active(&self) -> bool {
722 matches!(self, Self::Online | Self::Busy)
723 }
724 fn is_paused(&self) -> bool {
725 false
726 }
727}
728
729#[derive(Debug, Clone, Serialize, Deserialize)]
731pub struct FleetInboxEntry {
732 pub run_id: FleetRunId,
733 pub task_id: String,
734 pub priority: i32,
735 pub enqueued_at: String,
736 #[serde(default)]
737 pub lease_deadline: Option<String>,
738 #[serde(default)]
739 pub attempts: u32,
740}
741
742#[derive(Debug, Clone, Serialize, Deserialize)]
744pub struct FleetWorkerEvent {
745 pub seq: u64,
746 pub run_id: FleetRunId,
747 pub worker_id: String,
748 pub task_id: String,
749 pub timestamp: String,
750 #[serde(flatten)]
751 pub payload: FleetWorkerEventPayload,
752 #[serde(default)]
753 #[serde(skip_serializing_if = "BTreeMap::is_empty")]
754 pub extra: BTreeMap<String, Value>,
755}
756
757#[derive(Debug, Clone, Serialize, Deserialize)]
759#[serde(tag = "state", rename_all = "snake_case")]
760pub enum FleetWorkerEventPayload {
761 Queued,
762 Leased {
763 #[serde(skip_serializing_if = "Option::is_none")]
764 lease_expires_at: Option<String>,
765 },
766 Starting,
767 Running,
768 ModelWait {
769 #[serde(skip_serializing_if = "Option::is_none")]
770 model: Option<String>,
771 },
772 RunningTool {
773 tool: String,
774 #[serde(skip_serializing_if = "Option::is_none")]
775 call_id: Option<String>,
776 },
777 WorkflowEvent {
779 workflow_run_id: String,
782 event: Value,
783 },
784 Heartbeat {
785 #[serde(default)]
786 #[serde(skip_serializing_if = "Option::is_none")]
787 cpu_percent: Option<f32>,
788 #[serde(default)]
789 #[serde(skip_serializing_if = "Option::is_none")]
790 memory_mb: Option<u64>,
791 },
792 Artifact(FleetArtifactRef),
793 Completed {
794 #[serde(default)]
795 #[serde(skip_serializing_if = "Option::is_none")]
796 exit_code: Option<i32>,
797 #[serde(skip_serializing_if = "Option::is_none")]
798 summary: Option<String>,
799 },
800 Failed {
801 reason: String,
802 #[serde(default)]
803 recoverable: bool,
804 },
805 Cancelled {
806 #[serde(skip_serializing_if = "Option::is_none")]
807 cancelled_by: Option<String>,
808 },
809 Interrupted {
810 #[serde(skip_serializing_if = "Option::is_none")]
811 signal: Option<String>,
812 },
813 Stale {
814 #[serde(skip_serializing_if = "Option::is_none")]
815 last_heartbeat_at: Option<String>,
816 },
817 Restarted {
818 #[serde(default)]
819 restart_count: u32,
820 },
821 Escalated {
822 channel: String,
823 #[serde(skip_serializing_if = "Option::is_none")]
824 alert_id: Option<String>,
825 },
826}
827
828#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
830pub struct FleetRetryPolicy {
831 #[serde(default = "default_retry_max_attempts")]
832 pub max_attempts: u32,
833 #[serde(default = "default_retry_initial_backoff_seconds")]
834 pub initial_backoff_seconds: u64,
835 #[serde(default = "default_retry_max_backoff_seconds")]
836 pub max_backoff_seconds: u64,
837 #[serde(default = "default_retry_backoff_multiplier")]
838 pub backoff_multiplier: u32,
839}
840
841impl Default for FleetRetryPolicy {
842 fn default() -> Self {
843 Self {
844 max_attempts: 3,
845 initial_backoff_seconds: 5,
846 max_backoff_seconds: 300,
847 backoff_multiplier: 2,
848 }
849 }
850}
851
852fn default_retry_max_attempts() -> u32 {
853 FleetRetryPolicy::default().max_attempts
854}
855
856fn default_retry_initial_backoff_seconds() -> u64 {
857 FleetRetryPolicy::default().initial_backoff_seconds
858}
859
860fn default_retry_max_backoff_seconds() -> u64 {
861 FleetRetryPolicy::default().max_backoff_seconds
862}
863
864fn default_retry_backoff_multiplier() -> u32 {
865 FleetRetryPolicy::default().backoff_multiplier
866}
867
868#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
870pub struct FleetAlertPolicy {
871 #[serde(default)]
872 #[serde(skip_serializing_if = "Vec::is_empty")]
873 pub events: Vec<FleetAlertEventClass>,
874 #[serde(default)]
875 pub channels: Vec<FleetAlertChannel>,
876 #[serde(default)]
877 pub after_attempts: Option<u32>,
878 #[serde(default)]
879 pub after_minutes_stale: Option<u64>,
880}
881
882#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord, Hash)]
883#[serde(rename_all = "snake_case")]
884pub enum FleetAlertEventClass {
885 Stale,
886 RestartExhausted,
887 NeedsHuman,
888 BudgetExceeded,
889 VerifierFailed,
890 RunCompleted,
891}
892
893#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
894#[serde(tag = "kind", rename_all = "snake_case")]
895pub enum FleetAlertChannel {
896 Slack {
897 #[serde(flatten)]
899 webhook: FleetAlertEndpoint,
900 },
901 Webhook {
902 #[serde(flatten)]
903 endpoint: FleetAlertEndpoint,
904 },
905 #[serde(alias = "pager_duty")]
906 #[serde(alias = "pagerduty")]
907 PagerDuty {
908 routing_key: String,
909 severity: String,
910 },
911}
912
913#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
919pub struct FleetAlertEndpoint {
920 #[serde(
922 alias = "webhook_url",
923 alias = "endpoint_url",
924 skip_serializing_if = "Option::is_none"
925 )]
926 pub url: Option<String>,
927 #[serde(
929 alias = "webhook_url_ref",
930 alias = "webhook_ref",
931 alias = "url_secret_ref",
932 skip_serializing_if = "Option::is_none"
933 )]
934 pub url_ref: Option<FleetSecretRef>,
935 #[serde(
937 alias = "secret",
938 alias = "webhook_secret",
939 alias = "signing_secret",
940 skip_serializing_if = "Option::is_none"
941 )]
942 pub secret_ref: Option<FleetSecretRef>,
943}
944
945impl FleetAlertEndpoint {
946 #[must_use]
948 pub fn inline(url: impl Into<String>) -> Self {
949 Self {
950 url: Some(url.into()),
951 url_ref: None,
952 secret_ref: None,
953 }
954 }
955
956 #[must_use]
958 pub fn from_secret(url_ref: FleetSecretRef) -> Self {
959 Self {
960 url: None,
961 url_ref: Some(url_ref),
962 secret_ref: None,
963 }
964 }
965
966 #[must_use]
968 pub fn redacted(&self) -> String {
969 self.url_ref
970 .as_ref()
971 .map_or_else(|| "<inline-url>".to_string(), |r| r.redacted())
972 }
973}
974
975#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
988pub struct FleetResolvedRoute {
989 pub provider_id: String,
991 #[serde(default, skip_serializing_if = "Option::is_none")]
999 pub provider_exact_id: Option<String>,
1000 pub provider_kind: String,
1002 #[serde(default, skip_serializing_if = "Option::is_none")]
1004 pub canonical_model: Option<String>,
1005 pub wire_model_id: String,
1007 pub protocol: String,
1009 #[serde(default, skip_serializing_if = "Option::is_none")]
1011 pub role: Option<String>,
1012 #[serde(default, skip_serializing_if = "Option::is_none")]
1014 pub loadout: Option<String>,
1015 #[serde(default, skip_serializing_if = "Option::is_none")]
1018 pub model_class: Option<String>,
1019 #[serde(default, skip_serializing_if = "Option::is_none")]
1022 pub model_route: Option<String>,
1023 #[serde(default, skip_serializing_if = "Option::is_none")]
1025 pub reasoning_effort: Option<String>,
1026 #[serde(default, skip_serializing_if = "Option::is_none")]
1028 pub role_source: Option<String>,
1029 #[serde(default, skip_serializing_if = "Option::is_none")]
1031 pub loadout_source: Option<String>,
1032 #[serde(default, skip_serializing_if = "Option::is_none")]
1034 pub model_class_source: Option<String>,
1035 #[serde(default, skip_serializing_if = "Option::is_none")]
1037 pub model_source: Option<String>,
1038 pub source: String,
1040}
1041
1042#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1048pub struct FleetEffectivePermissions {
1049 pub write: bool,
1051 pub network: bool,
1053 pub shell: String,
1055 pub tool_scope: String,
1057 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1059 pub tools: Vec<String>,
1060 pub background: bool,
1062 pub max_spawn_depth: u32,
1064 #[serde(default, skip_serializing_if = "Option::is_none")]
1066 pub profile_id: Option<String>,
1067 #[serde(default, skip_serializing_if = "Option::is_none")]
1069 pub profile_origin: Option<String>,
1070 pub source: String,
1072}
1073
1074#[derive(Debug, Clone, Serialize, Deserialize)]
1076pub struct FleetReceipt {
1077 pub run_id: FleetRunId,
1078 pub task_id: String,
1079 pub worker_id: String,
1080 #[serde(default, skip_serializing_if = "Option::is_none")]
1085 pub attempt: Option<u32>,
1086 #[serde(default, skip_serializing_if = "Option::is_none")]
1090 pub terminal_seq: Option<u64>,
1091 pub completed_at: String,
1092 pub result: FleetTaskResult,
1093 #[serde(skip_serializing_if = "Option::is_none")]
1094 pub failure_kind: Option<FleetTaskFailureKind>,
1095 #[serde(default)]
1096 pub artifacts: Vec<FleetArtifactRef>,
1097 #[serde(default)]
1098 pub score: Option<FleetScore>,
1099 #[serde(default, skip_serializing_if = "Option::is_none")]
1104 pub resolved_route: Option<FleetResolvedRoute>,
1105 #[serde(default, skip_serializing_if = "Option::is_none")]
1107 pub effective_permissions: Option<FleetEffectivePermissions>,
1108}
1109
1110#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1111#[serde(rename_all = "snake_case")]
1112pub enum FleetTaskResult {
1113 Pass,
1114 Partial,
1115 Fail,
1116 Skip,
1117 Timeout,
1118}
1119
1120#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1122#[serde(rename_all = "snake_case")]
1123pub enum FleetTaskFailureKind {
1124 Transport,
1125 Task,
1126 Verifier,
1127}
1128
1129#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1130pub struct FleetScore {
1131 pub value: f64,
1132 #[serde(skip_serializing_if = "Option::is_none")]
1133 pub max: Option<f64>,
1134 #[serde(skip_serializing_if = "Option::is_none")]
1135 pub notes: Option<String>,
1136}
1137
1138#[cfg(test)]
1139mod tests {
1140 use super::*;
1141
1142 #[test]
1143 fn fleet_run_round_trip() {
1144 let run = FleetRun {
1145 id: FleetRunId::from("run-001"),
1146 name: "dogfood smoke".to_string(),
1147 status: FleetRunStatus::Running,
1148 target: Some(FleetRuntimeTarget::ThisComputer),
1149 workflow: Some(FleetWorkflowDescriptor {
1150 id: "release-checks".to_string(),
1151 kind: FleetWorkflowKind::Parallel,
1152 }),
1153 roles: vec!["release-checker".to_string()],
1154 max_workers: Some(1),
1155 task_specs: vec![FleetTaskSpec {
1156 id: "task-1".to_string(),
1157 name: "lint".to_string(),
1158 description: None,
1159 objective: Some("Keep the workspace lint-clean".to_string()),
1160 instructions: "run cargo clippy".to_string(),
1161 worker: Some(FleetTaskWorkerProfile {
1162 agent_profile: None,
1163 role: Some("release-checker".to_string()),
1164 loadout: None,
1165 model_class: None,
1166 model: None,
1167 tool_profile: Some("read-only".to_string()),
1168 tools: vec!["cargo".to_string()],
1169 capabilities: vec!["rust".to_string()],
1170 }),
1171 workspace: Some(FleetWorkspaceRequirements {
1172 root: Some(PathBuf::from(".")),
1173 required_files: vec![PathBuf::from("Cargo.toml")],
1174 writable_paths: vec![],
1175 environment: Some(FleetEnvironmentRequirements {
1176 required: vec!["PATH".to_string()],
1177 allowlist: vec!["RUST_LOG".to_string()],
1178 }),
1179 }),
1180 input_files: vec![PathBuf::from("crates/tui/src/main.rs")],
1181 context: vec!["release gate".to_string()],
1182 budget: Some(FleetTaskBudget {
1183 max_tokens: Some(8000),
1184 max_steps: Some(0),
1185 max_tool_calls: Some(20),
1186 max_seconds: Some(300),
1187 }),
1188 tags: vec!["release".to_string()],
1189 expected_artifacts: vec![FleetArtifactKind::Log],
1190 scorer: Some(FleetScorerSpec::ExitCode),
1191 retry_policy: Some(FleetRetryPolicy::default()),
1192 alert_policy: None,
1193 timeout_seconds: Some(300),
1194 metadata: BTreeMap::new(),
1195 }],
1196 worker_specs: vec![],
1197 labels: BTreeMap::new(),
1198 security_policy: None,
1199 created_at: "2026-06-12T17:00:00Z".to_string(),
1200 updated_at: None,
1201 completed_at: None,
1202 };
1203 let json = serde_json::to_string(&run).unwrap();
1204 let back: FleetRun = serde_json::from_str(&json).unwrap();
1205 assert_eq!(back.id, run.id);
1206 assert_eq!(back.status, FleetRunStatus::Running);
1207 assert_eq!(back.target, Some(FleetRuntimeTarget::ThisComputer));
1208 assert_eq!(back.roles, vec!["release-checker"]);
1209 assert_eq!(
1210 back.workflow.as_ref().map(|workflow| workflow.id.as_str()),
1211 Some("release-checks")
1212 );
1213 assert_eq!(back.task_specs.len(), 1);
1214 assert_eq!(
1215 back.task_specs[0].budget.as_ref().unwrap().max_steps,
1216 Some(0)
1217 );
1218 assert_eq!(
1219 back.task_specs[0].worker.as_ref().unwrap().role.as_deref(),
1220 Some("release-checker")
1221 );
1222 assert_eq!(
1223 back.task_specs[0]
1224 .workspace
1225 .as_ref()
1226 .unwrap()
1227 .required_files,
1228 vec![PathBuf::from("Cargo.toml")]
1229 );
1230 }
1231
1232 #[test]
1233 fn worker_profile_carries_agent_profile_and_loadout_intent() {
1234 let json = r#"{
1235 "profile": "adversarial_reviewer",
1236 "role": "reviewer",
1237 "loadout": "auto",
1238 "model_class": "balanced",
1239 "model": "deepseek-v4-pro",
1240 "tool_profile": "read-only",
1241 "tools": ["read_file"],
1242 "capabilities": ["rust"]
1243 }"#;
1244
1245 let profile: FleetTaskWorkerProfile = serde_json::from_str(json).unwrap();
1246
1247 assert_eq!(
1248 profile.agent_profile.as_deref(),
1249 Some("adversarial_reviewer")
1250 );
1251 assert_eq!(profile.role.as_deref(), Some("reviewer"));
1252 assert_eq!(profile.loadout.as_deref(), Some("auto"));
1253 assert_eq!(profile.model_class.as_deref(), Some("balanced"));
1254 assert_eq!(profile.model.as_deref(), Some("deepseek-v4-pro"));
1255 assert_eq!(profile.tool_profile.as_deref(), Some("read-only"));
1256
1257 let serialized = serde_json::to_value(&profile).unwrap();
1258 assert_eq!(serialized["agent_profile"], "adversarial_reviewer");
1259 assert_eq!(serialized["model"], "deepseek-v4-pro");
1260 assert!(serialized.get("profile").is_none());
1261 }
1262
1263 #[test]
1264 fn worker_event_lifecycle_round_trip() {
1265 let events = vec![
1266 FleetWorkerEvent {
1267 seq: 1,
1268 run_id: FleetRunId::from("run-002"),
1269 worker_id: "worker-a".to_string(),
1270 task_id: "task-1".to_string(),
1271 timestamp: "2026-06-12T17:01:00Z".to_string(),
1272 payload: FleetWorkerEventPayload::Queued,
1273 extra: BTreeMap::new(),
1274 },
1275 FleetWorkerEvent {
1276 seq: 2,
1277 run_id: FleetRunId::from("run-002"),
1278 worker_id: "worker-a".to_string(),
1279 task_id: "task-1".to_string(),
1280 timestamp: "2026-06-12T17:01:05Z".to_string(),
1281 payload: FleetWorkerEventPayload::RunningTool {
1282 tool: "bash".to_string(),
1283 call_id: Some("call-1".to_string()),
1284 },
1285 extra: BTreeMap::new(),
1286 },
1287 FleetWorkerEvent {
1288 seq: 3,
1289 run_id: FleetRunId::from("run-002"),
1290 worker_id: "worker-a".to_string(),
1291 task_id: "task-1".to_string(),
1292 timestamp: "2026-06-12T17:02:00Z".to_string(),
1293 payload: FleetWorkerEventPayload::Completed {
1294 exit_code: Some(0),
1295 summary: Some("ok".to_string()),
1296 },
1297 extra: BTreeMap::new(),
1298 },
1299 ];
1300 let json = serde_json::to_string(&events).unwrap();
1301 let back: Vec<FleetWorkerEvent> = serde_json::from_str(&json).unwrap();
1302 assert_eq!(back.len(), 3);
1303 assert!(matches!(back[0].payload, FleetWorkerEventPayload::Queued));
1304 assert!(matches!(
1305 back[2].payload,
1306 FleetWorkerEventPayload::Completed { .. }
1307 ));
1308 }
1309
1310 #[test]
1311 fn workflow_receipt_round_trip_keeps_outer_and_inner_run_ids_distinct() {
1312 let event = FleetWorkerEvent {
1313 seq: 3,
1314 run_id: FleetRunId::from("fleet-run-1"),
1315 worker_id: "worker-a".to_string(),
1316 task_id: "task-1".to_string(),
1317 timestamp: "2026-07-10T00:00:00Z".to_string(),
1318 payload: FleetWorkerEventPayload::WorkflowEvent {
1319 workflow_run_id: "workflow_1".to_string(),
1320 event: serde_json::json!({"type": "task_completed"}),
1321 },
1322 extra: BTreeMap::new(),
1323 };
1324 let value = serde_json::to_value(&event).unwrap();
1325 assert_eq!(value["run_id"], "fleet-run-1");
1326 assert_eq!(value["workflow_run_id"], "workflow_1");
1327 let back: FleetWorkerEvent = serde_json::from_value(value).unwrap();
1328 assert!(matches!(
1329 back.payload,
1330 FleetWorkerEventPayload::WorkflowEvent {
1331 workflow_run_id,
1332 ref event,
1333 } if workflow_run_id == "workflow_1" && event["type"] == "task_completed"
1334 ));
1335 }
1336
1337 #[test]
1338 fn alert_policy_round_trip() {
1339 let policy = FleetAlertPolicy {
1340 events: vec![FleetAlertEventClass::Stale],
1341 channels: vec![FleetAlertChannel::Slack {
1342 webhook: FleetAlertEndpoint::inline("https://hooks.slack.com/test"),
1343 }],
1344 after_attempts: Some(2),
1345 after_minutes_stale: Some(10),
1346 };
1347 let json = serde_json::to_string(&policy).unwrap();
1348 assert!(json.contains("\"events\":[\"stale\"]"));
1349 assert!(json.contains("\"kind\":\"slack\""));
1350 let back: FleetAlertPolicy = serde_json::from_str(&json).unwrap();
1351 assert_eq!(back.events, vec![FleetAlertEventClass::Stale]);
1352 assert_eq!(back.after_attempts, Some(2));
1353 }
1354
1355 #[test]
1356 fn artifact_other_kind_round_trip() {
1357 let artifact = FleetArtifactRef {
1358 kind: FleetArtifactKind::Other("coverage.xml".to_string()),
1359 path: PathBuf::from("/tmp/coverage.xml"),
1360 checksum: Some("sha256:abc".to_string()),
1361 mime_type: Some("application/xml".to_string()),
1362 size_bytes: Some(1024),
1363 };
1364 let json = serde_json::to_string(&artifact).unwrap();
1365 let back: FleetArtifactRef = serde_json::from_str(&json).unwrap();
1366 assert_eq!(back.kind, artifact.kind);
1367 assert_eq!(back.size_bytes, Some(1024));
1368 }
1369
1370 #[test]
1371 fn ssh_host_spec_accepts_minimal_legacy_json() {
1372 let json = r#"{"kind":"ssh","host":"builder.example.test"}"#;
1373 let host: FleetHostSpec = serde_json::from_str(json).unwrap();
1374
1375 match host {
1376 FleetHostSpec::Ssh {
1377 host,
1378 port,
1379 user,
1380 identity,
1381 known_hosts,
1382 host_key_fingerprint,
1383 working_directory,
1384 env_allowlist,
1385 codewhale_binary,
1386 } => {
1387 assert_eq!(host, "builder.example.test");
1388 assert_eq!(port, None);
1389 assert_eq!(user, None);
1390 assert_eq!(identity, None);
1391 assert_eq!(known_hosts, None);
1392 assert_eq!(host_key_fingerprint, None);
1393 assert_eq!(working_directory, None);
1394 assert!(env_allowlist.is_empty());
1395 assert_eq!(codewhale_binary, None);
1396 }
1397 other => panic!("expected ssh host spec, got {other:?}"),
1398 }
1399 }
1400
1401 #[test]
1402 fn artifact_kind_uses_flat_string_json() {
1403 let known = serde_json::to_string(&FleetArtifactKind::TestResult).unwrap();
1404 assert_eq!(known, "\"test_result\"");
1405
1406 let custom =
1407 serde_json::to_string(&FleetArtifactKind::Other("coverage.xml".to_string())).unwrap();
1408 assert_eq!(custom, "\"coverage.xml\"");
1409
1410 let parsed: FleetArtifactKind = serde_json::from_str("\"coverage.xml\"").unwrap();
1411 assert_eq!(parsed, FleetArtifactKind::Other("coverage.xml".to_string()));
1412 }
1413
1414 #[test]
1415 fn retry_policy_missing_fields_use_nonzero_defaults() {
1416 let policy: FleetRetryPolicy = serde_json::from_value(serde_json::json!({})).unwrap();
1417 assert_eq!(policy, FleetRetryPolicy::default());
1418
1419 let policy: FleetRetryPolicy =
1420 serde_json::from_value(serde_json::json!({"max_attempts": 5})).unwrap();
1421 assert_eq!(policy.max_attempts, 5);
1422 assert_eq!(
1423 policy.initial_backoff_seconds,
1424 FleetRetryPolicy::default().initial_backoff_seconds
1425 );
1426 assert_eq!(
1427 policy.max_backoff_seconds,
1428 FleetRetryPolicy::default().max_backoff_seconds
1429 );
1430 assert_eq!(
1431 policy.backoff_multiplier,
1432 FleetRetryPolicy::default().backoff_multiplier
1433 );
1434 }
1435
1436 #[test]
1437 fn sparse_worker_events_omit_absent_optional_fields() {
1438 let heartbeat = FleetWorkerEventPayload::Heartbeat {
1439 cpu_percent: None,
1440 memory_mb: None,
1441 };
1442 let heartbeat_json = serde_json::to_value(&heartbeat).unwrap();
1443 assert_eq!(heartbeat_json, serde_json::json!({"state": "heartbeat"}));
1444
1445 let completed = FleetWorkerEventPayload::Completed {
1446 exit_code: None,
1447 summary: None,
1448 };
1449 let completed_json = serde_json::to_value(&completed).unwrap();
1450 assert_eq!(completed_json, serde_json::json!({"state": "completed"}));
1451 }
1452
1453 #[test]
1454 fn receipt_round_trip() {
1455 let receipt = FleetReceipt {
1456 run_id: FleetRunId::from("run-003"),
1457 task_id: "task-1".to_string(),
1458 worker_id: "worker-b".to_string(),
1459 attempt: Some(2),
1460 terminal_seq: Some(7),
1461 completed_at: "2026-06-12T17:03:00Z".to_string(),
1462 result: FleetTaskResult::Pass,
1463 failure_kind: None,
1464 artifacts: vec![],
1465 score: Some(FleetScore {
1466 value: 0.95,
1467 max: Some(1.0),
1468 notes: None,
1469 }),
1470 resolved_route: None,
1471 effective_permissions: None,
1472 };
1473 let json = serde_json::to_string(&receipt).unwrap();
1474 let back: FleetReceipt = serde_json::from_str(&json).unwrap();
1475 assert_eq!(back.result, FleetTaskResult::Pass);
1476 assert_eq!(back.score.as_ref().unwrap().value, 0.95);
1477 assert_eq!(back.attempt, Some(2));
1478 assert_eq!(back.terminal_seq, Some(7));
1479 }
1480
1481 #[test]
1482 fn partial_receipt_records_failure_source_when_needed() {
1483 let receipt = FleetReceipt {
1484 run_id: FleetRunId::from("run-004"),
1485 task_id: "task-2".to_string(),
1486 worker_id: "worker-c".to_string(),
1487 attempt: None,
1488 terminal_seq: None,
1489 completed_at: "2026-06-12T17:04:00Z".to_string(),
1490 result: FleetTaskResult::Partial,
1491 failure_kind: Some(FleetTaskFailureKind::Verifier),
1492 artifacts: vec![],
1493 score: Some(FleetScore {
1494 value: 0.5,
1495 max: Some(1.0),
1496 notes: Some("manual verification required".to_string()),
1497 }),
1498 resolved_route: None,
1499 effective_permissions: None,
1500 };
1501
1502 let json = serde_json::to_string(&receipt).unwrap();
1503 assert!(json.contains("\"result\":\"partial\""));
1504 assert!(json.contains("\"failure_kind\":\"verifier\""));
1505 let back: FleetReceipt = serde_json::from_str(&json).unwrap();
1506 assert_eq!(back.result, FleetTaskResult::Partial);
1507 assert_eq!(back.failure_kind, Some(FleetTaskFailureKind::Verifier));
1508 }
1509
1510 #[test]
1511 fn ssh_host_spec_with_key_pinning_round_trip() {
1512 let spec = FleetHostSpec::Ssh {
1513 host: "builder.trusted.example.com".to_string(),
1514 port: Some(22),
1515 user: Some("codewhale".to_string()),
1516 identity: Some(PathBuf::from("~/.ssh/codewhale_fleet")),
1517 known_hosts: Some(PathBuf::from("~/.ssh/known_hosts")),
1518 host_key_fingerprint: Some("SHA256:aLGqZo1M6c...".to_string()),
1519 working_directory: Some(PathBuf::from("/srv/codewhale/work")),
1520 env_allowlist: vec!["CODEWHALE_PROFILE".to_string()],
1521 codewhale_binary: Some("/usr/local/bin/codewhale".to_string()),
1522 };
1523 let json = serde_json::to_string_pretty(&spec).unwrap();
1524 assert!(json.contains("\"known_hosts\""));
1525 assert!(json.contains("\"host_key_fingerprint\""));
1526 assert!(json.contains("SHA256:aLGqZo1M6c..."));
1527
1528 let back: FleetHostSpec = serde_json::from_str(&json).unwrap();
1529 match back {
1530 FleetHostSpec::Ssh {
1531 host,
1532 known_hosts,
1533 host_key_fingerprint,
1534 ..
1535 } => {
1536 assert_eq!(host, "builder.trusted.example.com");
1537 assert_eq!(known_hosts, Some(PathBuf::from("~/.ssh/known_hosts")));
1538 assert_eq!(
1539 host_key_fingerprint,
1540 Some("SHA256:aLGqZo1M6c...".to_string())
1541 );
1542 }
1543 other => panic!("expected ssh host spec, got {other:?}"),
1544 }
1545 }
1546
1547 #[test]
1548 fn secret_ref_redacted_never_exposes_value() {
1549 let ref_ = FleetSecretRef::new("DEEPSEEK_API_KEY");
1550 let redacted = ref_.redacted();
1551 assert!(redacted.contains("DEEPSEEK_API_KEY"));
1552 assert!(!redacted.contains("sk-"));
1553 assert!(redacted.contains("<secret:"));
1554
1555 let ref_ = FleetSecretRef::with_source("GH_TOKEN", "env");
1556 let redacted = ref_.redacted();
1557 assert!(redacted.contains("env.GH_TOKEN"));
1558 assert!(!redacted.contains("ghp_"));
1559 }
1560
1561 #[test]
1562 fn alert_endpoint_from_secret_round_trip() {
1563 let endpoint = FleetAlertEndpoint::from_secret(FleetSecretRef::new("SLACK_WEBHOOK"));
1564 let json = serde_json::to_string(&endpoint).unwrap();
1565 assert!(json.contains("SLACK_WEBHOOK"));
1566 assert!(!json.contains("hooks.slack.com"));
1567
1568 let back: FleetAlertEndpoint = serde_json::from_str(&json).unwrap();
1569 assert_eq!(back.url_ref.as_ref().unwrap().key, "SLACK_WEBHOOK");
1570 assert_eq!(back.url, None);
1571 }
1572
1573 #[test]
1574 fn secret_ref_accepts_legacy_string_wire_shape() {
1575 let ref_: FleetSecretRef = serde_json::from_str(r#""CODEWHALE_FLEET_TOKEN""#).unwrap();
1576 assert_eq!(ref_, FleetSecretRef::new("CODEWHALE_FLEET_TOKEN"));
1577
1578 let ref_: FleetSecretRef =
1579 serde_json::from_str(r#"{"key":"GH_TOKEN","source":"env"}"#).unwrap();
1580 assert_eq!(ref_, FleetSecretRef::with_source("GH_TOKEN", "env"));
1581 }
1582
1583 #[test]
1584 fn trust_level_accepts_hyphenated_remote_verified() {
1585 let trust: FleetTrustLevel = serde_json::from_str(r#""remote-verified""#).unwrap();
1586 assert_eq!(trust, FleetTrustLevel::RemoteVerified);
1587
1588 let canonical = serde_json::to_string(&trust).unwrap();
1589 assert_eq!(canonical, r#""remote_verified""#);
1590 }
1591
1592 #[test]
1593 fn alert_channel_accepts_legacy_webhook_fields() {
1594 let channel: FleetAlertChannel = serde_json::from_str(
1595 r#"{
1596 "kind": "slack",
1597 "webhook_url": "https://hooks.slack.com/test",
1598 "secret": "SLACK_SIGNING_SECRET"
1599 }"#,
1600 )
1601 .unwrap();
1602
1603 match channel {
1604 FleetAlertChannel::Slack { webhook } => {
1605 assert_eq!(webhook.url.as_deref(), Some("https://hooks.slack.com/test"));
1606 assert_eq!(
1607 webhook.secret_ref,
1608 Some(FleetSecretRef::new("SLACK_SIGNING_SECRET"))
1609 );
1610 }
1611 other => panic!("expected slack channel, got {other:?}"),
1612 }
1613 }
1614
1615 #[test]
1616 fn security_policy_defaults_are_conservative() {
1617 let policy = FleetSecurityPolicy::default();
1618 assert_eq!(policy.default_trust_level, FleetTrustLevel::Sandbox);
1619 assert!(policy.allowed_secrets.is_empty());
1620 assert!(policy.capability_grants.is_empty());
1621 assert_eq!(policy.max_trust_level, FleetTrustLevel::Operator);
1622 assert!(!policy.require_identity_verification);
1623 }
1624
1625 #[test]
1626 fn trust_level_ordinal_reflects_privilege() {
1627 assert!(FleetTrustLevel::Operator > FleetTrustLevel::RemoteVerified);
1628 assert!(FleetTrustLevel::RemoteVerified > FleetTrustLevel::Local);
1629 assert!(FleetTrustLevel::Local > FleetTrustLevel::Sandbox);
1630
1631 assert!(FleetTrustLevel::Operator.may_access_secrets());
1632 assert!(!FleetTrustLevel::Sandbox.may_access_secrets());
1633 assert!(!FleetTrustLevel::Sandbox.may_write_workspace());
1634 assert!(FleetTrustLevel::Operator.may_write_workspace());
1635 }
1636
1637 fn sample_receipt_with_route() -> FleetReceipt {
1638 FleetReceipt {
1639 run_id: FleetRunId::from("run-route"),
1640 task_id: "task-route".to_string(),
1641 worker_id: "worker-route".to_string(),
1642 attempt: Some(1),
1643 terminal_seq: Some(4),
1644 completed_at: "2026-06-23T00:00:00Z".to_string(),
1645 result: FleetTaskResult::Pass,
1646 failure_kind: None,
1647 artifacts: vec![],
1648 score: None,
1649 resolved_route: Some(FleetResolvedRoute {
1650 provider_id: "deepseek".to_string(),
1651 provider_exact_id: None,
1652 provider_kind: "deepseek".to_string(),
1653 canonical_model: Some("deepseek-v4-pro".to_string()),
1654 wire_model_id: "deepseek-v4-pro".to_string(),
1655 protocol: "chat_completions".to_string(),
1656 role: Some("builder".to_string()),
1657 loadout: Some("auto".to_string()),
1658 model_class: Some("balanced".to_string()),
1659 model_route: Some("auto".to_string()),
1660 reasoning_effort: Some("high".to_string()),
1661 role_source: Some("task.role".to_string()),
1662 loadout_source: Some("task.loadout".to_string()),
1663 model_class_source: Some("task.model_class".to_string()),
1664 model_source: Some("task.model".to_string()),
1665 source: "resolver".to_string(),
1666 }),
1667 effective_permissions: Some(FleetEffectivePermissions {
1668 write: true,
1669 network: true,
1670 shell: "full".to_string(),
1671 tool_scope: "explicit".to_string(),
1672 tools: vec!["read_file".to_string(), "apply_patch".to_string()],
1673 background: true,
1674 max_spawn_depth: 2,
1675 profile_id: Some("builder".to_string()),
1676 profile_origin: Some("built_in".to_string()),
1677 source: "worker_runtime_profile".to_string(),
1678 }),
1679 }
1680 }
1681
1682 #[test]
1683 fn fleet_resolved_route_round_trips() {
1684 let receipt = sample_receipt_with_route();
1685 let json = serde_json::to_string(&receipt).unwrap();
1686 let back: FleetReceipt = serde_json::from_str(&json).unwrap();
1687 assert_eq!(back.resolved_route, receipt.resolved_route);
1688 assert_eq!(back.effective_permissions, receipt.effective_permissions);
1689 let route = back.resolved_route.unwrap();
1690 assert_eq!(route.provider_id, "deepseek");
1691 assert_eq!(route.wire_model_id, "deepseek-v4-pro");
1692 assert_eq!(route.protocol, "chat_completions");
1693 assert_eq!(route.role.as_deref(), Some("builder"));
1694 assert_eq!(route.loadout.as_deref(), Some("auto"));
1695 assert_eq!(route.model_class.as_deref(), Some("balanced"));
1696 assert_eq!(route.model_route.as_deref(), Some("auto"));
1697 assert_eq!(route.reasoning_effort.as_deref(), Some("high"));
1698 assert_eq!(route.role_source.as_deref(), Some("task.role"));
1699 assert_eq!(route.loadout_source.as_deref(), Some("task.loadout"));
1700 assert_eq!(
1701 route.model_class_source.as_deref(),
1702 Some("task.model_class")
1703 );
1704 assert_eq!(route.model_source.as_deref(), Some("task.model"));
1705 assert_eq!(route.source, "resolver");
1706
1707 let permissions = back
1708 .effective_permissions
1709 .expect("effective permissions should round-trip");
1710 assert!(permissions.write);
1711 assert!(permissions.network);
1712 assert_eq!(permissions.shell, "full");
1713 assert_eq!(permissions.tool_scope, "explicit");
1714 assert_eq!(
1715 permissions.tools,
1716 vec!["read_file".to_string(), "apply_patch".to_string()]
1717 );
1718 assert!(permissions.background);
1719 assert_eq!(permissions.max_spawn_depth, 2);
1720 assert_eq!(permissions.profile_id.as_deref(), Some("builder"));
1721 assert_eq!(permissions.profile_origin.as_deref(), Some("built_in"));
1722 assert_eq!(permissions.source, "worker_runtime_profile");
1723 }
1724
1725 #[test]
1726 fn fleet_receipt_without_resolved_route_still_deserializes() {
1727 let legacy = r#"{
1730 "run_id": "run-legacy",
1731 "task_id": "task-legacy",
1732 "worker_id": "worker-legacy",
1733 "completed_at": "2026-06-01T00:00:00Z",
1734 "result": "pass",
1735 "artifacts": [],
1736 "score": null
1737 }"#;
1738 let receipt: FleetReceipt = serde_json::from_str(legacy).unwrap();
1739 assert_eq!(receipt.task_id, "task-legacy");
1740 assert!(receipt.resolved_route.is_none());
1741 assert!(receipt.attempt.is_none());
1742 assert!(receipt.terminal_seq.is_none());
1743 }
1744
1745 #[test]
1746 fn fleet_resolved_route_legacy_shape_still_deserializes() {
1747 let legacy = r#"{
1748 "run_id": "run-route",
1749 "task_id": "task-route",
1750 "worker_id": "worker-route",
1751 "completed_at": "2026-06-23T00:00:00Z",
1752 "result": "pass",
1753 "artifacts": [],
1754 "score": null,
1755 "resolved_route": {
1756 "provider_id": "deepseek",
1757 "provider_kind": "deepseek",
1758 "canonical_model": "deepseek-v4-pro",
1759 "wire_model_id": "deepseek-v4-pro",
1760 "protocol": "chat_completions",
1761 "role": "builder",
1762 "loadout": "fast",
1763 "source": "resolver"
1764 }
1765 }"#;
1766
1767 let receipt: FleetReceipt = serde_json::from_str(legacy).unwrap();
1768 let route = receipt.resolved_route.expect("legacy route should parse");
1769 assert_eq!(route.source, "resolver");
1770 assert_eq!(route.role.as_deref(), Some("builder"));
1771 assert_eq!(route.loadout.as_deref(), Some("fast"));
1772 assert_eq!(route.model_class, None);
1773 assert_eq!(route.model_route, None);
1774 assert_eq!(route.reasoning_effort, None);
1775 assert_eq!(route.role_source, None);
1776 assert_eq!(route.loadout_source, None);
1777 assert_eq!(route.model_class_source, None);
1778 assert_eq!(route.model_source, None);
1779 }
1780
1781 #[test]
1782 fn fleet_resolved_route_serialization_carries_no_secrets() {
1783 let receipt = sample_receipt_with_route();
1784 let route_json = serde_json::to_string(receipt.resolved_route.as_ref().unwrap()).unwrap();
1789 assert_no_secret_markers(&route_json);
1790 let receipt_json = serde_json::to_string(&receipt).unwrap();
1792 for needle in SECRET_KEY_MARKERS {
1793 assert!(
1794 !receipt_json.to_ascii_lowercase().contains(needle),
1795 "receipt JSON must not contain secret-key marker {needle:?}: {receipt_json}"
1796 );
1797 }
1798 }
1799
1800 const SECRET_KEY_MARKERS: &[&str] = &[
1803 "api_key",
1804 "apikey",
1805 "api-key",
1806 "authorization",
1807 "bearer ",
1808 "auth_token",
1809 "auth-token",
1810 "password",
1811 "credential",
1812 "sk-ant-",
1813 "sk-proj-",
1814 "sk-or-",
1815 "secret",
1816 ];
1817
1818 fn assert_no_secret_markers(json: &str) {
1819 let haystack = json.to_ascii_lowercase();
1820 for needle in SECRET_KEY_MARKERS {
1821 assert!(
1822 !haystack.contains(needle),
1823 "resolved-route JSON must not contain secret marker {needle:?}: {json}"
1824 );
1825 }
1826 }
1827}