use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use tokio::sync::oneshot;
use crate::error::DriverError;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct TaskRequest {
pub description: String,
pub subagent_type: Option<String>,
pub role: Option<String>,
pub profile: Option<String>,
pub model: Option<String>,
pub model_strength: Option<String>,
pub thinking: Option<String>,
pub cwd: Option<String>,
pub worktree: bool,
#[serde(default)]
pub write_authority: Option<String>,
#[serde(default)]
pub write_roots: Vec<String>,
#[serde(default)]
pub exact_files: Vec<String>,
#[serde(default)]
pub coordination_contracts: Vec<String>,
#[serde(default)]
pub dependencies: Vec<String>,
#[serde(default)]
pub acceptance: Vec<String>,
pub allowed_tools: Option<Vec<String>>,
#[serde(default)]
pub disallowed_tools: Vec<String>,
pub max_depth: Option<u32>,
pub token_budget: Option<u64>,
pub max_steps: Option<u32>,
pub wall_time_secs: Option<u64>,
pub response_schema: Option<serde_json::Value>,
pub label: Option<String>,
pub phase: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum TaskCompletion {
Completed { text: String },
Failed { message: String },
Cancelled,
BudgetExhausted { message: String },
}
#[derive(Debug)]
pub struct SpawnedTask {
pub task_id: String,
pub completion: oneshot::Receiver<TaskCompletion>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct BudgetSnapshot {
pub total: Option<u64>,
pub spent: u64,
}
impl BudgetSnapshot {
pub fn remaining(&self) -> Option<u64> {
self.total.map(|total| total.saturating_sub(self.spent))
}
pub fn exhausted(&self) -> bool {
matches!(self.total, Some(total) if self.spent >= total)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ProgressEvent {
Log {
message: String,
},
Phase {
title: String,
},
TaskSchemaValidationFailed {
task_id: String,
message: String,
},
}
#[async_trait]
pub trait WorkflowDriver: Send + Sync {
async fn spawn_task(&self, request: TaskRequest) -> Result<SpawnedTask, DriverError>;
fn cancel_all(&self);
fn budget(&self) -> BudgetSnapshot;
fn progress(&self, event: ProgressEvent);
}
pub fn normalize_profile(raw: &str) -> Result<String, String> {
let normalized = raw.trim().to_lowercase();
let invalid = normalized.is_empty()
|| normalized
.chars()
.any(|ch| ch.is_whitespace() || matches!(ch, '"' | '\'' | '`' | '='));
if invalid {
return Err(format!(
"invalid profile token {raw:?}: profiles must be non-empty and contain no whitespace, quotes, backticks, or '='"
));
}
Ok(normalized)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn normalize_profile_trims_and_lowercases() {
assert_eq!(normalize_profile(" ALpha-1 ").unwrap(), "alpha-1");
}
#[test]
fn normalize_profile_rejects_bad_tokens() {
for bad in ["", " ", "two words", "a=b", "a\"b", "a'b", "a`b"] {
assert!(
normalize_profile(bad).is_err(),
"expected rejection: {bad:?}"
);
}
}
#[test]
fn budget_snapshot_math() {
let unbounded = BudgetSnapshot {
total: None,
spent: 10,
};
assert_eq!(unbounded.remaining(), None);
assert!(!unbounded.exhausted());
let pool = BudgetSnapshot {
total: Some(100),
spent: 40,
};
assert_eq!(pool.remaining(), Some(60));
assert!(!pool.exhausted());
let drained = BudgetSnapshot {
total: Some(100),
spent: 120,
};
assert_eq!(drained.remaining(), Some(0));
assert!(drained.exhausted());
}
#[test]
fn legacy_task_request_defaults_new_coordination_fields() {
let legacy = serde_json::json!({
"description": "inspect the candidate",
"subagent_type": null,
"role": "reviewer",
"profile": null,
"model": null,
"model_strength": null,
"thinking": null,
"cwd": null,
"worktree": false,
"allowed_tools": null,
"max_depth": null,
"token_budget": null,
"max_steps": null,
"wall_time_secs": null,
"response_schema": null,
"label": null,
"phase": null
});
let request: TaskRequest = serde_json::from_value(legacy).unwrap();
assert_eq!(request.write_authority, None);
assert!(request.write_roots.is_empty());
assert!(request.exact_files.is_empty());
assert!(request.coordination_contracts.is_empty());
assert!(request.dependencies.is_empty());
assert!(request.acceptance.is_empty());
}
}