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