From c2750f86ebd6ac5c04b70dd8249501262d6dd07c Mon Sep 17 00:00:00 2001 From: soryu Date: Sun, 1 Feb 2026 01:35:18 +0000 Subject: [WIP] Heartbeat checkpoint - 2026-02-01 01:35:18 UTC --- makima/src/bin/makima.rs | 4 +- makima/src/daemon/api/contract.rs | 178 +++++++++++++++++++++++++++++++- makima/src/daemon/cli/contract.rs | 208 +++++++++++++++++++++++++++++++++++++- makima/src/daemon/cli/mod.rs | 21 ++++ 4 files changed, 408 insertions(+), 3 deletions(-) diff --git a/makima/src/bin/makima.rs b/makima/src/bin/makima.rs index ac577b8..2795e5e 100644 --- a/makima/src/bin/makima.rs +++ b/makima/src/bin/makima.rs @@ -6,7 +6,8 @@ use std::sync::Arc; use makima::daemon::api::{ApiClient, CreateContractRequest}; use makima::daemon::cli::{ - Cli, CliConfig, Commands, ConfigCommand, ContractCommand, RedTeamCommand, SupervisorCommand, ViewArgs, + Cli, CliConfig, Commands, ConfigCommand, ContractCommand, ContractsCommand, RedTeamCommand, SupervisorCommand, ViewArgs, + contract::{ListArgs, ListOutputFormat}, }; use makima::daemon::tui::{self, Action, App, ListItem, ViewType, TuiWsClient, WsEvent, OutputLine, OutputMessageType, WsConnectionState, RepositorySuggestion}; use makima::daemon::config::{DaemonConfig, RepoEntry}; @@ -31,6 +32,7 @@ async fn main() -> Result<(), Box> { Commands::View(args) => run_view(args).await, Commands::Config(cmd) => run_config(cmd).await, Commands::RedTeam(cmd) => run_red_team(cmd).await, + Commands::Contracts(cmd) => run_contracts(cmd).await, } } diff --git a/makima/src/daemon/api/contract.rs b/makima/src/daemon/api/contract.rs index 7c76b40..119c0ba 100644 --- a/makima/src/daemon/api/contract.rs +++ b/makima/src/daemon/api/contract.rs @@ -1,6 +1,6 @@ //! Contract API methods. -use serde::Serialize; +use serde::{Deserialize, Serialize}; use uuid::Uuid; use super::client::{ApiClient, ApiError}; @@ -281,3 +281,179 @@ struct AddRemoteRepositoryRequest { repository_url: String, is_primary: bool, } + +// ============================================================================ +// Contracts cleanup types +// ============================================================================ + +/// Request for batch contract operations (cleanup, archive, etc.). +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct BatchOperationRequest { + /// The operation to perform: "archive", "delete_archived", "cleanup_worktrees" + pub operation: String, + /// Age threshold in seconds (for archive and delete operations) + #[serde(skip_serializing_if = "Option::is_none")] + pub older_than_seconds: Option, + /// Status filter for the operation + #[serde(skip_serializing_if = "Option::is_none")] + pub status_filter: Option>, + /// If true, only show what would be affected without making changes + #[serde(skip_serializing_if = "Option::is_none")] + pub dry_run: Option, +} + +/// Response from a batch operation. +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct BatchOperationResponse { + /// The operation that was performed + pub operation: String, + /// Number of items affected + pub affected_count: usize, + /// IDs of affected items + #[serde(default)] + pub affected_ids: Vec, + /// Any errors that occurred + #[serde(default)] + pub errors: Vec, + /// Whether this was a dry run + #[serde(default)] + pub dry_run: bool, +} + +/// Summary of contracts that would be affected by cleanup. +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CleanupPreviewResponse { + /// Contracts that would be archived + #[serde(default)] + pub to_archive: Vec, + /// Archived contracts that would be deleted + #[serde(default)] + pub to_delete: Vec, + /// Orphaned worktrees that would be cleaned up + #[serde(default)] + pub orphaned_worktrees: Vec, +} + +/// Brief summary of a contract for cleanup operations. +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ContractSummary { + pub id: Uuid, + pub name: String, + pub status: String, + #[serde(default)] + pub updated_at: Option, +} + +impl ApiClient { + // ======================================================================== + // Contracts cleanup operations + // ======================================================================== + + /// Preview cleanup operations (dry run). + /// Returns what would be affected by archive, delete, and worktree cleanup. + pub async fn cleanup_preview( + &self, + older_than_seconds: u64, + archive: bool, + delete_archived: bool, + worktrees: bool, + ) -> Result { + let mut params = vec![ + format!("older_than_seconds={}", older_than_seconds), + "dry_run=true".to_string(), + ]; + if archive { + params.push("archive=true".to_string()); + } + if delete_archived { + params.push("delete_archived=true".to_string()); + } + if worktrees { + params.push("worktrees=true".to_string()); + } + let query = params.join("&"); + let result = self.get(&format!("/api/v1/contracts/cleanup?{}", query)).await?; + serde_json::from_value(result.0) + .map_err(|e| ApiError::Other(format!("Failed to parse cleanup preview: {}", e))) + } + + /// Archive completed/failed contracts older than the threshold. + pub async fn archive_contracts( + &self, + older_than_seconds: u64, + dry_run: bool, + ) -> Result { + let req = BatchOperationRequest { + operation: "archive".to_string(), + older_than_seconds: Some(older_than_seconds), + status_filter: Some(vec!["completed".to_string(), "failed".to_string()]), + dry_run: Some(dry_run), + }; + let result = self.post("/api/v1/contracts/batch", &req).await?; + serde_json::from_value(result.0) + .map_err(|e| ApiError::Other(format!("Failed to parse archive response: {}", e))) + } + + /// Delete archived contracts older than the threshold. + pub async fn delete_archived_contracts( + &self, + older_than_seconds: u64, + dry_run: bool, + ) -> Result { + let req = BatchOperationRequest { + operation: "delete_archived".to_string(), + older_than_seconds: Some(older_than_seconds), + status_filter: Some(vec!["archived".to_string()]), + dry_run: Some(dry_run), + }; + let result = self.post("/api/v1/contracts/batch", &req).await?; + serde_json::from_value(result.0) + .map_err(|e| ApiError::Other(format!("Failed to parse delete response: {}", e))) + } + + /// Clean up orphaned worktrees. + pub async fn cleanup_worktrees(&self, dry_run: bool) -> Result { + let req = BatchOperationRequest { + operation: "cleanup_worktrees".to_string(), + older_than_seconds: None, + status_filter: None, + dry_run: Some(dry_run), + }; + let result = self.post("/api/v1/contracts/batch", &req).await?; + serde_json::from_value(result.0) + .map_err(|e| ApiError::Other(format!("Failed to parse worktree cleanup response: {}", e))) + } + + /// List contracts with filtering options. + pub async fn list_contracts_filtered( + &self, + status: Option<&str>, + phase: Option<&str>, + stale: bool, + stale_threshold_seconds: Option, + ) -> Result { + let mut params = Vec::new(); + if let Some(s) = status { + params.push(format!("status={}", s)); + } + if let Some(p) = phase { + params.push(format!("phase={}", p)); + } + if stale { + params.push("stale=true".to_string()); + if let Some(threshold) = stale_threshold_seconds { + params.push(format!("stale_threshold={}", threshold)); + } + } + let query_string = if params.is_empty() { + String::new() + } else { + format!("?{}", params.join("&")) + }; + self.get(&format!("/api/v1/contracts{}", query_string)).await + } +} diff --git a/makima/src/daemon/cli/contract.rs b/makima/src/daemon/cli/contract.rs index a443b85..9d6784d 100644 --- a/makima/src/daemon/cli/contract.rs +++ b/makima/src/daemon/cli/contract.rs @@ -1,6 +1,6 @@ //! Contract subcommand - task-contract interaction commands. -use clap::Args; +use clap::{Args, Subcommand}; use uuid::Uuid; /// Common arguments for contract commands. @@ -85,3 +85,209 @@ pub struct CreateFileArgs { /// Name of the new file pub name: String, } + +// ============================================================================ +// Contracts management commands (makima contracts ...) +// ============================================================================ + +/// Common arguments for contracts management commands. +#[derive(Args, Debug, Clone)] +pub struct ContractsCommonArgs { + /// API URL + #[arg(long, env = "MAKIMA_API_URL", default_value = "https://api.makima.jp", global = true)] + pub api_url: String, + + /// API key for authentication + #[arg(long, env = "MAKIMA_API_KEY", global = true)] + pub api_key: String, +} + +/// Contracts management subcommands. +#[derive(Subcommand, Debug)] +pub enum ContractsSubcommand { + /// List all contracts with optional filters + List(ListArgs), + + /// Clean up old contracts, archived contracts, and orphaned worktrees + Cleanup(CleanupArgs), +} + +/// Output format for list command. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum ListOutputFormat { + #[default] + Table, + Json, + Compact, +} + +impl std::str::FromStr for ListOutputFormat { + type Err = String; + + fn from_str(s: &str) -> Result { + match s.to_lowercase().as_str() { + "table" => Ok(ListOutputFormat::Table), + "json" => Ok(ListOutputFormat::Json), + "compact" => Ok(ListOutputFormat::Compact), + _ => Err(format!("Invalid format '{}'. Valid options: table, json, compact", s)), + } + } +} + +impl std::fmt::Display for ListOutputFormat { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + ListOutputFormat::Table => write!(f, "table"), + ListOutputFormat::Json => write!(f, "json"), + ListOutputFormat::Compact => write!(f, "compact"), + } + } +} + +/// Arguments for the list subcommand. +#[derive(Args, Debug)] +pub struct ListArgs { + #[command(flatten)] + pub common: ContractsCommonArgs, + + /// Filter by status (active, completed, failed) + #[arg(long, value_delimiter = ',')] + pub status: Option>, + + /// Show only stale contracts (no activity within threshold) + #[arg(long)] + pub stale: bool, + + /// Stale threshold duration (e.g., "30m", "1h", "2d"). Default: 30m + #[arg(long, default_value = "30m")] + pub threshold: String, + + /// Show contracts waiting for user input + #[arg(long)] + pub waiting: bool, + + /// Filter by phase (e.g., "plan", "execute", "review") + #[arg(long)] + pub phase: Option, + + /// Output format: table, json, or compact + #[arg(long, short = 'f', default_value = "table")] + pub format: String, + + /// Limit number of results + #[arg(long, short = 'n')] + pub limit: Option, +} + +impl ListArgs { + /// Parse the format string into a ListOutputFormat enum. + pub fn parse_format(&self) -> Result { + self.format.parse() + } + + /// Parse the threshold duration into seconds. + pub fn parse_threshold(&self) -> Result { + parse_duration(&self.threshold) + } +} + +/// Arguments for the cleanup subcommand. +#[derive(Args, Debug)] +pub struct CleanupArgs { + #[command(flatten)] + pub common: ContractsCommonArgs, + + /// Archive completed/failed contracts older than the threshold + #[arg(long)] + pub archive: bool, + + /// Delete archived contracts older than the threshold + #[arg(long)] + pub delete_archived: bool, + + /// Clean up orphaned worktrees (worktrees without associated contracts) + #[arg(long)] + pub worktrees: bool, + + /// Run all cleanup operations (archive, delete-archived, worktrees) + #[arg(long)] + pub all: bool, + + /// Age threshold for cleanup operations (e.g., "7d", "24h", "30d") + /// Default: 7d (7 days) + #[arg(long, default_value = "7d")] + pub older_than: String, + + /// Show what would be affected without making changes + #[arg(long)] + pub dry_run: bool, + + /// Skip confirmation prompts for destructive operations + #[arg(long)] + pub force: bool, +} + +impl CleanupArgs { + /// Parse the older_than duration string into seconds. + /// Supports formats like "7d", "24h", "30m", "60s". + pub fn parse_older_than(&self) -> Result { + parse_duration(&self.older_than) + } + + /// Returns true if any cleanup operation is selected. + pub fn has_any_operation(&self) -> bool { + self.archive || self.delete_archived || self.worktrees || self.all + } +} + +/// Parse a duration string like "7d", "24h", "30m", "60s" into seconds. +pub fn parse_duration(s: &str) -> Result { + let s = s.trim(); + if s.is_empty() { + return Err("Empty duration string".to_string()); + } + + let (num_str, unit) = if s.ends_with('d') || s.ends_with('D') { + (&s[..s.len() - 1], 'd') + } else if s.ends_with('h') || s.ends_with('H') { + (&s[..s.len() - 1], 'h') + } else if s.ends_with('m') || s.ends_with('M') { + (&s[..s.len() - 1], 'm') + } else if s.ends_with('s') || s.ends_with('S') { + (&s[..s.len() - 1], 's') + } else { + // Default to days if no unit + (s, 'd') + }; + + let num: u64 = num_str + .parse() + .map_err(|_| format!("Invalid number in duration: {}", num_str))?; + + let seconds = match unit { + 'd' => num * 24 * 60 * 60, + 'h' => num * 60 * 60, + 'm' => num * 60, + 's' => num, + _ => unreachable!(), + }; + + Ok(seconds) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_duration() { + assert_eq!(parse_duration("7d").unwrap(), 7 * 24 * 60 * 60); + assert_eq!(parse_duration("24h").unwrap(), 24 * 60 * 60); + assert_eq!(parse_duration("30m").unwrap(), 30 * 60); + assert_eq!(parse_duration("60s").unwrap(), 60); + assert_eq!(parse_duration("7D").unwrap(), 7 * 24 * 60 * 60); + assert_eq!(parse_duration("7").unwrap(), 7 * 24 * 60 * 60); // defaults to days + assert!(parse_duration("").is_err()); + assert!(parse_duration("abc").is_err()); + } +} diff --git a/makima/src/daemon/cli/mod.rs b/makima/src/daemon/cli/mod.rs index c848e8e..8538afa 100644 --- a/makima/src/daemon/cli/mod.rs +++ b/makima/src/daemon/cli/mod.rs @@ -65,6 +65,27 @@ pub enum Commands { /// Red team commands for adversarial monitoring #[command(name = "red-team", subcommand)] RedTeam(RedTeamCommand), + + /// Contract management commands (list, cleanup) + #[command(subcommand)] + Contracts(ContractsCommand), +} + +/// Contracts management subcommands for multi-contract operations. +#[derive(Subcommand, Debug)] +pub enum ContractsCommand { + /// List all contracts with optional filters + /// + /// Examples: + /// makima contracts list # List all contracts + /// makima contracts list --status active # List only active contracts + /// makima contracts list --stale # Show stale contracts + /// makima contracts list --waiting # Show contracts waiting for input + /// makima contracts list --format json # Output as JSON + List(contract::ListArgs), + + /// Clean up old contracts, archived contracts, and orphaned worktrees + Cleanup(contract::CleanupArgs), } /// Config subcommands for CLI configuration. -- cgit v1.2.3