Skip to main content

codewhale_protocol/
fleet.rs

1//! Agent Fleet control-plane protocol types.
2//!
3//! These types define the durable, serializable contract between the fleet
4//! manager, workers, CLI/TUI surfaces, and the Runtime API. They are
5//! intentionally additive: existing runtime-event consumers ignore unknown
6//! fields and are unaffected by fleet extensions.
7//!
8//! See:
9//! - <https://github.com/Hmbown/CodeWhale/issues/3154> (Agent Fleet control plane)
10//! - <https://github.com/Hmbown/CodeWhale/issues/3096> (Runtime API sub-agent direction)
11
12use 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/// Globally unique identifier for a fleet run.
21#[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/// Top-level fleet run handle.
37#[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/// Lifecycle status for an entire fleet run.
58#[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/// Specification of a single unit of work within a run.
71#[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/// Worker role and tool expectations for a task.
110#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
111pub struct FleetTaskWorkerProfile {
112    /// Named agent profile/persona posture to layer onto this worker.
113    ///
114    /// `profile` is accepted as a shorter authoring alias. This is an intent
115    /// reference only; profile loading and permission narrowing happen in the
116    /// Fleet runtime layer.
117    #[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    /// Fleet loadout intent such as `auto`, `fast`, or `review`.
122    ///
123    /// This is not a concrete provider/model selection; route resolution owns
124    /// the executable provider/model/wire-model decision.
125    #[serde(default, skip_serializing_if = "Option::is_none")]
126    pub loadout: Option<String>,
127    /// Fleet model class hint such as `strong`, `balanced`, or `fast`.
128    #[serde(default, skip_serializing_if = "Option::is_none")]
129    pub model_class: Option<String>,
130    /// Optional explicit model id for this worker.
131    ///
132    /// Task-level model overrides are visible authoring data and take
133    /// precedence over the referenced agent profile's model hint. Provider and
134    /// wire-model validation still belong to route resolution.
135    #[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/// Workspace and environment constraints needed before a task starts.
148#[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/// Environment variables a task requires or may pass through to workers.
163#[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/// Budget limits for a task.
174#[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/// Reference to an artifact produced or consumed by a task.
185#[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/// Kind of artifact a task may produce or consume.
198#[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/// Scoring rule used to verify a task result.
255#[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/// Worker specification.
282#[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/// Host on which a worker runs.
299#[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        /// Known hosts file for host-key verification.
312        #[serde(skip_serializing_if = "Option::is_none")]
313        known_hosts: Option<PathBuf>,
314        /// Expected host key fingerprint (SHA256:...) for key pinning.
315        /// When set, the connection is only trusted if the server's
316        /// host key matches this fingerprint exactly.
317        #[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// ── Security and trust types ────────────────────────────────────────────────
337
338/// Trust classification assigned to a worker host.
339///
340/// The trust level determines what a worker is allowed to do and what
341/// secrets it may access. The default for new workers is [`FleetTrustLevel::Sandbox`];
342/// operators must explicitly raise trust for SSH or container workers.
343#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord, Default)]
344#[serde(rename_all = "snake_case")]
345pub enum FleetTrustLevel {
346    /// Fully isolated: no network, no secrets, no writes outside `.codewhale/fleet/`.
347    /// Suitable for untrusted code review, community PR checks, or third-party tool runs.
348    #[default]
349    Sandbox = 0,
350    /// Local-only worker with access to the workspace and configured secrets.
351    /// Default for local workers. May read repo files but writes are gated.
352    Local = 1,
353    /// Worker on a known remote host with verified identity and a bounded
354    /// set of explicitly granted capabilities. Requires SSH host-key
355    /// verification or equivalent attestation.
356    #[serde(alias = "remote-verified", alias = "remoteVerified")]
357    RemoteVerified = 2,
358    /// Fully trusted worker (e.g. operator's own machine, CI runner).
359    /// Has access to all configured secrets and may perform any action the
360    /// operator can. Reserved for dogfood smoke and operator-owned machines.
361    Operator = 3,
362}
363
364impl FleetTrustLevel {
365    /// Whether this trust level is allowed to access provider secrets.
366    #[must_use]
367    pub fn may_access_secrets(&self) -> bool {
368        matches!(self, Self::Operator | Self::RemoteVerified | Self::Local)
369    }
370
371    /// Whether this trust level is allowed to write outside `.codewhale/fleet/`.
372    #[must_use]
373    pub fn may_write_workspace(&self) -> bool {
374        matches!(self, Self::Operator | Self::Local)
375    }
376
377    /// Whether this trust level is allowed network access.
378    #[must_use]
379    pub fn may_access_network(&self) -> bool {
380        matches!(self, Self::Operator | Self::RemoteVerified | Self::Local)
381    }
382}
383
384/// Security policy applied to a fleet run.
385///
386/// A policy defines the default trust level for workers, which secrets
387/// may be resolved, and what capabilities are granted. When a run has no
388/// explicit policy, workers inherit conservative defaults.
389#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
390pub struct FleetSecurityPolicy {
391    /// Default trust level for workers that don't declare one explicitly.
392    #[serde(default)]
393    pub default_trust_level: FleetTrustLevel,
394    /// Secret refs that workers may resolve. An empty list means no secrets
395    /// are available. Each entry is a key name, not a value.
396    #[serde(default)]
397    #[serde(skip_serializing_if = "Vec::is_empty")]
398    pub allowed_secrets: Vec<FleetSecretRef>,
399    /// Capability grants for workers in this run.
400    #[serde(default)]
401    #[serde(skip_serializing_if = "Vec::is_empty")]
402    pub capability_grants: Vec<FleetCapabilityGrant>,
403    /// Maximum trust level any worker in this run may have, even if the
404    /// worker spec requests higher. Defaults to Operator (no ceiling).
405    #[serde(default = "default_max_trust_level")]
406    pub max_trust_level: FleetTrustLevel,
407    /// Require identity verification for remote workers. When true, SSH
408    /// workers must pass host-key verification before being trusted at
409    /// RemoteVerified level; unverified remotes stay at Sandbox.
410    #[serde(default)]
411    pub require_identity_verification: bool,
412    /// Allow conservative parallel execution of read-only tools (#2983).
413    /// When true, workers may batch independent read-only tool calls
414    /// (reads, searches, greps) into concurrent turns. Disabled by default
415    /// to avoid overwhelming providers or hitting rate limits.
416    #[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/// A reference to a secret that should be resolved at runtime, never
438/// serialized as a plaintext value.
439///
440/// Secret refs appear in task specs, alert configs, and worker definitions.
441/// The actual secret value is resolved by the fleet manager from the
442/// secrets backend (OS keyring, environment, or file store) just before
443/// the worker starts.
444#[derive(Debug, Clone, Serialize, PartialEq, Eq, Hash, PartialOrd, Ord)]
445pub struct FleetSecretRef {
446    /// The secret key name (e.g. `"CODEWHALE_API_KEY"`, `"GH_TOKEN"`).
447    pub key: String,
448    /// Optional source hint for resolution order.
449    /// - `"env"` — resolve from environment variable
450    /// - `"keyring"` — resolve from OS keyring
451    /// - `"file"` — resolve from `~/.codewhale/secrets/`
452    /// - absent / null — try all sources in default order
453    #[serde(skip_serializing_if = "Option::is_none")]
454    pub source: Option<String>,
455}
456
457impl FleetSecretRef {
458    /// Create a secret ref from a key name with default resolution.
459    #[must_use]
460    pub fn new(key: impl Into<String>) -> Self {
461        Self {
462            key: key.into(),
463            source: None,
464        }
465    }
466
467    /// Create a secret ref with an explicit source.
468    #[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    /// Redacted display form for logging. Shows the key name and source
477    /// but never the resolved value.
478    #[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/// How a worker authenticates to the fleet manager.
535#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
536#[serde(tag = "method", rename_all = "snake_case")]
537pub enum FleetWorkerAuth {
538    /// No authentication (local workers share the same uid).
539    None,
540    /// SSH key-based authentication with host-key verification.
541    SshKey {
542        /// Path to the SSH identity file (may be a FleetSecretRef in JSON
543        /// as `{"key": "...", "source": "file"}`).
544        identity: PathBuf,
545        /// Known hosts file for host-key verification.
546        #[serde(skip_serializing_if = "Option::is_none")]
547        known_hosts: Option<PathBuf>,
548        /// Expected host key fingerprint for pinning.
549        #[serde(skip_serializing_if = "Option::is_none")]
550        host_key_fingerprint: Option<String>,
551        /// SSH user for the connection.
552        #[serde(skip_serializing_if = "Option::is_none")]
553        user: Option<String>,
554    },
555    /// Token-based authentication for remote workers behind a fleet proxy.
556    Token {
557        /// Reference to the token secret.
558        token_ref: FleetSecretRef,
559    },
560    /// mTLS certificate-based authentication.
561    Mtls {
562        /// Path to the client certificate.
563        cert_path: PathBuf,
564        /// Reference to the private key secret.
565        key_ref: FleetSecretRef,
566    },
567}
568
569/// A capability grant that explicitly authorizes a worker to perform
570/// a specific class of action.
571///
572/// By default, new workers get no grants (least privilege). Grants are
573/// additive: a worker's effective capabilities are the union of its
574/// trust-level defaults plus any explicit grants.
575#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
576pub struct FleetCapabilityGrant {
577    /// The capability being granted (e.g. `"network"`, `"git-push"`,
578    /// `"provider-secrets"`, `"release"`).
579    pub capability: String,
580    /// Optional scope limiting the grant (e.g. `"github.com"` for network,
581    /// `"crates/tui/**"` for file writes).
582    #[serde(skip_serializing_if = "Option::is_none")]
583    pub scope: Option<String>,
584    /// Optional justification for the grant (audit trail).
585    #[serde(skip_serializing_if = "Option::is_none")]
586    pub reason: Option<String>,
587}
588
589/// Runtime status of a worker.
590#[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/// Durable inbox entry: a task waiting to be leased to a worker.
603#[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/// Worker event envelope.
616#[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/// Union of all worker event payloads.
631#[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/// Retry policy for a task or worker.
695#[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/// Alert/escalation policy attached to a task or run.
735#[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        /// Webhook URL, resolved from a secret ref or inline.
764        #[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/// An alert channel endpoint, supporting both inline URLs and secret refs.
780///
781/// For Slack and generic webhook channels, the URL may be provided directly
782/// or as a secret reference resolved at send time. When both `url` and
783/// `url_ref` are present, `url_ref` takes precedence after resolution.
784#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
785pub struct FleetAlertEndpoint {
786    /// Inline URL (plaintext; only for non-sensitive endpoints).
787    #[serde(
788        alias = "webhook_url",
789        alias = "endpoint_url",
790        skip_serializing_if = "Option::is_none"
791    )]
792    pub url: Option<String>,
793    /// Reference to a secret containing the webhook URL.
794    #[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    /// Optional HMAC secret for webhook payload signing, as a secret ref.
802    #[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    /// Create an inline URL endpoint (for non-sensitive use).
813    #[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    /// Create a secret-backed URL endpoint.
823    #[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    /// Redacted display form for logging.
833    #[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/// Resolved-route detail persisted on a [`FleetReceipt`] (#3154).
842///
843/// This is an additive, *plain-strings* snapshot of the route a fleet worker
844/// resolved to. It deliberately does NOT depend on any `codewhale-config` route
845/// type so the protocol crate stays free of the route model.
846///
847/// CRITICAL no-secrets invariant: this struct carries ONLY non-sensitive route
848/// shape — provider id/kind, model ids, wire protocol, role/loadout intent, and
849/// the resolution source. It must NEVER hold a credential, API key, bearer
850/// token, or a base URL that embeds credentials. There is intentionally no
851/// field that could carry a secret.
852#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
853pub struct FleetResolvedRoute {
854    /// Resolved provider canonical id (e.g. `"deepseek"`).
855    pub provider_id: String,
856    /// Resolved provider kind (e.g. `"deepseek"`).
857    pub provider_kind: String,
858    /// Canonical, provider-agnostic model identity, when known.
859    #[serde(default, skip_serializing_if = "Option::is_none")]
860    pub canonical_model: Option<String>,
861    /// Provider-owned wire model id placed on the request.
862    pub wire_model_id: String,
863    /// Selected wire protocol (e.g. `"chat_completions"`).
864    pub protocol: String,
865    /// Effective Fleet role intent, when one applied.
866    #[serde(default, skip_serializing_if = "Option::is_none")]
867    pub role: Option<String>,
868    /// Effective Fleet loadout intent, when one applied.
869    #[serde(default, skip_serializing_if = "Option::is_none")]
870    pub loadout: Option<String>,
871    /// How the route was produced (e.g. `"resolver"`).
872    pub source: String,
873}
874
875/// Receipt produced when a task completes verification.
876#[derive(Debug, Clone, Serialize, Deserialize)]
877pub struct FleetReceipt {
878    pub run_id: FleetRunId,
879    pub task_id: String,
880    pub worker_id: String,
881    pub completed_at: String,
882    pub result: FleetTaskResult,
883    #[serde(skip_serializing_if = "Option::is_none")]
884    pub failure_kind: Option<FleetTaskFailureKind>,
885    #[serde(default)]
886    pub artifacts: Vec<FleetArtifactRef>,
887    #[serde(default)]
888    pub score: Option<FleetScore>,
889    /// Resolved-route snapshot for this task (#3154).
890    ///
891    /// `#[serde(default)]` keeps older ledgers (written before this field
892    /// existed) deserializable.
893    #[serde(default, skip_serializing_if = "Option::is_none")]
894    pub resolved_route: Option<FleetResolvedRoute>,
895}
896
897#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
898#[serde(rename_all = "snake_case")]
899pub enum FleetTaskResult {
900    Pass,
901    Partial,
902    Fail,
903    Skip,
904    Timeout,
905}
906
907/// Source category for a failed task receipt.
908#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
909#[serde(rename_all = "snake_case")]
910pub enum FleetTaskFailureKind {
911    Transport,
912    Task,
913    Verifier,
914}
915
916#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
917pub struct FleetScore {
918    pub value: f64,
919    #[serde(skip_serializing_if = "Option::is_none")]
920    pub max: Option<f64>,
921    #[serde(skip_serializing_if = "Option::is_none")]
922    pub notes: Option<String>,
923}
924
925#[cfg(test)]
926mod tests {
927    use super::*;
928
929    #[test]
930    fn fleet_run_round_trip() {
931        let run = FleetRun {
932            id: FleetRunId::from("run-001"),
933            name: "dogfood smoke".to_string(),
934            status: FleetRunStatus::Running,
935            task_specs: vec![FleetTaskSpec {
936                id: "task-1".to_string(),
937                name: "lint".to_string(),
938                description: None,
939                objective: Some("Keep the workspace lint-clean".to_string()),
940                instructions: "run cargo clippy".to_string(),
941                worker: Some(FleetTaskWorkerProfile {
942                    agent_profile: None,
943                    role: Some("release-checker".to_string()),
944                    loadout: None,
945                    model_class: None,
946                    model: None,
947                    tool_profile: Some("read-only".to_string()),
948                    tools: vec!["cargo".to_string()],
949                    capabilities: vec!["rust".to_string()],
950                }),
951                workspace: Some(FleetWorkspaceRequirements {
952                    root: Some(PathBuf::from(".")),
953                    required_files: vec![PathBuf::from("Cargo.toml")],
954                    writable_paths: vec![],
955                    environment: Some(FleetEnvironmentRequirements {
956                        required: vec!["PATH".to_string()],
957                        allowlist: vec!["RUST_LOG".to_string()],
958                    }),
959                }),
960                input_files: vec![PathBuf::from("crates/tui/src/main.rs")],
961                context: vec!["release gate".to_string()],
962                budget: Some(FleetTaskBudget {
963                    max_tokens: Some(8000),
964                    max_tool_calls: Some(20),
965                    max_seconds: Some(300),
966                }),
967                tags: vec!["release".to_string()],
968                expected_artifacts: vec![FleetArtifactKind::Log],
969                scorer: Some(FleetScorerSpec::ExitCode),
970                retry_policy: Some(FleetRetryPolicy::default()),
971                alert_policy: None,
972                timeout_seconds: Some(300),
973                metadata: BTreeMap::new(),
974            }],
975            worker_specs: vec![],
976            labels: BTreeMap::new(),
977            security_policy: None,
978            created_at: "2026-06-12T17:00:00Z".to_string(),
979            updated_at: None,
980            completed_at: None,
981        };
982        let json = serde_json::to_string(&run).unwrap();
983        let back: FleetRun = serde_json::from_str(&json).unwrap();
984        assert_eq!(back.id, run.id);
985        assert_eq!(back.status, FleetRunStatus::Running);
986        assert_eq!(back.task_specs.len(), 1);
987        assert_eq!(
988            back.task_specs[0].worker.as_ref().unwrap().role.as_deref(),
989            Some("release-checker")
990        );
991        assert_eq!(
992            back.task_specs[0]
993                .workspace
994                .as_ref()
995                .unwrap()
996                .required_files,
997            vec![PathBuf::from("Cargo.toml")]
998        );
999    }
1000
1001    #[test]
1002    fn worker_profile_carries_agent_profile_and_loadout_intent() {
1003        let json = r#"{
1004            "profile": "adversarial_reviewer",
1005            "role": "reviewer",
1006            "loadout": "auto",
1007            "model_class": "balanced",
1008            "model": "deepseek-v4-pro",
1009            "tool_profile": "read-only",
1010            "tools": ["read_file"],
1011            "capabilities": ["rust"]
1012        }"#;
1013
1014        let profile: FleetTaskWorkerProfile = serde_json::from_str(json).unwrap();
1015
1016        assert_eq!(
1017            profile.agent_profile.as_deref(),
1018            Some("adversarial_reviewer")
1019        );
1020        assert_eq!(profile.role.as_deref(), Some("reviewer"));
1021        assert_eq!(profile.loadout.as_deref(), Some("auto"));
1022        assert_eq!(profile.model_class.as_deref(), Some("balanced"));
1023        assert_eq!(profile.model.as_deref(), Some("deepseek-v4-pro"));
1024        assert_eq!(profile.tool_profile.as_deref(), Some("read-only"));
1025
1026        let serialized = serde_json::to_value(&profile).unwrap();
1027        assert_eq!(serialized["agent_profile"], "adversarial_reviewer");
1028        assert_eq!(serialized["model"], "deepseek-v4-pro");
1029        assert!(serialized.get("profile").is_none());
1030    }
1031
1032    #[test]
1033    fn worker_event_lifecycle_round_trip() {
1034        let events = vec![
1035            FleetWorkerEvent {
1036                seq: 1,
1037                run_id: FleetRunId::from("run-002"),
1038                worker_id: "worker-a".to_string(),
1039                task_id: "task-1".to_string(),
1040                timestamp: "2026-06-12T17:01:00Z".to_string(),
1041                payload: FleetWorkerEventPayload::Queued,
1042                extra: BTreeMap::new(),
1043            },
1044            FleetWorkerEvent {
1045                seq: 2,
1046                run_id: FleetRunId::from("run-002"),
1047                worker_id: "worker-a".to_string(),
1048                task_id: "task-1".to_string(),
1049                timestamp: "2026-06-12T17:01:05Z".to_string(),
1050                payload: FleetWorkerEventPayload::RunningTool {
1051                    tool: "bash".to_string(),
1052                    call_id: Some("call-1".to_string()),
1053                },
1054                extra: BTreeMap::new(),
1055            },
1056            FleetWorkerEvent {
1057                seq: 3,
1058                run_id: FleetRunId::from("run-002"),
1059                worker_id: "worker-a".to_string(),
1060                task_id: "task-1".to_string(),
1061                timestamp: "2026-06-12T17:02:00Z".to_string(),
1062                payload: FleetWorkerEventPayload::Completed {
1063                    exit_code: Some(0),
1064                    summary: Some("ok".to_string()),
1065                },
1066                extra: BTreeMap::new(),
1067            },
1068        ];
1069        let json = serde_json::to_string(&events).unwrap();
1070        let back: Vec<FleetWorkerEvent> = serde_json::from_str(&json).unwrap();
1071        assert_eq!(back.len(), 3);
1072        assert!(matches!(back[0].payload, FleetWorkerEventPayload::Queued));
1073        assert!(matches!(
1074            back[2].payload,
1075            FleetWorkerEventPayload::Completed { .. }
1076        ));
1077    }
1078
1079    #[test]
1080    fn alert_policy_round_trip() {
1081        let policy = FleetAlertPolicy {
1082            events: vec![FleetAlertEventClass::Stale],
1083            channels: vec![FleetAlertChannel::Slack {
1084                webhook: FleetAlertEndpoint::inline("https://hooks.slack.com/test"),
1085            }],
1086            after_attempts: Some(2),
1087            after_minutes_stale: Some(10),
1088        };
1089        let json = serde_json::to_string(&policy).unwrap();
1090        assert!(json.contains("\"events\":[\"stale\"]"));
1091        assert!(json.contains("\"kind\":\"slack\""));
1092        let back: FleetAlertPolicy = serde_json::from_str(&json).unwrap();
1093        assert_eq!(back.events, vec![FleetAlertEventClass::Stale]);
1094        assert_eq!(back.after_attempts, Some(2));
1095    }
1096
1097    #[test]
1098    fn artifact_other_kind_round_trip() {
1099        let artifact = FleetArtifactRef {
1100            kind: FleetArtifactKind::Other("coverage.xml".to_string()),
1101            path: PathBuf::from("/tmp/coverage.xml"),
1102            checksum: Some("sha256:abc".to_string()),
1103            mime_type: Some("application/xml".to_string()),
1104            size_bytes: Some(1024),
1105        };
1106        let json = serde_json::to_string(&artifact).unwrap();
1107        let back: FleetArtifactRef = serde_json::from_str(&json).unwrap();
1108        assert_eq!(back.kind, artifact.kind);
1109        assert_eq!(back.size_bytes, Some(1024));
1110    }
1111
1112    #[test]
1113    fn ssh_host_spec_accepts_minimal_legacy_json() {
1114        let json = r#"{"kind":"ssh","host":"builder.example.test"}"#;
1115        let host: FleetHostSpec = serde_json::from_str(json).unwrap();
1116
1117        match host {
1118            FleetHostSpec::Ssh {
1119                host,
1120                port,
1121                user,
1122                identity,
1123                known_hosts,
1124                host_key_fingerprint,
1125                working_directory,
1126                env_allowlist,
1127                codewhale_binary,
1128            } => {
1129                assert_eq!(host, "builder.example.test");
1130                assert_eq!(port, None);
1131                assert_eq!(user, None);
1132                assert_eq!(identity, None);
1133                assert_eq!(known_hosts, None);
1134                assert_eq!(host_key_fingerprint, None);
1135                assert_eq!(working_directory, None);
1136                assert!(env_allowlist.is_empty());
1137                assert_eq!(codewhale_binary, None);
1138            }
1139            other => panic!("expected ssh host spec, got {other:?}"),
1140        }
1141    }
1142
1143    #[test]
1144    fn artifact_kind_uses_flat_string_json() {
1145        let known = serde_json::to_string(&FleetArtifactKind::TestResult).unwrap();
1146        assert_eq!(known, "\"test_result\"");
1147
1148        let custom =
1149            serde_json::to_string(&FleetArtifactKind::Other("coverage.xml".to_string())).unwrap();
1150        assert_eq!(custom, "\"coverage.xml\"");
1151
1152        let parsed: FleetArtifactKind = serde_json::from_str("\"coverage.xml\"").unwrap();
1153        assert_eq!(parsed, FleetArtifactKind::Other("coverage.xml".to_string()));
1154    }
1155
1156    #[test]
1157    fn retry_policy_missing_fields_use_nonzero_defaults() {
1158        let policy: FleetRetryPolicy = serde_json::from_value(serde_json::json!({})).unwrap();
1159        assert_eq!(policy, FleetRetryPolicy::default());
1160
1161        let policy: FleetRetryPolicy =
1162            serde_json::from_value(serde_json::json!({"max_attempts": 5})).unwrap();
1163        assert_eq!(policy.max_attempts, 5);
1164        assert_eq!(
1165            policy.initial_backoff_seconds,
1166            FleetRetryPolicy::default().initial_backoff_seconds
1167        );
1168        assert_eq!(
1169            policy.max_backoff_seconds,
1170            FleetRetryPolicy::default().max_backoff_seconds
1171        );
1172        assert_eq!(
1173            policy.backoff_multiplier,
1174            FleetRetryPolicy::default().backoff_multiplier
1175        );
1176    }
1177
1178    #[test]
1179    fn sparse_worker_events_omit_absent_optional_fields() {
1180        let heartbeat = FleetWorkerEventPayload::Heartbeat {
1181            cpu_percent: None,
1182            memory_mb: None,
1183        };
1184        let heartbeat_json = serde_json::to_value(&heartbeat).unwrap();
1185        assert_eq!(heartbeat_json, serde_json::json!({"state": "heartbeat"}));
1186
1187        let completed = FleetWorkerEventPayload::Completed {
1188            exit_code: None,
1189            summary: None,
1190        };
1191        let completed_json = serde_json::to_value(&completed).unwrap();
1192        assert_eq!(completed_json, serde_json::json!({"state": "completed"}));
1193    }
1194
1195    #[test]
1196    fn receipt_round_trip() {
1197        let receipt = FleetReceipt {
1198            run_id: FleetRunId::from("run-003"),
1199            task_id: "task-1".to_string(),
1200            worker_id: "worker-b".to_string(),
1201            completed_at: "2026-06-12T17:03:00Z".to_string(),
1202            result: FleetTaskResult::Pass,
1203            failure_kind: None,
1204            artifacts: vec![],
1205            score: Some(FleetScore {
1206                value: 0.95,
1207                max: Some(1.0),
1208                notes: None,
1209            }),
1210            resolved_route: None,
1211        };
1212        let json = serde_json::to_string(&receipt).unwrap();
1213        let back: FleetReceipt = serde_json::from_str(&json).unwrap();
1214        assert_eq!(back.result, FleetTaskResult::Pass);
1215        assert_eq!(back.score.as_ref().unwrap().value, 0.95);
1216    }
1217
1218    #[test]
1219    fn partial_receipt_records_failure_source_when_needed() {
1220        let receipt = FleetReceipt {
1221            run_id: FleetRunId::from("run-004"),
1222            task_id: "task-2".to_string(),
1223            worker_id: "worker-c".to_string(),
1224            completed_at: "2026-06-12T17:04:00Z".to_string(),
1225            result: FleetTaskResult::Partial,
1226            failure_kind: Some(FleetTaskFailureKind::Verifier),
1227            artifacts: vec![],
1228            score: Some(FleetScore {
1229                value: 0.5,
1230                max: Some(1.0),
1231                notes: Some("manual verification required".to_string()),
1232            }),
1233            resolved_route: None,
1234        };
1235
1236        let json = serde_json::to_string(&receipt).unwrap();
1237        assert!(json.contains("\"result\":\"partial\""));
1238        assert!(json.contains("\"failure_kind\":\"verifier\""));
1239        let back: FleetReceipt = serde_json::from_str(&json).unwrap();
1240        assert_eq!(back.result, FleetTaskResult::Partial);
1241        assert_eq!(back.failure_kind, Some(FleetTaskFailureKind::Verifier));
1242    }
1243
1244    #[test]
1245    fn ssh_host_spec_with_key_pinning_round_trip() {
1246        let spec = FleetHostSpec::Ssh {
1247            host: "builder.trusted.example.com".to_string(),
1248            port: Some(22),
1249            user: Some("codewhale".to_string()),
1250            identity: Some(PathBuf::from("~/.ssh/codewhale_fleet")),
1251            known_hosts: Some(PathBuf::from("~/.ssh/known_hosts")),
1252            host_key_fingerprint: Some("SHA256:aLGqZo1M6c...".to_string()),
1253            working_directory: Some(PathBuf::from("/srv/codewhale/work")),
1254            env_allowlist: vec!["CODEWHALE_PROFILE".to_string()],
1255            codewhale_binary: Some("/usr/local/bin/codewhale".to_string()),
1256        };
1257        let json = serde_json::to_string_pretty(&spec).unwrap();
1258        assert!(json.contains("\"known_hosts\""));
1259        assert!(json.contains("\"host_key_fingerprint\""));
1260        assert!(json.contains("SHA256:aLGqZo1M6c..."));
1261
1262        let back: FleetHostSpec = serde_json::from_str(&json).unwrap();
1263        match back {
1264            FleetHostSpec::Ssh {
1265                host,
1266                known_hosts,
1267                host_key_fingerprint,
1268                ..
1269            } => {
1270                assert_eq!(host, "builder.trusted.example.com");
1271                assert_eq!(known_hosts, Some(PathBuf::from("~/.ssh/known_hosts")));
1272                assert_eq!(
1273                    host_key_fingerprint,
1274                    Some("SHA256:aLGqZo1M6c...".to_string())
1275                );
1276            }
1277            other => panic!("expected ssh host spec, got {other:?}"),
1278        }
1279    }
1280
1281    #[test]
1282    fn secret_ref_redacted_never_exposes_value() {
1283        let ref_ = FleetSecretRef::new("DEEPSEEK_API_KEY");
1284        let redacted = ref_.redacted();
1285        assert!(redacted.contains("DEEPSEEK_API_KEY"));
1286        assert!(!redacted.contains("sk-"));
1287        assert!(redacted.contains("<secret:"));
1288
1289        let ref_ = FleetSecretRef::with_source("GH_TOKEN", "env");
1290        let redacted = ref_.redacted();
1291        assert!(redacted.contains("env.GH_TOKEN"));
1292        assert!(!redacted.contains("ghp_"));
1293    }
1294
1295    #[test]
1296    fn alert_endpoint_from_secret_round_trip() {
1297        let endpoint = FleetAlertEndpoint::from_secret(FleetSecretRef::new("SLACK_WEBHOOK"));
1298        let json = serde_json::to_string(&endpoint).unwrap();
1299        assert!(json.contains("SLACK_WEBHOOK"));
1300        assert!(!json.contains("hooks.slack.com"));
1301
1302        let back: FleetAlertEndpoint = serde_json::from_str(&json).unwrap();
1303        assert_eq!(back.url_ref.as_ref().unwrap().key, "SLACK_WEBHOOK");
1304        assert_eq!(back.url, None);
1305    }
1306
1307    #[test]
1308    fn secret_ref_accepts_legacy_string_wire_shape() {
1309        let ref_: FleetSecretRef = serde_json::from_str(r#""CODEWHALE_FLEET_TOKEN""#).unwrap();
1310        assert_eq!(ref_, FleetSecretRef::new("CODEWHALE_FLEET_TOKEN"));
1311
1312        let ref_: FleetSecretRef =
1313            serde_json::from_str(r#"{"key":"GH_TOKEN","source":"env"}"#).unwrap();
1314        assert_eq!(ref_, FleetSecretRef::with_source("GH_TOKEN", "env"));
1315    }
1316
1317    #[test]
1318    fn trust_level_accepts_hyphenated_remote_verified() {
1319        let trust: FleetTrustLevel = serde_json::from_str(r#""remote-verified""#).unwrap();
1320        assert_eq!(trust, FleetTrustLevel::RemoteVerified);
1321
1322        let canonical = serde_json::to_string(&trust).unwrap();
1323        assert_eq!(canonical, r#""remote_verified""#);
1324    }
1325
1326    #[test]
1327    fn alert_channel_accepts_legacy_webhook_fields() {
1328        let channel: FleetAlertChannel = serde_json::from_str(
1329            r#"{
1330                "kind": "slack",
1331                "webhook_url": "https://hooks.slack.com/test",
1332                "secret": "SLACK_SIGNING_SECRET"
1333            }"#,
1334        )
1335        .unwrap();
1336
1337        match channel {
1338            FleetAlertChannel::Slack { webhook } => {
1339                assert_eq!(webhook.url.as_deref(), Some("https://hooks.slack.com/test"));
1340                assert_eq!(
1341                    webhook.secret_ref,
1342                    Some(FleetSecretRef::new("SLACK_SIGNING_SECRET"))
1343                );
1344            }
1345            other => panic!("expected slack channel, got {other:?}"),
1346        }
1347    }
1348
1349    #[test]
1350    fn security_policy_defaults_are_conservative() {
1351        let policy = FleetSecurityPolicy::default();
1352        assert_eq!(policy.default_trust_level, FleetTrustLevel::Sandbox);
1353        assert!(policy.allowed_secrets.is_empty());
1354        assert!(policy.capability_grants.is_empty());
1355        assert_eq!(policy.max_trust_level, FleetTrustLevel::Operator);
1356        assert!(!policy.require_identity_verification);
1357    }
1358
1359    #[test]
1360    fn trust_level_ordinal_reflects_privilege() {
1361        assert!(FleetTrustLevel::Operator > FleetTrustLevel::RemoteVerified);
1362        assert!(FleetTrustLevel::RemoteVerified > FleetTrustLevel::Local);
1363        assert!(FleetTrustLevel::Local > FleetTrustLevel::Sandbox);
1364
1365        assert!(FleetTrustLevel::Operator.may_access_secrets());
1366        assert!(!FleetTrustLevel::Sandbox.may_access_secrets());
1367        assert!(!FleetTrustLevel::Sandbox.may_write_workspace());
1368        assert!(FleetTrustLevel::Operator.may_write_workspace());
1369    }
1370
1371    fn sample_receipt_with_route() -> FleetReceipt {
1372        FleetReceipt {
1373            run_id: FleetRunId::from("run-route"),
1374            task_id: "task-route".to_string(),
1375            worker_id: "worker-route".to_string(),
1376            completed_at: "2026-06-23T00:00:00Z".to_string(),
1377            result: FleetTaskResult::Pass,
1378            failure_kind: None,
1379            artifacts: vec![],
1380            score: None,
1381            resolved_route: Some(FleetResolvedRoute {
1382                provider_id: "deepseek".to_string(),
1383                provider_kind: "deepseek".to_string(),
1384                canonical_model: Some("deepseek-v4-pro".to_string()),
1385                wire_model_id: "deepseek-v4-pro".to_string(),
1386                protocol: "chat_completions".to_string(),
1387                role: Some("builder".to_string()),
1388                loadout: Some("auto".to_string()),
1389                source: "resolver".to_string(),
1390            }),
1391        }
1392    }
1393
1394    #[test]
1395    fn fleet_resolved_route_round_trips() {
1396        let receipt = sample_receipt_with_route();
1397        let json = serde_json::to_string(&receipt).unwrap();
1398        let back: FleetReceipt = serde_json::from_str(&json).unwrap();
1399        assert_eq!(back.resolved_route, receipt.resolved_route);
1400        let route = back.resolved_route.unwrap();
1401        assert_eq!(route.provider_id, "deepseek");
1402        assert_eq!(route.wire_model_id, "deepseek-v4-pro");
1403        assert_eq!(route.protocol, "chat_completions");
1404        assert_eq!(route.role.as_deref(), Some("builder"));
1405        assert_eq!(route.source, "resolver");
1406    }
1407
1408    #[test]
1409    fn fleet_receipt_without_resolved_route_still_deserializes() {
1410        // An old ledger receipt JSON written before #3154 has no
1411        // `resolved_route` key; `#[serde(default)]` must keep it readable.
1412        let legacy = r#"{
1413            "run_id": "run-legacy",
1414            "task_id": "task-legacy",
1415            "worker_id": "worker-legacy",
1416            "completed_at": "2026-06-01T00:00:00Z",
1417            "result": "pass",
1418            "artifacts": [],
1419            "score": null
1420        }"#;
1421        let receipt: FleetReceipt = serde_json::from_str(legacy).unwrap();
1422        assert_eq!(receipt.task_id, "task-legacy");
1423        assert!(receipt.resolved_route.is_none());
1424    }
1425
1426    #[test]
1427    fn fleet_resolved_route_serialization_carries_no_secrets() {
1428        let receipt = sample_receipt_with_route();
1429        // Scan the serialized resolved-route object: this is the field whose
1430        // no-secrets invariant we are asserting. Scoping to the route value
1431        // avoids false positives from unrelated envelope ids (e.g. a task id
1432        // such as "task-foo" innocently contains the substring "sk-").
1433        let route_json = serde_json::to_string(receipt.resolved_route.as_ref().unwrap()).unwrap();
1434        assert_no_secret_markers(&route_json);
1435        // The envelope as a whole must also stay credential-free.
1436        let receipt_json = serde_json::to_string(&receipt).unwrap();
1437        for needle in SECRET_KEY_MARKERS {
1438            assert!(
1439                !receipt_json.to_ascii_lowercase().contains(needle),
1440                "receipt JSON must not contain secret-key marker {needle:?}: {receipt_json}"
1441            );
1442        }
1443    }
1444
1445    /// Substrings that indicate a leaked credential field/value. These are
1446    /// deliberately specific so legitimate ids/model names do not trip them.
1447    const SECRET_KEY_MARKERS: &[&str] = &[
1448        "api_key",
1449        "apikey",
1450        "api-key",
1451        "authorization",
1452        "bearer ",
1453        "auth_token",
1454        "auth-token",
1455        "password",
1456        "credential",
1457        "sk-ant-",
1458        "sk-proj-",
1459        "sk-or-",
1460        "secret",
1461    ];
1462
1463    fn assert_no_secret_markers(json: &str) {
1464        let haystack = json.to_ascii_lowercase();
1465        for needle in SECRET_KEY_MARKERS {
1466            assert!(
1467                !haystack.contains(needle),
1468                "resolved-route JSON must not contain secret marker {needle:?}: {json}"
1469            );
1470        }
1471    }
1472}