use std::path::PathBuf;
use std::sync::Arc;
use serde_json::{Value, json};
use crate::core::catchup::{CatchupOptions, generate_catchup_json, session_finder};
use crate::daemon::state::DaemonState;
pub async fn session_context_catchup(
project_dir: &str,
session_id: Option<&str>,
all_projects: bool,
full: bool,
) -> Result<Value, String> {
let primary = PathBuf::from(project_dir);
if !primary.is_dir() {
return Err(format!(
"project_dir does not exist or is not a directory: {project_dir}"
));
}
let mut project_dirs = vec![primary.clone()];
if all_projects {
let registry = crate::core::claude_mpm_registry::default_registry_path();
match crate::core::claude_mpm_registry::discover_claude_mpm_projects(®istry) {
Ok(extra) => {
for p in extra {
if !project_dirs.contains(&p) {
project_dirs.push(p);
}
}
}
Err(e) => {
eprintln!(
"session_context_catchup: warning: could not read claude-mpm registry: {e}"
);
}
}
}
let config = crate::core::config::MpmConfig::load_default();
let memory_url = trusty_common::mcp::memory_rpc::resolve_memory_base_url_or_unreachable();
let mut sessions = Vec::new();
let mut recent_commits = Vec::new();
let mut recent_memory = Vec::new();
for dir in &project_dirs {
let opts = CatchupOptions {
project_dir: dir.clone(),
memory_url: memory_url.clone(),
include_git: config.catchup.include_git,
include_palace: config.catchup.include_palace,
git_limit: config.catchup.git_limit,
drawer_limit: config.catchup.drawer_limit,
full,
};
let digest = generate_catchup_json(&opts).await;
sessions.extend(digest.sessions);
recent_commits.extend(digest.recent_commits);
recent_memory.extend(digest.recent_memory);
}
let resolved_snapshot = session_finder::latest_trusty_mpm_snapshot(&primary, session_id)
.map(|p| p.display().to_string());
Ok(json!({
"sessions": sessions,
"recent_commits": recent_commits,
"recent_memory": recent_memory,
"resolved_snapshot": resolved_snapshot,
"watermark_advanced": false,
}))
}
#[allow(clippy::too_many_arguments)]
pub async fn session_context_pause(
state: &Arc<DaemonState>,
project_dir: &str,
session_id: &str,
summary: &str,
completed: Vec<String>,
in_progress: Vec<String>,
next_steps: Vec<String>,
tmux_window: Option<&str>,
prune_worktrees: bool,
) -> Result<Value, String> {
let project_path = PathBuf::from(project_dir);
if !project_path.is_dir() {
return Err(format!(
"project_dir does not exist or is not a directory: {project_dir}"
));
}
if summary.trim().is_empty() {
return Err("`summary` must not be empty".to_string());
}
let input = trusty_common::catchup::pause::PauseSnapshotInput {
session_id,
summary,
completed: &completed,
in_progress: &in_progress,
next_steps: &next_steps,
tmux_window,
};
let outcome = trusty_common::catchup::pause::write_pause_snapshot(&project_path, &input)
.map_err(|e| format!("failed to write pause snapshot: {e}"))?;
let pruned_worktrees: Vec<String> = if prune_worktrees {
let mgr = state.session_manager().await;
let records = mgr.list().await;
let active_workspace_paths: Vec<PathBuf> = records
.iter()
.filter_map(|r| r.workspace_path.clone())
.collect();
let tt_config = crate::core::trusty_tools_config::TrustyToolsConfig::load();
let repos_root = crate::core::trusty_tools_config::workspace_root(&tt_config);
match mgr
.prune_orphaned_worktrees(&repos_root, &active_workspace_paths, false)
.await
{
Ok(outcome) => outcome
.removed
.iter()
.map(|p| p.to_string_lossy().into_owned())
.collect(),
Err(e) => {
tracing::warn!("session_context_pause: worktree prune failed: {e}");
Vec::new()
}
}
} else {
Vec::new()
};
Ok(json!({
"snapshot_path": outcome.snapshot_path.display().to_string(),
"timestamp": outcome.timestamp.to_rfc3339(),
"pruned_worktrees": pruned_worktrees,
}))
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn session_context_catchup_missing_project_dir_errors() {
let err = session_context_catchup("/nonexistent/does/not/exist", None, false, true)
.await
.unwrap_err();
assert!(err.contains("project_dir"), "{err}");
}
#[tokio::test]
async fn session_context_catchup_returns_expected_shape() {
let tmp = tempfile::TempDir::new().unwrap();
let run = |args: &[&str]| {
std::process::Command::new("git")
.arg("-C")
.arg(tmp.path())
.args(args)
.output()
.unwrap();
};
run(&["init"]);
run(&["config", "user.email", "t@t.com"]);
run(&["config", "user.name", "T"]);
std::fs::write(tmp.path().join("a.txt"), b"a").unwrap();
run(&["add", "."]);
run(&["commit", "-m", "init"]);
let result = session_context_catchup(tmp.path().to_str().unwrap(), None, false, true)
.await
.unwrap();
assert_eq!(result["watermark_advanced"], false);
assert!(result["sessions"].is_array());
assert!(result["recent_commits"].is_array());
assert!(result["recent_memory"].is_array());
assert!(result["resolved_snapshot"].is_null());
}
#[tokio::test]
async fn session_context_pause_missing_project_dir_errors() {
let state = DaemonState::shared();
let err = session_context_pause(
&state,
"/nonexistent/does/not/exist",
"s1",
"summary",
vec![],
vec![],
vec![],
None,
false,
)
.await
.unwrap_err();
assert!(err.contains("project_dir"), "{err}");
}
#[tokio::test]
async fn session_context_pause_requires_summary() {
let tmp = tempfile::TempDir::new().unwrap();
let state = DaemonState::shared();
let err = session_context_pause(
&state,
tmp.path().to_str().unwrap(),
"s1",
" ",
vec![],
vec![],
vec![],
None,
false,
)
.await
.unwrap_err();
assert!(err.contains("summary"), "{err}");
}
#[tokio::test]
async fn session_context_pause_writes_snapshot_without_pruning() {
let tmp = tempfile::TempDir::new().unwrap();
let state = DaemonState::shared();
let result = session_context_pause(
&state,
tmp.path().to_str().unwrap(),
"s1",
"Did the thing.",
vec![],
vec![],
vec!["ship it".to_string()],
None,
false,
)
.await
.unwrap();
assert!(result["snapshot_path"].as_str().unwrap().ends_with(".md"));
assert_eq!(result["pruned_worktrees"], json!([]));
let sessions =
crate::core::catchup::session_finder::find_paused_sessions(tmp.path()).unwrap();
assert_eq!(sessions.len(), 1, "the written snapshot should round-trip");
}
}