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")]
49 pub max_workers: Option<usize>,
50 #[serde(default)]
51 pub task_specs: Vec<FleetTaskSpec>,
52 #[serde(default)]
53 pub worker_specs: Vec<FleetWorkerSpec>,
54 #[serde(default)]
55 pub labels: BTreeMap<String, String>,
56 #[serde(skip_serializing_if = "Option::is_none")]
57 pub security_policy: Option<FleetSecurityPolicy>,
58 pub created_at: String,
59 #[serde(skip_serializing_if = "Option::is_none")]
60 pub updated_at: Option<String>,
61 #[serde(skip_serializing_if = "Option::is_none")]
62 pub completed_at: Option<String>,
63}
64
65#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
67#[serde(rename_all = "snake_case")]
68pub enum FleetRunStatus {
69 Pending,
70 Queued,
71 Running,
72 Paused,
73 Completed,
74 Failed,
75 Cancelled,
76}
77
78impl Status for FleetRunStatus {
79 fn is_terminal(&self) -> bool {
80 matches!(self, Self::Completed | Self::Failed | Self::Cancelled)
81 }
82 fn is_active(&self) -> bool {
83 matches!(self, Self::Pending | Self::Queued | Self::Running)
84 }
85 fn is_paused(&self) -> bool {
86 matches!(self, Self::Paused)
87 }
88}
89
90#[derive(Debug, Clone, Serialize, Deserialize)]
92pub struct FleetTaskSpec {
93 pub id: String,
94 pub name: String,
95 #[serde(skip_serializing_if = "Option::is_none")]
96 pub description: Option<String>,
97 #[serde(skip_serializing_if = "Option::is_none")]
98 pub objective: Option<String>,
99 pub instructions: String,
100 #[serde(skip_serializing_if = "Option::is_none")]
101 pub worker: Option<FleetTaskWorkerProfile>,
102 #[serde(skip_serializing_if = "Option::is_none")]
103 pub workspace: Option<FleetWorkspaceRequirements>,
104 #[serde(default)]
105 #[serde(skip_serializing_if = "Vec::is_empty")]
106 pub input_files: Vec<PathBuf>,
107 #[serde(default)]
108 #[serde(skip_serializing_if = "Vec::is_empty")]
109 pub context: Vec<String>,
110 #[serde(skip_serializing_if = "Option::is_none")]
111 pub budget: Option<FleetTaskBudget>,
112 #[serde(default)]
113 #[serde(skip_serializing_if = "Vec::is_empty")]
114 pub tags: Vec<String>,
115 #[serde(default)]
116 pub expected_artifacts: Vec<FleetArtifactKind>,
117 #[serde(skip_serializing_if = "Option::is_none")]
118 pub scorer: Option<FleetScorerSpec>,
119 #[serde(skip_serializing_if = "Option::is_none")]
120 pub retry_policy: Option<FleetRetryPolicy>,
121 #[serde(skip_serializing_if = "Option::is_none")]
122 pub alert_policy: Option<FleetAlertPolicy>,
123 #[serde(default)]
124 pub timeout_seconds: Option<u64>,
125 #[serde(default)]
126 pub metadata: BTreeMap<String, Value>,
127}
128
129#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
131pub struct FleetTaskWorkerProfile {
132 #[serde(default, alias = "profile", skip_serializing_if = "Option::is_none")]
138 pub agent_profile: Option<String>,
139 #[serde(skip_serializing_if = "Option::is_none")]
140 pub role: Option<String>,
141 #[serde(default, skip_serializing_if = "Option::is_none")]
146 pub loadout: Option<String>,
147 #[serde(default, skip_serializing_if = "Option::is_none")]
149 pub model_class: Option<String>,
150 #[serde(default, skip_serializing_if = "Option::is_none")]
156 pub model: Option<String>,
157 #[serde(skip_serializing_if = "Option::is_none")]
158 pub tool_profile: Option<String>,
159 #[serde(default)]
160 #[serde(skip_serializing_if = "Vec::is_empty")]
161 pub tools: Vec<String>,
162 #[serde(default)]
163 #[serde(skip_serializing_if = "Vec::is_empty")]
164 pub capabilities: Vec<String>,
165}
166
167#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
169pub struct FleetWorkspaceRequirements {
170 #[serde(skip_serializing_if = "Option::is_none")]
171 pub root: Option<PathBuf>,
172 #[serde(default)]
173 #[serde(skip_serializing_if = "Vec::is_empty")]
174 pub required_files: Vec<PathBuf>,
175 #[serde(default)]
176 #[serde(skip_serializing_if = "Vec::is_empty")]
177 pub writable_paths: Vec<PathBuf>,
178 #[serde(skip_serializing_if = "Option::is_none")]
179 pub environment: Option<FleetEnvironmentRequirements>,
180}
181
182#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
184pub struct FleetEnvironmentRequirements {
185 #[serde(default)]
186 #[serde(skip_serializing_if = "Vec::is_empty")]
187 pub required: Vec<String>,
188 #[serde(default)]
189 #[serde(skip_serializing_if = "Vec::is_empty")]
190 pub allowlist: Vec<String>,
191}
192
193#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
195pub struct FleetTaskBudget {
196 #[serde(skip_serializing_if = "Option::is_none")]
197 pub max_tokens: Option<u64>,
198 #[serde(skip_serializing_if = "Option::is_none")]
199 pub max_tool_calls: Option<u32>,
200 #[serde(skip_serializing_if = "Option::is_none")]
201 pub max_seconds: Option<u64>,
202}
203
204#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
206pub struct FleetArtifactRef {
207 pub kind: FleetArtifactKind,
208 pub path: PathBuf,
209 #[serde(skip_serializing_if = "Option::is_none")]
210 pub checksum: Option<String>,
211 #[serde(skip_serializing_if = "Option::is_none")]
212 pub mime_type: Option<String>,
213 #[serde(default)]
214 pub size_bytes: Option<u64>,
215}
216
217#[derive(Debug, Clone, PartialEq, Eq)]
219pub enum FleetArtifactKind {
220 Log,
221 Patch,
222 TestResult,
223 Report,
224 Checkpoint,
225 Receipt,
226 Other(String),
227}
228
229impl FleetArtifactKind {
230 fn as_wire_str(&self) -> &str {
231 match self {
232 Self::Log => "log",
233 Self::Patch => "patch",
234 Self::TestResult => "test_result",
235 Self::Report => "report",
236 Self::Checkpoint => "checkpoint",
237 Self::Receipt => "receipt",
238 Self::Other(kind) => kind.as_str(),
239 }
240 }
241
242 fn from_wire_str(value: &str) -> Self {
243 match value {
244 "log" => Self::Log,
245 "patch" => Self::Patch,
246 "test_result" => Self::TestResult,
247 "report" => Self::Report,
248 "checkpoint" => Self::Checkpoint,
249 "receipt" => Self::Receipt,
250 other => Self::Other(other.to_string()),
251 }
252 }
253}
254
255impl Serialize for FleetArtifactKind {
256 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
257 where
258 S: Serializer,
259 {
260 serializer.serialize_str(self.as_wire_str())
261 }
262}
263
264impl<'de> Deserialize<'de> for FleetArtifactKind {
265 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
266 where
267 D: Deserializer<'de>,
268 {
269 let value = String::deserialize(deserializer)?;
270 Ok(Self::from_wire_str(&value))
271 }
272}
273
274#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
276#[serde(tag = "kind", rename_all = "snake_case")]
277pub enum FleetScorerSpec {
278 ExitCode,
279 FileExists {
280 path: PathBuf,
281 },
282 RegexMatch {
283 path: PathBuf,
284 pattern: String,
285 },
286 JsonPath {
287 path: PathBuf,
288 expression: String,
289 },
290 Command {
291 command: String,
292 #[serde(default)]
293 args: Vec<String>,
294 },
295 CodeWhaleVerifierPrompt {
296 prompt: String,
297 },
298 Manual,
299}
300
301#[derive(Debug, Clone, Serialize, Deserialize)]
303pub struct FleetWorkerSpec {
304 pub id: String,
305 pub name: String,
306 pub host: FleetHostSpec,
307 #[serde(default)]
308 #[serde(skip_serializing_if = "Option::is_none")]
309 pub trust_level: Option<FleetTrustLevel>,
310 #[serde(default)]
311 pub labels: BTreeMap<String, String>,
312 #[serde(default)]
313 pub capabilities: Vec<String>,
314 #[serde(skip_serializing_if = "Option::is_none")]
315 pub max_concurrent_tasks: Option<usize>,
316}
317
318#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
320#[serde(tag = "kind", rename_all = "snake_case")]
321pub enum FleetHostSpec {
322 Local,
323 Ssh {
324 host: String,
325 #[serde(skip_serializing_if = "Option::is_none")]
326 port: Option<u16>,
327 #[serde(skip_serializing_if = "Option::is_none")]
328 user: Option<String>,
329 #[serde(skip_serializing_if = "Option::is_none")]
330 identity: Option<PathBuf>,
331 #[serde(skip_serializing_if = "Option::is_none")]
333 known_hosts: Option<PathBuf>,
334 #[serde(skip_serializing_if = "Option::is_none")]
338 host_key_fingerprint: Option<String>,
339 #[serde(skip_serializing_if = "Option::is_none")]
340 working_directory: Option<PathBuf>,
341 #[serde(default)]
342 #[serde(skip_serializing_if = "Vec::is_empty")]
343 env_allowlist: Vec<String>,
344 #[serde(skip_serializing_if = "Option::is_none")]
345 codewhale_binary: Option<String>,
346 },
347 #[serde(alias = "container")]
348 #[serde(alias = "Container")]
349 Docker {
350 image: String,
351 #[serde(default)]
352 args: Vec<String>,
353 },
354}
355
356#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord, Default)]
364#[serde(rename_all = "snake_case")]
365pub enum FleetTrustLevel {
366 #[default]
369 Sandbox = 0,
370 Local = 1,
373 #[serde(alias = "remote-verified", alias = "remoteVerified")]
377 RemoteVerified = 2,
378 Operator = 3,
382}
383
384impl FleetTrustLevel {
385 #[must_use]
387 pub fn may_access_secrets(&self) -> bool {
388 matches!(self, Self::Operator | Self::RemoteVerified | Self::Local)
389 }
390
391 #[must_use]
393 pub fn may_write_workspace(&self) -> bool {
394 matches!(self, Self::Operator | Self::Local)
395 }
396
397 #[must_use]
399 pub fn may_access_network(&self) -> bool {
400 matches!(self, Self::Operator | Self::RemoteVerified | Self::Local)
401 }
402}
403
404#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
410pub struct FleetSecurityPolicy {
411 #[serde(default)]
413 pub default_trust_level: FleetTrustLevel,
414 #[serde(default)]
417 #[serde(skip_serializing_if = "Vec::is_empty")]
418 pub allowed_secrets: Vec<FleetSecretRef>,
419 #[serde(default)]
421 #[serde(skip_serializing_if = "Vec::is_empty")]
422 pub capability_grants: Vec<FleetCapabilityGrant>,
423 #[serde(default = "default_max_trust_level")]
426 pub max_trust_level: FleetTrustLevel,
427 #[serde(default)]
431 pub require_identity_verification: bool,
432 #[serde(default)]
437 pub allow_parallel_reads: bool,
438}
439
440fn default_max_trust_level() -> FleetTrustLevel {
441 FleetTrustLevel::Operator
442}
443
444impl Default for FleetSecurityPolicy {
445 fn default() -> Self {
446 Self {
447 default_trust_level: FleetTrustLevel::Sandbox,
448 allowed_secrets: Vec::new(),
449 capability_grants: Vec::new(),
450 max_trust_level: FleetTrustLevel::Operator,
451 require_identity_verification: false,
452 allow_parallel_reads: false,
453 }
454 }
455}
456
457#[derive(Debug, Clone, Serialize, PartialEq, Eq, Hash, PartialOrd, Ord)]
465pub struct FleetSecretRef {
466 pub key: String,
468 #[serde(skip_serializing_if = "Option::is_none")]
474 pub source: Option<String>,
475}
476
477impl FleetSecretRef {
478 #[must_use]
480 pub fn new(key: impl Into<String>) -> Self {
481 Self {
482 key: key.into(),
483 source: None,
484 }
485 }
486
487 #[must_use]
489 pub fn with_source(key: impl Into<String>, source: impl Into<String>) -> Self {
490 Self {
491 key: key.into(),
492 source: Some(source.into()),
493 }
494 }
495
496 #[must_use]
499 pub fn redacted(&self) -> String {
500 match &self.source {
501 Some(src) => format!("<secret:{}.{}>", src, self.key),
502 None => format!("<secret:{}>", self.key),
503 }
504 }
505}
506
507impl std::fmt::Display for FleetSecretRef {
508 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
509 write!(f, "{}", self.redacted())
510 }
511}
512
513impl From<&str> for FleetSecretRef {
514 fn from(key: &str) -> Self {
515 Self::new(key)
516 }
517}
518
519impl From<String> for FleetSecretRef {
520 fn from(key: String) -> Self {
521 Self::new(key)
522 }
523}
524
525impl<'de> Deserialize<'de> for FleetSecretRef {
526 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
527 where
528 D: Deserializer<'de>,
529 {
530 #[derive(Deserialize)]
531 #[serde(untagged)]
532 enum SecretRefWire {
533 Key(String),
534 Structured {
535 key: String,
536 #[serde(default)]
537 source: Option<String>,
538 },
539 }
540
541 match SecretRefWire::deserialize(deserializer)? {
542 SecretRefWire::Key(key) if !key.trim().is_empty() => Ok(FleetSecretRef::new(key)),
543 SecretRefWire::Key(_) => Err(de::Error::custom("secret ref key cannot be empty")),
544 SecretRefWire::Structured { key, source } if !key.trim().is_empty() => {
545 Ok(FleetSecretRef { key, source })
546 }
547 SecretRefWire::Structured { .. } => {
548 Err(de::Error::custom("secret ref key cannot be empty"))
549 }
550 }
551 }
552}
553
554#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
556#[serde(tag = "method", rename_all = "snake_case")]
557pub enum FleetWorkerAuth {
558 None,
560 SshKey {
562 identity: PathBuf,
565 #[serde(skip_serializing_if = "Option::is_none")]
567 known_hosts: Option<PathBuf>,
568 #[serde(skip_serializing_if = "Option::is_none")]
570 host_key_fingerprint: Option<String>,
571 #[serde(skip_serializing_if = "Option::is_none")]
573 user: Option<String>,
574 },
575 Token {
577 token_ref: FleetSecretRef,
579 },
580 Mtls {
582 cert_path: PathBuf,
584 key_ref: FleetSecretRef,
586 },
587}
588
589#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
596pub struct FleetCapabilityGrant {
597 pub capability: String,
600 #[serde(skip_serializing_if = "Option::is_none")]
603 pub scope: Option<String>,
604 #[serde(skip_serializing_if = "Option::is_none")]
606 pub reason: Option<String>,
607}
608
609#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
611#[serde(rename_all = "snake_case")]
612pub enum FleetWorkerStatus {
613 Unknown,
614 Online,
615 Busy,
616 Offline,
617 Unhealthy,
618 Draining,
619 Retired,
620}
621
622impl Status for FleetWorkerStatus {
623 fn is_terminal(&self) -> bool {
624 matches!(self, Self::Retired)
625 }
626 fn is_active(&self) -> bool {
627 matches!(self, Self::Online | Self::Busy)
628 }
629 fn is_paused(&self) -> bool {
630 false
631 }
632}
633
634#[derive(Debug, Clone, Serialize, Deserialize)]
636pub struct FleetInboxEntry {
637 pub run_id: FleetRunId,
638 pub task_id: String,
639 pub priority: i32,
640 pub enqueued_at: String,
641 #[serde(default)]
642 pub lease_deadline: Option<String>,
643 #[serde(default)]
644 pub attempts: u32,
645}
646
647#[derive(Debug, Clone, Serialize, Deserialize)]
649pub struct FleetWorkerEvent {
650 pub seq: u64,
651 pub run_id: FleetRunId,
652 pub worker_id: String,
653 pub task_id: String,
654 pub timestamp: String,
655 #[serde(flatten)]
656 pub payload: FleetWorkerEventPayload,
657 #[serde(default)]
658 #[serde(skip_serializing_if = "BTreeMap::is_empty")]
659 pub extra: BTreeMap<String, Value>,
660}
661
662#[derive(Debug, Clone, Serialize, Deserialize)]
664#[serde(tag = "state", rename_all = "snake_case")]
665pub enum FleetWorkerEventPayload {
666 Queued,
667 Leased {
668 #[serde(skip_serializing_if = "Option::is_none")]
669 lease_expires_at: Option<String>,
670 },
671 Starting,
672 Running,
673 ModelWait {
674 #[serde(skip_serializing_if = "Option::is_none")]
675 model: Option<String>,
676 },
677 RunningTool {
678 tool: String,
679 #[serde(skip_serializing_if = "Option::is_none")]
680 call_id: Option<String>,
681 },
682 WorkflowEvent {
684 workflow_run_id: String,
687 event: Value,
688 },
689 Heartbeat {
690 #[serde(default)]
691 #[serde(skip_serializing_if = "Option::is_none")]
692 cpu_percent: Option<f32>,
693 #[serde(default)]
694 #[serde(skip_serializing_if = "Option::is_none")]
695 memory_mb: Option<u64>,
696 },
697 Artifact(FleetArtifactRef),
698 Completed {
699 #[serde(default)]
700 #[serde(skip_serializing_if = "Option::is_none")]
701 exit_code: Option<i32>,
702 #[serde(skip_serializing_if = "Option::is_none")]
703 summary: Option<String>,
704 },
705 Failed {
706 reason: String,
707 #[serde(default)]
708 recoverable: bool,
709 },
710 Cancelled {
711 #[serde(skip_serializing_if = "Option::is_none")]
712 cancelled_by: Option<String>,
713 },
714 Interrupted {
715 #[serde(skip_serializing_if = "Option::is_none")]
716 signal: Option<String>,
717 },
718 Stale {
719 #[serde(skip_serializing_if = "Option::is_none")]
720 last_heartbeat_at: Option<String>,
721 },
722 Restarted {
723 #[serde(default)]
724 restart_count: u32,
725 },
726 Escalated {
727 channel: String,
728 #[serde(skip_serializing_if = "Option::is_none")]
729 alert_id: Option<String>,
730 },
731}
732
733#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
735pub struct FleetRetryPolicy {
736 #[serde(default = "default_retry_max_attempts")]
737 pub max_attempts: u32,
738 #[serde(default = "default_retry_initial_backoff_seconds")]
739 pub initial_backoff_seconds: u64,
740 #[serde(default = "default_retry_max_backoff_seconds")]
741 pub max_backoff_seconds: u64,
742 #[serde(default = "default_retry_backoff_multiplier")]
743 pub backoff_multiplier: u32,
744}
745
746impl Default for FleetRetryPolicy {
747 fn default() -> Self {
748 Self {
749 max_attempts: 3,
750 initial_backoff_seconds: 5,
751 max_backoff_seconds: 300,
752 backoff_multiplier: 2,
753 }
754 }
755}
756
757fn default_retry_max_attempts() -> u32 {
758 FleetRetryPolicy::default().max_attempts
759}
760
761fn default_retry_initial_backoff_seconds() -> u64 {
762 FleetRetryPolicy::default().initial_backoff_seconds
763}
764
765fn default_retry_max_backoff_seconds() -> u64 {
766 FleetRetryPolicy::default().max_backoff_seconds
767}
768
769fn default_retry_backoff_multiplier() -> u32 {
770 FleetRetryPolicy::default().backoff_multiplier
771}
772
773#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
775pub struct FleetAlertPolicy {
776 #[serde(default)]
777 #[serde(skip_serializing_if = "Vec::is_empty")]
778 pub events: Vec<FleetAlertEventClass>,
779 #[serde(default)]
780 pub channels: Vec<FleetAlertChannel>,
781 #[serde(default)]
782 pub after_attempts: Option<u32>,
783 #[serde(default)]
784 pub after_minutes_stale: Option<u64>,
785}
786
787#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord, Hash)]
788#[serde(rename_all = "snake_case")]
789pub enum FleetAlertEventClass {
790 Stale,
791 RestartExhausted,
792 NeedsHuman,
793 BudgetExceeded,
794 VerifierFailed,
795 RunCompleted,
796}
797
798#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
799#[serde(tag = "kind", rename_all = "snake_case")]
800pub enum FleetAlertChannel {
801 Slack {
802 #[serde(flatten)]
804 webhook: FleetAlertEndpoint,
805 },
806 Webhook {
807 #[serde(flatten)]
808 endpoint: FleetAlertEndpoint,
809 },
810 #[serde(alias = "pager_duty")]
811 #[serde(alias = "pagerduty")]
812 PagerDuty {
813 routing_key: String,
814 severity: String,
815 },
816}
817
818#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
824pub struct FleetAlertEndpoint {
825 #[serde(
827 alias = "webhook_url",
828 alias = "endpoint_url",
829 skip_serializing_if = "Option::is_none"
830 )]
831 pub url: Option<String>,
832 #[serde(
834 alias = "webhook_url_ref",
835 alias = "webhook_ref",
836 alias = "url_secret_ref",
837 skip_serializing_if = "Option::is_none"
838 )]
839 pub url_ref: Option<FleetSecretRef>,
840 #[serde(
842 alias = "secret",
843 alias = "webhook_secret",
844 alias = "signing_secret",
845 skip_serializing_if = "Option::is_none"
846 )]
847 pub secret_ref: Option<FleetSecretRef>,
848}
849
850impl FleetAlertEndpoint {
851 #[must_use]
853 pub fn inline(url: impl Into<String>) -> Self {
854 Self {
855 url: Some(url.into()),
856 url_ref: None,
857 secret_ref: None,
858 }
859 }
860
861 #[must_use]
863 pub fn from_secret(url_ref: FleetSecretRef) -> Self {
864 Self {
865 url: None,
866 url_ref: Some(url_ref),
867 secret_ref: None,
868 }
869 }
870
871 #[must_use]
873 pub fn redacted(&self) -> String {
874 self.url_ref
875 .as_ref()
876 .map_or_else(|| "<inline-url>".to_string(), |r| r.redacted())
877 }
878}
879
880#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
893pub struct FleetResolvedRoute {
894 pub provider_id: String,
896 #[serde(default, skip_serializing_if = "Option::is_none")]
904 pub provider_exact_id: Option<String>,
905 pub provider_kind: String,
907 #[serde(default, skip_serializing_if = "Option::is_none")]
909 pub canonical_model: Option<String>,
910 pub wire_model_id: String,
912 pub protocol: String,
914 #[serde(default, skip_serializing_if = "Option::is_none")]
916 pub role: Option<String>,
917 #[serde(default, skip_serializing_if = "Option::is_none")]
919 pub loadout: Option<String>,
920 #[serde(default, skip_serializing_if = "Option::is_none")]
923 pub model_class: Option<String>,
924 #[serde(default, skip_serializing_if = "Option::is_none")]
927 pub model_route: Option<String>,
928 #[serde(default, skip_serializing_if = "Option::is_none")]
930 pub reasoning_effort: Option<String>,
931 #[serde(default, skip_serializing_if = "Option::is_none")]
933 pub role_source: Option<String>,
934 #[serde(default, skip_serializing_if = "Option::is_none")]
936 pub loadout_source: Option<String>,
937 #[serde(default, skip_serializing_if = "Option::is_none")]
939 pub model_class_source: Option<String>,
940 #[serde(default, skip_serializing_if = "Option::is_none")]
942 pub model_source: Option<String>,
943 pub source: String,
945}
946
947#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
953pub struct FleetEffectivePermissions {
954 pub write: bool,
956 pub network: bool,
958 pub shell: String,
960 pub tool_scope: String,
962 #[serde(default, skip_serializing_if = "Vec::is_empty")]
964 pub tools: Vec<String>,
965 pub background: bool,
967 pub max_spawn_depth: u32,
969 #[serde(default, skip_serializing_if = "Option::is_none")]
971 pub profile_id: Option<String>,
972 #[serde(default, skip_serializing_if = "Option::is_none")]
974 pub profile_origin: Option<String>,
975 pub source: String,
977}
978
979#[derive(Debug, Clone, Serialize, Deserialize)]
981pub struct FleetReceipt {
982 pub run_id: FleetRunId,
983 pub task_id: String,
984 pub worker_id: String,
985 #[serde(default, skip_serializing_if = "Option::is_none")]
990 pub attempt: Option<u32>,
991 #[serde(default, skip_serializing_if = "Option::is_none")]
995 pub terminal_seq: Option<u64>,
996 pub completed_at: String,
997 pub result: FleetTaskResult,
998 #[serde(skip_serializing_if = "Option::is_none")]
999 pub failure_kind: Option<FleetTaskFailureKind>,
1000 #[serde(default)]
1001 pub artifacts: Vec<FleetArtifactRef>,
1002 #[serde(default)]
1003 pub score: Option<FleetScore>,
1004 #[serde(default, skip_serializing_if = "Option::is_none")]
1009 pub resolved_route: Option<FleetResolvedRoute>,
1010 #[serde(default, skip_serializing_if = "Option::is_none")]
1012 pub effective_permissions: Option<FleetEffectivePermissions>,
1013}
1014
1015#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1016#[serde(rename_all = "snake_case")]
1017pub enum FleetTaskResult {
1018 Pass,
1019 Partial,
1020 Fail,
1021 Skip,
1022 Timeout,
1023}
1024
1025#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1027#[serde(rename_all = "snake_case")]
1028pub enum FleetTaskFailureKind {
1029 Transport,
1030 Task,
1031 Verifier,
1032}
1033
1034#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1035pub struct FleetScore {
1036 pub value: f64,
1037 #[serde(skip_serializing_if = "Option::is_none")]
1038 pub max: Option<f64>,
1039 #[serde(skip_serializing_if = "Option::is_none")]
1040 pub notes: Option<String>,
1041}
1042
1043#[cfg(test)]
1044mod tests {
1045 use super::*;
1046
1047 #[test]
1048 fn fleet_run_round_trip() {
1049 let run = FleetRun {
1050 id: FleetRunId::from("run-001"),
1051 name: "dogfood smoke".to_string(),
1052 status: FleetRunStatus::Running,
1053 max_workers: Some(1),
1054 task_specs: vec![FleetTaskSpec {
1055 id: "task-1".to_string(),
1056 name: "lint".to_string(),
1057 description: None,
1058 objective: Some("Keep the workspace lint-clean".to_string()),
1059 instructions: "run cargo clippy".to_string(),
1060 worker: Some(FleetTaskWorkerProfile {
1061 agent_profile: None,
1062 role: Some("release-checker".to_string()),
1063 loadout: None,
1064 model_class: None,
1065 model: None,
1066 tool_profile: Some("read-only".to_string()),
1067 tools: vec!["cargo".to_string()],
1068 capabilities: vec!["rust".to_string()],
1069 }),
1070 workspace: Some(FleetWorkspaceRequirements {
1071 root: Some(PathBuf::from(".")),
1072 required_files: vec![PathBuf::from("Cargo.toml")],
1073 writable_paths: vec![],
1074 environment: Some(FleetEnvironmentRequirements {
1075 required: vec!["PATH".to_string()],
1076 allowlist: vec!["RUST_LOG".to_string()],
1077 }),
1078 }),
1079 input_files: vec![PathBuf::from("crates/tui/src/main.rs")],
1080 context: vec!["release gate".to_string()],
1081 budget: Some(FleetTaskBudget {
1082 max_tokens: Some(8000),
1083 max_tool_calls: Some(20),
1084 max_seconds: Some(300),
1085 }),
1086 tags: vec!["release".to_string()],
1087 expected_artifacts: vec![FleetArtifactKind::Log],
1088 scorer: Some(FleetScorerSpec::ExitCode),
1089 retry_policy: Some(FleetRetryPolicy::default()),
1090 alert_policy: None,
1091 timeout_seconds: Some(300),
1092 metadata: BTreeMap::new(),
1093 }],
1094 worker_specs: vec![],
1095 labels: BTreeMap::new(),
1096 security_policy: None,
1097 created_at: "2026-06-12T17:00:00Z".to_string(),
1098 updated_at: None,
1099 completed_at: None,
1100 };
1101 let json = serde_json::to_string(&run).unwrap();
1102 let back: FleetRun = serde_json::from_str(&json).unwrap();
1103 assert_eq!(back.id, run.id);
1104 assert_eq!(back.status, FleetRunStatus::Running);
1105 assert_eq!(back.task_specs.len(), 1);
1106 assert_eq!(
1107 back.task_specs[0].worker.as_ref().unwrap().role.as_deref(),
1108 Some("release-checker")
1109 );
1110 assert_eq!(
1111 back.task_specs[0]
1112 .workspace
1113 .as_ref()
1114 .unwrap()
1115 .required_files,
1116 vec![PathBuf::from("Cargo.toml")]
1117 );
1118 }
1119
1120 #[test]
1121 fn worker_profile_carries_agent_profile_and_loadout_intent() {
1122 let json = r#"{
1123 "profile": "adversarial_reviewer",
1124 "role": "reviewer",
1125 "loadout": "auto",
1126 "model_class": "balanced",
1127 "model": "deepseek-v4-pro",
1128 "tool_profile": "read-only",
1129 "tools": ["read_file"],
1130 "capabilities": ["rust"]
1131 }"#;
1132
1133 let profile: FleetTaskWorkerProfile = serde_json::from_str(json).unwrap();
1134
1135 assert_eq!(
1136 profile.agent_profile.as_deref(),
1137 Some("adversarial_reviewer")
1138 );
1139 assert_eq!(profile.role.as_deref(), Some("reviewer"));
1140 assert_eq!(profile.loadout.as_deref(), Some("auto"));
1141 assert_eq!(profile.model_class.as_deref(), Some("balanced"));
1142 assert_eq!(profile.model.as_deref(), Some("deepseek-v4-pro"));
1143 assert_eq!(profile.tool_profile.as_deref(), Some("read-only"));
1144
1145 let serialized = serde_json::to_value(&profile).unwrap();
1146 assert_eq!(serialized["agent_profile"], "adversarial_reviewer");
1147 assert_eq!(serialized["model"], "deepseek-v4-pro");
1148 assert!(serialized.get("profile").is_none());
1149 }
1150
1151 #[test]
1152 fn worker_event_lifecycle_round_trip() {
1153 let events = vec![
1154 FleetWorkerEvent {
1155 seq: 1,
1156 run_id: FleetRunId::from("run-002"),
1157 worker_id: "worker-a".to_string(),
1158 task_id: "task-1".to_string(),
1159 timestamp: "2026-06-12T17:01:00Z".to_string(),
1160 payload: FleetWorkerEventPayload::Queued,
1161 extra: BTreeMap::new(),
1162 },
1163 FleetWorkerEvent {
1164 seq: 2,
1165 run_id: FleetRunId::from("run-002"),
1166 worker_id: "worker-a".to_string(),
1167 task_id: "task-1".to_string(),
1168 timestamp: "2026-06-12T17:01:05Z".to_string(),
1169 payload: FleetWorkerEventPayload::RunningTool {
1170 tool: "bash".to_string(),
1171 call_id: Some("call-1".to_string()),
1172 },
1173 extra: BTreeMap::new(),
1174 },
1175 FleetWorkerEvent {
1176 seq: 3,
1177 run_id: FleetRunId::from("run-002"),
1178 worker_id: "worker-a".to_string(),
1179 task_id: "task-1".to_string(),
1180 timestamp: "2026-06-12T17:02:00Z".to_string(),
1181 payload: FleetWorkerEventPayload::Completed {
1182 exit_code: Some(0),
1183 summary: Some("ok".to_string()),
1184 },
1185 extra: BTreeMap::new(),
1186 },
1187 ];
1188 let json = serde_json::to_string(&events).unwrap();
1189 let back: Vec<FleetWorkerEvent> = serde_json::from_str(&json).unwrap();
1190 assert_eq!(back.len(), 3);
1191 assert!(matches!(back[0].payload, FleetWorkerEventPayload::Queued));
1192 assert!(matches!(
1193 back[2].payload,
1194 FleetWorkerEventPayload::Completed { .. }
1195 ));
1196 }
1197
1198 #[test]
1199 fn workflow_receipt_round_trip_keeps_outer_and_inner_run_ids_distinct() {
1200 let event = FleetWorkerEvent {
1201 seq: 3,
1202 run_id: FleetRunId::from("fleet-run-1"),
1203 worker_id: "worker-a".to_string(),
1204 task_id: "task-1".to_string(),
1205 timestamp: "2026-07-10T00:00:00Z".to_string(),
1206 payload: FleetWorkerEventPayload::WorkflowEvent {
1207 workflow_run_id: "workflow_1".to_string(),
1208 event: serde_json::json!({"type": "task_completed"}),
1209 },
1210 extra: BTreeMap::new(),
1211 };
1212 let value = serde_json::to_value(&event).unwrap();
1213 assert_eq!(value["run_id"], "fleet-run-1");
1214 assert_eq!(value["workflow_run_id"], "workflow_1");
1215 let back: FleetWorkerEvent = serde_json::from_value(value).unwrap();
1216 assert!(matches!(
1217 back.payload,
1218 FleetWorkerEventPayload::WorkflowEvent {
1219 workflow_run_id,
1220 ref event,
1221 } if workflow_run_id == "workflow_1" && event["type"] == "task_completed"
1222 ));
1223 }
1224
1225 #[test]
1226 fn alert_policy_round_trip() {
1227 let policy = FleetAlertPolicy {
1228 events: vec![FleetAlertEventClass::Stale],
1229 channels: vec![FleetAlertChannel::Slack {
1230 webhook: FleetAlertEndpoint::inline("https://hooks.slack.com/test"),
1231 }],
1232 after_attempts: Some(2),
1233 after_minutes_stale: Some(10),
1234 };
1235 let json = serde_json::to_string(&policy).unwrap();
1236 assert!(json.contains("\"events\":[\"stale\"]"));
1237 assert!(json.contains("\"kind\":\"slack\""));
1238 let back: FleetAlertPolicy = serde_json::from_str(&json).unwrap();
1239 assert_eq!(back.events, vec![FleetAlertEventClass::Stale]);
1240 assert_eq!(back.after_attempts, Some(2));
1241 }
1242
1243 #[test]
1244 fn artifact_other_kind_round_trip() {
1245 let artifact = FleetArtifactRef {
1246 kind: FleetArtifactKind::Other("coverage.xml".to_string()),
1247 path: PathBuf::from("/tmp/coverage.xml"),
1248 checksum: Some("sha256:abc".to_string()),
1249 mime_type: Some("application/xml".to_string()),
1250 size_bytes: Some(1024),
1251 };
1252 let json = serde_json::to_string(&artifact).unwrap();
1253 let back: FleetArtifactRef = serde_json::from_str(&json).unwrap();
1254 assert_eq!(back.kind, artifact.kind);
1255 assert_eq!(back.size_bytes, Some(1024));
1256 }
1257
1258 #[test]
1259 fn ssh_host_spec_accepts_minimal_legacy_json() {
1260 let json = r#"{"kind":"ssh","host":"builder.example.test"}"#;
1261 let host: FleetHostSpec = serde_json::from_str(json).unwrap();
1262
1263 match host {
1264 FleetHostSpec::Ssh {
1265 host,
1266 port,
1267 user,
1268 identity,
1269 known_hosts,
1270 host_key_fingerprint,
1271 working_directory,
1272 env_allowlist,
1273 codewhale_binary,
1274 } => {
1275 assert_eq!(host, "builder.example.test");
1276 assert_eq!(port, None);
1277 assert_eq!(user, None);
1278 assert_eq!(identity, None);
1279 assert_eq!(known_hosts, None);
1280 assert_eq!(host_key_fingerprint, None);
1281 assert_eq!(working_directory, None);
1282 assert!(env_allowlist.is_empty());
1283 assert_eq!(codewhale_binary, None);
1284 }
1285 other => panic!("expected ssh host spec, got {other:?}"),
1286 }
1287 }
1288
1289 #[test]
1290 fn artifact_kind_uses_flat_string_json() {
1291 let known = serde_json::to_string(&FleetArtifactKind::TestResult).unwrap();
1292 assert_eq!(known, "\"test_result\"");
1293
1294 let custom =
1295 serde_json::to_string(&FleetArtifactKind::Other("coverage.xml".to_string())).unwrap();
1296 assert_eq!(custom, "\"coverage.xml\"");
1297
1298 let parsed: FleetArtifactKind = serde_json::from_str("\"coverage.xml\"").unwrap();
1299 assert_eq!(parsed, FleetArtifactKind::Other("coverage.xml".to_string()));
1300 }
1301
1302 #[test]
1303 fn retry_policy_missing_fields_use_nonzero_defaults() {
1304 let policy: FleetRetryPolicy = serde_json::from_value(serde_json::json!({})).unwrap();
1305 assert_eq!(policy, FleetRetryPolicy::default());
1306
1307 let policy: FleetRetryPolicy =
1308 serde_json::from_value(serde_json::json!({"max_attempts": 5})).unwrap();
1309 assert_eq!(policy.max_attempts, 5);
1310 assert_eq!(
1311 policy.initial_backoff_seconds,
1312 FleetRetryPolicy::default().initial_backoff_seconds
1313 );
1314 assert_eq!(
1315 policy.max_backoff_seconds,
1316 FleetRetryPolicy::default().max_backoff_seconds
1317 );
1318 assert_eq!(
1319 policy.backoff_multiplier,
1320 FleetRetryPolicy::default().backoff_multiplier
1321 );
1322 }
1323
1324 #[test]
1325 fn sparse_worker_events_omit_absent_optional_fields() {
1326 let heartbeat = FleetWorkerEventPayload::Heartbeat {
1327 cpu_percent: None,
1328 memory_mb: None,
1329 };
1330 let heartbeat_json = serde_json::to_value(&heartbeat).unwrap();
1331 assert_eq!(heartbeat_json, serde_json::json!({"state": "heartbeat"}));
1332
1333 let completed = FleetWorkerEventPayload::Completed {
1334 exit_code: None,
1335 summary: None,
1336 };
1337 let completed_json = serde_json::to_value(&completed).unwrap();
1338 assert_eq!(completed_json, serde_json::json!({"state": "completed"}));
1339 }
1340
1341 #[test]
1342 fn receipt_round_trip() {
1343 let receipt = FleetReceipt {
1344 run_id: FleetRunId::from("run-003"),
1345 task_id: "task-1".to_string(),
1346 worker_id: "worker-b".to_string(),
1347 attempt: Some(2),
1348 terminal_seq: Some(7),
1349 completed_at: "2026-06-12T17:03:00Z".to_string(),
1350 result: FleetTaskResult::Pass,
1351 failure_kind: None,
1352 artifacts: vec![],
1353 score: Some(FleetScore {
1354 value: 0.95,
1355 max: Some(1.0),
1356 notes: None,
1357 }),
1358 resolved_route: None,
1359 effective_permissions: None,
1360 };
1361 let json = serde_json::to_string(&receipt).unwrap();
1362 let back: FleetReceipt = serde_json::from_str(&json).unwrap();
1363 assert_eq!(back.result, FleetTaskResult::Pass);
1364 assert_eq!(back.score.as_ref().unwrap().value, 0.95);
1365 assert_eq!(back.attempt, Some(2));
1366 assert_eq!(back.terminal_seq, Some(7));
1367 }
1368
1369 #[test]
1370 fn partial_receipt_records_failure_source_when_needed() {
1371 let receipt = FleetReceipt {
1372 run_id: FleetRunId::from("run-004"),
1373 task_id: "task-2".to_string(),
1374 worker_id: "worker-c".to_string(),
1375 attempt: None,
1376 terminal_seq: None,
1377 completed_at: "2026-06-12T17:04:00Z".to_string(),
1378 result: FleetTaskResult::Partial,
1379 failure_kind: Some(FleetTaskFailureKind::Verifier),
1380 artifacts: vec![],
1381 score: Some(FleetScore {
1382 value: 0.5,
1383 max: Some(1.0),
1384 notes: Some("manual verification required".to_string()),
1385 }),
1386 resolved_route: None,
1387 effective_permissions: None,
1388 };
1389
1390 let json = serde_json::to_string(&receipt).unwrap();
1391 assert!(json.contains("\"result\":\"partial\""));
1392 assert!(json.contains("\"failure_kind\":\"verifier\""));
1393 let back: FleetReceipt = serde_json::from_str(&json).unwrap();
1394 assert_eq!(back.result, FleetTaskResult::Partial);
1395 assert_eq!(back.failure_kind, Some(FleetTaskFailureKind::Verifier));
1396 }
1397
1398 #[test]
1399 fn ssh_host_spec_with_key_pinning_round_trip() {
1400 let spec = FleetHostSpec::Ssh {
1401 host: "builder.trusted.example.com".to_string(),
1402 port: Some(22),
1403 user: Some("codewhale".to_string()),
1404 identity: Some(PathBuf::from("~/.ssh/codewhale_fleet")),
1405 known_hosts: Some(PathBuf::from("~/.ssh/known_hosts")),
1406 host_key_fingerprint: Some("SHA256:aLGqZo1M6c...".to_string()),
1407 working_directory: Some(PathBuf::from("/srv/codewhale/work")),
1408 env_allowlist: vec!["CODEWHALE_PROFILE".to_string()],
1409 codewhale_binary: Some("/usr/local/bin/codewhale".to_string()),
1410 };
1411 let json = serde_json::to_string_pretty(&spec).unwrap();
1412 assert!(json.contains("\"known_hosts\""));
1413 assert!(json.contains("\"host_key_fingerprint\""));
1414 assert!(json.contains("SHA256:aLGqZo1M6c..."));
1415
1416 let back: FleetHostSpec = serde_json::from_str(&json).unwrap();
1417 match back {
1418 FleetHostSpec::Ssh {
1419 host,
1420 known_hosts,
1421 host_key_fingerprint,
1422 ..
1423 } => {
1424 assert_eq!(host, "builder.trusted.example.com");
1425 assert_eq!(known_hosts, Some(PathBuf::from("~/.ssh/known_hosts")));
1426 assert_eq!(
1427 host_key_fingerprint,
1428 Some("SHA256:aLGqZo1M6c...".to_string())
1429 );
1430 }
1431 other => panic!("expected ssh host spec, got {other:?}"),
1432 }
1433 }
1434
1435 #[test]
1436 fn secret_ref_redacted_never_exposes_value() {
1437 let ref_ = FleetSecretRef::new("DEEPSEEK_API_KEY");
1438 let redacted = ref_.redacted();
1439 assert!(redacted.contains("DEEPSEEK_API_KEY"));
1440 assert!(!redacted.contains("sk-"));
1441 assert!(redacted.contains("<secret:"));
1442
1443 let ref_ = FleetSecretRef::with_source("GH_TOKEN", "env");
1444 let redacted = ref_.redacted();
1445 assert!(redacted.contains("env.GH_TOKEN"));
1446 assert!(!redacted.contains("ghp_"));
1447 }
1448
1449 #[test]
1450 fn alert_endpoint_from_secret_round_trip() {
1451 let endpoint = FleetAlertEndpoint::from_secret(FleetSecretRef::new("SLACK_WEBHOOK"));
1452 let json = serde_json::to_string(&endpoint).unwrap();
1453 assert!(json.contains("SLACK_WEBHOOK"));
1454 assert!(!json.contains("hooks.slack.com"));
1455
1456 let back: FleetAlertEndpoint = serde_json::from_str(&json).unwrap();
1457 assert_eq!(back.url_ref.as_ref().unwrap().key, "SLACK_WEBHOOK");
1458 assert_eq!(back.url, None);
1459 }
1460
1461 #[test]
1462 fn secret_ref_accepts_legacy_string_wire_shape() {
1463 let ref_: FleetSecretRef = serde_json::from_str(r#""CODEWHALE_FLEET_TOKEN""#).unwrap();
1464 assert_eq!(ref_, FleetSecretRef::new("CODEWHALE_FLEET_TOKEN"));
1465
1466 let ref_: FleetSecretRef =
1467 serde_json::from_str(r#"{"key":"GH_TOKEN","source":"env"}"#).unwrap();
1468 assert_eq!(ref_, FleetSecretRef::with_source("GH_TOKEN", "env"));
1469 }
1470
1471 #[test]
1472 fn trust_level_accepts_hyphenated_remote_verified() {
1473 let trust: FleetTrustLevel = serde_json::from_str(r#""remote-verified""#).unwrap();
1474 assert_eq!(trust, FleetTrustLevel::RemoteVerified);
1475
1476 let canonical = serde_json::to_string(&trust).unwrap();
1477 assert_eq!(canonical, r#""remote_verified""#);
1478 }
1479
1480 #[test]
1481 fn alert_channel_accepts_legacy_webhook_fields() {
1482 let channel: FleetAlertChannel = serde_json::from_str(
1483 r#"{
1484 "kind": "slack",
1485 "webhook_url": "https://hooks.slack.com/test",
1486 "secret": "SLACK_SIGNING_SECRET"
1487 }"#,
1488 )
1489 .unwrap();
1490
1491 match channel {
1492 FleetAlertChannel::Slack { webhook } => {
1493 assert_eq!(webhook.url.as_deref(), Some("https://hooks.slack.com/test"));
1494 assert_eq!(
1495 webhook.secret_ref,
1496 Some(FleetSecretRef::new("SLACK_SIGNING_SECRET"))
1497 );
1498 }
1499 other => panic!("expected slack channel, got {other:?}"),
1500 }
1501 }
1502
1503 #[test]
1504 fn security_policy_defaults_are_conservative() {
1505 let policy = FleetSecurityPolicy::default();
1506 assert_eq!(policy.default_trust_level, FleetTrustLevel::Sandbox);
1507 assert!(policy.allowed_secrets.is_empty());
1508 assert!(policy.capability_grants.is_empty());
1509 assert_eq!(policy.max_trust_level, FleetTrustLevel::Operator);
1510 assert!(!policy.require_identity_verification);
1511 }
1512
1513 #[test]
1514 fn trust_level_ordinal_reflects_privilege() {
1515 assert!(FleetTrustLevel::Operator > FleetTrustLevel::RemoteVerified);
1516 assert!(FleetTrustLevel::RemoteVerified > FleetTrustLevel::Local);
1517 assert!(FleetTrustLevel::Local > FleetTrustLevel::Sandbox);
1518
1519 assert!(FleetTrustLevel::Operator.may_access_secrets());
1520 assert!(!FleetTrustLevel::Sandbox.may_access_secrets());
1521 assert!(!FleetTrustLevel::Sandbox.may_write_workspace());
1522 assert!(FleetTrustLevel::Operator.may_write_workspace());
1523 }
1524
1525 fn sample_receipt_with_route() -> FleetReceipt {
1526 FleetReceipt {
1527 run_id: FleetRunId::from("run-route"),
1528 task_id: "task-route".to_string(),
1529 worker_id: "worker-route".to_string(),
1530 attempt: Some(1),
1531 terminal_seq: Some(4),
1532 completed_at: "2026-06-23T00:00:00Z".to_string(),
1533 result: FleetTaskResult::Pass,
1534 failure_kind: None,
1535 artifacts: vec![],
1536 score: None,
1537 resolved_route: Some(FleetResolvedRoute {
1538 provider_id: "deepseek".to_string(),
1539 provider_exact_id: None,
1540 provider_kind: "deepseek".to_string(),
1541 canonical_model: Some("deepseek-v4-pro".to_string()),
1542 wire_model_id: "deepseek-v4-pro".to_string(),
1543 protocol: "chat_completions".to_string(),
1544 role: Some("builder".to_string()),
1545 loadout: Some("auto".to_string()),
1546 model_class: Some("balanced".to_string()),
1547 model_route: Some("auto".to_string()),
1548 reasoning_effort: Some("high".to_string()),
1549 role_source: Some("task.role".to_string()),
1550 loadout_source: Some("task.loadout".to_string()),
1551 model_class_source: Some("task.model_class".to_string()),
1552 model_source: Some("task.model".to_string()),
1553 source: "resolver".to_string(),
1554 }),
1555 effective_permissions: Some(FleetEffectivePermissions {
1556 write: true,
1557 network: true,
1558 shell: "full".to_string(),
1559 tool_scope: "explicit".to_string(),
1560 tools: vec!["read_file".to_string(), "apply_patch".to_string()],
1561 background: true,
1562 max_spawn_depth: 2,
1563 profile_id: Some("builder".to_string()),
1564 profile_origin: Some("built_in".to_string()),
1565 source: "worker_runtime_profile".to_string(),
1566 }),
1567 }
1568 }
1569
1570 #[test]
1571 fn fleet_resolved_route_round_trips() {
1572 let receipt = sample_receipt_with_route();
1573 let json = serde_json::to_string(&receipt).unwrap();
1574 let back: FleetReceipt = serde_json::from_str(&json).unwrap();
1575 assert_eq!(back.resolved_route, receipt.resolved_route);
1576 assert_eq!(back.effective_permissions, receipt.effective_permissions);
1577 let route = back.resolved_route.unwrap();
1578 assert_eq!(route.provider_id, "deepseek");
1579 assert_eq!(route.wire_model_id, "deepseek-v4-pro");
1580 assert_eq!(route.protocol, "chat_completions");
1581 assert_eq!(route.role.as_deref(), Some("builder"));
1582 assert_eq!(route.loadout.as_deref(), Some("auto"));
1583 assert_eq!(route.model_class.as_deref(), Some("balanced"));
1584 assert_eq!(route.model_route.as_deref(), Some("auto"));
1585 assert_eq!(route.reasoning_effort.as_deref(), Some("high"));
1586 assert_eq!(route.role_source.as_deref(), Some("task.role"));
1587 assert_eq!(route.loadout_source.as_deref(), Some("task.loadout"));
1588 assert_eq!(
1589 route.model_class_source.as_deref(),
1590 Some("task.model_class")
1591 );
1592 assert_eq!(route.model_source.as_deref(), Some("task.model"));
1593 assert_eq!(route.source, "resolver");
1594
1595 let permissions = back
1596 .effective_permissions
1597 .expect("effective permissions should round-trip");
1598 assert!(permissions.write);
1599 assert!(permissions.network);
1600 assert_eq!(permissions.shell, "full");
1601 assert_eq!(permissions.tool_scope, "explicit");
1602 assert_eq!(
1603 permissions.tools,
1604 vec!["read_file".to_string(), "apply_patch".to_string()]
1605 );
1606 assert!(permissions.background);
1607 assert_eq!(permissions.max_spawn_depth, 2);
1608 assert_eq!(permissions.profile_id.as_deref(), Some("builder"));
1609 assert_eq!(permissions.profile_origin.as_deref(), Some("built_in"));
1610 assert_eq!(permissions.source, "worker_runtime_profile");
1611 }
1612
1613 #[test]
1614 fn fleet_receipt_without_resolved_route_still_deserializes() {
1615 let legacy = r#"{
1618 "run_id": "run-legacy",
1619 "task_id": "task-legacy",
1620 "worker_id": "worker-legacy",
1621 "completed_at": "2026-06-01T00:00:00Z",
1622 "result": "pass",
1623 "artifacts": [],
1624 "score": null
1625 }"#;
1626 let receipt: FleetReceipt = serde_json::from_str(legacy).unwrap();
1627 assert_eq!(receipt.task_id, "task-legacy");
1628 assert!(receipt.resolved_route.is_none());
1629 assert!(receipt.attempt.is_none());
1630 assert!(receipt.terminal_seq.is_none());
1631 }
1632
1633 #[test]
1634 fn fleet_resolved_route_legacy_shape_still_deserializes() {
1635 let legacy = r#"{
1636 "run_id": "run-route",
1637 "task_id": "task-route",
1638 "worker_id": "worker-route",
1639 "completed_at": "2026-06-23T00:00:00Z",
1640 "result": "pass",
1641 "artifacts": [],
1642 "score": null,
1643 "resolved_route": {
1644 "provider_id": "deepseek",
1645 "provider_kind": "deepseek",
1646 "canonical_model": "deepseek-v4-pro",
1647 "wire_model_id": "deepseek-v4-pro",
1648 "protocol": "chat_completions",
1649 "role": "builder",
1650 "loadout": "fast",
1651 "source": "resolver"
1652 }
1653 }"#;
1654
1655 let receipt: FleetReceipt = serde_json::from_str(legacy).unwrap();
1656 let route = receipt.resolved_route.expect("legacy route should parse");
1657 assert_eq!(route.source, "resolver");
1658 assert_eq!(route.role.as_deref(), Some("builder"));
1659 assert_eq!(route.loadout.as_deref(), Some("fast"));
1660 assert_eq!(route.model_class, None);
1661 assert_eq!(route.model_route, None);
1662 assert_eq!(route.reasoning_effort, None);
1663 assert_eq!(route.role_source, None);
1664 assert_eq!(route.loadout_source, None);
1665 assert_eq!(route.model_class_source, None);
1666 assert_eq!(route.model_source, None);
1667 }
1668
1669 #[test]
1670 fn fleet_resolved_route_serialization_carries_no_secrets() {
1671 let receipt = sample_receipt_with_route();
1672 let route_json = serde_json::to_string(receipt.resolved_route.as_ref().unwrap()).unwrap();
1677 assert_no_secret_markers(&route_json);
1678 let receipt_json = serde_json::to_string(&receipt).unwrap();
1680 for needle in SECRET_KEY_MARKERS {
1681 assert!(
1682 !receipt_json.to_ascii_lowercase().contains(needle),
1683 "receipt JSON must not contain secret-key marker {needle:?}: {receipt_json}"
1684 );
1685 }
1686 }
1687
1688 const SECRET_KEY_MARKERS: &[&str] = &[
1691 "api_key",
1692 "apikey",
1693 "api-key",
1694 "authorization",
1695 "bearer ",
1696 "auth_token",
1697 "auth-token",
1698 "password",
1699 "credential",
1700 "sk-ant-",
1701 "sk-proj-",
1702 "sk-or-",
1703 "secret",
1704 ];
1705
1706 fn assert_no_secret_markers(json: &str) {
1707 let haystack = json.to_ascii_lowercase();
1708 for needle in SECRET_KEY_MARKERS {
1709 assert!(
1710 !haystack.contains(needle),
1711 "resolved-route JSON must not contain secret marker {needle:?}: {json}"
1712 );
1713 }
1714 }
1715}