mod comms; mod config; mod notifications; mod observability; mod session; mod tui; mod worktree; use anyhow::{Context, Result}; use clap::Parser; use serde::{Deserialize, Serialize}; use std::collections::{BTreeMap, BTreeSet}; use std::fs::{self, File}; use std::io::{BufRead, BufReader, Read, Write}; use std::net::{TcpListener, TcpStream}; use std::path::{Path, PathBuf}; use tracing_subscriber::EnvFilter; #[derive(Parser, Debug)] #[command(name = "ecc", version, about = "ECC 2.0 — Agentic IDE control plane")] struct Cli { #[command(subcommand)] command: Option, } #[derive(clap::Args, Debug, Clone, Default)] struct WorktreePolicyArgs { /// Create a dedicated worktree #[arg(short = 'w', long = "worktree", action = clap::ArgAction::SetTrue, overrides_with = "no_worktree")] worktree: bool, /// Skip dedicated worktree creation #[arg(long = "no-worktree", action = clap::ArgAction::SetTrue, overrides_with = "worktree")] no_worktree: bool, } impl WorktreePolicyArgs { fn resolve(&self, cfg: &config::Config) -> bool { if self.worktree { true } else if self.no_worktree { false } else { cfg.auto_create_worktrees } } } #[derive(clap::Args, Debug, Clone, Default)] struct OptionalWorktreePolicyArgs { /// Create a dedicated worktree #[arg(short = 'w', long = "worktree", action = clap::ArgAction::SetTrue, overrides_with = "no_worktree")] worktree: bool, /// Skip dedicated worktree creation #[arg(long = "no-worktree", action = clap::ArgAction::SetTrue, overrides_with = "worktree")] no_worktree: bool, } impl OptionalWorktreePolicyArgs { fn resolve(&self, default_value: bool) -> bool { if self.worktree { true } else if self.no_worktree { false } else { default_value } } } #[derive(clap::Subcommand, Debug)] enum Commands { /// Launch the TUI dashboard Dashboard, /// Start a new agent session Start { /// Task description for the agent #[arg(short, long)] task: String, /// Agent type (defaults to `default_agent` from ecc2.toml) #[arg(short, long)] agent: Option, /// Agent profile defined in ecc2.toml #[arg(long)] profile: Option, #[command(flatten)] worktree: WorktreePolicyArgs, /// Source session to delegate from #[arg(long)] from_session: Option, }, /// Delegate a new session from an existing one Delegate { /// Source session ID or alias from_session: String, /// Task description for the delegated session #[arg(short, long)] task: Option, /// Agent type (defaults to `default_agent` from ecc2.toml) #[arg(short, long)] agent: Option, /// Agent profile defined in ecc2.toml #[arg(long)] profile: Option, #[command(flatten)] worktree: WorktreePolicyArgs, }, /// Launch a named orchestration template Template { /// Template name defined in ecc2.toml name: String, /// Optional task injected into the template context #[arg(short, long)] task: Option, /// Source session to delegate the template from #[arg(long)] from_session: Option, /// Template variables in key=value form #[arg(long = "var")] vars: Vec, }, /// Route work to an existing delegate when possible, otherwise spawn a new one Assign { /// Lead session ID or alias from_session: String, /// Task description for the assignment #[arg(short, long)] task: String, /// Agent type (defaults to `default_agent` from ecc2.toml) #[arg(short, long)] agent: Option, /// Agent profile defined in ecc2.toml #[arg(long)] profile: Option, #[command(flatten)] worktree: WorktreePolicyArgs, }, /// Route unread task handoffs from a lead session inbox through the assignment policy DrainInbox { /// Lead session ID or alias session_id: String, /// Agent type for routed delegates (defaults to `default_agent` from ecc2.toml) #[arg(short, long)] agent: Option, #[command(flatten)] worktree: WorktreePolicyArgs, /// Maximum unread task handoffs to route #[arg(long, default_value_t = 5)] limit: usize, }, /// Sweep unread task handoffs across lead sessions and route them through the assignment policy AutoDispatch { /// Agent type for routed delegates (defaults to `default_agent` from ecc2.toml) #[arg(short, long)] agent: Option, #[command(flatten)] worktree: WorktreePolicyArgs, /// Maximum lead sessions to sweep in one pass #[arg(long, default_value_t = 10)] lead_limit: usize, }, /// Dispatch unread handoffs, then rebalance delegate backlog across lead teams CoordinateBacklog { /// Agent type for routed delegates (defaults to `default_agent` from ecc2.toml) #[arg(short, long)] agent: Option, #[command(flatten)] worktree: WorktreePolicyArgs, /// Maximum lead sessions to sweep in one pass #[arg(long, default_value_t = 10)] lead_limit: usize, /// Emit machine-readable JSON instead of the human summary #[arg(long)] json: bool, /// Return a non-zero exit code from the final coordination health #[arg(long)] check: bool, /// Keep coordinating until the backlog is healthy, saturated, or max passes is reached #[arg(long)] until_healthy: bool, /// Maximum coordination passes when using --until-healthy #[arg(long, default_value_t = 5)] max_passes: usize, }, /// Show global coordination, backlog, and daemon policy status CoordinationStatus { /// Emit machine-readable JSON instead of the human summary #[arg(long)] json: bool, /// Return a non-zero exit code when backlog or saturation needs attention #[arg(long)] check: bool, }, /// Coordinate only when backlog pressure actually needs work MaintainCoordination { /// Agent type for routed delegates (defaults to `default_agent` from ecc2.toml) #[arg(short, long)] agent: Option, #[command(flatten)] worktree: WorktreePolicyArgs, /// Maximum lead sessions to sweep in one pass #[arg(long, default_value_t = 10)] lead_limit: usize, /// Emit machine-readable JSON instead of the human summary #[arg(long)] json: bool, /// Return a non-zero exit code from the final coordination health #[arg(long)] check: bool, /// Maximum coordination passes when maintenance is needed #[arg(long, default_value_t = 5)] max_passes: usize, }, /// Rebalance unread handoffs across lead teams with backed-up delegates RebalanceAll { /// Agent type for routed delegates (defaults to `default_agent` from ecc2.toml) #[arg(short, long)] agent: Option, #[command(flatten)] worktree: WorktreePolicyArgs, /// Maximum lead sessions to sweep in one pass #[arg(long, default_value_t = 10)] lead_limit: usize, }, /// Rebalance unread handoffs off backed-up delegates onto clearer team capacity RebalanceTeam { /// Lead session ID or alias session_id: String, /// Agent type for routed delegates (defaults to `default_agent` from ecc2.toml) #[arg(short, long)] agent: Option, #[command(flatten)] worktree: WorktreePolicyArgs, /// Maximum handoffs to reroute in one pass #[arg(long, default_value_t = 5)] limit: usize, }, /// List active sessions Sessions, /// Show session details Status { /// Session ID or alias session_id: Option, }, /// Show delegated team board for a session Team { /// Lead session ID or alias session_id: Option, /// Delegation depth to traverse #[arg(long, default_value_t = 2)] depth: usize, }, /// Show worktree diff and merge-readiness details for a session WorktreeStatus { /// Session ID or alias session_id: Option, /// Show worktree status for all sessions #[arg(long)] all: bool, /// Emit machine-readable JSON instead of the human summary #[arg(long)] json: bool, /// Include a bounded patch preview when a worktree is attached #[arg(long)] patch: bool, /// Return a non-zero exit code when the worktree needs attention #[arg(long)] check: bool, }, /// Show conflict-resolution protocol for a worktree WorktreeResolution { /// Session ID or alias session_id: Option, /// Show conflict protocol for all conflicted worktrees #[arg(long)] all: bool, /// Emit machine-readable JSON instead of the human summary #[arg(long)] json: bool, /// Return a non-zero exit code when conflicted worktrees are present #[arg(long)] check: bool, }, /// Merge a session worktree branch into its base branch MergeWorktree { /// Session ID or alias session_id: Option, /// Merge all ready inactive worktrees #[arg(long)] all: bool, /// Emit machine-readable JSON instead of the human summary #[arg(long)] json: bool, /// Keep the worktree attached after a successful merge #[arg(long)] keep_worktree: bool, }, /// Show the merge queue for inactive worktrees and any branch-to-branch blockers MergeQueue { /// Emit machine-readable JSON instead of the human summary #[arg(long)] json: bool, /// Process the queue, auto-rebasing clean blocked worktrees and merging what becomes ready #[arg(long)] apply: bool, }, /// Prune worktrees for inactive sessions and report any active sessions still holding one PruneWorktrees { /// Emit machine-readable JSON instead of the human summary #[arg(long)] json: bool, }, /// Log a significant agent decision for auditability LogDecision { /// Session ID or alias. Omit to log against the latest session. session_id: Option, /// The chosen decision or direction #[arg(long)] decision: String, /// Why the agent made this choice #[arg(long)] reasoning: String, /// Alternative considered and rejected; repeat for multiple entries #[arg(long = "alternative")] alternatives: Vec, /// Emit machine-readable JSON instead of the human summary #[arg(long)] json: bool, }, /// Show recent decision-log entries Decisions { /// Session ID or alias. Omit to read the latest session. session_id: Option, /// Show decision log entries across all sessions #[arg(long)] all: bool, /// Emit machine-readable JSON instead of the human summary #[arg(long)] json: bool, /// Maximum decision-log entries to return #[arg(long, default_value_t = 20)] limit: usize, }, /// Read and write the shared context graph Graph { #[command(subcommand)] command: GraphCommands, }, /// Audit Hermes/OpenClaw-style workspaces and map them onto ECC2 Migrate { #[command(subcommand)] command: MigrationCommands, }, /// Manage persistent scheduled task dispatch Schedule { #[command(subcommand)] command: ScheduleCommands, }, /// Manage remote task intake and dispatch Remote { #[command(subcommand)] command: RemoteCommands, }, /// Export sessions, tool spans, and metrics in OTLP-compatible JSON ExportOtel { /// Session ID or alias. Omit to export all sessions. session_id: Option, /// Write the export to a file instead of stdout #[arg(long)] output: Option, }, /// Stop a running session Stop { /// Session ID or alias session_id: String, }, /// Resume a failed or stopped session Resume { /// Session ID or alias session_id: String, }, /// Send or inspect inter-session messages Messages { #[command(subcommand)] command: MessageCommands, }, /// Run as background daemon Daemon, #[command(hide = true)] RunSession { #[arg(long)] session_id: String, #[arg(long)] task: String, #[arg(long)] agent: String, #[arg(long)] cwd: PathBuf, }, } #[derive(clap::Subcommand, Debug)] enum MessageCommands { /// Send a structured message between sessions Send { #[arg(long)] from: String, #[arg(long)] to: String, #[arg(long, value_enum)] kind: MessageKindArg, #[arg(long)] text: String, #[arg(long)] context: Option, #[arg(long, value_enum, default_value_t = TaskPriorityArg::Normal)] priority: TaskPriorityArg, #[arg(long)] file: Vec, }, /// Show recent messages for a session Inbox { session_id: String, #[arg(long, default_value_t = 10)] limit: usize, }, } #[derive(clap::Subcommand, Debug)] enum ScheduleCommands { /// Add a persistent scheduled task Add { /// Cron expression in 5, 6, or 7-field form #[arg(long)] cron: String, /// Task description to run on each schedule #[arg(short, long)] task: String, /// Agent type (claude, codex, gemini, opencode) #[arg(short, long)] agent: Option, /// Agent profile defined in ecc2.toml #[arg(long)] profile: Option, #[command(flatten)] worktree: WorktreePolicyArgs, /// Optional project grouping override #[arg(long)] project: Option, /// Optional task-group grouping override #[arg(long)] task_group: Option, /// Emit machine-readable JSON instead of the human summary #[arg(long)] json: bool, }, /// List scheduled tasks List { /// Emit machine-readable JSON instead of the human summary #[arg(long)] json: bool, }, /// Remove a scheduled task Remove { /// Schedule ID schedule_id: i64, }, /// Dispatch currently due scheduled tasks RunDue { /// Maximum due schedules to dispatch in one pass #[arg(long, default_value_t = 10)] limit: usize, /// Emit machine-readable JSON instead of the human summary #[arg(long)] json: bool, }, } #[derive(clap::Subcommand, Debug)] enum RemoteCommands { /// Queue a remote task request Add { /// Task description to dispatch #[arg(short, long)] task: String, /// Optional lead session ID or alias to route through #[arg(long)] to_session: Option, /// Task priority #[arg(long, value_enum, default_value_t = TaskPriorityArg::Normal)] priority: TaskPriorityArg, /// Agent type (defaults to ECC default agent) #[arg(short, long)] agent: Option, /// Agent profile defined in ecc2.toml #[arg(long)] profile: Option, #[command(flatten)] worktree: WorktreePolicyArgs, /// Optional project grouping override #[arg(long)] project: Option, /// Optional task-group grouping override #[arg(long)] task_group: Option, /// Emit machine-readable JSON instead of the human summary #[arg(long)] json: bool, }, /// Queue a remote computer-use task request ComputerUse { /// Goal to complete with computer-use/browser tools #[arg(long)] goal: String, /// Optional target URL to open first #[arg(long)] target_url: Option, /// Extra context for the operator #[arg(long)] context: Option, /// Optional lead session ID or alias to route through #[arg(long)] to_session: Option, /// Task priority #[arg(long, value_enum, default_value_t = TaskPriorityArg::Normal)] priority: TaskPriorityArg, /// Agent type override (defaults to [computer_use_dispatch] or ECC default agent) #[arg(short, long)] agent: Option, /// Agent profile override (defaults to [computer_use_dispatch] or ECC default profile) #[arg(long)] profile: Option, #[command(flatten)] worktree: OptionalWorktreePolicyArgs, /// Optional project grouping override #[arg(long)] project: Option, /// Optional task-group grouping override #[arg(long)] task_group: Option, /// Emit machine-readable JSON instead of the human summary #[arg(long)] json: bool, }, /// List queued remote task requests List { /// Include already dispatched or failed requests #[arg(long)] all: bool, /// Maximum requests to return #[arg(long, default_value_t = 20)] limit: usize, /// Emit machine-readable JSON instead of the human summary #[arg(long)] json: bool, }, /// Dispatch queued remote task requests now Run { /// Maximum queued requests to process #[arg(long, default_value_t = 20)] limit: usize, /// Emit machine-readable JSON instead of the human summary #[arg(long)] json: bool, }, /// Serve a token-authenticated remote dispatch intake endpoint Serve { /// Address to bind, for example 127.0.0.1:8787 #[arg(long, default_value = "127.0.0.1:8787")] bind: String, /// Bearer token required for POST /dispatch #[arg(long)] token: String, }, } #[derive(clap::Subcommand, Debug)] enum MigrationCommands { /// Audit a Hermes/OpenClaw-style workspace and map it onto ECC2 features Audit { /// Path to the legacy Hermes/OpenClaw workspace root #[arg(long)] source: PathBuf, /// Emit machine-readable JSON instead of the human summary #[arg(long)] json: bool, }, /// Generate an actionable ECC2 migration plan from a legacy workspace audit Plan { /// Path to the legacy Hermes/OpenClaw workspace root #[arg(long)] source: PathBuf, /// Write the plan to a file instead of stdout #[arg(long)] output: Option, /// Emit machine-readable JSON instead of the human summary #[arg(long)] json: bool, }, /// Scaffold migration artifacts on disk from a legacy workspace audit Scaffold { /// Path to the legacy Hermes/OpenClaw workspace root #[arg(long)] source: PathBuf, /// Directory where scaffolded migration artifacts should be written #[arg(long)] output_dir: PathBuf, /// Emit machine-readable JSON instead of the human summary #[arg(long)] json: bool, }, /// Import recurring jobs from a legacy cron/jobs.json into ECC2 schedules ImportSchedules { /// Path to the legacy Hermes/OpenClaw workspace root #[arg(long)] source: PathBuf, /// Preview detected jobs without creating ECC2 schedules #[arg(long)] dry_run: bool, /// Emit machine-readable JSON instead of the human summary #[arg(long)] json: bool, }, } #[derive(clap::Subcommand, Debug)] enum GraphCommands { /// Create or update a graph entity AddEntity { /// Optional source session ID or alias for provenance #[arg(long)] session_id: Option, /// Entity type such as file, function, type, or decision #[arg(long = "type")] entity_type: String, /// Stable entity name #[arg(long)] name: String, /// Optional path associated with the entity #[arg(long)] path: Option, /// Short human summary #[arg(long, default_value = "")] summary: String, /// Metadata in key=value form #[arg(long = "meta")] metadata: Vec, /// Emit machine-readable JSON instead of the human summary #[arg(long)] json: bool, }, /// Create or update a relation between two entities Link { /// Optional source session ID or alias for provenance #[arg(long)] session_id: Option, /// Source entity ID #[arg(long)] from: i64, /// Target entity ID #[arg(long)] to: i64, /// Relation type such as references, defines, or depends_on #[arg(long)] relation: String, /// Short human summary #[arg(long, default_value = "")] summary: String, /// Emit machine-readable JSON instead of the human summary #[arg(long)] json: bool, }, /// List entities in the shared context graph Entities { /// Filter by source session ID or alias #[arg(long)] session_id: Option, /// Filter by entity type #[arg(long = "type")] entity_type: Option, /// Maximum entities to return #[arg(long, default_value_t = 20)] limit: usize, /// Emit machine-readable JSON instead of the human summary #[arg(long)] json: bool, }, /// List relations in the shared context graph Relations { /// Filter to relations touching a specific entity ID #[arg(long)] entity_id: Option, /// Maximum relations to return #[arg(long, default_value_t = 20)] limit: usize, /// Emit machine-readable JSON instead of the human summary #[arg(long)] json: bool, }, /// Record an observation against a context graph entity AddObservation { /// Optional source session ID or alias for provenance #[arg(long)] session_id: Option, /// Entity ID #[arg(long)] entity_id: i64, /// Observation type such as completion_summary, incident_note, or reminder #[arg(long = "type")] observation_type: String, /// Observation priority #[arg(long, value_enum, default_value_t = ObservationPriorityArg::Normal)] priority: ObservationPriorityArg, /// Keep this observation across aggressive compaction #[arg(long)] pinned: bool, /// Observation summary #[arg(long)] summary: String, /// Details in key=value form #[arg(long = "detail")] details: Vec, /// Emit machine-readable JSON instead of the human summary #[arg(long)] json: bool, }, /// Pin an existing observation so compaction preserves it PinObservation { /// Observation ID #[arg(long)] observation_id: i64, /// Emit machine-readable JSON instead of the human summary #[arg(long)] json: bool, }, /// Remove the pin from an existing observation UnpinObservation { /// Observation ID #[arg(long)] observation_id: i64, /// Emit machine-readable JSON instead of the human summary #[arg(long)] json: bool, }, /// List observations in the shared context graph Observations { /// Filter to observations for a specific entity ID #[arg(long)] entity_id: Option, /// Maximum observations to return #[arg(long, default_value_t = 20)] limit: usize, /// Emit machine-readable JSON instead of the human summary #[arg(long)] json: bool, }, /// Compact stored observations in the shared context graph Compact { /// Filter by source session ID or alias #[arg(long)] session_id: Option, /// Maximum observations to retain per entity after compaction #[arg(long, default_value_t = 12)] keep_observations_per_entity: usize, /// Emit machine-readable JSON instead of the human summary #[arg(long)] json: bool, }, /// Import external memory from a configured connector ConnectorSync { /// Connector name from ecc2.toml #[arg(required_unless_present = "all", conflicts_with = "all")] name: Option, /// Sync every configured memory connector #[arg(long, required_unless_present = "name")] all: bool, /// Maximum non-empty records to process #[arg(long, default_value_t = 256)] limit: usize, /// Emit machine-readable JSON instead of the human summary #[arg(long)] json: bool, }, /// Show configured memory connectors plus checkpoint status Connectors { /// Emit machine-readable JSON instead of the human summary #[arg(long)] json: bool, }, /// Recall relevant context graph entities for a query Recall { /// Filter by source session ID or alias #[arg(long)] session_id: Option, /// Natural-language query used for recall scoring query: String, /// Maximum entities to return #[arg(long, default_value_t = 8)] limit: usize, /// Emit machine-readable JSON instead of the human summary #[arg(long)] json: bool, }, /// Show one entity plus its incoming and outgoing relations Show { /// Entity ID entity_id: i64, /// Maximum incoming/outgoing relations to return #[arg(long, default_value_t = 10)] limit: usize, /// Emit machine-readable JSON instead of the human summary #[arg(long)] json: bool, }, /// Backfill the context graph from existing decisions and file activity Sync { /// Source session ID or alias. Omit to backfill the latest session. session_id: Option, /// Backfill across all sessions #[arg(long)] all: bool, /// Maximum decisions and file events to scan per session #[arg(long, default_value_t = 64)] limit: usize, /// Emit machine-readable JSON instead of the human summary #[arg(long)] json: bool, }, } #[derive(clap::ValueEnum, Clone, Debug)] enum MessageKindArg { Handoff, Query, Response, Completed, Conflict, } #[derive(clap::ValueEnum, Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] enum TaskPriorityArg { Low, Normal, High, Critical, } impl From for comms::TaskPriority { fn from(value: TaskPriorityArg) -> Self { match value { TaskPriorityArg::Low => Self::Low, TaskPriorityArg::Normal => Self::Normal, TaskPriorityArg::High => Self::High, TaskPriorityArg::Critical => Self::Critical, } } } #[derive(clap::ValueEnum, Clone, Debug)] enum ObservationPriorityArg { Low, Normal, High, Critical, } impl From for session::ContextObservationPriority { fn from(value: ObservationPriorityArg) -> Self { match value { ObservationPriorityArg::Low => Self::Low, ObservationPriorityArg::Normal => Self::Normal, ObservationPriorityArg::High => Self::High, ObservationPriorityArg::Critical => Self::Critical, } } } #[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] struct GraphConnectorSyncStats { connector_name: String, records_read: usize, entities_upserted: usize, observations_added: usize, skipped_records: usize, skipped_unchanged_sources: usize, } #[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] struct GraphConnectorSyncReport { connectors_synced: usize, records_read: usize, entities_upserted: usize, observations_added: usize, skipped_records: usize, skipped_unchanged_sources: usize, connectors: Vec, } #[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] struct GraphConnectorStatus { connector_name: String, connector_kind: String, source_path: String, recurse: bool, default_session_id: Option, default_entity_type: Option, default_observation_type: Option, synced_sources: usize, last_synced_at: Option>, } #[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] struct GraphConnectorStatusReport { configured_connectors: usize, connectors: Vec, } #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] enum LegacyMigrationReadiness { ReadyNow, ManualTranslation, LocalAuthRequired, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] struct LegacyMigrationArtifact { category: String, readiness: LegacyMigrationReadiness, source_paths: Vec, detected_items: usize, mapping: Vec, notes: Vec, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] struct LegacyMigrationAuditSummary { artifact_categories_detected: usize, ready_now_categories: usize, manual_translation_categories: usize, local_auth_required_categories: usize, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] struct LegacyMigrationAuditReport { source: String, detected_systems: Vec, summary: LegacyMigrationAuditSummary, recommended_next_steps: Vec, artifacts: Vec, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] struct LegacyMigrationPlanStep { category: String, readiness: LegacyMigrationReadiness, title: String, target_surface: String, source_paths: Vec, command_snippets: Vec, config_snippets: Vec, notes: Vec, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] struct LegacyMigrationPlanReport { source: String, generated_at: String, audit_summary: LegacyMigrationAuditSummary, steps: Vec, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] struct LegacyMigrationScaffoldReport { source: String, output_dir: String, files_written: Vec, steps_scaffolded: usize, } #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "snake_case")] enum LegacyScheduleImportJobStatus { Ready, Imported, Disabled, Invalid, Skipped, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] struct LegacyScheduleImportJobReport { source_path: String, job_name: String, cron_expr: Option, task: Option, agent: Option, profile: Option, project: Option, task_group: Option, use_worktree: Option, status: LegacyScheduleImportJobStatus, reason: Option, command_snippet: Option, imported_schedule_id: Option, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] struct LegacyScheduleImportReport { source: String, source_path: String, dry_run: bool, jobs_detected: usize, ready_jobs: usize, imported_jobs: usize, disabled_jobs: usize, invalid_jobs: usize, skipped_jobs: usize, jobs: Vec, } #[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] struct RemoteDispatchHttpRequest { task: String, to_session: Option, priority: Option, agent: Option, profile: Option, use_worktree: Option, project: Option, task_group: Option, } #[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] struct RemoteComputerUseHttpRequest { goal: String, target_url: Option, context: Option, to_session: Option, priority: Option, agent: Option, profile: Option, use_worktree: Option, project: Option, task_group: Option, } #[derive(Debug, Clone, Default, Deserialize)] #[serde(default)] struct JsonlMemoryConnectorRecord { session_id: Option, entity_type: Option, entity_name: String, path: Option, entity_summary: Option, metadata: BTreeMap, observation_type: Option, summary: String, details: BTreeMap, } const MARKDOWN_CONNECTOR_SUMMARY_LIMIT: usize = 160; const MARKDOWN_CONNECTOR_BODY_LIMIT: usize = 4000; const DOTENV_CONNECTOR_VALUE_LIMIT: usize = 160; #[derive(Debug, Clone)] struct MarkdownMemorySection { heading: String, path: String, summary: String, body: String, line_number: usize, } #[derive(Debug, Clone)] struct DotenvMemoryEntry { key: String, path: String, summary: String, details: BTreeMap, } #[tokio::main] async fn main() -> Result<()> { tracing_subscriber::fmt() .with_env_filter(EnvFilter::from_default_env()) .init(); let cli = Cli::parse(); let cfg = config::Config::load()?; let db = session::store::StateStore::open(&cfg.db_path)?; match cli.command { Some(Commands::Dashboard) | None => { tui::app::run(db, cfg).await?; } Some(Commands::Start { task, agent, profile, worktree, from_session, }) => { let use_worktree = worktree.resolve(&cfg); let source = if let Some(from_session) = from_session.as_ref() { let from_id = resolve_session_id(&db, from_session)?; Some( db.get_session(&from_id)? .ok_or_else(|| anyhow::anyhow!("Session not found: {from_id}"))?, ) } else { None }; let grouping = session::SessionGrouping { project: source.as_ref().map(|session| session.project.clone()), task_group: source.as_ref().map(|session| session.task_group.clone()), }; let session_id = if let Some(source) = source.as_ref() { session::manager::create_session_from_source_with_profile_and_grouping( &db, &cfg, &task, agent.as_deref().unwrap_or(&cfg.default_agent), use_worktree, profile.as_deref(), &source.id, grouping, ) .await? } else { session::manager::create_session_with_profile_and_grouping( &db, &cfg, &task, agent.as_deref().unwrap_or(&cfg.default_agent), use_worktree, profile.as_deref(), grouping, ) .await? }; if let Some(source) = source { let from_id = source.id; send_handoff_message(&db, &from_id, &session_id)?; } println!("Session started: {session_id}"); } Some(Commands::Delegate { from_session, task, agent, profile, worktree, }) => { let use_worktree = worktree.resolve(&cfg); let from_id = resolve_session_id(&db, &from_session)?; let source = db .get_session(&from_id)? .ok_or_else(|| anyhow::anyhow!("Session not found: {from_id}"))?; let task = task.unwrap_or_else(|| { format!( "Follow up on {}: {}", short_session(&source.id), source.task ) }); let session_id = session::manager::create_session_from_source_with_profile_and_grouping( &db, &cfg, &task, agent.as_deref().unwrap_or(&cfg.default_agent), use_worktree, profile.as_deref(), &source.id, session::SessionGrouping { project: Some(source.project.clone()), task_group: Some(source.task_group.clone()), }, ) .await?; send_handoff_message(&db, &source.id, &session_id)?; println!( "Delegated session started: {} <- {}", session_id, short_session(&source.id) ); } Some(Commands::Template { name, task, from_session, vars, }) => { let source_session_id = from_session .as_deref() .map(|session_id| resolve_session_id(&db, session_id)) .transpose()?; let outcome = session::manager::launch_orchestration_template( &db, &cfg, &name, source_session_id.as_deref(), task.as_deref(), parse_template_vars(&vars)?, ) .await?; println!( "Template launched: {} ({} step{})", outcome.template_name, outcome.created.len(), if outcome.created.len() == 1 { "" } else { "s" } ); if let Some(anchor_session_id) = outcome.anchor_session_id.as_deref() { println!("Anchor session: {}", short_session(anchor_session_id)); } for step in outcome.created { println!( "- {} -> {} | {}", step.step_name, short_session(&step.session_id), step.task ); } } Some(Commands::Assign { from_session, task, agent, profile, worktree, }) => { let use_worktree = worktree.resolve(&cfg); let lead_id = resolve_session_id(&db, &from_session)?; let outcome = session::manager::assign_session_with_profile_and_grouping( &db, &cfg, &lead_id, &task, agent.as_deref().unwrap_or(&cfg.default_agent), use_worktree, profile.as_deref(), session::SessionGrouping::default(), ) .await?; if session::manager::assignment_action_routes_work(outcome.action) { println!( "Assignment routed: {} -> {} ({})", short_session(&lead_id), short_session(&outcome.session_id), match outcome.action { session::manager::AssignmentAction::Spawned => "spawned", session::manager::AssignmentAction::ReusedIdle => "reused-idle", session::manager::AssignmentAction::ReusedActive => "reused-active", session::manager::AssignmentAction::DeferredSaturated => unreachable!(), } ); } else { println!( "Assignment deferred: {} is saturated; task stayed in {} inbox", short_session(&lead_id), short_session(&lead_id), ); } } Some(Commands::DrainInbox { session_id, agent, worktree, limit, }) => { let use_worktree = worktree.resolve(&cfg); let lead_id = resolve_session_id(&db, &session_id)?; let outcomes = session::manager::drain_inbox( &db, &cfg, &lead_id, agent.as_deref().unwrap_or(&cfg.default_agent), use_worktree, limit, ) .await?; if outcomes.is_empty() { println!("No unread task handoffs for {}", short_session(&lead_id)); } else { let routed_count = outcomes .iter() .filter(|outcome| { session::manager::assignment_action_routes_work(outcome.action) }) .count(); let deferred_count = outcomes.len().saturating_sub(routed_count); println!( "Processed {} inbox task handoff(s) from {} ({} routed, {} deferred)", outcomes.len(), short_session(&lead_id), routed_count, deferred_count ); for outcome in outcomes { println!( "- {} -> {} ({}) | {}", outcome.message_id, short_session(&outcome.session_id), match outcome.action { session::manager::AssignmentAction::Spawned => "spawned", session::manager::AssignmentAction::ReusedIdle => "reused-idle", session::manager::AssignmentAction::ReusedActive => "reused-active", session::manager::AssignmentAction::DeferredSaturated => { "deferred-saturated" } }, outcome.task ); } } } Some(Commands::AutoDispatch { agent, worktree, lead_limit, }) => { let use_worktree = worktree.resolve(&cfg); let outcomes = session::manager::auto_dispatch_backlog( &db, &cfg, agent.as_deref().unwrap_or(&cfg.default_agent), use_worktree, lead_limit, ) .await?; if outcomes.is_empty() { println!("No unread task handoff backlog found"); } else { let total_processed: usize = outcomes.iter().map(|outcome| outcome.routed.len()).sum(); let total_routed: usize = outcomes .iter() .map(|outcome| { outcome .routed .iter() .filter(|item| { session::manager::assignment_action_routes_work(item.action) }) .count() }) .sum(); let total_deferred = total_processed.saturating_sub(total_routed); println!( "Auto-dispatch processed {} task handoff(s) across {} lead session(s) ({} routed, {} deferred)", total_processed, outcomes.len(), total_routed, total_deferred ); for outcome in outcomes { let routed = outcome .routed .iter() .filter(|item| session::manager::assignment_action_routes_work(item.action)) .count(); let deferred = outcome.routed.len().saturating_sub(routed); println!( "- {} | unread {} | routed {} | deferred {}", short_session(&outcome.lead_session_id), outcome.unread_count, routed, deferred ); } } } Some(Commands::CoordinateBacklog { agent, worktree, lead_limit, json, check, until_healthy, max_passes, }) => { let use_worktree = worktree.resolve(&cfg); let pass_budget = if until_healthy { max_passes.max(1) } else { 1 }; let run = run_coordination_loop( &db, &cfg, agent.as_deref().unwrap_or(&cfg.default_agent), use_worktree, lead_limit, pass_budget, !json, ) .await?; if json { println!("{}", serde_json::to_string_pretty(&run)?); } if check { let exit_code = run .final_status .as_ref() .map(coordination_status_exit_code) .unwrap_or(0); std::process::exit(exit_code); } } Some(Commands::CoordinationStatus { json, check }) => { let status = session::manager::get_coordination_status(&db, &cfg)?; println!("{}", format_coordination_status(&status, json)?); if check { std::process::exit(coordination_status_exit_code(&status)); } } Some(Commands::MaintainCoordination { agent, worktree, lead_limit, json, check, max_passes, }) => { let use_worktree = worktree.resolve(&cfg); let initial_status = session::manager::get_coordination_status(&db, &cfg)?; let run = if matches!( initial_status.health, session::manager::CoordinationHealth::Healthy ) { None } else { Some( run_coordination_loop( &db, &cfg, agent.as_deref().unwrap_or(&cfg.default_agent), use_worktree, lead_limit, max_passes.max(1), !json, ) .await?, ) }; let final_status = run .as_ref() .and_then(|run| run.final_status.clone()) .unwrap_or_else(|| initial_status.clone()); if json { let payload = MaintainCoordinationRun { skipped: run.is_none(), initial_status, run, final_status: final_status.clone(), }; println!("{}", serde_json::to_string_pretty(&payload)?); } else if run.is_none() { println!("Coordination already healthy"); } if check { std::process::exit(coordination_status_exit_code(&final_status)); } } Some(Commands::RebalanceAll { agent, worktree, lead_limit, }) => { let use_worktree = worktree.resolve(&cfg); let outcomes = session::manager::rebalance_all_teams( &db, &cfg, agent.as_deref().unwrap_or(&cfg.default_agent), use_worktree, lead_limit, ) .await?; if outcomes.is_empty() { println!("No delegate backlog needed global rebalancing"); } else { let total_rerouted: usize = outcomes.iter().map(|outcome| outcome.rerouted.len()).sum(); println!( "Rebalanced {} task handoff(s) across {} lead session(s)", total_rerouted, outcomes.len() ); for outcome in outcomes { println!( "- {} | rerouted {}", short_session(&outcome.lead_session_id), outcome.rerouted.len() ); } } } Some(Commands::RebalanceTeam { session_id, agent, worktree, limit, }) => { let use_worktree = worktree.resolve(&cfg); let lead_id = resolve_session_id(&db, &session_id)?; let outcomes = session::manager::rebalance_team_backlog( &db, &cfg, &lead_id, agent.as_deref().unwrap_or(&cfg.default_agent), use_worktree, limit, ) .await?; if outcomes.is_empty() { println!( "No delegate backlog needed rebalancing for {}", short_session(&lead_id) ); } else { println!( "Rebalanced {} task handoff(s) for {}", outcomes.len(), short_session(&lead_id) ); for outcome in outcomes { println!( "- {} | {} -> {} ({}) | {}", outcome.message_id, short_session(&outcome.from_session_id), short_session(&outcome.session_id), match outcome.action { session::manager::AssignmentAction::Spawned => "spawned", session::manager::AssignmentAction::ReusedIdle => "reused-idle", session::manager::AssignmentAction::ReusedActive => "reused-active", session::manager::AssignmentAction::DeferredSaturated => { "deferred-saturated" } }, outcome.task ); } } } Some(Commands::Sessions) => { sync_runtime_session_metrics(&db, &cfg)?; let sessions = session::manager::list_sessions(&db)?; let harnesses = db.list_session_harnesses().unwrap_or_default(); for s in sessions { let harness = harnesses .get(&s.id) .cloned() .unwrap_or_else(|| { session::SessionHarnessInfo::detect(&s.agent_type, &s.working_dir) }) .with_config_detection(&cfg, &s.working_dir) .primary_label; println!("{} [{}] [{}] {}", s.id, s.state, harness, s.task); } } Some(Commands::Status { session_id }) => { sync_runtime_session_metrics(&db, &cfg)?; let id = session_id.unwrap_or_else(|| "latest".to_string()); let status = session::manager::get_status(&db, &cfg, &id)?; println!("{status}"); } Some(Commands::Team { session_id, depth }) => { sync_runtime_session_metrics(&db, &cfg)?; let id = session_id.unwrap_or_else(|| "latest".to_string()); let team = session::manager::get_team_status(&db, &id, depth)?; println!("{team}"); } Some(Commands::WorktreeStatus { session_id, all, json, patch, check, }) => { if all && session_id.is_some() { return Err(anyhow::anyhow!( "worktree-status does not accept a session ID when --all is set" )); } let reports = if all { session::manager::list_sessions(&db)? .into_iter() .map(|session| build_worktree_status_report(&session, patch)) .collect::>>()? } else { let id = session_id.unwrap_or_else(|| "latest".to_string()); let resolved_id = resolve_session_id(&db, &id)?; let session = db .get_session(&resolved_id)? .ok_or_else(|| anyhow::anyhow!("Session not found: {resolved_id}"))?; vec![build_worktree_status_report(&session, patch)?] }; if json { if all { println!("{}", serde_json::to_string_pretty(&reports)?); } else { println!("{}", serde_json::to_string_pretty(&reports[0])?); } } else { println!("{}", format_worktree_status_reports_human(&reports)); } if check { std::process::exit(worktree_status_reports_exit_code(&reports)); } } Some(Commands::WorktreeResolution { session_id, all, json, check, }) => { if all && session_id.is_some() { return Err(anyhow::anyhow!( "worktree-resolution does not accept a session ID when --all is set" )); } let reports = if all { session::manager::list_sessions(&db)? .into_iter() .map(|session| build_worktree_resolution_report(&session)) .collect::>>()? .into_iter() .filter(|report| report.conflicted) .collect::>() } else { let id = session_id.unwrap_or_else(|| "latest".to_string()); let resolved_id = resolve_session_id(&db, &id)?; let session = db .get_session(&resolved_id)? .ok_or_else(|| anyhow::anyhow!("Session not found: {resolved_id}"))?; vec![build_worktree_resolution_report(&session)?] }; if json { if all { println!("{}", serde_json::to_string_pretty(&reports)?); } else { println!("{}", serde_json::to_string_pretty(&reports[0])?); } } else { println!("{}", format_worktree_resolution_reports_human(&reports)); } if check { std::process::exit(worktree_resolution_reports_exit_code(&reports)); } } Some(Commands::MergeWorktree { session_id, all, json, keep_worktree, }) => { if all && session_id.is_some() { return Err(anyhow::anyhow!( "merge-worktree does not accept a session ID when --all is set" )); } if all { let outcome = session::manager::merge_ready_worktrees(&db, !keep_worktree).await?; if json { println!("{}", serde_json::to_string_pretty(&outcome)?); } else { println!("{}", format_bulk_worktree_merge_human(&outcome)); } } else { let id = session_id.unwrap_or_else(|| "latest".to_string()); let resolved_id = resolve_session_id(&db, &id)?; let outcome = session::manager::merge_session_worktree(&db, &resolved_id, !keep_worktree) .await?; if json { println!("{}", serde_json::to_string_pretty(&outcome)?); } else { println!("{}", format_worktree_merge_human(&outcome)); } } } Some(Commands::MergeQueue { json, apply }) => { if apply { let outcome = session::manager::process_merge_queue(&db).await?; if json { println!("{}", serde_json::to_string_pretty(&outcome)?); } else { println!("{}", format_bulk_worktree_merge_human(&outcome)); } } else { let report = session::manager::build_merge_queue(&db)?; if json { println!("{}", serde_json::to_string_pretty(&report)?); } else { println!("{}", format_merge_queue_human(&report)); } } } Some(Commands::PruneWorktrees { json }) => { let outcome = session::manager::prune_inactive_worktrees(&db, &cfg).await?; if json { println!("{}", serde_json::to_string_pretty(&outcome)?); } else { println!("{}", format_prune_worktrees_human(&outcome)); } } Some(Commands::LogDecision { session_id, decision, reasoning, alternatives, json, }) => { let resolved_id = resolve_session_id(&db, session_id.as_deref().unwrap_or("latest"))?; let entry = db.insert_decision(&resolved_id, &decision, &alternatives, &reasoning)?; if json { println!("{}", serde_json::to_string_pretty(&entry)?); } else { println!("{}", format_logged_decision_human(&entry)); } } Some(Commands::Decisions { session_id, all, json, limit, }) => { if all && session_id.is_some() { return Err(anyhow::anyhow!( "decisions does not accept a session ID when --all is set" )); } let entries = if all { db.list_decisions(limit)? } else { let resolved_id = resolve_session_id(&db, session_id.as_deref().unwrap_or("latest"))?; db.list_decisions_for_session(&resolved_id, limit)? }; if json { println!("{}", serde_json::to_string_pretty(&entries)?); } else { println!("{}", format_decisions_human(&entries, all)); } } Some(Commands::Migrate { command }) => match command { MigrationCommands::Audit { source, json } => { let report = build_legacy_migration_audit_report(&source)?; if json { println!("{}", serde_json::to_string_pretty(&report)?); } else { println!("{}", format_legacy_migration_audit_human(&report)); } } MigrationCommands::Plan { source, output, json, } => { let audit = build_legacy_migration_audit_report(&source)?; let plan = build_legacy_migration_plan_report(&audit); let rendered = if json { serde_json::to_string_pretty(&plan)? } else { format_legacy_migration_plan_human(&plan) }; if let Some(path) = output { std::fs::write(&path, &rendered)?; println!("Migration plan written to {}", path.display()); } else { println!("{rendered}"); } } MigrationCommands::Scaffold { source, output_dir, json, } => { let audit = build_legacy_migration_audit_report(&source)?; let plan = build_legacy_migration_plan_report(&audit); let report = write_legacy_migration_scaffold(&plan, &output_dir)?; if json { println!("{}", serde_json::to_string_pretty(&report)?); } else { println!("{}", format_legacy_migration_scaffold_human(&report)); } } MigrationCommands::ImportSchedules { source, dry_run, json, } => { let report = import_legacy_schedules(&db, &cfg, &source, dry_run)?; if json { println!("{}", serde_json::to_string_pretty(&report)?); } else { println!("{}", format_legacy_schedule_import_human(&report)); } } }, Some(Commands::Graph { command }) => match command { GraphCommands::AddEntity { session_id, entity_type, name, path, summary, metadata, json, } => { let resolved_session_id = session_id .as_deref() .map(|value| resolve_session_id(&db, value)) .transpose()?; let metadata = parse_key_value_pairs(&metadata, "graph metadata")?; let entity = db.upsert_context_entity( resolved_session_id.as_deref(), &entity_type, &name, path.as_deref(), &summary, &metadata, )?; if json { println!("{}", serde_json::to_string_pretty(&entity)?); } else { println!("{}", format_graph_entity_human(&entity)); } } GraphCommands::Link { session_id, from, to, relation, summary, json, } => { let resolved_session_id = session_id .as_deref() .map(|value| resolve_session_id(&db, value)) .transpose()?; let relation = db.upsert_context_relation( resolved_session_id.as_deref(), from, to, &relation, &summary, )?; if json { println!("{}", serde_json::to_string_pretty(&relation)?); } else { println!("{}", format_graph_relation_human(&relation)); } } GraphCommands::Entities { session_id, entity_type, limit, json, } => { let resolved_session_id = session_id .as_deref() .map(|value| resolve_session_id(&db, value)) .transpose()?; let entities = db.list_context_entities( resolved_session_id.as_deref(), entity_type.as_deref(), limit, )?; if json { println!("{}", serde_json::to_string_pretty(&entities)?); } else { println!( "{}", format_graph_entities_human(&entities, resolved_session_id.is_some()) ); } } GraphCommands::Relations { entity_id, limit, json, } => { let relations = db.list_context_relations(entity_id, limit)?; if json { println!("{}", serde_json::to_string_pretty(&relations)?); } else { println!("{}", format_graph_relations_human(&relations)); } } GraphCommands::AddObservation { session_id, entity_id, observation_type, priority, pinned, summary, details, json, } => { let resolved_session_id = session_id .as_deref() .map(|value| resolve_session_id(&db, value)) .transpose()?; let details = parse_key_value_pairs(&details, "graph observation details")?; let observation = db.add_context_observation( resolved_session_id.as_deref(), entity_id, &observation_type, priority.into(), pinned, &summary, &details, )?; if json { println!("{}", serde_json::to_string_pretty(&observation)?); } else { println!("{}", format_graph_observation_human(&observation)); } } GraphCommands::PinObservation { observation_id, json, } => { let Some(observation) = db.set_context_observation_pinned(observation_id, true)? else { return Err(anyhow::anyhow!( "Context graph observation #{observation_id} was not found" )); }; if json { println!("{}", serde_json::to_string_pretty(&observation)?); } else { println!("{}", format_graph_observation_human(&observation)); } } GraphCommands::UnpinObservation { observation_id, json, } => { let Some(observation) = db.set_context_observation_pinned(observation_id, false)? else { return Err(anyhow::anyhow!( "Context graph observation #{observation_id} was not found" )); }; if json { println!("{}", serde_json::to_string_pretty(&observation)?); } else { println!("{}", format_graph_observation_human(&observation)); } } GraphCommands::Observations { entity_id, limit, json, } => { let observations = db.list_context_observations(entity_id, limit)?; if json { println!("{}", serde_json::to_string_pretty(&observations)?); } else { println!("{}", format_graph_observations_human(&observations)); } } GraphCommands::Compact { session_id, keep_observations_per_entity, json, } => { let resolved_session_id = session_id .as_deref() .map(|value| resolve_session_id(&db, value)) .transpose()?; let stats = db.compact_context_graph( resolved_session_id.as_deref(), keep_observations_per_entity, )?; if json { println!("{}", serde_json::to_string_pretty(&stats)?); } else { println!( "{}", format_graph_compaction_stats_human( &stats, resolved_session_id.as_deref(), keep_observations_per_entity, ) ); } } GraphCommands::ConnectorSync { name, all, limit, json, } => { if all { let report = sync_all_memory_connectors(&db, &cfg, limit)?; if json { println!("{}", serde_json::to_string_pretty(&report)?); } else { println!("{}", format_graph_connector_sync_report_human(&report)); } } else { let name = name.as_deref().ok_or_else(|| { anyhow::anyhow!("connector name required unless --all is set") })?; let stats = sync_memory_connector(&db, &cfg, name, limit)?; if json { println!("{}", serde_json::to_string_pretty(&stats)?); } else { println!("{}", format_graph_connector_sync_stats_human(&stats)); } } } GraphCommands::Connectors { json } => { let report = memory_connector_status_report(&db, &cfg)?; if json { println!("{}", serde_json::to_string_pretty(&report)?); } else { println!("{}", format_graph_connector_status_report_human(&report)); } } GraphCommands::Recall { session_id, query, limit, json, } => { let resolved_session_id = session_id .as_deref() .map(|value| resolve_session_id(&db, value)) .transpose()?; let entries = db.recall_context_entities(resolved_session_id.as_deref(), &query, limit)?; if json { println!("{}", serde_json::to_string_pretty(&entries)?); } else { println!( "{}", format_graph_recall_human(&entries, resolved_session_id.as_deref(), &query) ); } } GraphCommands::Show { entity_id, limit, json, } => { let detail = db .get_context_entity_detail(entity_id, limit)? .ok_or_else(|| { anyhow::anyhow!("Context graph entity not found: {entity_id}") })?; if json { println!("{}", serde_json::to_string_pretty(&detail)?); } else { println!("{}", format_graph_entity_detail_human(&detail)); } } GraphCommands::Sync { session_id, all, limit, json, } => { if all && session_id.is_some() { return Err(anyhow::anyhow!( "graph sync does not accept a session ID when --all is set" )); } sync_runtime_session_metrics(&db, &cfg)?; let resolved_session_id = if all { None } else { Some(resolve_session_id( &db, session_id.as_deref().unwrap_or("latest"), )?) }; let stats = db.sync_context_graph_history(resolved_session_id.as_deref(), limit)?; if json { println!("{}", serde_json::to_string_pretty(&stats)?); } else { println!( "{}", format_graph_sync_stats_human(&stats, resolved_session_id.as_deref()) ); } } }, Some(Commands::ExportOtel { session_id, output }) => { sync_runtime_session_metrics(&db, &cfg)?; let resolved_session_id = session_id .as_deref() .map(|value| resolve_session_id(&db, value)) .transpose()?; let export = build_otel_export(&db, resolved_session_id.as_deref())?; let rendered = serde_json::to_string_pretty(&export)?; if let Some(path) = output { std::fs::write(&path, rendered)?; println!("OTLP export written to {}", path.display()); } else { println!("{rendered}"); } } Some(Commands::Stop { session_id }) => { session::manager::stop_session(&db, &session_id).await?; println!("Session stopped: {session_id}"); } Some(Commands::Resume { session_id }) => { let resumed_id = session::manager::resume_session(&db, &cfg, &session_id).await?; println!("Session resumed: {resumed_id}"); } Some(Commands::Messages { command }) => match command { MessageCommands::Send { from, to, kind, text, context, priority, file, } => { let from = resolve_session_id(&db, &from)?; let to = resolve_session_id(&db, &to)?; let message = build_message(kind, text, context, priority, file)?; comms::send(&db, &from, &to, &message)?; println!( "Message sent: {} -> {}", short_session(&from), short_session(&to) ); } MessageCommands::Inbox { session_id, limit } => { let session_id = resolve_session_id(&db, &session_id)?; let messages = db.list_messages_for_session(&session_id, limit)?; let unread_before = db .unread_message_counts()? .get(&session_id) .copied() .unwrap_or(0); if unread_before > 0 { let _ = db.mark_messages_read(&session_id)?; } if messages.is_empty() { println!("No messages for {}", short_session(&session_id)); } else { println!("Messages for {}", short_session(&session_id)); for message in messages { println!( "{} {} -> {} | {}", message.timestamp.format("%H:%M:%S"), short_session(&message.from_session), short_session(&message.to_session), comms::preview(&message.msg_type, &message.content) ); } } } }, Some(Commands::Schedule { command }) => match command { ScheduleCommands::Add { cron, task, agent, profile, worktree, project, task_group, json, } => { let schedule = session::manager::create_scheduled_task( &db, &cfg, &cron, &task, agent.as_deref().unwrap_or(&cfg.default_agent), profile.as_deref(), worktree.resolve(&cfg), session::SessionGrouping { project, task_group, }, )?; if json { println!("{}", serde_json::to_string_pretty(&schedule)?); } else { println!( "Scheduled task {} next runs at {}", schedule.id, schedule.next_run_at.to_rfc3339() ); println!( "- {} [{}] | {}", schedule.task, schedule.agent_type, schedule.cron_expr ); } } ScheduleCommands::List { json } => { let schedules = session::manager::list_scheduled_tasks(&db)?; if json { println!("{}", serde_json::to_string_pretty(&schedules)?); } else if schedules.is_empty() { println!("No scheduled tasks"); } else { println!("Scheduled tasks"); for schedule in schedules { println!( "#{} {} [{}] | {} | next {}", schedule.id, schedule.task, schedule.agent_type, schedule.cron_expr, schedule.next_run_at.to_rfc3339() ); } } } ScheduleCommands::Remove { schedule_id } => { if !session::manager::delete_scheduled_task(&db, schedule_id)? { anyhow::bail!("Scheduled task not found: {schedule_id}"); } println!("Removed scheduled task {schedule_id}"); } ScheduleCommands::RunDue { limit, json } => { let outcomes = session::manager::run_due_schedules(&db, &cfg, limit).await?; if json { println!("{}", serde_json::to_string_pretty(&outcomes)?); } else if outcomes.is_empty() { println!("No due scheduled tasks"); } else { println!("Dispatched {} scheduled task(s)", outcomes.len()); for outcome in outcomes { println!( "#{} -> {} | {} | next {}", outcome.schedule_id, short_session(&outcome.session_id), outcome.task, outcome.next_run_at.to_rfc3339() ); } } } }, Some(Commands::Remote { command }) => match command { RemoteCommands::Add { task, to_session, priority, agent, profile, worktree, project, task_group, json, } => { let target_session_id = to_session .as_deref() .map(|value| resolve_session_id(&db, value)) .transpose()?; let request = session::manager::create_remote_dispatch_request( &db, &cfg, &task, target_session_id.as_deref(), priority.into(), agent.as_deref().unwrap_or(&cfg.default_agent), profile.as_deref(), worktree.resolve(&cfg), session::SessionGrouping { project, task_group, }, "cli", None, )?; if json { println!("{}", serde_json::to_string_pretty(&request)?); } else { println!( "Queued remote request #{} [{}] {}", request.id, request.priority, request.task ); if let Some(target_session_id) = request.target_session_id.as_deref() { println!("- target {}", short_session(target_session_id)); } } } RemoteCommands::ComputerUse { goal, target_url, context, to_session, priority, agent, profile, worktree, project, task_group, json, } => { let target_session_id = to_session .as_deref() .map(|value| resolve_session_id(&db, value)) .transpose()?; let defaults = cfg.computer_use_dispatch_defaults(); let request = session::manager::create_computer_use_remote_dispatch_request( &db, &cfg, &goal, target_url.as_deref(), context.as_deref(), target_session_id.as_deref(), priority.into(), agent.as_deref(), profile.as_deref(), Some(worktree.resolve(defaults.use_worktree)), session::SessionGrouping { project, task_group, }, "cli_computer_use", None, )?; if json { println!("{}", serde_json::to_string_pretty(&request)?); } else { println!( "Queued remote {} request #{} [{}] {}", request.request_kind, request.id, request.priority, goal ); if let Some(target_url) = request.target_url.as_deref() { println!("- target url {target_url}"); } if let Some(target_session_id) = request.target_session_id.as_deref() { println!("- target {}", short_session(target_session_id)); } } } RemoteCommands::List { all, limit, json } => { let requests = session::manager::list_remote_dispatch_requests(&db, all, limit)?; if json { println!("{}", serde_json::to_string_pretty(&requests)?); } else if requests.is_empty() { println!("No remote dispatch requests"); } else { println!("Remote dispatch requests"); for request in requests { let target = request .target_session_id .as_deref() .map(short_session) .unwrap_or_else(|| "new-session".to_string()); let label = format_remote_dispatch_kind(request.request_kind); println!( "#{} [{}] {} {} -> {} | {}", request.id, request.priority, label, request.status, target, request.task.lines().next().unwrap_or(&request.task) ); } } } RemoteCommands::Run { limit, json } => { let outcomes = session::manager::run_remote_dispatch_requests(&db, &cfg, limit).await?; if json { println!("{}", serde_json::to_string_pretty(&outcomes)?); } else if outcomes.is_empty() { println!("No pending remote dispatch requests"); } else { println!("Processed {} remote request(s)", outcomes.len()); for outcome in outcomes { let target = outcome .target_session_id .as_deref() .map(short_session) .unwrap_or_else(|| "new-session".to_string()); let result = outcome .session_id .as_deref() .map(short_session) .unwrap_or_else(|| "-".to_string()); println!( "#{} [{}] {} -> {} | {}", outcome.request_id, outcome.priority, target, result, format_remote_dispatch_action(&outcome.action) ); } } } RemoteCommands::Serve { bind, token } => { run_remote_dispatch_server(&db, &cfg, &bind, &token)?; } }, Some(Commands::Daemon) => { println!("Starting ECC daemon..."); session::daemon::run(db, cfg).await?; } Some(Commands::RunSession { session_id, task, agent, cwd, }) => { session::manager::run_session(&cfg, &session_id, &task, &agent, &cwd).await?; } } Ok(()) } fn resolve_session_id(db: &session::store::StateStore, value: &str) -> Result { if value == "latest" { return db .get_latest_session()? .map(|session| session.id) .ok_or_else(|| anyhow::anyhow!("No sessions found")); } db.get_session(value)? .map(|session| session.id) .ok_or_else(|| anyhow::anyhow!("Session not found: {value}")) } fn sync_runtime_session_metrics( db: &session::store::StateStore, cfg: &config::Config, ) -> Result<()> { db.refresh_session_durations()?; db.sync_cost_tracker_metrics(&cfg.cost_metrics_path())?; db.sync_tool_activity_metrics(&cfg.tool_activity_metrics_path())?; let _ = session::manager::enforce_session_heartbeats(db, cfg)?; let _ = session::manager::enforce_budget_hard_limits(db, cfg)?; Ok(()) } fn sync_memory_connector( db: &session::store::StateStore, cfg: &config::Config, name: &str, limit: usize, ) -> Result { let connector = cfg .memory_connectors .get(name) .ok_or_else(|| anyhow::anyhow!("Unknown memory connector: {name}"))?; match connector { config::MemoryConnectorConfig::JsonlFile(settings) => { sync_jsonl_memory_connector(db, name, settings, limit) } config::MemoryConnectorConfig::JsonlDirectory(settings) => { sync_jsonl_directory_memory_connector(db, name, settings, limit) } config::MemoryConnectorConfig::MarkdownFile(settings) => { sync_markdown_memory_connector(db, name, settings, limit) } config::MemoryConnectorConfig::MarkdownDirectory(settings) => { sync_markdown_directory_memory_connector(db, name, settings, limit) } config::MemoryConnectorConfig::DotenvFile(settings) => { sync_dotenv_memory_connector(db, name, settings, limit) } } } fn sync_all_memory_connectors( db: &session::store::StateStore, cfg: &config::Config, limit: usize, ) -> Result { let mut report = GraphConnectorSyncReport::default(); for name in cfg.memory_connectors.keys() { let stats = sync_memory_connector(db, cfg, name, limit)?; report.connectors_synced += 1; report.records_read += stats.records_read; report.entities_upserted += stats.entities_upserted; report.observations_added += stats.observations_added; report.skipped_records += stats.skipped_records; report.skipped_unchanged_sources += stats.skipped_unchanged_sources; report.connectors.push(stats); } Ok(report) } fn memory_connector_status_report( db: &session::store::StateStore, cfg: &config::Config, ) -> Result { let mut report = GraphConnectorStatusReport { configured_connectors: cfg.memory_connectors.len(), connectors: Vec::with_capacity(cfg.memory_connectors.len()), }; for (name, connector) in &cfg.memory_connectors { let checkpoint = db.connector_checkpoint_summary(name)?; let ( connector_kind, source_path, recurse, default_session_id, default_entity_type, default_observation_type, ) = describe_memory_connector(connector); report.connectors.push(GraphConnectorStatus { connector_name: name.to_string(), connector_kind, source_path, recurse, default_session_id, default_entity_type, default_observation_type, synced_sources: checkpoint.synced_sources, last_synced_at: checkpoint.last_synced_at, }); } Ok(report) } fn describe_memory_connector( connector: &config::MemoryConnectorConfig, ) -> ( String, String, bool, Option, Option, Option, ) { match connector { config::MemoryConnectorConfig::JsonlFile(settings) => ( "jsonl_file".to_string(), settings.path.display().to_string(), false, settings.session_id.clone(), settings.default_entity_type.clone(), settings.default_observation_type.clone(), ), config::MemoryConnectorConfig::JsonlDirectory(settings) => ( "jsonl_directory".to_string(), settings.path.display().to_string(), settings.recurse, settings.session_id.clone(), settings.default_entity_type.clone(), settings.default_observation_type.clone(), ), config::MemoryConnectorConfig::MarkdownFile(settings) => ( "markdown_file".to_string(), settings.path.display().to_string(), false, settings.session_id.clone(), settings.default_entity_type.clone(), settings.default_observation_type.clone(), ), config::MemoryConnectorConfig::MarkdownDirectory(settings) => ( "markdown_directory".to_string(), settings.path.display().to_string(), settings.recurse, settings.session_id.clone(), settings.default_entity_type.clone(), settings.default_observation_type.clone(), ), config::MemoryConnectorConfig::DotenvFile(settings) => ( "dotenv_file".to_string(), settings.path.display().to_string(), false, settings.session_id.clone(), settings.default_entity_type.clone(), settings.default_observation_type.clone(), ), } } fn sync_jsonl_memory_connector( db: &session::store::StateStore, name: &str, settings: &config::MemoryConnectorJsonlFileConfig, limit: usize, ) -> Result { if settings.path.as_os_str().is_empty() { anyhow::bail!("memory connector {name} has no path configured"); } let file = File::open(&settings.path) .with_context(|| format!("open memory connector file {}", settings.path.display()))?; let reader = BufReader::new(file); let default_session_id = settings .session_id .as_deref() .map(|value| resolve_session_id(db, value)) .transpose()?; let source_path = settings.path.display().to_string(); let signature = connector_source_signature(&settings.path)?; if db.connector_source_is_unchanged(name, &source_path, &signature)? { return Ok(GraphConnectorSyncStats { connector_name: name.to_string(), skipped_unchanged_sources: 1, ..Default::default() }); } let stats = sync_jsonl_memory_reader( db, name, reader, default_session_id.as_deref(), settings.default_entity_type.as_deref(), settings.default_observation_type.as_deref(), limit, )?; if stats.records_read < limit { db.upsert_connector_source_checkpoint(name, &source_path, &signature)?; } Ok(stats) } fn sync_jsonl_directory_memory_connector( db: &session::store::StateStore, name: &str, settings: &config::MemoryConnectorJsonlDirectoryConfig, limit: usize, ) -> Result { if settings.path.as_os_str().is_empty() { anyhow::bail!("memory connector {name} has no path configured"); } if !settings.path.is_dir() { anyhow::bail!( "memory connector {name} path is not a directory: {}", settings.path.display() ); } let paths = collect_jsonl_paths(&settings.path, settings.recurse)?; let default_session_id = settings .session_id .as_deref() .map(|value| resolve_session_id(db, value)) .transpose()?; let mut stats = GraphConnectorSyncStats { connector_name: name.to_string(), ..Default::default() }; let mut remaining = limit; for path in paths { if remaining == 0 { break; } let source_path = path.display().to_string(); let signature = connector_source_signature(&path)?; if db.connector_source_is_unchanged(name, &source_path, &signature)? { stats.skipped_unchanged_sources += 1; continue; } let file = File::open(&path) .with_context(|| format!("open memory connector file {}", path.display()))?; let reader = BufReader::new(file); let remaining_before = remaining; let file_stats = sync_jsonl_memory_reader( db, name, reader, default_session_id.as_deref(), settings.default_entity_type.as_deref(), settings.default_observation_type.as_deref(), remaining, )?; remaining = remaining.saturating_sub(file_stats.records_read); stats.records_read += file_stats.records_read; stats.entities_upserted += file_stats.entities_upserted; stats.observations_added += file_stats.observations_added; stats.skipped_records += file_stats.skipped_records; stats.skipped_unchanged_sources += file_stats.skipped_unchanged_sources; if file_stats.records_read < remaining_before { db.upsert_connector_source_checkpoint(name, &source_path, &signature)?; } } Ok(stats) } fn sync_jsonl_memory_reader( db: &session::store::StateStore, name: &str, reader: R, default_session_id: Option<&str>, default_entity_type: Option<&str>, default_observation_type: Option<&str>, limit: usize, ) -> Result { let default_session_id = default_session_id.map(str::to_string); let mut stats = GraphConnectorSyncStats { connector_name: name.to_string(), ..Default::default() }; for line in reader.lines() { let line = line?; let trimmed = line.trim(); if trimmed.is_empty() { continue; } if stats.records_read >= limit { break; } stats.records_read += 1; let record: JsonlMemoryConnectorRecord = match serde_json::from_str(trimmed) { Ok(record) => record, Err(_) => { stats.skipped_records += 1; continue; } }; import_memory_connector_record( db, &mut stats, default_session_id.as_deref(), default_entity_type, default_observation_type, record, )?; } Ok(stats) } fn sync_markdown_memory_connector( db: &session::store::StateStore, name: &str, settings: &config::MemoryConnectorMarkdownFileConfig, limit: usize, ) -> Result { if settings.path.as_os_str().is_empty() { anyhow::bail!("memory connector {name} has no path configured"); } let default_session_id = settings .session_id .as_deref() .map(|value| resolve_session_id(db, value)) .transpose()?; let source_path = settings.path.display().to_string(); let signature = connector_source_signature(&settings.path)?; if db.connector_source_is_unchanged(name, &source_path, &signature)? { return Ok(GraphConnectorSyncStats { connector_name: name.to_string(), skipped_unchanged_sources: 1, ..Default::default() }); } let stats = sync_markdown_memory_path( db, name, "markdown_file", &settings.path, default_session_id.as_deref(), settings.default_entity_type.as_deref(), settings.default_observation_type.as_deref(), limit, )?; if stats.records_read < limit { db.upsert_connector_source_checkpoint(name, &source_path, &signature)?; } Ok(stats) } fn sync_markdown_directory_memory_connector( db: &session::store::StateStore, name: &str, settings: &config::MemoryConnectorMarkdownDirectoryConfig, limit: usize, ) -> Result { if settings.path.as_os_str().is_empty() { anyhow::bail!("memory connector {name} has no path configured"); } if !settings.path.is_dir() { anyhow::bail!( "memory connector {name} path is not a directory: {}", settings.path.display() ); } let paths = collect_markdown_paths(&settings.path, settings.recurse)?; let default_session_id = settings .session_id .as_deref() .map(|value| resolve_session_id(db, value)) .transpose()?; let mut stats = GraphConnectorSyncStats { connector_name: name.to_string(), ..Default::default() }; let mut remaining = limit; for path in paths { if remaining == 0 { break; } let source_path = path.display().to_string(); let signature = connector_source_signature(&path)?; if db.connector_source_is_unchanged(name, &source_path, &signature)? { stats.skipped_unchanged_sources += 1; continue; } let remaining_before = remaining; let file_stats = sync_markdown_memory_path( db, name, "markdown_directory", &path, default_session_id.as_deref(), settings.default_entity_type.as_deref(), settings.default_observation_type.as_deref(), remaining, )?; remaining = remaining.saturating_sub(file_stats.records_read); stats.records_read += file_stats.records_read; stats.entities_upserted += file_stats.entities_upserted; stats.observations_added += file_stats.observations_added; stats.skipped_records += file_stats.skipped_records; stats.skipped_unchanged_sources += file_stats.skipped_unchanged_sources; if file_stats.records_read < remaining_before { db.upsert_connector_source_checkpoint(name, &source_path, &signature)?; } } Ok(stats) } fn sync_markdown_memory_path( db: &session::store::StateStore, name: &str, connector_kind: &str, path: &Path, default_session_id: Option<&str>, default_entity_type: Option<&str>, default_observation_type: Option<&str>, limit: usize, ) -> Result { let body = std::fs::read_to_string(path) .with_context(|| format!("read memory connector file {}", path.display()))?; let sections = parse_markdown_memory_sections(path, &body, limit); let mut stats = GraphConnectorSyncStats { connector_name: name.to_string(), ..Default::default() }; for section in sections { stats.records_read += 1; let mut details = BTreeMap::new(); if !section.body.is_empty() { details.insert("body".to_string(), section.body.clone()); } details.insert("source_path".to_string(), path.display().to_string()); details.insert("line".to_string(), section.line_number.to_string()); let mut metadata = BTreeMap::new(); metadata.insert("connector".to_string(), connector_kind.to_string()); import_memory_connector_record( db, &mut stats, default_session_id, default_entity_type, default_observation_type, JsonlMemoryConnectorRecord { session_id: None, entity_type: None, entity_name: section.heading, path: Some(section.path), entity_summary: Some(section.summary.clone()), metadata, observation_type: None, summary: section.summary, details, }, )?; } Ok(stats) } fn sync_dotenv_memory_connector( db: &session::store::StateStore, name: &str, settings: &config::MemoryConnectorDotenvFileConfig, limit: usize, ) -> Result { if settings.path.as_os_str().is_empty() { anyhow::bail!("memory connector {name} has no path configured"); } let body = std::fs::read_to_string(&settings.path) .with_context(|| format!("read memory connector file {}", settings.path.display()))?; let default_session_id = settings .session_id .as_deref() .map(|value| resolve_session_id(db, value)) .transpose()?; let source_path = settings.path.display().to_string(); let signature = connector_source_signature(&settings.path)?; if db.connector_source_is_unchanged(name, &source_path, &signature)? { return Ok(GraphConnectorSyncStats { connector_name: name.to_string(), skipped_unchanged_sources: 1, ..Default::default() }); } let entries = parse_dotenv_memory_entries(&settings.path, &body, settings, limit); let mut stats = GraphConnectorSyncStats { connector_name: name.to_string(), ..Default::default() }; for entry in entries { stats.records_read += 1; import_memory_connector_record( db, &mut stats, default_session_id.as_deref(), settings.default_entity_type.as_deref(), settings.default_observation_type.as_deref(), JsonlMemoryConnectorRecord { session_id: None, entity_type: None, entity_name: entry.key, path: Some(entry.path), entity_summary: Some(entry.summary.clone()), metadata: BTreeMap::from([("connector".to_string(), "dotenv_file".to_string())]), observation_type: None, summary: entry.summary, details: entry.details, }, )?; } if stats.records_read < limit { db.upsert_connector_source_checkpoint(name, &source_path, &signature)?; } Ok(stats) } fn import_memory_connector_record( db: &session::store::StateStore, stats: &mut GraphConnectorSyncStats, default_session_id: Option<&str>, default_entity_type: Option<&str>, default_observation_type: Option<&str>, record: JsonlMemoryConnectorRecord, ) -> Result<()> { let session_id = match record.session_id.as_deref() { Some(value) => match resolve_session_id(db, value) { Ok(resolved) => Some(resolved), Err(_) => { stats.skipped_records += 1; return Ok(()); } }, None => default_session_id.map(str::to_string), }; let entity_type = record .entity_type .as_deref() .or(default_entity_type) .map(str::trim) .filter(|value| !value.is_empty()); let observation_type = record .observation_type .as_deref() .or(default_observation_type) .map(str::trim) .filter(|value| !value.is_empty()); let entity_name = record.entity_name.trim(); let summary = record.summary.trim(); let Some(entity_type) = entity_type else { stats.skipped_records += 1; return Ok(()); }; let Some(observation_type) = observation_type else { stats.skipped_records += 1; return Ok(()); }; if entity_name.is_empty() || summary.is_empty() { stats.skipped_records += 1; return Ok(()); } let entity_summary = record .entity_summary .as_deref() .map(str::trim) .filter(|value| !value.is_empty()) .unwrap_or(summary); let entity = db.upsert_context_entity( session_id.as_deref(), entity_type, entity_name, record.path.as_deref(), entity_summary, &record.metadata, )?; db.add_context_observation( session_id.as_deref(), entity.id, observation_type, session::ContextObservationPriority::Normal, false, summary, &record.details, )?; stats.entities_upserted += 1; stats.observations_added += 1; Ok(()) } fn collect_jsonl_paths(root: &Path, recurse: bool) -> Result> { let mut paths = Vec::new(); collect_jsonl_paths_inner(root, recurse, &mut paths)?; paths.sort(); Ok(paths) } fn collect_markdown_paths(root: &Path, recurse: bool) -> Result> { let mut paths = Vec::new(); collect_markdown_paths_inner(root, recurse, &mut paths)?; paths.sort(); Ok(paths) } fn connector_source_signature(path: &Path) -> Result { let metadata = std::fs::metadata(path) .with_context(|| format!("read memory connector metadata {}", path.display()))?; let modified = metadata .modified() .ok() .and_then(|timestamp| timestamp.duration_since(std::time::UNIX_EPOCH).ok()) .map(|duration| duration.as_nanos()) .unwrap_or(0); Ok(format!("{}:{modified}", metadata.len())) } fn collect_jsonl_paths_inner(root: &Path, recurse: bool, paths: &mut Vec) -> Result<()> { for entry in std::fs::read_dir(root) .with_context(|| format!("read memory connector directory {}", root.display()))? { let entry = entry?; let path = entry.path(); if path.is_dir() { if recurse { collect_jsonl_paths_inner(&path, recurse, paths)?; } continue; } if path .extension() .and_then(|value| value.to_str()) .is_some_and(|value| value.eq_ignore_ascii_case("jsonl")) { paths.push(path); } } Ok(()) } fn collect_markdown_paths_inner( root: &Path, recurse: bool, paths: &mut Vec, ) -> Result<()> { for entry in std::fs::read_dir(root) .with_context(|| format!("read memory connector directory {}", root.display()))? { let entry = entry?; let path = entry.path(); if path.is_dir() { if recurse { collect_markdown_paths_inner(&path, recurse, paths)?; } continue; } let is_markdown = path .extension() .and_then(|value| value.to_str()) .is_some_and(|value| { value.eq_ignore_ascii_case("md") || value.eq_ignore_ascii_case("markdown") }); if is_markdown { paths.push(path); } } Ok(()) } fn parse_dotenv_memory_entries( path: &Path, body: &str, settings: &config::MemoryConnectorDotenvFileConfig, limit: usize, ) -> Vec { if limit == 0 { return Vec::new(); } let mut entries = Vec::new(); let source_path = path.display().to_string(); for (index, raw_line) in body.lines().enumerate() { if entries.len() >= limit { break; } let line = raw_line.trim(); if line.is_empty() || line.starts_with('#') { continue; } let Some((key, value)) = parse_dotenv_assignment(line) else { continue; }; if !dotenv_key_included(key, settings) { continue; } let value = parse_dotenv_value(value); let secret_like = dotenv_key_is_secret(key); let mut details = BTreeMap::new(); details.insert("source_path".to_string(), source_path.clone()); details.insert("line".to_string(), (index + 1).to_string()); details.insert("key".to_string(), key.to_string()); details.insert("secret_redacted".to_string(), secret_like.to_string()); if settings.include_safe_values && !secret_like && !value.is_empty() { details.insert( "value".to_string(), truncate_connector_text(&value, DOTENV_CONNECTOR_VALUE_LIMIT), ); } let summary = if secret_like { format!("{key} configured (secret redacted)") } else if settings.include_safe_values && !value.is_empty() { format!( "{key}={}", truncate_connector_text(&value, DOTENV_CONNECTOR_VALUE_LIMIT) ) } else { format!("{key} configured") }; entries.push(DotenvMemoryEntry { key: key.to_string(), path: format!("{source_path}#{key}"), summary, details, }); } entries } fn parse_markdown_memory_sections( path: &Path, body: &str, limit: usize, ) -> Vec { if limit == 0 { return Vec::new(); } let source_path = path.display().to_string(); let fallback_heading = path .file_stem() .and_then(|value| value.to_str()) .filter(|value| !value.trim().is_empty()) .unwrap_or("note") .trim() .to_string(); let mut sections = Vec::new(); let mut preamble = Vec::new(); let mut current_heading: Option<(String, usize)> = None; let mut current_body = Vec::new(); for (index, line) in body.lines().enumerate() { let line_number = index + 1; if let Some(heading) = markdown_heading_title(line) { if let Some((title, start_line)) = current_heading.take() { if let Some(section) = markdown_memory_section( &source_path, &title, start_line, ¤t_body.join("\n"), ) { sections.push(section); } } else if !preamble.join("\n").trim().is_empty() { if let Some(section) = markdown_memory_section( &source_path, &fallback_heading, 1, &preamble.join("\n"), ) { sections.push(section); } } current_heading = Some((heading.to_string(), line_number)); current_body.clear(); continue; } if current_heading.is_some() { current_body.push(line.to_string()); } else { preamble.push(line.to_string()); } } if let Some((title, start_line)) = current_heading { if let Some(section) = markdown_memory_section(&source_path, &title, start_line, ¤t_body.join("\n")) { sections.push(section); } } else if let Some(section) = markdown_memory_section(&source_path, &fallback_heading, 1, &preamble.join("\n")) { sections.push(section); } sections.truncate(limit); sections } fn markdown_heading_title(line: &str) -> Option<&str> { let trimmed = line.trim_start(); let hashes = trimmed.chars().take_while(|ch| *ch == '#').count(); if hashes == 0 || hashes > 6 { return None; } let title = trimmed[hashes..].trim_start(); if title.is_empty() { return None; } Some(title.trim()) } fn markdown_memory_section( source_path: &str, heading: &str, line_number: usize, body: &str, ) -> Option { let heading = heading.trim(); if heading.is_empty() { return None; } let normalized_body = body.trim(); let summary = markdown_section_summary(heading, normalized_body); if summary.is_empty() { return None; } let slug = markdown_heading_slug(heading); let path = if slug.is_empty() { source_path.to_string() } else { format!("{source_path}#{slug}") }; Some(MarkdownMemorySection { heading: truncate_connector_text(heading, MARKDOWN_CONNECTOR_SUMMARY_LIMIT), path, summary, body: truncate_connector_text(normalized_body, MARKDOWN_CONNECTOR_BODY_LIMIT), line_number, }) } fn markdown_section_summary(heading: &str, body: &str) -> String { let candidate = body .lines() .map(str::trim) .find(|line| !line.is_empty()) .unwrap_or(heading); truncate_connector_text(candidate, MARKDOWN_CONNECTOR_SUMMARY_LIMIT) } fn markdown_heading_slug(value: &str) -> String { let mut slug = String::new(); let mut last_dash = false; for ch in value.chars() { if ch.is_ascii_alphanumeric() { slug.push(ch.to_ascii_lowercase()); last_dash = false; } else if !last_dash { slug.push('-'); last_dash = true; } } slug.trim_matches('-').to_string() } fn truncate_connector_text(value: &str, max_chars: usize) -> String { let trimmed = value.trim(); if trimmed.chars().count() <= max_chars { return trimmed.to_string(); } let truncated: String = trimmed.chars().take(max_chars.saturating_sub(1)).collect(); format!("{truncated}…") } fn parse_dotenv_assignment(line: &str) -> Option<(&str, &str)> { let trimmed = line.strip_prefix("export ").unwrap_or(line).trim(); let (key, value) = trimmed.split_once('=')?; let key = key.trim(); if key.is_empty() { return None; } Some((key, value.trim())) } fn parse_dotenv_value(raw: &str) -> String { let trimmed = raw.trim(); if let Some(unquoted) = trimmed .strip_prefix('"') .and_then(|value| value.strip_suffix('"')) { return unquoted.to_string(); } if let Some(unquoted) = trimmed .strip_prefix('\'') .and_then(|value| value.strip_suffix('\'')) { return unquoted.to_string(); } trimmed.to_string() } fn dotenv_key_included(key: &str, settings: &config::MemoryConnectorDotenvFileConfig) -> bool { if settings .exclude_keys .iter() .any(|candidate| candidate == key) { return false; } if !settings.include_keys.is_empty() && settings .include_keys .iter() .any(|candidate| candidate == key) { return true; } if settings.key_prefixes.is_empty() { return settings.include_keys.is_empty(); } settings .key_prefixes .iter() .any(|prefix| !prefix.is_empty() && key.starts_with(prefix)) } fn dotenv_key_is_secret(key: &str) -> bool { let upper = key.to_ascii_uppercase(); [ "SECRET", "TOKEN", "PASSWORD", "PRIVATE_KEY", "API_KEY", "CLIENT_SECRET", "ACCESS_KEY", ] .iter() .any(|marker| upper.contains(marker)) } fn build_message( kind: MessageKindArg, text: String, context: Option, priority: TaskPriorityArg, files: Vec, ) -> Result { Ok(match kind { MessageKindArg::Handoff => comms::MessageType::TaskHandoff { task: text, context: context.unwrap_or_default(), priority: priority.into(), }, MessageKindArg::Query => comms::MessageType::Query { question: text }, MessageKindArg::Response => comms::MessageType::Response { answer: text }, MessageKindArg::Completed => comms::MessageType::Completed { summary: text, files_changed: files, }, MessageKindArg::Conflict => { let file = files .first() .cloned() .ok_or_else(|| anyhow::anyhow!("Conflict messages require at least one --file"))?; comms::MessageType::Conflict { file, description: context.unwrap_or(text), } } }) } fn format_remote_dispatch_action(action: &session::manager::RemoteDispatchAction) -> String { match action { session::manager::RemoteDispatchAction::SpawnedTopLevel => "spawned top-level".to_string(), session::manager::RemoteDispatchAction::Assigned(action) => match action { session::manager::AssignmentAction::Spawned => "spawned delegate".to_string(), session::manager::AssignmentAction::ReusedIdle => "reused idle delegate".to_string(), session::manager::AssignmentAction::ReusedActive => { "reused active delegate".to_string() } session::manager::AssignmentAction::DeferredSaturated => { "deferred (saturated)".to_string() } }, session::manager::RemoteDispatchAction::DeferredSaturated => { "deferred (saturated)".to_string() } session::manager::RemoteDispatchAction::Failed(error) => format!("failed: {error}"), } } fn format_remote_dispatch_kind(kind: session::RemoteDispatchKind) -> &'static str { match kind { session::RemoteDispatchKind::Standard => "standard", session::RemoteDispatchKind::ComputerUse => "computer_use", } } fn short_session(session_id: &str) -> String { session_id.chars().take(8).collect() } fn run_remote_dispatch_server( db: &session::store::StateStore, cfg: &config::Config, bind_addr: &str, bearer_token: &str, ) -> Result<()> { let listener = TcpListener::bind(bind_addr) .with_context(|| format!("Failed to bind remote dispatch server on {bind_addr}"))?; println!("Remote dispatch server listening on http://{bind_addr}"); for stream in listener.incoming() { match stream { Ok(mut stream) => { if let Err(error) = handle_remote_dispatch_connection(&mut stream, db, cfg, bearer_token) { let _ = write_http_response( &mut stream, 500, "application/json", &serde_json::json!({ "error": error.to_string(), }) .to_string(), ); } } Err(error) => tracing::warn!("Remote dispatch accept failed: {error}"), } } Ok(()) } fn handle_remote_dispatch_connection( stream: &mut TcpStream, db: &session::store::StateStore, cfg: &config::Config, bearer_token: &str, ) -> Result<()> { let (method, path, headers, body) = read_http_request(stream)?; match (method.as_str(), path.as_str()) { ("GET", "/health") => write_http_response( stream, 200, "application/json", &serde_json::json!({"ok": true}).to_string(), ), ("POST", "/dispatch") => { let auth = headers .get("authorization") .map(String::as_str) .unwrap_or_default(); let expected = format!("Bearer {bearer_token}"); if auth != expected { return write_http_response( stream, 401, "application/json", &serde_json::json!({"error": "unauthorized"}).to_string(), ); } let payload: RemoteDispatchHttpRequest = serde_json::from_slice(&body).context("Invalid remote dispatch JSON body")?; if payload.task.trim().is_empty() { return write_http_response( stream, 400, "application/json", &serde_json::json!({"error": "task is required"}).to_string(), ); } let target_session_id = match payload .to_session .as_deref() .map(|value| resolve_session_id(db, value)) .transpose() { Ok(value) => value, Err(error) => { return write_http_response( stream, 400, "application/json", &serde_json::json!({"error": error.to_string()}).to_string(), ); } }; let requester = stream.peer_addr().ok().map(|addr| addr.ip().to_string()); let request = match session::manager::create_remote_dispatch_request( db, cfg, &payload.task, target_session_id.as_deref(), payload.priority.unwrap_or(TaskPriorityArg::Normal).into(), payload.agent.as_deref().unwrap_or(&cfg.default_agent), payload.profile.as_deref(), payload.use_worktree.unwrap_or(cfg.auto_create_worktrees), session::SessionGrouping { project: payload.project, task_group: payload.task_group, }, "http", requester.as_deref(), ) { Ok(request) => request, Err(error) => { return write_http_response( stream, 400, "application/json", &serde_json::json!({"error": error.to_string()}).to_string(), ); } }; write_http_response( stream, 202, "application/json", &serde_json::to_string(&request)?, ) } ("POST", "/computer-use") => { let auth = headers .get("authorization") .map(String::as_str) .unwrap_or_default(); let expected = format!("Bearer {bearer_token}"); if auth != expected { return write_http_response( stream, 401, "application/json", &serde_json::json!({"error": "unauthorized"}).to_string(), ); } let payload: RemoteComputerUseHttpRequest = serde_json::from_slice(&body).context("Invalid remote computer-use JSON body")?; if payload.goal.trim().is_empty() { return write_http_response( stream, 400, "application/json", &serde_json::json!({"error": "goal is required"}).to_string(), ); } let target_session_id = match payload .to_session .as_deref() .map(|value| resolve_session_id(db, value)) .transpose() { Ok(value) => value, Err(error) => { return write_http_response( stream, 400, "application/json", &serde_json::json!({"error": error.to_string()}).to_string(), ); } }; let requester = stream.peer_addr().ok().map(|addr| addr.ip().to_string()); let defaults = cfg.computer_use_dispatch_defaults(); let request = match session::manager::create_computer_use_remote_dispatch_request( db, cfg, &payload.goal, payload.target_url.as_deref(), payload.context.as_deref(), target_session_id.as_deref(), payload.priority.unwrap_or(TaskPriorityArg::Normal).into(), payload.agent.as_deref(), payload.profile.as_deref(), Some(payload.use_worktree.unwrap_or(defaults.use_worktree)), session::SessionGrouping { project: payload.project, task_group: payload.task_group, }, "http_computer_use", requester.as_deref(), ) { Ok(request) => request, Err(error) => { return write_http_response( stream, 400, "application/json", &serde_json::json!({"error": error.to_string()}).to_string(), ); } }; write_http_response( stream, 202, "application/json", &serde_json::to_string(&request)?, ) } _ => write_http_response( stream, 404, "application/json", &serde_json::json!({"error": "not found"}).to_string(), ), } } fn read_http_request( stream: &mut TcpStream, ) -> Result<(String, String, BTreeMap, Vec)> { let mut buffer = Vec::new(); let mut temp = [0_u8; 1024]; let header_end = loop { let read = stream.read(&mut temp)?; if read == 0 { anyhow::bail!("Unexpected EOF while reading HTTP request"); } buffer.extend_from_slice(&temp[..read]); if let Some(index) = buffer.windows(4).position(|window| window == b"\r\n\r\n") { break index + 4; } if buffer.len() > 64 * 1024 { anyhow::bail!("HTTP request headers too large"); } }; let header_text = String::from_utf8(buffer[..header_end].to_vec()) .context("HTTP request headers were not valid UTF-8")?; let mut lines = header_text.split("\r\n"); let request_line = lines .next() .filter(|line| !line.trim().is_empty()) .ok_or_else(|| anyhow::anyhow!("Missing HTTP request line"))?; let mut request_parts = request_line.split_whitespace(); let method = request_parts .next() .ok_or_else(|| anyhow::anyhow!("Missing HTTP method"))? .to_string(); let path = request_parts .next() .ok_or_else(|| anyhow::anyhow!("Missing HTTP path"))? .to_string(); let mut headers = BTreeMap::new(); for line in lines { if line.is_empty() { break; } if let Some((key, value)) = line.split_once(':') { headers.insert(key.trim().to_ascii_lowercase(), value.trim().to_string()); } } let content_length = headers .get("content-length") .and_then(|value| value.parse::().ok()) .unwrap_or(0); let mut body = buffer[header_end..].to_vec(); while body.len() < content_length { let read = stream.read(&mut temp)?; if read == 0 { anyhow::bail!("Unexpected EOF while reading HTTP request body"); } body.extend_from_slice(&temp[..read]); } body.truncate(content_length); Ok((method, path, headers, body)) } fn write_http_response( stream: &mut TcpStream, status: u16, content_type: &str, body: &str, ) -> Result<()> { let status_text = match status { 200 => "OK", 202 => "Accepted", 400 => "Bad Request", 401 => "Unauthorized", 404 => "Not Found", _ => "Internal Server Error", }; write!( stream, "HTTP/1.1 {status} {status_text}\r\nContent-Type: {content_type}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", body.len(), body )?; stream.flush()?; Ok(()) } fn format_coordination_status( status: &session::manager::CoordinationStatus, json: bool, ) -> Result { if json { return Ok(serde_json::to_string_pretty(status)?); } Ok(status.to_string()) } async fn run_coordination_loop( db: &session::store::StateStore, cfg: &config::Config, agent: &str, use_worktree: bool, lead_limit: usize, pass_budget: usize, emit_progress: bool, ) -> Result { let mut final_status = None; let mut pass_summaries = Vec::new(); for pass in 1..=pass_budget.max(1) { let outcome = session::manager::coordinate_backlog(db, cfg, agent, use_worktree, lead_limit).await?; let mut summary = summarize_coordinate_backlog(&outcome); summary.pass = pass; pass_summaries.push(summary.clone()); if emit_progress { if pass_budget > 1 { println!("Pass {pass}/{pass_budget}: {}", summary.message); } else { println!("{}", summary.message); } } let status = session::manager::get_coordination_status(db, cfg)?; let should_stop = matches!( status.health, session::manager::CoordinationHealth::Healthy | session::manager::CoordinationHealth::Saturated | session::manager::CoordinationHealth::EscalationRequired ); final_status = Some(status); if should_stop { break; } } let run = CoordinateBacklogRun { pass_budget, passes: pass_summaries, final_status, }; if emit_progress && pass_budget > 1 { if let Some(status) = run.final_status.as_ref() { println!( "Final coordination health: {:?} | mode {:?} | backlog {} handoff(s) across {} lead(s)", status.health, status.mode, status.backlog_messages, status.backlog_leads ); } } Ok(run) } #[derive(Debug, Clone, Serialize)] struct CoordinateBacklogPassSummary { pass: usize, processed: usize, routed: usize, deferred: usize, rerouted: usize, dispatched_leads: usize, rebalanced_leads: usize, remaining_backlog_sessions: usize, remaining_backlog_messages: usize, remaining_absorbable_sessions: usize, remaining_saturated_sessions: usize, message: String, } #[derive(Debug, Clone, Serialize)] struct CoordinateBacklogRun { pass_budget: usize, passes: Vec, final_status: Option, } #[derive(Debug, Clone, Serialize)] struct MaintainCoordinationRun { skipped: bool, initial_status: session::manager::CoordinationStatus, run: Option, final_status: session::manager::CoordinationStatus, } #[derive(Debug, Clone, Serialize)] struct WorktreeMergeReadinessReport { status: String, summary: String, conflicts: Vec, } #[derive(Debug, Clone, Serialize)] struct WorktreeStatusReport { session_id: String, task: String, session_state: String, health: String, check_exit_code: i32, patch_included: bool, attached: bool, path: Option, branch: Option, base_branch: Option, diff_summary: Option, file_preview: Vec, patch_preview: Option, merge_readiness: Option, } #[derive(Debug, Clone, Serialize)] struct WorktreeResolutionReport { session_id: String, task: String, session_state: String, attached: bool, conflicted: bool, check_exit_code: i32, path: Option, branch: Option, base_branch: Option, summary: String, conflicts: Vec, resolution_steps: Vec, } #[derive(Debug, Clone, Serialize, PartialEq)] #[serde(rename_all = "camelCase")] struct OtlpExport { resource_spans: Vec, } #[derive(Debug, Clone, Serialize, PartialEq)] #[serde(rename_all = "camelCase")] struct OtlpResourceSpans { resource: OtlpResource, scope_spans: Vec, } #[derive(Debug, Clone, Serialize, PartialEq)] #[serde(rename_all = "camelCase")] struct OtlpResource { attributes: Vec, } #[derive(Debug, Clone, Serialize, PartialEq)] #[serde(rename_all = "camelCase")] struct OtlpScopeSpans { scope: OtlpInstrumentationScope, spans: Vec, } #[derive(Debug, Clone, Serialize, PartialEq)] #[serde(rename_all = "camelCase")] struct OtlpInstrumentationScope { name: String, version: String, } #[derive(Debug, Clone, Serialize, PartialEq)] #[serde(rename_all = "camelCase")] struct OtlpSpan { trace_id: String, span_id: String, #[serde(skip_serializing_if = "Option::is_none")] parent_span_id: Option, name: String, kind: String, start_time_unix_nano: String, end_time_unix_nano: String, attributes: Vec, #[serde(skip_serializing_if = "Vec::is_empty")] links: Vec, status: OtlpSpanStatus, } #[derive(Debug, Clone, Serialize, PartialEq)] #[serde(rename_all = "camelCase")] struct OtlpSpanLink { trace_id: String, span_id: String, #[serde(skip_serializing_if = "Vec::is_empty")] attributes: Vec, } #[derive(Debug, Clone, Serialize, PartialEq)] #[serde(rename_all = "camelCase")] struct OtlpSpanStatus { code: String, #[serde(skip_serializing_if = "Option::is_none")] message: Option, } #[derive(Debug, Clone, Serialize, PartialEq)] #[serde(rename_all = "camelCase")] struct OtlpKeyValue { key: String, value: OtlpAnyValue, } #[derive(Debug, Clone, Serialize, PartialEq)] #[serde(rename_all = "camelCase")] struct OtlpAnyValue { #[serde(skip_serializing_if = "Option::is_none")] string_value: Option, #[serde(skip_serializing_if = "Option::is_none")] int_value: Option, #[serde(skip_serializing_if = "Option::is_none")] double_value: Option, #[serde(skip_serializing_if = "Option::is_none")] bool_value: Option, } fn build_worktree_status_report( session: &session::Session, include_patch: bool, ) -> Result { let Some(worktree) = session.worktree.as_ref() else { return Ok(WorktreeStatusReport { session_id: session.id.clone(), task: session.task.clone(), session_state: session.state.to_string(), health: "clear".to_string(), check_exit_code: 0, patch_included: include_patch, attached: false, path: None, branch: None, base_branch: None, diff_summary: None, file_preview: Vec::new(), patch_preview: None, merge_readiness: None, }); }; let diff_summary = worktree::diff_summary(worktree)?; let file_preview = worktree::diff_file_preview(worktree, 8)?; let patch_preview = if include_patch { worktree::diff_patch_preview(worktree, 80)? } else { None }; let merge_readiness = worktree::merge_readiness(worktree)?; let worktree_health = worktree::health(worktree)?; let (health, check_exit_code) = match worktree_health { worktree::WorktreeHealth::Conflicted => ("conflicted".to_string(), 2), worktree::WorktreeHealth::Clear => ("clear".to_string(), 0), worktree::WorktreeHealth::InProgress => ("in_progress".to_string(), 1), }; Ok(WorktreeStatusReport { session_id: session.id.clone(), task: session.task.clone(), session_state: session.state.to_string(), health, check_exit_code, patch_included: include_patch, attached: true, path: Some(worktree.path.display().to_string()), branch: Some(worktree.branch.clone()), base_branch: Some(worktree.base_branch.clone()), diff_summary, file_preview, patch_preview, merge_readiness: Some(WorktreeMergeReadinessReport { status: match merge_readiness.status { worktree::MergeReadinessStatus::Ready => "ready".to_string(), worktree::MergeReadinessStatus::Conflicted => "conflicted".to_string(), }, summary: merge_readiness.summary, conflicts: merge_readiness.conflicts, }), }) } fn build_worktree_resolution_report( session: &session::Session, ) -> Result { let Some(worktree) = session.worktree.as_ref() else { return Ok(WorktreeResolutionReport { session_id: session.id.clone(), task: session.task.clone(), session_state: session.state.to_string(), attached: false, conflicted: false, check_exit_code: 0, path: None, branch: None, base_branch: None, summary: "No worktree attached".to_string(), conflicts: Vec::new(), resolution_steps: Vec::new(), }); }; let merge_readiness = worktree::merge_readiness(worktree)?; let conflicted = merge_readiness.status == worktree::MergeReadinessStatus::Conflicted; let resolution_steps = if conflicted { vec![ format!( "Inspect current patch: ecc worktree-status {} --patch", session.id ), format!("Open worktree: cd {}", worktree.path.display()), "Resolve conflicts and stage files: git add ".to_string(), format!("Commit the resolution on {}: git commit", worktree.branch), format!( "Re-check readiness: ecc worktree-status {} --check", session.id ), format!("Merge when clear: ecc merge-worktree {}", session.id), ] } else { Vec::new() }; Ok(WorktreeResolutionReport { session_id: session.id.clone(), task: session.task.clone(), session_state: session.state.to_string(), attached: true, conflicted, check_exit_code: if conflicted { 2 } else { 0 }, path: Some(worktree.path.display().to_string()), branch: Some(worktree.branch.clone()), base_branch: Some(worktree.base_branch.clone()), summary: merge_readiness.summary, conflicts: merge_readiness.conflicts, resolution_steps, }) } fn format_worktree_status_human(report: &WorktreeStatusReport) -> String { let mut lines = vec![format!( "Worktree status for {} [{}]", short_session(&report.session_id), report.session_state )]; lines.push(format!("Task {}", report.task)); lines.push(format!("Health {}", report.health)); if !report.attached { lines.push("No worktree attached".to_string()); return lines.join("\n"); } if let Some(path) = report.path.as_ref() { lines.push(format!("Path {path}")); } if let (Some(branch), Some(base_branch)) = (report.branch.as_ref(), report.base_branch.as_ref()) { lines.push(format!("Branch {branch} (base {base_branch})")); } if let Some(diff_summary) = report.diff_summary.as_ref() { lines.push(diff_summary.clone()); } if !report.file_preview.is_empty() { lines.push("Files".to_string()); for entry in &report.file_preview { lines.push(format!("- {entry}")); } } if let Some(merge_readiness) = report.merge_readiness.as_ref() { lines.push(merge_readiness.summary.clone()); for conflict in merge_readiness.conflicts.iter().take(5) { lines.push(format!("- conflict {conflict}")); } } if report.patch_included { if let Some(patch_preview) = report.patch_preview.as_ref() { lines.push("Patch preview".to_string()); lines.push(patch_preview.clone()); } else { lines.push("Patch preview unavailable".to_string()); } } lines.join("\n") } fn format_worktree_status_reports_human(reports: &[WorktreeStatusReport]) -> String { reports .iter() .map(format_worktree_status_human) .collect::>() .join("\n\n") } fn format_worktree_resolution_human(report: &WorktreeResolutionReport) -> String { let mut lines = vec![format!( "Worktree resolution for {} [{}]", short_session(&report.session_id), report.session_state )]; lines.push(format!("Task {}", report.task)); if !report.attached { lines.push(report.summary.clone()); return lines.join("\n"); } if let Some(path) = report.path.as_ref() { lines.push(format!("Path {path}")); } if let (Some(branch), Some(base_branch)) = (report.branch.as_ref(), report.base_branch.as_ref()) { lines.push(format!("Branch {branch} (base {base_branch})")); } lines.push(report.summary.clone()); if !report.conflicts.is_empty() { lines.push("Conflicts".to_string()); for conflict in &report.conflicts { lines.push(format!("- {conflict}")); } } if report.resolution_steps.is_empty() { lines.push("No conflict-resolution steps required".to_string()); } else { lines.push("Resolution steps".to_string()); for (index, step) in report.resolution_steps.iter().enumerate() { lines.push(format!("{}. {step}", index + 1)); } } lines.join("\n") } fn format_worktree_resolution_reports_human(reports: &[WorktreeResolutionReport]) -> String { if reports.is_empty() { return "No conflicted worktrees found".to_string(); } reports .iter() .map(format_worktree_resolution_human) .collect::>() .join("\n\n") } fn format_worktree_merge_human(outcome: &session::manager::WorktreeMergeOutcome) -> String { let mut lines = vec![format!( "Merged worktree for {}", short_session(&outcome.session_id) )]; lines.push(format!( "Branch {} -> {}", outcome.branch, outcome.base_branch )); lines.push(if outcome.already_up_to_date { "Result already up to date".to_string() } else { "Result merged into base".to_string() }); lines.push(if outcome.cleaned_worktree { "Cleanup removed worktree and branch".to_string() } else { "Cleanup kept worktree attached".to_string() }); lines.join("\n") } fn format_bulk_worktree_merge_human( outcome: &session::manager::WorktreeBulkMergeOutcome, ) -> String { let mut lines = Vec::new(); lines.push(format!("Merged {} ready worktree(s)", outcome.merged.len())); for merged in &outcome.merged { lines.push(format!( "- merged {} -> {} for {}{}", merged.branch, merged.base_branch, short_session(&merged.session_id), if merged.already_up_to_date { " (already up to date)" } else { "" } )); } if !outcome.rebased.is_empty() { lines.push(format!( "Rebased {} blocked worktree(s) onto their base branch", outcome.rebased.len() )); for rebased in &outcome.rebased { lines.push(format!( "- rebased {} onto {} for {}{}", rebased.branch, rebased.base_branch, short_session(&rebased.session_id), if rebased.already_up_to_date { " (already up to date)" } else { "" } )); } } if !outcome.active_with_worktree_ids.is_empty() { lines.push(format!( "Skipped {} active worktree session(s)", outcome.active_with_worktree_ids.len() )); } if !outcome.conflicted_session_ids.is_empty() { lines.push(format!( "Skipped {} conflicted worktree(s)", outcome.conflicted_session_ids.len() )); } if !outcome.dirty_worktree_ids.is_empty() { lines.push(format!( "Skipped {} dirty worktree(s)", outcome.dirty_worktree_ids.len() )); } if !outcome.blocked_by_queue_session_ids.is_empty() { lines.push(format!( "Blocked {} worktree(s) on remaining queue conflicts", outcome.blocked_by_queue_session_ids.len() )); } if !outcome.failures.is_empty() { lines.push(format!( "Encountered {} merge failure(s)", outcome.failures.len() )); for failure in &outcome.failures { lines.push(format!( "- failed {}: {}", short_session(&failure.session_id), failure.reason )); } } lines.join("\n") } fn worktree_status_exit_code(report: &WorktreeStatusReport) -> i32 { report.check_exit_code } fn worktree_status_reports_exit_code(reports: &[WorktreeStatusReport]) -> i32 { reports .iter() .map(worktree_status_exit_code) .max() .unwrap_or(0) } fn worktree_resolution_reports_exit_code(reports: &[WorktreeResolutionReport]) -> i32 { reports .iter() .map(|report| report.check_exit_code) .max() .unwrap_or(0) } fn format_prune_worktrees_human(outcome: &session::manager::WorktreePruneOutcome) -> String { let mut lines = Vec::new(); if outcome.cleaned_session_ids.is_empty() { lines.push("Pruned 0 inactive worktree(s)".to_string()); } else { lines.push(format!( "Pruned {} inactive worktree(s)", outcome.cleaned_session_ids.len() )); for session_id in &outcome.cleaned_session_ids { lines.push(format!("- cleaned {}", short_session(session_id))); } } if outcome.active_with_worktree_ids.is_empty() { lines.push("No active sessions are holding worktrees".to_string()); } else { lines.push(format!( "Skipped {} active session(s) still holding worktrees", outcome.active_with_worktree_ids.len() )); for session_id in &outcome.active_with_worktree_ids { lines.push(format!("- active {}", short_session(session_id))); } } if outcome.retained_session_ids.is_empty() { lines.push("No inactive worktrees are being retained".to_string()); } else { lines.push(format!( "Deferred {} inactive worktree(s) still within retention", outcome.retained_session_ids.len() )); for session_id in &outcome.retained_session_ids { lines.push(format!("- retained {}", short_session(session_id))); } } lines.join("\n") } fn format_logged_decision_human(entry: &session::DecisionLogEntry) -> String { let mut lines = vec![ format!("Logged decision for {}", short_session(&entry.session_id)), format!("Decision: {}", entry.decision), format!("Why: {}", entry.reasoning), ]; if entry.alternatives.is_empty() { lines.push("Alternatives: none recorded".to_string()); } else { lines.push("Alternatives:".to_string()); for alternative in &entry.alternatives { lines.push(format!("- {alternative}")); } } lines.push(format!( "Recorded at: {}", entry.timestamp.format("%Y-%m-%d %H:%M:%S UTC") )); lines.join("\n") } fn format_decisions_human(entries: &[session::DecisionLogEntry], include_session: bool) -> String { if entries.is_empty() { return if include_session { "No decision-log entries across all sessions yet.".to_string() } else { "No decision-log entries for this session yet.".to_string() }; } let mut lines = vec![format!("Decision log: {} entries", entries.len())]; for entry in entries { let prefix = if include_session { format!("{} | ", short_session(&entry.session_id)) } else { String::new() }; lines.push(format!( "- [{}] {prefix}{}", entry.timestamp.format("%H:%M:%S"), entry.decision )); lines.push(format!(" why {}", entry.reasoning)); if entry.alternatives.is_empty() { lines.push(" alternatives none recorded".to_string()); } else { for alternative in &entry.alternatives { lines.push(format!(" alternative {alternative}")); } } } lines.join("\n") } fn format_graph_entity_human(entity: &session::ContextGraphEntity) -> String { let mut lines = vec![ format!("Context graph entity #{}", entity.id), format!("Type: {}", entity.entity_type), format!("Name: {}", entity.name), ]; if let Some(path) = &entity.path { lines.push(format!("Path: {path}")); } if let Some(session_id) = &entity.session_id { lines.push(format!("Session: {}", short_session(session_id))); } if entity.summary.is_empty() { lines.push("Summary: none recorded".to_string()); } else { lines.push(format!("Summary: {}", entity.summary)); } if entity.metadata.is_empty() { lines.push("Metadata: none recorded".to_string()); } else { lines.push("Metadata:".to_string()); for (key, value) in &entity.metadata { lines.push(format!("- {key}={value}")); } } lines.push(format!( "Updated: {}", entity.updated_at.format("%Y-%m-%d %H:%M:%S UTC") )); lines.join("\n") } fn format_graph_entities_human( entities: &[session::ContextGraphEntity], include_session: bool, ) -> String { if entities.is_empty() { return "No context graph entities found.".to_string(); } let mut lines = vec![format!("Context graph entities: {}", entities.len())]; for entity in entities { let mut line = format!("- #{} [{}] {}", entity.id, entity.entity_type, entity.name); if include_session { line.push_str(&format!( " | {}", entity .session_id .as_deref() .map(short_session) .unwrap_or_else(|| "global".to_string()) )); } if let Some(path) = &entity.path { line.push_str(&format!(" | {path}")); } lines.push(line); if !entity.summary.is_empty() { lines.push(format!(" summary {}", entity.summary)); } } lines.join("\n") } fn format_graph_relation_human(relation: &session::ContextGraphRelation) -> String { let mut lines = vec![ format!("Context graph relation #{}", relation.id), format!( "Edge: #{} [{}] {} -> #{} [{}] {}", relation.from_entity_id, relation.from_entity_type, relation.from_entity_name, relation.to_entity_id, relation.to_entity_type, relation.to_entity_name ), format!("Relation: {}", relation.relation_type), ]; if let Some(session_id) = &relation.session_id { lines.push(format!("Session: {}", short_session(session_id))); } if relation.summary.is_empty() { lines.push("Summary: none recorded".to_string()); } else { lines.push(format!("Summary: {}", relation.summary)); } lines.push(format!( "Created: {}", relation.created_at.format("%Y-%m-%d %H:%M:%S UTC") )); lines.join("\n") } fn format_graph_relations_human(relations: &[session::ContextGraphRelation]) -> String { if relations.is_empty() { return "No context graph relations found.".to_string(); } let mut lines = vec![format!("Context graph relations: {}", relations.len())]; for relation in relations { lines.push(format!( "- #{} {} -> {} [{}]", relation.id, relation.from_entity_name, relation.to_entity_name, relation.relation_type )); if !relation.summary.is_empty() { lines.push(format!(" summary {}", relation.summary)); } } lines.join("\n") } fn format_graph_observation_human(observation: &session::ContextGraphObservation) -> String { let mut lines = vec![ format!("Context graph observation #{}", observation.id), format!( "Entity: #{} [{}] {}", observation.entity_id, observation.entity_type, observation.entity_name ), format!("Type: {}", observation.observation_type), format!("Priority: {}", observation.priority), format!("Pinned: {}", if observation.pinned { "yes" } else { "no" }), format!("Summary: {}", observation.summary), ]; if let Some(session_id) = observation.session_id.as_deref() { lines.push(format!("Session: {}", short_session(session_id))); } if observation.details.is_empty() { lines.push("Details: none recorded".to_string()); } else { lines.push("Details:".to_string()); for (key, value) in &observation.details { lines.push(format!("- {key}={value}")); } } lines.push(format!( "Created: {}", observation.created_at.format("%Y-%m-%d %H:%M:%S UTC") )); lines.join("\n") } fn format_graph_observations_human(observations: &[session::ContextGraphObservation]) -> String { if observations.is_empty() { return "No context graph observations found.".to_string(); } let mut lines = vec![format!( "Context graph observations: {}", observations.len() )]; for observation in observations { let mut line = format!( "- #{} [{}/{}{}] {}", observation.id, observation.observation_type, observation.priority, if observation.pinned { "/pinned" } else { "" }, observation.entity_name ); if let Some(session_id) = observation.session_id.as_deref() { line.push_str(&format!(" | {}", short_session(session_id))); } lines.push(line); lines.push(format!(" summary {}", observation.summary)); } lines.join("\n") } fn build_legacy_migration_audit_report(source: &Path) -> Result { let source = source .canonicalize() .with_context(|| format!("Legacy workspace not found: {}", source.display()))?; if !source.is_dir() { anyhow::bail!( "Legacy workspace source must be a directory: {}", source.display() ); } let mut artifacts = Vec::new(); let scheduler_paths = collect_existing_relative_paths( &source, &["cron/scheduler.py", "jobs.py", "cron/jobs.json"], ); if !scheduler_paths.is_empty() { artifacts.push(LegacyMigrationArtifact { category: "scheduler".to_string(), readiness: LegacyMigrationReadiness::ReadyNow, detected_items: scheduler_paths.len(), source_paths: scheduler_paths, mapping: vec![ "ecc schedule add".to_string(), "ecc schedule list".to_string(), "ecc schedule run-due".to_string(), "ecc daemon".to_string(), ], notes: vec![ "Recurring jobs can be recreated directly in ECC2's persistent scheduler." .to_string(), "Translate each legacy cron prompt into an explicit ECC task body before enabling it." .to_string(), ], }); } let gateway_dir = source.join("gateway"); if gateway_dir.is_dir() { artifacts.push(LegacyMigrationArtifact { category: "gateway_dispatch".to_string(), readiness: LegacyMigrationReadiness::ReadyNow, detected_items: count_files_recursive(&gateway_dir)?, source_paths: vec!["gateway".to_string()], mapping: vec![ "ecc remote serve".to_string(), "ecc remote add".to_string(), "ecc remote computer-use".to_string(), "ecc remote run".to_string(), ], notes: vec![ "ECC2 already ships a token-authenticated remote dispatch queue and HTTP intake." .to_string(), "Remote handlers should be translated to ECC task bodies instead of copied verbatim." .to_string(), ], }); } let memory_paths = collect_existing_relative_paths(&source, &["memory_tool.py"]); if !memory_paths.is_empty() { artifacts.push(LegacyMigrationArtifact { category: "memory_tool".to_string(), readiness: LegacyMigrationReadiness::ReadyNow, detected_items: memory_paths.len(), source_paths: memory_paths, mapping: vec![ "ecc graph add-observation".to_string(), "ecc graph connector-sync".to_string(), "ecc graph recall".to_string(), "ecc graph connectors".to_string(), ], notes: vec![ "ECC2 deep memory now supports persistent observations, recall, compaction, and external connectors." .to_string(), ], }); } let workspace_dir = source.join("workspace"); if workspace_dir.is_dir() { artifacts.push(LegacyMigrationArtifact { category: "workspace_memory".to_string(), readiness: LegacyMigrationReadiness::ReadyNow, detected_items: count_files_recursive(&workspace_dir)?, source_paths: vec!["workspace".to_string()], mapping: vec![ "ecc graph connector-sync".to_string(), "ecc graph recall".to_string(), "WORKING-CONTEXT.md".to_string(), ], notes: vec![ "Import only sanitized operator memory into the shared context graph." .to_string(), "Private business data, secrets, and personal archives should stay outside the public repo." .to_string(), ], }); } let skills_paths = collect_existing_relative_paths(&source, &["skills", "skills/ecc-imports"]); if !skills_paths.is_empty() { artifacts.push(LegacyMigrationArtifact { category: "skills".to_string(), readiness: LegacyMigrationReadiness::ManualTranslation, detected_items: count_files_recursive(&source.join("skills"))?, source_paths: skills_paths, mapping: vec![ "skills/".to_string(), "ecc template".to_string(), "configure-ecc".to_string(), ], notes: vec![ "Reusable skills should be ported one by one into ECC-native skills or orchestration templates." .to_string(), "Do not bulk-copy legacy private skills without auditing for secrets and operator-only assumptions." .to_string(), ], }); } let tools_dir = source.join("tools"); if tools_dir.is_dir() { artifacts.push(LegacyMigrationArtifact { category: "tools".to_string(), readiness: LegacyMigrationReadiness::ManualTranslation, detected_items: count_files_recursive(&tools_dir)?, source_paths: vec!["tools".to_string()], mapping: vec![ "agents/".to_string(), "commands/".to_string(), "hooks/".to_string(), "harness_runners.".to_string(), ], notes: vec![ "Legacy tool wrappers should be rebuilt as ECC agents, commands, hooks, or configured harness runners." .to_string(), "Only the reusable workflow surface should move across; opaque runtime glue should be reimplemented minimally." .to_string(), ], }); } let plugins_dir = source.join("plugins"); if plugins_dir.is_dir() { artifacts.push(LegacyMigrationArtifact { category: "plugins".to_string(), readiness: LegacyMigrationReadiness::ManualTranslation, detected_items: count_files_recursive(&plugins_dir)?, source_paths: vec!["plugins".to_string()], mapping: vec![ "hooks/".to_string(), "commands/".to_string(), "skills/".to_string(), ], notes: vec![ "Bridge plugins normally translate into ECC hooks, commands, or skills instead of one-for-one plugin copies." .to_string(), ], }); } let env_service_paths = collect_env_service_paths(&source)?; if !env_service_paths.is_empty() { artifacts.push(LegacyMigrationArtifact { category: "env_services".to_string(), readiness: LegacyMigrationReadiness::LocalAuthRequired, detected_items: env_service_paths.len(), source_paths: env_service_paths, mapping: vec![ "Claude connectors / OAuth".to_string(), "MCP config".to_string(), "local API key setup".to_string(), ], notes: vec![ "Secret material should not be imported into ECC2." .to_string(), "Re-enter credentials locally through connectors, OAuth, MCP servers, or local env configuration." .to_string(), ], }); } let summary = LegacyMigrationAuditSummary { artifact_categories_detected: artifacts.len(), ready_now_categories: artifacts .iter() .filter(|artifact| artifact.readiness == LegacyMigrationReadiness::ReadyNow) .count(), manual_translation_categories: artifacts .iter() .filter(|artifact| artifact.readiness == LegacyMigrationReadiness::ManualTranslation) .count(), local_auth_required_categories: artifacts .iter() .filter(|artifact| artifact.readiness == LegacyMigrationReadiness::LocalAuthRequired) .count(), }; Ok(LegacyMigrationAuditReport { source: source.display().to_string(), detected_systems: detect_legacy_workspace_systems(&source, &artifacts), summary, recommended_next_steps: build_legacy_migration_next_steps(&artifacts), artifacts, }) } fn collect_existing_relative_paths(source: &Path, relative_paths: &[&str]) -> Vec { let mut matches = Vec::new(); for relative_path in relative_paths { if source.join(relative_path).exists() { matches.push((*relative_path).to_string()); } } matches } fn collect_env_service_paths(source: &Path) -> Result> { let mut matches = Vec::new(); for file_name in [ "config.yaml", ".env", ".env.local", ".env.production", ".envrc", ] { if source.join(file_name).is_file() { matches.push(file_name.to_string()); } } let services_dir = source.join("services"); if services_dir.is_dir() { let service_file_count = count_files_recursive(&services_dir)?; if service_file_count > 0 { matches.push("services".to_string()); } } Ok(matches) } fn count_files_recursive(path: &Path) -> Result { if !path.exists() { return Ok(0); } if path.is_file() { return Ok(1); } let mut total = 0usize; for entry in fs::read_dir(path)? { let entry = entry?; let entry_path = entry.path(); total += count_files_recursive(&entry_path)?; } Ok(total) } fn detect_legacy_workspace_systems( source: &Path, artifacts: &[LegacyMigrationArtifact], ) -> Vec { let mut detected = BTreeSet::new(); let display = source.display().to_string().to_lowercase(); if display.contains("hermes") || source.join("config.yaml").is_file() || source.join("cron").exists() || source.join("workspace").exists() { detected.insert("hermes".to_string()); } if display.contains("openclaw") || source.join(".openclaw").exists() { detected.insert("openclaw".to_string()); } if detected.is_empty() && !artifacts.is_empty() { detected.insert("legacy_workspace".to_string()); } detected.into_iter().collect() } fn build_legacy_migration_next_steps(artifacts: &[LegacyMigrationArtifact]) -> Vec { let mut steps = Vec::new(); let categories: BTreeSet<&str> = artifacts .iter() .map(|artifact| artifact.category.as_str()) .collect(); if categories.contains("scheduler") { steps.push( "Recreate recurring jobs with `ecc schedule add`, verify them with `ecc schedule list`, then enable processing through `ecc daemon`." .to_string(), ); } if categories.contains("gateway_dispatch") { steps.push( "Replace gateway/dispatch entrypoints with `ecc remote serve`, `ecc remote add`, and `ecc remote computer-use`." .to_string(), ); } if categories.contains("memory_tool") || categories.contains("workspace_memory") { steps.push( "Import sanitized operator memory through `ecc graph connector-sync`, then use `ecc graph recall` and pinned observations for durable context." .to_string(), ); } if categories.contains("skills") { steps.push( "Translate reusable Hermes/OpenClaw skills into ECC skills or orchestration templates one lane at a time instead of bulk-copying them." .to_string(), ); } if categories.contains("tools") || categories.contains("plugins") { steps.push( "Rebuild valuable tool/plugin wrappers as ECC agents, commands, hooks, or harness runners, keeping only reusable workflow behavior." .to_string(), ); } if categories.contains("env_services") { steps.push( "Reconfigure credentials locally through Claude connectors, MCP config, OAuth, or local API key setup; do not import raw secret material." .to_string(), ); } if steps.is_empty() { steps.push( "No recognizable Hermes/OpenClaw migration surfaces were detected; inspect the workspace manually before attempting migration." .to_string(), ); } steps } #[derive(Debug, Clone, PartialEq, Eq)] struct LegacyScheduleDraft { source_path: String, job_name: String, cron_expr: Option, task: Option, agent: Option, profile: Option, project: Option, task_group: Option, use_worktree: Option, enabled: bool, } fn load_legacy_schedule_drafts(source: &Path) -> Result> { let jobs_path = source.join("cron/jobs.json"); if !jobs_path.is_file() { return Ok(Vec::new()); } let text = fs::read_to_string(&jobs_path) .with_context(|| format!("read legacy scheduler jobs: {}", jobs_path.display()))?; let value: serde_json::Value = serde_json::from_str(&text) .with_context(|| format!("parse legacy scheduler jobs JSON: {}", jobs_path.display()))?; let source_path = jobs_path .strip_prefix(source) .unwrap_or(&jobs_path) .display() .to_string(); let entries: Vec<&serde_json::Value> = match &value { serde_json::Value::Array(items) => items.iter().collect(), serde_json::Value::Object(map) => { if let Some(items) = ["jobs", "schedules", "tasks"] .iter() .find_map(|key| map.get(*key).and_then(serde_json::Value::as_array)) { items.iter().collect() } else { vec![&value] } } _ => anyhow::bail!( "legacy scheduler jobs file must be a JSON object or array: {}", jobs_path.display() ), }; Ok(entries .into_iter() .enumerate() .map(|(index, value)| build_legacy_schedule_draft(value, index, &source_path)) .collect()) } fn build_legacy_schedule_draft( value: &serde_json::Value, index: usize, source_path: &str, ) -> LegacyScheduleDraft { let job_name = json_string_candidates( value, &[ &["name"], &["id"], &["title"], &["job_name"], &["task_name"], ], ) .unwrap_or_else(|| format!("legacy-job-{}", index + 1)); let cron_expr = json_string_candidates( value, &[ &["cron"], &["schedule"], &["cron_expr"], &["trigger", "cron"], &["timing", "cron"], ], ); let task = json_string_candidates( value, &[ &["task"], &["prompt"], &["goal"], &["description"], &["command"], &["task", "prompt"], &["task", "description"], ], ); let enabled = !json_bool_candidates(value, &[&["disabled"]]).unwrap_or(false) && json_bool_candidates(value, &[&["enabled"], &["active"]]).unwrap_or(true); LegacyScheduleDraft { source_path: source_path.to_string(), job_name, cron_expr, task, agent: json_string_candidates(value, &[&["agent"], &["runner"]]), profile: json_string_candidates(value, &[&["profile"], &["agent_profile"]]), project: json_string_candidates(value, &[&["project"]]), task_group: json_string_candidates(value, &[&["task_group"], &["group"]]), use_worktree: json_bool_candidates(value, &[&["use_worktree"], &["worktree"]]), enabled, } } fn json_string_candidates(value: &serde_json::Value, paths: &[&[&str]]) -> Option { paths .iter() .find_map(|path| json_lookup(value, path)) .and_then(json_to_string) } fn json_bool_candidates(value: &serde_json::Value, paths: &[&[&str]]) -> Option { paths.iter().find_map(|path| { json_lookup(value, path).and_then(|value| match value { serde_json::Value::Bool(boolean) => Some(*boolean), serde_json::Value::String(text) => match text.trim().to_ascii_lowercase().as_str() { "true" | "1" | "yes" | "on" => Some(true), "false" | "0" | "no" | "off" => Some(false), _ => None, }, _ => None, }) }) } fn json_lookup<'a>(value: &'a serde_json::Value, path: &[&str]) -> Option<&'a serde_json::Value> { let mut current = value; for segment in path { current = current.get(*segment)?; } Some(current) } fn json_to_string(value: &serde_json::Value) -> Option { match value { serde_json::Value::String(text) => { let trimmed = text.trim(); if trimmed.is_empty() { None } else { Some(trimmed.to_string()) } } serde_json::Value::Number(number) => Some(number.to_string()), _ => None, } } fn shell_quote_double(value: &str) -> String { format!( "\"{}\"", value .replace('\\', "\\\\") .replace('"', "\\\"") .replace('\n', "\\n") ) } fn validate_schedule_cron_expr(expr: &str) -> Result<()> { let trimmed = expr.trim(); let normalized = match trimmed.split_whitespace().count() { 5 => format!("0 {trimmed}"), 6 | 7 => trimmed.to_string(), fields => { anyhow::bail!( "invalid cron expression `{trimmed}`: expected 5, 6, or 7 fields but found {fields}" ) } }; ::from_str(&normalized) .with_context(|| format!("invalid cron expression `{trimmed}`"))?; Ok(()) } fn build_legacy_schedule_add_command(draft: &LegacyScheduleDraft) -> Option { let cron_expr = draft.cron_expr.as_deref()?; let task = draft.task.as_deref()?; let mut parts = vec![ "ecc schedule add".to_string(), format!("--cron {}", shell_quote_double(cron_expr)), format!("--task {}", shell_quote_double(task)), ]; if let Some(agent) = draft.agent.as_deref() { parts.push(format!("--agent {}", shell_quote_double(agent))); } if let Some(profile) = draft.profile.as_deref() { parts.push(format!("--profile {}", shell_quote_double(profile))); } match draft.use_worktree { Some(true) => parts.push("--worktree".to_string()), Some(false) => parts.push("--no-worktree".to_string()), None => {} } if let Some(project) = draft.project.as_deref() { parts.push(format!("--project {}", shell_quote_double(project))); } if let Some(task_group) = draft.task_group.as_deref() { parts.push(format!("--task-group {}", shell_quote_double(task_group))); } Some(parts.join(" ")) } fn import_legacy_schedules( db: &session::store::StateStore, cfg: &config::Config, source: &Path, dry_run: bool, ) -> Result { let source = source .canonicalize() .with_context(|| format!("Legacy workspace not found: {}", source.display()))?; if !source.is_dir() { anyhow::bail!( "Legacy workspace source must be a directory: {}", source.display() ); } let drafts = load_legacy_schedule_drafts(&source)?; let source_path = source.join("cron/jobs.json"); let source_path = source_path .strip_prefix(&source) .unwrap_or(&source_path) .display() .to_string(); let mut report = LegacyScheduleImportReport { source: source.display().to_string(), source_path, dry_run, jobs_detected: drafts.len(), ready_jobs: 0, imported_jobs: 0, disabled_jobs: 0, invalid_jobs: 0, skipped_jobs: 0, jobs: Vec::new(), }; for draft in drafts { let mut item = LegacyScheduleImportJobReport { source_path: draft.source_path.clone(), job_name: draft.job_name.clone(), cron_expr: draft.cron_expr.clone(), task: draft.task.clone(), agent: draft.agent.clone(), profile: draft.profile.clone(), project: draft.project.clone(), task_group: draft.task_group.clone(), use_worktree: draft.use_worktree, status: LegacyScheduleImportJobStatus::Ready, reason: None, command_snippet: build_legacy_schedule_add_command(&draft), imported_schedule_id: None, }; if !draft.enabled { item.status = LegacyScheduleImportJobStatus::Disabled; item.reason = Some("disabled in legacy workspace".to_string()); report.disabled_jobs += 1; report.jobs.push(item); continue; } let cron_expr = match draft.cron_expr.as_deref() { Some(value) => value, None => { item.status = LegacyScheduleImportJobStatus::Invalid; item.reason = Some("missing cron expression".to_string()); report.invalid_jobs += 1; report.jobs.push(item); continue; } }; let task = match draft.task.as_deref() { Some(value) => value, None => { item.status = LegacyScheduleImportJobStatus::Invalid; item.reason = Some("missing task/prompt".to_string()); report.invalid_jobs += 1; report.jobs.push(item); continue; } }; if let Err(error) = validate_schedule_cron_expr(cron_expr) { item.status = LegacyScheduleImportJobStatus::Invalid; item.reason = Some(error.to_string()); report.invalid_jobs += 1; report.jobs.push(item); continue; } if let Some(profile) = draft.profile.as_deref() { if let Err(error) = cfg.resolve_agent_profile(profile) { item.status = LegacyScheduleImportJobStatus::Skipped; item.reason = Some(format!("profile `{profile}` is not usable here: {error}")); report.skipped_jobs += 1; report.jobs.push(item); continue; } } report.ready_jobs += 1; if dry_run { report.jobs.push(item); continue; } let schedule = session::manager::create_scheduled_task( db, cfg, cron_expr, task, draft.agent.as_deref().unwrap_or(&cfg.default_agent), draft.profile.as_deref(), draft.use_worktree.unwrap_or(cfg.auto_create_worktrees), session::SessionGrouping { project: draft.project.clone(), task_group: draft.task_group.clone(), }, )?; item.status = LegacyScheduleImportJobStatus::Imported; item.imported_schedule_id = Some(schedule.id); report.imported_jobs += 1; report.jobs.push(item); } Ok(report) } fn build_legacy_migration_plan_report( audit: &LegacyMigrationAuditReport, ) -> LegacyMigrationPlanReport { let mut steps = Vec::new(); let legacy_schedule_drafts = load_legacy_schedule_drafts(Path::new(&audit.source)).unwrap_or_default(); let schedule_commands = legacy_schedule_drafts .iter() .filter(|draft| draft.enabled) .filter_map(build_legacy_schedule_add_command) .collect::>(); let disabled_schedule_jobs = legacy_schedule_drafts .iter() .filter(|draft| !draft.enabled) .count(); let invalid_schedule_jobs = legacy_schedule_drafts .iter() .filter(|draft| draft.enabled && (draft.cron_expr.is_none() || draft.task.is_none())) .count(); for artifact in &audit.artifacts { let step = match artifact.category.as_str() { "scheduler" => LegacyMigrationPlanStep { category: artifact.category.clone(), readiness: artifact.readiness, title: "Recreate Hermes/OpenClaw recurring jobs in ECC2 scheduler".to_string(), target_surface: "ECC2 scheduler".to_string(), source_paths: artifact.source_paths.clone(), command_snippets: if schedule_commands.is_empty() { vec![ "ecc schedule add --cron \"\" --task \"Translate legacy recurring job from cron/scheduler.py\"".to_string(), "ecc schedule list".to_string(), "ecc daemon".to_string(), ] } else { let mut commands = schedule_commands.clone(); commands.push("ecc schedule list".to_string()); commands.push("ecc daemon".to_string()); commands }, config_snippets: Vec::new(), notes: { let mut notes = artifact.notes.clone(); if !schedule_commands.is_empty() { notes.push(format!( "Recovered {} concrete recurring job(s) from cron/jobs.json.", schedule_commands.len() )); } if disabled_schedule_jobs > 0 { notes.push(format!( "{disabled_schedule_jobs} legacy recurring job(s) are disabled and were left out of generated ECC2 commands." )); } if invalid_schedule_jobs > 0 { notes.push(format!( "{invalid_schedule_jobs} legacy recurring job(s) were missing cron/task fields and still need manual translation." )); } notes }, }, "gateway_dispatch" => LegacyMigrationPlanStep { category: artifact.category.clone(), readiness: artifact.readiness, title: "Replace legacy gateway intake with ECC2 remote dispatch".to_string(), target_surface: "ECC2 remote dispatch".to_string(), source_paths: artifact.source_paths.clone(), command_snippets: vec![ "ecc remote serve --bind 127.0.0.1:8787 --token ".to_string(), "ecc remote add --task \"Translate legacy dispatch workflow\"".to_string(), "ecc remote computer-use --goal \"Translate legacy browser/operator flow\"".to_string(), ], config_snippets: Vec::new(), notes: artifact.notes.clone(), }, "memory_tool" => LegacyMigrationPlanStep { category: artifact.category.clone(), readiness: artifact.readiness, title: "Port legacy memory tool usage to ECC2 deep memory".to_string(), target_surface: "ECC2 context graph".to_string(), source_paths: artifact.source_paths.clone(), command_snippets: vec![ "ecc graph add-observation --entity-id --type migration_note --summary \"Imported legacy memory pattern\"".to_string(), "ecc graph recall \"\"".to_string(), "ecc graph connectors".to_string(), ], config_snippets: Vec::new(), notes: artifact.notes.clone(), }, "workspace_memory" => LegacyMigrationPlanStep { category: artifact.category.clone(), readiness: artifact.readiness, title: "Import sanitized workspace memory through ECC2 connectors".to_string(), target_surface: "ECC2 memory connectors".to_string(), source_paths: artifact.source_paths.clone(), command_snippets: vec![ "ecc graph connector-sync hermes_workspace".to_string(), "ecc graph recall \"\"".to_string(), ], config_snippets: vec![format!( "[memory_connectors.hermes_workspace]\nkind = \"markdown_directory\"\npath = \"{}\"\nrecurse = true\ndefault_entity_type = \"legacy_workspace_note\"\ndefault_observation_type = \"legacy_workspace_memory\"", Path::new(&audit.source).join("workspace").display() )], notes: artifact.notes.clone(), }, "skills" => LegacyMigrationPlanStep { category: artifact.category.clone(), readiness: artifact.readiness, title: "Translate reusable legacy skills into ECC-native surfaces".to_string(), target_surface: "ECC skills / orchestration templates".to_string(), source_paths: artifact.source_paths.clone(), command_snippets: vec![ "ecc template --task \"\"".to_string(), ], config_snippets: vec![ "[orchestration_templates.legacy_workflow]\nproject = \"legacy-migration\"\ntask_group = \"legacy workflow\"\nagent = \"claude\"\nworktree = false\n\n[[orchestration_templates.legacy_workflow.steps]]\nname = \"operator\"\ntask = \"Translate and run the legacy workflow for {{task}}\"".to_string(), ], notes: artifact.notes.clone(), }, "tools" => LegacyMigrationPlanStep { category: artifact.category.clone(), readiness: artifact.readiness, title: "Rebuild valuable legacy tools as ECC agents, hooks, commands, or harness runners".to_string(), target_surface: "ECC agents / hooks / commands / harness runners".to_string(), source_paths: artifact.source_paths.clone(), command_snippets: vec![ "ecc start --task \"Rebuild one legacy tool as an ECC-native command or hook\"".to_string(), ], config_snippets: vec![ "[harness_runners.legacy-runner]\nprogram = \"\"\nbase_args = []\nproject_markers = [\".legacy-runner\"]".to_string(), ], notes: artifact.notes.clone(), }, "plugins" => LegacyMigrationPlanStep { category: artifact.category.clone(), readiness: artifact.readiness, title: "Translate legacy bridge plugins into ECC-native automation".to_string(), target_surface: "ECC hooks / commands / skills".to_string(), source_paths: artifact.source_paths.clone(), command_snippets: vec![ "ecc start --task \"Port one bridge plugin behavior into an ECC hook or command\"".to_string(), ], config_snippets: Vec::new(), notes: artifact.notes.clone(), }, "env_services" => LegacyMigrationPlanStep { category: artifact.category.clone(), readiness: artifact.readiness, title: "Reconfigure local auth and connectors without importing secrets".to_string(), target_surface: "Claude connectors / MCP / local API key setup".to_string(), source_paths: artifact.source_paths.clone(), command_snippets: Vec::new(), config_snippets: vec![ "# Re-enter connector auth locally; do not copy legacy secrets into ECC2.\n# Typical targets: Google Drive OAuth, GitHub, Stripe, Linear, browser creds.".to_string(), ], notes: artifact.notes.clone(), }, _ => LegacyMigrationPlanStep { category: artifact.category.clone(), readiness: artifact.readiness, title: format!("Review legacy {} surface", artifact.category), target_surface: "Manual ECC2 translation".to_string(), source_paths: artifact.source_paths.clone(), command_snippets: Vec::new(), config_snippets: Vec::new(), notes: artifact.notes.clone(), }, }; steps.push(step); } LegacyMigrationPlanReport { source: audit.source.clone(), generated_at: chrono::Utc::now().to_rfc3339(), audit_summary: audit.summary.clone(), steps, } } fn write_legacy_migration_scaffold( plan: &LegacyMigrationPlanReport, output_dir: &Path, ) -> Result { fs::create_dir_all(output_dir).with_context(|| { format!( "create migration scaffold output directory: {}", output_dir.display() ) })?; let plan_path = output_dir.join("migration-plan.md"); let config_path = output_dir.join("ecc2.migration.toml"); fs::write(&plan_path, format_legacy_migration_plan_human(plan)) .with_context(|| format!("write migration plan: {}", plan_path.display()))?; fs::write(&config_path, render_legacy_migration_config_scaffold(plan)) .with_context(|| format!("write migration config scaffold: {}", config_path.display()))?; Ok(LegacyMigrationScaffoldReport { source: plan.source.clone(), output_dir: output_dir.display().to_string(), files_written: vec![ plan_path.display().to_string(), config_path.display().to_string(), ], steps_scaffolded: plan.steps.len(), }) } fn render_legacy_migration_config_scaffold(plan: &LegacyMigrationPlanReport) -> String { let mut sections = vec![ format!( "# ECC2 migration scaffold generated from {}\n# Review every section before merging it into a real ecc2.toml.", plan.source ), ]; for step in &plan.steps { if step.config_snippets.is_empty() { continue; } sections.push(format!( "\n# {} [{} -> {}]", step.title, format_legacy_migration_readiness(step.readiness), step.target_surface )); for snippet in &step.config_snippets { sections.push(snippet.clone()); } } sections.join("\n\n") } fn format_legacy_migration_audit_human(report: &LegacyMigrationAuditReport) -> String { let mut lines = vec![ format!("Legacy migration audit: {}", report.source), format!( "Detected systems: {}", if report.detected_systems.is_empty() { "none".to_string() } else { report.detected_systems.join(", ") } ), format!( "Artifact categories: {} | ready now {} | manual translation {} | local auth {}", report.summary.artifact_categories_detected, report.summary.ready_now_categories, report.summary.manual_translation_categories, report.summary.local_auth_required_categories ), ]; if report.artifacts.is_empty() { lines.push("No recognizable Hermes/OpenClaw migration surfaces found.".to_string()); return lines.join("\n"); } lines.push(String::new()); lines.push("Artifacts".to_string()); for artifact in &report.artifacts { lines.push(format!( "- {} [{}] | items {}", artifact.category, format_legacy_migration_readiness(artifact.readiness), artifact.detected_items )); lines.push(format!(" sources {}", artifact.source_paths.join(", "))); lines.push(format!(" map to {}", artifact.mapping.join(", "))); for note in &artifact.notes { lines.push(format!(" note {note}")); } } lines.push(String::new()); lines.push("Recommended next steps".to_string()); for step in &report.recommended_next_steps { lines.push(format!("- {step}")); } lines.join("\n") } fn format_legacy_migration_readiness(readiness: LegacyMigrationReadiness) -> &'static str { match readiness { LegacyMigrationReadiness::ReadyNow => "ready_now", LegacyMigrationReadiness::ManualTranslation => "manual_translation", LegacyMigrationReadiness::LocalAuthRequired => "local_auth_required", } } fn format_legacy_migration_plan_human(report: &LegacyMigrationPlanReport) -> String { let mut lines = vec![ format!("Legacy migration plan: {}", report.source), format!("Generated at: {}", report.generated_at), format!( "Audit summary: {} categories | ready now {} | manual translation {} | local auth {}", report.audit_summary.artifact_categories_detected, report.audit_summary.ready_now_categories, report.audit_summary.manual_translation_categories, report.audit_summary.local_auth_required_categories ), ]; if report.steps.is_empty() { lines.push("No migration steps generated.".to_string()); return lines.join("\n"); } lines.push(String::new()); lines.push("Plan".to_string()); for step in &report.steps { lines.push(format!( "- {} [{}] -> {}", step.title, format_legacy_migration_readiness(step.readiness), step.target_surface )); if !step.source_paths.is_empty() { lines.push(format!(" sources {}", step.source_paths.join(", "))); } for command in &step.command_snippets { lines.push(format!(" command {}", command)); } for snippet in &step.config_snippets { lines.push(" config".to_string()); for line in snippet.lines() { lines.push(format!(" {}", line)); } } for note in &step.notes { lines.push(format!(" note {}", note)); } } lines.join("\n") } fn format_legacy_migration_scaffold_human(report: &LegacyMigrationScaffoldReport) -> String { let mut lines = vec![ format!("Legacy migration scaffold written for {}", report.source), format!("- output dir {}", report.output_dir), format!("- steps scaffolded {}", report.steps_scaffolded), "- files".to_string(), ]; for path in &report.files_written { lines.push(format!(" {}", path)); } lines.join("\n") } fn format_legacy_schedule_import_human(report: &LegacyScheduleImportReport) -> String { let mut lines = vec![ format!( "Legacy schedule import {} for {}", if report.dry_run { "preview" } else { "complete" }, report.source ), format!("- source path {}", report.source_path), format!("- jobs detected {}", report.jobs_detected), format!("- ready jobs {}", report.ready_jobs), format!("- imported jobs {}", report.imported_jobs), format!("- disabled jobs {}", report.disabled_jobs), format!("- invalid jobs {}", report.invalid_jobs), format!("- skipped jobs {}", report.skipped_jobs), ]; if report.jobs.is_empty() { lines.push("- no importable cron/jobs.json entries were found".to_string()); return lines.join("\n"); } lines.push("Jobs".to_string()); for job in &report.jobs { lines.push(format!( "- {} [{}]", job.job_name, match job.status { LegacyScheduleImportJobStatus::Ready => "ready", LegacyScheduleImportJobStatus::Imported => "imported", LegacyScheduleImportJobStatus::Disabled => "disabled", LegacyScheduleImportJobStatus::Invalid => "invalid", LegacyScheduleImportJobStatus::Skipped => "skipped", } )); if let Some(cron_expr) = job.cron_expr.as_deref() { lines.push(format!(" cron {}", cron_expr)); } if let Some(task) = job.task.as_deref() { lines.push(format!(" task {}", task)); } if let Some(command) = job.command_snippet.as_deref() { lines.push(format!(" command {}", command)); } if let Some(schedule_id) = job.imported_schedule_id { lines.push(format!(" schedule {}", schedule_id)); } if let Some(reason) = job.reason.as_deref() { lines.push(format!(" note {}", reason)); } } lines.join("\n") } fn format_graph_recall_human( entries: &[session::ContextGraphRecallEntry], session_id: Option<&str>, query: &str, ) -> String { if entries.is_empty() { return format!("No relevant context graph entities found for query: {query}"); } let scope = session_id .map(short_session) .unwrap_or_else(|| "all sessions".to_string()); let mut lines = vec![format!( "Relevant memory: {} entries for \"{}\" ({scope})", entries.len(), query )]; for entry in entries { let mut line = format!( "- #{} [{}] {} | score {} | relations {} | observations {} | priority {}", entry.entity.id, entry.entity.entity_type, entry.entity.name, entry.score, entry.relation_count, entry.observation_count, entry.max_observation_priority ); if entry.has_pinned_observation { line.push_str(" | pinned"); } if let Some(session_id) = entry.entity.session_id.as_deref() { line.push_str(&format!(" | {}", short_session(session_id))); } lines.push(line); if !entry.matched_terms.is_empty() { lines.push(format!(" matches {}", entry.matched_terms.join(", "))); } if let Some(path) = entry.entity.path.as_deref() { lines.push(format!(" path {path}")); } if !entry.entity.summary.is_empty() { lines.push(format!(" summary {}", entry.entity.summary)); } } lines.join("\n") } fn format_graph_compaction_stats_human( stats: &session::ContextGraphCompactionStats, session_id: Option<&str>, keep_observations_per_entity: usize, ) -> String { let scope = session_id .map(short_session) .unwrap_or_else(|| "all sessions".to_string()); [ format!( "Context graph compaction complete for {scope} (keep {keep_observations_per_entity} observations per entity)" ), format!("- entities scanned {}", stats.entities_scanned), format!( "- duplicate observations deleted {}", stats.duplicate_observations_deleted ), format!( "- overflow observations deleted {}", stats.overflow_observations_deleted ), format!("- observations retained {}", stats.observations_retained), ] .join("\n") } fn format_graph_connector_sync_stats_human(stats: &GraphConnectorSyncStats) -> String { [ format!("Memory connector sync complete: {}", stats.connector_name), format!("- records read {}", stats.records_read), format!("- entities upserted {}", stats.entities_upserted), format!("- observations added {}", stats.observations_added), format!("- skipped records {}", stats.skipped_records), format!( "- skipped unchanged sources {}", stats.skipped_unchanged_sources ), ] .join("\n") } fn format_graph_connector_sync_report_human(report: &GraphConnectorSyncReport) -> String { let mut lines = vec![ format!( "Memory connector sync complete: {} connector(s)", report.connectors_synced ), format!("- records read {}", report.records_read), format!("- entities upserted {}", report.entities_upserted), format!("- observations added {}", report.observations_added), format!("- skipped records {}", report.skipped_records), format!( "- skipped unchanged sources {}", report.skipped_unchanged_sources ), ]; if !report.connectors.is_empty() { lines.push(String::new()); lines.push("Connectors:".to_string()); for stats in &report.connectors { lines.push(format!("- {}", stats.connector_name)); lines.push(format!(" records read {}", stats.records_read)); lines.push(format!(" entities upserted {}", stats.entities_upserted)); lines.push(format!(" observations added {}", stats.observations_added)); lines.push(format!(" skipped records {}", stats.skipped_records)); lines.push(format!( " skipped unchanged sources {}", stats.skipped_unchanged_sources )); } } lines.join("\n") } fn format_graph_connector_status_report_human(report: &GraphConnectorStatusReport) -> String { let mut lines = vec![format!( "Memory connectors: {} configured", report.configured_connectors )]; if report.connectors.is_empty() { lines.push("- none".to_string()); return lines.join("\n"); } for connector in &report.connectors { lines.push(format!( "- {} [{}]", connector.connector_name, connector.connector_kind )); lines.push(format!(" source {}", connector.source_path)); if connector.recurse { lines.push(" recurse true".to_string()); } lines.push(format!(" synced sources {}", connector.synced_sources)); lines.push(format!( " last synced {}", connector .last_synced_at .map(|value| value.to_rfc3339()) .unwrap_or_else(|| "never".to_string()) )); if let Some(session_id) = &connector.default_session_id { lines.push(format!(" default session {}", session_id)); } if let Some(entity_type) = &connector.default_entity_type { lines.push(format!(" default entity type {}", entity_type)); } if let Some(observation_type) = &connector.default_observation_type { lines.push(format!(" default observation type {}", observation_type)); } } lines.join("\n") } fn format_graph_entity_detail_human(detail: &session::ContextGraphEntityDetail) -> String { let mut lines = vec![format_graph_entity_human(&detail.entity)]; lines.push(String::new()); lines.push(format!("Outgoing relations: {}", detail.outgoing.len())); if detail.outgoing.is_empty() { lines.push("- none".to_string()); } else { for relation in &detail.outgoing { lines.push(format!( "- [{}] {} -> #{} {}", relation.relation_type, detail.entity.name, relation.to_entity_id, relation.to_entity_name )); if !relation.summary.is_empty() { lines.push(format!(" summary {}", relation.summary)); } } } lines.push(format!("Incoming relations: {}", detail.incoming.len())); if detail.incoming.is_empty() { lines.push("- none".to_string()); } else { for relation in &detail.incoming { lines.push(format!( "- [{}] #{} {} -> {}", relation.relation_type, relation.from_entity_id, relation.from_entity_name, detail.entity.name )); if !relation.summary.is_empty() { lines.push(format!(" summary {}", relation.summary)); } } } lines.join("\n") } fn format_graph_sync_stats_human( stats: &session::ContextGraphSyncStats, session_id: Option<&str>, ) -> String { let scope = session_id .map(short_session) .unwrap_or_else(|| "all sessions".to_string()); vec![ format!("Context graph sync complete for {scope}"), format!("- sessions scanned {}", stats.sessions_scanned), format!("- decisions processed {}", stats.decisions_processed), format!("- file events processed {}", stats.file_events_processed), format!("- messages processed {}", stats.messages_processed), ] .join("\n") } fn format_merge_queue_human(report: &session::manager::MergeQueueReport) -> String { let mut lines = Vec::new(); lines.push(format!( "Merge queue: {} ready / {} blocked", report.ready_entries.len(), report.blocked_entries.len() )); if report.ready_entries.is_empty() { lines.push("No merge-ready worktrees queued".to_string()); } else { lines.push("Ready".to_string()); for entry in &report.ready_entries { lines.push(format!( "- #{} {} [{}] | {} / {} | {}", entry.queue_position.unwrap_or(0), entry.session_id, entry.branch, entry.project, entry.task_group, entry.task )); } } if !report.blocked_entries.is_empty() { lines.push(String::new()); lines.push("Blocked".to_string()); for entry in &report.blocked_entries { lines.push(format!( "- {} [{}] | {} / {} | {}", entry.session_id, entry.branch, entry.project, entry.task_group, entry.suggested_action )); for blocker in entry.blocked_by.iter().take(2) { lines.push(format!( " blocker {} [{}] | {}", blocker.session_id, blocker.branch, blocker.summary )); for conflict in blocker.conflicts.iter().take(3) { lines.push(format!(" conflict {conflict}")); } if let Some(preview) = blocker.conflicting_patch_preview.as_ref() { for line in preview.lines().take(6) { lines.push(format!(" {}", line)); } } } } } lines.join("\n") } fn build_otel_export( db: &session::store::StateStore, session_id: Option<&str>, ) -> Result { let sessions = if let Some(session_id) = session_id { vec![db .get_session(session_id)? .ok_or_else(|| anyhow::anyhow!("Session not found: {session_id}"))?] } else { db.list_sessions()? }; let mut spans = Vec::new(); for session in &sessions { spans.extend(build_session_otel_spans(db, session)?); } Ok(OtlpExport { resource_spans: vec![OtlpResourceSpans { resource: OtlpResource { attributes: vec![ otlp_string_attr("service.name", "ecc2"), otlp_string_attr("service.version", env!("CARGO_PKG_VERSION")), otlp_string_attr("telemetry.sdk.language", "rust"), ], }, scope_spans: vec![OtlpScopeSpans { scope: OtlpInstrumentationScope { name: "ecc2".to_string(), version: env!("CARGO_PKG_VERSION").to_string(), }, spans, }], }], }) } fn build_session_otel_spans( db: &session::store::StateStore, session: &session::Session, ) -> Result> { let trace_id = otlp_trace_id(&session.id); let session_span_id = otlp_span_id(&format!("session:{}", session.id)); let parent_link = db.latest_task_handoff_source(&session.id)?; let session_end = session.updated_at.max(session.created_at); let mut spans = vec![OtlpSpan { trace_id: trace_id.clone(), span_id: session_span_id.clone(), parent_span_id: None, name: format!("session {}", session.task), kind: "SPAN_KIND_INTERNAL".to_string(), start_time_unix_nano: otlp_timestamp_nanos(session.created_at), end_time_unix_nano: otlp_timestamp_nanos(session_end), attributes: vec![ otlp_string_attr("ecc.session.id", &session.id), otlp_string_attr("ecc.session.state", &session.state.to_string()), otlp_string_attr("ecc.agent.type", &session.agent_type), otlp_string_attr("ecc.session.task", &session.task), otlp_string_attr( "ecc.working_dir", session.working_dir.to_string_lossy().as_ref(), ), otlp_int_attr("ecc.metrics.input_tokens", session.metrics.input_tokens), otlp_int_attr("ecc.metrics.output_tokens", session.metrics.output_tokens), otlp_int_attr("ecc.metrics.tokens_used", session.metrics.tokens_used), otlp_int_attr("ecc.metrics.tool_calls", session.metrics.tool_calls), otlp_int_attr( "ecc.metrics.files_changed", u64::from(session.metrics.files_changed), ), otlp_int_attr("ecc.metrics.duration_secs", session.metrics.duration_secs), otlp_double_attr("ecc.metrics.cost_usd", session.metrics.cost_usd), ], links: parent_link .into_iter() .map(|parent_session_id| OtlpSpanLink { trace_id: otlp_trace_id(&parent_session_id), span_id: otlp_span_id(&format!("session:{parent_session_id}")), attributes: vec![otlp_string_attr( "ecc.parent_session.id", &parent_session_id, )], }) .collect(), status: otlp_session_status(&session.state), }]; for entry in db.list_tool_logs_for_session(&session.id)? { let span_end = chrono::DateTime::parse_from_rfc3339(&entry.timestamp) .unwrap_or_else(|_| session.updated_at.into()) .with_timezone(&chrono::Utc); let span_start = span_end - chrono::Duration::milliseconds(entry.duration_ms as i64); spans.push(OtlpSpan { trace_id: trace_id.clone(), span_id: otlp_span_id(&format!("tool:{}:{}", session.id, entry.id)), parent_span_id: Some(session_span_id.clone()), name: format!("tool {}", entry.tool_name), kind: "SPAN_KIND_INTERNAL".to_string(), start_time_unix_nano: otlp_timestamp_nanos(span_start), end_time_unix_nano: otlp_timestamp_nanos(span_end), attributes: vec![ otlp_string_attr("ecc.session.id", &entry.session_id), otlp_string_attr("tool.name", &entry.tool_name), otlp_string_attr("tool.input_summary", &entry.input_summary), otlp_string_attr("tool.output_summary", &entry.output_summary), otlp_string_attr("tool.trigger_summary", &entry.trigger_summary), otlp_string_attr("tool.input_params_json", &entry.input_params_json), otlp_int_attr("tool.duration_ms", entry.duration_ms), otlp_double_attr("tool.risk_score", entry.risk_score), ], links: Vec::new(), status: OtlpSpanStatus { code: "STATUS_CODE_UNSET".to_string(), message: None, }, }); } Ok(spans) } fn otlp_timestamp_nanos(value: chrono::DateTime) -> String { value .timestamp_nanos_opt() .unwrap_or_default() .max(0) .to_string() } fn otlp_trace_id(seed: &str) -> String { format!( "{:016x}{:016x}", fnv1a64(seed.as_bytes()), fnv1a64_with_seed(seed.as_bytes(), 1099511628211) ) } fn otlp_span_id(seed: &str) -> String { format!("{:016x}", fnv1a64(seed.as_bytes())) } fn fnv1a64(bytes: &[u8]) -> u64 { fnv1a64_with_seed(bytes, 14695981039346656037) } fn fnv1a64_with_seed(bytes: &[u8], offset_basis: u64) -> u64 { let mut hash = offset_basis; for byte in bytes { hash ^= u64::from(*byte); hash = hash.wrapping_mul(1099511628211); } hash } fn otlp_string_attr(key: &str, value: &str) -> OtlpKeyValue { OtlpKeyValue { key: key.to_string(), value: OtlpAnyValue { string_value: Some(value.to_string()), int_value: None, double_value: None, bool_value: None, }, } } fn otlp_int_attr(key: &str, value: u64) -> OtlpKeyValue { OtlpKeyValue { key: key.to_string(), value: OtlpAnyValue { string_value: None, int_value: Some(value.to_string()), double_value: None, bool_value: None, }, } } fn otlp_double_attr(key: &str, value: f64) -> OtlpKeyValue { OtlpKeyValue { key: key.to_string(), value: OtlpAnyValue { string_value: None, int_value: None, double_value: Some(value), bool_value: None, }, } } fn otlp_session_status(state: &session::SessionState) -> OtlpSpanStatus { match state { session::SessionState::Completed => OtlpSpanStatus { code: "STATUS_CODE_OK".to_string(), message: None, }, session::SessionState::Failed => OtlpSpanStatus { code: "STATUS_CODE_ERROR".to_string(), message: Some("session failed".to_string()), }, _ => OtlpSpanStatus { code: "STATUS_CODE_UNSET".to_string(), message: None, }, } } fn summarize_coordinate_backlog( outcome: &session::manager::CoordinateBacklogOutcome, ) -> CoordinateBacklogPassSummary { let total_processed: usize = outcome .dispatched .iter() .map(|dispatch| dispatch.routed.len()) .sum(); let total_routed: usize = outcome .dispatched .iter() .map(|dispatch| { dispatch .routed .iter() .filter(|item| session::manager::assignment_action_routes_work(item.action)) .count() }) .sum(); let total_deferred = total_processed.saturating_sub(total_routed); let total_rerouted: usize = outcome .rebalanced .iter() .map(|rebalance| rebalance.rerouted.len()) .sum(); let message = if total_routed == 0 && total_rerouted == 0 && outcome.remaining_backlog_sessions == 0 { "Backlog already clear".to_string() } else { format!( "Coordinated backlog: processed {} handoff(s) across {} lead(s) ({} routed, {} deferred); rebalanced {} handoff(s) across {} lead(s); remaining {} handoff(s) across {} session(s) [{} absorbable, {} saturated]", total_processed, outcome.dispatched.len(), total_routed, total_deferred, total_rerouted, outcome.rebalanced.len(), outcome.remaining_backlog_messages, outcome.remaining_backlog_sessions, outcome.remaining_absorbable_sessions, outcome.remaining_saturated_sessions ) }; CoordinateBacklogPassSummary { pass: 0, processed: total_processed, routed: total_routed, deferred: total_deferred, rerouted: total_rerouted, dispatched_leads: outcome.dispatched.len(), rebalanced_leads: outcome.rebalanced.len(), remaining_backlog_sessions: outcome.remaining_backlog_sessions, remaining_backlog_messages: outcome.remaining_backlog_messages, remaining_absorbable_sessions: outcome.remaining_absorbable_sessions, remaining_saturated_sessions: outcome.remaining_saturated_sessions, message, } } fn coordination_status_exit_code(status: &session::manager::CoordinationStatus) -> i32 { match status.health { session::manager::CoordinationHealth::Healthy => 0, session::manager::CoordinationHealth::BacklogAbsorbable => 1, session::manager::CoordinationHealth::Saturated | session::manager::CoordinationHealth::EscalationRequired => 2, } } fn send_handoff_message(db: &session::store::StateStore, from_id: &str, to_id: &str) -> Result<()> { let from_session = db .get_session(from_id)? .ok_or_else(|| anyhow::anyhow!("Session not found: {from_id}"))?; let context = format!( "Delegated from {} [{}] | cwd {}{}", short_session(&from_session.id), from_session.agent_type, from_session.working_dir.display(), from_session .worktree .as_ref() .map(|worktree| format!( " | worktree {} ({})", worktree.branch, worktree.path.display() )) .unwrap_or_default() ); comms::send( db, &from_session.id, to_id, &comms::MessageType::TaskHandoff { task: from_session.task, context, priority: comms::TaskPriority::Normal, }, ) } fn parse_template_vars(values: &[String]) -> Result> { parse_key_value_pairs(values, "template vars") } fn parse_key_value_pairs(values: &[String], label: &str) -> Result> { let mut vars = BTreeMap::new(); for value in values { let (key, raw_value) = value .split_once('=') .ok_or_else(|| anyhow::anyhow!("{label} must use key=value form: {value}"))?; let key = key.trim(); let raw_value = raw_value.trim(); if key.is_empty() || raw_value.is_empty() { anyhow::bail!("{label} must use non-empty key=value form: {value}"); } vars.insert(key.to_string(), raw_value.to_string()); } Ok(vars) } #[cfg(test)] mod tests { use super::*; use crate::config::Config; use crate::session::store::StateStore; use crate::session::{Session, SessionMetrics, SessionState}; use chrono::{Duration, Utc}; use std::fs; use std::path::{Path, PathBuf}; struct TestDir { path: PathBuf, } impl TestDir { fn new(label: &str) -> Result { let path = std::env::temp_dir().join(format!("ecc2-main-{label}-{}", uuid::Uuid::new_v4())); fs::create_dir_all(&path)?; Ok(Self { path }) } fn path(&self) -> &Path { &self.path } } impl Drop for TestDir { fn drop(&mut self) { let _ = fs::remove_dir_all(&self.path); } } fn build_session(id: &str, task: &str, state: SessionState) -> Session { let now = Utc::now(); Session { id: id.to_string(), task: task.to_string(), project: "workspace".to_string(), task_group: "general".to_string(), agent_type: "claude".to_string(), working_dir: PathBuf::from("/tmp/ecc"), state, pid: None, worktree: None, created_at: now - Duration::seconds(5), updated_at: now, last_heartbeat_at: now, metrics: SessionMetrics { input_tokens: 120, output_tokens: 30, tokens_used: 150, tool_calls: 2, files_changed: 1, duration_secs: 5, cost_usd: 0.42, }, } } fn attr_value<'a>(attrs: &'a [OtlpKeyValue], key: &str) -> Option<&'a OtlpAnyValue> { attrs .iter() .find(|attr| attr.key == key) .map(|attr| &attr.value) } #[test] fn worktree_policy_defaults_to_config_setting() { let mut cfg = Config::default(); let policy = WorktreePolicyArgs::default(); assert!(policy.resolve(&cfg)); cfg.auto_create_worktrees = false; assert!(!policy.resolve(&cfg)); } #[test] fn worktree_policy_explicit_flags_override_config_setting() { let mut cfg = Config::default(); cfg.auto_create_worktrees = false; assert!(WorktreePolicyArgs { worktree: true, no_worktree: false, } .resolve(&cfg)); cfg.auto_create_worktrees = true; assert!(!WorktreePolicyArgs { worktree: false, no_worktree: true, } .resolve(&cfg)); } #[test] fn cli_parses_resume_command() { let cli = Cli::try_parse_from(["ecc", "resume", "deadbeef"]) .expect("resume subcommand should parse"); match cli.command { Some(Commands::Resume { session_id }) => assert_eq!(session_id, "deadbeef"), _ => panic!("expected resume subcommand"), } } #[test] fn cli_parses_export_otel_command() { let cli = Cli::try_parse_from([ "ecc", "export-otel", "worker-1234", "--output", "/tmp/ecc-otel.json", ]) .expect("export-otel should parse"); match cli.command { Some(Commands::ExportOtel { session_id, output }) => { assert_eq!(session_id.as_deref(), Some("worker-1234")); assert_eq!(output.as_deref(), Some(Path::new("/tmp/ecc-otel.json"))); } _ => panic!("expected export-otel subcommand"), } } #[test] fn cli_parses_messages_send_command() { let cli = Cli::try_parse_from([ "ecc", "messages", "send", "--from", "planner", "--to", "worker", "--kind", "query", "--text", "Need context", ]) .expect("messages send should parse"); match cli.command { Some(Commands::Messages { command: MessageCommands::Send { from, to, kind, text, priority, .. }, }) => { assert_eq!(from, "planner"); assert_eq!(to, "worker"); assert!(matches!(kind, MessageKindArg::Query)); assert_eq!(text, "Need context"); assert_eq!(priority, TaskPriorityArg::Normal); } _ => panic!("expected messages send subcommand"), } } #[test] fn cli_parses_schedule_add_command() { let cli = Cli::try_parse_from([ "ecc", "schedule", "add", "--cron", "*/15 * * * *", "--task", "Check backlog health", "--agent", "codex", "--profile", "planner", "--project", "ecc-core", "--task-group", "scheduled maintenance", ]) .expect("schedule add should parse"); match cli.command { Some(Commands::Schedule { command: ScheduleCommands::Add { cron, task, agent, profile, project, task_group, .. }, }) => { assert_eq!(cron, "*/15 * * * *"); assert_eq!(task, "Check backlog health"); assert_eq!(agent.as_deref(), Some("codex")); assert_eq!(profile.as_deref(), Some("planner")); assert_eq!(project.as_deref(), Some("ecc-core")); assert_eq!(task_group.as_deref(), Some("scheduled maintenance")); } _ => panic!("expected schedule add subcommand"), } } #[test] fn cli_parses_remote_computer_use_command() { let cli = Cli::try_parse_from([ "ecc", "remote", "computer-use", "--goal", "Confirm the recovery banner", "--target-url", "https://ecc.tools/account", "--context", "Use the production flow", "--priority", "critical", "--agent", "codex", "--profile", "browser", "--no-worktree", ]) .expect("remote computer-use should parse"); match cli.command { Some(Commands::Remote { command: RemoteCommands::ComputerUse { goal, target_url, context, priority, agent, profile, worktree, .. }, }) => { assert_eq!(goal, "Confirm the recovery banner"); assert_eq!(target_url.as_deref(), Some("https://ecc.tools/account")); assert_eq!(context.as_deref(), Some("Use the production flow")); assert_eq!(priority, TaskPriorityArg::Critical); assert_eq!(agent.as_deref(), Some("codex")); assert_eq!(profile.as_deref(), Some("browser")); assert!(worktree.no_worktree); assert!(!worktree.worktree); } _ => panic!("expected remote computer-use subcommand"), } } #[test] fn cli_parses_start_with_handoff_source() { let cli = Cli::try_parse_from([ "ecc", "start", "--task", "Follow up", "--agent", "claude", "--from-session", "planner", ]) .expect("start with handoff source should parse"); match cli.command { Some(Commands::Start { from_session, task, agent, .. }) => { assert_eq!(task, "Follow up"); assert_eq!(agent.as_deref(), Some("claude")); assert_eq!(from_session.as_deref(), Some("planner")); } _ => panic!("expected start subcommand"), } } #[test] fn cli_parses_start_without_agent_override() { let cli = Cli::try_parse_from(["ecc", "start", "--task", "Follow up"]) .expect("start without --agent should parse"); match cli.command { Some(Commands::Start { task, agent, .. }) => { assert_eq!(task, "Follow up"); assert!(agent.is_none()); } _ => panic!("expected start subcommand"), } } #[test] fn cli_parses_start_no_worktree_override() { let cli = Cli::try_parse_from(["ecc", "start", "--task", "Follow up", "--no-worktree"]) .expect("start --no-worktree should parse"); match cli.command { Some(Commands::Start { worktree, .. }) => { assert!(!worktree.worktree); assert!(worktree.no_worktree); } _ => panic!("expected start subcommand"), } } #[test] fn cli_parses_delegate_command() { let cli = Cli::try_parse_from([ "ecc", "delegate", "planner", "--task", "Review auth changes", "--agent", "codex", ]) .expect("delegate should parse"); match cli.command { Some(Commands::Delegate { from_session, task, agent, .. }) => { assert_eq!(from_session, "planner"); assert_eq!(task.as_deref(), Some("Review auth changes")); assert_eq!(agent.as_deref(), Some("codex")); } _ => panic!("expected delegate subcommand"), } } #[test] fn cli_parses_delegate_worktree_override() { let cli = Cli::try_parse_from(["ecc", "delegate", "planner", "--worktree"]) .expect("delegate --worktree should parse"); match cli.command { Some(Commands::Delegate { worktree, .. }) => { assert!(worktree.worktree); assert!(!worktree.no_worktree); } _ => panic!("expected delegate subcommand"), } } #[test] fn cli_parses_template_command() { let cli = Cli::try_parse_from([ "ecc", "template", "feature_development", "--task", "stabilize auth callback", "--from-session", "lead", "--var", "component=billing", "--var", "area=oauth", ]) .expect("template should parse"); match cli.command { Some(Commands::Template { name, task, from_session, vars, }) => { assert_eq!(name, "feature_development"); assert_eq!(task.as_deref(), Some("stabilize auth callback")); assert_eq!(from_session.as_deref(), Some("lead")); assert_eq!( vars, vec!["component=billing".to_string(), "area=oauth".to_string(),] ); } _ => panic!("expected template subcommand"), } } #[test] fn parse_template_vars_builds_map() { let vars = parse_template_vars(&["component=billing".to_string(), "area=oauth".to_string()]) .expect("template vars"); assert_eq!( vars, BTreeMap::from([ ("area".to_string(), "oauth".to_string()), ("component".to_string(), "billing".to_string()), ]) ); } #[test] fn parse_template_vars_rejects_invalid_entries() { let error = parse_template_vars(&["missing-delimiter".to_string()]) .expect_err("invalid template var should fail"); assert!( error .to_string() .contains("template vars must use key=value form"), "unexpected error: {error}" ); } #[test] fn parse_key_value_pairs_rejects_empty_values() { let error = parse_key_value_pairs(&["language=".to_string()], "graph metadata") .expect_err("invalid metadata should fail"); assert!( error .to_string() .contains("graph metadata must use non-empty key=value form"), "unexpected error: {error}" ); } #[test] fn cli_parses_team_command() { let cli = Cli::try_parse_from(["ecc", "team", "planner", "--depth", "3"]) .expect("team should parse"); match cli.command { Some(Commands::Team { session_id, depth }) => { assert_eq!(session_id.as_deref(), Some("planner")); assert_eq!(depth, 3); } _ => panic!("expected team subcommand"), } } #[test] fn cli_parses_worktree_status_command() { let cli = Cli::try_parse_from(["ecc", "worktree-status", "planner"]) .expect("worktree-status should parse"); match cli.command { Some(Commands::WorktreeStatus { session_id, all, json, patch, check, }) => { assert_eq!(session_id.as_deref(), Some("planner")); assert!(!all); assert!(!json); assert!(!patch); assert!(!check); } _ => panic!("expected worktree-status subcommand"), } } #[test] fn cli_parses_worktree_status_json_flag() { let cli = Cli::try_parse_from(["ecc", "worktree-status", "--json"]) .expect("worktree-status --json should parse"); match cli.command { Some(Commands::WorktreeStatus { session_id, all, json, patch, check, }) => { assert_eq!(session_id, None); assert!(!all); assert!(json); assert!(!patch); assert!(!check); } _ => panic!("expected worktree-status subcommand"), } } #[test] fn cli_parses_worktree_status_all_flag() { let cli = Cli::try_parse_from(["ecc", "worktree-status", "--all"]) .expect("worktree-status --all should parse"); match cli.command { Some(Commands::WorktreeStatus { session_id, all, json, patch, check, }) => { assert_eq!(session_id, None); assert!(all); assert!(!json); assert!(!patch); assert!(!check); } _ => panic!("expected worktree-status subcommand"), } } #[test] fn cli_parses_worktree_status_session_id_with_all_flag() { let err = Cli::try_parse_from(["ecc", "worktree-status", "planner", "--all"]) .expect("worktree-status planner --all should parse"); let command = err.command.expect("expected command"); let Commands::WorktreeStatus { session_id, all, .. } = command else { panic!("expected worktree-status subcommand"); }; assert_eq!(session_id.as_deref(), Some("planner")); assert!(all); } #[test] fn format_worktree_status_reports_human_joins_multiple_reports() { let reports = vec![ WorktreeStatusReport { session_id: "sess-a".to_string(), task: "first".to_string(), session_state: "running".to_string(), health: "in_progress".to_string(), check_exit_code: 1, patch_included: false, attached: false, path: None, branch: None, base_branch: None, diff_summary: None, file_preview: Vec::new(), patch_preview: None, merge_readiness: None, }, WorktreeStatusReport { session_id: "sess-b".to_string(), task: "second".to_string(), session_state: "stopped".to_string(), health: "clear".to_string(), check_exit_code: 0, patch_included: false, attached: false, path: None, branch: None, base_branch: None, diff_summary: None, file_preview: Vec::new(), patch_preview: None, merge_readiness: None, }, ]; let text = format_worktree_status_reports_human(&reports); assert!(text.contains("Worktree status for sess-a [running]")); assert!(text.contains("Worktree status for sess-b [stopped]")); assert!(text.contains("\n\nWorktree status for sess-b [stopped]")); } #[test] fn cli_parses_worktree_status_patch_flag() { let cli = Cli::try_parse_from(["ecc", "worktree-status", "--patch"]) .expect("worktree-status --patch should parse"); match cli.command { Some(Commands::WorktreeStatus { session_id, all, json, patch, check, }) => { assert_eq!(session_id, None); assert!(!all); assert!(!json); assert!(patch); assert!(!check); } _ => panic!("expected worktree-status subcommand"), } } #[test] fn build_otel_export_includes_session_and_tool_spans() -> Result<()> { let tempdir = TestDir::new("otel-export-session")?; let db = StateStore::open(&tempdir.path().join("state.db"))?; let session = build_session("session-1", "Investigate export", SessionState::Completed); db.insert_session(&session)?; db.insert_tool_log( &session.id, "Write", "Write src/lib.rs", "{\"file\":\"src/lib.rs\"}", "Updated file", "manual test", 120, 0.75, &Utc::now().to_rfc3339(), )?; let export = build_otel_export(&db, Some("session-1"))?; let spans = &export.resource_spans[0].scope_spans[0].spans; assert_eq!(spans.len(), 2); let session_span = spans .iter() .find(|span| span.parent_span_id.is_none()) .expect("session root span"); let tool_span = spans .iter() .find(|span| span.parent_span_id.is_some()) .expect("tool child span"); assert_eq!(session_span.trace_id, tool_span.trace_id); assert_eq!( tool_span.parent_span_id.as_deref(), Some(session_span.span_id.as_str()) ); assert_eq!(session_span.status.code, "STATUS_CODE_OK"); assert_eq!( attr_value(&session_span.attributes, "ecc.session.id") .and_then(|value| value.string_value.as_deref()), Some("session-1") ); assert_eq!( attr_value(&tool_span.attributes, "tool.name") .and_then(|value| value.string_value.as_deref()), Some("Write") ); assert_eq!( attr_value(&tool_span.attributes, "tool.duration_ms") .and_then(|value| value.int_value.as_deref()), Some("120") ); Ok(()) } #[test] fn build_otel_export_links_delegated_session_to_parent_trace() -> Result<()> { let tempdir = TestDir::new("otel-export-parent-link")?; let db = StateStore::open(&tempdir.path().join("state.db"))?; let parent = build_session("lead-1", "Lead task", SessionState::Running); let child = build_session("worker-1", "Delegated task", SessionState::Running); db.insert_session(&parent)?; db.insert_session(&child)?; db.send_message( &parent.id, &child.id, "{\"task\":\"Delegated task\",\"context\":\"Delegated from lead\"}", "task_handoff", )?; let export = build_otel_export(&db, Some("worker-1"))?; let session_span = export.resource_spans[0].scope_spans[0] .spans .iter() .find(|span| span.parent_span_id.is_none()) .expect("session root span"); assert_eq!(session_span.links.len(), 1); assert_eq!(session_span.links[0].trace_id, otlp_trace_id("lead-1")); assert_eq!( session_span.links[0].span_id, otlp_span_id("session:lead-1") ); assert_eq!( attr_value(&session_span.links[0].attributes, "ecc.parent_session.id") .and_then(|value| value.string_value.as_deref()), Some("lead-1") ); Ok(()) } #[test] fn cli_parses_worktree_status_check_flag() { let cli = Cli::try_parse_from(["ecc", "worktree-status", "--check"]) .expect("worktree-status --check should parse"); match cli.command { Some(Commands::WorktreeStatus { session_id, all, json, patch, check, }) => { assert_eq!(session_id, None); assert!(!all); assert!(!json); assert!(!patch); assert!(check); } _ => panic!("expected worktree-status subcommand"), } } #[test] fn cli_parses_worktree_resolution_flags() { let cli = Cli::try_parse_from(["ecc", "worktree-resolution", "planner", "--json", "--check"]) .expect("worktree-resolution flags should parse"); match cli.command { Some(Commands::WorktreeResolution { session_id, all, json, check, }) => { assert_eq!(session_id.as_deref(), Some("planner")); assert!(!all); assert!(json); assert!(check); } _ => panic!("expected worktree-resolution subcommand"), } } #[test] fn cli_parses_worktree_resolution_all_flag() { let cli = Cli::try_parse_from(["ecc", "worktree-resolution", "--all"]) .expect("worktree-resolution --all should parse"); match cli.command { Some(Commands::WorktreeResolution { session_id, all, json, check, }) => { assert!(session_id.is_none()); assert!(all); assert!(!json); assert!(!check); } _ => panic!("expected worktree-resolution subcommand"), } } #[test] fn cli_parses_prune_worktrees_json_flag() { let cli = Cli::try_parse_from(["ecc", "prune-worktrees", "--json"]) .expect("prune-worktrees --json should parse"); match cli.command { Some(Commands::PruneWorktrees { json }) => { assert!(json); } _ => panic!("expected prune-worktrees subcommand"), } } #[test] fn cli_parses_merge_worktree_flags() { let cli = Cli::try_parse_from([ "ecc", "merge-worktree", "deadbeef", "--json", "--keep-worktree", ]) .expect("merge-worktree flags should parse"); match cli.command { Some(Commands::MergeWorktree { session_id, all, json, keep_worktree, }) => { assert_eq!(session_id.as_deref(), Some("deadbeef")); assert!(!all); assert!(json); assert!(keep_worktree); } _ => panic!("expected merge-worktree subcommand"), } } #[test] fn cli_parses_merge_worktree_all_flags() { let cli = Cli::try_parse_from(["ecc", "merge-worktree", "--all", "--json"]) .expect("merge-worktree --all --json should parse"); match cli.command { Some(Commands::MergeWorktree { session_id, all, json, keep_worktree, }) => { assert!(session_id.is_none()); assert!(all); assert!(json); assert!(!keep_worktree); } _ => panic!("expected merge-worktree subcommand"), } } #[test] fn cli_parses_merge_queue_json_flag() { let cli = Cli::try_parse_from(["ecc", "merge-queue", "--json"]) .expect("merge-queue --json should parse"); match cli.command { Some(Commands::MergeQueue { json, apply }) => { assert!(json); assert!(!apply); } _ => panic!("expected merge-queue subcommand"), } } #[test] fn cli_parses_merge_queue_apply_flag() { let cli = Cli::try_parse_from(["ecc", "merge-queue", "--apply", "--json"]) .expect("merge-queue --apply --json should parse"); match cli.command { Some(Commands::MergeQueue { json, apply }) => { assert!(json); assert!(apply); } _ => panic!("expected merge-queue subcommand"), } } #[test] fn format_worktree_status_human_includes_readiness_and_conflicts() { let report = WorktreeStatusReport { session_id: "deadbeefcafefeed".to_string(), task: "Review merge readiness".to_string(), session_state: "running".to_string(), health: "conflicted".to_string(), check_exit_code: 2, patch_included: true, attached: true, path: Some("/tmp/ecc/wt-1".to_string()), branch: Some("ecc/deadbeefcafefeed".to_string()), base_branch: Some("main".to_string()), diff_summary: Some("Branch 1 file changed, 2 insertions(+)".to_string()), file_preview: vec!["Branch M README.md".to_string()], patch_preview: Some("--- Branch diff vs main ---\n+hello".to_string()), merge_readiness: Some(WorktreeMergeReadinessReport { status: "conflicted".to_string(), summary: "Merge blocked by 1 conflict(s): README.md".to_string(), conflicts: vec!["README.md".to_string()], }), }; let text = format_worktree_status_human(&report); assert!(text.contains("Worktree status for deadbeef [running]")); assert!(text.contains("Branch ecc/deadbeefcafefeed (base main)")); assert!(text.contains("Health conflicted")); assert!(text.contains("Branch M README.md")); assert!(text.contains("Merge blocked by 1 conflict(s): README.md")); assert!(text.contains("- conflict README.md")); assert!(text.contains("Patch preview")); assert!(text.contains("--- Branch diff vs main ---")); } #[test] fn format_worktree_resolution_human_includes_protocol_steps() { let report = WorktreeResolutionReport { session_id: "deadbeefcafefeed".to_string(), task: "Resolve merge conflict".to_string(), session_state: "stopped".to_string(), attached: true, conflicted: true, check_exit_code: 2, path: Some("/tmp/ecc/wt-1".to_string()), branch: Some("ecc/deadbeefcafefeed".to_string()), base_branch: Some("main".to_string()), summary: "Merge blocked by 1 conflict(s): README.md".to_string(), conflicts: vec!["README.md".to_string()], resolution_steps: vec![ "Inspect current patch: ecc worktree-status deadbeefcafefeed --patch".to_string(), "Open worktree: cd /tmp/ecc/wt-1".to_string(), "Resolve conflicts and stage files: git add ".to_string(), ], }; let text = format_worktree_resolution_human(&report); assert!(text.contains("Worktree resolution for deadbeef [stopped]")); assert!(text.contains("Merge blocked by 1 conflict(s): README.md")); assert!(text.contains("Conflicts")); assert!(text.contains("- README.md")); assert!(text.contains("Resolution steps")); assert!(text.contains("1. Inspect current patch")); } #[test] fn worktree_resolution_reports_exit_code_tracks_conflicts() { let clear = WorktreeResolutionReport { session_id: "clear".to_string(), task: "ok".to_string(), session_state: "stopped".to_string(), attached: false, conflicted: false, check_exit_code: 0, path: None, branch: None, base_branch: None, summary: "No worktree attached".to_string(), conflicts: Vec::new(), resolution_steps: Vec::new(), }; let conflicted = WorktreeResolutionReport { session_id: "conflicted".to_string(), task: "resolve".to_string(), session_state: "failed".to_string(), attached: true, conflicted: true, check_exit_code: 2, path: Some("/tmp/ecc/wt-2".to_string()), branch: Some("ecc/conflicted".to_string()), base_branch: Some("main".to_string()), summary: "Merge blocked by 1 conflict(s): src/lib.rs".to_string(), conflicts: vec!["src/lib.rs".to_string()], resolution_steps: vec!["Inspect current patch".to_string()], }; assert_eq!(worktree_resolution_reports_exit_code(&[clear]), 0); assert_eq!(worktree_resolution_reports_exit_code(&[conflicted]), 2); } #[test] fn format_prune_worktrees_human_reports_cleaned_and_active_sessions() { let text = format_prune_worktrees_human(&session::manager::WorktreePruneOutcome { cleaned_session_ids: vec!["deadbeefcafefeed".to_string()], active_with_worktree_ids: vec!["facefeed12345678".to_string()], retained_session_ids: vec!["retain1234567890".to_string()], }); assert!(text.contains("Pruned 1 inactive worktree(s)")); assert!(text.contains("- cleaned deadbeef")); assert!(text.contains("Skipped 1 active session(s) still holding worktrees")); assert!(text.contains("- active facefeed")); assert!(text.contains("Deferred 1 inactive worktree(s) still within retention")); assert!(text.contains("- retained retain12")); } #[test] fn format_worktree_merge_human_reports_merge_and_cleanup() { let text = format_worktree_merge_human(&session::manager::WorktreeMergeOutcome { session_id: "deadbeefcafefeed".to_string(), branch: "ecc/deadbeef".to_string(), base_branch: "main".to_string(), already_up_to_date: false, cleaned_worktree: true, }); assert!(text.contains("Merged worktree for deadbeef")); assert!(text.contains("Branch ecc/deadbeef -> main")); assert!(text.contains("Result merged into base")); assert!(text.contains("Cleanup removed worktree and branch")); } #[test] fn format_merge_queue_human_reports_ready_and_blocked_entries() { let text = format_merge_queue_human(&session::manager::MergeQueueReport { ready_entries: vec![session::manager::MergeQueueEntry { session_id: "alpha1234".to_string(), task: "merge alpha".to_string(), project: "ecc".to_string(), task_group: "checkout".to_string(), branch: "ecc/alpha1234".to_string(), base_branch: "main".to_string(), state: session::SessionState::Stopped, worktree_health: worktree::WorktreeHealth::InProgress, dirty: false, queue_position: Some(1), ready_to_merge: true, blocked_by: Vec::new(), suggested_action: "merge in queue order #1".to_string(), }], blocked_entries: vec![session::manager::MergeQueueEntry { session_id: "beta5678".to_string(), task: "merge beta".to_string(), project: "ecc".to_string(), task_group: "checkout".to_string(), branch: "ecc/beta5678".to_string(), base_branch: "main".to_string(), state: session::SessionState::Stopped, worktree_health: worktree::WorktreeHealth::InProgress, dirty: false, queue_position: None, ready_to_merge: false, blocked_by: vec![session::manager::MergeQueueBlocker { session_id: "alpha1234".to_string(), branch: "ecc/alpha1234".to_string(), state: session::SessionState::Stopped, conflicts: vec!["README.md".to_string()], summary: "merge after alpha1234 to avoid branch conflicts".to_string(), conflicting_patch_preview: Some( "--- Branch diff vs main ---\nREADME.md".to_string(), ), blocker_patch_preview: None, }], suggested_action: "merge after alpha1234".to_string(), }], }); assert!(text.contains("Merge queue: 1 ready / 1 blocked")); assert!(text.contains("Ready")); assert!(text.contains("#1 alpha1234")); assert!(text.contains("Blocked")); assert!(text.contains("beta5678")); assert!(text.contains("blocker alpha1234")); assert!(text.contains("conflict README.md")); } #[test] fn format_bulk_worktree_merge_human_reports_summary_and_skips() { let text = format_bulk_worktree_merge_human(&session::manager::WorktreeBulkMergeOutcome { merged: vec![session::manager::WorktreeMergeOutcome { session_id: "deadbeefcafefeed".to_string(), branch: "ecc/deadbeefcafefeed".to_string(), base_branch: "main".to_string(), already_up_to_date: false, cleaned_worktree: true, }], rebased: vec![session::manager::WorktreeRebaseOutcome { session_id: "rebased12345678".to_string(), branch: "ecc/rebased12345678".to_string(), base_branch: "main".to_string(), already_up_to_date: false, }], active_with_worktree_ids: vec!["running12345678".to_string()], conflicted_session_ids: vec!["conflict123456".to_string()], dirty_worktree_ids: vec!["dirty123456789".to_string()], blocked_by_queue_session_ids: vec!["queue123456789".to_string()], failures: vec![session::manager::WorktreeMergeFailure { session_id: "fail1234567890".to_string(), reason: "base branch not checked out".to_string(), }], }); assert!(text.contains("Merged 1 ready worktree(s)")); assert!(text.contains("- merged ecc/deadbeefcafefeed -> main for deadbeef")); assert!(text.contains("Rebased 1 blocked worktree(s) onto their base branch")); assert!(text.contains("- rebased ecc/rebased12345678 onto main for rebased1")); assert!(text.contains("Skipped 1 active worktree session(s)")); assert!(text.contains("Skipped 1 conflicted worktree(s)")); assert!(text.contains("Skipped 1 dirty worktree(s)")); assert!(text.contains("Blocked 1 worktree(s) on remaining queue conflicts")); assert!(text.contains("Encountered 1 merge failure(s)")); assert!(text.contains("- failed fail1234: base branch not checked out")); } #[test] fn format_worktree_status_human_handles_missing_worktree() { let report = WorktreeStatusReport { session_id: "deadbeefcafefeed".to_string(), task: "No worktree here".to_string(), session_state: "stopped".to_string(), health: "clear".to_string(), check_exit_code: 0, patch_included: true, attached: false, path: None, branch: None, base_branch: None, diff_summary: None, file_preview: Vec::new(), patch_preview: None, merge_readiness: None, }; let text = format_worktree_status_human(&report); assert!(text.contains("Worktree status for deadbeef [stopped]")); assert!(text.contains("Task No worktree here")); assert!(text.contains("Health clear")); assert!(text.contains("No worktree attached")); } #[test] fn worktree_status_exit_code_tracks_health() { let clear = WorktreeStatusReport { session_id: "a".to_string(), task: "clear".to_string(), session_state: "idle".to_string(), health: "clear".to_string(), check_exit_code: 0, patch_included: false, attached: false, path: None, branch: None, base_branch: None, diff_summary: None, file_preview: Vec::new(), patch_preview: None, merge_readiness: None, }; let in_progress = WorktreeStatusReport { session_id: "b".to_string(), task: "progress".to_string(), session_state: "running".to_string(), health: "in_progress".to_string(), check_exit_code: 1, patch_included: false, attached: true, path: Some("/tmp/ecc/wt-2".to_string()), branch: Some("ecc/b".to_string()), base_branch: Some("main".to_string()), diff_summary: Some("Branch 1 file changed".to_string()), file_preview: vec!["Branch M README.md".to_string()], patch_preview: None, merge_readiness: Some(WorktreeMergeReadinessReport { status: "ready".to_string(), summary: "Merge ready into main".to_string(), conflicts: Vec::new(), }), }; let conflicted = WorktreeStatusReport { session_id: "c".to_string(), task: "conflict".to_string(), session_state: "running".to_string(), health: "conflicted".to_string(), check_exit_code: 2, patch_included: false, attached: true, path: Some("/tmp/ecc/wt-3".to_string()), branch: Some("ecc/c".to_string()), base_branch: Some("main".to_string()), diff_summary: Some("Branch 1 file changed".to_string()), file_preview: vec!["Branch M README.md".to_string()], patch_preview: None, merge_readiness: Some(WorktreeMergeReadinessReport { status: "conflicted".to_string(), summary: "Merge blocked by 1 conflict(s): README.md".to_string(), conflicts: vec!["README.md".to_string()], }), }; assert_eq!(worktree_status_exit_code(&clear), 0); assert_eq!(worktree_status_exit_code(&in_progress), 1); assert_eq!(worktree_status_exit_code(&conflicted), 2); } #[test] fn worktree_status_reports_exit_code_uses_highest_severity() { let reports = vec![ WorktreeStatusReport { session_id: "sess-a".to_string(), task: "first".to_string(), session_state: "running".to_string(), health: "clear".to_string(), check_exit_code: 0, patch_included: false, attached: false, path: None, branch: None, base_branch: None, diff_summary: None, file_preview: Vec::new(), patch_preview: None, merge_readiness: None, }, WorktreeStatusReport { session_id: "sess-b".to_string(), task: "second".to_string(), session_state: "running".to_string(), health: "in_progress".to_string(), check_exit_code: 1, patch_included: false, attached: false, path: None, branch: None, base_branch: None, diff_summary: None, file_preview: Vec::new(), patch_preview: None, merge_readiness: None, }, WorktreeStatusReport { session_id: "sess-c".to_string(), task: "third".to_string(), session_state: "running".to_string(), health: "conflicted".to_string(), check_exit_code: 2, patch_included: false, attached: false, path: None, branch: None, base_branch: None, diff_summary: None, file_preview: Vec::new(), patch_preview: None, merge_readiness: None, }, ]; assert_eq!(worktree_status_reports_exit_code(&reports), 2); } #[test] fn cli_parses_assign_command() { let cli = Cli::try_parse_from([ "ecc", "assign", "lead", "--task", "Review auth changes", "--agent", "claude", ]) .expect("assign should parse"); match cli.command { Some(Commands::Assign { from_session, task, agent, .. }) => { assert_eq!(from_session, "lead"); assert_eq!(task, "Review auth changes"); assert_eq!(agent.as_deref(), Some("claude")); } _ => panic!("expected assign subcommand"), } } #[test] fn cli_parses_drain_inbox_command() { let cli = Cli::try_parse_from([ "ecc", "drain-inbox", "lead", "--agent", "claude", "--limit", "3", ]) .expect("drain-inbox should parse"); match cli.command { Some(Commands::DrainInbox { session_id, agent, limit, .. }) => { assert_eq!(session_id, "lead"); assert_eq!(agent.as_deref(), Some("claude")); assert_eq!(limit, 3); } _ => panic!("expected drain-inbox subcommand"), } } #[test] fn cli_parses_auto_dispatch_command() { let cli = Cli::try_parse_from([ "ecc", "auto-dispatch", "--agent", "claude", "--lead-limit", "4", ]) .expect("auto-dispatch should parse"); match cli.command { Some(Commands::AutoDispatch { agent, lead_limit, .. }) => { assert_eq!(agent.as_deref(), Some("claude")); assert_eq!(lead_limit, 4); } _ => panic!("expected auto-dispatch subcommand"), } } #[test] fn cli_parses_coordinate_backlog_command() { let cli = Cli::try_parse_from([ "ecc", "coordinate-backlog", "--agent", "claude", "--lead-limit", "7", ]) .expect("coordinate-backlog should parse"); match cli.command { Some(Commands::CoordinateBacklog { agent, lead_limit, check, until_healthy, max_passes, .. }) => { assert_eq!(agent.as_deref(), Some("claude")); assert_eq!(lead_limit, 7); assert!(!check); assert!(!until_healthy); assert_eq!(max_passes, 5); } _ => panic!("expected coordinate-backlog subcommand"), } } #[test] fn cli_parses_coordinate_backlog_until_healthy_flags() { let cli = Cli::try_parse_from([ "ecc", "coordinate-backlog", "--until-healthy", "--max-passes", "3", ]) .expect("coordinate-backlog looping flags should parse"); match cli.command { Some(Commands::CoordinateBacklog { json, until_healthy, max_passes, .. }) => { assert!(!json); assert!(until_healthy); assert_eq!(max_passes, 3); } _ => panic!("expected coordinate-backlog subcommand"), } } #[test] fn cli_parses_coordinate_backlog_json_flag() { let cli = Cli::try_parse_from(["ecc", "coordinate-backlog", "--json"]) .expect("coordinate-backlog --json should parse"); match cli.command { Some(Commands::CoordinateBacklog { json, check, until_healthy, max_passes, .. }) => { assert!(json); assert!(!check); assert!(!until_healthy); assert_eq!(max_passes, 5); } _ => panic!("expected coordinate-backlog subcommand"), } } #[test] fn cli_parses_coordinate_backlog_check_flag() { let cli = Cli::try_parse_from(["ecc", "coordinate-backlog", "--check"]) .expect("coordinate-backlog --check should parse"); match cli.command { Some(Commands::CoordinateBacklog { json, check, until_healthy, max_passes, .. }) => { assert!(!json); assert!(check); assert!(!until_healthy); assert_eq!(max_passes, 5); } _ => panic!("expected coordinate-backlog subcommand"), } } #[test] fn cli_parses_rebalance_all_command() { let cli = Cli::try_parse_from([ "ecc", "rebalance-all", "--agent", "claude", "--lead-limit", "6", ]) .expect("rebalance-all should parse"); match cli.command { Some(Commands::RebalanceAll { agent, lead_limit, .. }) => { assert_eq!(agent.as_deref(), Some("claude")); assert_eq!(lead_limit, 6); } _ => panic!("expected rebalance-all subcommand"), } } #[test] fn cli_parses_coordination_status_command() { let cli = Cli::try_parse_from(["ecc", "coordination-status"]) .expect("coordination-status should parse"); match cli.command { Some(Commands::CoordinationStatus { json, check }) => { assert!(!json); assert!(!check); } _ => panic!("expected coordination-status subcommand"), } } #[test] fn cli_parses_log_decision_command() { let cli = Cli::try_parse_from([ "ecc", "log-decision", "latest", "--decision", "Use sqlite", "--reasoning", "It is already embedded", "--alternative", "json files", "--alternative", "memory only", "--json", ]) .expect("log-decision should parse"); match cli.command { Some(Commands::LogDecision { session_id, decision, reasoning, alternatives, json, }) => { assert_eq!(session_id.as_deref(), Some("latest")); assert_eq!(decision, "Use sqlite"); assert_eq!(reasoning, "It is already embedded"); assert_eq!(alternatives, vec!["json files", "memory only"]); assert!(json); } _ => panic!("expected log-decision subcommand"), } } #[test] fn cli_parses_decisions_command() { let cli = Cli::try_parse_from(["ecc", "decisions", "--all", "--limit", "5", "--json"]) .expect("decisions should parse"); match cli.command { Some(Commands::Decisions { session_id, all, json, limit, }) => { assert!(session_id.is_none()); assert!(all); assert!(json); assert_eq!(limit, 5); } _ => panic!("expected decisions subcommand"), } } #[test] fn cli_parses_graph_add_entity_command() { let cli = Cli::try_parse_from([ "ecc", "graph", "add-entity", "--session-id", "latest", "--type", "file", "--name", "dashboard.rs", "--path", "ecc2/src/tui/dashboard.rs", "--summary", "Primary TUI surface", "--meta", "language=rust", "--json", ]) .expect("graph add-entity should parse"); match cli.command { Some(Commands::Graph { command: GraphCommands::AddEntity { session_id, entity_type, name, path, summary, metadata, json, }, }) => { assert_eq!(session_id.as_deref(), Some("latest")); assert_eq!(entity_type, "file"); assert_eq!(name, "dashboard.rs"); assert_eq!(path.as_deref(), Some("ecc2/src/tui/dashboard.rs")); assert_eq!(summary, "Primary TUI surface"); assert_eq!(metadata, vec!["language=rust"]); assert!(json); } _ => panic!("expected graph add-entity subcommand"), } } #[test] fn cli_parses_graph_sync_command() { let cli = Cli::try_parse_from(["ecc", "graph", "sync", "--all", "--limit", "12", "--json"]) .expect("graph sync should parse"); match cli.command { Some(Commands::Graph { command: GraphCommands::Sync { session_id, all, limit, json, }, }) => { assert!(session_id.is_none()); assert!(all); assert_eq!(limit, 12); assert!(json); } _ => panic!("expected graph sync subcommand"), } } #[test] fn cli_parses_graph_recall_command() { let cli = Cli::try_parse_from([ "ecc", "graph", "recall", "--session-id", "latest", "--limit", "4", "--json", "auth callback recovery", ]) .expect("graph recall should parse"); match cli.command { Some(Commands::Graph { command: GraphCommands::Recall { session_id, query, limit, json, }, }) => { assert_eq!(session_id.as_deref(), Some("latest")); assert_eq!(query, "auth callback recovery"); assert_eq!(limit, 4); assert!(json); } _ => panic!("expected graph recall subcommand"), } } #[test] fn cli_parses_graph_add_observation_command() { let cli = Cli::try_parse_from([ "ecc", "graph", "add-observation", "--session-id", "latest", "--entity-id", "7", "--type", "completion_summary", "--pinned", "--summary", "Finished auth callback recovery", "--detail", "tests_run=2", "--json", ]) .expect("graph add-observation should parse"); match cli.command { Some(Commands::Graph { command: GraphCommands::AddObservation { session_id, entity_id, observation_type, priority, pinned, summary, details, json, }, }) => { assert_eq!(session_id.as_deref(), Some("latest")); assert_eq!(entity_id, 7); assert_eq!(observation_type, "completion_summary"); assert!(matches!(priority, ObservationPriorityArg::Normal)); assert!(pinned); assert_eq!(summary, "Finished auth callback recovery"); assert_eq!(details, vec!["tests_run=2"]); assert!(json); } _ => panic!("expected graph add-observation subcommand"), } } #[test] fn cli_parses_graph_pin_observation_command() { let cli = Cli::try_parse_from([ "ecc", "graph", "pin-observation", "--observation-id", "42", "--json", ]) .expect("graph pin-observation should parse"); match cli.command { Some(Commands::Graph { command: GraphCommands::PinObservation { observation_id, json, }, }) => { assert_eq!(observation_id, 42); assert!(json); } _ => panic!("expected graph pin-observation subcommand"), } } #[test] fn cli_parses_graph_unpin_observation_command() { let cli = Cli::try_parse_from([ "ecc", "graph", "unpin-observation", "--observation-id", "42", "--json", ]) .expect("graph unpin-observation should parse"); match cli.command { Some(Commands::Graph { command: GraphCommands::UnpinObservation { observation_id, json, }, }) => { assert_eq!(observation_id, 42); assert!(json); } _ => panic!("expected graph unpin-observation subcommand"), } } #[test] fn cli_parses_graph_compact_command() { let cli = Cli::try_parse_from([ "ecc", "graph", "compact", "--session-id", "latest", "--keep-observations-per-entity", "6", "--json", ]) .expect("graph compact should parse"); match cli.command { Some(Commands::Graph { command: GraphCommands::Compact { session_id, keep_observations_per_entity, json, }, }) => { assert_eq!(session_id.as_deref(), Some("latest")); assert_eq!(keep_observations_per_entity, 6); assert!(json); } _ => panic!("expected graph compact subcommand"), } } #[test] fn cli_parses_graph_connector_sync_command() { let cli = Cli::try_parse_from([ "ecc", "graph", "connector-sync", "hermes_notes", "--limit", "32", "--json", ]) .expect("graph connector-sync should parse"); match cli.command { Some(Commands::Graph { command: GraphCommands::ConnectorSync { name, all, limit, json, }, }) => { assert_eq!(name.as_deref(), Some("hermes_notes")); assert!(!all); assert_eq!(limit, 32); assert!(json); } _ => panic!("expected graph connector-sync subcommand"), } } #[test] fn cli_parses_graph_connector_sync_all_command() { let cli = Cli::try_parse_from([ "ecc", "graph", "connector-sync", "--all", "--limit", "16", "--json", ]) .expect("graph connector-sync --all should parse"); match cli.command { Some(Commands::Graph { command: GraphCommands::ConnectorSync { name, all, limit, json, }, }) => { assert_eq!(name, None); assert!(all); assert_eq!(limit, 16); assert!(json); } _ => panic!("expected graph connector-sync --all subcommand"), } } #[test] fn cli_parses_graph_connectors_command() { let cli = Cli::try_parse_from(["ecc", "graph", "connectors", "--json"]) .expect("graph connectors should parse"); match cli.command { Some(Commands::Graph { command: GraphCommands::Connectors { json }, }) => { assert!(json); } _ => panic!("expected graph connectors subcommand"), } } #[test] fn cli_parses_migrate_audit_command() { let cli = Cli::try_parse_from([ "ecc", "migrate", "audit", "--source", "/tmp/hermes", "--json", ]) .expect("migrate audit should parse"); match cli.command { Some(Commands::Migrate { command: MigrationCommands::Audit { source, json }, }) => { assert_eq!(source, PathBuf::from("/tmp/hermes")); assert!(json); } _ => panic!("expected migrate audit subcommand"), } } #[test] fn cli_parses_migrate_plan_command() { let cli = Cli::try_parse_from([ "ecc", "migrate", "plan", "--source", "/tmp/hermes", "--output", "/tmp/plan.md", ]) .expect("migrate plan should parse"); match cli.command { Some(Commands::Migrate { command: MigrationCommands::Plan { source, output, json, }, }) => { assert_eq!(source, PathBuf::from("/tmp/hermes")); assert_eq!(output, Some(PathBuf::from("/tmp/plan.md"))); assert!(!json); } _ => panic!("expected migrate plan subcommand"), } } #[test] fn cli_parses_migrate_scaffold_command() { let cli = Cli::try_parse_from([ "ecc", "migrate", "scaffold", "--source", "/tmp/hermes", "--output-dir", "/tmp/migration-scaffold", "--json", ]) .expect("migrate scaffold should parse"); match cli.command { Some(Commands::Migrate { command: MigrationCommands::Scaffold { source, output_dir, json, }, }) => { assert_eq!(source, PathBuf::from("/tmp/hermes")); assert_eq!(output_dir, PathBuf::from("/tmp/migration-scaffold")); assert!(json); } _ => panic!("expected migrate scaffold subcommand"), } } #[test] fn cli_parses_migrate_import_schedules_command() { let cli = Cli::try_parse_from([ "ecc", "migrate", "import-schedules", "--source", "/tmp/hermes", "--dry-run", "--json", ]) .expect("migrate import-schedules should parse"); match cli.command { Some(Commands::Migrate { command: MigrationCommands::ImportSchedules { source, dry_run, json, }, }) => { assert_eq!(source, PathBuf::from("/tmp/hermes")); assert!(dry_run); assert!(json); } _ => panic!("expected migrate import-schedules subcommand"), } } #[test] fn legacy_migration_audit_report_maps_detected_artifacts() -> Result<()> { let tempdir = TestDir::new("legacy-migration-audit")?; let root = tempdir.path(); fs::create_dir_all(root.join("cron"))?; fs::create_dir_all(root.join("gateway"))?; fs::create_dir_all(root.join("workspace/notes"))?; fs::create_dir_all(root.join("skills/ecc-imports"))?; fs::create_dir_all(root.join("tools"))?; fs::create_dir_all(root.join("plugins"))?; fs::write(root.join("config.yaml"), "model: claude\n")?; fs::write(root.join("cron/scheduler.py"), "print('tick')\n")?; fs::write(root.join("jobs.py"), "JOBS = []\n")?; fs::write(root.join("gateway/router.py"), "route = True\n")?; fs::write(root.join("memory_tool.py"), "class MemoryTool: pass\n")?; fs::write(root.join("workspace/notes/recovery.md"), "# recovery\n")?; fs::write(root.join("skills/ecc-imports/research.md"), "# skill\n")?; fs::write(root.join("tools/browser.py"), "print('browser')\n")?; fs::write(root.join("plugins/reminders.py"), "print('reminders')\n")?; fs::write( root.join(".env.local"), "STRIPE_SECRET_KEY=sk_test_secret\n", )?; let report = build_legacy_migration_audit_report(root)?; assert_eq!(report.detected_systems, vec!["hermes"]); assert_eq!(report.summary.artifact_categories_detected, 8); assert_eq!(report.summary.ready_now_categories, 4); assert_eq!(report.summary.manual_translation_categories, 3); assert_eq!(report.summary.local_auth_required_categories, 1); assert!(report .recommended_next_steps .iter() .any(|step| step.contains("ecc schedule add"))); assert!(report .recommended_next_steps .iter() .any(|step| step.contains("ecc remote serve"))); let scheduler = report .artifacts .iter() .find(|artifact| artifact.category == "scheduler") .expect("scheduler artifact"); assert_eq!(scheduler.readiness, LegacyMigrationReadiness::ReadyNow); assert_eq!(scheduler.detected_items, 2); let env_services = report .artifacts .iter() .find(|artifact| artifact.category == "env_services") .expect("env services artifact"); assert_eq!( env_services.readiness, LegacyMigrationReadiness::LocalAuthRequired ); assert!(env_services .source_paths .contains(&"config.yaml".to_string())); assert!(env_services .source_paths .contains(&".env.local".to_string())); Ok(()) } #[test] fn legacy_migration_plan_report_generates_workspace_connector_step() -> Result<()> { let tempdir = TestDir::new("legacy-migration-plan")?; let root = tempdir.path(); fs::create_dir_all(root.join("cron"))?; fs::create_dir_all(root.join("workspace/notes"))?; fs::write(root.join("config.yaml"), "model: claude\n")?; fs::write( root.join("cron/jobs.json"), serde_json::json!({ "jobs": [ { "name": "portal-recovery", "cron": "*/15 * * * *", "prompt": "Check portal-first recovery flow", "agent": "codex", "project": "billing-web", "task_group": "recovery", "use_worktree": false }, { "name": "paused-job", "cron": "0 12 * * *", "prompt": "This one stays paused", "disabled": true } ] }) .to_string(), )?; fs::write(root.join("workspace/notes/recovery.md"), "# recovery\n")?; let audit = build_legacy_migration_audit_report(root)?; let plan = build_legacy_migration_plan_report(&audit); let workspace_step = plan .steps .iter() .find(|step| step.category == "workspace_memory") .expect("workspace memory step"); assert_eq!(workspace_step.readiness, LegacyMigrationReadiness::ReadyNow); assert!(workspace_step .config_snippets .iter() .any(|snippet| snippet.contains("[memory_connectors.hermes_workspace]"))); assert!(workspace_step .command_snippets .contains(&"ecc graph connector-sync hermes_workspace".to_string())); let scheduler_step = plan .steps .iter() .find(|step| step.category == "scheduler") .expect("scheduler step"); assert!(scheduler_step .command_snippets .iter() .any(|command| command.contains("ecc schedule add --cron \"*/15 * * * *\""))); assert!(!scheduler_step .command_snippets .iter() .any(|command| command.contains(""))); assert!(scheduler_step .notes .iter() .any(|note| note.contains("disabled"))); let rendered = format_legacy_migration_plan_human(&plan); assert!(rendered.contains("Legacy migration plan")); assert!(rendered.contains("Import sanitized workspace memory through ECC2 connectors")); Ok(()) } #[test] fn import_legacy_schedules_dry_run_reports_ready_disabled_and_invalid_jobs() -> Result<()> { let tempdir = TestDir::new("legacy-schedule-import-dry-run")?; let root = tempdir.path(); fs::create_dir_all(root.join("cron"))?; fs::write( root.join("cron/jobs.json"), serde_json::json!({ "jobs": [ { "name": "portal-recovery", "cron": "*/15 * * * *", "prompt": "Check portal-first recovery flow", "agent": "codex", "project": "billing-web", "task_group": "recovery", "use_worktree": false }, { "name": "paused-job", "cron": "0 12 * * *", "prompt": "This one stays paused", "disabled": true }, { "name": "broken-job", "prompt": "Missing cron" } ] }) .to_string(), )?; let tempdb = TestDir::new("legacy-schedule-import-dry-run-db")?; let db = StateStore::open(&tempdb.path().join("state.db"))?; let report = import_legacy_schedules(&db, &config::Config::default(), root, true)?; assert!(report.dry_run); assert_eq!(report.jobs_detected, 3); assert_eq!(report.ready_jobs, 1); assert_eq!(report.imported_jobs, 0); assert_eq!(report.disabled_jobs, 1); assert_eq!(report.invalid_jobs, 1); assert_eq!(report.skipped_jobs, 0); assert_eq!(report.jobs.len(), 3); assert!(report .jobs .iter() .any(|job| job.command_snippet.as_deref() == Some("ecc schedule add --cron \"*/15 * * * *\" --task \"Check portal-first recovery flow\" --agent \"codex\" --no-worktree --project \"billing-web\" --task-group \"recovery\""))); Ok(()) } #[test] fn import_legacy_schedules_creates_real_ecc2_schedules() -> Result<()> { let tempdir = TestDir::new("legacy-schedule-import-live")?; let root = tempdir.path(); fs::create_dir_all(root.join("cron"))?; fs::write( root.join("cron/jobs.json"), serde_json::json!({ "jobs": [ { "name": "portal-recovery", "cron": "*/15 * * * *", "prompt": "Check portal-first recovery flow", "agent": "codex", "project": "billing-web", "task_group": "recovery", "use_worktree": false } ] }) .to_string(), )?; let target_repo = tempdir.path().join("target"); fs::create_dir_all(&target_repo)?; fs::write(target_repo.join(".gitignore"), "target\n")?; let tempdb = TestDir::new("legacy-schedule-import-live-db")?; let db = StateStore::open(&tempdb.path().join("state.db"))?; struct CurrentDirGuard(PathBuf); impl Drop for CurrentDirGuard { fn drop(&mut self) { let _ = std::env::set_current_dir(&self.0); } } let _cwd_guard = CurrentDirGuard(std::env::current_dir()?); std::env::set_current_dir(&target_repo)?; let report = import_legacy_schedules(&db, &config::Config::default(), root, false)?; assert!(!report.dry_run); assert_eq!(report.ready_jobs, 1); assert_eq!(report.imported_jobs, 1); assert_eq!( report.jobs[0].status, LegacyScheduleImportJobStatus::Imported ); assert!(report.jobs[0].imported_schedule_id.is_some()); let schedules = db.list_scheduled_tasks()?; assert_eq!(schedules.len(), 1); assert_eq!(schedules[0].task, "Check portal-first recovery flow"); assert_eq!(schedules[0].agent_type, "codex"); assert_eq!(schedules[0].project, "billing-web"); assert_eq!(schedules[0].task_group, "recovery"); assert!(!schedules[0].use_worktree); assert_eq!( schedules[0].working_dir.canonicalize()?, target_repo.canonicalize()? ); Ok(()) } #[test] fn legacy_migration_scaffold_writes_plan_and_config_files() -> Result<()> { let tempdir = TestDir::new("legacy-migration-scaffold")?; let root = tempdir.path(); fs::create_dir_all(root.join("workspace/notes"))?; fs::create_dir_all(root.join("skills/ecc-imports"))?; fs::write(root.join("config.yaml"), "model: claude\n")?; fs::write(root.join("workspace/notes/recovery.md"), "# recovery\n")?; fs::write(root.join("skills/ecc-imports/triage.md"), "# triage\n")?; let audit = build_legacy_migration_audit_report(root)?; let plan = build_legacy_migration_plan_report(&audit); let output_dir = root.join("out"); let report = write_legacy_migration_scaffold(&plan, &output_dir)?; assert_eq!(report.steps_scaffolded, plan.steps.len()); assert_eq!(report.files_written.len(), 2); let plan_text = fs::read_to_string(output_dir.join("migration-plan.md"))?; let config_text = fs::read_to_string(output_dir.join("ecc2.migration.toml"))?; assert!(plan_text.contains("Legacy migration plan")); assert!(config_text.contains("[memory_connectors.hermes_workspace]")); assert!(config_text.contains("[orchestration_templates.legacy_workflow]")); Ok(()) } #[test] fn format_decisions_human_renders_details() { let text = format_decisions_human( &[session::DecisionLogEntry { id: 1, session_id: "sess-12345678".to_string(), decision: "Use sqlite for the shared context graph".to_string(), alternatives: vec!["json files".to_string(), "memory only".to_string()], reasoning: "SQLite keeps the audit trail queryable.".to_string(), timestamp: chrono::DateTime::parse_from_rfc3339("2026-04-09T01:02:03Z") .unwrap() .with_timezone(&chrono::Utc), }], true, ); assert!(text.contains("Decision log: 1 entries")); assert!(text.contains("sess-123")); assert!(text.contains("Use sqlite for the shared context graph")); assert!(text.contains("why SQLite keeps the audit trail queryable.")); assert!(text.contains("alternative json files")); assert!(text.contains("alternative memory only")); } #[test] fn format_graph_entity_detail_human_renders_relations() { let detail = session::ContextGraphEntityDetail { entity: session::ContextGraphEntity { id: 7, session_id: Some("sess-12345678".to_string()), entity_type: "function".to_string(), name: "render_metrics".to_string(), path: Some("ecc2/src/tui/dashboard.rs".to_string()), summary: "Renders the metrics pane".to_string(), metadata: BTreeMap::from([("language".to_string(), "rust".to_string())]), created_at: chrono::DateTime::parse_from_rfc3339("2026-04-10T01:02:03Z") .unwrap() .with_timezone(&chrono::Utc), updated_at: chrono::DateTime::parse_from_rfc3339("2026-04-10T01:02:03Z") .unwrap() .with_timezone(&chrono::Utc), }, outgoing: vec![session::ContextGraphRelation { id: 9, session_id: Some("sess-12345678".to_string()), from_entity_id: 7, from_entity_type: "function".to_string(), from_entity_name: "render_metrics".to_string(), to_entity_id: 10, to_entity_type: "type".to_string(), to_entity_name: "MetricsSnapshot".to_string(), relation_type: "returns".to_string(), summary: "Produces the rendered metrics model".to_string(), created_at: chrono::DateTime::parse_from_rfc3339("2026-04-10T01:02:03Z") .unwrap() .with_timezone(&chrono::Utc), }], incoming: vec![session::ContextGraphRelation { id: 8, session_id: Some("sess-12345678".to_string()), from_entity_id: 6, from_entity_type: "file".to_string(), from_entity_name: "dashboard.rs".to_string(), to_entity_id: 7, to_entity_type: "function".to_string(), to_entity_name: "render_metrics".to_string(), relation_type: "contains".to_string(), summary: "Dashboard owns the render path".to_string(), created_at: chrono::DateTime::parse_from_rfc3339("2026-04-10T01:02:03Z") .unwrap() .with_timezone(&chrono::Utc), }], }; let text = format_graph_entity_detail_human(&detail); assert!(text.contains("Context graph entity #7")); assert!(text.contains("Outgoing relations: 1")); assert!(text.contains("[returns] render_metrics -> #10 MetricsSnapshot")); assert!(text.contains("Incoming relations: 1")); assert!(text.contains("[contains] #6 dashboard.rs -> render_metrics")); } #[test] fn format_graph_recall_human_renders_scores_and_matches() { let text = format_graph_recall_human( &[session::ContextGraphRecallEntry { entity: session::ContextGraphEntity { id: 11, session_id: Some("sess-12345678".to_string()), entity_type: "file".to_string(), name: "callback.ts".to_string(), path: Some("src/routes/auth/callback.ts".to_string()), summary: "Handles auth callback recovery".to_string(), metadata: BTreeMap::new(), created_at: chrono::DateTime::parse_from_rfc3339("2026-04-10T01:02:03Z") .unwrap() .with_timezone(&chrono::Utc), updated_at: chrono::DateTime::parse_from_rfc3339("2026-04-10T01:02:03Z") .unwrap() .with_timezone(&chrono::Utc), }, score: 319, matched_terms: vec![ "auth".to_string(), "callback".to_string(), "recovery".to_string(), ], relation_count: 2, observation_count: 1, max_observation_priority: session::ContextObservationPriority::High, has_pinned_observation: true, }], Some("sess-12345678"), "auth callback recovery", ); assert!(text.contains("Relevant memory: 1 entries")); assert!(text.contains("[file] callback.ts | score 319 | relations 2 | observations 1")); assert!(text.contains("priority high")); assert!(text.contains("| pinned")); assert!(text.contains("matches auth, callback, recovery")); assert!(text.contains("path src/routes/auth/callback.ts")); } #[test] fn format_graph_observations_human_renders_summaries() { let text = format_graph_observations_human(&[session::ContextGraphObservation { id: 5, session_id: Some("sess-12345678".to_string()), entity_id: 11, entity_type: "session".to_string(), entity_name: "sess-12345678".to_string(), observation_type: "completion_summary".to_string(), priority: session::ContextObservationPriority::High, pinned: true, summary: "Finished auth callback recovery with 2 tests".to_string(), details: BTreeMap::from([("tests_run".to_string(), "2".to_string())]), created_at: chrono::DateTime::parse_from_rfc3339("2026-04-10T01:02:03Z") .unwrap() .with_timezone(&chrono::Utc), }]); assert!(text.contains("Context graph observations: 1")); assert!(text.contains("[completion_summary/high/pinned] sess-12345678")); assert!(text.contains("summary Finished auth callback recovery with 2 tests")); } #[test] fn format_graph_compaction_stats_human_renders_counts() { let text = format_graph_compaction_stats_human( &session::ContextGraphCompactionStats { entities_scanned: 3, duplicate_observations_deleted: 2, overflow_observations_deleted: 4, observations_retained: 9, }, Some("sess-12345678"), 6, ); assert!(text.contains("Context graph compaction complete for sess-123")); assert!(text.contains("keep 6 observations per entity")); assert!(text.contains("- entities scanned 3")); assert!(text.contains("- duplicate observations deleted 2")); assert!(text.contains("- overflow observations deleted 4")); assert!(text.contains("- observations retained 9")); } #[test] fn format_graph_connector_sync_stats_human_renders_counts() { let text = format_graph_connector_sync_stats_human(&GraphConnectorSyncStats { connector_name: "hermes_notes".to_string(), records_read: 4, entities_upserted: 3, observations_added: 3, skipped_records: 1, skipped_unchanged_sources: 2, }); assert!(text.contains("Memory connector sync complete: hermes_notes")); assert!(text.contains("- records read 4")); assert!(text.contains("- entities upserted 3")); assert!(text.contains("- observations added 3")); assert!(text.contains("- skipped records 1")); assert!(text.contains("- skipped unchanged sources 2")); } #[test] fn format_graph_connector_sync_report_human_renders_totals_and_connectors() { let text = format_graph_connector_sync_report_human(&GraphConnectorSyncReport { connectors_synced: 2, records_read: 7, entities_upserted: 5, observations_added: 5, skipped_records: 2, skipped_unchanged_sources: 3, connectors: vec![ GraphConnectorSyncStats { connector_name: "hermes_notes".to_string(), records_read: 4, entities_upserted: 3, observations_added: 3, skipped_records: 1, skipped_unchanged_sources: 2, }, GraphConnectorSyncStats { connector_name: "workspace_note".to_string(), records_read: 3, entities_upserted: 2, observations_added: 2, skipped_records: 1, skipped_unchanged_sources: 1, }, ], }); assert!(text.contains("Memory connector sync complete: 2 connector(s)")); assert!(text.contains("- records read 7")); assert!(text.contains("- skipped unchanged sources 3")); assert!(text.contains("Connectors:")); assert!(text.contains("- hermes_notes")); assert!(text.contains("- workspace_note")); assert!(text.contains(" skipped unchanged sources 2")); } #[test] fn format_graph_connector_status_report_human_renders_connector_details() { let text = format_graph_connector_status_report_human(&GraphConnectorStatusReport { configured_connectors: 2, connectors: vec![ GraphConnectorStatus { connector_name: "hermes_notes".to_string(), connector_kind: "jsonl_directory".to_string(), source_path: "/tmp/hermes-notes".to_string(), recurse: true, default_session_id: Some("latest".to_string()), default_entity_type: Some("incident".to_string()), default_observation_type: Some("external_note".to_string()), synced_sources: 3, last_synced_at: Some( chrono::DateTime::parse_from_rfc3339("2026-04-10T12:34:56Z") .unwrap() .with_timezone(&chrono::Utc), ), }, GraphConnectorStatus { connector_name: "workspace_env".to_string(), connector_kind: "dotenv_file".to_string(), source_path: "/tmp/.env".to_string(), recurse: false, default_session_id: None, default_entity_type: None, default_observation_type: None, synced_sources: 0, last_synced_at: None, }, ], }); assert!(text.contains("Memory connectors: 2 configured")); assert!(text.contains("- hermes_notes [jsonl_directory]")); assert!(text.contains(" source /tmp/hermes-notes")); assert!(text.contains(" recurse true")); assert!(text.contains(" synced sources 3")); assert!(text.contains(" last synced 2026-04-10T12:34:56+00:00")); assert!(text.contains(" default session latest")); assert!(text.contains(" default entity type incident")); assert!(text.contains(" default observation type external_note")); assert!(text.contains("- workspace_env [dotenv_file]")); assert!(text.contains(" last synced never")); } #[test] fn memory_connector_status_report_includes_checkpoint_state() -> Result<()> { let tempdir = TestDir::new("graph-connector-status-report")?; let db = session::store::StateStore::open(&tempdir.path().join("state.db"))?; let markdown_path = tempdir.path().join("workspace-memory.md"); fs::write( &markdown_path, r#"# Billing incident Customer wiped setup and got charged twice after reinstalling. "#, )?; let mut cfg = config::Config::default(); cfg.memory_connectors.insert( "workspace_note".to_string(), config::MemoryConnectorConfig::MarkdownFile( config::MemoryConnectorMarkdownFileConfig { path: markdown_path.clone(), session_id: Some("latest".to_string()), default_entity_type: Some("note_section".to_string()), default_observation_type: Some("external_note".to_string()), }, ), ); cfg.memory_connectors.insert( "workspace_env".to_string(), config::MemoryConnectorConfig::DotenvFile(config::MemoryConnectorDotenvFileConfig { path: tempdir.path().join(".env"), session_id: None, default_entity_type: Some("service_config".to_string()), default_observation_type: Some("external_config".to_string()), key_prefixes: vec!["PUBLIC_".to_string()], include_keys: Vec::new(), exclude_keys: Vec::new(), include_safe_values: true, }), ); db.upsert_connector_source_checkpoint( "workspace_note", &markdown_path.display().to_string(), "sig-a", )?; let report = memory_connector_status_report(&db, &cfg)?; assert_eq!(report.configured_connectors, 2); assert_eq!( report .connectors .iter() .map(|connector| connector.connector_name.as_str()) .collect::>(), vec!["workspace_env", "workspace_note"] ); let workspace_env = report .connectors .iter() .find(|connector| connector.connector_name == "workspace_env") .expect("workspace_env connector should exist"); assert_eq!(workspace_env.connector_kind, "dotenv_file"); assert_eq!(workspace_env.synced_sources, 0); assert!(workspace_env.last_synced_at.is_none()); let workspace_note = report .connectors .iter() .find(|connector| connector.connector_name == "workspace_note") .expect("workspace_note connector should exist"); assert_eq!(workspace_note.connector_kind, "markdown_file"); assert_eq!( workspace_note.source_path, markdown_path.display().to_string() ); assert_eq!(workspace_note.default_session_id.as_deref(), Some("latest")); assert_eq!( workspace_note.default_entity_type.as_deref(), Some("note_section") ); assert_eq!( workspace_note.default_observation_type.as_deref(), Some("external_note") ); assert_eq!(workspace_note.synced_sources, 1); assert!(workspace_note.last_synced_at.is_some()); Ok(()) } #[test] fn sync_memory_connector_imports_jsonl_observations() -> Result<()> { let tempdir = TestDir::new("graph-connector-sync")?; let db = session::store::StateStore::open(&tempdir.path().join("state.db"))?; let now = chrono::Utc::now(); db.insert_session(&session::Session { id: "session-1".to_string(), task: "recovery incident".to_string(), project: "ecc-tools".to_string(), task_group: "incident".to_string(), agent_type: "claude".to_string(), working_dir: PathBuf::from("/tmp"), state: session::SessionState::Running, pid: None, worktree: None, created_at: now, updated_at: now, last_heartbeat_at: now, metrics: session::SessionMetrics::default(), })?; let connector_path = tempdir.path().join("hermes-memory.jsonl"); std::fs::write( &connector_path, [ serde_json::json!({ "entity_name": "Auth callback recovery", "summary": "Customer wiped setup and got charged twice", "details": {"customer": "viktor"} }) .to_string(), serde_json::json!({ "session_id": "latest", "entity_type": "file", "entity_name": "callback.ts", "path": "src/routes/auth/callback.ts", "observation_type": "incident_note", "summary": "Recovery flow needs portal-first routing" }) .to_string(), ] .join("\n"), )?; let mut cfg = config::Config::default(); cfg.memory_connectors.insert( "hermes_notes".to_string(), config::MemoryConnectorConfig::JsonlFile(config::MemoryConnectorJsonlFileConfig { path: connector_path, session_id: Some("latest".to_string()), default_entity_type: Some("incident".to_string()), default_observation_type: Some("external_note".to_string()), }), ); let stats = sync_memory_connector(&db, &cfg, "hermes_notes", 10)?; assert_eq!(stats.records_read, 2); assert_eq!(stats.entities_upserted, 2); assert_eq!(stats.observations_added, 2); assert_eq!(stats.skipped_records, 0); let recalled = db.recall_context_entities(None, "charged twice routing", 5)?; assert_eq!(recalled.len(), 2); assert!(recalled .iter() .any(|entry| entry.entity.name == "Auth callback recovery")); assert!(recalled .iter() .any(|entry| entry.entity.name == "callback.ts")); Ok(()) } #[test] fn sync_memory_connector_skips_unchanged_jsonl_sources() -> Result<()> { let tempdir = TestDir::new("graph-connector-sync-unchanged")?; let db = session::store::StateStore::open(&tempdir.path().join("state.db"))?; let now = chrono::Utc::now(); db.insert_session(&session::Session { id: "session-1".to_string(), task: "recovery incident".to_string(), project: "ecc-tools".to_string(), task_group: "incident".to_string(), agent_type: "claude".to_string(), working_dir: PathBuf::from("/tmp"), state: session::SessionState::Running, pid: None, worktree: None, created_at: now, updated_at: now, last_heartbeat_at: now, metrics: session::SessionMetrics::default(), })?; let connector_path = tempdir.path().join("hermes-memory.jsonl"); fs::write( &connector_path, serde_json::json!({ "entity_name": "Portal routing", "summary": "Route reinstalls to portal before checkout", }) .to_string(), )?; let mut cfg = config::Config::default(); cfg.memory_connectors.insert( "hermes_notes".to_string(), config::MemoryConnectorConfig::JsonlFile(config::MemoryConnectorJsonlFileConfig { path: connector_path, session_id: Some("latest".to_string()), default_entity_type: Some("incident".to_string()), default_observation_type: Some("external_note".to_string()), }), ); let first = sync_memory_connector(&db, &cfg, "hermes_notes", 10)?; assert_eq!(first.records_read, 1); assert_eq!(first.skipped_unchanged_sources, 0); let second = sync_memory_connector(&db, &cfg, "hermes_notes", 10)?; assert_eq!(second.records_read, 0); assert_eq!(second.entities_upserted, 0); assert_eq!(second.observations_added, 0); assert_eq!(second.skipped_unchanged_sources, 1); Ok(()) } #[test] fn sync_memory_connector_imports_jsonl_directory_observations() -> Result<()> { let tempdir = TestDir::new("graph-connector-sync-dir")?; let db = session::store::StateStore::open(&tempdir.path().join("state.db"))?; let now = chrono::Utc::now(); db.insert_session(&session::Session { id: "session-1".to_string(), task: "recovery incident".to_string(), project: "ecc-tools".to_string(), task_group: "incident".to_string(), agent_type: "claude".to_string(), working_dir: PathBuf::from("/tmp"), state: session::SessionState::Running, pid: None, worktree: None, created_at: now, updated_at: now, last_heartbeat_at: now, metrics: session::SessionMetrics::default(), })?; let connector_dir = tempdir.path().join("hermes-memory"); fs::create_dir_all(connector_dir.join("nested"))?; fs::write( connector_dir.join("a.jsonl"), [ serde_json::json!({ "entity_name": "Auth callback recovery", "summary": "Customer wiped setup and got charged twice", }) .to_string(), serde_json::json!({ "entity_name": "Portal routing", "summary": "Route existing installs to portal first", }) .to_string(), ] .join("\n"), )?; fs::write( connector_dir.join("nested").join("b.jsonl"), [ serde_json::json!({ "entity_name": "Billing UX note", "summary": "Warn against buying twice after wiping setup", }) .to_string(), "{invalid json}".to_string(), ] .join("\n"), )?; fs::write(connector_dir.join("ignore.txt"), "not imported")?; let mut cfg = config::Config::default(); cfg.memory_connectors.insert( "hermes_dir".to_string(), config::MemoryConnectorConfig::JsonlDirectory( config::MemoryConnectorJsonlDirectoryConfig { path: connector_dir, recurse: true, session_id: Some("latest".to_string()), default_entity_type: Some("incident".to_string()), default_observation_type: Some("external_note".to_string()), }, ), ); let stats = sync_memory_connector(&db, &cfg, "hermes_dir", 10)?; assert_eq!(stats.records_read, 4); assert_eq!(stats.entities_upserted, 3); assert_eq!(stats.observations_added, 3); assert_eq!(stats.skipped_records, 1); let recalled = db.recall_context_entities(None, "charged twice portal billing", 10)?; assert_eq!(recalled.len(), 3); assert!(recalled .iter() .any(|entry| entry.entity.name == "Auth callback recovery")); assert!(recalled .iter() .any(|entry| entry.entity.name == "Portal routing")); assert!(recalled .iter() .any(|entry| entry.entity.name == "Billing UX note")); Ok(()) } #[test] fn sync_memory_connector_imports_markdown_file_sections() -> Result<()> { let tempdir = TestDir::new("graph-connector-sync-markdown")?; let db = session::store::StateStore::open(&tempdir.path().join("state.db"))?; let now = chrono::Utc::now(); db.insert_session(&session::Session { id: "session-1".to_string(), task: "knowledge import".to_string(), project: "everything-claude-code".to_string(), task_group: "memory".to_string(), agent_type: "claude".to_string(), working_dir: PathBuf::from("/tmp"), state: session::SessionState::Running, pid: None, worktree: None, created_at: now, updated_at: now, last_heartbeat_at: now, metrics: session::SessionMetrics::default(), })?; let connector_path = tempdir.path().join("workspace-memory.md"); fs::write( &connector_path, r#"# Billing incident Customer wiped setup and got charged twice after reinstalling. ## Portal routing Route existing installs to portal first before presenting checkout again. ## Docs fix Guide users to repair before reinstall so wiped setups do not buy twice. "#, )?; let mut cfg = config::Config::default(); cfg.memory_connectors.insert( "workspace_note".to_string(), config::MemoryConnectorConfig::MarkdownFile( config::MemoryConnectorMarkdownFileConfig { path: connector_path.clone(), session_id: Some("latest".to_string()), default_entity_type: Some("note_section".to_string()), default_observation_type: Some("external_note".to_string()), }, ), ); let stats = sync_memory_connector(&db, &cfg, "workspace_note", 10)?; assert_eq!(stats.records_read, 3); assert_eq!(stats.entities_upserted, 3); assert_eq!(stats.observations_added, 3); assert_eq!(stats.skipped_records, 0); let recalled = db.recall_context_entities(None, "charged twice reinstall", 10)?; assert!(recalled .iter() .any(|entry| entry.entity.name == "Billing incident")); assert!(recalled.iter().any(|entry| entry.entity.name == "Docs fix")); let billing = recalled .iter() .find(|entry| entry.entity.name == "Billing incident") .expect("billing section should exist"); let expected_anchor_path = format!("{}#billing-incident", connector_path.display()); assert_eq!( billing.entity.path.as_deref(), Some(expected_anchor_path.as_str()) ); let observations = db.list_context_observations(Some(billing.entity.id), 5)?; assert_eq!(observations.len(), 1); let expected_source_path = connector_path.display().to_string(); assert_eq!( observations[0] .details .get("source_path") .map(String::as_str), Some(expected_source_path.as_str()) ); assert!(observations[0] .details .get("body") .is_some_and(|value: &String| value.contains("charged twice"))); Ok(()) } #[test] fn sync_memory_connector_imports_markdown_directory_sections() -> Result<()> { let tempdir = TestDir::new("graph-connector-sync-markdown-dir")?; let db = session::store::StateStore::open(&tempdir.path().join("state.db"))?; let now = chrono::Utc::now(); db.insert_session(&session::Session { id: "session-1".to_string(), task: "knowledge import".to_string(), project: "everything-claude-code".to_string(), task_group: "memory".to_string(), agent_type: "claude".to_string(), working_dir: PathBuf::from("/tmp"), state: session::SessionState::Running, pid: None, worktree: None, created_at: now, updated_at: now, last_heartbeat_at: now, metrics: session::SessionMetrics::default(), })?; let connector_dir = tempdir.path().join("workspace-notes"); fs::create_dir_all(connector_dir.join("nested"))?; fs::write( connector_dir.join("incident.md"), r#"# Billing incident Customer wiped setup and got charged twice after reinstalling. ## Portal routing Route existing installs to portal first before presenting checkout again. "#, )?; fs::write( connector_dir.join("nested").join("docs.markdown"), r#"# Docs fix Guide users to repair before reinstall so wiped setups do not buy twice. "#, )?; fs::write(connector_dir.join("ignore.txt"), "not imported")?; let mut cfg = config::Config::default(); cfg.memory_connectors.insert( "workspace_notes".to_string(), config::MemoryConnectorConfig::MarkdownDirectory( config::MemoryConnectorMarkdownDirectoryConfig { path: connector_dir.clone(), recurse: true, session_id: Some("latest".to_string()), default_entity_type: Some("note_section".to_string()), default_observation_type: Some("external_note".to_string()), }, ), ); let stats = sync_memory_connector(&db, &cfg, "workspace_notes", 10)?; assert_eq!(stats.records_read, 3); assert_eq!(stats.entities_upserted, 3); assert_eq!(stats.observations_added, 3); assert_eq!(stats.skipped_records, 0); let recalled = db.recall_context_entities(None, "charged twice portal docs", 10)?; assert!(recalled .iter() .any(|entry| entry.entity.name == "Billing incident")); assert!(recalled .iter() .any(|entry| entry.entity.name == "Portal routing")); assert!(recalled.iter().any(|entry| entry.entity.name == "Docs fix")); let docs_fix = recalled .iter() .find(|entry| entry.entity.name == "Docs fix") .expect("docs section should exist"); let expected_anchor_path = format!( "{}#docs-fix", connector_dir.join("nested").join("docs.markdown").display() ); assert_eq!( docs_fix.entity.path.as_deref(), Some(expected_anchor_path.as_str()) ); Ok(()) } #[test] fn sync_memory_connector_imports_dotenv_entries_safely() -> Result<()> { let tempdir = TestDir::new("graph-connector-sync-dotenv")?; let db = session::store::StateStore::open(&tempdir.path().join("state.db"))?; let now = chrono::Utc::now(); db.insert_session(&session::Session { id: "session-1".to_string(), task: "service config import".to_string(), project: "ecc-tools".to_string(), task_group: "memory".to_string(), agent_type: "claude".to_string(), working_dir: PathBuf::from("/tmp"), state: session::SessionState::Running, pid: None, worktree: None, created_at: now, updated_at: now, last_heartbeat_at: now, metrics: session::SessionMetrics::default(), })?; let connector_path = tempdir.path().join("hermes.env"); fs::write( &connector_path, r#"# Hermes service config STRIPE_SECRET_KEY=sk_test_secret STRIPE_PRO_PRICE_ID=price_pro_monthly PUBLIC_BASE_URL="https://ecc.tools" STRIPE_WEBHOOK_SECRET=whsec_secret GITHUB_TOKEN=ghp_should_not_import INVALID LINE "#, )?; let mut cfg = config::Config::default(); cfg.memory_connectors.insert( "hermes_env".to_string(), config::MemoryConnectorConfig::DotenvFile(config::MemoryConnectorDotenvFileConfig { path: connector_path.clone(), session_id: Some("latest".to_string()), default_entity_type: Some("service_config".to_string()), default_observation_type: Some("external_config".to_string()), key_prefixes: vec!["STRIPE_".to_string(), "PUBLIC_".to_string()], include_keys: Vec::new(), exclude_keys: vec!["STRIPE_WEBHOOK_SECRET".to_string()], include_safe_values: true, }), ); let stats = sync_memory_connector(&db, &cfg, "hermes_env", 10)?; assert_eq!(stats.records_read, 3); assert_eq!(stats.entities_upserted, 3); assert_eq!(stats.observations_added, 3); assert_eq!(stats.skipped_records, 0); let recalled = db.recall_context_entities(None, "stripe ecc.tools", 10)?; assert!(recalled .iter() .any(|entry| entry.entity.name == "STRIPE_SECRET_KEY")); assert!(recalled .iter() .any(|entry| entry.entity.name == "STRIPE_PRO_PRICE_ID")); assert!(recalled .iter() .any(|entry| entry.entity.name == "PUBLIC_BASE_URL")); assert!(!recalled .iter() .any(|entry| entry.entity.name == "STRIPE_WEBHOOK_SECRET")); assert!(!recalled .iter() .any(|entry| entry.entity.name == "GITHUB_TOKEN")); let secret = recalled .iter() .find(|entry| entry.entity.name == "STRIPE_SECRET_KEY") .expect("secret entry should exist"); let secret_observations = db.list_context_observations(Some(secret.entity.id), 5)?; assert_eq!(secret_observations.len(), 1); assert_eq!( secret_observations[0] .details .get("secret_redacted") .map(String::as_str), Some("true") ); assert!(!secret_observations[0].details.contains_key("value")); let public_base = recalled .iter() .find(|entry| entry.entity.name == "PUBLIC_BASE_URL") .expect("public base url should exist"); let public_observations = db.list_context_observations(Some(public_base.entity.id), 5)?; assert_eq!(public_observations.len(), 1); assert_eq!( public_observations[0] .details .get("value") .map(String::as_str), Some("https://ecc.tools") ); Ok(()) } #[test] fn sync_all_memory_connectors_aggregates_results() -> Result<()> { let tempdir = TestDir::new("graph-connector-sync-all")?; let db = session::store::StateStore::open(&tempdir.path().join("state.db"))?; let now = chrono::Utc::now(); db.insert_session(&session::Session { id: "session-1".to_string(), task: "memory import".to_string(), project: "everything-claude-code".to_string(), task_group: "memory".to_string(), agent_type: "claude".to_string(), working_dir: PathBuf::from("/tmp"), state: session::SessionState::Running, pid: None, worktree: None, created_at: now, updated_at: now, last_heartbeat_at: now, metrics: session::SessionMetrics::default(), })?; let jsonl_path = tempdir.path().join("hermes-memory.jsonl"); fs::write( &jsonl_path, serde_json::json!({ "entity_name": "Portal routing", "summary": "Route reinstalls to portal before checkout", }) .to_string(), )?; let markdown_path = tempdir.path().join("workspace-memory.md"); fs::write( &markdown_path, r#"# Billing incident Customer wiped setup and got charged twice after reinstalling. ## Docs fix Guide users to repair before reinstall. "#, )?; let mut cfg = config::Config::default(); cfg.memory_connectors.insert( "hermes_notes".to_string(), config::MemoryConnectorConfig::JsonlFile(config::MemoryConnectorJsonlFileConfig { path: jsonl_path, session_id: Some("latest".to_string()), default_entity_type: Some("incident".to_string()), default_observation_type: Some("external_note".to_string()), }), ); cfg.memory_connectors.insert( "workspace_note".to_string(), config::MemoryConnectorConfig::MarkdownFile( config::MemoryConnectorMarkdownFileConfig { path: markdown_path, session_id: Some("latest".to_string()), default_entity_type: Some("note_section".to_string()), default_observation_type: Some("external_note".to_string()), }, ), ); let report = sync_all_memory_connectors(&db, &cfg, 10)?; assert_eq!(report.connectors_synced, 2); assert_eq!(report.records_read, 3); assert_eq!(report.entities_upserted, 3); assert_eq!(report.observations_added, 3); assert_eq!(report.skipped_records, 0); assert_eq!( report .connectors .iter() .map(|stats| stats.connector_name.as_str()) .collect::>(), vec!["hermes_notes", "workspace_note"] ); let recalled = db.recall_context_entities(None, "charged twice portal reinstall", 10)?; assert_eq!(recalled.len(), 3); Ok(()) } #[test] fn format_graph_sync_stats_human_renders_counts() { let text = format_graph_sync_stats_human( &session::ContextGraphSyncStats { sessions_scanned: 2, decisions_processed: 3, file_events_processed: 5, messages_processed: 4, }, Some("sess-12345678"), ); assert!(text.contains("Context graph sync complete for sess-123")); assert!(text.contains("- sessions scanned 2")); assert!(text.contains("- decisions processed 3")); assert!(text.contains("- file events processed 5")); assert!(text.contains("- messages processed 4")); } #[test] fn cli_parses_coordination_status_json_flag() { let cli = Cli::try_parse_from(["ecc", "coordination-status", "--json"]) .expect("coordination-status --json should parse"); match cli.command { Some(Commands::CoordinationStatus { json, check }) => { assert!(json); assert!(!check); } _ => panic!("expected coordination-status subcommand"), } } #[test] fn cli_parses_coordination_status_check_flag() { let cli = Cli::try_parse_from(["ecc", "coordination-status", "--check"]) .expect("coordination-status --check should parse"); match cli.command { Some(Commands::CoordinationStatus { json, check }) => { assert!(!json); assert!(check); } _ => panic!("expected coordination-status subcommand"), } } #[test] fn cli_parses_maintain_coordination_command() { let cli = Cli::try_parse_from(["ecc", "maintain-coordination"]) .expect("maintain-coordination should parse"); match cli.command { Some(Commands::MaintainCoordination { agent, json, check, max_passes, .. }) => { assert!(agent.is_none()); assert!(!json); assert!(!check); assert_eq!(max_passes, 5); } _ => panic!("expected maintain-coordination subcommand"), } } #[test] fn cli_parses_maintain_coordination_json_flag() { let cli = Cli::try_parse_from(["ecc", "maintain-coordination", "--json"]) .expect("maintain-coordination --json should parse"); match cli.command { Some(Commands::MaintainCoordination { json, check, max_passes, .. }) => { assert!(json); assert!(!check); assert_eq!(max_passes, 5); } _ => panic!("expected maintain-coordination subcommand"), } } #[test] fn cli_parses_maintain_coordination_check_flag() { let cli = Cli::try_parse_from(["ecc", "maintain-coordination", "--check"]) .expect("maintain-coordination --check should parse"); match cli.command { Some(Commands::MaintainCoordination { json, check, max_passes, .. }) => { assert!(!json); assert!(check); assert_eq!(max_passes, 5); } _ => panic!("expected maintain-coordination subcommand"), } } #[test] fn format_coordination_status_emits_json() { let status = session::manager::CoordinationStatus { backlog_leads: 2, backlog_messages: 5, absorbable_sessions: 1, saturated_sessions: 1, mode: session::manager::CoordinationMode::RebalanceFirstChronicSaturation, health: session::manager::CoordinationHealth::Saturated, operator_escalation_required: false, auto_dispatch_enabled: true, auto_dispatch_limit_per_session: 4, daemon_activity: session::store::DaemonActivity { last_dispatch_routed: 3, last_dispatch_deferred: 1, last_dispatch_leads: 2, ..Default::default() }, }; let rendered = format_coordination_status(&status, true).expect("json formatting should succeed"); let value: serde_json::Value = serde_json::from_str(&rendered).expect("valid json should be emitted"); assert_eq!(value["backlog_leads"], 2); assert_eq!(value["backlog_messages"], 5); assert_eq!(value["daemon_activity"]["last_dispatch_routed"], 3); } #[test] fn coordination_status_exit_codes_reflect_pressure() { let clear = session::manager::CoordinationStatus { backlog_leads: 0, backlog_messages: 0, absorbable_sessions: 0, saturated_sessions: 0, mode: session::manager::CoordinationMode::DispatchFirst, health: session::manager::CoordinationHealth::Healthy, operator_escalation_required: false, auto_dispatch_enabled: false, auto_dispatch_limit_per_session: 5, daemon_activity: Default::default(), }; assert_eq!(coordination_status_exit_code(&clear), 0); let absorbable = session::manager::CoordinationStatus { backlog_messages: 2, backlog_leads: 1, absorbable_sessions: 1, health: session::manager::CoordinationHealth::BacklogAbsorbable, ..clear.clone() }; assert_eq!(coordination_status_exit_code(&absorbable), 1); let saturated = session::manager::CoordinationStatus { saturated_sessions: 1, health: session::manager::CoordinationHealth::Saturated, ..absorbable }; assert_eq!(coordination_status_exit_code(&saturated), 2); } #[test] fn summarize_coordinate_backlog_reports_clear_state() { let summary = summarize_coordinate_backlog(&session::manager::CoordinateBacklogOutcome { dispatched: Vec::new(), rebalanced: Vec::new(), remaining_backlog_sessions: 0, remaining_backlog_messages: 0, remaining_absorbable_sessions: 0, remaining_saturated_sessions: 0, }); assert_eq!(summary.message, "Backlog already clear"); assert_eq!(summary.processed, 0); assert_eq!(summary.rerouted, 0); } #[test] fn summarize_coordinate_backlog_structures_counts() { let summary = summarize_coordinate_backlog(&session::manager::CoordinateBacklogOutcome { dispatched: vec![session::manager::LeadDispatchOutcome { lead_session_id: "lead".into(), unread_count: 2, routed: vec![ session::manager::InboxDrainOutcome { message_id: 1, task: "one".into(), session_id: "a".into(), action: session::manager::AssignmentAction::Spawned, }, session::manager::InboxDrainOutcome { message_id: 2, task: "two".into(), session_id: "lead".into(), action: session::manager::AssignmentAction::DeferredSaturated, }, ], }], rebalanced: vec![session::manager::LeadRebalanceOutcome { lead_session_id: "lead".into(), rerouted: vec![session::manager::RebalanceOutcome { from_session_id: "a".into(), message_id: 3, task: "three".into(), session_id: "b".into(), action: session::manager::AssignmentAction::ReusedIdle, }], }], remaining_backlog_sessions: 1, remaining_backlog_messages: 2, remaining_absorbable_sessions: 1, remaining_saturated_sessions: 0, }); assert_eq!(summary.processed, 2); assert_eq!(summary.routed, 1); assert_eq!(summary.deferred, 1); assert_eq!(summary.rerouted, 1); assert_eq!(summary.dispatched_leads, 1); assert_eq!(summary.rebalanced_leads, 1); assert_eq!(summary.remaining_backlog_messages, 2); } #[test] fn cli_parses_rebalance_team_command() { let cli = Cli::try_parse_from([ "ecc", "rebalance-team", "lead", "--agent", "claude", "--limit", "2", ]) .expect("rebalance-team should parse"); match cli.command { Some(Commands::RebalanceTeam { session_id, agent, limit, .. }) => { assert_eq!(session_id, "lead"); assert_eq!(agent.as_deref(), Some("claude")); assert_eq!(limit, 2); } _ => panic!("expected rebalance-team subcommand"), } } }