返回 CodeWhale
fleet.rs
根目录 / crates / protocol / src / fleet.rs
1 //! Agent Fleet control-plane protocol types.
2 //!
3 //! These types define the durable, serializable contract between the fleet
4 //! manager, workers, CLI/TUI surfaces, and the Runtime API. They are
5 //! intentionally additive: existing runtime-event consumers ignore unknown
6 //! fields and are unaffected by fleet extensions.
7 //!
8 //! See:
9 //! - <https://github.com/Hmbown/CodeWhale/issues/3154> (Agent Fleet control plane)
10 //! - <https://github.com/Hmbown/CodeWhale/issues/3096> (Runtime API sub-agent direction)
11
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
1863 lines RUST