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