mirror of
https://github.com/affaan-m/everything-claude-code.git
synced 2026-06-14 20:21:23 +08:00
Compare commits
44 Commits
beaba1ca15
...
125d5e6199
| Author | SHA1 | Date | |
|---|---|---|---|
| 125d5e6199 | |||
| 4ff5a7169f | |||
| cee82417db | |||
| f4b1b11e10 | |||
| e7dd7047b5 | |||
| b6426ade32 | |||
| 790cb0205c | |||
| 046af44065 | |||
| d36e9c48a4 | |||
| 0f028f38f6 | |||
| feee17ad02 | |||
| 7b7ec434df | |||
| 176efb7623 | |||
| b51792fe0e | |||
| 050d9a9707 | |||
| 03e52f49e8 | |||
| 30913b2cc4 | |||
| 7809518612 | |||
| bbed46d3eb | |||
| 4a1f3cbd3f | |||
| bcd869d520 | |||
| 2e6eeafabd | |||
| 52371f5016 | |||
| d84c64fa0e | |||
| a4aaa30e93 | |||
| 97afd95451 | |||
| 29ff44e23e | |||
| 9c525009d7 | |||
| 9c294f7815 | |||
| 766bf31737 | |||
| 9523575721 | |||
| 406722b5ef | |||
| 5258a75382 | |||
| 966af37f89 | |||
| 22a5a8de6d | |||
| d3b680b6db | |||
| d49ceacb7d | |||
| 8cc92c59a6 | |||
| 77c9082deb | |||
| 727d9380cb | |||
| 7a13564a8b | |||
| 23348a21a6 | |||
| 0b68af123c | |||
| 4b1ff48219 |
@@ -183,6 +183,21 @@ It is mostly:
|
||||
- clarifying public docs
|
||||
- continuing the ECC 2.0 operator/control-plane buildout
|
||||
|
||||
ECC 2.0 now ships a bounded migration audit entrypoint:
|
||||
|
||||
- `ecc migrate audit --source ~/.hermes`
|
||||
- `ecc migrate plan --source ~/.hermes --output migration-plan.md`
|
||||
- `ecc migrate scaffold --source ~/.hermes --output-dir migration-artifacts`
|
||||
- `ecc migrate import-skills --source ~/.hermes --output-dir migration-artifacts/skills`
|
||||
- `ecc migrate import-tools --source ~/.hermes --output-dir migration-artifacts/tools`
|
||||
- `ecc migrate import-plugins --source ~/.hermes --output-dir migration-artifacts/plugins`
|
||||
- `ecc migrate import-schedules --source ~/.hermes --dry-run`
|
||||
- `ecc migrate import-remote --source ~/.hermes --dry-run`
|
||||
- `ecc migrate import-env --source ~/.hermes --dry-run`
|
||||
- `ecc migrate import-memory --source ~/.hermes`
|
||||
|
||||
Use that first to inventory the legacy workspace and map detected surfaces onto the current ECC2 scheduler, remote dispatch, memory graph, templates, and manual-translation lanes.
|
||||
|
||||
## What Still Belongs In Backlog
|
||||
|
||||
The remaining large migration themes are already tracked:
|
||||
|
||||
@@ -82,6 +82,8 @@ These stay local and should be configured per operator:
|
||||
|
||||
## Suggested Bring-Up Order
|
||||
|
||||
0. Run `ecc migrate audit --source ~/.hermes` first to inventory the legacy workspace and see which parts already map onto ECC2.
|
||||
0.5. Generate and review artifacts with `ecc migrate plan` / `ecc migrate scaffold`, scaffold reusable legacy skills with `ecc migrate import-skills --output-dir migration-artifacts/skills`, scaffold legacy tool translation templates with `ecc migrate import-tools --output-dir migration-artifacts/tools`, scaffold legacy bridge plugins with `ecc migrate import-plugins --output-dir migration-artifacts/plugins`, preview recurring jobs with `ecc migrate import-schedules --dry-run`, preview gateway dispatch with `ecc migrate import-remote --dry-run`, preview safe env/service context with `ecc migrate import-env --dry-run`, then import sanitized workspace memory with `ecc migrate import-memory`.
|
||||
1. Install ECC and verify the baseline harness setup.
|
||||
2. Install Hermes and point it at ECC-imported skills.
|
||||
3. Register the MCP servers you actually use every day.
|
||||
|
||||
Generated
+12
@@ -315,6 +315,17 @@ dependencies = [
|
||||
"cfg-if",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cron"
|
||||
version = "0.12.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6f8c3e73077b4b4a6ab1ea5047c37c57aee77657bc8ecd6f29b0af082d0b0c07"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"nom",
|
||||
"once_cell",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crossterm"
|
||||
version = "0.28.1"
|
||||
@@ -507,6 +518,7 @@ dependencies = [
|
||||
"anyhow",
|
||||
"chrono",
|
||||
"clap",
|
||||
"cron",
|
||||
"crossterm 0.28.1",
|
||||
"dirs",
|
||||
"git2",
|
||||
|
||||
@@ -43,6 +43,7 @@ libc = "0.2"
|
||||
|
||||
# Time
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
cron = "0.12"
|
||||
|
||||
# UUID for session IDs
|
||||
uuid = { version = "1", features = ["v4"] }
|
||||
|
||||
+72
-2
@@ -1,13 +1,41 @@
|
||||
use anyhow::Result;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::fmt;
|
||||
|
||||
use crate::session::store::StateStore;
|
||||
|
||||
#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum TaskPriority {
|
||||
Low,
|
||||
#[default]
|
||||
Normal,
|
||||
High,
|
||||
Critical,
|
||||
}
|
||||
|
||||
impl fmt::Display for TaskPriority {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
let label = match self {
|
||||
Self::Low => "low",
|
||||
Self::Normal => "normal",
|
||||
Self::High => "high",
|
||||
Self::Critical => "critical",
|
||||
};
|
||||
write!(f, "{label}")
|
||||
}
|
||||
}
|
||||
|
||||
/// Message types for inter-agent communication.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub enum MessageType {
|
||||
/// Task handoff from one agent to another
|
||||
TaskHandoff { task: String, context: String },
|
||||
TaskHandoff {
|
||||
task: String,
|
||||
context: String,
|
||||
#[serde(default)]
|
||||
priority: TaskPriority,
|
||||
},
|
||||
/// Agent requesting information from another
|
||||
Query { question: String },
|
||||
/// Response to a query
|
||||
@@ -46,7 +74,16 @@ pub fn parse(content: &str) -> Option<MessageType> {
|
||||
pub fn preview(msg_type: &str, content: &str) -> String {
|
||||
match parse(content) {
|
||||
Some(MessageType::TaskHandoff { task, .. }) => {
|
||||
format!("handoff {}", truncate(&task, 56))
|
||||
let priority = handoff_priority(content);
|
||||
if priority == TaskPriority::Normal {
|
||||
format!("handoff {}", truncate(&task, 56))
|
||||
} else {
|
||||
format!(
|
||||
"handoff [{}] {}",
|
||||
priority_label(priority),
|
||||
truncate(&task, 48)
|
||||
)
|
||||
}
|
||||
}
|
||||
Some(MessageType::Query { question }) => {
|
||||
format!("query {}", truncate(&question, 56))
|
||||
@@ -75,6 +112,39 @@ pub fn preview(msg_type: &str, content: &str) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn handoff_priority(content: &str) -> TaskPriority {
|
||||
match parse(content) {
|
||||
Some(MessageType::TaskHandoff { priority, .. }) => priority,
|
||||
_ => extract_legacy_handoff_priority(content),
|
||||
}
|
||||
}
|
||||
|
||||
fn extract_legacy_handoff_priority(content: &str) -> TaskPriority {
|
||||
let value: serde_json::Value = match serde_json::from_str(content) {
|
||||
Ok(value) => value,
|
||||
Err(_) => return TaskPriority::Normal,
|
||||
};
|
||||
match value
|
||||
.get("priority")
|
||||
.and_then(|priority| priority.as_str())
|
||||
.unwrap_or("normal")
|
||||
{
|
||||
"low" => TaskPriority::Low,
|
||||
"high" => TaskPriority::High,
|
||||
"critical" => TaskPriority::Critical,
|
||||
_ => TaskPriority::Normal,
|
||||
}
|
||||
}
|
||||
|
||||
fn priority_label(priority: TaskPriority) -> &'static str {
|
||||
match priority {
|
||||
TaskPriority::Low => "low",
|
||||
TaskPriority::Normal => "normal",
|
||||
TaskPriority::High => "high",
|
||||
TaskPriority::Critical => "critical",
|
||||
}
|
||||
}
|
||||
|
||||
fn truncate(value: &str, max_chars: usize) -> String {
|
||||
let trimmed = value.trim();
|
||||
if trimmed.chars().count() <= max_chars {
|
||||
|
||||
+427
-2
@@ -50,6 +50,16 @@ pub struct ConflictResolutionConfig {
|
||||
pub notify_lead: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct ComputerUseDispatchConfig {
|
||||
pub agent: Option<String>,
|
||||
pub profile: Option<String>,
|
||||
pub use_worktree: bool,
|
||||
pub project: Option<String>,
|
||||
pub task_group: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct AgentProfileConfig {
|
||||
@@ -79,6 +89,27 @@ pub struct ResolvedAgentProfile {
|
||||
pub append_system_prompt: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct HarnessRunnerConfig {
|
||||
pub program: String,
|
||||
pub base_args: Vec<String>,
|
||||
pub project_markers: Vec<PathBuf>,
|
||||
pub cwd_flag: Option<String>,
|
||||
pub session_name_flag: Option<String>,
|
||||
pub task_flag: Option<String>,
|
||||
pub model_flag: Option<String>,
|
||||
pub add_dir_flag: Option<String>,
|
||||
pub include_directories_flag: Option<String>,
|
||||
pub allowed_tools_flag: Option<String>,
|
||||
pub disallowed_tools_flag: Option<String>,
|
||||
pub permission_mode_flag: Option<String>,
|
||||
pub max_budget_usd_flag: Option<String>,
|
||||
pub append_system_prompt_flag: Option<String>,
|
||||
pub inline_system_prompt_for_task: bool,
|
||||
pub env: BTreeMap<String, String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct OrchestrationTemplateConfig {
|
||||
@@ -103,6 +134,67 @@ pub struct OrchestrationTemplateStepConfig {
|
||||
pub task_group: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(tag = "kind", rename_all = "snake_case")]
|
||||
pub enum MemoryConnectorConfig {
|
||||
JsonlFile(MemoryConnectorJsonlFileConfig),
|
||||
JsonlDirectory(MemoryConnectorJsonlDirectoryConfig),
|
||||
MarkdownFile(MemoryConnectorMarkdownFileConfig),
|
||||
MarkdownDirectory(MemoryConnectorMarkdownDirectoryConfig),
|
||||
DotenvFile(MemoryConnectorDotenvFileConfig),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct MemoryConnectorJsonlFileConfig {
|
||||
pub path: PathBuf,
|
||||
pub session_id: Option<String>,
|
||||
pub default_entity_type: Option<String>,
|
||||
pub default_observation_type: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct MemoryConnectorJsonlDirectoryConfig {
|
||||
pub path: PathBuf,
|
||||
pub recurse: bool,
|
||||
pub session_id: Option<String>,
|
||||
pub default_entity_type: Option<String>,
|
||||
pub default_observation_type: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct MemoryConnectorMarkdownFileConfig {
|
||||
pub path: PathBuf,
|
||||
pub session_id: Option<String>,
|
||||
pub default_entity_type: Option<String>,
|
||||
pub default_observation_type: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct MemoryConnectorMarkdownDirectoryConfig {
|
||||
pub path: PathBuf,
|
||||
pub recurse: bool,
|
||||
pub session_id: Option<String>,
|
||||
pub default_entity_type: Option<String>,
|
||||
pub default_observation_type: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct MemoryConnectorDotenvFileConfig {
|
||||
pub path: PathBuf,
|
||||
pub session_id: Option<String>,
|
||||
pub default_entity_type: Option<String>,
|
||||
pub default_observation_type: Option<String>,
|
||||
pub key_prefixes: Vec<String>,
|
||||
pub include_keys: Vec<String>,
|
||||
pub exclude_keys: Vec<String>,
|
||||
pub include_safe_values: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ResolvedOrchestrationTemplate {
|
||||
pub template_name: String,
|
||||
@@ -137,8 +229,11 @@ pub struct Config {
|
||||
pub auto_terminate_stale_sessions: bool,
|
||||
pub default_agent: String,
|
||||
pub default_agent_profile: Option<String>,
|
||||
pub harness_runners: BTreeMap<String, HarnessRunnerConfig>,
|
||||
pub agent_profiles: BTreeMap<String, AgentProfileConfig>,
|
||||
pub orchestration_templates: BTreeMap<String, OrchestrationTemplateConfig>,
|
||||
pub memory_connectors: BTreeMap<String, MemoryConnectorConfig>,
|
||||
pub computer_use_dispatch: ComputerUseDispatchConfig,
|
||||
pub auto_dispatch_unread_handoffs: bool,
|
||||
pub auto_dispatch_limit_per_session: usize,
|
||||
pub auto_create_worktrees: bool,
|
||||
@@ -201,8 +296,11 @@ impl Default for Config {
|
||||
auto_terminate_stale_sessions: false,
|
||||
default_agent: "claude".to_string(),
|
||||
default_agent_profile: None,
|
||||
harness_runners: BTreeMap::new(),
|
||||
agent_profiles: BTreeMap::new(),
|
||||
orchestration_templates: BTreeMap::new(),
|
||||
memory_connectors: BTreeMap::new(),
|
||||
computer_use_dispatch: ComputerUseDispatchConfig::default(),
|
||||
auto_dispatch_unread_handoffs: false,
|
||||
auto_dispatch_limit_per_session: 5,
|
||||
auto_create_worktrees: true,
|
||||
@@ -261,11 +359,36 @@ impl Config {
|
||||
self.budget_alert_thresholds.sanitized()
|
||||
}
|
||||
|
||||
pub fn computer_use_dispatch_defaults(&self) -> ResolvedComputerUseDispatchConfig {
|
||||
let agent = self
|
||||
.computer_use_dispatch
|
||||
.agent
|
||||
.clone()
|
||||
.unwrap_or_else(|| self.default_agent.clone());
|
||||
let profile = self
|
||||
.computer_use_dispatch
|
||||
.profile
|
||||
.clone()
|
||||
.or_else(|| self.default_agent_profile.clone());
|
||||
ResolvedComputerUseDispatchConfig {
|
||||
agent,
|
||||
profile,
|
||||
use_worktree: self.computer_use_dispatch.use_worktree,
|
||||
project: self.computer_use_dispatch.project.clone(),
|
||||
task_group: self.computer_use_dispatch.task_group.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn resolve_agent_profile(&self, name: &str) -> Result<ResolvedAgentProfile> {
|
||||
let mut chain = Vec::new();
|
||||
self.resolve_agent_profile_inner(name, &mut chain)
|
||||
}
|
||||
|
||||
pub fn harness_runner(&self, harness: &str) -> Option<&HarnessRunnerConfig> {
|
||||
let key = harness.trim().to_ascii_lowercase();
|
||||
self.harness_runners.get(&key)
|
||||
}
|
||||
|
||||
pub fn resolve_orchestration_template(
|
||||
&self,
|
||||
name: &str,
|
||||
@@ -657,6 +780,50 @@ impl ResolvedAgentProfile {
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for HarnessRunnerConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
program: String::new(),
|
||||
base_args: Vec::new(),
|
||||
project_markers: Vec::new(),
|
||||
cwd_flag: None,
|
||||
session_name_flag: None,
|
||||
task_flag: None,
|
||||
model_flag: None,
|
||||
add_dir_flag: None,
|
||||
include_directories_flag: None,
|
||||
allowed_tools_flag: None,
|
||||
disallowed_tools_flag: None,
|
||||
permission_mode_flag: None,
|
||||
max_budget_usd_flag: None,
|
||||
append_system_prompt_flag: None,
|
||||
inline_system_prompt_for_task: true,
|
||||
env: BTreeMap::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for ComputerUseDispatchConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
agent: None,
|
||||
profile: None,
|
||||
use_worktree: false,
|
||||
project: None,
|
||||
task_group: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub struct ResolvedComputerUseDispatchConfig {
|
||||
pub agent: String,
|
||||
pub profile: Option<String>,
|
||||
pub use_worktree: bool,
|
||||
pub project: Option<String>,
|
||||
pub task_group: Option<String>,
|
||||
}
|
||||
|
||||
fn merge_unique<T>(base: &mut Vec<T>, additions: &[T])
|
||||
where
|
||||
T: Clone + PartialEq,
|
||||
@@ -737,8 +904,8 @@ impl BudgetAlertThresholds {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
BudgetAlertThresholds, Config, ConflictResolutionConfig, ConflictResolutionStrategy,
|
||||
PaneLayout,
|
||||
BudgetAlertThresholds, ComputerUseDispatchConfig, Config, ConflictResolutionConfig,
|
||||
ConflictResolutionStrategy, PaneLayout, ResolvedComputerUseDispatchConfig,
|
||||
};
|
||||
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
|
||||
use std::collections::BTreeMap;
|
||||
@@ -1088,6 +1255,42 @@ notify_lead = false
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn computer_use_dispatch_deserializes_from_toml() {
|
||||
let config: Config = toml::from_str(
|
||||
r#"
|
||||
[computer_use_dispatch]
|
||||
agent = "codex"
|
||||
profile = "browser"
|
||||
use_worktree = true
|
||||
project = "ops"
|
||||
task_group = "remote browser"
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
config.computer_use_dispatch,
|
||||
ComputerUseDispatchConfig {
|
||||
agent: Some("codex".to_string()),
|
||||
profile: Some("browser".to_string()),
|
||||
use_worktree: true,
|
||||
project: Some("ops".to_string()),
|
||||
task_group: Some("remote browser".to_string()),
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
config.computer_use_dispatch_defaults(),
|
||||
ResolvedComputerUseDispatchConfig {
|
||||
agent: "codex".to_string(),
|
||||
profile: Some("browser".to_string()),
|
||||
use_worktree: true,
|
||||
project: Some("ops".to_string()),
|
||||
task_group: Some("remote browser".to_string()),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn agent_profiles_resolve_inheritance_and_defaults() {
|
||||
let config: Config = toml::from_str(
|
||||
@@ -1147,6 +1350,49 @@ inherits = "a"
|
||||
.contains("agent profile inheritance cycle"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn harness_runners_deserialize_from_toml() {
|
||||
let config: Config = toml::from_str(
|
||||
r#"
|
||||
[harness_runners.cursor]
|
||||
program = "cursor-agent"
|
||||
base_args = ["run"]
|
||||
project_markers = [".cursor", ".cursor/rules"]
|
||||
cwd_flag = "--cwd"
|
||||
session_name_flag = "--name"
|
||||
task_flag = "--task"
|
||||
model_flag = "--model"
|
||||
permission_mode_flag = "--permission-mode"
|
||||
inline_system_prompt_for_task = true
|
||||
|
||||
[harness_runners.cursor.env]
|
||||
ECC_HARNESS = "cursor"
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let runner = config.harness_runner("cursor").expect("cursor runner");
|
||||
assert_eq!(runner.program, "cursor-agent");
|
||||
assert_eq!(runner.base_args, vec!["run"]);
|
||||
assert_eq!(
|
||||
runner.project_markers,
|
||||
vec![PathBuf::from(".cursor"), PathBuf::from(".cursor/rules")]
|
||||
);
|
||||
assert_eq!(runner.cwd_flag.as_deref(), Some("--cwd"));
|
||||
assert_eq!(runner.session_name_flag.as_deref(), Some("--name"));
|
||||
assert_eq!(runner.task_flag.as_deref(), Some("--task"));
|
||||
assert_eq!(runner.model_flag.as_deref(), Some("--model"));
|
||||
assert_eq!(
|
||||
runner.permission_mode_flag.as_deref(),
|
||||
Some("--permission-mode")
|
||||
);
|
||||
assert!(runner.inline_system_prompt_for_task);
|
||||
assert_eq!(
|
||||
runner.env.get("ECC_HARNESS").map(String::as_str),
|
||||
Some("cursor")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn orchestration_templates_resolve_steps_and_interpolate_variables() {
|
||||
let config: Config = toml::from_str(
|
||||
@@ -1231,6 +1477,185 @@ task = "Plan {{task}} for {{component}}"
|
||||
assert!(error_text.contains("missing orchestration template variable(s): component"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn memory_connectors_deserialize_from_toml() {
|
||||
let config: Config = toml::from_str(
|
||||
r#"
|
||||
[memory_connectors.hermes_notes]
|
||||
kind = "jsonl_file"
|
||||
path = "/tmp/hermes-memory.jsonl"
|
||||
session_id = "latest"
|
||||
default_entity_type = "incident"
|
||||
default_observation_type = "external_note"
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let connector = config
|
||||
.memory_connectors
|
||||
.get("hermes_notes")
|
||||
.expect("connector should deserialize");
|
||||
match connector {
|
||||
crate::config::MemoryConnectorConfig::JsonlFile(settings) => {
|
||||
assert_eq!(settings.path, PathBuf::from("/tmp/hermes-memory.jsonl"));
|
||||
assert_eq!(settings.session_id.as_deref(), Some("latest"));
|
||||
assert_eq!(settings.default_entity_type.as_deref(), Some("incident"));
|
||||
assert_eq!(
|
||||
settings.default_observation_type.as_deref(),
|
||||
Some("external_note")
|
||||
);
|
||||
}
|
||||
_ => panic!("expected jsonl_file connector"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn memory_jsonl_directory_connectors_deserialize_from_toml() {
|
||||
let config: Config = toml::from_str(
|
||||
r#"
|
||||
[memory_connectors.hermes_dir]
|
||||
kind = "jsonl_directory"
|
||||
path = "/tmp/hermes-memory"
|
||||
recurse = true
|
||||
default_entity_type = "incident"
|
||||
default_observation_type = "external_note"
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let connector = config
|
||||
.memory_connectors
|
||||
.get("hermes_dir")
|
||||
.expect("connector should deserialize");
|
||||
match connector {
|
||||
crate::config::MemoryConnectorConfig::JsonlDirectory(settings) => {
|
||||
assert_eq!(settings.path, PathBuf::from("/tmp/hermes-memory"));
|
||||
assert!(settings.recurse);
|
||||
assert_eq!(settings.default_entity_type.as_deref(), Some("incident"));
|
||||
assert_eq!(
|
||||
settings.default_observation_type.as_deref(),
|
||||
Some("external_note")
|
||||
);
|
||||
}
|
||||
_ => panic!("expected jsonl_directory connector"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn memory_markdown_file_connectors_deserialize_from_toml() {
|
||||
let config: Config = toml::from_str(
|
||||
r#"
|
||||
[memory_connectors.workspace_note]
|
||||
kind = "markdown_file"
|
||||
path = "/tmp/hermes-memory.md"
|
||||
session_id = "latest"
|
||||
default_entity_type = "note_section"
|
||||
default_observation_type = "external_note"
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let connector = config
|
||||
.memory_connectors
|
||||
.get("workspace_note")
|
||||
.expect("connector should deserialize");
|
||||
match connector {
|
||||
crate::config::MemoryConnectorConfig::MarkdownFile(settings) => {
|
||||
assert_eq!(settings.path, PathBuf::from("/tmp/hermes-memory.md"));
|
||||
assert_eq!(settings.session_id.as_deref(), Some("latest"));
|
||||
assert_eq!(
|
||||
settings.default_entity_type.as_deref(),
|
||||
Some("note_section")
|
||||
);
|
||||
assert_eq!(
|
||||
settings.default_observation_type.as_deref(),
|
||||
Some("external_note")
|
||||
);
|
||||
}
|
||||
_ => panic!("expected markdown_file connector"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn memory_markdown_directory_connectors_deserialize_from_toml() {
|
||||
let config: Config = toml::from_str(
|
||||
r#"
|
||||
[memory_connectors.workspace_notes]
|
||||
kind = "markdown_directory"
|
||||
path = "/tmp/hermes-memory"
|
||||
recurse = true
|
||||
session_id = "latest"
|
||||
default_entity_type = "note_section"
|
||||
default_observation_type = "external_note"
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let connector = config
|
||||
.memory_connectors
|
||||
.get("workspace_notes")
|
||||
.expect("connector should deserialize");
|
||||
match connector {
|
||||
crate::config::MemoryConnectorConfig::MarkdownDirectory(settings) => {
|
||||
assert_eq!(settings.path, PathBuf::from("/tmp/hermes-memory"));
|
||||
assert!(settings.recurse);
|
||||
assert_eq!(settings.session_id.as_deref(), Some("latest"));
|
||||
assert_eq!(
|
||||
settings.default_entity_type.as_deref(),
|
||||
Some("note_section")
|
||||
);
|
||||
assert_eq!(
|
||||
settings.default_observation_type.as_deref(),
|
||||
Some("external_note")
|
||||
);
|
||||
}
|
||||
_ => panic!("expected markdown_directory connector"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn memory_dotenv_file_connectors_deserialize_from_toml() {
|
||||
let config: Config = toml::from_str(
|
||||
r#"
|
||||
[memory_connectors.hermes_env]
|
||||
kind = "dotenv_file"
|
||||
path = "/tmp/hermes.env"
|
||||
session_id = "latest"
|
||||
default_entity_type = "service_config"
|
||||
default_observation_type = "external_config"
|
||||
key_prefixes = ["STRIPE_", "PUBLIC_"]
|
||||
include_keys = ["PUBLIC_BASE_URL"]
|
||||
exclude_keys = ["STRIPE_WEBHOOK_SECRET"]
|
||||
include_safe_values = true
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let connector = config
|
||||
.memory_connectors
|
||||
.get("hermes_env")
|
||||
.expect("connector should deserialize");
|
||||
match connector {
|
||||
crate::config::MemoryConnectorConfig::DotenvFile(settings) => {
|
||||
assert_eq!(settings.path, PathBuf::from("/tmp/hermes.env"));
|
||||
assert_eq!(settings.session_id.as_deref(), Some("latest"));
|
||||
assert_eq!(
|
||||
settings.default_entity_type.as_deref(),
|
||||
Some("service_config")
|
||||
);
|
||||
assert_eq!(
|
||||
settings.default_observation_type.as_deref(),
|
||||
Some("external_config")
|
||||
);
|
||||
assert_eq!(settings.key_prefixes, vec!["STRIPE_", "PUBLIC_"]);
|
||||
assert_eq!(settings.include_keys, vec!["PUBLIC_BASE_URL"]);
|
||||
assert_eq!(settings.exclude_keys, vec!["STRIPE_WEBHOOK_SECRET"]);
|
||||
assert!(settings.include_safe_values);
|
||||
}
|
||||
_ => panic!("expected dotenv_file connector"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn completion_summary_notifications_deserialize_from_toml() {
|
||||
let config: Config = toml::from_str(
|
||||
|
||||
+8168
-57
File diff suppressed because it is too large
Load Diff
@@ -27,6 +27,14 @@ pub async fn run(db: StateStore, cfg: Config) -> Result<()> {
|
||||
tracing::error!("Session check failed: {e}");
|
||||
}
|
||||
|
||||
if let Err(e) = maybe_run_due_schedules(&db, &cfg).await {
|
||||
tracing::error!("Scheduled task dispatch pass failed: {e}");
|
||||
}
|
||||
|
||||
if let Err(e) = maybe_run_remote_dispatch(&db, &cfg).await {
|
||||
tracing::error!("Remote dispatch pass failed: {e}");
|
||||
}
|
||||
|
||||
if let Err(e) = coordinate_backlog_cycle(&db, &cfg).await {
|
||||
tracing::error!("Backlog coordination pass failed: {e}");
|
||||
}
|
||||
@@ -89,6 +97,33 @@ fn check_sessions(db: &StateStore, cfg: &Config) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn maybe_run_due_schedules(db: &StateStore, cfg: &Config) -> Result<usize> {
|
||||
let outcomes = manager::run_due_schedules(db, cfg, cfg.max_parallel_sessions).await?;
|
||||
if !outcomes.is_empty() {
|
||||
tracing::info!("Dispatched {} scheduled task(s)", outcomes.len());
|
||||
}
|
||||
Ok(outcomes.len())
|
||||
}
|
||||
|
||||
async fn maybe_run_remote_dispatch(db: &StateStore, cfg: &Config) -> Result<usize> {
|
||||
let outcomes =
|
||||
manager::run_remote_dispatch_requests(db, cfg, cfg.max_parallel_sessions).await?;
|
||||
let routed = outcomes
|
||||
.iter()
|
||||
.filter(|outcome| {
|
||||
matches!(
|
||||
outcome.action,
|
||||
manager::RemoteDispatchAction::SpawnedTopLevel
|
||||
| manager::RemoteDispatchAction::Assigned(_)
|
||||
)
|
||||
})
|
||||
.count();
|
||||
if routed > 0 {
|
||||
tracing::info!("Dispatched {} remote request(s)", routed);
|
||||
}
|
||||
Ok(routed)
|
||||
}
|
||||
|
||||
async fn maybe_auto_dispatch(db: &StateStore, cfg: &Config) -> Result<usize> {
|
||||
let summary = maybe_auto_dispatch_with_recorder(
|
||||
cfg,
|
||||
|
||||
+2321
-75
File diff suppressed because it is too large
Load Diff
@@ -13,6 +13,294 @@ use std::path::PathBuf;
|
||||
|
||||
pub type SessionAgentProfile = crate::config::ResolvedAgentProfile;
|
||||
|
||||
#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq, Hash)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum HarnessKind {
|
||||
#[default]
|
||||
Unknown,
|
||||
Claude,
|
||||
Codex,
|
||||
OpenCode,
|
||||
Gemini,
|
||||
Cursor,
|
||||
Kiro,
|
||||
Trae,
|
||||
Zed,
|
||||
FactoryDroid,
|
||||
Windsurf,
|
||||
}
|
||||
|
||||
impl HarnessKind {
|
||||
pub fn from_agent_type(agent_type: &str) -> Self {
|
||||
match agent_type.trim().to_ascii_lowercase().as_str() {
|
||||
"claude" | "claude-code" => Self::Claude,
|
||||
"codex" => Self::Codex,
|
||||
"opencode" => Self::OpenCode,
|
||||
"gemini" | "gemini-cli" => Self::Gemini,
|
||||
"cursor" => Self::Cursor,
|
||||
"kiro" => Self::Kiro,
|
||||
"trae" => Self::Trae,
|
||||
"zed" => Self::Zed,
|
||||
"factory-droid" | "factory_droid" | "factorydroid" => Self::FactoryDroid,
|
||||
"windsurf" => Self::Windsurf,
|
||||
_ => Self::Unknown,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_db_value(value: &str) -> Self {
|
||||
match value.trim().to_ascii_lowercase().as_str() {
|
||||
"claude" => Self::Claude,
|
||||
"codex" => Self::Codex,
|
||||
"opencode" => Self::OpenCode,
|
||||
"gemini" => Self::Gemini,
|
||||
"cursor" => Self::Cursor,
|
||||
"kiro" => Self::Kiro,
|
||||
"trae" => Self::Trae,
|
||||
"zed" => Self::Zed,
|
||||
"factory_droid" => Self::FactoryDroid,
|
||||
"windsurf" => Self::Windsurf,
|
||||
_ => Self::Unknown,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Unknown => "unknown",
|
||||
Self::Claude => "claude",
|
||||
Self::Codex => "codex",
|
||||
Self::OpenCode => "opencode",
|
||||
Self::Gemini => "gemini",
|
||||
Self::Cursor => "cursor",
|
||||
Self::Kiro => "kiro",
|
||||
Self::Trae => "trae",
|
||||
Self::Zed => "zed",
|
||||
Self::FactoryDroid => "factory_droid",
|
||||
Self::Windsurf => "windsurf",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn canonical_agent_type(agent_type: &str) -> String {
|
||||
match Self::from_agent_type(agent_type) {
|
||||
Self::Unknown => agent_type.trim().to_ascii_lowercase(),
|
||||
harness => harness.as_str().to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn supports_direct_execution(self) -> bool {
|
||||
matches!(
|
||||
self,
|
||||
Self::Claude | Self::Codex | Self::OpenCode | Self::Gemini
|
||||
)
|
||||
}
|
||||
|
||||
fn project_markers(self) -> &'static [&'static str] {
|
||||
match self {
|
||||
Self::Claude => &[".claude"],
|
||||
Self::Codex => &[".codex", ".codex-plugin"],
|
||||
Self::OpenCode => &[".opencode"],
|
||||
Self::Gemini => &[".gemini"],
|
||||
Self::Cursor => &[".cursor"],
|
||||
Self::Kiro => &[".kiro"],
|
||||
Self::Trae => &[".trae"],
|
||||
Self::Zed => &[".zed"],
|
||||
Self::FactoryDroid => &[".factory-droid", ".factory_droid"],
|
||||
Self::Windsurf => &[".windsurf"],
|
||||
Self::Unknown => &[],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for HarnessKind {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(f, "{}", self.as_str())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct SessionHarnessInfo {
|
||||
pub primary: HarnessKind,
|
||||
pub primary_label: String,
|
||||
pub detected: Vec<HarnessKind>,
|
||||
pub detected_labels: Vec<String>,
|
||||
}
|
||||
|
||||
impl SessionHarnessInfo {
|
||||
fn detected_labels_for(detected: &[HarnessKind]) -> Vec<String> {
|
||||
detected.iter().map(|harness| harness.to_string()).collect()
|
||||
}
|
||||
|
||||
fn configured_detected_labels(cfg: &crate::config::Config, working_dir: &Path) -> Vec<String> {
|
||||
let mut labels = Vec::new();
|
||||
for (name, runner) in &cfg.harness_runners {
|
||||
if runner.project_markers.is_empty() {
|
||||
continue;
|
||||
}
|
||||
if runner
|
||||
.project_markers
|
||||
.iter()
|
||||
.any(|marker| working_dir.join(marker).exists())
|
||||
{
|
||||
let label = Self::runner_key(name);
|
||||
if !label.is_empty() && !labels.contains(&label) {
|
||||
labels.push(label);
|
||||
}
|
||||
}
|
||||
}
|
||||
labels
|
||||
}
|
||||
|
||||
pub fn runner_key(agent_type: &str) -> String {
|
||||
let canonical = HarnessKind::canonical_agent_type(agent_type);
|
||||
match HarnessKind::from_agent_type(&canonical) {
|
||||
HarnessKind::Unknown if canonical.is_empty() => {
|
||||
HarnessKind::Unknown.as_str().to_string()
|
||||
}
|
||||
HarnessKind::Unknown => canonical,
|
||||
harness => harness.as_str().to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn primary_label_for(agent_type: &str, primary: HarnessKind) -> String {
|
||||
match primary {
|
||||
HarnessKind::Unknown => {
|
||||
let label = Self::runner_key(agent_type);
|
||||
if label.is_empty() {
|
||||
HarnessKind::Unknown.as_str().to_string()
|
||||
} else {
|
||||
label
|
||||
}
|
||||
}
|
||||
harness => harness.as_str().to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn detect(agent_type: &str, working_dir: &Path) -> Self {
|
||||
let runner_key = Self::runner_key(agent_type);
|
||||
let detected = [
|
||||
HarnessKind::Claude,
|
||||
HarnessKind::Codex,
|
||||
HarnessKind::OpenCode,
|
||||
HarnessKind::Gemini,
|
||||
HarnessKind::Cursor,
|
||||
HarnessKind::Kiro,
|
||||
HarnessKind::Trae,
|
||||
HarnessKind::Zed,
|
||||
HarnessKind::FactoryDroid,
|
||||
HarnessKind::Windsurf,
|
||||
]
|
||||
.into_iter()
|
||||
.filter(|harness| {
|
||||
harness
|
||||
.project_markers()
|
||||
.iter()
|
||||
.any(|marker| working_dir.join(marker).exists())
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let primary = match HarnessKind::from_agent_type(&runner_key) {
|
||||
HarnessKind::Unknown if runner_key == HarnessKind::Unknown.as_str() => {
|
||||
detected.first().copied().unwrap_or(HarnessKind::Unknown)
|
||||
}
|
||||
HarnessKind::Unknown => HarnessKind::Unknown,
|
||||
harness => harness,
|
||||
};
|
||||
|
||||
let detected_labels = Self::detected_labels_for(&detected);
|
||||
Self {
|
||||
primary,
|
||||
primary_label: Self::primary_label_for(agent_type, primary),
|
||||
detected,
|
||||
detected_labels,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_persisted(
|
||||
harness_label: &str,
|
||||
agent_type: &str,
|
||||
working_dir: &Path,
|
||||
detected: Vec<HarnessKind>,
|
||||
) -> Self {
|
||||
let primary = HarnessKind::from_db_value(harness_label);
|
||||
if primary == HarnessKind::Unknown && detected.is_empty() && harness_label.trim().is_empty()
|
||||
{
|
||||
return Self::detect(agent_type, working_dir);
|
||||
}
|
||||
|
||||
let normalized_label = harness_label.trim().to_ascii_lowercase();
|
||||
let detected_labels = Self::detected_labels_for(&detected);
|
||||
Self {
|
||||
primary,
|
||||
primary_label: if normalized_label.is_empty() {
|
||||
Self::primary_label_for(agent_type, primary)
|
||||
} else {
|
||||
normalized_label
|
||||
},
|
||||
detected,
|
||||
detected_labels,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_config_detection(
|
||||
mut self,
|
||||
cfg: &crate::config::Config,
|
||||
working_dir: &Path,
|
||||
) -> Self {
|
||||
for label in Self::configured_detected_labels(cfg, working_dir) {
|
||||
if !self.detected_labels.contains(&label) {
|
||||
self.detected_labels.push(label);
|
||||
}
|
||||
}
|
||||
|
||||
if self.primary == HarnessKind::Unknown
|
||||
&& self.primary_label == HarnessKind::Unknown.as_str()
|
||||
&& !self.detected_labels.is_empty()
|
||||
{
|
||||
self.primary_label = self.detected_labels[0].clone();
|
||||
}
|
||||
|
||||
self
|
||||
}
|
||||
|
||||
pub fn resolve_requested_agent_type(
|
||||
cfg: &crate::config::Config,
|
||||
requested_agent_type: &str,
|
||||
working_dir: &Path,
|
||||
) -> String {
|
||||
let canonical = HarnessKind::canonical_agent_type(requested_agent_type);
|
||||
if !canonical.is_empty() && canonical != "auto" {
|
||||
return canonical;
|
||||
}
|
||||
|
||||
let detected = Self::detect("", working_dir).with_config_detection(cfg, working_dir);
|
||||
if detected.primary_label != HarnessKind::Unknown.as_str()
|
||||
&& Self::can_launch_detected_label(cfg, &detected.primary_label)
|
||||
{
|
||||
return Self::runner_key(&detected.primary_label);
|
||||
}
|
||||
|
||||
for label in &detected.detected_labels {
|
||||
if Self::can_launch_detected_label(cfg, label) {
|
||||
return Self::runner_key(label);
|
||||
}
|
||||
}
|
||||
|
||||
HarnessKind::Claude.as_str().to_string()
|
||||
}
|
||||
|
||||
fn can_launch_detected_label(cfg: &crate::config::Config, label: &str) -> bool {
|
||||
cfg.harness_runner(label).is_some()
|
||||
|| HarnessKind::from_agent_type(label).supports_direct_execution()
|
||||
}
|
||||
|
||||
pub fn detected_summary(&self) -> String {
|
||||
if self.detected_labels.is_empty() {
|
||||
"none detected".to_string()
|
||||
} else {
|
||||
self.detected_labels.join(", ")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Session {
|
||||
pub id: String,
|
||||
@@ -134,6 +422,101 @@ pub struct SessionMessage {
|
||||
pub timestamp: DateTime<Utc>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct ScheduledTask {
|
||||
pub id: i64,
|
||||
pub cron_expr: String,
|
||||
pub task: String,
|
||||
pub agent_type: String,
|
||||
pub profile_name: Option<String>,
|
||||
pub working_dir: PathBuf,
|
||||
pub project: String,
|
||||
pub task_group: String,
|
||||
pub use_worktree: bool,
|
||||
pub last_run_at: Option<DateTime<Utc>>,
|
||||
pub next_run_at: DateTime<Utc>,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct RemoteDispatchRequest {
|
||||
pub id: i64,
|
||||
pub request_kind: RemoteDispatchKind,
|
||||
pub target_session_id: Option<String>,
|
||||
pub task: String,
|
||||
pub target_url: Option<String>,
|
||||
pub priority: crate::comms::TaskPriority,
|
||||
pub agent_type: String,
|
||||
pub profile_name: Option<String>,
|
||||
pub working_dir: PathBuf,
|
||||
pub project: String,
|
||||
pub task_group: String,
|
||||
pub use_worktree: bool,
|
||||
pub source: String,
|
||||
pub requester: Option<String>,
|
||||
pub status: RemoteDispatchStatus,
|
||||
pub result_session_id: Option<String>,
|
||||
pub result_action: Option<String>,
|
||||
pub error: Option<String>,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
pub dispatched_at: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum RemoteDispatchKind {
|
||||
Standard,
|
||||
ComputerUse,
|
||||
}
|
||||
|
||||
impl fmt::Display for RemoteDispatchKind {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Self::Standard => write!(f, "standard"),
|
||||
Self::ComputerUse => write!(f, "computer_use"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl RemoteDispatchKind {
|
||||
pub fn from_db_value(value: &str) -> Self {
|
||||
match value {
|
||||
"computer_use" => Self::ComputerUse,
|
||||
_ => Self::Standard,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum RemoteDispatchStatus {
|
||||
Pending,
|
||||
Dispatched,
|
||||
Failed,
|
||||
}
|
||||
|
||||
impl fmt::Display for RemoteDispatchStatus {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Self::Pending => write!(f, "pending"),
|
||||
Self::Dispatched => write!(f, "dispatched"),
|
||||
Self::Failed => write!(f, "failed"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl RemoteDispatchStatus {
|
||||
pub fn from_db_value(value: &str) -> Self {
|
||||
match value {
|
||||
"dispatched" => Self::Dispatched,
|
||||
"failed" => Self::Failed,
|
||||
_ => Self::Pending,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct FileActivityEntry {
|
||||
pub session_id: String,
|
||||
@@ -190,6 +573,78 @@ pub struct ContextGraphEntityDetail {
|
||||
pub incoming: Vec<ContextGraphRelation>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct ContextGraphObservation {
|
||||
pub id: i64,
|
||||
pub session_id: Option<String>,
|
||||
pub entity_id: i64,
|
||||
pub entity_type: String,
|
||||
pub entity_name: String,
|
||||
pub observation_type: String,
|
||||
pub priority: ContextObservationPriority,
|
||||
pub pinned: bool,
|
||||
pub summary: String,
|
||||
pub details: BTreeMap<String, String>,
|
||||
pub created_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct ContextGraphRecallEntry {
|
||||
pub entity: ContextGraphEntity,
|
||||
pub score: u64,
|
||||
pub matched_terms: Vec<String>,
|
||||
pub relation_count: usize,
|
||||
pub observation_count: usize,
|
||||
pub max_observation_priority: ContextObservationPriority,
|
||||
pub has_pinned_observation: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ContextObservationPriority {
|
||||
Low,
|
||||
Normal,
|
||||
High,
|
||||
Critical,
|
||||
}
|
||||
|
||||
impl Default for ContextObservationPriority {
|
||||
fn default() -> Self {
|
||||
Self::Normal
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for ContextObservationPriority {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Self::Low => write!(f, "low"),
|
||||
Self::Normal => write!(f, "normal"),
|
||||
Self::High => write!(f, "high"),
|
||||
Self::Critical => write!(f, "critical"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ContextObservationPriority {
|
||||
pub fn from_db_value(value: i64) -> Self {
|
||||
match value {
|
||||
0 => Self::Low,
|
||||
2 => Self::High,
|
||||
3 => Self::Critical,
|
||||
_ => Self::Normal,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn as_db_value(self) -> i64 {
|
||||
match self {
|
||||
Self::Low => 0,
|
||||
Self::Normal => 1,
|
||||
Self::High => 2,
|
||||
Self::Critical => 3,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct ContextGraphSyncStats {
|
||||
pub sessions_scanned: usize,
|
||||
@@ -198,6 +653,14 @@ pub struct ContextGraphSyncStats {
|
||||
pub messages_processed: usize,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct ContextGraphCompactionStats {
|
||||
pub entities_scanned: usize,
|
||||
pub duplicate_observations_deleted: usize,
|
||||
pub overflow_observations_deleted: usize,
|
||||
pub observations_retained: usize,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum FileActivityAction {
|
||||
@@ -235,3 +698,266 @@ pub struct SessionGrouping {
|
||||
pub project: Option<String>,
|
||||
pub task_group: Option<String>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::fs;
|
||||
|
||||
struct TestDir {
|
||||
path: PathBuf,
|
||||
}
|
||||
|
||||
impl TestDir {
|
||||
fn new(label: &str) -> Result<Self, Box<dyn std::error::Error>> {
|
||||
let path =
|
||||
std::env::temp_dir().join(format!("ecc2-{}-{}", 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);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detect_session_harness_prefers_agent_type_and_collects_project_markers(
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let repo = TestDir::new("session-harness-detect")?;
|
||||
fs::create_dir_all(repo.path().join(".codex"))?;
|
||||
fs::create_dir_all(repo.path().join(".claude"))?;
|
||||
|
||||
let harness = SessionHarnessInfo::detect("claude", repo.path());
|
||||
assert_eq!(harness.primary, HarnessKind::Claude);
|
||||
assert_eq!(harness.primary_label, "claude");
|
||||
assert_eq!(
|
||||
harness.detected,
|
||||
vec![HarnessKind::Claude, HarnessKind::Codex]
|
||||
);
|
||||
assert_eq!(harness.detected_labels, vec!["claude", "codex"]);
|
||||
assert_eq!(harness.detected_summary(), "claude, codex");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detect_session_harness_falls_back_to_project_markers_when_agent_unspecified(
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let repo = TestDir::new("session-harness-markers")?;
|
||||
fs::create_dir_all(repo.path().join(".gemini"))?;
|
||||
|
||||
let harness = SessionHarnessInfo::detect("", repo.path());
|
||||
assert_eq!(harness.primary, HarnessKind::Gemini);
|
||||
assert_eq!(harness.primary_label, "gemini");
|
||||
assert_eq!(harness.detected, vec![HarnessKind::Gemini]);
|
||||
assert_eq!(harness.detected_labels, vec!["gemini"]);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detect_session_harness_collects_extended_builtin_markers(
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let repo = TestDir::new("session-harness-extended-markers")?;
|
||||
fs::create_dir_all(repo.path().join(".zed"))?;
|
||||
fs::create_dir_all(repo.path().join(".factory-droid"))?;
|
||||
fs::create_dir_all(repo.path().join(".windsurf"))?;
|
||||
|
||||
let harness = SessionHarnessInfo::detect("", repo.path());
|
||||
assert_eq!(harness.primary, HarnessKind::Zed);
|
||||
assert_eq!(harness.primary_label, "zed");
|
||||
assert_eq!(
|
||||
harness.detected,
|
||||
vec![
|
||||
HarnessKind::Zed,
|
||||
HarnessKind::FactoryDroid,
|
||||
HarnessKind::Windsurf
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
harness.detected_labels,
|
||||
vec!["zed", "factory_droid", "windsurf"]
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn canonical_agent_type_normalizes_known_aliases() {
|
||||
assert_eq!(HarnessKind::canonical_agent_type("claude-code"), "claude");
|
||||
assert_eq!(HarnessKind::canonical_agent_type("gemini-cli"), "gemini");
|
||||
assert_eq!(
|
||||
HarnessKind::canonical_agent_type("factory-droid"),
|
||||
"factory_droid"
|
||||
);
|
||||
assert_eq!(
|
||||
HarnessKind::canonical_agent_type(" custom-runner "),
|
||||
"custom-runner"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detect_session_harness_preserves_custom_agent_label_without_markers() {
|
||||
let harness = SessionHarnessInfo::detect(" custom-runner ", Path::new("."));
|
||||
assert_eq!(harness.primary, HarnessKind::Unknown);
|
||||
assert_eq!(harness.primary_label, "custom-runner");
|
||||
assert!(harness.detected.is_empty());
|
||||
assert!(harness.detected_labels.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detect_session_harness_preserves_custom_agent_label_with_project_markers(
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let repo = TestDir::new("session-harness-custom-markers")?;
|
||||
fs::create_dir_all(repo.path().join(".claude"))?;
|
||||
fs::create_dir_all(repo.path().join(".codex"))?;
|
||||
|
||||
let harness = SessionHarnessInfo::detect("custom-runner", repo.path());
|
||||
assert_eq!(harness.primary, HarnessKind::Unknown);
|
||||
assert_eq!(harness.primary_label, "custom-runner");
|
||||
assert_eq!(
|
||||
harness.detected,
|
||||
vec![HarnessKind::Claude, HarnessKind::Codex]
|
||||
);
|
||||
assert_eq!(harness.detected_labels, vec!["claude", "codex"]);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_detection_adds_custom_markers_to_detected_summary(
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let repo = TestDir::new("session-harness-custom-config")?;
|
||||
fs::create_dir_all(repo.path().join(".acme"))?;
|
||||
let mut cfg = crate::config::Config::default();
|
||||
cfg.harness_runners.insert(
|
||||
"acme-runner".to_string(),
|
||||
crate::config::HarnessRunnerConfig {
|
||||
project_markers: vec![PathBuf::from(".acme")],
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
|
||||
let harness =
|
||||
SessionHarnessInfo::detect("", repo.path()).with_config_detection(&cfg, repo.path());
|
||||
assert_eq!(harness.primary, HarnessKind::Unknown);
|
||||
assert_eq!(harness.primary_label, "acme-runner");
|
||||
assert_eq!(harness.detected_labels, vec!["acme-runner"]);
|
||||
assert_eq!(harness.detected_summary(), "acme-runner");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_detection_preserves_custom_primary_label_and_appends_marker_matches(
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let repo = TestDir::new("session-harness-config-append")?;
|
||||
fs::create_dir_all(repo.path().join(".acme"))?;
|
||||
fs::create_dir_all(repo.path().join(".codex"))?;
|
||||
let mut cfg = crate::config::Config::default();
|
||||
cfg.harness_runners.insert(
|
||||
"acme-runner".to_string(),
|
||||
crate::config::HarnessRunnerConfig {
|
||||
project_markers: vec![PathBuf::from(".acme")],
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
|
||||
let harness = SessionHarnessInfo::detect("acme-runner", repo.path())
|
||||
.with_config_detection(&cfg, repo.path());
|
||||
assert_eq!(harness.primary, HarnessKind::Unknown);
|
||||
assert_eq!(harness.primary_label, "acme-runner");
|
||||
assert_eq!(harness.detected_labels, vec!["codex", "acme-runner"]);
|
||||
assert_eq!(harness.detected_summary(), "codex, acme-runner");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn runner_key_uses_canonical_label_for_unknown_harnesses() {
|
||||
assert_eq!(
|
||||
SessionHarnessInfo::runner_key(" custom-runner "),
|
||||
"custom-runner"
|
||||
);
|
||||
assert_eq!(SessionHarnessInfo::runner_key("claude-code"), "claude");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_requested_agent_type_uses_detected_builtin_marker_for_auto(
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let repo = TestDir::new("session-harness-resolve-auto-built-in")?;
|
||||
fs::create_dir_all(repo.path().join(".codex"))?;
|
||||
|
||||
let resolved = SessionHarnessInfo::resolve_requested_agent_type(
|
||||
&crate::config::Config::default(),
|
||||
"auto",
|
||||
repo.path(),
|
||||
);
|
||||
assert_eq!(resolved, "codex");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_requested_agent_type_uses_configured_marker_for_auto(
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let repo = TestDir::new("session-harness-resolve-auto-custom")?;
|
||||
fs::create_dir_all(repo.path().join(".acme"))?;
|
||||
let mut cfg = crate::config::Config::default();
|
||||
cfg.harness_runners.insert(
|
||||
"acme-runner".to_string(),
|
||||
crate::config::HarnessRunnerConfig {
|
||||
project_markers: vec![PathBuf::from(".acme")],
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
|
||||
let resolved = SessionHarnessInfo::resolve_requested_agent_type(&cfg, "auto", repo.path());
|
||||
assert_eq!(resolved, "acme-runner");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_requested_agent_type_skips_nonlaunchable_builtin_markers_without_runner(
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let repo = TestDir::new("session-harness-resolve-auto-nonlaunchable")?;
|
||||
fs::create_dir_all(repo.path().join(".zed"))?;
|
||||
|
||||
let resolved = SessionHarnessInfo::resolve_requested_agent_type(
|
||||
&crate::config::Config::default(),
|
||||
"auto",
|
||||
repo.path(),
|
||||
);
|
||||
assert_eq!(resolved, "claude");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_requested_agent_type_uses_configured_runner_for_extended_builtin_markers(
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let repo = TestDir::new("session-harness-resolve-auto-extended-runner")?;
|
||||
fs::create_dir_all(repo.path().join(".windsurf"))?;
|
||||
let mut cfg = crate::config::Config::default();
|
||||
cfg.harness_runners.insert(
|
||||
"windsurf".to_string(),
|
||||
crate::config::HarnessRunnerConfig {
|
||||
program: "windsurf".to_string(),
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
|
||||
let resolved = SessionHarnessInfo::resolve_requested_agent_type(&cfg, "auto", repo.path());
|
||||
assert_eq!(resolved, "windsurf");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_requested_agent_type_falls_back_to_claude_without_markers() {
|
||||
let resolved = SessionHarnessInfo::resolve_requested_agent_type(
|
||||
&crate::config::Config::default(),
|
||||
"auto",
|
||||
Path::new("."),
|
||||
);
|
||||
assert_eq!(resolved, "claude");
|
||||
}
|
||||
}
|
||||
|
||||
+1779
-22
File diff suppressed because it is too large
Load Diff
+659
-14
@@ -23,7 +23,8 @@ use crate::session::output::{
|
||||
};
|
||||
use crate::session::store::{DaemonActivity, FileActivityOverlap, StateStore};
|
||||
use crate::session::{
|
||||
DecisionLogEntry, FileActivityEntry, Session, SessionGrouping, SessionMessage, SessionState,
|
||||
ContextObservationPriority, DecisionLogEntry, FileActivityEntry, Session, SessionGrouping,
|
||||
SessionHarnessInfo, SessionMessage, SessionState,
|
||||
};
|
||||
use crate::worktree;
|
||||
|
||||
@@ -38,6 +39,7 @@ const PANE_RESIZE_STEP_PERCENT: u16 = 5;
|
||||
const MAX_LOG_ENTRIES: u64 = 12;
|
||||
const MAX_DIFF_PREVIEW_LINES: usize = 6;
|
||||
const MAX_DIFF_PATCH_LINES: usize = 80;
|
||||
const MAX_METRICS_GRAPH_RELATIONS: usize = 6;
|
||||
const MAX_FILE_ACTIVITY_PATCH_LINES: usize = 3;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
@@ -85,6 +87,7 @@ pub struct Dashboard {
|
||||
notifier: DesktopNotifier,
|
||||
webhook_notifier: WebhookNotifier,
|
||||
sessions: Vec<Session>,
|
||||
session_harnesses: HashMap<String, SessionHarnessInfo>,
|
||||
session_output_cache: HashMap<String, Vec<OutputLine>>,
|
||||
unread_message_counts: HashMap<String, usize>,
|
||||
approval_queue_counts: HashMap<String, usize>,
|
||||
@@ -473,6 +476,29 @@ impl SessionCompletionSummary {
|
||||
}
|
||||
}
|
||||
|
||||
fn load_session_harnesses(
|
||||
db: &StateStore,
|
||||
cfg: &Config,
|
||||
sessions: &[Session],
|
||||
) -> HashMap<String, SessionHarnessInfo> {
|
||||
let working_dirs = sessions
|
||||
.iter()
|
||||
.map(|session| (session.id.as_str(), session.working_dir.as_path()))
|
||||
.collect::<HashMap<_, _>>();
|
||||
db.list_session_harnesses()
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.map(|(session_id, info)| {
|
||||
let info = if let Some(working_dir) = working_dirs.get(session_id.as_str()) {
|
||||
info.with_config_detection(cfg, working_dir)
|
||||
} else {
|
||||
info
|
||||
};
|
||||
(session_id, info)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
impl Dashboard {
|
||||
pub fn new(db: StateStore, cfg: Config) -> Self {
|
||||
Self::with_output_store(db, cfg, SessionOutputStore::default())
|
||||
@@ -495,6 +521,7 @@ impl Dashboard {
|
||||
let _ = db.sync_tool_activity_metrics(&cfg.tool_activity_metrics_path());
|
||||
}
|
||||
let sessions = db.list_sessions().unwrap_or_default();
|
||||
let session_harnesses = load_session_harnesses(&db, &cfg, &sessions);
|
||||
let initial_session_states = sessions
|
||||
.iter()
|
||||
.map(|session| (session.id.clone(), session.state.clone()))
|
||||
@@ -520,6 +547,7 @@ impl Dashboard {
|
||||
notifier,
|
||||
webhook_notifier,
|
||||
sessions,
|
||||
session_harnesses,
|
||||
session_output_cache: HashMap::new(),
|
||||
unread_message_counts: HashMap::new(),
|
||||
approval_queue_counts: HashMap::new(),
|
||||
@@ -843,7 +871,8 @@ impl Dashboard {
|
||||
self.render_searchable_graph(&lines)
|
||||
} else {
|
||||
Text::from(
|
||||
lines.into_iter()
|
||||
lines
|
||||
.into_iter()
|
||||
.map(|line| Line::from(line.text))
|
||||
.collect::<Vec<_>>(),
|
||||
)
|
||||
@@ -1227,7 +1256,7 @@ impl Dashboard {
|
||||
self.theme_palette(),
|
||||
)
|
||||
})
|
||||
.collect::<Vec<_>>(),
|
||||
.collect::<Vec<_>>(),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2168,6 +2197,7 @@ impl Dashboard {
|
||||
&comms::MessageType::TaskHandoff {
|
||||
task: source_session.task.clone(),
|
||||
context,
|
||||
priority: comms::TaskPriority::Normal,
|
||||
},
|
||||
) {
|
||||
tracing::warn!(
|
||||
@@ -3295,7 +3325,10 @@ impl Dashboard {
|
||||
return;
|
||||
}
|
||||
|
||||
if !matches!(self.output_mode, OutputMode::SessionOutput | OutputMode::ContextGraph) {
|
||||
if !matches!(
|
||||
self.output_mode,
|
||||
OutputMode::SessionOutput | OutputMode::ContextGraph
|
||||
) {
|
||||
self.set_operator_note(
|
||||
"search is only available in session output or graph view".to_string(),
|
||||
);
|
||||
@@ -3646,6 +3679,7 @@ impl Dashboard {
|
||||
&comms::MessageType::TaskHandoff {
|
||||
task: task.clone(),
|
||||
context: context.clone(),
|
||||
priority: comms::TaskPriority::Normal,
|
||||
},
|
||||
) {
|
||||
tracing::warn!(
|
||||
@@ -4029,6 +4063,7 @@ impl Dashboard {
|
||||
Vec::new()
|
||||
}
|
||||
};
|
||||
self.session_harnesses = load_session_harnesses(&self.db, &self.cfg, &self.sessions);
|
||||
self.unread_message_counts = match self.db.unread_message_counts() {
|
||||
Ok(counts) => counts,
|
||||
Err(error) => {
|
||||
@@ -4148,6 +4183,11 @@ impl Dashboard {
|
||||
}
|
||||
SessionState::Completed => {
|
||||
let summary = self.build_completion_summary(session);
|
||||
self.persist_completion_summary_observation(
|
||||
session,
|
||||
&summary,
|
||||
"completion_summary",
|
||||
);
|
||||
if self.cfg.completion_summary_notifications.enabled {
|
||||
completion_summaries.push(summary.clone());
|
||||
} else if self.cfg.desktop_notifications.session_completed {
|
||||
@@ -4169,6 +4209,11 @@ impl Dashboard {
|
||||
}
|
||||
SessionState::Failed => {
|
||||
let summary = self.build_completion_summary(session);
|
||||
self.persist_completion_summary_observation(
|
||||
session,
|
||||
&summary,
|
||||
"failure_summary",
|
||||
);
|
||||
failed_notifications.push((
|
||||
"ECC 2.0: Session failed".to_string(),
|
||||
format!(
|
||||
@@ -4221,6 +4266,41 @@ impl Dashboard {
|
||||
self.last_session_states = next_states;
|
||||
}
|
||||
|
||||
fn persist_completion_summary_observation(
|
||||
&self,
|
||||
session: &Session,
|
||||
summary: &SessionCompletionSummary,
|
||||
observation_type: &str,
|
||||
) {
|
||||
let observation_summary = format!(
|
||||
"{} | files {} | tests {}/{} | warnings {}",
|
||||
truncate_for_dashboard(&summary.task, 72),
|
||||
summary.files_changed,
|
||||
summary.tests_passed,
|
||||
summary.tests_run,
|
||||
summary.warnings.len()
|
||||
);
|
||||
let details = completion_summary_observation_details(summary, session);
|
||||
let priority = if observation_type == "failure_summary" {
|
||||
ContextObservationPriority::High
|
||||
} else {
|
||||
ContextObservationPriority::Normal
|
||||
};
|
||||
if let Err(error) = self.db.add_session_observation(
|
||||
&session.id,
|
||||
observation_type,
|
||||
priority,
|
||||
false,
|
||||
&observation_summary,
|
||||
&details,
|
||||
) {
|
||||
tracing::warn!(
|
||||
"Failed to persist completion observation for {}: {error}",
|
||||
session.id
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn sync_approval_notifications(&mut self) {
|
||||
let latest_message = match self.db.latest_unread_approval_message() {
|
||||
Ok(message) => message,
|
||||
@@ -4909,8 +4989,16 @@ impl Dashboard {
|
||||
}
|
||||
|
||||
self.selected_team_summary = if team.total > 0 { Some(team) } else { None };
|
||||
self.selected_route_preview =
|
||||
self.build_route_preview(team.total, &route_candidates);
|
||||
let selected_agent_type = self
|
||||
.selected_agent_type()
|
||||
.unwrap_or(self.cfg.default_agent.as_str())
|
||||
.to_string();
|
||||
self.selected_route_preview = self.build_route_preview(
|
||||
&session_id,
|
||||
&selected_agent_type,
|
||||
team.total,
|
||||
&route_candidates,
|
||||
);
|
||||
delegated.sort_by_key(|delegate| {
|
||||
(
|
||||
delegate_attention_priority(delegate),
|
||||
@@ -4933,9 +5021,23 @@ impl Dashboard {
|
||||
|
||||
fn build_route_preview(
|
||||
&self,
|
||||
lead_id: &str,
|
||||
lead_agent_type: &str,
|
||||
delegate_count: usize,
|
||||
delegates: &[DelegatedChildSummary],
|
||||
) -> Option<String> {
|
||||
if let Some(task) = self.latest_route_task(lead_id) {
|
||||
if let Ok(preview) = manager::preview_assignment_for_task(
|
||||
&self.db,
|
||||
&self.cfg,
|
||||
lead_id,
|
||||
&task,
|
||||
lead_agent_type,
|
||||
) {
|
||||
return Some(self.format_assignment_preview(&task, &preview));
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(idle_clear) = delegates
|
||||
.iter()
|
||||
.filter(|delegate| {
|
||||
@@ -4959,7 +5061,7 @@ impl Dashboard {
|
||||
.min_by_key(|delegate| (delegate.handoff_backlog, delegate.session_id.as_str()))
|
||||
{
|
||||
return Some(format!(
|
||||
"reuse idle {} with backlog {}",
|
||||
"defer; idle {} backlog {}",
|
||||
format_session_id(&idle_backed_up.session_id),
|
||||
idle_backed_up.handoff_backlog
|
||||
));
|
||||
@@ -4976,9 +5078,18 @@ impl Dashboard {
|
||||
.min_by_key(|delegate| (delegate.handoff_backlog, delegate.session_id.as_str()))
|
||||
{
|
||||
return Some(format!(
|
||||
"reuse active {} with backlog {}",
|
||||
"{} active {}{}",
|
||||
if active_delegate.handoff_backlog > 0 {
|
||||
"defer;"
|
||||
} else {
|
||||
"reuse"
|
||||
},
|
||||
format_session_id(&active_delegate.session_id),
|
||||
active_delegate.handoff_backlog
|
||||
if active_delegate.handoff_backlog > 0 {
|
||||
format!(" backlog {}", active_delegate.handoff_backlog)
|
||||
} else {
|
||||
String::new()
|
||||
}
|
||||
));
|
||||
}
|
||||
|
||||
@@ -4989,6 +5100,77 @@ impl Dashboard {
|
||||
}
|
||||
}
|
||||
|
||||
fn latest_route_task(&self, session_id: &str) -> Option<String> {
|
||||
self.db
|
||||
.list_messages_for_session(session_id, 16)
|
||||
.ok()?
|
||||
.into_iter()
|
||||
.rev()
|
||||
.find_map(|message| {
|
||||
if message.to_session != session_id || message.msg_type != "task_handoff" {
|
||||
return None;
|
||||
}
|
||||
manager::parse_task_handoff_task(&message.content).or_else(|| Some(message.content))
|
||||
})
|
||||
}
|
||||
|
||||
fn format_assignment_preview(
|
||||
&self,
|
||||
task: &str,
|
||||
preview: &manager::AssignmentPreview,
|
||||
) -> String {
|
||||
let task_preview = truncate_for_dashboard(task, 40);
|
||||
let graph_suffix = if preview.graph_match_terms.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
format!(
|
||||
" | graph {}",
|
||||
truncate_for_dashboard(&preview.graph_match_terms.join(", "), 36)
|
||||
)
|
||||
};
|
||||
|
||||
match preview.action {
|
||||
manager::AssignmentAction::Spawned => {
|
||||
format!("for `{task_preview}` spawn new delegate")
|
||||
}
|
||||
manager::AssignmentAction::ReusedIdle => format!(
|
||||
"for `{task_preview}` reuse idle {}{}",
|
||||
preview
|
||||
.session_id
|
||||
.as_deref()
|
||||
.map(format_session_id)
|
||||
.unwrap_or_else(|| "unknown".to_string()),
|
||||
graph_suffix
|
||||
),
|
||||
manager::AssignmentAction::ReusedActive => format!(
|
||||
"for `{task_preview}` reuse active {}{}",
|
||||
preview
|
||||
.session_id
|
||||
.as_deref()
|
||||
.map(format_session_id)
|
||||
.unwrap_or_else(|| "unknown".to_string()),
|
||||
graph_suffix
|
||||
),
|
||||
manager::AssignmentAction::DeferredSaturated => {
|
||||
let state_label = match preview.delegate_state {
|
||||
Some(SessionState::Idle) => "idle",
|
||||
Some(SessionState::Running) | Some(SessionState::Pending) => "active",
|
||||
_ => "delegate",
|
||||
};
|
||||
format!(
|
||||
"for `{task_preview}` defer; {state_label} {} backlog {}{}",
|
||||
preview
|
||||
.session_id
|
||||
.as_deref()
|
||||
.map(format_session_id)
|
||||
.unwrap_or_else(|| "unknown".to_string()),
|
||||
preview.handoff_backlog,
|
||||
graph_suffix
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn selected_session_id(&self) -> Option<&str> {
|
||||
self.sessions
|
||||
.get(self.selected_session)
|
||||
@@ -5135,6 +5317,129 @@ impl Dashboard {
|
||||
lines
|
||||
}
|
||||
|
||||
fn session_graph_metrics_lines(&self, session_id: &str) -> Vec<String> {
|
||||
let entity = self
|
||||
.db
|
||||
.list_context_entities(Some(session_id), Some("session"), 4)
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.find(|entity| {
|
||||
entity.session_id.as_deref() == Some(session_id) || entity.name == session_id
|
||||
});
|
||||
let Some(entity) = entity else {
|
||||
return Vec::new();
|
||||
};
|
||||
|
||||
let Ok(Some(detail)) = self
|
||||
.db
|
||||
.get_context_entity_detail(entity.id, MAX_METRICS_GRAPH_RELATIONS)
|
||||
else {
|
||||
return Vec::new();
|
||||
};
|
||||
|
||||
if detail.outgoing.is_empty() && detail.incoming.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
let mut lines = vec![
|
||||
"Context graph".to_string(),
|
||||
format!(
|
||||
"- outgoing {} | incoming {}",
|
||||
detail.outgoing.len(),
|
||||
detail.incoming.len()
|
||||
),
|
||||
];
|
||||
|
||||
for relation in detail.outgoing.iter().take(4) {
|
||||
lines.push(format!(
|
||||
"- -> {} {}:{}",
|
||||
relation.relation_type,
|
||||
relation.to_entity_type,
|
||||
truncate_for_dashboard(&relation.to_entity_name, 72)
|
||||
));
|
||||
}
|
||||
|
||||
for relation in detail.incoming.iter().take(2) {
|
||||
lines.push(format!(
|
||||
"- <- {} {}:{}",
|
||||
relation.relation_type,
|
||||
relation.from_entity_type,
|
||||
truncate_for_dashboard(&relation.from_entity_name, 72)
|
||||
));
|
||||
}
|
||||
|
||||
lines
|
||||
}
|
||||
|
||||
fn session_graph_recall_lines(&self, session: &Session) -> Vec<String> {
|
||||
let query = session.task.trim();
|
||||
if query.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
let Ok(entries) = self.db.recall_context_entities(None, query, 4) else {
|
||||
return Vec::new();
|
||||
};
|
||||
|
||||
let entries = entries
|
||||
.into_iter()
|
||||
.filter(|entry| {
|
||||
!(entry.entity.entity_type == "session" && entry.entity.name == session.id)
|
||||
})
|
||||
.take(3)
|
||||
.collect::<Vec<_>>();
|
||||
if entries.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
let mut lines = vec!["Relevant memory".to_string()];
|
||||
for entry in entries {
|
||||
let mut line = format!(
|
||||
"- #{} [{}] {} | score {} | relations {} | observations {} | priority {}",
|
||||
entry.entity.id,
|
||||
entry.entity.entity_type,
|
||||
truncate_for_dashboard(&entry.entity.name, 60),
|
||||
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() {
|
||||
if session_id != session.id {
|
||||
line.push_str(&format!(" | {}", format_session_id(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 {}", truncate_for_dashboard(path, 72)));
|
||||
}
|
||||
if !entry.entity.summary.is_empty() {
|
||||
lines.push(format!(
|
||||
" summary {}",
|
||||
truncate_for_dashboard(&entry.entity.summary, 72)
|
||||
));
|
||||
}
|
||||
if let Ok(observations) = self.db.list_context_observations(Some(entry.entity.id), 1) {
|
||||
if let Some(observation) = observations.first() {
|
||||
lines.push(format!(
|
||||
" memory [{}{}] {}",
|
||||
observation.priority,
|
||||
if observation.pinned { "/pinned" } else { "" },
|
||||
truncate_for_dashboard(&observation.summary, 72)
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
lines
|
||||
}
|
||||
|
||||
fn visible_git_status_lines(&self) -> Vec<Line<'static>> {
|
||||
self.selected_git_status_entries
|
||||
.iter()
|
||||
@@ -6056,6 +6361,14 @@ impl Dashboard {
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(harness) = self.session_harnesses.get(&session.id) {
|
||||
lines.push(format!(
|
||||
"Harness {} | Detected {}",
|
||||
harness.primary_label,
|
||||
harness.detected_summary()
|
||||
));
|
||||
}
|
||||
|
||||
lines.push(format!(
|
||||
"Tokens {} total | In {} | Out {}",
|
||||
format_token_count(metrics.tokens_used),
|
||||
@@ -6100,6 +6413,8 @@ impl Dashboard {
|
||||
}
|
||||
}
|
||||
}
|
||||
lines.extend(self.session_graph_recall_lines(session));
|
||||
lines.extend(self.session_graph_metrics_lines(&session.id));
|
||||
let file_overlaps = self
|
||||
.db
|
||||
.list_file_overlaps(&session.id, 3)
|
||||
@@ -8300,6 +8615,39 @@ fn summarize_completion_warnings(
|
||||
warnings
|
||||
}
|
||||
|
||||
fn completion_summary_observation_details(
|
||||
summary: &SessionCompletionSummary,
|
||||
session: &Session,
|
||||
) -> BTreeMap<String, String> {
|
||||
let mut details = BTreeMap::new();
|
||||
details.insert("state".to_string(), session.state.to_string());
|
||||
details.insert(
|
||||
"files_changed".to_string(),
|
||||
summary.files_changed.to_string(),
|
||||
);
|
||||
details.insert("tokens_used".to_string(), summary.tokens_used.to_string());
|
||||
details.insert(
|
||||
"duration_secs".to_string(),
|
||||
summary.duration_secs.to_string(),
|
||||
);
|
||||
details.insert("cost_usd".to_string(), format!("{:.4}", summary.cost_usd));
|
||||
details.insert("tests_run".to_string(), summary.tests_run.to_string());
|
||||
details.insert("tests_passed".to_string(), summary.tests_passed.to_string());
|
||||
if !summary.recent_files.is_empty() {
|
||||
details.insert("recent_files".to_string(), summary.recent_files.join(" | "));
|
||||
}
|
||||
if !summary.key_decisions.is_empty() {
|
||||
details.insert(
|
||||
"key_decisions".to_string(),
|
||||
summary.key_decisions.join(" | "),
|
||||
);
|
||||
}
|
||||
if !summary.warnings.is_empty() {
|
||||
details.insert("warnings".to_string(), summary.warnings.join(" | "));
|
||||
}
|
||||
details
|
||||
}
|
||||
|
||||
fn session_started_webhook_body(session: &Session, compare_url: Option<&str>) -> String {
|
||||
let mut lines = vec![
|
||||
"*ECC 2.0: Session started*".to_string(),
|
||||
@@ -10058,8 +10406,12 @@ diff --git a/src/lib.rs b/src/lib.rs\n\
|
||||
let mut dashboard = test_dashboard(vec![focus.clone(), review.clone()], 0);
|
||||
dashboard.db.insert_session(&focus)?;
|
||||
dashboard.db.insert_session(&review)?;
|
||||
dashboard.db.insert_decision(&focus.id, "Alpha graph path", &[], "planner path")?;
|
||||
dashboard.db.insert_decision(&review.id, "Beta graph path", &[], "review path")?;
|
||||
dashboard
|
||||
.db
|
||||
.insert_decision(&focus.id, "Alpha graph path", &[], "planner path")?;
|
||||
dashboard
|
||||
.db
|
||||
.insert_decision(&review.id, "Beta graph path", &[], "review path")?;
|
||||
|
||||
dashboard.toggle_context_graph_mode();
|
||||
dashboard.toggle_search_scope();
|
||||
@@ -10099,8 +10451,12 @@ diff --git a/src/lib.rs b/src/lib.rs\n\
|
||||
let mut dashboard = test_dashboard(vec![focus.clone(), review.clone()], 0);
|
||||
dashboard.db.insert_session(&focus)?;
|
||||
dashboard.db.insert_session(&review)?;
|
||||
dashboard.db.insert_decision(&focus.id, "alpha local graph", &[], "planner path")?;
|
||||
dashboard.db.insert_decision(&review.id, "alpha remote graph", &[], "review path")?;
|
||||
dashboard
|
||||
.db
|
||||
.insert_decision(&focus.id, "alpha local graph", &[], "planner path")?;
|
||||
dashboard
|
||||
.db
|
||||
.insert_decision(&review.id, "alpha remote graph", &[], "review path")?;
|
||||
|
||||
dashboard.toggle_context_graph_mode();
|
||||
dashboard.toggle_search_scope();
|
||||
@@ -10119,7 +10475,10 @@ diff --git a/src/lib.rs b/src/lib.rs\n\
|
||||
dashboard.operator_note.as_deref(),
|
||||
Some("graph search /alpha.* match 2/2 | all sessions")
|
||||
);
|
||||
assert_ne!(dashboard.selected_session_id().map(str::to_string), first_session);
|
||||
assert_ne!(
|
||||
dashboard.selected_session_id().map(str::to_string),
|
||||
first_session
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -10157,6 +10516,92 @@ diff --git a/src/lib.rs b/src/lib.rs\n\
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selected_session_metrics_text_includes_context_graph_relations() -> Result<()> {
|
||||
let focus = sample_session(
|
||||
"focus-12345678",
|
||||
"planner",
|
||||
SessionState::Running,
|
||||
None,
|
||||
1,
|
||||
1,
|
||||
);
|
||||
let delegate = sample_session("delegate-87654321", "coder", SessionState::Idle, None, 1, 1);
|
||||
let dashboard = test_dashboard(vec![focus.clone(), delegate.clone()], 0);
|
||||
dashboard.db.insert_session(&focus)?;
|
||||
dashboard.db.insert_session(&delegate)?;
|
||||
dashboard.db.insert_decision(
|
||||
&focus.id,
|
||||
"Use sqlite graph sync",
|
||||
&[],
|
||||
"Keeps shared memory queryable",
|
||||
)?;
|
||||
dashboard.db.send_message(
|
||||
&focus.id,
|
||||
&delegate.id,
|
||||
"{\"task\":\"Review graph edge\",\"context\":\"coordination smoke\"}",
|
||||
"task_handoff",
|
||||
)?;
|
||||
|
||||
let text = dashboard.selected_session_metrics_text();
|
||||
assert!(text.contains("Context graph"));
|
||||
assert!(text.contains("outgoing 2 | incoming 0"));
|
||||
assert!(text.contains("-> decided decision:Use sqlite graph sync"));
|
||||
assert!(text.contains("-> delegates_to session:delegate-87654321"));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selected_session_metrics_text_includes_relevant_memory() -> Result<()> {
|
||||
let mut focus = sample_session(
|
||||
"focus-12345678",
|
||||
"planner",
|
||||
SessionState::Running,
|
||||
None,
|
||||
1,
|
||||
1,
|
||||
);
|
||||
focus.task = "Investigate auth callback recovery".to_string();
|
||||
let mut memory = sample_session("memory-87654321", "coder", SessionState::Idle, None, 1, 1);
|
||||
memory.task = "Auth callback recovery notes".to_string();
|
||||
let dashboard = test_dashboard(vec![focus.clone(), memory.clone()], 0);
|
||||
dashboard.db.insert_session(&focus)?;
|
||||
dashboard.db.insert_session(&memory)?;
|
||||
dashboard.db.upsert_context_entity(
|
||||
Some(&memory.id),
|
||||
"file",
|
||||
"callback.ts",
|
||||
Some("src/routes/auth/callback.ts"),
|
||||
"Handles auth callback recovery and billing fallback",
|
||||
&BTreeMap::from([("area".to_string(), "auth".to_string())]),
|
||||
)?;
|
||||
let entity = dashboard
|
||||
.db
|
||||
.list_context_entities(Some(&memory.id), Some("file"), 10)?
|
||||
.into_iter()
|
||||
.find(|entry| entry.name == "callback.ts")
|
||||
.expect("callback entity");
|
||||
dashboard.db.add_context_observation(
|
||||
Some(&memory.id),
|
||||
entity.id,
|
||||
"completion_summary",
|
||||
ContextObservationPriority::Normal,
|
||||
true,
|
||||
"Recovered auth callback incident with billing fallback",
|
||||
&BTreeMap::new(),
|
||||
)?;
|
||||
|
||||
let text = dashboard.selected_session_metrics_text();
|
||||
assert!(text.contains("Relevant memory"));
|
||||
assert!(text.contains("[file] callback.ts"));
|
||||
assert!(text.contains("| pinned"));
|
||||
assert!(text.contains("matches auth, callback, recovery"));
|
||||
assert!(text.contains(
|
||||
"memory [normal/pinned] Recovered auth callback incident with billing fallback"
|
||||
));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn worktree_diff_columns_split_removed_and_added_lines() {
|
||||
let patch = "\
|
||||
@@ -10954,6 +11399,91 @@ diff --git a/src/lib.rs b/src/lib.rs
|
||||
assert!(!text.contains("Backlog focus-12"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn route_preview_uses_graph_context_for_latest_incoming_handoff() {
|
||||
let lead = sample_session(
|
||||
"lead-12345678",
|
||||
"planner",
|
||||
SessionState::Running,
|
||||
Some("ecc/lead"),
|
||||
512,
|
||||
42,
|
||||
);
|
||||
let older_worker = sample_session(
|
||||
"older-worker",
|
||||
"planner",
|
||||
SessionState::Idle,
|
||||
Some("ecc/older"),
|
||||
128,
|
||||
12,
|
||||
);
|
||||
let auth_worker = sample_session(
|
||||
"auth-worker",
|
||||
"planner",
|
||||
SessionState::Idle,
|
||||
Some("ecc/auth"),
|
||||
256,
|
||||
24,
|
||||
);
|
||||
|
||||
let mut dashboard = test_dashboard(
|
||||
vec![lead.clone(), older_worker.clone(), auth_worker.clone()],
|
||||
0,
|
||||
);
|
||||
dashboard.db.insert_session(&lead).unwrap();
|
||||
dashboard.db.insert_session(&older_worker).unwrap();
|
||||
dashboard.db.insert_session(&auth_worker).unwrap();
|
||||
dashboard
|
||||
.db
|
||||
.send_message(
|
||||
"lead-12345678",
|
||||
"older-worker",
|
||||
"{\"task\":\"Legacy delegated work\",\"context\":\"Delegated from lead\"}",
|
||||
"task_handoff",
|
||||
)
|
||||
.unwrap();
|
||||
dashboard
|
||||
.db
|
||||
.send_message(
|
||||
"lead-12345678",
|
||||
"auth-worker",
|
||||
"{\"task\":\"Auth delegated work\",\"context\":\"Delegated from lead\"}",
|
||||
"task_handoff",
|
||||
)
|
||||
.unwrap();
|
||||
dashboard.db.mark_messages_read("older-worker").unwrap();
|
||||
dashboard.db.mark_messages_read("auth-worker").unwrap();
|
||||
dashboard
|
||||
.db
|
||||
.send_message(
|
||||
"planner-root",
|
||||
"lead-12345678",
|
||||
"{\"task\":\"Investigate auth callback recovery\",\"context\":\"Delegated from planner-root\"}",
|
||||
"task_handoff",
|
||||
)
|
||||
.unwrap();
|
||||
dashboard
|
||||
.db
|
||||
.upsert_context_entity(
|
||||
Some("auth-worker"),
|
||||
"file",
|
||||
"auth-callback.ts",
|
||||
Some("src/auth/callback.ts"),
|
||||
"Auth callback recovery edge cases",
|
||||
&BTreeMap::new(),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
dashboard.unread_message_counts = dashboard.db.unread_message_counts().unwrap();
|
||||
dashboard.sync_selected_messages();
|
||||
dashboard.sync_selected_lineage();
|
||||
|
||||
assert_eq!(
|
||||
dashboard.selected_route_preview.as_deref(),
|
||||
Some("for `Investigate auth callback recovery` reuse idle auth-wor | graph auth, callback, recovery")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn route_preview_ignores_non_handoff_inbox_noise() {
|
||||
let lead = sample_session(
|
||||
@@ -11496,6 +12026,73 @@ diff --git a/src/lib.rs b/src/lib.rs
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn refresh_persists_completion_summary_observation() -> Result<()> {
|
||||
let root =
|
||||
std::env::temp_dir().join(format!("ecc2-completion-observation-{}", Uuid::new_v4()));
|
||||
fs::create_dir_all(root.join(".claude").join("metrics"))?;
|
||||
|
||||
let mut cfg = build_config(&root.join(".claude"));
|
||||
cfg.completion_summary_notifications.delivery =
|
||||
crate::notifications::CompletionSummaryDelivery::TuiPopup;
|
||||
cfg.desktop_notifications.session_completed = false;
|
||||
|
||||
let db = StateStore::open(&cfg.db_path)?;
|
||||
let mut session = sample_session(
|
||||
"done-observation",
|
||||
"claude",
|
||||
SessionState::Running,
|
||||
Some("ecc/observation"),
|
||||
144,
|
||||
42,
|
||||
);
|
||||
session.task = "Recover auth callback after wipe".to_string();
|
||||
db.insert_session(&session)?;
|
||||
|
||||
let metrics_path = cfg.tool_activity_metrics_path();
|
||||
fs::create_dir_all(metrics_path.parent().unwrap())?;
|
||||
fs::write(
|
||||
&metrics_path,
|
||||
concat!(
|
||||
"{\"id\":\"evt-1\",\"session_id\":\"done-observation\",\"tool_name\":\"Bash\",\"input_summary\":\"cargo test -q\",\"input_params_json\":\"{\\\"command\\\":\\\"cargo test -q\\\"}\",\"output_summary\":\"ok\",\"timestamp\":\"2026-04-09T00:00:00Z\"}\n",
|
||||
"{\"id\":\"evt-2\",\"session_id\":\"done-observation\",\"tool_name\":\"Write\",\"input_summary\":\"Write src/routes/auth/callback.ts\",\"output_summary\":\"updated callback\",\"file_events\":[{\"path\":\"src/routes/auth/callback.ts\",\"action\":\"modify\",\"diff_preview\":\"portal first\",\"patch_preview\":\"+ portal first\"}],\"timestamp\":\"2026-04-09T00:01:00Z\"}\n"
|
||||
),
|
||||
)?;
|
||||
|
||||
let mut dashboard = Dashboard::new(db, cfg);
|
||||
dashboard
|
||||
.db
|
||||
.update_state("done-observation", &SessionState::Completed)?;
|
||||
|
||||
dashboard.refresh();
|
||||
|
||||
let session_entity = dashboard
|
||||
.db
|
||||
.list_context_entities(Some("done-observation"), Some("session"), 10)?
|
||||
.into_iter()
|
||||
.find(|entity| entity.name == "done-observation")
|
||||
.expect("session entity");
|
||||
let observations = dashboard
|
||||
.db
|
||||
.list_context_observations(Some(session_entity.id), 10)?;
|
||||
assert!(!observations.is_empty());
|
||||
assert_eq!(observations[0].observation_type, "completion_summary");
|
||||
assert!(observations[0]
|
||||
.summary
|
||||
.contains("Recover auth callback after wipe"));
|
||||
assert_eq!(
|
||||
observations[0].details.get("tests_run"),
|
||||
Some(&"1".to_string())
|
||||
);
|
||||
assert!(observations[0]
|
||||
.details
|
||||
.get("recent_files")
|
||||
.is_some_and(|value| value.contains("modify src/routes/auth/callback.ts")));
|
||||
|
||||
let _ = fs::remove_dir_all(root);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dismiss_completion_popup_promotes_the_next_summary() {
|
||||
let mut dashboard = test_dashboard(Vec::new(), 0);
|
||||
@@ -11721,6 +12318,40 @@ diff --git a/src/lib.rs b/src/lib.rs
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selected_session_metrics_text_includes_harness_summary() -> Result<()> {
|
||||
let tempdir = std::env::temp_dir().join(format!(
|
||||
"ecc2-dashboard-harness-metrics-{}",
|
||||
uuid::Uuid::new_v4()
|
||||
));
|
||||
fs::create_dir_all(tempdir.join(".claude"))?;
|
||||
fs::create_dir_all(tempdir.join(".codex"))?;
|
||||
|
||||
let now = Utc::now();
|
||||
let session = Session {
|
||||
id: "sess-harness".to_string(),
|
||||
task: "Map harness metadata".to_string(),
|
||||
project: "ecc".to_string(),
|
||||
task_group: "compat".to_string(),
|
||||
agent_type: "claude".to_string(),
|
||||
working_dir: tempdir.clone(),
|
||||
state: SessionState::Running,
|
||||
pid: Some(4242),
|
||||
worktree: None,
|
||||
created_at: now - Duration::minutes(3),
|
||||
updated_at: now - Duration::minutes(1),
|
||||
last_heartbeat_at: now - Duration::minutes(1),
|
||||
metrics: SessionMetrics::default(),
|
||||
};
|
||||
|
||||
let dashboard = test_dashboard(vec![session], 0);
|
||||
let metrics_text = dashboard.selected_session_metrics_text();
|
||||
assert!(metrics_text.contains("Harness claude | Detected claude, codex"));
|
||||
|
||||
let _ = fs::remove_dir_all(tempdir);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_session_task_uses_selected_session_context() {
|
||||
let dashboard = test_dashboard(
|
||||
@@ -13869,6 +14500,16 @@ diff --git a/src/lib.rs b/src/lib.rs
|
||||
.iter()
|
||||
.map(|session| (session.id.clone(), session.state.clone()))
|
||||
.collect();
|
||||
let session_harnesses = sessions
|
||||
.iter()
|
||||
.map(|session| {
|
||||
(
|
||||
session.id.clone(),
|
||||
SessionHarnessInfo::detect(&session.agent_type, &session.working_dir)
|
||||
.with_config_detection(&cfg, &session.working_dir),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
let output_store = SessionOutputStore::default();
|
||||
let output_rx = output_store.subscribe();
|
||||
let mut session_table_state = TableState::default();
|
||||
@@ -13885,6 +14526,7 @@ diff --git a/src/lib.rs b/src/lib.rs
|
||||
notifier,
|
||||
webhook_notifier,
|
||||
sessions,
|
||||
session_harnesses,
|
||||
session_output_cache: HashMap::new(),
|
||||
unread_message_counts: HashMap::new(),
|
||||
approval_queue_counts: HashMap::new(),
|
||||
@@ -13966,8 +14608,11 @@ diff --git a/src/lib.rs b/src/lib.rs
|
||||
auto_terminate_stale_sessions: false,
|
||||
default_agent: "claude".to_string(),
|
||||
default_agent_profile: None,
|
||||
harness_runners: Default::default(),
|
||||
agent_profiles: Default::default(),
|
||||
orchestration_templates: Default::default(),
|
||||
memory_connectors: Default::default(),
|
||||
computer_use_dispatch: crate::config::ComputerUseDispatchConfig::default(),
|
||||
auto_dispatch_unread_handoffs: false,
|
||||
auto_dispatch_limit_per_session: 5,
|
||||
auto_create_worktrees: true,
|
||||
|
||||
Reference in New Issue
Block a user