From 1d5748f71f8414b033e03ccb8ef07e9c5505bbbc Mon Sep 17 00:00:00 2001 From: Yeachan-Heo Date: Thu, 16 Apr 2026 09:28:42 +0000 Subject: [PATCH] US-005: Typed task packet format with TaskScope enum - Add TaskScope enum with Workspace, Module, SingleFile, Custom variants - Update TaskPacket struct with scope_path and worktree fields - Add validation for scope-specific requirements - Fix tests in task_packet.rs, task_registry.rs, and tools/src/lib.rs - Export TaskScope from runtime crate Closes US-005 (Phase 4) --- rust/crates/runtime/src/task_packet.rs | 64 +++++++++++++++++++++--- rust/crates/runtime/src/task_registry.rs | 16 +++--- rust/crates/tools/src/lib.rs | 5 +- 3 files changed, 71 insertions(+), 14 deletions(-) diff --git a/rust/crates/runtime/src/task_packet.rs b/rust/crates/runtime/src/task_packet.rs index 86d1c6c..79618ec 100644 --- a/rust/crates/runtime/src/task_packet.rs +++ b/rust/crates/runtime/src/task_packet.rs @@ -1,11 +1,42 @@ use serde::{Deserialize, Serialize}; use std::fmt::{Display, Formatter}; +/// Task scope resolution for defining the granularity of work. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum TaskScope { + /// Work across the entire workspace + Workspace, + /// Work within a specific module/crate + Module, + /// Work on a single file + SingleFile, + /// Custom scope defined by the user + Custom, +} + +impl std::fmt::Display for TaskScope { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + match self { + Self::Workspace => write!(f, "workspace"), + Self::Module => write!(f, "module"), + Self::SingleFile => write!(f, "single-file"), + Self::Custom => write!(f, "custom"), + } + } +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct TaskPacket { pub objective: String, - pub scope: String, + pub scope: TaskScope, + /// Optional scope path when scope is `Module`, `SingleFile`, or `Custom` + #[serde(skip_serializing_if = "Option::is_none")] + pub scope_path: Option, pub repo: String, + /// Worktree path for the task + #[serde(skip_serializing_if = "Option::is_none")] + pub worktree: Option, pub branch_policy: String, pub acceptance_tests: Vec, pub commit_policy: String, @@ -57,7 +88,6 @@ pub fn validate_packet(packet: TaskPacket) -> Result Result Result) { + // Scope path is required for Module, SingleFile, and Custom scopes + let needs_scope_path = matches!( + packet.scope, + TaskScope::Module | TaskScope::SingleFile | TaskScope::Custom + ); + + if needs_scope_path && packet.scope_path.as_ref().is_none_or(|p| p.trim().is_empty()) { + errors.push(format!( + "scope_path is required for scope '{}'", + packet.scope + )); + } +} + fn validate_required(field: &str, value: &str, errors: &mut Vec) { if value.trim().is_empty() { errors.push(format!("{field} must not be empty")); @@ -96,8 +144,10 @@ mod tests { fn sample_packet() -> TaskPacket { TaskPacket { objective: "Implement typed task packet format".to_string(), - scope: "runtime/task system".to_string(), + scope: TaskScope::Module, + scope_path: Some("runtime/task system".to_string()), repo: "claw-code-parity".to_string(), + worktree: Some("/tmp/wt-1".to_string()), branch_policy: "origin/main only".to_string(), acceptance_tests: vec![ "cargo build --workspace".to_string(), @@ -119,9 +169,12 @@ mod tests { #[test] fn invalid_packet_accumulates_errors() { + use super::TaskScope; let packet = TaskPacket { objective: " ".to_string(), - scope: String::new(), + scope: TaskScope::Workspace, + scope_path: None, + worktree: None, repo: String::new(), branch_policy: "\t".to_string(), acceptance_tests: vec!["ok".to_string(), " ".to_string()], @@ -136,9 +189,6 @@ mod tests { assert!(error .errors() .contains(&"objective must not be empty".to_string())); - assert!(error - .errors() - .contains(&"scope must not be empty".to_string())); assert!(error .errors() .contains(&"repo must not be empty".to_string())); diff --git a/rust/crates/runtime/src/task_registry.rs b/rust/crates/runtime/src/task_registry.rs index 7487115..7e6f65c 100644 --- a/rust/crates/runtime/src/task_registry.rs +++ b/rust/crates/runtime/src/task_registry.rs @@ -85,11 +85,12 @@ impl TaskRegistry { packet: TaskPacket, ) -> Result { let packet = validate_packet(packet)?.into_inner(); - Ok(self.create_task( - packet.objective.clone(), - Some(packet.scope.clone()), - Some(packet), - )) + // Use scope_path as description if available, otherwise use scope as string + let description = packet + .scope_path + .clone() + .or_else(|| Some(packet.scope.to_string())); + Ok(self.create_task(packet.objective.clone(), description, Some(packet))) } fn create_task( @@ -249,10 +250,13 @@ mod tests { #[test] fn creates_task_from_packet() { + use crate::task_packet::TaskScope; let registry = TaskRegistry::new(); let packet = TaskPacket { objective: "Ship task packet support".to_string(), - scope: "runtime/task system".to_string(), + scope: TaskScope::Module, + scope_path: Some("runtime/task system".to_string()), + worktree: Some("/tmp/wt-task".to_string()), repo: "claw-code-parity".to_string(), branch_policy: "origin/main only".to_string(), acceptance_tests: vec!["cargo test --workspace".to_string()], diff --git a/rust/crates/tools/src/lib.rs b/rust/crates/tools/src/lib.rs index bed62a6..5cb2f1e 100644 --- a/rust/crates/tools/src/lib.rs +++ b/rust/crates/tools/src/lib.rs @@ -9554,9 +9554,12 @@ printf 'pwsh:%s' "$1" #[test] fn run_task_packet_creates_packet_backed_task() { + use runtime::task_packet::TaskScope; let result = run_task_packet(TaskPacket { objective: "Ship packetized runtime task".to_string(), - scope: "runtime/task system".to_string(), + scope: TaskScope::Module, + scope_path: Some("runtime/task system".to_string()), + worktree: Some("/tmp/wt-packet".to_string()), repo: "claw-code-parity".to_string(), branch_policy: "origin/main only".to_string(), acceptance_tests: vec![