Browse Source

merge: ultraclaw/task-packet into main

Jobdori 2 tháng trước cách đây
mục cha
commit
ba08b0eb93

+ 6 - 0
rust/crates/runtime/src/lib.rs

@@ -22,6 +22,7 @@ mod session;
 mod sse;
 pub mod stale_branch;
 pub mod task_registry;
+pub mod task_packet;
 pub mod team_cron_registry;
 mod usage;
 pub mod worker_boot;
@@ -105,6 +106,11 @@ pub use stale_branch::{
     StaleBranchPolicy,
 };
 pub use sse::{IncrementalSseParser, SseEvent};
+pub use task_packet::{
+    validate_packet, AcceptanceTest, BranchPolicy, CommitPolicy, EscalationPolicy, GreenLevel,
+    RepoConfig, ReportingContract, TaskPacket, TaskPacketValidationError, TaskScope,
+    ValidatedPacket,
+};
 pub use worker_boot::{
     Worker, WorkerEvent, WorkerEventKind, WorkerFailure, WorkerFailureKind, WorkerReadySnapshot,
     WorkerRegistry, WorkerStatus,

+ 591 - 0
rust/crates/runtime/src/task_packet.rs

@@ -0,0 +1,591 @@
+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<PathBuf>,
+}
+
+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<PathBuf> },
+}
+
+impl TaskScope {
+    #[must_use]
+    pub fn resolve_paths(&self, repo_config: &RepoConfig) -> Vec<PathBuf> {
+        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<String> },
+    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<AcceptanceTest>,
+    pub commit_policy: CommitPolicy,
+    pub reporting: ReportingContract,
+    pub escalation: EscalationPolicy,
+    pub created_at: u64,
+    pub metadata: BTreeMap<String, JsonValue>,
+}
+
+impl TaskPacket {
+    #[must_use]
+    pub fn resolve_scope_paths(&self) -> Vec<PathBuf> {
+        self.scope.resolve_paths(&self.repo_config)
+    }
+}
+
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct TaskPacketValidationError {
+    errors: Vec<String>,
+}
+
+impl TaskPacketValidationError {
+    #[must_use]
+    pub fn new(errors: Vec<String>) -> Self {
+        Self { errors }
+    }
+
+    #[must_use]
+    pub fn errors(&self) -> &[String] {
+        &self.errors
+    }
+}
+
+impl Display for TaskPacketValidationError {
+    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
+        write!(f, "{}", self.errors.join("; "))
+    }
+}
+
+impl std::error::Error for TaskPacketValidationError {}
+
+#[derive(Debug, Clone, PartialEq)]
+pub struct ValidatedPacket(TaskPacket);
+
+impl ValidatedPacket {
+    #[must_use]
+    pub fn packet(&self) -> &TaskPacket {
+        &self.0
+    }
+
+    #[must_use]
+    pub fn into_inner(self) -> TaskPacket {
+        self.0
+    }
+
+    #[must_use]
+    pub fn resolve_scope_paths(&self) -> Vec<PathBuf> {
+        self.0.resolve_scope_paths()
+    }
+}
+
+pub fn validate_packet(packet: TaskPacket) -> Result<ValidatedPacket, TaskPacketValidationError> {
+    let mut errors = Vec::new();
+
+    if packet.id.trim().is_empty() {
+        errors.push("packet id must not be empty".to_string());
+    }
+
+    if packet.objective.trim().is_empty() {
+        errors.push("packet objective must not be empty".to_string());
+    }
+
+    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 {
+        Err(TaskPacketValidationError::new(errors))
+    }
+}
+
+fn validate_scope(scope: &TaskScope, errors: &mut Vec<String>) {
+    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<String>) {
+    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<String>) {
+    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<String>) {
+    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)
+    }
+}
+
+#[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(),
+            },
+            acceptance_tests: vec![
+                AcceptanceTest::CargoTest {
+                    filter: Some("task_packet".to_string()),
+                },
+                AcceptanceTest::GreenLevel {
+                    level: GreenLevel::Workspace,
+                },
+            ],
+            commit_policy: CommitPolicy::CommitPerTask,
+            reporting: ReportingContract::EventStream,
+            escalation: EscalationPolicy::RetryThenEscalate { max_retries: 2 },
+            created_at: now_secs(),
+            metadata,
+        }
+    }
+
+    #[test]
+    fn valid_packet_passes_validation() {
+        // given
+        let packet = sample_packet();
+
+        // when
+        let validated = validate_packet(packet);
+
+        // then
+        assert!(validated.is_ok());
+    }
+
+    #[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(),
+        };
+
+        // when
+        let error = validate_packet(packet).expect_err("packet should be rejected");
+
+        // then
+        assert!(error.errors().len() >= 8);
+        assert!(error
+            .errors()
+            .contains(&"packet id must not be empty".to_string()));
+        assert!(error
+            .errors()
+            .contains(&"packet objective 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"),
+            ]
+        );
+    }
+
+    #[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);
+    }
+}

+ 1 - 0
rust/crates/rusty-claude-cli/.claw/sessions/session-newer.jsonl

@@ -0,0 +1 @@
+{"created_at_ms":1775230717464,"session_id":"session-1775230717464-3","type":"session_meta","updated_at_ms":1775230717464,"version":1}