返回 CodeWhale
task_spec.rs
根目录 / crates / tui / src / fleet / task_spec.rs
1 //! Typed task-spec loading, artifact refs, deterministic scorers, and receipts.
2
3 #![allow(dead_code)]
4
5 use std::collections::{BTreeMap, BTreeSet};
6 use std::path::{Path, PathBuf};
7
8 use anyhow::{Context, Result, bail};
9 use chrono::{SecondsFormat, Utc};
10 use codewhale_protocol::fleet::*;
11 use regex::Regex;
12 use serde::{Deserialize, Serialize};
13 use serde_json::{Value, json};
14
15 use super::ledger::FleetLedger;
16
17 const MAX_SCORER_READ_BYTES: u64 = 1_000_000;
18 const MAX_FLEET_ID_BYTES: usize = 128;
19 const MAX_FLEET_NAME_BYTES: usize = 256;
20
21 #[derive(Debug, Clone, Serialize, Deserialize)]
22 pub struct FleetTaskSpecDocument {
23 #[serde(default)]
24 pub name: Option<String>,
25 #[serde(default)]
26 pub labels: BTreeMap<String, String>,
27 #[serde(default)]
28 #[serde(skip_serializing_if = "Option::is_none")]
29 /// Legacy replay/input compatibility only. New run validation rejects it;
30 /// Runtime policy owns execution authority.
31 pub security_policy: Option<FleetSecurityPolicy>,
32 #[serde(default, alias = "worker_specs")]
33 pub workers: Vec<FleetWorkerSpec>,
34 #[serde(default)]
35 pub tasks: Vec<FleetTaskSpec>,
36 /// Optional run-wide usage ceiling (R6, #5567), e.g.
37 /// `usage_ceiling = { max_total_tokens = 2_000_000 }`.
38 #[serde(default)]
39 #[serde(skip_serializing_if = "Option::is_none")]
40 pub usage_ceiling: Option<codewhale_protocol::fleet::FleetUsageCeiling>,
41 }
42
43 #[derive(Debug, Clone, Deserialize)]
44 #[serde(untagged)]
45 enum FleetTaskSpecFile {
46 Document(FleetTaskSpecDocument),
47 Tasks(Vec<FleetTaskSpec>),
48 Single(Box<FleetTaskSpec>),
49 }
50
51 impl FleetTaskSpecFile {
52 fn into_document(self, fallback_name: String) -> FleetTaskSpecDocument {
53 match self {
54 Self::Document(mut doc) => {
55 if doc.name.as_deref().is_none_or(str::is_empty) {
56 doc.name = Some(fallback_name);
57 }
58 doc
59 }
60 Self::Tasks(tasks) => FleetTaskSpecDocument {
61 name: Some(fallback_name),
62 labels: BTreeMap::new(),
63 security_policy: None,
64 workers: Vec::new(),
65 tasks,
66 usage_ceiling: None,
67 },
68 Self::Single(task) => FleetTaskSpecDocument {
69 name: Some(fallback_name),
70 labels: BTreeMap::new(),
71 security_policy: None,
72 workers: Vec::new(),
73 tasks: vec![*task],
74 usage_ceiling: None,
75 },
76 }
77 }
78 }
79
80 /// The worker's visible final answer as carried by the terminal exec
81 /// `metadata` receipt: `excerpt` is already bounded and secret-redacted by the
82 /// emitter (`visible_final_answer_excerpt`), `chars` is the real
83 /// pre-truncation length (`visible_final_answer_chars`).
84 #[derive(Debug, Clone, PartialEq, Eq)]
85 pub struct FleetWorkerFinalAnswer {
86 pub excerpt: String,
87 pub chars: usize,
88 }
89
90 impl FleetWorkerFinalAnswer {
91 /// The receipt note for a task whose only deliverable is its answer text.
92 /// The excerpt is used verbatim: it was bounded and redacted once at the
93 /// emitter, and the ledger redacts receipt notes again on write.
94 pub fn receipt_note(&self) -> String {
95 format!(
96 "worker produced {} characters of deliverable: {}",
97 self.chars, self.excerpt
98 )
99 }
100 }
101
102 #[derive(Debug, Clone)]
103 pub struct FleetTaskVerificationInput {
104 pub run_id: FleetRunId,
105 pub task_id: String,
106 pub worker_id: String,
107 /// Durable lease generation whose result is being verified.
108 pub attempt: u32,
109 pub exit_code: Option<i32>,
110 pub artifacts: Vec<FleetArtifactRef>,
111 /// The worker's visible final answer, as reported by its terminal exec
112 /// receipt. Report/summary tasks with no scorer and no file artifact
113 /// surface this as their deliverable instead of "no verifiable output".
114 pub final_answer: Option<FleetWorkerFinalAnswer>,
115 /// Saved exec session id holding the worker's full transcript, when the
116 /// worker persisted one on completion.
117 pub saved_session_id: Option<String>,
118 /// Resolved-route snapshot to persist on the receipt (#3154).
119 pub resolved_route: Option<FleetResolvedRoute>,
120 /// Effective worker authority snapshot to persist on the receipt (#3211).
121 pub effective_permissions: Option<FleetEffectivePermissions>,
122 }
123
124 #[derive(Debug, Clone)]
125 pub struct FleetTaskVerification {
126 pub result: FleetTaskResult,
127 pub failure_kind: Option<FleetTaskFailureKind>,
128 pub score: FleetScore,
129 pub evidence: Vec<String>,
130 }
131
132 pub fn load_task_spec_document(path: &Path) -> Result<FleetTaskSpecDocument> {
133 let raw = std::fs::read_to_string(path)
134 .with_context(|| format!("reading fleet task spec {}", path.display()))?;
135 let fallback_name = path
136 .file_stem()
137 .and_then(|s| s.to_str())
138 .filter(|s| !s.is_empty())
139 .unwrap_or("fleet-run")
140 .to_string();
141 let parsed = match path.extension().and_then(|s| s.to_str()) {
142 Some("toml") => toml::from_str::<FleetTaskSpecFile>(&raw)
143 .with_context(|| format!("parsing TOML fleet task spec {}", path.display()))?,
144 _ => serde_json::from_str::<FleetTaskSpecFile>(&raw)
145 .with_context(|| format!("parsing JSON fleet task spec {}", path.display()))?,
146 };
147 let doc = parsed.into_document(fallback_name);
148 validate_task_spec_document(&doc)?;
149 Ok(doc)
150 }
151
152 pub fn validate_task_spec_document(doc: &FleetTaskSpecDocument) -> Result<()> {
153 if doc.security_policy.is_some() {
154 bail!(
155 "fleet task spec security_policy is a legacy compatibility field, not executable Fleet identity; configure trust, secrets, approvals, sandboxing, and tool authority through Runtime policy"
156 );
157 }
158 if doc.tasks.is_empty() {
159 bail!("fleet task spec must include at least one task");
160 }
161 let mut ids = BTreeSet::new();
162 for task in &doc.tasks {
163 validate_fleet_identity("task id", &task.id)?;
164 if !ids.insert(task.id.clone()) {
165 bail!("duplicate fleet task id {}", task.id);
166 }
167 validate_fleet_name(&format!("task {} name", task.id), &task.name)?;
168 if task.instructions.trim().is_empty() {
169 bail!("fleet task {} instructions cannot be empty", task.id);
170 }
171 if let Some(objective) = &task.objective
172 && objective.trim().is_empty()
173 {
174 bail!("fleet task {} objective cannot be empty", task.id);
175 }
176 validate_worker_profile(&task.id, task.worker.as_ref())?;
177 if task
178 .metadata
179 .contains_key(super::worker_runtime::FROZEN_FLEET_MEMBER_METADATA_KEY)
180 {
181 bail!(
182 "fleet task {} metadata key {} is reserved for the durable Runtime selection receipt",
183 task.id,
184 super::worker_runtime::FROZEN_FLEET_MEMBER_METADATA_KEY
185 );
186 }
187 validate_tags(&task.id, &task.tags)?;
188 validate_workspace_requirements(task)?;
189 }
190 let mut worker_ids = BTreeSet::new();
191 for worker in &doc.workers {
192 validate_fleet_identity("worker id", &worker.id)?;
193 if !worker_ids.insert(worker.id.clone()) {
194 bail!("duplicate fleet worker id {}", worker.id);
195 }
196 validate_fleet_name(&format!("worker {} name", worker.id), &worker.name)?;
197 if worker.trust_level.is_some() {
198 bail!(
199 "fleet worker {} trust_level is a legacy compatibility field, not Fleet identity; configure execution authority through Runtime policy",
200 worker.id
201 );
202 }
203 }
204 Ok(())
205 }
206
207 fn validate_fleet_identity(field: &str, value: &str) -> Result<()> {
208 if value.is_empty() {
209 bail!("fleet {field} cannot be empty");
210 }
211 if value.len() > MAX_FLEET_ID_BYTES || !value.chars().all(is_worker_token_char) {
212 bail!(
213 "fleet {field} must be a simple ASCII token no longer than {MAX_FLEET_ID_BYTES} bytes"
214 );
215 }
216 Ok(())
217 }
218
219 fn validate_fleet_name(field: &str, value: &str) -> Result<()> {
220 if value.trim().is_empty() {
221 bail!("fleet {field} cannot be empty");
222 }
223 if value.len() > MAX_FLEET_NAME_BYTES || value.chars().any(char::is_control) {
224 bail!(
225 "fleet {field} must be one printable line no longer than {MAX_FLEET_NAME_BYTES} bytes"
226 );
227 }
228 Ok(())
229 }
230
231 fn validate_worker_profile(task_id: &str, worker: Option<&FleetTaskWorkerProfile>) -> Result<()> {
232 let Some(worker) = worker else {
233 return Ok(());
234 };
235 validate_worker_selector(
236 task_id,
237 "worker.agent_profile",
238 worker.agent_profile.as_deref(),
239 )?;
240 validate_worker_token(task_id, "worker.loadout", worker.loadout.as_deref())?;
241 validate_worker_token(task_id, "worker.model_class", worker.model_class.as_deref())?;
242 validate_worker_model(task_id, worker.model.as_deref())?;
243 Ok(())
244 }
245
246 fn validate_worker_selector(task_id: &str, field: &str, value: Option<&str>) -> Result<()> {
247 let Some(value) = value else {
248 return Ok(());
249 };
250 let trimmed = value.trim();
251 if trimmed.is_empty() {
252 bail!("fleet task {task_id} {field} cannot be empty");
253 }
254 if trimmed != value || value.len() > MAX_FLEET_NAME_BYTES || value.chars().any(char::is_control)
255 {
256 bail!(
257 "fleet task {task_id} {field} must be one printable selector no longer than {MAX_FLEET_NAME_BYTES} bytes"
258 );
259 }
260 Ok(())
261 }
262
263 fn validate_worker_token(task_id: &str, field: &str, value: Option<&str>) -> Result<()> {
264 let Some(value) = value else {
265 return Ok(());
266 };
267 let trimmed = value.trim();
268 if trimmed.is_empty() {
269 bail!("fleet task {task_id} {field} cannot be empty");
270 }
271 if trimmed != value || !trimmed.chars().all(is_worker_token_char) {
272 bail!(
273 "fleet task {task_id} {field} must be a simple token, not a path or provider/model id"
274 );
275 }
276 Ok(())
277 }
278
279 fn is_worker_token_char(ch: char) -> bool {
280 ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.')
281 }
282
283 fn validate_worker_model(task_id: &str, value: Option<&str>) -> Result<()> {
284 let Some(value) = value else {
285 return Ok(());
286 };
287 let trimmed = value.trim();
288 if trimmed.is_empty() {
289 bail!("fleet task {task_id} worker.model cannot be empty");
290 }
291 if trimmed != value
292 || !trimmed
293 .chars()
294 .all(|ch| ch.is_ascii_graphic() && !matches!(ch, '=' | '\'' | '"'))
295 {
296 bail!(
297 "fleet task {task_id} worker.model must be a visible model id without whitespace or secrets"
298 );
299 }
300 Ok(())
301 }
302
303 #[allow(clippy::too_many_arguments)]
304 pub fn write_fleet_artifact_ref(
305 workspace: &Path,
306 run_id: &FleetRunId,
307 task_id: &str,
308 worker_id: &str,
309 kind: FleetArtifactKind,
310 filename: &str,
311 contents: &[u8],
312 mime_type: Option<&str>,
313 ) -> Result<FleetArtifactRef> {
314 let rel_path = PathBuf::from(".codewhale")
315 .join("fleet")
316 .join(safe_path_segment(&run_id.0))
317 .join(safe_path_segment(task_id))
318 .join(safe_path_segment(worker_id))
319 .join(safe_path_segment(filename));
320 super::artifacts::write(workspace, &rel_path, contents)?;
321 Ok(FleetArtifactRef {
322 kind,
323 path: rel_path,
324 checksum: Some(format!("sha256:{}", crate::hashing::sha256_hex(contents))),
325 mime_type: mime_type.map(str::to_string),
326 size_bytes: Some(contents.len() as u64),
327 })
328 }
329
330 pub fn verify_task_result(
331 workspace: &Path,
332 task: &FleetTaskSpec,
333 input: &FleetTaskVerificationInput,
334 ) -> FleetTaskVerification {
335 match &task.scorer {
336 Some(FleetScorerSpec::ExitCode) => verify_exit_code(input.exit_code),
337 Some(FleetScorerSpec::FileExists { path }) => verify_file_exists(workspace, path),
338 Some(FleetScorerSpec::RegexMatch { path, pattern }) => {
339 verify_regex_match(workspace, path, pattern)
340 }
341 Some(FleetScorerSpec::JsonPath { path, expression }) => {
342 verify_json_path(workspace, path, expression)
343 }
344 Some(FleetScorerSpec::Command { command, .. }) => partial(
345 format!("external scorer command configured: {command}"),
346 "run the configured scorer command to finalize this receipt",
347 ),
348 Some(FleetScorerSpec::CodeWhaleVerifierPrompt { .. }) => partial(
349 "Codewhale verifier prompt configured",
350 "run a verifier prompt pass to finalize this receipt",
351 ),
352 Some(FleetScorerSpec::Manual) => partial(
353 "manual scorer configured",
354 "manual verification is required to finalize this receipt",
355 ),
356 None if !has_verifiable_artifact(input) => match input
357 .final_answer
358 .as_ref()
359 .filter(|answer| !answer.excerpt.trim().is_empty())
360 {
361 Some(answer) => partial(
362 "no scorer configured; worker produced a summary deliverable",
363 answer.receipt_note(),
364 ),
365 None => partial(
366 "no scorer configured and no verifiable artifacts recorded",
367 "worker exited successfully but produced no verifiable output",
368 ),
369 },
370 None => partial(
371 "no scorer configured",
372 "task has artifacts but no deterministic scorer",
373 ),
374 }
375 }
376
377 pub fn prepare_verification_receipt(
378 workspace: &Path,
379 input: &FleetTaskVerificationInput,
380 verification: FleetTaskVerification,
381 ) -> Result<FleetReceipt> {
382 let evidence = json!({
383 "run_id": input.run_id.0.clone(),
384 "task_id": input.task_id.clone(),
385 "worker_id": input.worker_id.clone(),
386 "attempt": input.attempt,
387 "result": verification.result.clone(),
388 "failure_kind": verification.failure_kind.clone(),
389 "score": verification.score.clone(),
390 "evidence": verification.evidence.clone(),
391 "artifacts": input.artifacts.clone(),
392 });
393 let bytes =
394 serde_json::to_vec_pretty(&evidence).context("serializing fleet receipt evidence")?;
395 // Content-address the evidence as well as namespacing it by attempt. A
396 // stale verifier may finish after a retry has started; it is allowed to
397 // leave an orphaned evidence file, but it must never overwrite the file a
398 // winning attempt's durable receipt references.
399 let evidence_hash = crate::hashing::sha256_hex(&bytes);
400 let filename = format!(
401 "verification-receipt-attempt-{:010}-{}.json",
402 input.attempt, evidence_hash
403 );
404 let receipt_artifact = write_fleet_artifact_ref(
405 workspace,
406 &input.run_id,
407 &input.task_id,
408 &input.worker_id,
409 FleetArtifactKind::Receipt,
410 &filename,
411 &bytes,
412 Some("application/json"),
413 )?;
414 let mut artifacts = input.artifacts.clone();
415 artifacts.push(receipt_artifact);
416 let receipt = FleetReceipt {
417 run_id: input.run_id.clone(),
418 task_id: input.task_id.clone(),
419 worker_id: input.worker_id.clone(),
420 attempt: Some(input.attempt),
421 terminal_seq: None,
422 completed_at: timestamp(),
423 result: verification.result,
424 failure_kind: verification.failure_kind,
425 artifacts,
426 score: Some(verification.score),
427 resolved_route: input.resolved_route.clone(),
428 saved_session_id: input.saved_session_id.clone(),
429 effective_permissions: input.effective_permissions.clone(),
430 };
431 Ok(receipt)
432 }
433
434 pub fn record_verification_receipt(
435 ledger: &FleetLedger,
436 workspace: &Path,
437 input: &FleetTaskVerificationInput,
438 verification: FleetTaskVerification,
439 ) -> Result<FleetReceipt> {
440 let receipt = prepare_verification_receipt(workspace, input, verification)?;
441 ledger.record_receipt(receipt.clone())?;
442 Ok(receipt)
443 }
444
445 fn validate_tags(task_id: &str, tags: &[String]) -> Result<()> {
446 let mut seen = BTreeSet::new();
447 for tag in tags {
448 if tag.trim().is_empty() {
449 bail!("fleet task {task_id} tag cannot be empty");
450 }
451 if !seen.insert(tag) {
452 bail!("fleet task {task_id} has duplicate tag {tag}");
453 }
454 }
455 Ok(())
456 }
457
458 fn validate_workspace_requirements(task: &FleetTaskSpec) -> Result<()> {
459 let Some(workspace) = &task.workspace else {
460 return Ok(());
461 };
462 let env = workspace.environment.as_ref();
463 for name in env
464 .into_iter()
465 .flat_map(|env| env.required.iter().chain(env.allowlist.iter()))
466 {
467 if name.trim().is_empty() {
468 bail!(
469 "fleet task {} environment variable name cannot be empty",
470 task.id
471 );
472 }
473 }
474 Ok(())
475 }
476
477 fn verify_exit_code(exit_code: Option<i32>) -> FleetTaskVerification {
478 match exit_code {
479 Some(0) => pass("exit_code=0"),
480 Some(code) => fail(
481 FleetTaskFailureKind::Task,
482 0.0,
483 format!("exit_code={code}"),
484 "worker task exited unsuccessfully",
485 ),
486 None => fail(
487 FleetTaskFailureKind::Transport,
488 0.0,
489 "missing exit code",
490 "worker transport did not report a process result",
491 ),
492 }
493 }
494
495 fn verify_file_exists(workspace: &Path, path: &Path) -> FleetTaskVerification {
496 let abs_path = resolve_workspace_path(workspace, path);
497 if abs_path.is_file() {
498 pass(format!("file exists: {}", path.display()))
499 } else {
500 fail(
501 FleetTaskFailureKind::Task,
502 0.0,
503 format!("missing file: {}", path.display()),
504 "expected artifact file was not produced",
505 )
506 }
507 }
508
509 fn verify_regex_match(workspace: &Path, path: &Path, pattern: &str) -> FleetTaskVerification {
510 let regex = match Regex::new(pattern) {
511 Ok(regex) => regex,
512 Err(err) => {
513 return fail(
514 FleetTaskFailureKind::Verifier,
515 0.0,
516 format!("invalid regex: {err}"),
517 "regex scorer could not be compiled",
518 );
519 }
520 };
521 let contents = match read_bounded_to_string(workspace, path) {
522 Ok(contents) => contents,
523 Err(err) => {
524 return fail(
525 err.failure_kind,
526 0.0,
527 err.evidence,
528 "regex scorer could not read bounded evidence",
529 );
530 }
531 };
532 if regex.is_match(&contents) {
533 pass(format!("regex matched {}: {pattern}", path.display()))
534 } else {
535 fail(
536 FleetTaskFailureKind::Task,
537 0.0,
538 format!("regex did not match {}: {pattern}", path.display()),
539 "worker output did not satisfy the regex scorer",
540 )
541 }
542 }
543
544 fn verify_json_path(workspace: &Path, path: &Path, expression: &str) -> FleetTaskVerification {
545 let Some(segments) = json_path_segments(expression) else {
546 return fail(
547 FleetTaskFailureKind::Verifier,
548 0.0,
549 format!("unsupported JSON path expression: {expression}"),
550 "json_path scorer supports $.field or .field paths",
551 );
552 };
553 let contents = match read_bounded_to_string(workspace, path) {
554 Ok(contents) => contents,
555 Err(err) => {
556 return fail(
557 err.failure_kind,
558 0.0,
559 err.evidence,
560 "json_path scorer could not read bounded evidence",
561 );
562 }
563 };
564 let value: Value = match serde_json::from_str(&contents) {
565 Ok(value) => value,
566 Err(err) => {
567 return fail(
568 FleetTaskFailureKind::Task,
569 0.0,
570 format!("invalid JSON in {}: {err}", path.display()),
571 "worker artifact was not valid JSON",
572 );
573 }
574 };
575 match json_path_lookup(&value, &segments) {
576 Some(found) if json_truthy(found) => pass(format!(
577 "json_path matched {}: {expression}",
578 path.display()
579 )),
580 _ => fail(
581 FleetTaskFailureKind::Task,
582 0.0,
583 format!(
584 "json_path missing or false in {}: {expression}",
585 path.display()
586 ),
587 "worker JSON artifact did not satisfy the scorer",
588 ),
589 }
590 }
591
592 fn pass(evidence: impl Into<String>) -> FleetTaskVerification {
593 let evidence = evidence.into();
594 FleetTaskVerification {
595 result: FleetTaskResult::Pass,
596 failure_kind: None,
597 score: FleetScore {
598 value: 1.0,
599 max: Some(1.0),
600 notes: Some(evidence.clone()),
601 },
602 evidence: vec![evidence],
603 }
604 }
605
606 fn partial(evidence: impl Into<String>, notes: impl Into<String>) -> FleetTaskVerification {
607 let evidence = evidence.into();
608 let notes = notes.into();
609 FleetTaskVerification {
610 result: FleetTaskResult::Partial,
611 failure_kind: None,
612 score: FleetScore {
613 value: 0.5,
614 max: Some(1.0),
615 notes: Some(notes),
616 },
617 evidence: vec![evidence],
618 }
619 }
620
621 fn fail(
622 failure_kind: FleetTaskFailureKind,
623 value: f64,
624 evidence: impl Into<String>,
625 notes: impl Into<String>,
626 ) -> FleetTaskVerification {
627 let evidence = evidence.into();
628 FleetTaskVerification {
629 result: FleetTaskResult::Fail,
630 failure_kind: Some(failure_kind),
631 score: FleetScore {
632 value,
633 max: Some(1.0),
634 notes: Some(notes.into()),
635 },
636 evidence: vec![evidence],
637 }
638 }
639
640 fn has_verifiable_artifact(input: &FleetTaskVerificationInput) -> bool {
641 input.artifacts.iter().any(|artifact| {
642 !matches!(
643 artifact.kind,
644 FleetArtifactKind::Log | FleetArtifactKind::Receipt
645 )
646 })
647 }
648
649 #[derive(Debug)]
650 struct EvidenceReadError {
651 failure_kind: FleetTaskFailureKind,
652 evidence: String,
653 }
654
655 fn read_bounded_to_string(
656 workspace: &Path,
657 path: &Path,
658 ) -> std::result::Result<String, EvidenceReadError> {
659 let abs_path = resolve_workspace_path(workspace, path);
660 let metadata = std::fs::metadata(&abs_path).map_err(|err| EvidenceReadError {
661 failure_kind: if err.kind() == std::io::ErrorKind::NotFound {
662 FleetTaskFailureKind::Task
663 } else {
664 FleetTaskFailureKind::Verifier
665 },
666 evidence: format!("cannot read {}: {err}", path.display()),
667 })?;
668 if metadata.len() > MAX_SCORER_READ_BYTES {
669 return Err(EvidenceReadError {
670 failure_kind: FleetTaskFailureKind::Verifier,
671 evidence: format!(
672 "refusing to read oversized evidence {}: {} bytes",
673 path.display(),
674 metadata.len()
675 ),
676 });
677 }
678 std::fs::read_to_string(&abs_path).map_err(|err| EvidenceReadError {
679 failure_kind: FleetTaskFailureKind::Verifier,
680 evidence: format!("cannot decode {} as UTF-8: {err}", path.display()),
681 })
682 }
683
684 fn resolve_workspace_path(workspace: &Path, path: &Path) -> PathBuf {
685 if path.is_absolute() {
686 path.to_path_buf()
687 } else {
688 workspace.join(path)
689 }
690 }
691
692 fn json_path_segments(expression: &str) -> Option<Vec<&str>> {
693 let trimmed = expression.trim();
694 let path = trimmed
695 .strip_prefix("$.")
696 .or_else(|| trimmed.strip_prefix('.'))?;
697 if path.is_empty() {
698 return None;
699 }
700 let segments: Vec<_> = path.split('.').collect();
701 if segments.iter().any(|segment| segment.is_empty()) {
702 return None;
703 }
704 Some(segments)
705 }
706
707 fn json_path_lookup<'a>(value: &'a Value, segments: &[&str]) -> Option<&'a Value> {
708 let mut current = value;
709 for segment in segments {
710 current = current.as_object()?.get(*segment)?;
711 }
712 Some(current)
713 }
714
715 fn json_truthy(value: &Value) -> bool {
716 !matches!(value, Value::Null | Value::Bool(false))
717 }
718
719 fn timestamp() -> String {
720 Utc::now().to_rfc3339_opts(SecondsFormat::Secs, true)
721 }
722
723 fn safe_path_segment(value: &str) -> String {
724 value
725 .chars()
726 .map(|ch| {
727 if ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.') {
728 ch
729 } else {
730 '_'
731 }
732 })
733 .collect()
734 }
735
736 #[cfg(test)]
737 mod tests {
738 use super::*;
739 use serde_json::json;
740 use tempfile::TempDir;
741
742 fn task(id: &str, scorer: Option<FleetScorerSpec>) -> FleetTaskSpec {
743 FleetTaskSpec {
744 id: id.to_string(),
745 name: id.to_string(),
746 description: None,
747 objective: Some(format!("Verify {id}")),
748 instructions: format!("do {id}"),
749 worker: Some(FleetTaskWorkerProfile {
750 agent_profile: None,
751 role: Some("reviewer".to_string()),
752 loadout: None,
753 model_class: None,
754 model: None,
755 tool_profile: Some("read-only".to_string()),
756 tools: vec!["git".to_string()],
757 capabilities: vec!["rust".to_string()],
758 }),
759 workspace: Some(FleetWorkspaceRequirements {
760 root: Some(PathBuf::from(".")),
761 required_files: vec![PathBuf::from("Cargo.toml")],
762 writable_paths: vec![PathBuf::from(".codewhale/fleet")],
763 environment: Some(FleetEnvironmentRequirements {
764 required: vec!["PATH".to_string()],
765 allowlist: vec!["RUST_LOG".to_string()],
766 }),
767 }),
768 input_files: vec![PathBuf::from("Cargo.toml")],
769 context: vec!["fleet verifier test".to_string()],
770 budget: Some(FleetTaskBudget {
771 max_tokens: Some(4000),
772 max_steps: None,
773 max_tool_calls: Some(12),
774 max_seconds: Some(120),
775 }),
776 expected_artifacts: vec![FleetArtifactKind::Log, FleetArtifactKind::Receipt],
777 scorer,
778 retry_policy: Some(FleetRetryPolicy::default()),
779 alert_policy: None,
780 timeout_seconds: Some(120),
781 tags: vec!["review".to_string()],
782 metadata: BTreeMap::new(),
783 }
784 }
785
786 #[test]
787 fn fleet_task_spec_document_parses_multi_task_verified_shape() {
788 let tmp = TempDir::new().unwrap();
789 let path = tmp.path().join("fleet-tasks.json");
790 let doc = json!({
791 "name": "release triage",
792 "labels": {"milestone": "v0.8.60"},
793 "tasks": [
794 task("release-notes", Some(FleetScorerSpec::ExitCode)),
795 task("risk-review", Some(FleetScorerSpec::Manual))
796 ]
797 });
798 std::fs::write(&path, serde_json::to_string_pretty(&doc).unwrap()).unwrap();
799
800 let parsed = load_task_spec_document(&path).unwrap();
801
802 assert_eq!(parsed.name.as_deref(), Some("release triage"));
803 assert_eq!(parsed.tasks.len(), 2);
804 assert_eq!(
805 parsed.tasks[0].objective.as_deref(),
806 Some("Verify release-notes")
807 );
808 assert_eq!(
809 parsed.tasks[0].worker.as_ref().unwrap().role.as_deref(),
810 Some("reviewer")
811 );
812 assert_eq!(parsed.tasks[1].tags, vec!["review"]);
813 }
814
815 #[test]
816 fn fleet_task_spec_document_parses_worker_profile_loadout_intent() {
817 let tmp = TempDir::new().unwrap();
818 let path = tmp.path().join("fleet-profile-task.json");
819 let doc = json!({
820 "name": "profile loadout smoke",
821 "tasks": [{
822 "id": "review",
823 "name": "review",
824 "instructions": "review the patch",
825 "worker": {
826 "profile": "DeepSeek V4 Flash",
827 "role": "reviewer",
828 "loadout": "auto",
829 "model_class": "balanced",
830 "model": "deepseek-v4-pro",
831 "tool_profile": "read-only",
832 "tools": ["read_file", "grep_files"],
833 "capabilities": ["rust"]
834 }
835 }]
836 });
837 std::fs::write(&path, serde_json::to_string_pretty(&doc).unwrap()).unwrap();
838
839 let parsed = load_task_spec_document(&path).unwrap();
840 let worker = parsed.tasks[0].worker.as_ref().unwrap();
841
842 assert_eq!(worker.agent_profile.as_deref(), Some("DeepSeek V4 Flash"));
843 assert_eq!(worker.role.as_deref(), Some("reviewer"));
844 assert_eq!(worker.loadout.as_deref(), Some("auto"));
845 assert_eq!(worker.model_class.as_deref(), Some("balanced"));
846 assert_eq!(worker.model.as_deref(), Some("deepseek-v4-pro"));
847 assert_eq!(worker.tool_profile.as_deref(), Some("read-only"));
848 }
849
850 #[test]
851 fn fleet_task_spec_rejects_unsafe_worker_profile_intent_tokens() {
852 let tmp = TempDir::new().unwrap();
853 let path = tmp.path().join("unsafe-profile-task.json");
854 let doc = json!({
855 "tasks": [{
856 "id": "review",
857 "name": "review",
858 "instructions": "review the patch",
859 "worker": {
860 "profile": "reviewer\n../../secrets",
861 "loadout": "openrouter/deepseek",
862 "model_class": "",
863 "model": "deepseek/deepseek-v4-pro"
864 }
865 }]
866 });
867 std::fs::write(&path, serde_json::to_string_pretty(&doc).unwrap()).unwrap();
868
869 let err = load_task_spec_document(&path).unwrap_err().to_string();
870
871 assert!(
872 err.contains("worker.agent_profile must be one printable selector"),
873 "unexpected error: {err}"
874 );
875 }
876
877 #[test]
878 fn fleet_task_spec_rejects_secret_like_worker_model() {
879 let tmp = TempDir::new().unwrap();
880 let path = tmp.path().join("unsafe-worker-model.json");
881 let doc = json!({
882 "tasks": [{
883 "id": "review",
884 "name": "review",
885 "instructions": "review the patch",
886 "worker": {
887 "model": "deepseek-v4-pro api_key=secret"
888 }
889 }]
890 });
891 std::fs::write(&path, serde_json::to_string_pretty(&doc).unwrap()).unwrap();
892
893 let err = load_task_spec_document(&path).unwrap_err().to_string();
894
895 assert!(
896 err.contains("worker.model must be a visible model id"),
897 "unexpected error: {err}"
898 );
899 }
900
901 #[test]
902 fn fleet_task_spec_rejects_unbounded_or_multiline_task_and_worker_identities() {
903 let tmp = TempDir::new().unwrap();
904 let path = tmp.path().join("unsafe-identities.json");
905 let doc = json!({
906 "workers": [{
907 "id": "worker\r\nforged",
908 "name": "forged worker",
909 "host": {"kind": "local"}
910 }],
911 "tasks": [{
912 "id": "review",
913 "name": "review",
914 "instructions": "review the patch"
915 }]
916 });
917 std::fs::write(&path, serde_json::to_string_pretty(&doc).unwrap()).unwrap();
918
919 let err = load_task_spec_document(&path).unwrap_err().to_string();
920 assert!(
921 err.contains("worker id must be a simple ASCII token"),
922 "unexpected error: {err}"
923 );
924
925 let mut doc = task("review", None);
926 doc.id = "a".repeat(MAX_FLEET_ID_BYTES + 1);
927 let err = validate_task_spec_document(&FleetTaskSpecDocument {
928 name: None,
929 labels: BTreeMap::new(),
930 security_policy: None,
931 workers: Vec::new(),
932 tasks: vec![doc],
933 usage_ceiling: None,
934 })
935 .unwrap_err()
936 .to_string();
937 assert!(
938 err.contains("task id must be a simple ASCII token"),
939 "unexpected error: {err}"
940 );
941 }
942
943 #[test]
944 fn fleet_task_spec_rejects_runtime_authority_fields_for_new_runs() {
945 let mut security_doc = FleetTaskSpecDocument {
946 name: None,
947 labels: BTreeMap::new(),
948 security_policy: Some(FleetSecurityPolicy::default()),
949 workers: Vec::new(),
950 tasks: vec![task("review", None)],
951 usage_ceiling: None,
952 };
953 let error = validate_task_spec_document(&security_doc)
954 .unwrap_err()
955 .to_string();
956 assert!(
957 error.contains("security_policy is a legacy compatibility field"),
958 "unexpected error: {error}"
959 );
960
961 security_doc.security_policy = None;
962 security_doc.workers.push(FleetWorkerSpec {
963 id: "worker-1".to_string(),
964 name: "Worker 1".to_string(),
965 host: FleetHostSpec::Local,
966 trust_level: Some(FleetTrustLevel::Local),
967 labels: BTreeMap::new(),
968 capabilities: vec!["local".to_string()],
969 max_concurrent_tasks: Some(1),
970 });
971 let error = validate_task_spec_document(&security_doc)
972 .unwrap_err()
973 .to_string();
974 assert!(
975 error.contains("trust_level is a legacy compatibility field"),
976 "unexpected error: {error}"
977 );
978 }
979
980 #[cfg(unix)]
981 #[test]
982 fn fleet_artifact_publication_rejects_symlinked_workspace_paths() {
983 use std::os::unix::fs::symlink;
984 let workspace = TempDir::new().unwrap();
985 let outside = TempDir::new().unwrap();
986 std::fs::create_dir_all(workspace.path().join(".codewhale")).unwrap();
987 symlink(outside.path(), workspace.path().join(".codewhale/fleet")).unwrap();
988 let result = write_fleet_artifact_ref(
989 workspace.path(),
990 &FleetRunId::from("run-1"),
991 "task-a",
992 "worker-1",
993 FleetArtifactKind::Receipt,
994 "receipt.json",
995 b"synthetic receipt",
996 Some("application/json"),
997 );
998 assert!(
999 result.is_err(),
1000 "publication must reject a symlinked parent"
1001 );
1002 assert!(!outside.path().join("run-1").exists());
1003 }
1004
1005 #[test]
1006 fn fleet_task_spec_artifact_refs_are_bounded_paths() {
1007 let tmp = TempDir::new().unwrap();
1008 let artifact = write_fleet_artifact_ref(
1009 tmp.path(),
1010 &FleetRunId::from("run-1"),
1011 "task-a",
1012 "worker-1",
1013 FleetArtifactKind::Log,
1014 "worker.log",
1015 b"this is artifact content",
1016 Some("text/plain"),
1017 )
1018 .unwrap();
1019
1020 let json = serde_json::to_string(&artifact).unwrap();
1021 assert!(!json.contains("this is artifact content"));
1022 assert!(json.contains("worker.log"));
1023 assert_eq!(artifact.size_bytes, Some(24));
1024 assert!(artifact.checksum.as_deref().unwrap().starts_with("sha256:"));
1025 assert!(tmp.path().join(&artifact.path).exists());
1026 }
1027
1028 #[test]
1029 fn fleet_task_spec_scorers_record_pass_fail_partial_evidence() {
1030 let tmp = TempDir::new().unwrap();
1031 std::fs::write(tmp.path().join("result.txt"), "status=ok\n").unwrap();
1032 std::fs::write(tmp.path().join("result.json"), r#"{"status":"ok"}"#).unwrap();
1033 let input = FleetTaskVerificationInput {
1034 run_id: FleetRunId::from("run-1"),
1035 task_id: "task-a".to_string(),
1036 worker_id: "worker-1".to_string(),
1037 attempt: 1,
1038 exit_code: Some(0),
1039 artifacts: vec![],
1040 final_answer: None,
1041 saved_session_id: None,
1042 resolved_route: None,
1043 effective_permissions: None,
1044 };
1045
1046 let pass = verify_task_result(
1047 tmp.path(),
1048 &task("exit", Some(FleetScorerSpec::ExitCode)),
1049 &input,
1050 );
1051 assert_eq!(pass.result, FleetTaskResult::Pass);
1052 assert_eq!(pass.failure_kind, None);
1053
1054 let regex = verify_task_result(
1055 tmp.path(),
1056 &task(
1057 "regex",
1058 Some(FleetScorerSpec::RegexMatch {
1059 path: PathBuf::from("result.txt"),
1060 pattern: "status=ok".to_string(),
1061 }),
1062 ),
1063 &input,
1064 );
1065 assert_eq!(regex.result, FleetTaskResult::Pass);
1066
1067 let json_path = verify_task_result(
1068 tmp.path(),
1069 &task(
1070 "json",
1071 Some(FleetScorerSpec::JsonPath {
1072 path: PathBuf::from("result.json"),
1073 expression: "$.status".to_string(),
1074 }),
1075 ),
1076 &input,
1077 );
1078 assert_eq!(json_path.result, FleetTaskResult::Pass);
1079
1080 let manual = verify_task_result(
1081 tmp.path(),
1082 &task("manual", Some(FleetScorerSpec::Manual)),
1083 &input,
1084 );
1085 assert_eq!(manual.result, FleetTaskResult::Partial);
1086
1087 let no_scorer_empty = verify_task_result(tmp.path(), &task("unscored", None), &input);
1088 assert_eq!(no_scorer_empty.result, FleetTaskResult::Partial);
1089 assert!(
1090 no_scorer_empty
1091 .score
1092 .notes
1093 .as_deref()
1094 .unwrap_or_default()
1095 .contains("no verifiable output")
1096 );
1097
1098 let failed = verify_task_result(
1099 tmp.path(),
1100 &task(
1101 "missing",
1102 Some(FleetScorerSpec::FileExists {
1103 path: PathBuf::from("missing.txt"),
1104 }),
1105 ),
1106 &input,
1107 );
1108 assert_eq!(failed.result, FleetTaskResult::Fail);
1109 assert_eq!(failed.failure_kind, Some(FleetTaskFailureKind::Task));
1110
1111 let verifier_failed = verify_task_result(
1112 tmp.path(),
1113 &task(
1114 "bad-regex",
1115 Some(FleetScorerSpec::RegexMatch {
1116 path: PathBuf::from("result.txt"),
1117 pattern: "[".to_string(),
1118 }),
1119 ),
1120 &input,
1121 );
1122 assert_eq!(verifier_failed.result, FleetTaskResult::Fail);
1123 assert_eq!(
1124 verifier_failed.failure_kind,
1125 Some(FleetTaskFailureKind::Verifier)
1126 );
1127 }
1128
1129 #[test]
1130 fn unscored_worker_surfaces_summary_deliverable_instead_of_no_output() {
1131 let tmp = TempDir::new().unwrap();
1132 let input = FleetTaskVerificationInput {
1133 run_id: FleetRunId::from("run-1"),
1134 task_id: "task-a".to_string(),
1135 worker_id: "worker-1".to_string(),
1136 attempt: 1,
1137 exit_code: Some(0),
1138 artifacts: vec![],
1139 final_answer: Some(FleetWorkerFinalAnswer {
1140 excerpt: "The Changelog review is complete".to_string(),
1141 chars: 32,
1142 }),
1143 saved_session_id: None,
1144 resolved_route: None,
1145 effective_permissions: None,
1146 };
1147 let verification = verify_task_result(tmp.path(), &task("unscored", None), &input);
1148 assert_eq!(verification.result, FleetTaskResult::Partial);
1149 let notes = verification
1150 .score
1151 .notes
1152 .as_deref()
1153 .unwrap_or_default()
1154 .to_string();
1155 assert!(
1156 notes.contains("worker produced 32 characters of deliverable"),
1157 "unexpected notes: {notes}"
1158 );
1159 assert!(notes.contains("Changelog review is complete"));
1160 assert!(!notes.contains("no verifiable output"));
1161 }
1162
1163 #[test]
1164 fn fleet_task_spec_receipt_records_artifacts_scores_and_failure_kind() {
1165 let tmp = TempDir::new().unwrap();
1166 let ledger = FleetLedger::open(tmp.path()).unwrap();
1167 let log = write_fleet_artifact_ref(
1168 tmp.path(),
1169 &FleetRunId::from("run-1"),
1170 "task-a",
1171 "worker-1",
1172 FleetArtifactKind::Log,
1173 "worker.log",
1174 b"exit_code=1",
1175 Some("text/plain"),
1176 )
1177 .unwrap();
1178 let input = FleetTaskVerificationInput {
1179 run_id: FleetRunId::from("run-1"),
1180 task_id: "task-a".to_string(),
1181 worker_id: "worker-1".to_string(),
1182 attempt: 3,
1183 exit_code: Some(1),
1184 artifacts: vec![log],
1185 final_answer: None,
1186 saved_session_id: None,
1187 resolved_route: None,
1188 effective_permissions: Some(FleetEffectivePermissions {
1189 write: false,
1190 network: false,
1191 shell: "read_only".to_string(),
1192 tool_scope: "explicit".to_string(),
1193 tools: vec!["read_file".to_string()],
1194 background: true,
1195 max_spawn_depth: 0,
1196 profile_id: None,
1197 profile_origin: None,
1198 source: "worker_runtime_profile".to_string(),
1199 }),
1200 };
1201 let verification = verify_task_result(
1202 tmp.path(),
1203 &task("task-a", Some(FleetScorerSpec::ExitCode)),
1204 &input,
1205 );
1206
1207 let receipt =
1208 record_verification_receipt(&ledger, tmp.path(), &input, verification).unwrap();
1209
1210 assert_eq!(receipt.result, FleetTaskResult::Fail);
1211 assert_eq!(receipt.failure_kind, Some(FleetTaskFailureKind::Task));
1212 assert_eq!(receipt.attempt, Some(3));
1213 assert_eq!(receipt.terminal_seq, None);
1214 assert_eq!(receipt.effective_permissions, input.effective_permissions);
1215 assert_eq!(receipt.artifacts.len(), 2);
1216 assert!(matches!(
1217 receipt.artifacts.last().unwrap().kind,
1218 FleetArtifactKind::Receipt
1219 ));
1220 assert!(
1221 receipt
1222 .artifacts
1223 .last()
1224 .unwrap()
1225 .path
1226 .to_string_lossy()
1227 .contains("verification-receipt-attempt-0000000003-")
1228 );
1229 let state = ledger.rebuild_state().unwrap();
1230 assert_eq!(
1231 state.receipts["run-1:task-a"].failure_kind,
1232 Some(FleetTaskFailureKind::Task)
1233 );
1234 }
1235
1236 #[test]
1237 fn verification_evidence_is_attempt_and_content_addressed() {
1238 let tmp = TempDir::new().unwrap();
1239 let mut input = FleetTaskVerificationInput {
1240 run_id: FleetRunId::from("run-1"),
1241 task_id: "task-a".to_string(),
1242 worker_id: "worker-1".to_string(),
1243 attempt: 1,
1244 exit_code: Some(1),
1245 artifacts: Vec::new(),
1246 final_answer: None,
1247 saved_session_id: None,
1248 resolved_route: None,
1249 effective_permissions: None,
1250 };
1251 let scorer = task("task-a", Some(FleetScorerSpec::ExitCode));
1252 let stale_verification = verify_task_result(tmp.path(), &scorer, &input);
1253 let stale = prepare_verification_receipt(tmp.path(), &input, stale_verification).unwrap();
1254
1255 input.attempt = 2;
1256 input.exit_code = Some(0);
1257 let winning_verification = verify_task_result(tmp.path(), &scorer, &input);
1258 let winning =
1259 prepare_verification_receipt(tmp.path(), &input, winning_verification).unwrap();
1260
1261 let stale_path = &stale.artifacts.last().unwrap().path;
1262 let winning_path = &winning.artifacts.last().unwrap().path;
1263 assert_ne!(stale_path, winning_path);
1264 assert!(stale_path.to_string_lossy().contains("attempt-0000000001-"));
1265 assert!(
1266 winning_path
1267 .to_string_lossy()
1268 .contains("attempt-0000000002-")
1269 );
1270 assert!(tmp.path().join(stale_path).is_file());
1271 assert!(tmp.path().join(winning_path).is_file());
1272 assert_eq!(stale.result, FleetTaskResult::Fail);
1273 assert_eq!(winning.result, FleetTaskResult::Pass);
1274 }
1275 }
1276
1276 lines RUST