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
18use super::Status;
19
20pub const FLEET_PROTOCOL_VERSION: &str = "0.1.0";
21
22/// Globally unique identifier for a fleet run.
23#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)]
24pub struct FleetRunId(pub String);
25
26impl From<String> for FleetRunId {
27    fn from(value: String) -> Self {
28        Self(value)
29    }
30}
31
32impl From<&str> for FleetRunId {
33    fn from(value: &str) -> Self {
34        Self(value.to_string())
35    }
36}
37
38/// Top-level fleet run handle.
39#[derive(Debug, Clone, Serialize, Deserialize)]
40pub struct FleetRun {
41    pub id: FleetRunId,
42    pub name: String,
43    pub status: FleetRunStatus,
44    /// Maximum number of workers the manager may drive concurrently.
45    ///
46    /// Older ledgers omit this field; callers fall back to the persisted
47    /// worker roster when resuming those runs.
48    #[serde(default, skip_serializing_if = "Option::is_none")]
49    pub max_workers: Option<usize>,
50    #[serde(default)]
51    pub task_specs: Vec<FleetTaskSpec>,
52    #[serde(default)]
53    pub worker_specs: Vec<FleetWorkerSpec>,
54    #[serde(default)]
55    pub labels: BTreeMap<String, String>,
56    #[serde(skip_serializing_if = "Option::is_none")]
57    pub security_policy: Option<FleetSecurityPolicy>,
58    pub created_at: String,
59    #[serde(skip_serializing_if = "Option::is_none")]
60    pub updated_at: Option<String>,
61    #[serde(skip_serializing_if = "Option::is_none")]
62    pub completed_at: Option<String>,
63}
64
65/// Lifecycle status for an entire fleet run.
66#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
67#[serde(rename_all = "snake_case")]
68pub enum FleetRunStatus {
69    Pending,
70    Queued,
71    Running,
72    Paused,
73    Completed,
74    Failed,
75    Cancelled,
76}
77
78impl Status for FleetRunStatus {
79    fn is_terminal(&self) -> bool {
80        matches!(self, Self::Completed | Self::Failed | Self::Cancelled)
81    }
82    fn is_active(&self) -> bool {
83        matches!(self, Self::Pending | Self::Queued | Self::Running)
84    }
85    fn is_paused(&self) -> bool {
86        matches!(self, Self::Paused)
87    }
88}
89
90/// Specification of a single unit of work within a run.
91#[derive(Debug, Clone, Serialize, Deserialize)]
92pub struct FleetTaskSpec {
93    pub id: String,
94    pub name: String,
95    #[serde(skip_serializing_if = "Option::is_none")]
96    pub description: Option<String>,
97    #[serde(skip_serializing_if = "Option::is_none")]
98    pub objective: Option<String>,
99    pub instructions: String,
100    #[serde(skip_serializing_if = "Option::is_none")]
101    pub worker: Option<FleetTaskWorkerProfile>,
102    #[serde(skip_serializing_if = "Option::is_none")]
103    pub workspace: Option<FleetWorkspaceRequirements>,
104    #[serde(default)]
105    #[serde(skip_serializing_if = "Vec::is_empty")]
106    pub input_files: Vec<PathBuf>,
107    #[serde(default)]
108    #[serde(skip_serializing_if = "Vec::is_empty")]
109    pub context: Vec<String>,
110    #[serde(skip_serializing_if = "Option::is_none")]
111    pub budget: Option<FleetTaskBudget>,
112    #[serde(default)]
113    #[serde(skip_serializing_if = "Vec::is_empty")]
114    pub tags: Vec<String>,
115    #[serde(default)]
116    pub expected_artifacts: Vec<FleetArtifactKind>,
117    #[serde(skip_serializing_if = "Option::is_none")]
118    pub scorer: Option<FleetScorerSpec>,
119    #[serde(skip_serializing_if = "Option::is_none")]
120    pub retry_policy: Option<FleetRetryPolicy>,
121    #[serde(skip_serializing_if = "Option::is_none")]
122    pub alert_policy: Option<FleetAlertPolicy>,
123    #[serde(default)]
124    pub timeout_seconds: Option<u64>,
125    #[serde(default)]
126    pub metadata: BTreeMap<String, Value>,
127}
128
129/// Worker role and tool expectations for a task.
130#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
131pub struct FleetTaskWorkerProfile {
132    /// Named agent profile/persona posture to layer onto this worker.
133    ///
134    /// `profile` is accepted as a shorter authoring alias. This is an intent
135    /// reference only; profile loading and permission narrowing happen in the
136    /// Fleet runtime layer.
137    #[serde(default, alias = "profile", skip_serializing_if = "Option::is_none")]
138    pub agent_profile: Option<String>,
139    #[serde(skip_serializing_if = "Option::is_none")]
140    pub role: Option<String>,
141    /// Fleet loadout intent such as `auto`, `fast`, or `review`.
142    ///
143    /// This is not a concrete provider/model selection; route resolution owns
144    /// the executable provider/model/wire-model decision.
145    #[serde(default, skip_serializing_if = "Option::is_none")]
146    pub loadout: Option<String>,
147    /// Fleet model class hint such as `strong`, `balanced`, or `fast`.
148    #[serde(default, skip_serializing_if = "Option::is_none")]
149    pub model_class: Option<String>,
150    /// Optional explicit model id for this worker.
151    ///
152    /// Task-level model overrides are visible authoring data and take
153    /// precedence over the referenced agent profile's model hint. Provider and
154    /// wire-model validation still belong to route resolution.
155    #[serde(default, skip_serializing_if = "Option::is_none")]
156    pub model: Option<String>,
157    #[serde(skip_serializing_if = "Option::is_none")]
158    pub tool_profile: Option<String>,
159    #[serde(default)]
160    #[serde(skip_serializing_if = "Vec::is_empty")]
161    pub tools: Vec<String>,
162    #[serde(default)]
163    #[serde(skip_serializing_if = "Vec::is_empty")]
164    pub capabilities: Vec<String>,
165}
166
167/// Workspace and environment constraints needed before a task starts.
168#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
169pub struct FleetWorkspaceRequirements {
170    #[serde(skip_serializing_if = "Option::is_none")]
171    pub root: Option<PathBuf>,
172    #[serde(default)]
173    #[serde(skip_serializing_if = "Vec::is_empty")]
174    pub required_files: Vec<PathBuf>,
175    #[serde(default)]
176    #[serde(skip_serializing_if = "Vec::is_empty")]
177    pub writable_paths: Vec<PathBuf>,
178    #[serde(skip_serializing_if = "Option::is_none")]
179    pub environment: Option<FleetEnvironmentRequirements>,
180}
181
182/// Environment variables a task requires or may pass through to workers.
183#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
184pub struct FleetEnvironmentRequirements {
185    #[serde(default)]
186    #[serde(skip_serializing_if = "Vec::is_empty")]
187    pub required: Vec<String>,
188    #[serde(default)]
189    #[serde(skip_serializing_if = "Vec::is_empty")]
190    pub allowlist: Vec<String>,
191}
192
193/// Budget limits for a task.
194#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
195pub struct FleetTaskBudget {
196    #[serde(skip_serializing_if = "Option::is_none")]
197    pub max_tokens: Option<u64>,
198    #[serde(skip_serializing_if = "Option::is_none")]
199    pub max_tool_calls: Option<u32>,
200    #[serde(skip_serializing_if = "Option::is_none")]
201    pub max_seconds: Option<u64>,
202}
203
204/// Reference to an artifact produced or consumed by a task.
205#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
206pub struct FleetArtifactRef {
207    pub kind: FleetArtifactKind,
208    pub path: PathBuf,
209    #[serde(skip_serializing_if = "Option::is_none")]
210    pub checksum: Option<String>,
211    #[serde(skip_serializing_if = "Option::is_none")]
212    pub mime_type: Option<String>,
213    #[serde(default)]
214    pub size_bytes: Option<u64>,
215}
216
217/// Kind of artifact a task may produce or consume.
218#[derive(Debug, Clone, PartialEq, Eq)]
219pub enum FleetArtifactKind {
220    Log,
221    Patch,
222    TestResult,
223    Report,
224    Checkpoint,
225    Receipt,
226    Other(String),
227}
228
229impl FleetArtifactKind {
230    fn as_wire_str(&self) -> &str {
231        match self {
232            Self::Log => "log",
233            Self::Patch => "patch",
234            Self::TestResult => "test_result",
235            Self::Report => "report",
236            Self::Checkpoint => "checkpoint",
237            Self::Receipt => "receipt",
238            Self::Other(kind) => kind.as_str(),
239        }
240    }
241
242    fn from_wire_str(value: &str) -> Self {
243        match value {
244            "log" => Self::Log,
245            "patch" => Self::Patch,
246            "test_result" => Self::TestResult,
247            "report" => Self::Report,
248            "checkpoint" => Self::Checkpoint,
249            "receipt" => Self::Receipt,
250            other => Self::Other(other.to_string()),
251        }
252    }
253}
254
255impl Serialize for FleetArtifactKind {
256    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
257    where
258        S: Serializer,
259    {
260        serializer.serialize_str(self.as_wire_str())
261    }
262}
263
264impl<'de> Deserialize<'de> for FleetArtifactKind {
265    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
266    where
267        D: Deserializer<'de>,
268    {
269        let value = String::deserialize(deserializer)?;
270        Ok(Self::from_wire_str(&value))
271    }
272}
273
274/// Scoring rule used to verify a task result.
275#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
276#[serde(tag = "kind", rename_all = "snake_case")]
277pub enum FleetScorerSpec {
278    ExitCode,
279    FileExists {
280        path: PathBuf,
281    },
282    RegexMatch {
283        path: PathBuf,
284        pattern: String,
285    },
286    JsonPath {
287        path: PathBuf,
288        expression: String,
289    },
290    Command {
291        command: String,
292        #[serde(default)]
293        args: Vec<String>,
294    },
295    CodeWhaleVerifierPrompt {
296        prompt: String,
297    },
298    Manual,
299}
300
301/// Worker specification.
302#[derive(Debug, Clone, Serialize, Deserialize)]
303pub struct FleetWorkerSpec {
304    pub id: String,
305    pub name: String,
306    pub host: FleetHostSpec,
307    #[serde(default)]
308    #[serde(skip_serializing_if = "Option::is_none")]
309    pub trust_level: Option<FleetTrustLevel>,
310    #[serde(default)]
311    pub labels: BTreeMap<String, String>,
312    #[serde(default)]
313    pub capabilities: Vec<String>,
314    #[serde(skip_serializing_if = "Option::is_none")]
315    pub max_concurrent_tasks: Option<usize>,
316}
317
318/// Host on which a worker runs.
319#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
320#[serde(tag = "kind", rename_all = "snake_case")]
321pub enum FleetHostSpec {
322    Local,
323    Ssh {
324        host: String,
325        #[serde(skip_serializing_if = "Option::is_none")]
326        port: Option<u16>,
327        #[serde(skip_serializing_if = "Option::is_none")]
328        user: Option<String>,
329        #[serde(skip_serializing_if = "Option::is_none")]
330        identity: Option<PathBuf>,
331        /// Known hosts file for host-key verification.
332        #[serde(skip_serializing_if = "Option::is_none")]
333        known_hosts: Option<PathBuf>,
334        /// Expected host key fingerprint (SHA256:...) for key pinning.
335        /// When set, the connection is only trusted if the server's
336        /// host key matches this fingerprint exactly.
337        #[serde(skip_serializing_if = "Option::is_none")]
338        host_key_fingerprint: Option<String>,
339        #[serde(skip_serializing_if = "Option::is_none")]
340        working_directory: Option<PathBuf>,
341        #[serde(default)]
342        #[serde(skip_serializing_if = "Vec::is_empty")]
343        env_allowlist: Vec<String>,
344        #[serde(skip_serializing_if = "Option::is_none")]
345        codewhale_binary: Option<String>,
346    },
347    #[serde(alias = "container")]
348    #[serde(alias = "Container")]
349    Docker {
350        image: String,
351        #[serde(default)]
352        args: Vec<String>,
353    },
354}
355
356// ── Security and trust types ────────────────────────────────────────────────
357
358/// Trust classification assigned to a worker host.
359///
360/// The trust level determines what a worker is allowed to do and what
361/// secrets it may access. The default for new workers is [`FleetTrustLevel::Sandbox`];
362/// operators must explicitly raise trust for SSH or container workers.
363#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord, Default)]
364#[serde(rename_all = "snake_case")]
365pub enum FleetTrustLevel {
366    /// Fully isolated: no network, no secrets, no writes outside `.codewhale/fleet/`.
367    /// Suitable for untrusted code review, community PR checks, or third-party tool runs.
368    #[default]
369    Sandbox = 0,
370    /// Local-only worker with access to the workspace and configured secrets.
371    /// Default for local workers. May read repo files but writes are gated.
372    Local = 1,
373    /// Worker on a known remote host with verified identity and a bounded
374    /// set of explicitly granted capabilities. Requires SSH host-key
375    /// verification or equivalent attestation.
376    #[serde(alias = "remote-verified", alias = "remoteVerified")]
377    RemoteVerified = 2,
378    /// Fully trusted worker (e.g. operator's own machine, CI runner).
379    /// Has access to all configured secrets and may perform any action the
380    /// operator can. Reserved for dogfood smoke and operator-owned machines.
381    Operator = 3,
382}
383
384impl FleetTrustLevel {
385    /// Whether this trust level is allowed to access provider secrets.
386    #[must_use]
387    pub fn may_access_secrets(&self) -> bool {
388        matches!(self, Self::Operator | Self::RemoteVerified | Self::Local)
389    }
390
391    /// Whether this trust level is allowed to write outside `.codewhale/fleet/`.
392    #[must_use]
393    pub fn may_write_workspace(&self) -> bool {
394        matches!(self, Self::Operator | Self::Local)
395    }
396
397    /// Whether this trust level is allowed network access.
398    #[must_use]
399    pub fn may_access_network(&self) -> bool {
400        matches!(self, Self::Operator | Self::RemoteVerified | Self::Local)
401    }
402}
403
404/// Security policy applied to a fleet run.
405///
406/// A policy defines the default trust level for workers, which secrets
407/// may be resolved, and what capabilities are granted. When a run has no
408/// explicit policy, workers inherit conservative defaults.
409#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
410pub struct FleetSecurityPolicy {
411    /// Default trust level for workers that don't declare one explicitly.
412    #[serde(default)]
413    pub default_trust_level: FleetTrustLevel,
414    /// Secret refs that workers may resolve. An empty list means no secrets
415    /// are available. Each entry is a key name, not a value.
416    #[serde(default)]
417    #[serde(skip_serializing_if = "Vec::is_empty")]
418    pub allowed_secrets: Vec<FleetSecretRef>,
419    /// Capability grants for workers in this run.
420    #[serde(default)]
421    #[serde(skip_serializing_if = "Vec::is_empty")]
422    pub capability_grants: Vec<FleetCapabilityGrant>,
423    /// Maximum trust level any worker in this run may have, even if the
424    /// worker spec requests higher. Defaults to Operator (no ceiling).
425    #[serde(default = "default_max_trust_level")]
426    pub max_trust_level: FleetTrustLevel,
427    /// Require identity verification for remote workers. When true, SSH
428    /// workers must pass host-key verification before being trusted at
429    /// RemoteVerified level; unverified remotes stay at Sandbox.
430    #[serde(default)]
431    pub require_identity_verification: bool,
432    /// Allow conservative parallel execution of read-only tools (#2983).
433    /// When true, workers may batch independent read-only tool calls
434    /// (reads, searches, greps) into concurrent turns. Disabled by default
435    /// to avoid overwhelming providers or hitting rate limits.
436    #[serde(default)]
437    pub allow_parallel_reads: bool,
438}
439
440fn default_max_trust_level() -> FleetTrustLevel {
441    FleetTrustLevel::Operator
442}
443
444impl Default for FleetSecurityPolicy {
445    fn default() -> Self {
446        Self {
447            default_trust_level: FleetTrustLevel::Sandbox,
448            allowed_secrets: Vec::new(),
449            capability_grants: Vec::new(),
450            max_trust_level: FleetTrustLevel::Operator,
451            require_identity_verification: false,
452            allow_parallel_reads: false,
453        }
454    }
455}
456
457/// A reference to a secret that should be resolved at runtime, never
458/// serialized as a plaintext value.
459///
460/// Secret refs appear in task specs, alert configs, and worker definitions.
461/// The actual secret value is resolved by the fleet manager from the
462/// secrets backend (OS keyring, environment, or file store) just before
463/// the worker starts.
464#[derive(Debug, Clone, Serialize, PartialEq, Eq, Hash, PartialOrd, Ord)]
465pub struct FleetSecretRef {
466    /// The secret key name (e.g. `"CODEWHALE_API_KEY"`, `"GH_TOKEN"`).
467    pub key: String,
468    /// Optional source hint for resolution order.
469    /// - `"env"` — resolve from environment variable
470    /// - `"keyring"` — resolve from OS keyring
471    /// - `"file"` — resolve from `~/.codewhale/secrets/`
472    /// - absent / null — try all sources in default order
473    #[serde(skip_serializing_if = "Option::is_none")]
474    pub source: Option<String>,
475}
476
477impl FleetSecretRef {
478    /// Create a secret ref from a key name with default resolution.
479    #[must_use]
480    pub fn new(key: impl Into<String>) -> Self {
481        Self {
482            key: key.into(),
483            source: None,
484        }
485    }
486
487    /// Create a secret ref with an explicit source.
488    #[must_use]
489    pub fn with_source(key: impl Into<String>, source: impl Into<String>) -> Self {
490        Self {
491            key: key.into(),
492            source: Some(source.into()),
493        }
494    }
495
496    /// Redacted display form for logging. Shows the key name and source
497    /// but never the resolved value.
498    #[must_use]
499    pub fn redacted(&self) -> String {
500        match &self.source {
501            Some(src) => format!("<secret:{}.{}>", src, self.key),
502            None => format!("<secret:{}>", self.key),
503        }
504    }
505}
506
507impl std::fmt::Display for FleetSecretRef {
508    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
509        write!(f, "{}", self.redacted())
510    }
511}
512
513impl From<&str> for FleetSecretRef {
514    fn from(key: &str) -> Self {
515        Self::new(key)
516    }
517}
518
519impl From<String> for FleetSecretRef {
520    fn from(key: String) -> Self {
521        Self::new(key)
522    }
523}
524
525impl<'de> Deserialize<'de> for FleetSecretRef {
526    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
527    where
528        D: Deserializer<'de>,
529    {
530        #[derive(Deserialize)]
531        #[serde(untagged)]
532        enum SecretRefWire {
533            Key(String),
534            Structured {
535                key: String,
536                #[serde(default)]
537                source: Option<String>,
538            },
539        }
540
541        match SecretRefWire::deserialize(deserializer)? {
542            SecretRefWire::Key(key) if !key.trim().is_empty() => Ok(FleetSecretRef::new(key)),
543            SecretRefWire::Key(_) => Err(de::Error::custom("secret ref key cannot be empty")),
544            SecretRefWire::Structured { key, source } if !key.trim().is_empty() => {
545                Ok(FleetSecretRef { key, source })
546            }
547            SecretRefWire::Structured { .. } => {
548                Err(de::Error::custom("secret ref key cannot be empty"))
549            }
550        }
551    }
552}
553
554/// How a worker authenticates to the fleet manager.
555#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
556#[serde(tag = "method", rename_all = "snake_case")]
557pub enum FleetWorkerAuth {
558    /// No authentication (local workers share the same uid).
559    None,
560    /// SSH key-based authentication with host-key verification.
561    SshKey {
562        /// Path to the SSH identity file (may be a FleetSecretRef in JSON
563        /// as `{"key": "...", "source": "file"}`).
564        identity: PathBuf,
565        /// Known hosts file for host-key verification.
566        #[serde(skip_serializing_if = "Option::is_none")]
567        known_hosts: Option<PathBuf>,
568        /// Expected host key fingerprint for pinning.
569        #[serde(skip_serializing_if = "Option::is_none")]
570        host_key_fingerprint: Option<String>,
571        /// SSH user for the connection.
572        #[serde(skip_serializing_if = "Option::is_none")]
573        user: Option<String>,
574    },
575    /// Token-based authentication for remote workers behind a fleet proxy.
576    Token {
577        /// Reference to the token secret.
578        token_ref: FleetSecretRef,
579    },
580    /// mTLS certificate-based authentication.
581    Mtls {
582        /// Path to the client certificate.
583        cert_path: PathBuf,
584        /// Reference to the private key secret.
585        key_ref: FleetSecretRef,
586    },
587}
588
589/// A capability grant that explicitly authorizes a worker to perform
590/// a specific class of action.
591///
592/// By default, new workers get no grants (least privilege). Grants are
593/// additive: a worker's effective capabilities are the union of its
594/// trust-level defaults plus any explicit grants.
595#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
596pub struct FleetCapabilityGrant {
597    /// The capability being granted (e.g. `"network"`, `"git-push"`,
598    /// `"provider-secrets"`, `"release"`).
599    pub capability: String,
600    /// Optional scope limiting the grant (e.g. `"github.com"` for network,
601    /// `"crates/tui/**"` for file writes).
602    #[serde(skip_serializing_if = "Option::is_none")]
603    pub scope: Option<String>,
604    /// Optional justification for the grant (audit trail).
605    #[serde(skip_serializing_if = "Option::is_none")]
606    pub reason: Option<String>,
607}
608
609/// Runtime status of a worker.
610#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
611#[serde(rename_all = "snake_case")]
612pub enum FleetWorkerStatus {
613    Unknown,
614    Online,
615    Busy,
616    Offline,
617    Unhealthy,
618    Draining,
619    Retired,
620}
621
622impl Status for FleetWorkerStatus {
623    fn is_terminal(&self) -> bool {
624        matches!(self, Self::Retired)
625    }
626    fn is_active(&self) -> bool {
627        matches!(self, Self::Online | Self::Busy)
628    }
629    fn is_paused(&self) -> bool {
630        false
631    }
632}
633
634/// Durable inbox entry: a task waiting to be leased to a worker.
635#[derive(Debug, Clone, Serialize, Deserialize)]
636pub struct FleetInboxEntry {
637    pub run_id: FleetRunId,
638    pub task_id: String,
639    pub priority: i32,
640    pub enqueued_at: String,
641    #[serde(default)]
642    pub lease_deadline: Option<String>,
643    #[serde(default)]
644    pub attempts: u32,
645}
646
647/// Worker event envelope.
648#[derive(Debug, Clone, Serialize, Deserialize)]
649pub struct FleetWorkerEvent {
650    pub seq: u64,
651    pub run_id: FleetRunId,
652    pub worker_id: String,
653    pub task_id: String,
654    pub timestamp: String,
655    #[serde(flatten)]
656    pub payload: FleetWorkerEventPayload,
657    #[serde(default)]
658    #[serde(skip_serializing_if = "BTreeMap::is_empty")]
659    pub extra: BTreeMap<String, Value>,
660}
661
662/// Union of all worker event payloads.
663#[derive(Debug, Clone, Serialize, Deserialize)]
664#[serde(tag = "state", rename_all = "snake_case")]
665pub enum FleetWorkerEventPayload {
666    Queued,
667    Leased {
668        #[serde(skip_serializing_if = "Option::is_none")]
669        lease_expires_at: Option<String>,
670    },
671    Starting,
672    Running,
673    ModelWait {
674        #[serde(skip_serializing_if = "Option::is_none")]
675        model: Option<String>,
676    },
677    RunningTool {
678        tool: String,
679        #[serde(skip_serializing_if = "Option::is_none")]
680        call_id: Option<String>,
681    },
682    /// Typed receipt emitted by a Workflow running inside this worker.
683    WorkflowEvent {
684        /// Inner Workflow run id. Named distinctly from the outer Fleet run id
685        /// because payloads are flattened into `FleetWorkerEvent`.
686        workflow_run_id: String,
687        event: Value,
688    },
689    Heartbeat {
690        #[serde(default)]
691        #[serde(skip_serializing_if = "Option::is_none")]
692        cpu_percent: Option<f32>,
693        #[serde(default)]
694        #[serde(skip_serializing_if = "Option::is_none")]
695        memory_mb: Option<u64>,
696    },
697    Artifact(FleetArtifactRef),
698    Completed {
699        #[serde(default)]
700        #[serde(skip_serializing_if = "Option::is_none")]
701        exit_code: Option<i32>,
702        #[serde(skip_serializing_if = "Option::is_none")]
703        summary: Option<String>,
704    },
705    Failed {
706        reason: String,
707        #[serde(default)]
708        recoverable: bool,
709    },
710    Cancelled {
711        #[serde(skip_serializing_if = "Option::is_none")]
712        cancelled_by: Option<String>,
713    },
714    Interrupted {
715        #[serde(skip_serializing_if = "Option::is_none")]
716        signal: Option<String>,
717    },
718    Stale {
719        #[serde(skip_serializing_if = "Option::is_none")]
720        last_heartbeat_at: Option<String>,
721    },
722    Restarted {
723        #[serde(default)]
724        restart_count: u32,
725    },
726    Escalated {
727        channel: String,
728        #[serde(skip_serializing_if = "Option::is_none")]
729        alert_id: Option<String>,
730    },
731}
732
733/// Retry policy for a task or worker.
734#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
735pub struct FleetRetryPolicy {
736    #[serde(default = "default_retry_max_attempts")]
737    pub max_attempts: u32,
738    #[serde(default = "default_retry_initial_backoff_seconds")]
739    pub initial_backoff_seconds: u64,
740    #[serde(default = "default_retry_max_backoff_seconds")]
741    pub max_backoff_seconds: u64,
742    #[serde(default = "default_retry_backoff_multiplier")]
743    pub backoff_multiplier: u32,
744}
745
746impl Default for FleetRetryPolicy {
747    fn default() -> Self {
748        Self {
749            max_attempts: 3,
750            initial_backoff_seconds: 5,
751            max_backoff_seconds: 300,
752            backoff_multiplier: 2,
753        }
754    }
755}
756
757fn default_retry_max_attempts() -> u32 {
758    FleetRetryPolicy::default().max_attempts
759}
760
761fn default_retry_initial_backoff_seconds() -> u64 {
762    FleetRetryPolicy::default().initial_backoff_seconds
763}
764
765fn default_retry_max_backoff_seconds() -> u64 {
766    FleetRetryPolicy::default().max_backoff_seconds
767}
768
769fn default_retry_backoff_multiplier() -> u32 {
770    FleetRetryPolicy::default().backoff_multiplier
771}
772
773/// Alert/escalation policy attached to a task or run.
774#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
775pub struct FleetAlertPolicy {
776    #[serde(default)]
777    #[serde(skip_serializing_if = "Vec::is_empty")]
778    pub events: Vec<FleetAlertEventClass>,
779    #[serde(default)]
780    pub channels: Vec<FleetAlertChannel>,
781    #[serde(default)]
782    pub after_attempts: Option<u32>,
783    #[serde(default)]
784    pub after_minutes_stale: Option<u64>,
785}
786
787#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord, Hash)]
788#[serde(rename_all = "snake_case")]
789pub enum FleetAlertEventClass {
790    Stale,
791    RestartExhausted,
792    NeedsHuman,
793    BudgetExceeded,
794    VerifierFailed,
795    RunCompleted,
796}
797
798#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
799#[serde(tag = "kind", rename_all = "snake_case")]
800pub enum FleetAlertChannel {
801    Slack {
802        /// Webhook URL, resolved from a secret ref or inline.
803        #[serde(flatten)]
804        webhook: FleetAlertEndpoint,
805    },
806    Webhook {
807        #[serde(flatten)]
808        endpoint: FleetAlertEndpoint,
809    },
810    #[serde(alias = "pager_duty")]
811    #[serde(alias = "pagerduty")]
812    PagerDuty {
813        routing_key: String,
814        severity: String,
815    },
816}
817
818/// An alert channel endpoint, supporting both inline URLs and secret refs.
819///
820/// For Slack and generic webhook channels, the URL may be provided directly
821/// or as a secret reference resolved at send time. When both `url` and
822/// `url_ref` are present, `url_ref` takes precedence after resolution.
823#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
824pub struct FleetAlertEndpoint {
825    /// Inline URL (plaintext; only for non-sensitive endpoints).
826    #[serde(
827        alias = "webhook_url",
828        alias = "endpoint_url",
829        skip_serializing_if = "Option::is_none"
830    )]
831    pub url: Option<String>,
832    /// Reference to a secret containing the webhook URL.
833    #[serde(
834        alias = "webhook_url_ref",
835        alias = "webhook_ref",
836        alias = "url_secret_ref",
837        skip_serializing_if = "Option::is_none"
838    )]
839    pub url_ref: Option<FleetSecretRef>,
840    /// Optional HMAC secret for webhook payload signing, as a secret ref.
841    #[serde(
842        alias = "secret",
843        alias = "webhook_secret",
844        alias = "signing_secret",
845        skip_serializing_if = "Option::is_none"
846    )]
847    pub secret_ref: Option<FleetSecretRef>,
848}
849
850impl FleetAlertEndpoint {
851    /// Create an inline URL endpoint (for non-sensitive use).
852    #[must_use]
853    pub fn inline(url: impl Into<String>) -> Self {
854        Self {
855            url: Some(url.into()),
856            url_ref: None,
857            secret_ref: None,
858        }
859    }
860
861    /// Create a secret-backed URL endpoint.
862    #[must_use]
863    pub fn from_secret(url_ref: FleetSecretRef) -> Self {
864        Self {
865            url: None,
866            url_ref: Some(url_ref),
867            secret_ref: None,
868        }
869    }
870
871    /// Redacted display form for logging.
872    #[must_use]
873    pub fn redacted(&self) -> String {
874        self.url_ref
875            .as_ref()
876            .map_or_else(|| "<inline-url>".to_string(), |r| r.redacted())
877    }
878}
879
880/// Resolved-route detail persisted on a [`FleetReceipt`] (#3154).
881///
882/// This is an additive, *plain-strings* snapshot of the route a fleet worker
883/// resolved to. It deliberately does NOT depend on any `codewhale-config` route
884/// type so the protocol crate stays free of the route model.
885///
886/// CRITICAL no-secrets invariant: this struct carries ONLY non-sensitive route
887/// shape — provider id/kind, model ids, wire protocol, role/loadout/model-class
888/// intent, reasoning tier when known, and deterministic intent sources. It
889/// must NEVER hold a credential, API key, bearer token, or a base URL that
890/// embeds credentials. There is intentionally no field that could carry a
891/// secret.
892#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
893pub struct FleetResolvedRoute {
894    /// Resolved provider canonical id (e.g. `"deepseek"`).
895    pub provider_id: String,
896    /// Exact configured provider-table id when the worker used one.
897    ///
898    /// This is intentionally additive to `provider_id`: literal
899    /// `[providers.custom]` resolves to `Some("custom")`, while the legacy
900    /// idless root custom route resolves to `None`. Keeping the distinction
901    /// prevents a receipt from silently collapsing two different credential
902    /// and endpoint authorities into the same generic `custom` label.
903    #[serde(default, skip_serializing_if = "Option::is_none")]
904    pub provider_exact_id: Option<String>,
905    /// Resolved provider kind (e.g. `"deepseek"`).
906    pub provider_kind: String,
907    /// Canonical, provider-agnostic model identity, when known.
908    #[serde(default, skip_serializing_if = "Option::is_none")]
909    pub canonical_model: Option<String>,
910    /// Provider-owned wire model id placed on the request.
911    pub wire_model_id: String,
912    /// Selected wire protocol (e.g. `"chat_completions"`).
913    pub protocol: String,
914    /// Effective Fleet role intent, when one applied.
915    #[serde(default, skip_serializing_if = "Option::is_none")]
916    pub role: Option<String>,
917    /// Effective Fleet loadout intent, when one applied.
918    #[serde(default, skip_serializing_if = "Option::is_none")]
919    pub loadout: Option<String>,
920    /// Original task-level model-class intent, when authored separately from
921    /// `loadout`. Profile `model_class_hint` is normalized into `loadout`.
922    #[serde(default, skip_serializing_if = "Option::is_none")]
923    pub model_class: Option<String>,
924    /// Runtime model-route seam used by sub-agent routing (`inherit`, `faster`,
925    /// `auto`, or `fixed`).
926    #[serde(default, skip_serializing_if = "Option::is_none")]
927    pub model_route: Option<String>,
928    /// Concrete reasoning tier, when it is known by the route resolver path.
929    #[serde(default, skip_serializing_if = "Option::is_none")]
930    pub reasoning_effort: Option<String>,
931    /// Deterministic source for the effective role intent.
932    #[serde(default, skip_serializing_if = "Option::is_none")]
933    pub role_source: Option<String>,
934    /// Deterministic source for the effective loadout intent.
935    #[serde(default, skip_serializing_if = "Option::is_none")]
936    pub loadout_source: Option<String>,
937    /// Deterministic source for the model-class hint, when present.
938    #[serde(default, skip_serializing_if = "Option::is_none")]
939    pub model_class_source: Option<String>,
940    /// Deterministic source for the model selector used by the resolver.
941    #[serde(default, skip_serializing_if = "Option::is_none")]
942    pub model_source: Option<String>,
943    /// How the route was produced (e.g. `"resolver"`).
944    pub source: String,
945}
946
947/// Effective worker authority persisted on a [`FleetReceipt`] (#3211).
948///
949/// This is a non-secret snapshot of the already-computed runtime profile. It
950/// records what the worker was allowed to do; it does not grant permissions and
951/// does not carry credentials, sandbox paths, or provider endpoints.
952#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
953pub struct FleetEffectivePermissions {
954    /// Whether the worker profile may modify workspace files.
955    pub write: bool,
956    /// Whether the worker profile may use network-capable tools.
957    pub network: bool,
958    /// Shell posture (`none`, `read_only`, or `full`).
959    pub shell: String,
960    /// Tool-surface posture (`inherit` or `explicit`).
961    pub tool_scope: String,
962    /// Explicit tool names when `tool_scope` is `explicit`.
963    #[serde(default, skip_serializing_if = "Vec::is_empty")]
964    pub tools: Vec<String>,
965    /// Whether the worker is intended to run detached/background.
966    pub background: bool,
967    /// Remaining nested-delegation budget after parent intersection/hardening.
968    pub max_spawn_depth: u32,
969    /// Roster profile id that contributed to this worker, when any.
970    #[serde(default, skip_serializing_if = "Option::is_none")]
971    pub profile_id: Option<String>,
972    /// Roster layer for `profile_id` (`built_in`, `config`, or `workspace`).
973    #[serde(default, skip_serializing_if = "Option::is_none")]
974    pub profile_origin: Option<String>,
975    /// How this snapshot was produced (e.g. `"worker_runtime_profile"`).
976    pub source: String,
977}
978
979/// Receipt produced when a task completes verification.
980#[derive(Debug, Clone, Serialize, Deserialize)]
981pub struct FleetReceipt {
982    pub run_id: FleetRunId,
983    pub task_id: String,
984    pub worker_id: String,
985    /// Durable lease generation that produced this receipt.
986    ///
987    /// Optional for backward compatibility with receipts written before Fleet
988    /// attempts were fenced explicitly.
989    #[serde(default, skip_serializing_if = "Option::is_none")]
990    pub attempt: Option<u32>,
991    /// Sequence of the terminal worker event finalized with this receipt.
992    ///
993    /// Optional so older ledger records remain replayable.
994    #[serde(default, skip_serializing_if = "Option::is_none")]
995    pub terminal_seq: Option<u64>,
996    pub completed_at: String,
997    pub result: FleetTaskResult,
998    #[serde(skip_serializing_if = "Option::is_none")]
999    pub failure_kind: Option<FleetTaskFailureKind>,
1000    #[serde(default)]
1001    pub artifacts: Vec<FleetArtifactRef>,
1002    #[serde(default)]
1003    pub score: Option<FleetScore>,
1004    /// Resolved-route snapshot for this task (#3154).
1005    ///
1006    /// `#[serde(default)]` keeps older ledgers (written before this field
1007    /// existed) deserializable.
1008    #[serde(default, skip_serializing_if = "Option::is_none")]
1009    pub resolved_route: Option<FleetResolvedRoute>,
1010    /// Effective worker authority for this task (#3211).
1011    #[serde(default, skip_serializing_if = "Option::is_none")]
1012    pub effective_permissions: Option<FleetEffectivePermissions>,
1013}
1014
1015#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1016#[serde(rename_all = "snake_case")]
1017pub enum FleetTaskResult {
1018    Pass,
1019    Partial,
1020    Fail,
1021    Skip,
1022    Timeout,
1023}
1024
1025/// Source category for a failed task receipt.
1026#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1027#[serde(rename_all = "snake_case")]
1028pub enum FleetTaskFailureKind {
1029    Transport,
1030    Task,
1031    Verifier,
1032}
1033
1034#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1035pub struct FleetScore {
1036    pub value: f64,
1037    #[serde(skip_serializing_if = "Option::is_none")]
1038    pub max: Option<f64>,
1039    #[serde(skip_serializing_if = "Option::is_none")]
1040    pub notes: Option<String>,
1041}
1042
1043#[cfg(test)]
1044mod tests {
1045    use super::*;
1046
1047    #[test]
1048    fn fleet_run_round_trip() {
1049        let run = FleetRun {
1050            id: FleetRunId::from("run-001"),
1051            name: "dogfood smoke".to_string(),
1052            status: FleetRunStatus::Running,
1053            max_workers: Some(1),
1054            task_specs: vec![FleetTaskSpec {
1055                id: "task-1".to_string(),
1056                name: "lint".to_string(),
1057                description: None,
1058                objective: Some("Keep the workspace lint-clean".to_string()),
1059                instructions: "run cargo clippy".to_string(),
1060                worker: Some(FleetTaskWorkerProfile {
1061                    agent_profile: None,
1062                    role: Some("release-checker".to_string()),
1063                    loadout: None,
1064                    model_class: None,
1065                    model: None,
1066                    tool_profile: Some("read-only".to_string()),
1067                    tools: vec!["cargo".to_string()],
1068                    capabilities: vec!["rust".to_string()],
1069                }),
1070                workspace: Some(FleetWorkspaceRequirements {
1071                    root: Some(PathBuf::from(".")),
1072                    required_files: vec![PathBuf::from("Cargo.toml")],
1073                    writable_paths: vec![],
1074                    environment: Some(FleetEnvironmentRequirements {
1075                        required: vec!["PATH".to_string()],
1076                        allowlist: vec!["RUST_LOG".to_string()],
1077                    }),
1078                }),
1079                input_files: vec![PathBuf::from("crates/tui/src/main.rs")],
1080                context: vec!["release gate".to_string()],
1081                budget: Some(FleetTaskBudget {
1082                    max_tokens: Some(8000),
1083                    max_tool_calls: Some(20),
1084                    max_seconds: Some(300),
1085                }),
1086                tags: vec!["release".to_string()],
1087                expected_artifacts: vec![FleetArtifactKind::Log],
1088                scorer: Some(FleetScorerSpec::ExitCode),
1089                retry_policy: Some(FleetRetryPolicy::default()),
1090                alert_policy: None,
1091                timeout_seconds: Some(300),
1092                metadata: BTreeMap::new(),
1093            }],
1094            worker_specs: vec![],
1095            labels: BTreeMap::new(),
1096            security_policy: None,
1097            created_at: "2026-06-12T17:00:00Z".to_string(),
1098            updated_at: None,
1099            completed_at: None,
1100        };
1101        let json = serde_json::to_string(&run).unwrap();
1102        let back: FleetRun = serde_json::from_str(&json).unwrap();
1103        assert_eq!(back.id, run.id);
1104        assert_eq!(back.status, FleetRunStatus::Running);
1105        assert_eq!(back.task_specs.len(), 1);
1106        assert_eq!(
1107            back.task_specs[0].worker.as_ref().unwrap().role.as_deref(),
1108            Some("release-checker")
1109        );
1110        assert_eq!(
1111            back.task_specs[0]
1112                .workspace
1113                .as_ref()
1114                .unwrap()
1115                .required_files,
1116            vec![PathBuf::from("Cargo.toml")]
1117        );
1118    }
1119
1120    #[test]
1121    fn worker_profile_carries_agent_profile_and_loadout_intent() {
1122        let json = r#"{
1123            "profile": "adversarial_reviewer",
1124            "role": "reviewer",
1125            "loadout": "auto",
1126            "model_class": "balanced",
1127            "model": "deepseek-v4-pro",
1128            "tool_profile": "read-only",
1129            "tools": ["read_file"],
1130            "capabilities": ["rust"]
1131        }"#;
1132
1133        let profile: FleetTaskWorkerProfile = serde_json::from_str(json).unwrap();
1134
1135        assert_eq!(
1136            profile.agent_profile.as_deref(),
1137            Some("adversarial_reviewer")
1138        );
1139        assert_eq!(profile.role.as_deref(), Some("reviewer"));
1140        assert_eq!(profile.loadout.as_deref(), Some("auto"));
1141        assert_eq!(profile.model_class.as_deref(), Some("balanced"));
1142        assert_eq!(profile.model.as_deref(), Some("deepseek-v4-pro"));
1143        assert_eq!(profile.tool_profile.as_deref(), Some("read-only"));
1144
1145        let serialized = serde_json::to_value(&profile).unwrap();
1146        assert_eq!(serialized["agent_profile"], "adversarial_reviewer");
1147        assert_eq!(serialized["model"], "deepseek-v4-pro");
1148        assert!(serialized.get("profile").is_none());
1149    }
1150
1151    #[test]
1152    fn worker_event_lifecycle_round_trip() {
1153        let events = vec![
1154            FleetWorkerEvent {
1155                seq: 1,
1156                run_id: FleetRunId::from("run-002"),
1157                worker_id: "worker-a".to_string(),
1158                task_id: "task-1".to_string(),
1159                timestamp: "2026-06-12T17:01:00Z".to_string(),
1160                payload: FleetWorkerEventPayload::Queued,
1161                extra: BTreeMap::new(),
1162            },
1163            FleetWorkerEvent {
1164                seq: 2,
1165                run_id: FleetRunId::from("run-002"),
1166                worker_id: "worker-a".to_string(),
1167                task_id: "task-1".to_string(),
1168                timestamp: "2026-06-12T17:01:05Z".to_string(),
1169                payload: FleetWorkerEventPayload::RunningTool {
1170                    tool: "bash".to_string(),
1171                    call_id: Some("call-1".to_string()),
1172                },
1173                extra: BTreeMap::new(),
1174            },
1175            FleetWorkerEvent {
1176                seq: 3,
1177                run_id: FleetRunId::from("run-002"),
1178                worker_id: "worker-a".to_string(),
1179                task_id: "task-1".to_string(),
1180                timestamp: "2026-06-12T17:02:00Z".to_string(),
1181                payload: FleetWorkerEventPayload::Completed {
1182                    exit_code: Some(0),
1183                    summary: Some("ok".to_string()),
1184                },
1185                extra: BTreeMap::new(),
1186            },
1187        ];
1188        let json = serde_json::to_string(&events).unwrap();
1189        let back: Vec<FleetWorkerEvent> = serde_json::from_str(&json).unwrap();
1190        assert_eq!(back.len(), 3);
1191        assert!(matches!(back[0].payload, FleetWorkerEventPayload::Queued));
1192        assert!(matches!(
1193            back[2].payload,
1194            FleetWorkerEventPayload::Completed { .. }
1195        ));
1196    }
1197
1198    #[test]
1199    fn workflow_receipt_round_trip_keeps_outer_and_inner_run_ids_distinct() {
1200        let event = FleetWorkerEvent {
1201            seq: 3,
1202            run_id: FleetRunId::from("fleet-run-1"),
1203            worker_id: "worker-a".to_string(),
1204            task_id: "task-1".to_string(),
1205            timestamp: "2026-07-10T00:00:00Z".to_string(),
1206            payload: FleetWorkerEventPayload::WorkflowEvent {
1207                workflow_run_id: "workflow_1".to_string(),
1208                event: serde_json::json!({"type": "task_completed"}),
1209            },
1210            extra: BTreeMap::new(),
1211        };
1212        let value = serde_json::to_value(&event).unwrap();
1213        assert_eq!(value["run_id"], "fleet-run-1");
1214        assert_eq!(value["workflow_run_id"], "workflow_1");
1215        let back: FleetWorkerEvent = serde_json::from_value(value).unwrap();
1216        assert!(matches!(
1217            back.payload,
1218            FleetWorkerEventPayload::WorkflowEvent {
1219                workflow_run_id,
1220                ref event,
1221            } if workflow_run_id == "workflow_1" && event["type"] == "task_completed"
1222        ));
1223    }
1224
1225    #[test]
1226    fn alert_policy_round_trip() {
1227        let policy = FleetAlertPolicy {
1228            events: vec![FleetAlertEventClass::Stale],
1229            channels: vec![FleetAlertChannel::Slack {
1230                webhook: FleetAlertEndpoint::inline("https://hooks.slack.com/test"),
1231            }],
1232            after_attempts: Some(2),
1233            after_minutes_stale: Some(10),
1234        };
1235        let json = serde_json::to_string(&policy).unwrap();
1236        assert!(json.contains("\"events\":[\"stale\"]"));
1237        assert!(json.contains("\"kind\":\"slack\""));
1238        let back: FleetAlertPolicy = serde_json::from_str(&json).unwrap();
1239        assert_eq!(back.events, vec![FleetAlertEventClass::Stale]);
1240        assert_eq!(back.after_attempts, Some(2));
1241    }
1242
1243    #[test]
1244    fn artifact_other_kind_round_trip() {
1245        let artifact = FleetArtifactRef {
1246            kind: FleetArtifactKind::Other("coverage.xml".to_string()),
1247            path: PathBuf::from("/tmp/coverage.xml"),
1248            checksum: Some("sha256:abc".to_string()),
1249            mime_type: Some("application/xml".to_string()),
1250            size_bytes: Some(1024),
1251        };
1252        let json = serde_json::to_string(&artifact).unwrap();
1253        let back: FleetArtifactRef = serde_json::from_str(&json).unwrap();
1254        assert_eq!(back.kind, artifact.kind);
1255        assert_eq!(back.size_bytes, Some(1024));
1256    }
1257
1258    #[test]
1259    fn ssh_host_spec_accepts_minimal_legacy_json() {
1260        let json = r#"{"kind":"ssh","host":"builder.example.test"}"#;
1261        let host: FleetHostSpec = serde_json::from_str(json).unwrap();
1262
1263        match host {
1264            FleetHostSpec::Ssh {
1265                host,
1266                port,
1267                user,
1268                identity,
1269                known_hosts,
1270                host_key_fingerprint,
1271                working_directory,
1272                env_allowlist,
1273                codewhale_binary,
1274            } => {
1275                assert_eq!(host, "builder.example.test");
1276                assert_eq!(port, None);
1277                assert_eq!(user, None);
1278                assert_eq!(identity, None);
1279                assert_eq!(known_hosts, None);
1280                assert_eq!(host_key_fingerprint, None);
1281                assert_eq!(working_directory, None);
1282                assert!(env_allowlist.is_empty());
1283                assert_eq!(codewhale_binary, None);
1284            }
1285            other => panic!("expected ssh host spec, got {other:?}"),
1286        }
1287    }
1288
1289    #[test]
1290    fn artifact_kind_uses_flat_string_json() {
1291        let known = serde_json::to_string(&FleetArtifactKind::TestResult).unwrap();
1292        assert_eq!(known, "\"test_result\"");
1293
1294        let custom =
1295            serde_json::to_string(&FleetArtifactKind::Other("coverage.xml".to_string())).unwrap();
1296        assert_eq!(custom, "\"coverage.xml\"");
1297
1298        let parsed: FleetArtifactKind = serde_json::from_str("\"coverage.xml\"").unwrap();
1299        assert_eq!(parsed, FleetArtifactKind::Other("coverage.xml".to_string()));
1300    }
1301
1302    #[test]
1303    fn retry_policy_missing_fields_use_nonzero_defaults() {
1304        let policy: FleetRetryPolicy = serde_json::from_value(serde_json::json!({})).unwrap();
1305        assert_eq!(policy, FleetRetryPolicy::default());
1306
1307        let policy: FleetRetryPolicy =
1308            serde_json::from_value(serde_json::json!({"max_attempts": 5})).unwrap();
1309        assert_eq!(policy.max_attempts, 5);
1310        assert_eq!(
1311            policy.initial_backoff_seconds,
1312            FleetRetryPolicy::default().initial_backoff_seconds
1313        );
1314        assert_eq!(
1315            policy.max_backoff_seconds,
1316            FleetRetryPolicy::default().max_backoff_seconds
1317        );
1318        assert_eq!(
1319            policy.backoff_multiplier,
1320            FleetRetryPolicy::default().backoff_multiplier
1321        );
1322    }
1323
1324    #[test]
1325    fn sparse_worker_events_omit_absent_optional_fields() {
1326        let heartbeat = FleetWorkerEventPayload::Heartbeat {
1327            cpu_percent: None,
1328            memory_mb: None,
1329        };
1330        let heartbeat_json = serde_json::to_value(&heartbeat).unwrap();
1331        assert_eq!(heartbeat_json, serde_json::json!({"state": "heartbeat"}));
1332
1333        let completed = FleetWorkerEventPayload::Completed {
1334            exit_code: None,
1335            summary: None,
1336        };
1337        let completed_json = serde_json::to_value(&completed).unwrap();
1338        assert_eq!(completed_json, serde_json::json!({"state": "completed"}));
1339    }
1340
1341    #[test]
1342    fn receipt_round_trip() {
1343        let receipt = FleetReceipt {
1344            run_id: FleetRunId::from("run-003"),
1345            task_id: "task-1".to_string(),
1346            worker_id: "worker-b".to_string(),
1347            attempt: Some(2),
1348            terminal_seq: Some(7),
1349            completed_at: "2026-06-12T17:03:00Z".to_string(),
1350            result: FleetTaskResult::Pass,
1351            failure_kind: None,
1352            artifacts: vec![],
1353            score: Some(FleetScore {
1354                value: 0.95,
1355                max: Some(1.0),
1356                notes: None,
1357            }),
1358            resolved_route: None,
1359            effective_permissions: None,
1360        };
1361        let json = serde_json::to_string(&receipt).unwrap();
1362        let back: FleetReceipt = serde_json::from_str(&json).unwrap();
1363        assert_eq!(back.result, FleetTaskResult::Pass);
1364        assert_eq!(back.score.as_ref().unwrap().value, 0.95);
1365        assert_eq!(back.attempt, Some(2));
1366        assert_eq!(back.terminal_seq, Some(7));
1367    }
1368
1369    #[test]
1370    fn partial_receipt_records_failure_source_when_needed() {
1371        let receipt = FleetReceipt {
1372            run_id: FleetRunId::from("run-004"),
1373            task_id: "task-2".to_string(),
1374            worker_id: "worker-c".to_string(),
1375            attempt: None,
1376            terminal_seq: None,
1377            completed_at: "2026-06-12T17:04:00Z".to_string(),
1378            result: FleetTaskResult::Partial,
1379            failure_kind: Some(FleetTaskFailureKind::Verifier),
1380            artifacts: vec![],
1381            score: Some(FleetScore {
1382                value: 0.5,
1383                max: Some(1.0),
1384                notes: Some("manual verification required".to_string()),
1385            }),
1386            resolved_route: None,
1387            effective_permissions: None,
1388        };
1389
1390        let json = serde_json::to_string(&receipt).unwrap();
1391        assert!(json.contains("\"result\":\"partial\""));
1392        assert!(json.contains("\"failure_kind\":\"verifier\""));
1393        let back: FleetReceipt = serde_json::from_str(&json).unwrap();
1394        assert_eq!(back.result, FleetTaskResult::Partial);
1395        assert_eq!(back.failure_kind, Some(FleetTaskFailureKind::Verifier));
1396    }
1397
1398    #[test]
1399    fn ssh_host_spec_with_key_pinning_round_trip() {
1400        let spec = FleetHostSpec::Ssh {
1401            host: "builder.trusted.example.com".to_string(),
1402            port: Some(22),
1403            user: Some("codewhale".to_string()),
1404            identity: Some(PathBuf::from("~/.ssh/codewhale_fleet")),
1405            known_hosts: Some(PathBuf::from("~/.ssh/known_hosts")),
1406            host_key_fingerprint: Some("SHA256:aLGqZo1M6c...".to_string()),
1407            working_directory: Some(PathBuf::from("/srv/codewhale/work")),
1408            env_allowlist: vec!["CODEWHALE_PROFILE".to_string()],
1409            codewhale_binary: Some("/usr/local/bin/codewhale".to_string()),
1410        };
1411        let json = serde_json::to_string_pretty(&spec).unwrap();
1412        assert!(json.contains("\"known_hosts\""));
1413        assert!(json.contains("\"host_key_fingerprint\""));
1414        assert!(json.contains("SHA256:aLGqZo1M6c..."));
1415
1416        let back: FleetHostSpec = serde_json::from_str(&json).unwrap();
1417        match back {
1418            FleetHostSpec::Ssh {
1419                host,
1420                known_hosts,
1421                host_key_fingerprint,
1422                ..
1423            } => {
1424                assert_eq!(host, "builder.trusted.example.com");
1425                assert_eq!(known_hosts, Some(PathBuf::from("~/.ssh/known_hosts")));
1426                assert_eq!(
1427                    host_key_fingerprint,
1428                    Some("SHA256:aLGqZo1M6c...".to_string())
1429                );
1430            }
1431            other => panic!("expected ssh host spec, got {other:?}"),
1432        }
1433    }
1434
1435    #[test]
1436    fn secret_ref_redacted_never_exposes_value() {
1437        let ref_ = FleetSecretRef::new("DEEPSEEK_API_KEY");
1438        let redacted = ref_.redacted();
1439        assert!(redacted.contains("DEEPSEEK_API_KEY"));
1440        assert!(!redacted.contains("sk-"));
1441        assert!(redacted.contains("<secret:"));
1442
1443        let ref_ = FleetSecretRef::with_source("GH_TOKEN", "env");
1444        let redacted = ref_.redacted();
1445        assert!(redacted.contains("env.GH_TOKEN"));
1446        assert!(!redacted.contains("ghp_"));
1447    }
1448
1449    #[test]
1450    fn alert_endpoint_from_secret_round_trip() {
1451        let endpoint = FleetAlertEndpoint::from_secret(FleetSecretRef::new("SLACK_WEBHOOK"));
1452        let json = serde_json::to_string(&endpoint).unwrap();
1453        assert!(json.contains("SLACK_WEBHOOK"));
1454        assert!(!json.contains("hooks.slack.com"));
1455
1456        let back: FleetAlertEndpoint = serde_json::from_str(&json).unwrap();
1457        assert_eq!(back.url_ref.as_ref().unwrap().key, "SLACK_WEBHOOK");
1458        assert_eq!(back.url, None);
1459    }
1460
1461    #[test]
1462    fn secret_ref_accepts_legacy_string_wire_shape() {
1463        let ref_: FleetSecretRef = serde_json::from_str(r#""CODEWHALE_FLEET_TOKEN""#).unwrap();
1464        assert_eq!(ref_, FleetSecretRef::new("CODEWHALE_FLEET_TOKEN"));
1465
1466        let ref_: FleetSecretRef =
1467            serde_json::from_str(r#"{"key":"GH_TOKEN","source":"env"}"#).unwrap();
1468        assert_eq!(ref_, FleetSecretRef::with_source("GH_TOKEN", "env"));
1469    }
1470
1471    #[test]
1472    fn trust_level_accepts_hyphenated_remote_verified() {
1473        let trust: FleetTrustLevel = serde_json::from_str(r#""remote-verified""#).unwrap();
1474        assert_eq!(trust, FleetTrustLevel::RemoteVerified);
1475
1476        let canonical = serde_json::to_string(&trust).unwrap();
1477        assert_eq!(canonical, r#""remote_verified""#);
1478    }
1479
1480    #[test]
1481    fn alert_channel_accepts_legacy_webhook_fields() {
1482        let channel: FleetAlertChannel = serde_json::from_str(
1483            r#"{
1484                "kind": "slack",
1485                "webhook_url": "https://hooks.slack.com/test",
1486                "secret": "SLACK_SIGNING_SECRET"
1487            }"#,
1488        )
1489        .unwrap();
1490
1491        match channel {
1492            FleetAlertChannel::Slack { webhook } => {
1493                assert_eq!(webhook.url.as_deref(), Some("https://hooks.slack.com/test"));
1494                assert_eq!(
1495                    webhook.secret_ref,
1496                    Some(FleetSecretRef::new("SLACK_SIGNING_SECRET"))
1497                );
1498            }
1499            other => panic!("expected slack channel, got {other:?}"),
1500        }
1501    }
1502
1503    #[test]
1504    fn security_policy_defaults_are_conservative() {
1505        let policy = FleetSecurityPolicy::default();
1506        assert_eq!(policy.default_trust_level, FleetTrustLevel::Sandbox);
1507        assert!(policy.allowed_secrets.is_empty());
1508        assert!(policy.capability_grants.is_empty());
1509        assert_eq!(policy.max_trust_level, FleetTrustLevel::Operator);
1510        assert!(!policy.require_identity_verification);
1511    }
1512
1513    #[test]
1514    fn trust_level_ordinal_reflects_privilege() {
1515        assert!(FleetTrustLevel::Operator > FleetTrustLevel::RemoteVerified);
1516        assert!(FleetTrustLevel::RemoteVerified > FleetTrustLevel::Local);
1517        assert!(FleetTrustLevel::Local > FleetTrustLevel::Sandbox);
1518
1519        assert!(FleetTrustLevel::Operator.may_access_secrets());
1520        assert!(!FleetTrustLevel::Sandbox.may_access_secrets());
1521        assert!(!FleetTrustLevel::Sandbox.may_write_workspace());
1522        assert!(FleetTrustLevel::Operator.may_write_workspace());
1523    }
1524
1525    fn sample_receipt_with_route() -> FleetReceipt {
1526        FleetReceipt {
1527            run_id: FleetRunId::from("run-route"),
1528            task_id: "task-route".to_string(),
1529            worker_id: "worker-route".to_string(),
1530            attempt: Some(1),
1531            terminal_seq: Some(4),
1532            completed_at: "2026-06-23T00:00:00Z".to_string(),
1533            result: FleetTaskResult::Pass,
1534            failure_kind: None,
1535            artifacts: vec![],
1536            score: None,
1537            resolved_route: Some(FleetResolvedRoute {
1538                provider_id: "deepseek".to_string(),
1539                provider_exact_id: None,
1540                provider_kind: "deepseek".to_string(),
1541                canonical_model: Some("deepseek-v4-pro".to_string()),
1542                wire_model_id: "deepseek-v4-pro".to_string(),
1543                protocol: "chat_completions".to_string(),
1544                role: Some("builder".to_string()),
1545                loadout: Some("auto".to_string()),
1546                model_class: Some("balanced".to_string()),
1547                model_route: Some("auto".to_string()),
1548                reasoning_effort: Some("high".to_string()),
1549                role_source: Some("task.role".to_string()),
1550                loadout_source: Some("task.loadout".to_string()),
1551                model_class_source: Some("task.model_class".to_string()),
1552                model_source: Some("task.model".to_string()),
1553                source: "resolver".to_string(),
1554            }),
1555            effective_permissions: Some(FleetEffectivePermissions {
1556                write: true,
1557                network: true,
1558                shell: "full".to_string(),
1559                tool_scope: "explicit".to_string(),
1560                tools: vec!["read_file".to_string(), "apply_patch".to_string()],
1561                background: true,
1562                max_spawn_depth: 2,
1563                profile_id: Some("builder".to_string()),
1564                profile_origin: Some("built_in".to_string()),
1565                source: "worker_runtime_profile".to_string(),
1566            }),
1567        }
1568    }
1569
1570    #[test]
1571    fn fleet_resolved_route_round_trips() {
1572        let receipt = sample_receipt_with_route();
1573        let json = serde_json::to_string(&receipt).unwrap();
1574        let back: FleetReceipt = serde_json::from_str(&json).unwrap();
1575        assert_eq!(back.resolved_route, receipt.resolved_route);
1576        assert_eq!(back.effective_permissions, receipt.effective_permissions);
1577        let route = back.resolved_route.unwrap();
1578        assert_eq!(route.provider_id, "deepseek");
1579        assert_eq!(route.wire_model_id, "deepseek-v4-pro");
1580        assert_eq!(route.protocol, "chat_completions");
1581        assert_eq!(route.role.as_deref(), Some("builder"));
1582        assert_eq!(route.loadout.as_deref(), Some("auto"));
1583        assert_eq!(route.model_class.as_deref(), Some("balanced"));
1584        assert_eq!(route.model_route.as_deref(), Some("auto"));
1585        assert_eq!(route.reasoning_effort.as_deref(), Some("high"));
1586        assert_eq!(route.role_source.as_deref(), Some("task.role"));
1587        assert_eq!(route.loadout_source.as_deref(), Some("task.loadout"));
1588        assert_eq!(
1589            route.model_class_source.as_deref(),
1590            Some("task.model_class")
1591        );
1592        assert_eq!(route.model_source.as_deref(), Some("task.model"));
1593        assert_eq!(route.source, "resolver");
1594
1595        let permissions = back
1596            .effective_permissions
1597            .expect("effective permissions should round-trip");
1598        assert!(permissions.write);
1599        assert!(permissions.network);
1600        assert_eq!(permissions.shell, "full");
1601        assert_eq!(permissions.tool_scope, "explicit");
1602        assert_eq!(
1603            permissions.tools,
1604            vec!["read_file".to_string(), "apply_patch".to_string()]
1605        );
1606        assert!(permissions.background);
1607        assert_eq!(permissions.max_spawn_depth, 2);
1608        assert_eq!(permissions.profile_id.as_deref(), Some("builder"));
1609        assert_eq!(permissions.profile_origin.as_deref(), Some("built_in"));
1610        assert_eq!(permissions.source, "worker_runtime_profile");
1611    }
1612
1613    #[test]
1614    fn fleet_receipt_without_resolved_route_still_deserializes() {
1615        // An old ledger receipt JSON written before #3154 has no
1616        // `resolved_route` key; `#[serde(default)]` must keep it readable.
1617        let legacy = r#"{
1618            "run_id": "run-legacy",
1619            "task_id": "task-legacy",
1620            "worker_id": "worker-legacy",
1621            "completed_at": "2026-06-01T00:00:00Z",
1622            "result": "pass",
1623            "artifacts": [],
1624            "score": null
1625        }"#;
1626        let receipt: FleetReceipt = serde_json::from_str(legacy).unwrap();
1627        assert_eq!(receipt.task_id, "task-legacy");
1628        assert!(receipt.resolved_route.is_none());
1629        assert!(receipt.attempt.is_none());
1630        assert!(receipt.terminal_seq.is_none());
1631    }
1632
1633    #[test]
1634    fn fleet_resolved_route_legacy_shape_still_deserializes() {
1635        let legacy = r#"{
1636            "run_id": "run-route",
1637            "task_id": "task-route",
1638            "worker_id": "worker-route",
1639            "completed_at": "2026-06-23T00:00:00Z",
1640            "result": "pass",
1641            "artifacts": [],
1642            "score": null,
1643            "resolved_route": {
1644                "provider_id": "deepseek",
1645                "provider_kind": "deepseek",
1646                "canonical_model": "deepseek-v4-pro",
1647                "wire_model_id": "deepseek-v4-pro",
1648                "protocol": "chat_completions",
1649                "role": "builder",
1650                "loadout": "fast",
1651                "source": "resolver"
1652            }
1653        }"#;
1654
1655        let receipt: FleetReceipt = serde_json::from_str(legacy).unwrap();
1656        let route = receipt.resolved_route.expect("legacy route should parse");
1657        assert_eq!(route.source, "resolver");
1658        assert_eq!(route.role.as_deref(), Some("builder"));
1659        assert_eq!(route.loadout.as_deref(), Some("fast"));
1660        assert_eq!(route.model_class, None);
1661        assert_eq!(route.model_route, None);
1662        assert_eq!(route.reasoning_effort, None);
1663        assert_eq!(route.role_source, None);
1664        assert_eq!(route.loadout_source, None);
1665        assert_eq!(route.model_class_source, None);
1666        assert_eq!(route.model_source, None);
1667    }
1668
1669    #[test]
1670    fn fleet_resolved_route_serialization_carries_no_secrets() {
1671        let receipt = sample_receipt_with_route();
1672        // Scan the serialized resolved-route object: this is the field whose
1673        // no-secrets invariant we are asserting. Scoping to the route value
1674        // avoids false positives from unrelated envelope ids (e.g. a task id
1675        // such as "task-foo" innocently contains the substring "sk-").
1676        let route_json = serde_json::to_string(receipt.resolved_route.as_ref().unwrap()).unwrap();
1677        assert_no_secret_markers(&route_json);
1678        // The envelope as a whole must also stay credential-free.
1679        let receipt_json = serde_json::to_string(&receipt).unwrap();
1680        for needle in SECRET_KEY_MARKERS {
1681            assert!(
1682                !receipt_json.to_ascii_lowercase().contains(needle),
1683                "receipt JSON must not contain secret-key marker {needle:?}: {receipt_json}"
1684            );
1685        }
1686    }
1687
1688    /// Substrings that indicate a leaked credential field/value. These are
1689    /// deliberately specific so legitimate ids/model names do not trip them.
1690    const SECRET_KEY_MARKERS: &[&str] = &[
1691        "api_key",
1692        "apikey",
1693        "api-key",
1694        "authorization",
1695        "bearer ",
1696        "auth_token",
1697        "auth-token",
1698        "password",
1699        "credential",
1700        "sk-ant-",
1701        "sk-proj-",
1702        "sk-or-",
1703        "secret",
1704    ];
1705
1706    fn assert_no_secret_markers(json: &str) {
1707        let haystack = json.to_ascii_lowercase();
1708        for needle in SECRET_KEY_MARKERS {
1709            assert!(
1710                !haystack.contains(needle),
1711                "resolved-route JSON must not contain secret marker {needle:?}: {json}"
1712            );
1713        }
1714    }
1715}