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