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/model-class
849/// intent, reasoning tier when known, and deterministic intent sources. It
850/// must NEVER hold a credential, API key, bearer token, or a base URL that
851/// embeds credentials. There is intentionally no field that could carry a
852/// secret.
853#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
854pub struct FleetResolvedRoute {
855    /// Resolved provider canonical id (e.g. `"deepseek"`).
856    pub provider_id: String,
857    /// Resolved provider kind (e.g. `"deepseek"`).
858    pub provider_kind: String,
859    /// Canonical, provider-agnostic model identity, when known.
860    #[serde(default, skip_serializing_if = "Option::is_none")]
861    pub canonical_model: Option<String>,
862    /// Provider-owned wire model id placed on the request.
863    pub wire_model_id: String,
864    /// Selected wire protocol (e.g. `"chat_completions"`).
865    pub protocol: String,
866    /// Effective Fleet role intent, when one applied.
867    #[serde(default, skip_serializing_if = "Option::is_none")]
868    pub role: Option<String>,
869    /// Effective Fleet loadout intent, when one applied.
870    #[serde(default, skip_serializing_if = "Option::is_none")]
871    pub loadout: Option<String>,
872    /// Original task-level model-class intent, when authored separately from
873    /// `loadout`. Profile `model_class_hint` is normalized into `loadout`.
874    #[serde(default, skip_serializing_if = "Option::is_none")]
875    pub model_class: Option<String>,
876    /// Runtime model-route seam used by sub-agent routing (`inherit`, `faster`,
877    /// `auto`, or `fixed`).
878    #[serde(default, skip_serializing_if = "Option::is_none")]
879    pub model_route: Option<String>,
880    /// Concrete reasoning tier, when it is known by the route resolver path.
881    #[serde(default, skip_serializing_if = "Option::is_none")]
882    pub reasoning_effort: Option<String>,
883    /// Deterministic source for the effective role intent.
884    #[serde(default, skip_serializing_if = "Option::is_none")]
885    pub role_source: Option<String>,
886    /// Deterministic source for the effective loadout intent.
887    #[serde(default, skip_serializing_if = "Option::is_none")]
888    pub loadout_source: Option<String>,
889    /// Deterministic source for the model-class hint, when present.
890    #[serde(default, skip_serializing_if = "Option::is_none")]
891    pub model_class_source: Option<String>,
892    /// Deterministic source for the model selector used by the resolver.
893    #[serde(default, skip_serializing_if = "Option::is_none")]
894    pub model_source: Option<String>,
895    /// How the route was produced (e.g. `"resolver"`).
896    pub source: String,
897}
898
899/// Effective worker authority persisted on a [`FleetReceipt`] (#3211).
900///
901/// This is a non-secret snapshot of the already-computed runtime profile. It
902/// records what the worker was allowed to do; it does not grant permissions and
903/// does not carry credentials, sandbox paths, or provider endpoints.
904#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
905pub struct FleetEffectivePermissions {
906    /// Whether the worker profile may modify workspace files.
907    pub write: bool,
908    /// Whether the worker profile may use network-capable tools.
909    pub network: bool,
910    /// Shell posture (`none`, `read_only`, or `full`).
911    pub shell: String,
912    /// Tool-surface posture (`inherit` or `explicit`).
913    pub tool_scope: String,
914    /// Explicit tool names when `tool_scope` is `explicit`.
915    #[serde(default, skip_serializing_if = "Vec::is_empty")]
916    pub tools: Vec<String>,
917    /// Whether the worker is intended to run detached/background.
918    pub background: bool,
919    /// Remaining nested-delegation budget after parent intersection/hardening.
920    pub max_spawn_depth: u32,
921    /// Roster profile id that contributed to this worker, when any.
922    #[serde(default, skip_serializing_if = "Option::is_none")]
923    pub profile_id: Option<String>,
924    /// Roster layer for `profile_id` (`built_in`, `config`, or `workspace`).
925    #[serde(default, skip_serializing_if = "Option::is_none")]
926    pub profile_origin: Option<String>,
927    /// How this snapshot was produced (e.g. `"worker_runtime_profile"`).
928    pub source: String,
929}
930
931/// Receipt produced when a task completes verification.
932#[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    /// Resolved-route snapshot for this task (#3154).
946    ///
947    /// `#[serde(default)]` keeps older ledgers (written before this field
948    /// existed) deserializable.
949    #[serde(default, skip_serializing_if = "Option::is_none")]
950    pub resolved_route: Option<FleetResolvedRoute>,
951    /// Effective worker authority for this task (#3211).
952    #[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/// Source category for a failed task receipt.
967#[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        // An old ledger receipt JSON written before #3154 has no
1520        // `resolved_route` key; `#[serde(default)]` must keep it readable.
1521        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        // Scan the serialized resolved-route object: this is the field whose
1575        // no-secrets invariant we are asserting. Scoping to the route value
1576        // avoids false positives from unrelated envelope ids (e.g. a task id
1577        // such as "task-foo" innocently contains the substring "sk-").
1578        let route_json = serde_json::to_string(receipt.resolved_route.as_ref().unwrap()).unwrap();
1579        assert_no_secret_markers(&route_json);
1580        // The envelope as a whole must also stay credential-free.
1581        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    /// Substrings that indicate a leaked credential field/value. These are
1591    /// deliberately specific so legitimate ids/model names do not trip them.
1592    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}