diff --git a/rust/crates/runtime/src/lib.rs b/rust/crates/runtime/src/lib.rs index e0f3ba3..243a478 100644 --- a/rust/crates/runtime/src/lib.rs +++ b/rust/crates/runtime/src/lib.rs @@ -135,8 +135,7 @@ pub use stale_branch::{ StaleBranchPolicy, }; pub use task_packet::{ - validate_packet, AcceptanceTest, BranchPolicy, CommitPolicy, RepoConfig, ReportingContract, - TaskPacket, TaskPacketValidationError, TaskScope, ValidatedPacket, + validate_packet, TaskPacket, TaskPacketValidationError, ValidatedPacket, }; pub use trust_resolver::{TrustConfig, TrustDecision, TrustEvent, TrustPolicy, TrustResolver}; pub use usage::{ diff --git a/rust/crates/runtime/src/task_packet.rs b/rust/crates/runtime/src/task_packet.rs index 5ce58fe..2170f22 100644 --- a/rust/crates/runtime/src/task_packet.rs +++ b/rust/crates/runtime/src/task_packet.rs @@ -1,188 +1,16 @@ use serde::{Deserialize, Serialize}; -use serde_json::Value as JsonValue; -use std::collections::BTreeMap; use std::fmt::{Display, Formatter}; -use std::path::{Path, PathBuf}; #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct RepoConfig { - pub repo_root: PathBuf, - pub worktree_root: Option, -} - -impl RepoConfig { - #[must_use] - pub fn dispatch_root(&self) -> &Path { - self.worktree_root - .as_deref() - .unwrap_or(self.repo_root.as_path()) - } -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum TaskScope { - SingleFile { path: PathBuf }, - Module { crate_name: String }, - Workspace, - Custom { paths: Vec }, -} - -impl TaskScope { - #[must_use] - pub fn resolve_paths(&self, repo_config: &RepoConfig) -> Vec { - let dispatch_root = repo_config.dispatch_root(); - match self { - Self::SingleFile { path } => vec![resolve_path(dispatch_root, path)], - Self::Module { crate_name } => vec![dispatch_root.join("crates").join(crate_name)], - Self::Workspace => vec![dispatch_root.to_path_buf()], - Self::Custom { paths } => paths - .iter() - .map(|path| resolve_path(dispatch_root, path)) - .collect(), - } - } -} - -impl Display for TaskScope { - fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { - match self { - Self::SingleFile { .. } => write!(f, "single_file"), - Self::Module { .. } => write!(f, "module"), - Self::Workspace => write!(f, "workspace"), - Self::Custom { .. } => write!(f, "custom"), - } - } -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum BranchPolicy { - CreateNew { prefix: String }, - UseExisting { name: String }, - WorktreeIsolated, -} - -impl Display for BranchPolicy { - fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { - match self { - Self::CreateNew { .. } => write!(f, "create_new"), - Self::UseExisting { .. } => write!(f, "use_existing"), - Self::WorktreeIsolated => write!(f, "worktree_isolated"), - } - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum CommitPolicy { - CommitPerTask, - SquashOnMerge, - NoAutoCommit, -} - -impl Display for CommitPolicy { - fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { - match self { - Self::CommitPerTask => write!(f, "commit_per_task"), - Self::SquashOnMerge => write!(f, "squash_on_merge"), - Self::NoAutoCommit => write!(f, "no_auto_commit"), - } - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum GreenLevel { - Package, - Workspace, - MergeReady, -} - -impl Display for GreenLevel { - fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { - match self { - Self::Package => write!(f, "package"), - Self::Workspace => write!(f, "workspace"), - Self::MergeReady => write!(f, "merge_ready"), - } - } -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum AcceptanceTest { - CargoTest { filter: Option }, - CustomCommand { cmd: String }, - GreenLevel { level: GreenLevel }, -} - -impl Display for AcceptanceTest { - fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { - match self { - Self::CargoTest { .. } => write!(f, "cargo_test"), - Self::CustomCommand { .. } => write!(f, "custom_command"), - Self::GreenLevel { .. } => write!(f, "green_level"), - } - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum ReportingContract { - EventStream, - Summary, - Silent, -} - -impl Display for ReportingContract { - fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { - match self { - Self::EventStream => write!(f, "event_stream"), - Self::Summary => write!(f, "summary"), - Self::Silent => write!(f, "silent"), - } - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum EscalationPolicy { - RetryThenEscalate { max_retries: u32 }, - AutoEscalate, - NeverEscalate, -} - -impl Display for EscalationPolicy { - fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { - match self { - Self::RetryThenEscalate { .. } => write!(f, "retry_then_escalate"), - Self::AutoEscalate => write!(f, "auto_escalate"), - Self::NeverEscalate => write!(f, "never_escalate"), - } - } -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct TaskPacket { - pub id: String, pub objective: String, - pub scope: TaskScope, - pub repo_config: RepoConfig, - pub branch_policy: BranchPolicy, - pub acceptance_tests: Vec, - pub commit_policy: CommitPolicy, - pub reporting: ReportingContract, - pub escalation: EscalationPolicy, - pub created_at: u64, - pub metadata: BTreeMap, -} - -impl TaskPacket { - #[must_use] - pub fn resolve_scope_paths(&self) -> Vec { - self.scope.resolve_paths(&self.repo_config) - } + pub scope: String, + pub repo: String, + pub branch_policy: String, + pub acceptance_tests: Vec, + pub commit_policy: String, + pub reporting_contract: String, + pub escalation_policy: String, } #[derive(Debug, Clone, PartialEq, Eq)] @@ -210,7 +38,7 @@ impl Display for TaskPacketValidationError { impl std::error::Error for TaskPacketValidationError {} -#[derive(Debug, Clone, PartialEq)] +#[derive(Debug, Clone, PartialEq, Eq)] pub struct ValidatedPacket(TaskPacket); impl ValidatedPacket { @@ -223,42 +51,35 @@ impl ValidatedPacket { pub fn into_inner(self) -> TaskPacket { self.0 } - - #[must_use] - pub fn resolve_scope_paths(&self) -> Vec { - self.0.resolve_scope_paths() - } } pub fn validate_packet(packet: TaskPacket) -> Result { let mut errors = Vec::new(); - if packet.id.trim().is_empty() { - errors.push("packet id must not be empty".to_string()); - } + validate_required("objective", &packet.objective, &mut errors); + validate_required("scope", &packet.scope, &mut errors); + validate_required("repo", &packet.repo, &mut errors); + validate_required("branch_policy", &packet.branch_policy, &mut errors); + validate_required("commit_policy", &packet.commit_policy, &mut errors); + validate_required( + "reporting_contract", + &packet.reporting_contract, + &mut errors, + ); + validate_required( + "escalation_policy", + &packet.escalation_policy, + &mut errors, + ); - if packet.objective.trim().is_empty() { - errors.push("packet objective must not be empty".to_string()); + for (index, test) in packet.acceptance_tests.iter().enumerate() { + if test.trim().is_empty() { + errors.push(format!( + "acceptance_tests contains an empty value at index {index}" + )); + } } - if packet.repo_config.repo_root.as_os_str().is_empty() { - errors.push("repo_config repo_root must not be empty".to_string()); - } - - if packet - .repo_config - .worktree_root - .as_ref() - .is_some_and(|path| path.as_os_str().is_empty()) - { - errors.push("repo_config worktree_root must not be empty when present".to_string()); - } - - validate_scope(&packet.scope, &mut errors); - validate_branch_policy(&packet.branch_policy, &mut errors); - validate_acceptance_tests(&packet.acceptance_tests, &mut errors); - validate_escalation_policy(packet.escalation, &mut errors); - if errors.is_empty() { Ok(ValidatedPacket(packet)) } else { @@ -266,326 +87,76 @@ pub fn validate_packet(packet: TaskPacket) -> Result) { - match scope { - TaskScope::SingleFile { path } if path.as_os_str().is_empty() => { - errors.push("single_file scope path must not be empty".to_string()); - } - TaskScope::Module { crate_name } if crate_name.trim().is_empty() => { - errors.push("module scope crate_name must not be empty".to_string()); - } - TaskScope::Custom { paths } if paths.is_empty() => { - errors.push("custom scope paths must not be empty".to_string()); - } - TaskScope::Custom { paths } => { - for (index, path) in paths.iter().enumerate() { - if path.as_os_str().is_empty() { - errors.push(format!("custom scope contains empty path at index {index}")); - } - } - } - TaskScope::SingleFile { .. } | TaskScope::Module { .. } | TaskScope::Workspace => {} - } -} - -fn validate_branch_policy(branch_policy: &BranchPolicy, errors: &mut Vec) { - match branch_policy { - BranchPolicy::CreateNew { prefix } if prefix.trim().is_empty() => { - errors.push("create_new branch prefix must not be empty".to_string()); - } - BranchPolicy::UseExisting { name } if name.trim().is_empty() => { - errors.push("use_existing branch name must not be empty".to_string()); - } - BranchPolicy::CreateNew { .. } - | BranchPolicy::UseExisting { .. } - | BranchPolicy::WorktreeIsolated => {} - } -} - -fn validate_acceptance_tests(tests: &[AcceptanceTest], errors: &mut Vec) { - for test in tests { - match test { - AcceptanceTest::CargoTest { filter } => { - if filter - .as_deref() - .is_some_and(|value| value.trim().is_empty()) - { - errors.push("cargo_test filter must not be empty when present".to_string()); - } - } - AcceptanceTest::CustomCommand { cmd } if cmd.trim().is_empty() => { - errors.push("custom_command cmd must not be empty".to_string()); - } - AcceptanceTest::CustomCommand { .. } | AcceptanceTest::GreenLevel { .. } => {} - } - } -} - -fn validate_escalation_policy(escalation: EscalationPolicy, errors: &mut Vec) { - if matches!( - escalation, - EscalationPolicy::RetryThenEscalate { max_retries: 0 } - ) { - errors.push("retry_then_escalate max_retries must be greater than zero".to_string()); - } -} - -fn resolve_path(dispatch_root: &Path, path: &Path) -> PathBuf { - if path.is_absolute() { - path.to_path_buf() - } else { - dispatch_root.join(path) +fn validate_required(field: &str, value: &str, errors: &mut Vec) { + if value.trim().is_empty() { + errors.push(format!("{field} must not be empty")); } } #[cfg(test)] mod tests { use super::*; - use serde_json::json; - use std::time::{SystemTime, UNIX_EPOCH}; - - fn now_secs() -> u64 { - SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_default() - .as_secs() - } - - fn sample_repo_config() -> RepoConfig { - RepoConfig { - repo_root: PathBuf::from("/repo"), - worktree_root: Some(PathBuf::from("/repo/.worktrees/task-1")), - } - } fn sample_packet() -> TaskPacket { - let mut metadata = BTreeMap::new(); - metadata.insert("attempt".to_string(), json!(1)); - metadata.insert("lane".to_string(), json!("runtime")); - TaskPacket { - id: "packet_001".to_string(), objective: "Implement typed task packet format".to_string(), - scope: TaskScope::Module { - crate_name: "runtime".to_string(), - }, - repo_config: sample_repo_config(), - branch_policy: BranchPolicy::CreateNew { - prefix: "ultraclaw".to_string(), - }, + scope: "runtime/task system".to_string(), + repo: "claw-code-parity".to_string(), + branch_policy: "origin/main only".to_string(), acceptance_tests: vec![ - AcceptanceTest::CargoTest { - filter: Some("task_packet".to_string()), - }, - AcceptanceTest::GreenLevel { - level: GreenLevel::Workspace, - }, + "cargo build --workspace".to_string(), + "cargo test --workspace".to_string(), ], - commit_policy: CommitPolicy::CommitPerTask, - reporting: ReportingContract::EventStream, - escalation: EscalationPolicy::RetryThenEscalate { max_retries: 2 }, - created_at: now_secs(), - metadata, + commit_policy: "single verified commit".to_string(), + reporting_contract: "print build result, test result, commit sha".to_string(), + escalation_policy: "stop only on destructive ambiguity".to_string(), } } #[test] fn valid_packet_passes_validation() { - // given let packet = sample_packet(); - - // when - let validated = validate_packet(packet); - - // then - assert!(validated.is_ok()); + let validated = validate_packet(packet.clone()).expect("packet should validate"); + assert_eq!(validated.packet(), &packet); + assert_eq!(validated.into_inner(), packet); } #[test] fn invalid_packet_accumulates_errors() { - // given let packet = TaskPacket { - id: " ".to_string(), objective: " ".to_string(), - scope: TaskScope::Custom { - paths: vec![PathBuf::new()], - }, - repo_config: RepoConfig { - repo_root: PathBuf::new(), - worktree_root: Some(PathBuf::new()), - }, - branch_policy: BranchPolicy::CreateNew { - prefix: " ".to_string(), - }, - acceptance_tests: vec![ - AcceptanceTest::CargoTest { - filter: Some(" ".to_string()), - }, - AcceptanceTest::CustomCommand { - cmd: " ".to_string(), - }, - ], - commit_policy: CommitPolicy::NoAutoCommit, - reporting: ReportingContract::Silent, - escalation: EscalationPolicy::RetryThenEscalate { max_retries: 0 }, - created_at: 0, - metadata: BTreeMap::new(), + scope: String::new(), + repo: String::new(), + branch_policy: "\t".to_string(), + acceptance_tests: vec!["ok".to_string(), " ".to_string()], + commit_policy: String::new(), + reporting_contract: String::new(), + escalation_policy: String::new(), }; - // when let error = validate_packet(packet).expect_err("packet should be rejected"); - // then - assert!(error.errors().len() >= 8); + assert!(error.errors().len() >= 7); assert!(error .errors() - .contains(&"packet id must not be empty".to_string())); + .contains(&"objective must not be empty".to_string())); assert!(error .errors() - .contains(&"packet objective must not be empty".to_string())); + .contains(&"scope must not be empty".to_string())); assert!(error .errors() - .contains(&"repo_config repo_root must not be empty".to_string())); - assert!(error - .errors() - .contains(&"create_new branch prefix must not be empty".to_string())); - } - - #[test] - fn single_file_scope_resolves_against_worktree_root() { - // given - let repo_config = sample_repo_config(); - let scope = TaskScope::SingleFile { - path: PathBuf::from("crates/runtime/src/task_packet.rs"), - }; - - // when - let paths = scope.resolve_paths(&repo_config); - - // then - assert_eq!( - paths, - vec![PathBuf::from( - "/repo/.worktrees/task-1/crates/runtime/src/task_packet.rs" - )] - ); - } - - #[test] - fn workspace_scope_resolves_to_dispatch_root() { - // given - let repo_config = sample_repo_config(); - let scope = TaskScope::Workspace; - - // when - let paths = scope.resolve_paths(&repo_config); - - // then - assert_eq!(paths, vec![PathBuf::from("/repo/.worktrees/task-1")]); - } - - #[test] - fn module_scope_resolves_to_crate_directory() { - // given - let repo_config = sample_repo_config(); - let scope = TaskScope::Module { - crate_name: "runtime".to_string(), - }; - - // when - let paths = scope.resolve_paths(&repo_config); - - // then - assert_eq!( - paths, - vec![PathBuf::from("/repo/.worktrees/task-1/crates/runtime")] - ); - } - - #[test] - fn custom_scope_preserves_absolute_paths_and_resolves_relative_paths() { - // given - let repo_config = sample_repo_config(); - let scope = TaskScope::Custom { - paths: vec![ - PathBuf::from("Cargo.toml"), - PathBuf::from("/tmp/shared/script.sh"), - ], - }; - - // when - let paths = scope.resolve_paths(&repo_config); - - // then - assert_eq!( - paths, - vec![ - PathBuf::from("/repo/.worktrees/task-1/Cargo.toml"), - PathBuf::from("/tmp/shared/script.sh"), - ] - ); + .contains(&"repo must not be empty".to_string())); + assert!(error.errors().contains( + &"acceptance_tests contains an empty value at index 1".to_string() + )); } #[test] fn serialization_roundtrip_preserves_packet() { - // given let packet = sample_packet(); - - // when let serialized = serde_json::to_string(&packet).expect("packet should serialize"); let deserialized: TaskPacket = serde_json::from_str(&serialized).expect("packet should deserialize"); - - // then assert_eq!(deserialized, packet); } - - #[test] - fn validated_packet_exposes_inner_packet_and_scope_paths() { - // given - let packet = sample_packet(); - - // when - let validated = validate_packet(packet.clone()).expect("packet should validate"); - let resolved_paths = validated.resolve_scope_paths(); - let inner = validated.into_inner(); - - // then - assert_eq!( - resolved_paths, - vec![PathBuf::from("/repo/.worktrees/task-1/crates/runtime")] - ); - assert_eq!(inner, packet); - } - - #[test] - fn display_impls_render_snake_case_variants() { - // given - let rendered = vec![ - TaskScope::Workspace.to_string(), - BranchPolicy::WorktreeIsolated.to_string(), - CommitPolicy::SquashOnMerge.to_string(), - GreenLevel::MergeReady.to_string(), - AcceptanceTest::GreenLevel { - level: GreenLevel::Package, - } - .to_string(), - ReportingContract::EventStream.to_string(), - EscalationPolicy::AutoEscalate.to_string(), - ]; - - // when - let expected = vec![ - "workspace", - "worktree_isolated", - "squash_on_merge", - "merge_ready", - "green_level", - "event_stream", - "auto_escalate", - ]; - - // then - assert_eq!(rendered, expected); - } } diff --git a/rust/crates/runtime/src/task_registry.rs b/rust/crates/runtime/src/task_registry.rs index 69f088c..4505f86 100644 --- a/rust/crates/runtime/src/task_registry.rs +++ b/rust/crates/runtime/src/task_registry.rs @@ -6,6 +6,8 @@ use std::time::{SystemTime, UNIX_EPOCH}; use serde::{Deserialize, Serialize}; +use crate::{validate_packet, TaskPacket, TaskPacketValidationError}; + #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum TaskStatus { @@ -33,6 +35,7 @@ pub struct Task { pub task_id: String, pub prompt: String, pub description: Option, + pub task_packet: Option, pub status: TaskStatus, pub created_at: u64, pub updated_at: u64, @@ -73,14 +76,40 @@ impl TaskRegistry { } pub fn create(&self, prompt: &str, description: Option<&str>) -> Task { + self.create_task( + prompt.to_owned(), + description.map(str::to_owned), + None, + ) + } + + pub fn create_from_packet( + &self, + packet: TaskPacket, + ) -> Result { + let packet = validate_packet(packet)?.into_inner(); + Ok(self.create_task( + packet.objective.clone(), + Some(packet.scope.clone()), + Some(packet), + )) + } + + fn create_task( + &self, + prompt: String, + description: Option, + task_packet: Option, + ) -> Task { let mut inner = self.inner.lock().expect("registry lock poisoned"); inner.counter += 1; let ts = now_secs(); let task_id = format!("task_{:08x}_{}", ts, inner.counter); let task = Task { task_id: task_id.clone(), - prompt: prompt.to_owned(), - description: description.map(str::to_owned), + prompt, + description, + task_packet, status: TaskStatus::Created, created_at: ts, updated_at: ts, @@ -215,11 +244,38 @@ mod tests { assert_eq!(task.status, TaskStatus::Created); assert_eq!(task.prompt, "Do something"); assert_eq!(task.description.as_deref(), Some("A test task")); + assert_eq!(task.task_packet, None); let fetched = registry.get(&task.task_id).expect("task should exist"); assert_eq!(fetched.task_id, task.task_id); } + #[test] + fn creates_task_from_packet() { + let registry = TaskRegistry::new(); + let packet = TaskPacket { + objective: "Ship task packet support".to_string(), + scope: "runtime/task system".to_string(), + repo: "claw-code-parity".to_string(), + branch_policy: "origin/main only".to_string(), + acceptance_tests: vec!["cargo test --workspace".to_string()], + commit_policy: "single commit".to_string(), + reporting_contract: "print commit sha".to_string(), + escalation_policy: "manual escalation".to_string(), + }; + + let task = registry + .create_from_packet(packet.clone()) + .expect("packet-backed task should be created"); + + assert_eq!(task.prompt, packet.objective); + assert_eq!(task.description.as_deref(), Some("runtime/task system")); + assert_eq!(task.task_packet, Some(packet.clone())); + + let fetched = registry.get(&task.task_id).expect("task should exist"); + assert_eq!(fetched.task_packet, Some(packet)); + } + #[test] fn lists_tasks_with_optional_filter() { let registry = TaskRegistry::new(); @@ -417,6 +473,7 @@ mod tests { // then assert!(task.task_id.starts_with("task_")); assert_eq!(task.description, None); + assert_eq!(task.task_packet, None); assert!(task.messages.is_empty()); assert!(task.output.is_empty()); assert_eq!(task.team_id, None); diff --git a/rust/crates/tools/src/lib.rs b/rust/crates/tools/src/lib.rs index 9d7a2bb..e686899 100644 --- a/rust/crates/tools/src/lib.rs +++ b/rust/crates/tools/src/lib.rs @@ -17,6 +17,7 @@ use runtime::{ permission_enforcer::{EnforcementResult, PermissionEnforcer}, read_file, summary_compression::compress_summary_text, + TaskPacket, task_registry::TaskRegistry, team_cron_registry::{CronRegistry, TeamRegistry}, worker_boot::{WorkerReadySnapshot, WorkerRegistry}, @@ -755,6 +756,38 @@ pub fn mvp_tool_specs() -> Vec { }), required_permission: PermissionMode::DangerFullAccess, }, + ToolSpec { + name: "RunTaskPacket", + description: "Create a background task from a structured task packet.", + input_schema: json!({ + "type": "object", + "properties": { + "objective": { "type": "string" }, + "scope": { "type": "string" }, + "repo": { "type": "string" }, + "branch_policy": { "type": "string" }, + "acceptance_tests": { + "type": "array", + "items": { "type": "string" } + }, + "commit_policy": { "type": "string" }, + "reporting_contract": { "type": "string" }, + "escalation_policy": { "type": "string" } + }, + "required": [ + "objective", + "scope", + "repo", + "branch_policy", + "acceptance_tests", + "commit_policy", + "reporting_contract", + "escalation_policy" + ], + "additionalProperties": false + }), + required_permission: PermissionMode::DangerFullAccess, + }, ToolSpec { name: "TaskGet", description: "Get the status and details of a background task by ID.", @@ -1177,6 +1210,7 @@ fn execute_tool_with_enforcer( from_value::(input).and_then(run_ask_user_question) } "TaskCreate" => from_value::(input).and_then(run_task_create), + "RunTaskPacket" => from_value::(input).and_then(run_task_packet), "TaskGet" => from_value::(input).and_then(run_task_get), "TaskList" => run_task_list(input.clone()), "TaskStop" => from_value::(input).and_then(run_task_stop), @@ -1285,6 +1319,24 @@ fn run_task_create(input: TaskCreateInput) -> Result { "status": task.status, "prompt": task.prompt, "description": task.description, + "task_packet": task.task_packet, + "created_at": task.created_at + })) +} + +#[allow(clippy::needless_pass_by_value)] +fn run_task_packet(input: TaskPacket) -> Result { + let registry = global_task_registry(); + let task = registry + .create_from_packet(input) + .map_err(|error| error.to_string())?; + + to_pretty_json(json!({ + "task_id": task.task_id, + "status": task.status, + "prompt": task.prompt, + "description": task.description, + "task_packet": task.task_packet, "created_at": task.created_at })) } @@ -1298,6 +1350,7 @@ fn run_task_get(input: TaskIdInput) -> Result { "status": task.status, "prompt": task.prompt, "description": task.description, + "task_packet": task.task_packet, "created_at": task.created_at, "updated_at": task.updated_at, "messages": task.messages, @@ -1318,6 +1371,7 @@ fn run_task_list(_input: Value) -> Result { "status": t.status, "prompt": t.prompt, "description": t.description, + "task_packet": t.task_packet, "created_at": t.created_at, "updated_at": t.updated_at, "team_id": t.team_id @@ -4897,13 +4951,14 @@ mod tests { use super::{ agent_permission_policy, allowed_tools_for_subagent, classify_lane_failure, execute_agent_with_spawn, execute_tool, final_assistant_text, mvp_tool_specs, - permission_mode_from_plugin, persist_agent_terminal_state, push_output_block, AgentInput, - AgentJob, GlobalToolRegistry, LaneEventName, LaneFailureClass, SubagentToolExecutor, + permission_mode_from_plugin, persist_agent_terminal_state, push_output_block, + run_task_packet, AgentInput, AgentJob, GlobalToolRegistry, LaneEventName, + LaneFailureClass, SubagentToolExecutor, }; use api::OutputContentBlock; use runtime::{ permission_enforcer::PermissionEnforcer, ApiRequest, AssistantEvent, ConversationRuntime, - PermissionMode, PermissionPolicy, RuntimeError, Session, ToolExecutor, + PermissionMode, PermissionPolicy, RuntimeError, Session, TaskPacket, ToolExecutor, }; use serde_json::json; @@ -6996,6 +7051,34 @@ printf 'pwsh:%s' "$1" assert_eq!(output["stdout"], "ok"); } + #[test] + fn run_task_packet_creates_packet_backed_task() { + let result = run_task_packet(TaskPacket { + objective: "Ship packetized runtime task".to_string(), + scope: "runtime/task system".to_string(), + repo: "claw-code-parity".to_string(), + branch_policy: "origin/main only".to_string(), + acceptance_tests: vec![ + "cargo build --workspace".to_string(), + "cargo test --workspace".to_string(), + ], + commit_policy: "single commit".to_string(), + reporting_contract: "print build/test result and sha".to_string(), + escalation_policy: "manual escalation".to_string(), + }) + .expect("task packet should create a task"); + + let output: serde_json::Value = serde_json::from_str(&result).expect("json"); + assert_eq!(output["status"], "created"); + assert_eq!(output["prompt"], "Ship packetized runtime task"); + assert_eq!(output["description"], "runtime/task system"); + assert_eq!(output["task_packet"]["repo"], "claw-code-parity"); + assert_eq!( + output["task_packet"]["acceptance_tests"][1], + "cargo test --workspace" + ); + } + struct TestServer { addr: SocketAddr, shutdown: Option>,