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