Initial commit
@@ -0,0 +1,7 @@
|
||||
# Generated by Cargo
|
||||
# will have compiled files and executables
|
||||
/target/
|
||||
|
||||
# Generated by Tauri
|
||||
# will have schema files for capabilities auto-completion
|
||||
/gen/schemas
|
||||
@@ -0,0 +1,59 @@
|
||||
[package]
|
||||
name = "codeg"
|
||||
version = "0.0.13"
|
||||
description = "Agent Code Generation App"
|
||||
authors = ["feitao"]
|
||||
edition = "2021"
|
||||
|
||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||
|
||||
[lib]
|
||||
# The `_lib` suffix may seem redundant but it is necessary
|
||||
# to make the lib name unique and wouldn't conflict with the bin name.
|
||||
# This seems to be only an issue on Windows, see https://github.com/rust-lang/cargo/issues/8519
|
||||
name = "codeg_lib"
|
||||
crate-type = ["staticlib", "cdylib", "rlib"]
|
||||
|
||||
[build-dependencies]
|
||||
tauri-build = { version = "2", features = [] }
|
||||
|
||||
[dependencies]
|
||||
tauri = { version = "2", features = [] }
|
||||
tauri-plugin-opener = "2"
|
||||
tauri-plugin-dialog = "2"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
thiserror = "2"
|
||||
dirs = "6"
|
||||
walkdir = "2"
|
||||
sacp = "11.0.0-alpha.1"
|
||||
sacp-tokio = "11.0.0-alpha.1"
|
||||
tokio = { version = "1", features = ["process", "io-util", "sync", "macros", "rt"] }
|
||||
uuid = { version = "1", features = ["v4"] }
|
||||
futures = "0.3"
|
||||
reqwest = { version = "0.12", features = ["stream"] }
|
||||
flate2 = "1"
|
||||
bzip2 = "0.5"
|
||||
tar = "0.4"
|
||||
zip = "2"
|
||||
regex = "1"
|
||||
portable-pty = "0.8"
|
||||
urlencoding = "2"
|
||||
fix-path-env = { git = "https://github.com/tauri-apps/fix-path-env-rs", version = "0.0.0" }
|
||||
sea-orm = { version = "1.1", features = ["sqlx-sqlite", "runtime-tokio-rustls", "macros"] }
|
||||
sea-orm-migration = { version = "1.1", features = ["sqlx-sqlite", "runtime-tokio-rustls"] }
|
||||
toml = "0.8"
|
||||
notify = "6"
|
||||
|
||||
[target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies]
|
||||
tauri-plugin-window-state = "2"
|
||||
tauri-plugin-updater = "2"
|
||||
tauri-plugin-process = "2"
|
||||
|
||||
|
||||
[target.'cfg(target_os = "windows")'.dependencies]
|
||||
windows-sys = { version = "0.59", features = ["Win32_Storage_FileSystem"] }
|
||||
|
||||
[patch.crates-io]
|
||||
sacp-tokio = { path = "vendor/sacp-tokio" }
|
||||
@@ -0,0 +1,3 @@
|
||||
fn main() {
|
||||
tauri_build::build()
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"$schema": "../gen/schemas/desktop-schema.json",
|
||||
"identifier": "default",
|
||||
"description": "Capability for the main window",
|
||||
"windows": ["welcome", "folder-*", "commit-*", "settings"],
|
||||
"permissions": [
|
||||
"core:default",
|
||||
"core:window:default",
|
||||
"core:window:allow-start-dragging",
|
||||
"core:window:allow-close",
|
||||
"core:window:allow-minimize",
|
||||
"core:window:allow-toggle-maximize",
|
||||
"core:window:allow-unmaximize",
|
||||
"opener:default",
|
||||
{
|
||||
"identifier": "opener:allow-open-path",
|
||||
"allow": [
|
||||
{
|
||||
"path": "$HOME/**"
|
||||
}
|
||||
]
|
||||
},
|
||||
"dialog:default",
|
||||
"updater:default",
|
||||
"process:default"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"identifier": "desktop-capability",
|
||||
"platforms": [
|
||||
"macOS",
|
||||
"windows",
|
||||
"linux"
|
||||
],
|
||||
"windows": [
|
||||
"welcome",
|
||||
"folder-*",
|
||||
"commit-*",
|
||||
"settings"
|
||||
],
|
||||
"permissions": [
|
||||
"window-state:default"
|
||||
]
|
||||
}
|
||||
|
After Width: | Height: | Size: 3.4 KiB |
|
After Width: | Height: | Size: 6.8 KiB |
|
After Width: | Height: | Size: 974 B |
|
After Width: | Height: | Size: 2.8 KiB |
|
After Width: | Height: | Size: 3.8 KiB |
|
After Width: | Height: | Size: 3.9 KiB |
|
After Width: | Height: | Size: 7.6 KiB |
|
After Width: | Height: | Size: 903 B |
|
After Width: | Height: | Size: 8.4 KiB |
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 85 KiB |
|
After Width: | Height: | Size: 14 KiB |
@@ -0,0 +1,417 @@
|
||||
use std::collections::HashSet;
|
||||
use std::io::Read;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use crate::acp::error::AcpError;
|
||||
use crate::acp::registry;
|
||||
use crate::models::agent::AgentType;
|
||||
|
||||
pub(crate) fn cache_dir() -> Result<PathBuf, AcpError> {
|
||||
let base = dirs::cache_dir()
|
||||
.ok_or_else(|| AcpError::DownloadFailed("cannot determine cache directory".into()))?;
|
||||
Ok(base.join("app.codeg").join("acp-binaries"))
|
||||
}
|
||||
|
||||
fn normalize_version_label(version: &str) -> String {
|
||||
let trimmed = version.trim();
|
||||
if let Some(stripped) = trimmed
|
||||
.strip_prefix('v')
|
||||
.or_else(|| trimmed.strip_prefix('V'))
|
||||
{
|
||||
stripped.trim().to_string()
|
||||
} else {
|
||||
trimmed.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn agent_cache_key(agent_type: AgentType) -> String {
|
||||
registry::registry_id_for(agent_type).to_string()
|
||||
}
|
||||
|
||||
pub(crate) fn binary_dir(agent_id: &str, version: &str) -> Result<PathBuf, AcpError> {
|
||||
let version = normalize_version_label(version);
|
||||
if version.is_empty() {
|
||||
return Err(AcpError::DownloadFailed(
|
||||
"binary version is empty".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
Ok(cache_dir()?
|
||||
.join(agent_id)
|
||||
.join(version)
|
||||
.join(registry::current_platform()))
|
||||
}
|
||||
|
||||
pub fn clear_agent_cache(agent_type: AgentType) -> Result<(), AcpError> {
|
||||
let agent_id = agent_cache_key(agent_type);
|
||||
let dir = cache_dir()?.join(agent_id);
|
||||
if dir.exists() {
|
||||
std::fs::remove_dir_all(&dir)
|
||||
.map_err(|e| AcpError::DownloadFailed(format!("failed to clear cache: {e}")))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn installed_binary_path(agent_id: &str, version: &str, cmd_name: &str) -> Option<PathBuf> {
|
||||
let bin_name = if cfg!(target_os = "windows") {
|
||||
format!("{cmd_name}.exe")
|
||||
} else {
|
||||
cmd_name.to_string()
|
||||
};
|
||||
|
||||
let normalized = normalize_version_label(version);
|
||||
if normalized.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let path = cache_dir()
|
||||
.ok()?
|
||||
.join(agent_id)
|
||||
.join(normalized)
|
||||
.join(registry::current_platform())
|
||||
.join(bin_name);
|
||||
|
||||
if !path.exists() {
|
||||
return None;
|
||||
}
|
||||
if is_binary_file_compatible(path.as_path()) {
|
||||
return Some(path);
|
||||
}
|
||||
let _ = std::fs::remove_file(path);
|
||||
None
|
||||
}
|
||||
|
||||
fn installed_version_labels(agent_id: &str, cmd_name: &str) -> Result<Vec<String>, AcpError> {
|
||||
let root = cache_dir()?.join(agent_id);
|
||||
if !root.exists() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
let mut versions = Vec::new();
|
||||
let mut seen = HashSet::new();
|
||||
let entries = std::fs::read_dir(&root)
|
||||
.map_err(|e| AcpError::DownloadFailed(format!("failed to read cache dir: {e}")))?;
|
||||
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
if !path.is_dir() {
|
||||
continue;
|
||||
}
|
||||
let raw_version = entry.file_name().to_string_lossy().to_string();
|
||||
let normalized = normalize_version_label(&raw_version);
|
||||
if normalized.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
if installed_binary_path(agent_id, &normalized, cmd_name).is_some()
|
||||
&& seen.insert(normalized.clone())
|
||||
{
|
||||
versions.push(normalized);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(versions)
|
||||
}
|
||||
|
||||
fn installed_version_for_agent(
|
||||
agent_type: AgentType,
|
||||
cmd_name: &str,
|
||||
) -> Result<Option<String>, AcpError> {
|
||||
let agent_id = agent_cache_key(agent_type);
|
||||
let mut versions = installed_version_labels(&agent_id, cmd_name)?;
|
||||
if versions.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
versions.sort_by(|a, b| version_cmp(a, b));
|
||||
Ok(versions.pop())
|
||||
}
|
||||
|
||||
pub fn detect_installed_version(
|
||||
agent_type: AgentType,
|
||||
cmd_name: &str,
|
||||
) -> Result<Option<String>, AcpError> {
|
||||
installed_version_for_agent(agent_type, cmd_name)
|
||||
}
|
||||
|
||||
fn version_cmp(a: &str, b: &str) -> std::cmp::Ordering {
|
||||
let mut a_parts = parse_version_parts(a);
|
||||
let mut b_parts = parse_version_parts(b);
|
||||
let len = a_parts.len().max(b_parts.len());
|
||||
a_parts.resize(len, 0);
|
||||
b_parts.resize(len, 0);
|
||||
|
||||
for i in 0..len {
|
||||
match a_parts[i].cmp(&b_parts[i]) {
|
||||
std::cmp::Ordering::Equal => continue,
|
||||
order => return order,
|
||||
}
|
||||
}
|
||||
a.cmp(b)
|
||||
}
|
||||
|
||||
fn parse_version_parts(input: &str) -> Vec<u32> {
|
||||
input
|
||||
.trim_start_matches(|c: char| !c.is_ascii_digit())
|
||||
.split('.')
|
||||
.map(|part| {
|
||||
let numeric: String = part.chars().take_while(|c| c.is_ascii_digit()).collect();
|
||||
numeric.parse::<u32>().unwrap_or(0)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Ensure a binary agent is available locally.
|
||||
/// Returns the absolute path to the executable.
|
||||
pub async fn ensure_binary_for_agent(
|
||||
agent_type: AgentType,
|
||||
version: &str,
|
||||
archive_url: &str,
|
||||
cmd_name: &str,
|
||||
) -> Result<PathBuf, AcpError> {
|
||||
if let Some(path) = find_cached_binary_for_agent(agent_type, version, cmd_name)? {
|
||||
return Ok(path);
|
||||
}
|
||||
|
||||
let agent_id = agent_cache_key(agent_type);
|
||||
ensure_binary(&agent_id, version, archive_url, cmd_name).await
|
||||
}
|
||||
|
||||
/// Ensure a binary is available for a specific cache key.
|
||||
/// Returns the absolute path to the executable.
|
||||
pub async fn ensure_binary(
|
||||
agent_id: &str,
|
||||
version: &str,
|
||||
archive_url: &str,
|
||||
cmd_name: &str,
|
||||
) -> Result<PathBuf, AcpError> {
|
||||
if let Some(path) = find_cached_binary(agent_id, version, cmd_name)? {
|
||||
return Ok(path);
|
||||
}
|
||||
|
||||
let dir = binary_dir(agent_id, version)?;
|
||||
let bin_name = if cfg!(target_os = "windows") {
|
||||
format!("{cmd_name}.exe")
|
||||
} else {
|
||||
cmd_name.to_string()
|
||||
};
|
||||
|
||||
// Download and extract
|
||||
std::fs::create_dir_all(&dir)
|
||||
.map_err(|e| AcpError::DownloadFailed(format!("failed to create cache dir: {e}")))?;
|
||||
|
||||
let tmp_dir = dir.join(".tmp");
|
||||
if tmp_dir.exists() {
|
||||
let _ = std::fs::remove_dir_all(&tmp_dir);
|
||||
}
|
||||
std::fs::create_dir_all(&tmp_dir)
|
||||
.map_err(|e| AcpError::DownloadFailed(format!("failed to create tmp dir: {e}")))?;
|
||||
|
||||
let result: Result<PathBuf, AcpError> = async {
|
||||
let archive_path = tmp_dir.join("archive");
|
||||
download_file(archive_url, &archive_path).await?;
|
||||
|
||||
let extract_dir = tmp_dir.join("extracted");
|
||||
std::fs::create_dir_all(&extract_dir)
|
||||
.map_err(|e| AcpError::DownloadFailed(format!("failed to create extract dir: {e}")))?;
|
||||
|
||||
if archive_url.ends_with(".tar.gz") || archive_url.ends_with(".tgz") {
|
||||
extract_tar_gz(&archive_path, &extract_dir)?;
|
||||
} else if archive_url.ends_with(".tar.bz2") || archive_url.ends_with(".tbz2") {
|
||||
extract_tar_bz2(&archive_path, &extract_dir)?;
|
||||
} else if archive_url.ends_with(".zip") {
|
||||
extract_zip(&archive_path, &extract_dir)?;
|
||||
} else {
|
||||
return Err(AcpError::DownloadFailed(format!(
|
||||
"unsupported archive format: {archive_url}"
|
||||
)));
|
||||
}
|
||||
|
||||
// Find the binary in extracted files and move to final location.
|
||||
let extracted_bin = find_binary_recursive(&extract_dir, &bin_name).ok_or_else(|| {
|
||||
AcpError::DownloadFailed(format!("binary '{bin_name}' not found in archive"))
|
||||
})?;
|
||||
|
||||
let final_path = dir.join(&bin_name);
|
||||
std::fs::copy(&extracted_bin, &final_path)
|
||||
.map_err(|e| AcpError::DownloadFailed(format!("failed to copy binary: {e}")))?;
|
||||
|
||||
if !is_binary_file_compatible(&final_path) {
|
||||
let _ = std::fs::remove_file(&final_path);
|
||||
return Err(AcpError::DownloadFailed(
|
||||
"downloaded binary format is invalid for current platform".into(),
|
||||
));
|
||||
}
|
||||
set_executable_permissions(&final_path)?;
|
||||
Ok(final_path)
|
||||
}
|
||||
.await;
|
||||
|
||||
// Always clean up temp extraction artifacts.
|
||||
let _ = std::fs::remove_dir_all(&tmp_dir);
|
||||
if result.is_err() {
|
||||
// Avoid leaving empty version/platform directories on failed downloads.
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
pub(crate) fn find_cached_binary(
|
||||
agent_id: &str,
|
||||
version: &str,
|
||||
cmd_name: &str,
|
||||
) -> Result<Option<PathBuf>, AcpError> {
|
||||
Ok(installed_binary_path(agent_id, version, cmd_name))
|
||||
}
|
||||
|
||||
pub(crate) fn find_cached_binary_for_agent(
|
||||
agent_type: AgentType,
|
||||
version: &str,
|
||||
cmd_name: &str,
|
||||
) -> Result<Option<PathBuf>, AcpError> {
|
||||
let agent_id = agent_cache_key(agent_type);
|
||||
find_cached_binary(&agent_id, version, cmd_name)
|
||||
}
|
||||
|
||||
pub(crate) fn find_binary_recursive(dir: &PathBuf, name: &str) -> Option<PathBuf> {
|
||||
if !dir.exists() {
|
||||
return None;
|
||||
}
|
||||
for entry in walkdir::WalkDir::new(dir).into_iter().flatten() {
|
||||
if entry.file_type().is_file() && entry.file_name().to_string_lossy() == name {
|
||||
return Some(entry.into_path());
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
async fn download_file(url: &str, dest: &PathBuf) -> Result<(), AcpError> {
|
||||
let response = reqwest::Client::new()
|
||||
.get(url)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| AcpError::DownloadFailed(format!("HTTP request failed: {e}")))?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
return Err(AcpError::DownloadFailed(format!(
|
||||
"HTTP {} for {url}",
|
||||
response.status()
|
||||
)));
|
||||
}
|
||||
|
||||
let bytes = response
|
||||
.bytes()
|
||||
.await
|
||||
.map_err(|e| AcpError::DownloadFailed(format!("failed to read response: {e}")))?;
|
||||
|
||||
std::fs::write(dest, &bytes)
|
||||
.map_err(|e| AcpError::DownloadFailed(format!("failed to write archive: {e}")))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn extract_tar_gz(archive: &PathBuf, dest: &PathBuf) -> Result<(), AcpError> {
|
||||
let file = std::fs::File::open(archive)
|
||||
.map_err(|e| AcpError::DownloadFailed(format!("failed to open archive: {e}")))?;
|
||||
let gz = flate2::read::GzDecoder::new(file);
|
||||
let mut tar = tar::Archive::new(gz);
|
||||
tar.unpack(dest)
|
||||
.map_err(|e| AcpError::DownloadFailed(format!("failed to extract tar.gz: {e}")))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn extract_tar_bz2(archive: &PathBuf, dest: &PathBuf) -> Result<(), AcpError> {
|
||||
let file = std::fs::File::open(archive)
|
||||
.map_err(|e| AcpError::DownloadFailed(format!("failed to open archive: {e}")))?;
|
||||
let bz = bzip2::read::BzDecoder::new(file);
|
||||
let mut tar = tar::Archive::new(bz);
|
||||
tar.unpack(dest)
|
||||
.map_err(|e| AcpError::DownloadFailed(format!("failed to extract tar.bz2: {e}")))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn extract_zip(archive: &PathBuf, dest: &PathBuf) -> Result<(), AcpError> {
|
||||
let file = std::fs::File::open(archive)
|
||||
.map_err(|e| AcpError::DownloadFailed(format!("failed to open archive: {e}")))?;
|
||||
let mut zip = zip::ZipArchive::new(file)
|
||||
.map_err(|e| AcpError::DownloadFailed(format!("failed to read zip: {e}")))?;
|
||||
zip.extract(dest)
|
||||
.map_err(|e| AcpError::DownloadFailed(format!("failed to extract zip: {e}")))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn set_executable_permissions(path: &Path) -> Result<(), AcpError> {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let mut perms = std::fs::metadata(path)
|
||||
.map_err(|e| AcpError::DownloadFailed(e.to_string()))?
|
||||
.permissions();
|
||||
perms.set_mode(0o755);
|
||||
std::fs::set_permissions(path, perms).map_err(|e| AcpError::DownloadFailed(e.to_string()))
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
{
|
||||
let _ = path;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn is_binary_file_compatible(path: &Path) -> bool {
|
||||
let mut file = match std::fs::File::open(path) {
|
||||
Ok(f) => f,
|
||||
Err(_) => return false,
|
||||
};
|
||||
let mut header = [0_u8; 4];
|
||||
if file.read_exact(&mut header).is_err() {
|
||||
return false;
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
matches!(
|
||||
header,
|
||||
[0xFE, 0xED, 0xFA, 0xCE]
|
||||
| [0xCE, 0xFA, 0xED, 0xFE]
|
||||
| [0xFE, 0xED, 0xFA, 0xCF]
|
||||
| [0xCF, 0xFA, 0xED, 0xFE]
|
||||
| [0xCA, 0xFE, 0xBA, 0xBE]
|
||||
| [0xBE, 0xBA, 0xFE, 0xCA]
|
||||
| [0xCA, 0xFE, 0xBA, 0xBF]
|
||||
| [0xBF, 0xBA, 0xFE, 0xCA]
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
header == [0x7F, b'E', b'L', b'F']
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
header[0] == b'M' && header[1] == b'Z'
|
||||
}
|
||||
|
||||
#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
|
||||
{
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn cache_key_uses_registry_id() {
|
||||
assert_eq!(agent_cache_key(AgentType::OpenCode), "opencode");
|
||||
assert_eq!(agent_cache_key(AgentType::Codex), "codex-acp");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn version_normalization_is_consistent() {
|
||||
assert_eq!(normalize_version_label("v1.2.15"), "1.2.15");
|
||||
assert_eq!(normalize_version_label("V0.9.4 "), "0.9.4");
|
||||
assert_eq!(normalize_version_label("1.25.1"), "1.25.1");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
use serde::Serialize;
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum AcpError {
|
||||
#[error("agent process failed to spawn: {0}")]
|
||||
SpawnFailed(String),
|
||||
#[error("connection not found: {0}")]
|
||||
ConnectionNotFound(String),
|
||||
#[error("ACP protocol error: {0}")]
|
||||
Protocol(String),
|
||||
#[error("agent process exited unexpectedly")]
|
||||
ProcessExited,
|
||||
#[allow(dead_code)]
|
||||
#[error("conversation error: {0}")]
|
||||
Conversation(String),
|
||||
#[error("binary download failed: {0}")]
|
||||
DownloadFailed(String),
|
||||
#[allow(dead_code)]
|
||||
#[error("agent not found: {0}")]
|
||||
AgentNotFound(String),
|
||||
#[error("platform not supported: {0}")]
|
||||
PlatformNotSupported(String),
|
||||
}
|
||||
|
||||
impl AcpError {
|
||||
pub fn protocol(raw: impl Into<String>) -> Self {
|
||||
let raw = raw.into();
|
||||
let sanitized = sanitize_protocol_message(&raw);
|
||||
|
||||
if is_executable_format_error(&sanitized) {
|
||||
return Self::Protocol(
|
||||
"Agent executable appears incompatible or corrupted. Please retry to re-download it."
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
|
||||
Self::Protocol(sanitized)
|
||||
}
|
||||
}
|
||||
|
||||
impl Serialize for AcpError {
|
||||
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
|
||||
where
|
||||
S: serde::Serializer,
|
||||
{
|
||||
serializer.serialize_str(&self.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
fn sanitize_protocol_message(raw: &str) -> String {
|
||||
let without_spawned_at = regex::Regex::new(r#"\s*,?\s*"spawned_at"\s*:\s*"[^"]*"\s*,?"#)
|
||||
.ok()
|
||||
.map(|re| re.replace_all(raw, "").into_owned())
|
||||
.unwrap_or_else(|| raw.to_string());
|
||||
|
||||
let without_dangling_comma = regex::Regex::new(r#",\s*([}\]])"#)
|
||||
.ok()
|
||||
.map(|re| re.replace_all(&without_spawned_at, "$1").into_owned())
|
||||
.unwrap_or(without_spawned_at);
|
||||
|
||||
regex::Regex::new(r#"/(?:Users|home)/[^"\s]+"#)
|
||||
.ok()
|
||||
.map(|re| {
|
||||
re.replace_all(&without_dangling_comma, "<local-path>")
|
||||
.into_owned()
|
||||
})
|
||||
.unwrap_or(without_dangling_comma)
|
||||
}
|
||||
|
||||
fn is_executable_format_error(message: &str) -> bool {
|
||||
let lowered = message.to_lowercase();
|
||||
lowered.contains("malformed mach-o file")
|
||||
|| lowered.contains("exec format error")
|
||||
|| lowered.contains("bad cpu type in executable")
|
||||
|| lowered.contains("not a valid win32 application")
|
||||
|| lowered.contains("is not a valid application for this os platform")
|
||||
}
|
||||
@@ -0,0 +1,512 @@
|
||||
use std::fs::{File, OpenOptions};
|
||||
use std::io::{BufRead, BufReader, ErrorKind, Write};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use sacp::schema::{
|
||||
ReadTextFileRequest, ReadTextFileResponse, WriteTextFileRequest, WriteTextFileResponse,
|
||||
};
|
||||
use tokio::sync::Semaphore;
|
||||
|
||||
const FS_MAX_CONCURRENT_OPS: usize = 8;
|
||||
const FS_IO_TIMEOUT: Duration = Duration::from_secs(30);
|
||||
const FS_MAX_FILE_SIZE_BYTES: u64 = 16 * 1024 * 1024;
|
||||
const FS_MAX_READ_RESPONSE_BYTES: usize = 2 * 1024 * 1024;
|
||||
const FS_MAX_WRITE_BYTES: usize = 2 * 1024 * 1024;
|
||||
const FS_SLOW_OPERATION_MS: u128 = 200;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum FileSystemRuntimeError {
|
||||
InvalidParams(String),
|
||||
Internal(String),
|
||||
}
|
||||
|
||||
impl FileSystemRuntimeError {
|
||||
pub fn to_rpc_error(self) -> sacp::Error {
|
||||
match self {
|
||||
Self::InvalidParams(message) => sacp::Error::invalid_params().data(message),
|
||||
Self::Internal(message) => sacp::util::internal_error(message),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct FileSystemRuntime {
|
||||
workspace_root: PathBuf,
|
||||
workspace_root_canonical: Option<PathBuf>,
|
||||
io_semaphore: Arc<Semaphore>,
|
||||
}
|
||||
|
||||
impl FileSystemRuntime {
|
||||
pub fn new(workspace_root: PathBuf) -> Self {
|
||||
let workspace_root = if workspace_root.is_absolute() {
|
||||
workspace_root
|
||||
} else {
|
||||
std::env::current_dir()
|
||||
.unwrap_or_default()
|
||||
.join(workspace_root)
|
||||
};
|
||||
let workspace_root_canonical = std::fs::canonicalize(&workspace_root).ok();
|
||||
|
||||
Self {
|
||||
workspace_root,
|
||||
workspace_root_canonical,
|
||||
io_semaphore: Arc::new(Semaphore::new(FS_MAX_CONCURRENT_OPS)),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn read_text_file(
|
||||
&self,
|
||||
request: ReadTextFileRequest,
|
||||
) -> Result<ReadTextFileResponse, FileSystemRuntimeError> {
|
||||
if !request.path.is_absolute() {
|
||||
return Err(FileSystemRuntimeError::InvalidParams(
|
||||
"fs/read_text_file requires an absolute path".to_string(),
|
||||
));
|
||||
}
|
||||
if matches!(request.line, Some(0)) {
|
||||
return Err(FileSystemRuntimeError::InvalidParams(
|
||||
"fs/read_text_file line must be >= 1".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let _permit = self
|
||||
.io_semaphore
|
||||
.clone()
|
||||
.acquire_owned()
|
||||
.await
|
||||
.map_err(|_| {
|
||||
FileSystemRuntimeError::Internal("filesystem runtime closed".to_string())
|
||||
})?;
|
||||
|
||||
let workspace_root = self.workspace_root.clone();
|
||||
let workspace_root_canonical = self.workspace_root_canonical.clone();
|
||||
let path = request.path;
|
||||
let line = request.line;
|
||||
let limit = request.limit;
|
||||
let started_at = Instant::now();
|
||||
let path_for_log = path.clone();
|
||||
|
||||
let response = run_blocking_with_timeout("fs/read_text_file", move || {
|
||||
read_text_file_impl(
|
||||
&path,
|
||||
line,
|
||||
limit,
|
||||
&workspace_root,
|
||||
workspace_root_canonical.as_deref(),
|
||||
)
|
||||
.map(ReadTextFileResponse::new)
|
||||
})
|
||||
.await;
|
||||
|
||||
log_if_slow("fs/read_text_file", &path_for_log, started_at);
|
||||
response
|
||||
}
|
||||
|
||||
pub async fn write_text_file(
|
||||
&self,
|
||||
request: WriteTextFileRequest,
|
||||
) -> Result<WriteTextFileResponse, FileSystemRuntimeError> {
|
||||
if !request.path.is_absolute() {
|
||||
return Err(FileSystemRuntimeError::InvalidParams(
|
||||
"fs/write_text_file requires an absolute path".to_string(),
|
||||
));
|
||||
}
|
||||
if request.content.len() > FS_MAX_WRITE_BYTES {
|
||||
return Err(FileSystemRuntimeError::InvalidParams(format!(
|
||||
"write payload too large ({} bytes, limit {} bytes)",
|
||||
request.content.len(),
|
||||
FS_MAX_WRITE_BYTES
|
||||
)));
|
||||
}
|
||||
|
||||
let _permit = self
|
||||
.io_semaphore
|
||||
.clone()
|
||||
.acquire_owned()
|
||||
.await
|
||||
.map_err(|_| {
|
||||
FileSystemRuntimeError::Internal("filesystem runtime closed".to_string())
|
||||
})?;
|
||||
|
||||
let workspace_root = self.workspace_root.clone();
|
||||
let workspace_root_canonical = self.workspace_root_canonical.clone();
|
||||
let path = request.path;
|
||||
let content = request.content;
|
||||
let started_at = Instant::now();
|
||||
let path_for_log = path.clone();
|
||||
|
||||
let response = run_blocking_with_timeout("fs/write_text_file", move || {
|
||||
ensure_path_in_workspace(
|
||||
&path,
|
||||
&workspace_root,
|
||||
workspace_root_canonical.as_deref(),
|
||||
true,
|
||||
)?;
|
||||
atomic_write_text(&path, content.as_bytes())?;
|
||||
Ok(WriteTextFileResponse::new())
|
||||
})
|
||||
.await;
|
||||
|
||||
log_if_slow("fs/write_text_file", &path_for_log, started_at);
|
||||
response
|
||||
}
|
||||
}
|
||||
|
||||
async fn run_blocking_with_timeout<T, F>(
|
||||
operation: &'static str,
|
||||
f: F,
|
||||
) -> Result<T, FileSystemRuntimeError>
|
||||
where
|
||||
T: Send + 'static,
|
||||
F: FnOnce() -> Result<T, FileSystemRuntimeError> + Send + 'static,
|
||||
{
|
||||
let task = tokio::task::spawn_blocking(f);
|
||||
let join_result = tokio::time::timeout(FS_IO_TIMEOUT, task)
|
||||
.await
|
||||
.map_err(|_| {
|
||||
FileSystemRuntimeError::Internal(format!(
|
||||
"{operation} timed out after {}s",
|
||||
FS_IO_TIMEOUT.as_secs()
|
||||
))
|
||||
})?;
|
||||
|
||||
let op_result = join_result.map_err(|err| {
|
||||
FileSystemRuntimeError::Internal(format!("{operation} worker failed: {err}"))
|
||||
})?;
|
||||
|
||||
op_result
|
||||
}
|
||||
|
||||
fn read_text_file_impl(
|
||||
path: &Path,
|
||||
line: Option<u32>,
|
||||
limit: Option<u32>,
|
||||
workspace_root: &Path,
|
||||
workspace_root_canonical: Option<&Path>,
|
||||
) -> Result<String, FileSystemRuntimeError> {
|
||||
ensure_path_in_workspace(path, workspace_root, workspace_root_canonical, false)?;
|
||||
|
||||
let metadata = std::fs::metadata(path).map_err(|err| map_io_error("read", path, err))?;
|
||||
if metadata.len() > FS_MAX_FILE_SIZE_BYTES {
|
||||
return Err(FileSystemRuntimeError::InvalidParams(format!(
|
||||
"file too large for fs/read_text_file ({} bytes, limit {} bytes)",
|
||||
metadata.len(),
|
||||
FS_MAX_FILE_SIZE_BYTES
|
||||
)));
|
||||
}
|
||||
|
||||
let file = File::open(path).map_err(|err| map_io_error("read", path, err))?;
|
||||
let mut reader = BufReader::new(file);
|
||||
|
||||
let start_line = usize::try_from(line.unwrap_or(1)).unwrap_or(usize::MAX);
|
||||
let max_lines = limit.map(|v| usize::try_from(v).unwrap_or(usize::MAX));
|
||||
|
||||
let mut current_line = 1usize;
|
||||
let mut taken = 0usize;
|
||||
let mut out = String::with_capacity(
|
||||
usize::try_from(metadata.len())
|
||||
.unwrap_or(FS_MAX_READ_RESPONSE_BYTES)
|
||||
.min(FS_MAX_READ_RESPONSE_BYTES),
|
||||
);
|
||||
let mut line_buf = String::new();
|
||||
|
||||
loop {
|
||||
line_buf.clear();
|
||||
let bytes_read = reader
|
||||
.read_line(&mut line_buf)
|
||||
.map_err(|err| map_io_error("read", path, err))?;
|
||||
if bytes_read == 0 {
|
||||
break;
|
||||
}
|
||||
|
||||
if current_line >= start_line {
|
||||
if let Some(max) = max_lines {
|
||||
if taken >= max {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if out.len().saturating_add(line_buf.len()) > FS_MAX_READ_RESPONSE_BYTES {
|
||||
return Err(FileSystemRuntimeError::InvalidParams(format!(
|
||||
"read result too large (limit {} bytes). Narrow with line/limit.",
|
||||
FS_MAX_READ_RESPONSE_BYTES
|
||||
)));
|
||||
}
|
||||
|
||||
out.push_str(&line_buf);
|
||||
taken += 1;
|
||||
}
|
||||
|
||||
current_line = current_line.saturating_add(1);
|
||||
}
|
||||
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn atomic_write_text(path: &Path, bytes: &[u8]) -> Result<(), FileSystemRuntimeError> {
|
||||
let parent = path.parent().ok_or_else(|| {
|
||||
FileSystemRuntimeError::InvalidParams(format!(
|
||||
"cannot determine parent directory for path: {}",
|
||||
path.display()
|
||||
))
|
||||
})?;
|
||||
|
||||
if !parent.exists() {
|
||||
return Err(FileSystemRuntimeError::InvalidParams(format!(
|
||||
"parent directory does not exist: {}",
|
||||
parent.display()
|
||||
)));
|
||||
}
|
||||
|
||||
let temp_path = parent.join(format!(
|
||||
".codeg-fs-{}.{}.tmp",
|
||||
std::process::id(),
|
||||
uuid::Uuid::new_v4().simple()
|
||||
));
|
||||
|
||||
let existing_permissions = std::fs::metadata(path).ok().map(|m| m.permissions());
|
||||
|
||||
let write_result = (|| {
|
||||
let mut tmp = OpenOptions::new()
|
||||
.create_new(true)
|
||||
.write(true)
|
||||
.open(&temp_path)
|
||||
.map_err(|err| map_io_error("create temporary file", &temp_path, err))?;
|
||||
|
||||
tmp.write_all(bytes)
|
||||
.map_err(|err| map_io_error("write", &temp_path, err))?;
|
||||
tmp.sync_all()
|
||||
.map_err(|err| map_io_error("flush", &temp_path, err))?;
|
||||
|
||||
if let Some(permissions) = existing_permissions {
|
||||
std::fs::set_permissions(&temp_path, permissions)
|
||||
.map_err(|err| map_io_error("set permissions", &temp_path, err))?;
|
||||
}
|
||||
|
||||
replace_file(&temp_path, path)?;
|
||||
sync_directory(parent)?;
|
||||
|
||||
Ok(())
|
||||
})();
|
||||
|
||||
if write_result.is_err() {
|
||||
let _ = std::fs::remove_file(&temp_path);
|
||||
}
|
||||
|
||||
write_result
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn replace_file(temp_path: &Path, target_path: &Path) -> Result<(), FileSystemRuntimeError> {
|
||||
std::fs::rename(temp_path, target_path).map_err(|err| map_io_error("replace", target_path, err))
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn replace_file(temp_path: &Path, target_path: &Path) -> Result<(), FileSystemRuntimeError> {
|
||||
use std::os::windows::ffi::OsStrExt;
|
||||
|
||||
use windows_sys::Win32::Storage::FileSystem::{
|
||||
MoveFileExW, MOVEFILE_REPLACE_EXISTING, MOVEFILE_WRITE_THROUGH,
|
||||
};
|
||||
|
||||
fn to_wide(path: &Path) -> Vec<u16> {
|
||||
path.as_os_str()
|
||||
.encode_wide()
|
||||
.chain(std::iter::once(0))
|
||||
.collect()
|
||||
}
|
||||
|
||||
let src = to_wide(temp_path);
|
||||
let dst = to_wide(target_path);
|
||||
|
||||
// SAFETY: pointers are valid, null-terminated UTF-16 buffers alive for the call.
|
||||
let ok = unsafe {
|
||||
MoveFileExW(
|
||||
src.as_ptr(),
|
||||
dst.as_ptr(),
|
||||
MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH,
|
||||
)
|
||||
};
|
||||
|
||||
if ok == 0 {
|
||||
let err = std::io::Error::last_os_error();
|
||||
return Err(map_io_error("atomically replace", target_path, err));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(not(any(unix, target_os = "windows")))]
|
||||
fn replace_file(temp_path: &Path, target_path: &Path) -> Result<(), FileSystemRuntimeError> {
|
||||
std::fs::rename(temp_path, target_path).map_err(|err| map_io_error("replace", target_path, err))
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn sync_directory(path: &Path) -> Result<(), FileSystemRuntimeError> {
|
||||
let dir = File::open(path).map_err(|err| map_io_error("sync directory", path, err))?;
|
||||
dir.sync_all()
|
||||
.map_err(|err| map_io_error("sync directory", path, err))
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
fn sync_directory(_path: &Path) -> Result<(), FileSystemRuntimeError> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn canonical_workspace_root(workspace_root: &Path, canonical: Option<&Path>) -> PathBuf {
|
||||
canonical
|
||||
.map(Path::to_path_buf)
|
||||
.or_else(|| std::fs::canonicalize(workspace_root).ok())
|
||||
.unwrap_or_else(|| workspace_root.to_path_buf())
|
||||
}
|
||||
|
||||
fn ensure_path_in_workspace(
|
||||
path: &Path,
|
||||
workspace_root: &Path,
|
||||
workspace_root_canonical: Option<&Path>,
|
||||
for_write: bool,
|
||||
) -> Result<(), FileSystemRuntimeError> {
|
||||
let root = canonical_workspace_root(workspace_root, workspace_root_canonical);
|
||||
let target = canonical_target_path(path, for_write)?;
|
||||
|
||||
if !target.starts_with(&root) {
|
||||
return Err(FileSystemRuntimeError::InvalidParams(format!(
|
||||
"path is outside workspace root: {}",
|
||||
path.display()
|
||||
)));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn canonical_target_path(path: &Path, for_write: bool) -> Result<PathBuf, FileSystemRuntimeError> {
|
||||
if !for_write || path.exists() {
|
||||
return std::fs::canonicalize(path).map_err(|err| map_io_error("access", path, err));
|
||||
}
|
||||
|
||||
let parent = path.parent().ok_or_else(|| {
|
||||
FileSystemRuntimeError::InvalidParams(format!(
|
||||
"cannot determine parent directory for path: {}",
|
||||
path.display()
|
||||
))
|
||||
})?;
|
||||
|
||||
if !parent.exists() {
|
||||
return Err(FileSystemRuntimeError::InvalidParams(format!(
|
||||
"parent directory does not exist: {}",
|
||||
parent.display()
|
||||
)));
|
||||
}
|
||||
|
||||
std::fs::canonicalize(parent).map_err(|err| map_io_error("access", parent, err))
|
||||
}
|
||||
|
||||
fn map_io_error(action: &str, path: &Path, err: std::io::Error) -> FileSystemRuntimeError {
|
||||
match err.kind() {
|
||||
ErrorKind::NotFound
|
||||
| ErrorKind::PermissionDenied
|
||||
| ErrorKind::InvalidInput
|
||||
| ErrorKind::InvalidData => FileSystemRuntimeError::InvalidParams(format!(
|
||||
"failed to {action} {}: {err}",
|
||||
path.display()
|
||||
)),
|
||||
_ => FileSystemRuntimeError::Internal(format!(
|
||||
"failed to {action} {}: {err}",
|
||||
path.display()
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
fn log_if_slow(operation: &str, path: &Path, started_at: Instant) {
|
||||
let elapsed = started_at.elapsed();
|
||||
if elapsed.as_millis() >= FS_SLOW_OPERATION_MS {
|
||||
eprintln!(
|
||||
"[ACP] {operation} slow path={} elapsed_ms={}",
|
||||
path.display(),
|
||||
elapsed.as_millis()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::fs;
|
||||
|
||||
fn temp_workspace() -> PathBuf {
|
||||
let path = std::env::temp_dir().join(format!("codeg-fs-test-{}", uuid::Uuid::new_v4()));
|
||||
fs::create_dir_all(&path).expect("create test workspace");
|
||||
path
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn read_honors_line_and_limit() {
|
||||
let workspace = temp_workspace();
|
||||
let file = workspace.join("sample.txt");
|
||||
fs::write(&file, "a\nb\nc\nd\n").expect("write file");
|
||||
|
||||
let runtime = FileSystemRuntime::new(workspace.clone());
|
||||
let response = runtime
|
||||
.read_text_file(ReadTextFileRequest::new("sid", &file).line(2).limit(2))
|
||||
.await
|
||||
.expect("read file");
|
||||
|
||||
assert_eq!(response.content, "b\nc\n");
|
||||
|
||||
let _ = fs::remove_dir_all(workspace);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn rejects_path_outside_workspace() {
|
||||
let workspace = temp_workspace();
|
||||
let outside = std::env::temp_dir().join(format!("outside-{}.txt", uuid::Uuid::new_v4()));
|
||||
fs::write(&outside, "x").expect("write outside file");
|
||||
|
||||
let runtime = FileSystemRuntime::new(workspace.clone());
|
||||
let error = runtime
|
||||
.read_text_file(ReadTextFileRequest::new("sid", &outside))
|
||||
.await
|
||||
.expect_err("should reject outside path");
|
||||
|
||||
match error {
|
||||
FileSystemRuntimeError::InvalidParams(message) => {
|
||||
assert!(message.contains("outside workspace"));
|
||||
}
|
||||
other => panic!("unexpected error: {other:?}"),
|
||||
}
|
||||
|
||||
let _ = fs::remove_file(outside);
|
||||
let _ = fs::remove_dir_all(workspace);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn write_replaces_existing_content() {
|
||||
let workspace = temp_workspace();
|
||||
let file = workspace.join("target.txt");
|
||||
fs::write(&file, "old").expect("write old content");
|
||||
|
||||
let runtime = FileSystemRuntime::new(workspace.clone());
|
||||
runtime
|
||||
.write_text_file(WriteTextFileRequest::new("sid", &file, "new-content"))
|
||||
.await
|
||||
.expect("write file");
|
||||
|
||||
let content = fs::read_to_string(&file).expect("read file");
|
||||
assert_eq!(content, "new-content");
|
||||
|
||||
let leaked_tmp = fs::read_dir(&workspace)
|
||||
.expect("read workspace")
|
||||
.filter_map(Result::ok)
|
||||
.any(|entry| {
|
||||
entry
|
||||
.file_name()
|
||||
.to_string_lossy()
|
||||
.starts_with(".codeg-fs-")
|
||||
});
|
||||
assert!(!leaked_tmp, "temporary file should be cleaned up");
|
||||
|
||||
let _ = fs::remove_dir_all(workspace);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
use std::collections::BTreeMap;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
use crate::acp::connection::{spawn_agent_connection, AgentConnection, ConnectionCommand};
|
||||
use crate::acp::error::AcpError;
|
||||
use crate::acp::types::{ConnectionInfo, PromptInputBlock};
|
||||
use crate::models::agent::AgentType;
|
||||
|
||||
pub struct ConnectionManager {
|
||||
connections: Arc<Mutex<HashMap<String, AgentConnection>>>,
|
||||
}
|
||||
|
||||
impl ConnectionManager {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
connections: Arc::new(Mutex::new(HashMap::new())),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn spawn_agent(
|
||||
&self,
|
||||
agent_type: AgentType,
|
||||
working_dir: Option<String>,
|
||||
session_id: Option<String>,
|
||||
runtime_env: BTreeMap<String, String>,
|
||||
owner_window_label: String,
|
||||
app_handle: tauri::AppHandle,
|
||||
) -> Result<String, AcpError> {
|
||||
let connection_id = uuid::Uuid::new_v4().to_string();
|
||||
eprintln!(
|
||||
"[ACP] spawning connection id={} owner_window={} agent={:?}",
|
||||
connection_id, owner_window_label, agent_type
|
||||
);
|
||||
|
||||
let conn = spawn_agent_connection(
|
||||
connection_id.clone(),
|
||||
agent_type,
|
||||
working_dir,
|
||||
session_id,
|
||||
runtime_env,
|
||||
owner_window_label,
|
||||
app_handle,
|
||||
)
|
||||
.await?;
|
||||
|
||||
self.connections
|
||||
.lock()
|
||||
.await
|
||||
.insert(connection_id.clone(), conn);
|
||||
|
||||
Ok(connection_id)
|
||||
}
|
||||
|
||||
pub async fn send_prompt(
|
||||
&self,
|
||||
conn_id: &str,
|
||||
blocks: Vec<PromptInputBlock>,
|
||||
) -> Result<(), AcpError> {
|
||||
let cmd_tx = {
|
||||
let connections = self.connections.lock().await;
|
||||
let conn = connections
|
||||
.get(conn_id)
|
||||
.ok_or_else(|| AcpError::ConnectionNotFound(conn_id.into()))?;
|
||||
conn.cmd_tx.clone()
|
||||
};
|
||||
cmd_tx
|
||||
.send(ConnectionCommand::Prompt { blocks })
|
||||
.await
|
||||
.map_err(|_| AcpError::ProcessExited)
|
||||
}
|
||||
|
||||
pub async fn set_mode(&self, conn_id: &str, mode_id: String) -> Result<(), AcpError> {
|
||||
let cmd_tx = {
|
||||
let connections = self.connections.lock().await;
|
||||
let conn = connections
|
||||
.get(conn_id)
|
||||
.ok_or_else(|| AcpError::ConnectionNotFound(conn_id.into()))?;
|
||||
conn.cmd_tx.clone()
|
||||
};
|
||||
cmd_tx
|
||||
.send(ConnectionCommand::SetMode { mode_id })
|
||||
.await
|
||||
.map_err(|_| AcpError::ProcessExited)
|
||||
}
|
||||
|
||||
pub async fn set_config_option(
|
||||
&self,
|
||||
conn_id: &str,
|
||||
config_id: String,
|
||||
value_id: String,
|
||||
) -> Result<(), AcpError> {
|
||||
let cmd_tx = {
|
||||
let connections = self.connections.lock().await;
|
||||
let conn = connections
|
||||
.get(conn_id)
|
||||
.ok_or_else(|| AcpError::ConnectionNotFound(conn_id.into()))?;
|
||||
conn.cmd_tx.clone()
|
||||
};
|
||||
cmd_tx
|
||||
.send(ConnectionCommand::SetConfigOption {
|
||||
config_id,
|
||||
value_id,
|
||||
})
|
||||
.await
|
||||
.map_err(|_| AcpError::ProcessExited)
|
||||
}
|
||||
|
||||
pub async fn cancel(&self, conn_id: &str) -> Result<(), AcpError> {
|
||||
let cmd_tx = {
|
||||
let connections = self.connections.lock().await;
|
||||
let conn = connections
|
||||
.get(conn_id)
|
||||
.ok_or_else(|| AcpError::ConnectionNotFound(conn_id.into()))?;
|
||||
conn.cmd_tx.clone()
|
||||
};
|
||||
cmd_tx
|
||||
.send(ConnectionCommand::Cancel)
|
||||
.await
|
||||
.map_err(|_| AcpError::ProcessExited)
|
||||
}
|
||||
|
||||
pub async fn respond_permission(
|
||||
&self,
|
||||
conn_id: &str,
|
||||
request_id: &str,
|
||||
option_id: &str,
|
||||
) -> Result<(), AcpError> {
|
||||
let cmd_tx = {
|
||||
let connections = self.connections.lock().await;
|
||||
let conn = connections
|
||||
.get(conn_id)
|
||||
.ok_or_else(|| AcpError::ConnectionNotFound(conn_id.into()))?;
|
||||
conn.cmd_tx.clone()
|
||||
};
|
||||
cmd_tx
|
||||
.send(ConnectionCommand::RespondPermission {
|
||||
request_id: request_id.into(),
|
||||
option_id: option_id.into(),
|
||||
})
|
||||
.await
|
||||
.map_err(|_| AcpError::ProcessExited)
|
||||
}
|
||||
|
||||
pub async fn disconnect(&self, conn_id: &str) -> Result<(), AcpError> {
|
||||
let cmd_tx = {
|
||||
let mut connections = self.connections.lock().await;
|
||||
connections.remove(conn_id).map(|conn| conn.cmd_tx)
|
||||
};
|
||||
if let Some(cmd_tx) = cmd_tx {
|
||||
let _ = cmd_tx.send(ConnectionCommand::Disconnect).await;
|
||||
Ok(())
|
||||
} else {
|
||||
Err(AcpError::ConnectionNotFound(conn_id.into()))
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn disconnect_by_owner_window(&self, owner_window_label: &str) -> usize {
|
||||
let cmd_txs = {
|
||||
let mut connections = self.connections.lock().await;
|
||||
let ids: Vec<String> = connections
|
||||
.iter()
|
||||
.filter_map(|(id, conn)| {
|
||||
if conn.owner_window_label == owner_window_label {
|
||||
Some(id.clone())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
let mut txs = Vec::with_capacity(ids.len());
|
||||
for id in ids {
|
||||
if let Some(conn) = connections.remove(&id) {
|
||||
txs.push(conn.cmd_tx);
|
||||
}
|
||||
}
|
||||
txs
|
||||
};
|
||||
|
||||
let disconnected = cmd_txs.len();
|
||||
for cmd_tx in cmd_txs {
|
||||
let _ = cmd_tx.send(ConnectionCommand::Disconnect).await;
|
||||
}
|
||||
eprintln!(
|
||||
"[ACP] disconnect by owner window owner_window={} count={}",
|
||||
owner_window_label, disconnected
|
||||
);
|
||||
disconnected
|
||||
}
|
||||
|
||||
pub async fn list_connections(&self) -> Vec<ConnectionInfo> {
|
||||
let connections = self.connections.lock().await;
|
||||
connections.values().map(|c| c.info()).collect()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
pub mod binary_cache;
|
||||
pub mod connection;
|
||||
pub mod error;
|
||||
pub mod file_system_runtime;
|
||||
pub mod manager;
|
||||
pub mod preflight;
|
||||
pub mod registry;
|
||||
pub mod terminal_runtime;
|
||||
pub mod types;
|
||||
@@ -0,0 +1,400 @@
|
||||
use serde::Serialize;
|
||||
use std::sync::Mutex;
|
||||
|
||||
use crate::acp::binary_cache;
|
||||
use crate::acp::registry::{self, AgentDistribution};
|
||||
use crate::models::agent::AgentType;
|
||||
|
||||
/// Cache for NPX environment check results.
|
||||
/// Stores `Some(checks)` after a successful (all-pass) run;
|
||||
/// stays `None` if checks failed so they are retried next time.
|
||||
static NPX_ENV_CACHE: Mutex<Option<Vec<CheckItem>>> = Mutex::new(None);
|
||||
/// Cache for UVX environment check results.
|
||||
/// Stores `Some(checks)` after a successful (all-pass) run;
|
||||
/// stays `None` if checks failed so they are retried next time.
|
||||
static UVX_ENV_CACHE: Mutex<Option<Vec<CheckItem>>> = Mutex::new(None);
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
#[allow(dead_code)]
|
||||
pub enum FixActionKind {
|
||||
OpenUrl,
|
||||
RedownloadBinary,
|
||||
RetryConnection,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct FixAction {
|
||||
pub label: String,
|
||||
pub kind: FixActionKind,
|
||||
pub payload: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum CheckStatus {
|
||||
Pass,
|
||||
Fail,
|
||||
Warn,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct CheckItem {
|
||||
pub check_id: String,
|
||||
pub label: String,
|
||||
pub status: CheckStatus,
|
||||
pub message: String,
|
||||
pub fixes: Vec<FixAction>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct PreflightResult {
|
||||
pub agent_type: AgentType,
|
||||
pub agent_name: String,
|
||||
pub passed: bool,
|
||||
pub checks: Vec<CheckItem>,
|
||||
}
|
||||
|
||||
pub async fn run_preflight(agent_type: AgentType) -> PreflightResult {
|
||||
let meta = registry::get_agent_meta(agent_type);
|
||||
let checks = match &meta.distribution {
|
||||
AgentDistribution::Npx { node_required, .. } => check_npx_environment(*node_required).await,
|
||||
AgentDistribution::Uvx { .. } => check_uvx_environment().await,
|
||||
AgentDistribution::Binary {
|
||||
version,
|
||||
cmd,
|
||||
platforms,
|
||||
..
|
||||
} => check_binary_environment(agent_type, version, cmd, platforms).await,
|
||||
};
|
||||
|
||||
let passed = checks
|
||||
.iter()
|
||||
.all(|c| !matches!(c.status, CheckStatus::Fail));
|
||||
|
||||
PreflightResult {
|
||||
agent_type,
|
||||
agent_name: meta.name.to_string(),
|
||||
passed,
|
||||
checks,
|
||||
}
|
||||
}
|
||||
|
||||
async fn check_npx_environment(node_required: Option<&str>) -> Vec<CheckItem> {
|
||||
// Return cached result if a previous check passed.
|
||||
// The cache stores only the base checks (node_available + npx_available);
|
||||
// the per-agent node_version check is appended separately.
|
||||
let cached = NPX_ENV_CACHE.lock().unwrap().clone();
|
||||
if let Some(cached) = cached {
|
||||
let mut checks = cached;
|
||||
if let Some(required) = node_required {
|
||||
// Extract node version string from the cached node_available message
|
||||
// (format: "Node.js v20.19.0 available")
|
||||
let node_ver = extract_node_version_from_message(&checks[0].message);
|
||||
checks.push(build_node_version_check(node_ver.as_deref(), required));
|
||||
}
|
||||
return checks;
|
||||
}
|
||||
|
||||
// Run node and npx checks in parallel
|
||||
let (node_result, npx_result) = tokio::join!(
|
||||
crate::process::tokio_command("node")
|
||||
.arg("--version")
|
||||
.output(),
|
||||
crate::process::tokio_command("npx")
|
||||
.arg("--version")
|
||||
.output(),
|
||||
);
|
||||
|
||||
// Track the raw node version string for reuse in the version check
|
||||
let mut node_version_str: Option<String> = None;
|
||||
|
||||
let node_check = match node_result {
|
||||
Ok(output) if output.status.success() => {
|
||||
let version = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
||||
node_version_str = Some(version.clone());
|
||||
CheckItem {
|
||||
check_id: "node_available".into(),
|
||||
label: "Node.js".into(),
|
||||
status: CheckStatus::Pass,
|
||||
message: format!("Node.js {version} available"),
|
||||
fixes: vec![],
|
||||
}
|
||||
}
|
||||
_ => CheckItem {
|
||||
check_id: "node_available".into(),
|
||||
label: "Node.js".into(),
|
||||
status: CheckStatus::Fail,
|
||||
message: "Node.js is not installed or not in PATH".into(),
|
||||
fixes: vec![FixAction {
|
||||
label: "Install Node.js".into(),
|
||||
kind: FixActionKind::OpenUrl,
|
||||
payload: "https://nodejs.org/".into(),
|
||||
}],
|
||||
},
|
||||
};
|
||||
|
||||
let npx_check = match npx_result {
|
||||
Ok(output) if output.status.success() => {
|
||||
let version = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
||||
CheckItem {
|
||||
check_id: "npx_available".into(),
|
||||
label: "npx".into(),
|
||||
status: CheckStatus::Pass,
|
||||
message: format!("npx {version} available"),
|
||||
fixes: vec![],
|
||||
}
|
||||
}
|
||||
_ => CheckItem {
|
||||
check_id: "npx_available".into(),
|
||||
label: "npx".into(),
|
||||
status: CheckStatus::Fail,
|
||||
message: "npx is not installed or not in PATH".into(),
|
||||
fixes: vec![FixAction {
|
||||
label: "Install Node.js".into(),
|
||||
kind: FixActionKind::OpenUrl,
|
||||
payload: "https://nodejs.org/".into(),
|
||||
}],
|
||||
},
|
||||
};
|
||||
|
||||
let mut checks = vec![node_check, npx_check];
|
||||
|
||||
// Cache only if all checks passed — failed results are not cached so
|
||||
// the user can retry after installing the missing tools.
|
||||
let all_passed = checks
|
||||
.iter()
|
||||
.all(|c| !matches!(c.status, CheckStatus::Fail));
|
||||
if all_passed {
|
||||
*NPX_ENV_CACHE.lock().unwrap() = Some(checks.clone());
|
||||
}
|
||||
|
||||
// After caching the base checks, append the per-agent Node.js version
|
||||
// requirement if specified. Only meaningful when node is available.
|
||||
if let Some(required) = node_required {
|
||||
if all_passed {
|
||||
checks.push(build_node_version_check(
|
||||
node_version_str.as_deref(),
|
||||
required,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
checks
|
||||
}
|
||||
|
||||
/// Parse a Node.js version string like "v20.19.0" or "20.19.0" into (major, minor, patch).
|
||||
/// Handles pre-release suffixes such as "v22.0.0-nightly" by stripping non-numeric tails.
|
||||
fn parse_node_version(v: &str) -> Option<(u32, u32, u32)> {
|
||||
let v = v.trim().trim_start_matches('v');
|
||||
let mut parts = v.splitn(3, '.');
|
||||
let major = parts.next()?.parse().ok()?;
|
||||
let minor = parts.next()?.parse().ok()?;
|
||||
let patch_str = parts.next()?;
|
||||
// Strip pre-release/build suffixes: "0-nightly" → "0", "3+build" → "3"
|
||||
let patch_digits: String = patch_str
|
||||
.chars()
|
||||
.take_while(|c| c.is_ascii_digit())
|
||||
.collect();
|
||||
let patch = patch_digits.parse().ok()?;
|
||||
Some((major, minor, patch))
|
||||
}
|
||||
|
||||
/// Extract the node version string from a cached node_available message.
|
||||
/// Expected format: "Node.js v20.19.0 available" → Some("v20.19.0")
|
||||
fn extract_node_version_from_message(message: &str) -> Option<String> {
|
||||
message
|
||||
.split_whitespace()
|
||||
.find(|s| s.starts_with('v') && s.contains('.'))
|
||||
.map(|s| s.to_string())
|
||||
}
|
||||
|
||||
/// Build a `CheckItem` for the Node.js version requirement check.
|
||||
/// `current_version` is the raw output from `node --version` (e.g. "v20.19.0").
|
||||
fn build_node_version_check(current_version: Option<&str>, required: &str) -> CheckItem {
|
||||
let current_version = match current_version {
|
||||
Some(v) => v,
|
||||
None => {
|
||||
return CheckItem {
|
||||
check_id: "node_version".into(),
|
||||
label: "Node.js version".into(),
|
||||
status: CheckStatus::Fail,
|
||||
message: "Cannot determine Node.js version".into(),
|
||||
fixes: vec![],
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
let current = parse_node_version(current_version);
|
||||
let required_parsed = parse_node_version(required);
|
||||
|
||||
match (current, required_parsed) {
|
||||
(Some(cur), Some(req)) if cur >= req => CheckItem {
|
||||
check_id: "node_version".into(),
|
||||
label: "Node.js version".into(),
|
||||
status: CheckStatus::Pass,
|
||||
message: format!(
|
||||
"Node.js {current_version} meets the minimum requirement (>={required})"
|
||||
),
|
||||
fixes: vec![],
|
||||
},
|
||||
(Some(_), Some(_)) => CheckItem {
|
||||
check_id: "node_version".into(),
|
||||
label: "Node.js version".into(),
|
||||
status: CheckStatus::Fail,
|
||||
message: format!(
|
||||
"Node.js {current_version} is too old — this package requires Node.js >={required}"
|
||||
),
|
||||
fixes: vec![FixAction {
|
||||
label: "Update Node.js".into(),
|
||||
kind: FixActionKind::OpenUrl,
|
||||
payload: "https://nodejs.org/".into(),
|
||||
}],
|
||||
},
|
||||
_ => CheckItem {
|
||||
check_id: "node_version".into(),
|
||||
label: "Node.js version".into(),
|
||||
status: CheckStatus::Warn,
|
||||
message: format!("Cannot parse Node.js version; required >={required}"),
|
||||
fixes: vec![],
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
async fn check_uvx_environment() -> Vec<CheckItem> {
|
||||
// Return cached result if a previous check passed
|
||||
let cached = UVX_ENV_CACHE.lock().unwrap().clone();
|
||||
if let Some(cached) = cached {
|
||||
return cached;
|
||||
}
|
||||
|
||||
// Run uv and uvx checks in parallel
|
||||
let (uv_result, uvx_result) = tokio::join!(
|
||||
crate::process::tokio_command("uv")
|
||||
.arg("--version")
|
||||
.output(),
|
||||
crate::process::tokio_command("uvx")
|
||||
.arg("--version")
|
||||
.output(),
|
||||
);
|
||||
|
||||
let install_fix = vec![FixAction {
|
||||
label: "Install uv".into(),
|
||||
kind: FixActionKind::OpenUrl,
|
||||
payload: "https://docs.astral.sh/uv/getting-started/installation/".into(),
|
||||
}];
|
||||
|
||||
let uv_check = match uv_result {
|
||||
Ok(output) if output.status.success() => {
|
||||
let version = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
||||
CheckItem {
|
||||
check_id: "uv_available".into(),
|
||||
label: "uv".into(),
|
||||
status: CheckStatus::Pass,
|
||||
message: format!("uv {version} available"),
|
||||
fixes: vec![],
|
||||
}
|
||||
}
|
||||
_ => CheckItem {
|
||||
check_id: "uv_available".into(),
|
||||
label: "uv".into(),
|
||||
status: CheckStatus::Fail,
|
||||
message: "uv is not installed or not in PATH".into(),
|
||||
fixes: install_fix.clone(),
|
||||
},
|
||||
};
|
||||
|
||||
let uvx_check = match uvx_result {
|
||||
Ok(output) if output.status.success() => {
|
||||
let version = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
||||
CheckItem {
|
||||
check_id: "uvx_available".into(),
|
||||
label: "uvx".into(),
|
||||
status: CheckStatus::Pass,
|
||||
message: format!("uvx {version} available"),
|
||||
fixes: vec![],
|
||||
}
|
||||
}
|
||||
_ => CheckItem {
|
||||
check_id: "uvx_available".into(),
|
||||
label: "uvx".into(),
|
||||
status: CheckStatus::Fail,
|
||||
message: "uvx is not installed or not in PATH".into(),
|
||||
fixes: install_fix,
|
||||
},
|
||||
};
|
||||
|
||||
let checks = vec![uv_check, uvx_check];
|
||||
|
||||
let all_passed = checks
|
||||
.iter()
|
||||
.all(|c| !matches!(c.status, CheckStatus::Fail));
|
||||
if all_passed {
|
||||
*UVX_ENV_CACHE.lock().unwrap() = Some(checks.clone());
|
||||
}
|
||||
|
||||
checks
|
||||
}
|
||||
|
||||
async fn check_binary_environment(
|
||||
agent_type: AgentType,
|
||||
version: &str,
|
||||
cmd: &str,
|
||||
platforms: &[registry::PlatformBinary],
|
||||
) -> Vec<CheckItem> {
|
||||
let mut checks = Vec::new();
|
||||
|
||||
// Check platform support
|
||||
let current = registry::current_platform();
|
||||
let platform_supported = platforms.iter().any(|p| p.platform == current);
|
||||
|
||||
let platform_check = if platform_supported {
|
||||
CheckItem {
|
||||
check_id: "platform_supported".into(),
|
||||
label: "Platform".into(),
|
||||
status: CheckStatus::Pass,
|
||||
message: format!("Platform {current} is supported"),
|
||||
fixes: vec![],
|
||||
}
|
||||
} else {
|
||||
CheckItem {
|
||||
check_id: "platform_supported".into(),
|
||||
label: "Platform".into(),
|
||||
status: CheckStatus::Fail,
|
||||
message: format!("Platform {current} is not supported"),
|
||||
fixes: vec![],
|
||||
}
|
||||
};
|
||||
checks.push(platform_check);
|
||||
|
||||
// Check binary cache
|
||||
if platform_supported {
|
||||
let cache_check = match binary_cache::find_cached_binary_for_agent(agent_type, version, cmd)
|
||||
{
|
||||
Ok(Some(_)) => CheckItem {
|
||||
check_id: "binary_cached".into(),
|
||||
label: "Binary cache".into(),
|
||||
status: CheckStatus::Pass,
|
||||
message: "Binary is cached locally".into(),
|
||||
fixes: vec![],
|
||||
},
|
||||
Ok(None) => CheckItem {
|
||||
check_id: "binary_cached".into(),
|
||||
label: "Binary cache".into(),
|
||||
status: CheckStatus::Warn,
|
||||
message: "Binary not cached yet, will be downloaded on first connection".into(),
|
||||
fixes: vec![],
|
||||
},
|
||||
Err(_) => CheckItem {
|
||||
check_id: "binary_cached".into(),
|
||||
label: "Binary cache".into(),
|
||||
status: CheckStatus::Warn,
|
||||
message: "Cannot determine binary cache path".into(),
|
||||
fixes: vec![],
|
||||
},
|
||||
};
|
||||
checks.push(cache_check);
|
||||
}
|
||||
|
||||
checks
|
||||
}
|
||||
@@ -0,0 +1,542 @@
|
||||
use crate::models::agent::AgentType;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum AgentDistribution {
|
||||
Npx {
|
||||
version: &'static str,
|
||||
package: &'static str,
|
||||
args: &'static [&'static str],
|
||||
env: &'static [(&'static str, &'static str)],
|
||||
/// Minimum Node.js version required, e.g. "22.12.0". None means no specific requirement.
|
||||
node_required: Option<&'static str>,
|
||||
},
|
||||
Uvx {
|
||||
version: &'static str,
|
||||
package: &'static str,
|
||||
args: &'static [&'static str],
|
||||
env: &'static [(&'static str, &'static str)],
|
||||
},
|
||||
Binary {
|
||||
version: &'static str,
|
||||
cmd: &'static str,
|
||||
args: &'static [&'static str],
|
||||
env: &'static [(&'static str, &'static str)],
|
||||
platforms: &'static [PlatformBinary],
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PlatformBinary {
|
||||
pub platform: &'static str,
|
||||
pub url: &'static str,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AcpAgentMeta {
|
||||
#[allow(dead_code)]
|
||||
pub agent_type: AgentType,
|
||||
pub name: &'static str,
|
||||
pub description: &'static str,
|
||||
pub distribution: AgentDistribution,
|
||||
}
|
||||
|
||||
impl AcpAgentMeta {
|
||||
pub fn registry_version(&self) -> Option<&'static str> {
|
||||
match &self.distribution {
|
||||
AgentDistribution::Npx { version, .. }
|
||||
| AgentDistribution::Uvx { version, .. }
|
||||
| AgentDistribution::Binary { version, .. } => Some(*version),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn current_platform() -> &'static str {
|
||||
#[cfg(all(target_os = "macos", target_arch = "aarch64"))]
|
||||
{
|
||||
"darwin-aarch64"
|
||||
}
|
||||
#[cfg(all(target_os = "macos", target_arch = "x86_64"))]
|
||||
{
|
||||
"darwin-x86_64"
|
||||
}
|
||||
#[cfg(all(target_os = "linux", target_arch = "aarch64"))]
|
||||
{
|
||||
"linux-aarch64"
|
||||
}
|
||||
#[cfg(all(target_os = "linux", target_arch = "x86_64"))]
|
||||
{
|
||||
"linux-x86_64"
|
||||
}
|
||||
#[cfg(all(target_os = "windows", target_arch = "aarch64"))]
|
||||
{
|
||||
"windows-aarch64"
|
||||
}
|
||||
#[cfg(all(target_os = "windows", target_arch = "x86_64"))]
|
||||
{
|
||||
"windows-x86_64"
|
||||
}
|
||||
}
|
||||
|
||||
pub fn all_acp_agents() -> Vec<AgentType> {
|
||||
vec![
|
||||
AgentType::Auggie,
|
||||
AgentType::Autohand,
|
||||
AgentType::ClaudeCode,
|
||||
AgentType::Cline,
|
||||
AgentType::CodebuddyCode,
|
||||
AgentType::Codex,
|
||||
AgentType::CorustAgent,
|
||||
AgentType::FactoryDroid,
|
||||
AgentType::Gemini,
|
||||
AgentType::GithubCopilot,
|
||||
AgentType::Goose,
|
||||
AgentType::Junie,
|
||||
AgentType::Kimi,
|
||||
AgentType::MinionCode,
|
||||
AgentType::MistralVibe,
|
||||
AgentType::OpenClaw,
|
||||
AgentType::OpenCode,
|
||||
AgentType::Qoder,
|
||||
AgentType::QwenCode,
|
||||
AgentType::Stakpak,
|
||||
]
|
||||
}
|
||||
|
||||
pub fn registry_id_for(agent_type: AgentType) -> &'static str {
|
||||
match agent_type {
|
||||
AgentType::Auggie => "auggie",
|
||||
AgentType::Autohand => "autohand",
|
||||
AgentType::ClaudeCode => "claude-acp",
|
||||
AgentType::Cline => "cline",
|
||||
AgentType::CodebuddyCode => "codebuddy-code",
|
||||
AgentType::Codex => "codex-acp",
|
||||
AgentType::CorustAgent => "corust-agent",
|
||||
AgentType::FactoryDroid => "factory-droid",
|
||||
AgentType::Gemini => "gemini",
|
||||
AgentType::GithubCopilot => "github-copilot",
|
||||
AgentType::Goose => "goose",
|
||||
AgentType::Junie => "junie-acp",
|
||||
AgentType::Kimi => "kimi",
|
||||
AgentType::MinionCode => "minion-code",
|
||||
AgentType::MistralVibe => "mistral-vibe",
|
||||
AgentType::OpenClaw => "openclaw-acp",
|
||||
AgentType::OpenCode => "opencode",
|
||||
AgentType::Qoder => "qoder",
|
||||
AgentType::QwenCode => "qwen-code",
|
||||
AgentType::Stakpak => "stakpak",
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub fn from_registry_id(id: &str) -> Option<AgentType> {
|
||||
match id {
|
||||
"auggie" => Some(AgentType::Auggie),
|
||||
"autohand" => Some(AgentType::Autohand),
|
||||
"claude-acp" => Some(AgentType::ClaudeCode),
|
||||
"cline" => Some(AgentType::Cline),
|
||||
"codebuddy-code" => Some(AgentType::CodebuddyCode),
|
||||
"codex-acp" => Some(AgentType::Codex),
|
||||
"corust-agent" => Some(AgentType::CorustAgent),
|
||||
"factory-droid" => Some(AgentType::FactoryDroid),
|
||||
"gemini" => Some(AgentType::Gemini),
|
||||
"github-copilot" => Some(AgentType::GithubCopilot),
|
||||
"goose" => Some(AgentType::Goose),
|
||||
"junie-acp" => Some(AgentType::Junie),
|
||||
"kimi" => Some(AgentType::Kimi),
|
||||
"minion-code" => Some(AgentType::MinionCode),
|
||||
"mistral-vibe" => Some(AgentType::MistralVibe),
|
||||
"openclaw-acp" => Some(AgentType::OpenClaw),
|
||||
"opencode" => Some(AgentType::OpenCode),
|
||||
"qoder" => Some(AgentType::Qoder),
|
||||
"qwen-code" => Some(AgentType::QwenCode),
|
||||
"stakpak" => Some(AgentType::Stakpak),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_agent_meta(agent_type: AgentType) -> AcpAgentMeta {
|
||||
match agent_type {
|
||||
AgentType::Auggie => AcpAgentMeta {
|
||||
agent_type,
|
||||
name: "Auggie CLI",
|
||||
description: "Augment Code's powerful software agent, backed by industry-leading context engine",
|
||||
distribution: AgentDistribution::Npx {
|
||||
version: "0.17.0",
|
||||
package: "@augmentcode/auggie@0.17.0",
|
||||
args: &["--acp"],
|
||||
env: &[("AUGMENT_DISABLE_AUTO_UPDATE", "1")],
|
||||
node_required: None,
|
||||
},
|
||||
},
|
||||
AgentType::Autohand => AcpAgentMeta {
|
||||
agent_type,
|
||||
name: "Autohand Code",
|
||||
description: "Autohand Code - AI coding agent powered by Autohand AI",
|
||||
distribution: AgentDistribution::Npx {
|
||||
version: "0.2.1",
|
||||
package: "@autohandai/autohand-acp@0.2.1",
|
||||
args: &[],
|
||||
env: &[],
|
||||
node_required: None,
|
||||
},
|
||||
},
|
||||
AgentType::ClaudeCode => AcpAgentMeta {
|
||||
agent_type,
|
||||
name: "Claude Code",
|
||||
description: "ACP wrapper for Anthropic's Claude",
|
||||
distribution: AgentDistribution::Npx {
|
||||
version: "0.18.0",
|
||||
package: "@zed-industries/claude-agent-acp@0.18.0",
|
||||
args: &[],
|
||||
env: &[],
|
||||
node_required: None,
|
||||
},
|
||||
},
|
||||
AgentType::Cline => AcpAgentMeta {
|
||||
agent_type,
|
||||
name: "Cline",
|
||||
description: "Autonomous coding agent CLI - capable of creating/editing files, running commands, using the browser, and more",
|
||||
distribution: AgentDistribution::Npx {
|
||||
version: "2.5.0",
|
||||
package: "cline@2.5.0",
|
||||
args: &["--acp"],
|
||||
env: &[],
|
||||
node_required: None,
|
||||
},
|
||||
},
|
||||
AgentType::CodebuddyCode => AcpAgentMeta {
|
||||
agent_type,
|
||||
name: "Codebuddy Code",
|
||||
description: "Tencent Cloud's official intelligent coding tool",
|
||||
distribution: AgentDistribution::Npx {
|
||||
version: "2.51.2",
|
||||
package: "@tencent-ai/codebuddy-code@2.51.2",
|
||||
args: &["--acp"],
|
||||
env: &[],
|
||||
node_required: None,
|
||||
},
|
||||
},
|
||||
AgentType::Codex => AcpAgentMeta {
|
||||
agent_type,
|
||||
name: "Codex CLI",
|
||||
description: "ACP adapter for OpenAI's coding assistant",
|
||||
distribution: AgentDistribution::Binary {
|
||||
version: "0.9.4",
|
||||
cmd: "codex-acp",
|
||||
args: &[],
|
||||
env: &[],
|
||||
platforms: &[
|
||||
PlatformBinary {
|
||||
platform: "darwin-aarch64",
|
||||
url: "https://github.com/zed-industries/codex-acp/releases/download/v0.9.4/codex-acp-0.9.4-aarch64-apple-darwin.tar.gz",
|
||||
},
|
||||
PlatformBinary {
|
||||
platform: "darwin-x86_64",
|
||||
url: "https://github.com/zed-industries/codex-acp/releases/download/v0.9.4/codex-acp-0.9.4-x86_64-apple-darwin.tar.gz",
|
||||
},
|
||||
PlatformBinary {
|
||||
platform: "linux-aarch64",
|
||||
url: "https://github.com/zed-industries/codex-acp/releases/download/v0.9.4/codex-acp-0.9.4-aarch64-unknown-linux-gnu.tar.gz",
|
||||
},
|
||||
PlatformBinary {
|
||||
platform: "linux-x86_64",
|
||||
url: "https://github.com/zed-industries/codex-acp/releases/download/v0.9.4/codex-acp-0.9.4-x86_64-unknown-linux-gnu.tar.gz",
|
||||
},
|
||||
PlatformBinary {
|
||||
platform: "windows-aarch64",
|
||||
url: "https://github.com/zed-industries/codex-acp/releases/download/v0.9.4/codex-acp-0.9.4-aarch64-pc-windows-msvc.zip",
|
||||
},
|
||||
PlatformBinary {
|
||||
platform: "windows-x86_64",
|
||||
url: "https://github.com/zed-industries/codex-acp/releases/download/v0.9.4/codex-acp-0.9.4-x86_64-pc-windows-msvc.zip",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
AgentType::CorustAgent => AcpAgentMeta {
|
||||
agent_type,
|
||||
name: "Corust Agent",
|
||||
description: "Co-building with a seasoned Rust partner.",
|
||||
distribution: AgentDistribution::Binary {
|
||||
version: "0.3.4",
|
||||
cmd: "corust-agent-acp",
|
||||
args: &[],
|
||||
env: &[],
|
||||
platforms: &[
|
||||
PlatformBinary {
|
||||
platform: "darwin-aarch64",
|
||||
url: "https://github.com/Corust-ai/corust-agent-release/releases/download/v0.3.4/agent-darwin-arm64.tar.gz",
|
||||
},
|
||||
PlatformBinary {
|
||||
platform: "linux-x86_64",
|
||||
url: "https://github.com/Corust-ai/corust-agent-release/releases/download/v0.3.4/agent-linux-x64.tar.gz",
|
||||
},
|
||||
PlatformBinary {
|
||||
platform: "windows-x86_64",
|
||||
url: "https://github.com/Corust-ai/corust-agent-release/releases/download/v0.3.4/agent-windows-x64.zip",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
AgentType::FactoryDroid => AcpAgentMeta {
|
||||
agent_type,
|
||||
name: "Factory Droid",
|
||||
description: "Factory Droid - AI coding agent powered by Factory AI",
|
||||
distribution: AgentDistribution::Npx {
|
||||
version: "0.63.0",
|
||||
package: "droid@0.63.0",
|
||||
args: &["exec", "--output-format", "acp"],
|
||||
env: &[("DROID_DISABLE_AUTO_UPDATE", "true"), ("FACTORY_DROID_AUTO_UPDATE_ENABLED", "false")],
|
||||
node_required: None,
|
||||
},
|
||||
},
|
||||
AgentType::Gemini => AcpAgentMeta {
|
||||
agent_type,
|
||||
name: "Gemini CLI",
|
||||
description: "Google's official CLI for Gemini",
|
||||
distribution: AgentDistribution::Npx {
|
||||
version: "0.30.0",
|
||||
package: "@google/gemini-cli@0.30.0",
|
||||
args: &["--experimental-acp"],
|
||||
env: &[],
|
||||
node_required: None,
|
||||
},
|
||||
},
|
||||
AgentType::GithubCopilot => AcpAgentMeta {
|
||||
agent_type,
|
||||
name: "GitHub Copilot",
|
||||
description: "GitHub's AI pair programmer",
|
||||
distribution: AgentDistribution::Npx {
|
||||
version: "1.432.0",
|
||||
package: "@github/copilot-language-server@1.432.0",
|
||||
args: &["--acp"],
|
||||
env: &[],
|
||||
node_required: None,
|
||||
},
|
||||
},
|
||||
AgentType::Goose => AcpAgentMeta {
|
||||
agent_type,
|
||||
name: "goose",
|
||||
description: "A local, extensible, open source AI agent that automates engineering tasks",
|
||||
distribution: AgentDistribution::Binary {
|
||||
version: "1.25.1",
|
||||
cmd: "goose",
|
||||
args: &[],
|
||||
env: &[],
|
||||
platforms: &[
|
||||
PlatformBinary {
|
||||
platform: "darwin-aarch64",
|
||||
url: "https://github.com/block/goose/releases/download/v1.25.1/goose-aarch64-apple-darwin.tar.bz2",
|
||||
},
|
||||
PlatformBinary {
|
||||
platform: "darwin-x86_64",
|
||||
url: "https://github.com/block/goose/releases/download/v1.25.1/goose-x86_64-apple-darwin.tar.bz2",
|
||||
},
|
||||
PlatformBinary {
|
||||
platform: "linux-aarch64",
|
||||
url: "https://github.com/block/goose/releases/download/v1.25.1/goose-aarch64-unknown-linux-gnu.tar.bz2",
|
||||
},
|
||||
PlatformBinary {
|
||||
platform: "linux-x86_64",
|
||||
url: "https://github.com/block/goose/releases/download/v1.25.1/goose-x86_64-unknown-linux-gnu.tar.bz2",
|
||||
},
|
||||
PlatformBinary {
|
||||
platform: "windows-x86_64",
|
||||
url: "https://github.com/block/goose/releases/download/v1.25.1/goose-x86_64-pc-windows-msvc.zip",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
AgentType::Junie => AcpAgentMeta {
|
||||
agent_type,
|
||||
name: "Junie",
|
||||
description: "AI Coding Agent by JetBrains",
|
||||
distribution: AgentDistribution::Npx {
|
||||
version: "849.19.0",
|
||||
package: "@jetbrains/junie-cli@849.19.0",
|
||||
args: &["--acp=true"],
|
||||
env: &[],
|
||||
node_required: None,
|
||||
},
|
||||
},
|
||||
AgentType::Kimi => AcpAgentMeta {
|
||||
agent_type,
|
||||
name: "Kimi CLI",
|
||||
description: "Moonshot AI's coding assistant",
|
||||
distribution: AgentDistribution::Binary {
|
||||
version: "1.14.0",
|
||||
cmd: "kimi",
|
||||
args: &[],
|
||||
env: &[],
|
||||
platforms: &[
|
||||
PlatformBinary {
|
||||
platform: "darwin-aarch64",
|
||||
url: "https://github.com/MoonshotAI/kimi-cli/releases/download/1.14.0/kimi-1.14.0-aarch64-apple-darwin.tar.gz",
|
||||
},
|
||||
PlatformBinary {
|
||||
platform: "linux-aarch64",
|
||||
url: "https://github.com/MoonshotAI/kimi-cli/releases/download/1.14.0/kimi-1.14.0-aarch64-unknown-linux-gnu.tar.gz",
|
||||
},
|
||||
PlatformBinary {
|
||||
platform: "linux-x86_64",
|
||||
url: "https://github.com/MoonshotAI/kimi-cli/releases/download/1.14.0/kimi-1.14.0-x86_64-unknown-linux-gnu.tar.gz",
|
||||
},
|
||||
PlatformBinary {
|
||||
platform: "windows-x86_64",
|
||||
url: "https://github.com/MoonshotAI/kimi-cli/releases/download/1.14.0/kimi-1.14.0-x86_64-pc-windows-msvc.zip",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
AgentType::MinionCode => AcpAgentMeta {
|
||||
agent_type,
|
||||
name: "Minion Code",
|
||||
description: "An enhanced AI code assistant built on the Minion framework with rich development tools",
|
||||
distribution: AgentDistribution::Uvx {
|
||||
version: "0.1.39",
|
||||
package: "minion-code@0.1.39",
|
||||
args: &["acp"],
|
||||
env: &[],
|
||||
},
|
||||
},
|
||||
AgentType::MistralVibe => AcpAgentMeta {
|
||||
agent_type,
|
||||
name: "Mistral Vibe",
|
||||
description: "Mistral's open-source coding assistant",
|
||||
distribution: AgentDistribution::Binary {
|
||||
version: "2.2.1",
|
||||
cmd: "vibe-acp",
|
||||
args: &[],
|
||||
env: &[],
|
||||
platforms: &[
|
||||
PlatformBinary {
|
||||
platform: "darwin-aarch64",
|
||||
url: "https://github.com/mistralai/mistral-vibe/releases/download/v2.2.1/vibe-acp-darwin-aarch64-2.2.1.zip",
|
||||
},
|
||||
PlatformBinary {
|
||||
platform: "darwin-x86_64",
|
||||
url: "https://github.com/mistralai/mistral-vibe/releases/download/v2.2.1/vibe-acp-darwin-x86_64-2.2.1.zip",
|
||||
},
|
||||
PlatformBinary {
|
||||
platform: "linux-aarch64",
|
||||
url: "https://github.com/mistralai/mistral-vibe/releases/download/v2.2.1/vibe-acp-linux-aarch64-2.2.1.zip",
|
||||
},
|
||||
PlatformBinary {
|
||||
platform: "linux-x86_64",
|
||||
url: "https://github.com/mistralai/mistral-vibe/releases/download/v2.2.1/vibe-acp-linux-x86_64-2.2.1.zip",
|
||||
},
|
||||
PlatformBinary {
|
||||
platform: "windows-aarch64",
|
||||
url: "https://github.com/mistralai/mistral-vibe/releases/download/v2.2.1/vibe-acp-windows-aarch64-2.2.1.zip",
|
||||
},
|
||||
PlatformBinary {
|
||||
platform: "windows-x86_64",
|
||||
url: "https://github.com/mistralai/mistral-vibe/releases/download/v2.2.1/vibe-acp-windows-x86_64-2.2.1.zip",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
AgentType::OpenClaw => AcpAgentMeta {
|
||||
agent_type,
|
||||
name: "OpenClaw",
|
||||
description: "Open-source personal AI assistant with ACP bridge",
|
||||
distribution: AgentDistribution::Npx {
|
||||
version: "2026.2.26",
|
||||
package: "openclaw@2026.2.26",
|
||||
args: &["acp"],
|
||||
env: &[],
|
||||
node_required: Some("22.12.0"),
|
||||
},
|
||||
},
|
||||
AgentType::OpenCode => AcpAgentMeta {
|
||||
agent_type,
|
||||
name: "OpenCode",
|
||||
description: "The open source coding agent",
|
||||
distribution: AgentDistribution::Binary {
|
||||
version: "1.2.15",
|
||||
cmd: "opencode",
|
||||
args: &["acp"],
|
||||
env: &[],
|
||||
platforms: &[
|
||||
PlatformBinary {
|
||||
platform: "darwin-aarch64",
|
||||
url: "https://github.com/anomalyco/opencode/releases/download/v1.2.15/opencode-darwin-arm64.zip",
|
||||
},
|
||||
PlatformBinary {
|
||||
platform: "darwin-x86_64",
|
||||
url: "https://github.com/anomalyco/opencode/releases/download/v1.2.15/opencode-darwin-x64.zip",
|
||||
},
|
||||
PlatformBinary {
|
||||
platform: "linux-aarch64",
|
||||
url: "https://github.com/anomalyco/opencode/releases/download/v1.2.15/opencode-linux-arm64.tar.gz",
|
||||
},
|
||||
PlatformBinary {
|
||||
platform: "linux-x86_64",
|
||||
url: "https://github.com/anomalyco/opencode/releases/download/v1.2.15/opencode-linux-x64.tar.gz",
|
||||
},
|
||||
PlatformBinary {
|
||||
platform: "windows-x86_64",
|
||||
url: "https://github.com/anomalyco/opencode/releases/download/v1.2.15/opencode-windows-x64.zip",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
AgentType::Qoder => AcpAgentMeta {
|
||||
agent_type,
|
||||
name: "Qoder CLI",
|
||||
description: "AI coding assistant with agentic capabilities",
|
||||
distribution: AgentDistribution::Npx {
|
||||
version: "0.1.29",
|
||||
package: "@qoder-ai/qodercli@0.1.29",
|
||||
args: &["--acp"],
|
||||
env: &[],
|
||||
node_required: None,
|
||||
},
|
||||
},
|
||||
AgentType::QwenCode => AcpAgentMeta {
|
||||
agent_type,
|
||||
name: "Qwen Code",
|
||||
description: "Alibaba's Qwen coding assistant",
|
||||
distribution: AgentDistribution::Npx {
|
||||
version: "0.10.6",
|
||||
package: "@qwen-code/qwen-code@0.10.6",
|
||||
args: &["--acp", "--experimental-skills"],
|
||||
env: &[],
|
||||
node_required: None,
|
||||
},
|
||||
},
|
||||
AgentType::Stakpak => AcpAgentMeta {
|
||||
agent_type,
|
||||
name: "Stakpak",
|
||||
description: "Open-source DevOps agent in Rust with enterprise-grade security",
|
||||
distribution: AgentDistribution::Binary {
|
||||
version: "0.3.62",
|
||||
cmd: "stakpak",
|
||||
args: &[],
|
||||
env: &[],
|
||||
platforms: &[
|
||||
PlatformBinary {
|
||||
platform: "darwin-aarch64",
|
||||
url: "https://github.com/stakpak/agent/releases/download/v0.3.62/stakpak-darwin-aarch64.tar.gz",
|
||||
},
|
||||
PlatformBinary {
|
||||
platform: "darwin-x86_64",
|
||||
url: "https://github.com/stakpak/agent/releases/download/v0.3.62/stakpak-darwin-x86_64.tar.gz",
|
||||
},
|
||||
PlatformBinary {
|
||||
platform: "linux-aarch64",
|
||||
url: "https://github.com/stakpak/agent/releases/download/v0.3.62/stakpak-linux-aarch64.tar.gz",
|
||||
},
|
||||
PlatformBinary {
|
||||
platform: "linux-x86_64",
|
||||
url: "https://github.com/stakpak/agent/releases/download/v0.3.62/stakpak-linux-x86_64.tar.gz",
|
||||
},
|
||||
PlatformBinary {
|
||||
platform: "windows-x86_64",
|
||||
url: "https://github.com/stakpak/agent/releases/download/v0.3.62/stakpak-windows-x86_64.zip",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::acp::registry;
|
||||
use crate::models::agent::AgentType;
|
||||
|
||||
pub const REGISTRY_URL: &str =
|
||||
"https://cdn.agentclientprotocol.com/registry/v1/latest/registry.json";
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct RegistryAgent {
|
||||
pub agent_type: AgentType,
|
||||
pub registry_id: String,
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
pub version: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct RegistryBinaryRelease {
|
||||
pub version: String,
|
||||
pub archive_url: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct RegistryPayload {
|
||||
agents: Vec<RegistryAgentItem>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct RegistryAgentItem {
|
||||
id: String,
|
||||
name: String,
|
||||
description: String,
|
||||
#[serde(default)]
|
||||
version: Option<String>,
|
||||
#[serde(default)]
|
||||
distribution: RegistryDistribution,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Deserialize)]
|
||||
struct RegistryDistribution {
|
||||
#[serde(default)]
|
||||
binary: BTreeMap<String, RegistryBinaryPlatformItem>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Deserialize)]
|
||||
struct RegistryBinaryPlatformItem {
|
||||
#[serde(default, alias = "url")]
|
||||
archive: String,
|
||||
}
|
||||
|
||||
async fn fetch_registry_payload() -> Result<RegistryPayload, String> {
|
||||
let response = reqwest::Client::new()
|
||||
.get(REGISTRY_URL)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("failed to fetch ACP registry: {e}"))?;
|
||||
if !response.status().is_success() {
|
||||
return Err(format!(
|
||||
"failed to fetch ACP registry: HTTP {}",
|
||||
response.status()
|
||||
));
|
||||
}
|
||||
|
||||
response
|
||||
.text()
|
||||
.await
|
||||
.map_err(|e| format!("failed to read ACP registry response: {e}"))
|
||||
.and_then(|text| {
|
||||
serde_json::from_str::<RegistryPayload>(&text)
|
||||
.map_err(|e| format!("failed to parse ACP registry JSON: {e}"))
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn fetch_supported_agents() -> Result<Vec<RegistryAgent>, String> {
|
||||
let payload = fetch_registry_payload().await?;
|
||||
|
||||
let mut supported = Vec::new();
|
||||
for item in payload.agents {
|
||||
if let Some(agent_type) = registry::from_registry_id(&item.id) {
|
||||
supported.push(RegistryAgent {
|
||||
agent_type,
|
||||
registry_id: item.id,
|
||||
name: item.name,
|
||||
description: item.description,
|
||||
version: item.version,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Ok(supported)
|
||||
}
|
||||
|
||||
pub async fn fetch_binary_release(
|
||||
agent_type: AgentType,
|
||||
platform: &str,
|
||||
) -> Result<Option<RegistryBinaryRelease>, String> {
|
||||
let payload = fetch_registry_payload().await?;
|
||||
let item = payload.agents.into_iter().find(|item| {
|
||||
registry::from_registry_id(&item.id)
|
||||
.map(|candidate| candidate == agent_type)
|
||||
.unwrap_or(false)
|
||||
});
|
||||
|
||||
let Some(item) = item else {
|
||||
return Ok(None);
|
||||
};
|
||||
if item.version.as_deref().unwrap_or_default().is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
let platform_item = item.distribution.binary.get(platform);
|
||||
let Some(platform_item) = platform_item else {
|
||||
return Ok(None);
|
||||
};
|
||||
if platform_item.archive.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
Ok(Some(RegistryBinaryRelease {
|
||||
version: item.version.unwrap_or_default(),
|
||||
archive_url: platform_item.archive.clone(),
|
||||
}))
|
||||
}
|
||||
@@ -0,0 +1,517 @@
|
||||
use std::collections::HashMap;
|
||||
use std::process::Stdio;
|
||||
use std::sync::Arc;
|
||||
|
||||
use sacp::schema::{
|
||||
CreateTerminalRequest, CreateTerminalResponse, KillTerminalCommandRequest,
|
||||
KillTerminalCommandResponse, ReleaseTerminalRequest, ReleaseTerminalResponse,
|
||||
TerminalExitStatus, TerminalOutputRequest, TerminalOutputResponse, WaitForTerminalExitRequest,
|
||||
WaitForTerminalExitResponse,
|
||||
};
|
||||
use tokio::io::{AsyncRead, AsyncReadExt};
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
type TerminalMap = HashMap<String, Arc<TerminalInstance>>;
|
||||
const DEFAULT_OUTPUT_BYTE_LIMIT: u64 = 1_000_000;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum TerminalRuntimeError {
|
||||
InvalidParams(String),
|
||||
Internal(String),
|
||||
}
|
||||
|
||||
impl TerminalRuntimeError {
|
||||
pub fn to_rpc_error(self) -> sacp::Error {
|
||||
match self {
|
||||
Self::InvalidParams(message) => sacp::Error::invalid_params().data(message),
|
||||
Self::Internal(message) => sacp::util::internal_error(message),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Clone)]
|
||||
struct TerminalSnapshot {
|
||||
output: String,
|
||||
output_base_offset: u64,
|
||||
truncated: bool,
|
||||
exit_status: Option<TerminalExitStatus>,
|
||||
}
|
||||
|
||||
struct TerminalInstance {
|
||||
session_id: String,
|
||||
output_limit: Option<usize>,
|
||||
child: Mutex<Option<tokio::process::Child>>,
|
||||
snapshot: Mutex<TerminalSnapshot>,
|
||||
}
|
||||
|
||||
impl TerminalInstance {
|
||||
fn new(session_id: String, output_limit: Option<u64>, child: tokio::process::Child) -> Self {
|
||||
Self {
|
||||
session_id,
|
||||
output_limit: output_limit.and_then(|v| usize::try_from(v).ok()),
|
||||
child: Mutex::new(Some(child)),
|
||||
snapshot: Mutex::new(TerminalSnapshot::default()),
|
||||
}
|
||||
}
|
||||
|
||||
async fn append_output(&self, text: &str) {
|
||||
let mut snapshot = self.snapshot.lock().await;
|
||||
snapshot.output.push_str(text);
|
||||
if let Some(limit) = self.output_limit {
|
||||
let removed = enforce_output_limit(&mut snapshot.output, limit);
|
||||
if removed > 0 {
|
||||
snapshot.truncated = true;
|
||||
snapshot.output_base_offset = snapshot
|
||||
.output_base_offset
|
||||
.saturating_add(u64::try_from(removed).unwrap_or(u64::MAX));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn refresh_exit_status(&self) -> Result<(), TerminalRuntimeError> {
|
||||
{
|
||||
let snapshot = self.snapshot.lock().await;
|
||||
if snapshot.exit_status.is_some() {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
|
||||
let maybe_status = {
|
||||
let mut child_guard = self.child.lock().await;
|
||||
if let Some(child) = child_guard.as_mut() {
|
||||
match child.try_wait() {
|
||||
Ok(Some(status)) => {
|
||||
*child_guard = None;
|
||||
Some(status)
|
||||
}
|
||||
Ok(None) => None,
|
||||
Err(err) => {
|
||||
return Err(TerminalRuntimeError::Internal(format!(
|
||||
"failed to query terminal exit status: {err}"
|
||||
)))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
None
|
||||
}
|
||||
};
|
||||
|
||||
if let Some(status) = maybe_status {
|
||||
let mut snapshot = self.snapshot.lock().await;
|
||||
snapshot.exit_status = Some(map_exit_status(status));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn wait_for_exit(&self) -> Result<TerminalExitStatus, TerminalRuntimeError> {
|
||||
self.refresh_exit_status().await?;
|
||||
{
|
||||
let snapshot = self.snapshot.lock().await;
|
||||
if let Some(exit_status) = snapshot.exit_status.clone() {
|
||||
return Ok(exit_status);
|
||||
}
|
||||
}
|
||||
|
||||
let exit_status = {
|
||||
let mut child_guard = self.child.lock().await;
|
||||
let Some(child) = child_guard.as_mut() else {
|
||||
return Err(TerminalRuntimeError::Internal(
|
||||
"terminal process missing while waiting for exit".to_string(),
|
||||
));
|
||||
};
|
||||
let status = child.wait().await.map_err(|err| {
|
||||
TerminalRuntimeError::Internal(format!(
|
||||
"failed waiting for terminal process to exit: {err}"
|
||||
))
|
||||
})?;
|
||||
*child_guard = None;
|
||||
map_exit_status(status)
|
||||
};
|
||||
|
||||
let mut snapshot = self.snapshot.lock().await;
|
||||
snapshot.exit_status = Some(exit_status.clone());
|
||||
Ok(exit_status)
|
||||
}
|
||||
|
||||
async fn kill_command(&self) -> Result<(), TerminalRuntimeError> {
|
||||
self.refresh_exit_status().await?;
|
||||
{
|
||||
let snapshot = self.snapshot.lock().await;
|
||||
if snapshot.exit_status.is_some() {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
|
||||
let exit_status = {
|
||||
let mut child_guard = self.child.lock().await;
|
||||
let Some(child) = child_guard.as_mut() else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
if let Err(err) = child.kill().await {
|
||||
if err.kind() != std::io::ErrorKind::InvalidInput {
|
||||
return Err(TerminalRuntimeError::Internal(format!(
|
||||
"failed to kill terminal process: {err}"
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
let status = child.wait().await.map_err(|err| {
|
||||
TerminalRuntimeError::Internal(format!(
|
||||
"failed to wait for killed terminal process: {err}"
|
||||
))
|
||||
})?;
|
||||
*child_guard = None;
|
||||
map_exit_status(status)
|
||||
};
|
||||
|
||||
let mut snapshot = self.snapshot.lock().await;
|
||||
snapshot.exit_status = Some(exit_status);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn snapshot(&self) -> TerminalSnapshot {
|
||||
self.snapshot.lock().await.clone()
|
||||
}
|
||||
}
|
||||
|
||||
pub struct TerminalRuntime {
|
||||
terminals: Mutex<TerminalMap>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct TerminalOutputDelta {
|
||||
pub output: String,
|
||||
pub next_offset: u64,
|
||||
pub had_gap: bool,
|
||||
pub truncated: bool,
|
||||
pub exit_status: Option<TerminalExitStatus>,
|
||||
}
|
||||
|
||||
impl TerminalRuntime {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
terminals: Mutex::new(HashMap::new()),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn create_terminal(
|
||||
&self,
|
||||
request: CreateTerminalRequest,
|
||||
) -> Result<CreateTerminalResponse, TerminalRuntimeError> {
|
||||
if let Some(cwd) = request.cwd.as_ref() {
|
||||
if !cwd.is_absolute() {
|
||||
return Err(TerminalRuntimeError::InvalidParams(
|
||||
"terminal/create requires an absolute cwd when provided".to_string(),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
let output_byte_limit = request
|
||||
.output_byte_limit
|
||||
.unwrap_or(DEFAULT_OUTPUT_BYTE_LIMIT);
|
||||
if output_byte_limit == 0 {
|
||||
return Err(TerminalRuntimeError::InvalidParams(
|
||||
"terminal/create outputByteLimit must be greater than 0".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let mut command = crate::process::tokio_command(&request.command);
|
||||
command
|
||||
.args(&request.args)
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.stdin(Stdio::null());
|
||||
|
||||
if let Some(cwd) = request.cwd.as_ref() {
|
||||
command.current_dir(cwd);
|
||||
}
|
||||
|
||||
for env_var in &request.env {
|
||||
command.env(&env_var.name, &env_var.value);
|
||||
}
|
||||
|
||||
let mut child = command.spawn().map_err(|err| {
|
||||
TerminalRuntimeError::Internal(format!(
|
||||
"failed to spawn terminal command {}: {err}",
|
||||
request.command
|
||||
))
|
||||
})?;
|
||||
|
||||
let stdout = child.stdout.take();
|
||||
let stderr = child.stderr.take();
|
||||
|
||||
let terminal_id = format!("term_{}", uuid::Uuid::new_v4().simple());
|
||||
let terminal = Arc::new(TerminalInstance::new(
|
||||
request.session_id.to_string(),
|
||||
Some(output_byte_limit),
|
||||
child,
|
||||
));
|
||||
|
||||
if let Some(reader) = stdout {
|
||||
let terminal_ref = terminal.clone();
|
||||
tokio::spawn(async move {
|
||||
read_stream(reader, terminal_ref).await;
|
||||
});
|
||||
}
|
||||
|
||||
if let Some(reader) = stderr {
|
||||
let terminal_ref = terminal.clone();
|
||||
tokio::spawn(async move {
|
||||
read_stream(reader, terminal_ref).await;
|
||||
});
|
||||
}
|
||||
|
||||
self.terminals
|
||||
.lock()
|
||||
.await
|
||||
.insert(terminal_id.clone(), terminal);
|
||||
|
||||
Ok(CreateTerminalResponse::new(terminal_id))
|
||||
}
|
||||
|
||||
pub async fn terminal_output(
|
||||
&self,
|
||||
request: TerminalOutputRequest,
|
||||
) -> Result<TerminalOutputResponse, TerminalRuntimeError> {
|
||||
let terminal = self
|
||||
.find_terminal(
|
||||
&request.terminal_id.to_string(),
|
||||
&request.session_id.to_string(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
terminal.refresh_exit_status().await?;
|
||||
let snapshot = terminal.snapshot().await;
|
||||
|
||||
Ok(
|
||||
TerminalOutputResponse::new(snapshot.output, snapshot.truncated)
|
||||
.exit_status(snapshot.exit_status),
|
||||
)
|
||||
}
|
||||
|
||||
pub async fn terminal_output_delta(
|
||||
&self,
|
||||
session_id: &str,
|
||||
terminal_id: &str,
|
||||
from_offset: Option<u64>,
|
||||
) -> Result<TerminalOutputDelta, TerminalRuntimeError> {
|
||||
let terminal = self.find_terminal(terminal_id, session_id).await?;
|
||||
terminal.refresh_exit_status().await?;
|
||||
let snapshot = terminal.snapshot().await;
|
||||
|
||||
let output_len = u64::try_from(snapshot.output.len()).unwrap_or(u64::MAX);
|
||||
let base_offset = snapshot.output_base_offset;
|
||||
let end_offset = base_offset.saturating_add(output_len);
|
||||
let requested_offset = from_offset.unwrap_or(base_offset);
|
||||
let had_gap = from_offset
|
||||
.map(|offset| offset < base_offset)
|
||||
.unwrap_or(false);
|
||||
let start_offset = requested_offset.clamp(base_offset, end_offset);
|
||||
let start_index = usize::try_from(start_offset.saturating_sub(base_offset)).unwrap_or(0);
|
||||
let output = snapshot.output[start_index..].to_string();
|
||||
|
||||
Ok(TerminalOutputDelta {
|
||||
output,
|
||||
next_offset: end_offset,
|
||||
had_gap,
|
||||
truncated: snapshot.truncated,
|
||||
exit_status: snapshot.exit_status,
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn wait_for_terminal_exit(
|
||||
&self,
|
||||
request: WaitForTerminalExitRequest,
|
||||
) -> Result<WaitForTerminalExitResponse, TerminalRuntimeError> {
|
||||
let terminal = self
|
||||
.find_terminal(
|
||||
&request.terminal_id.to_string(),
|
||||
&request.session_id.to_string(),
|
||||
)
|
||||
.await?;
|
||||
let exit_status = terminal.wait_for_exit().await?;
|
||||
Ok(WaitForTerminalExitResponse::new(exit_status))
|
||||
}
|
||||
|
||||
pub async fn kill_terminal(
|
||||
&self,
|
||||
request: KillTerminalCommandRequest,
|
||||
) -> Result<KillTerminalCommandResponse, TerminalRuntimeError> {
|
||||
let terminal = self
|
||||
.find_terminal(
|
||||
&request.terminal_id.to_string(),
|
||||
&request.session_id.to_string(),
|
||||
)
|
||||
.await?;
|
||||
terminal.kill_command().await?;
|
||||
Ok(KillTerminalCommandResponse::new())
|
||||
}
|
||||
|
||||
pub async fn release_terminal(
|
||||
&self,
|
||||
request: ReleaseTerminalRequest,
|
||||
) -> Result<ReleaseTerminalResponse, TerminalRuntimeError> {
|
||||
let terminal_id = request.terminal_id.to_string();
|
||||
let session_id = request.session_id.to_string();
|
||||
let terminal = {
|
||||
let mut terminals = self.terminals.lock().await;
|
||||
let Some(existing) = terminals.get(&terminal_id) else {
|
||||
return Err(TerminalRuntimeError::InvalidParams(format!(
|
||||
"terminal {terminal_id} not found"
|
||||
)));
|
||||
};
|
||||
if existing.session_id != session_id {
|
||||
return Err(TerminalRuntimeError::InvalidParams(format!(
|
||||
"terminal {terminal_id} does not belong to session {session_id}"
|
||||
)));
|
||||
}
|
||||
terminals.remove(&terminal_id).expect("terminal exists")
|
||||
};
|
||||
|
||||
terminal.kill_command().await?;
|
||||
Ok(ReleaseTerminalResponse::new())
|
||||
}
|
||||
|
||||
pub async fn release_all_for_session(&self, session_id: &str) {
|
||||
let removed = {
|
||||
let mut terminals = self.terminals.lock().await;
|
||||
let ids: Vec<String> = terminals
|
||||
.iter()
|
||||
.filter(|(_, term)| term.session_id == session_id)
|
||||
.map(|(id, _)| id.clone())
|
||||
.collect();
|
||||
|
||||
let mut removed = Vec::with_capacity(ids.len());
|
||||
for id in ids {
|
||||
if let Some(term) = terminals.remove(&id) {
|
||||
removed.push(term);
|
||||
}
|
||||
}
|
||||
removed
|
||||
};
|
||||
|
||||
for terminal in removed {
|
||||
if let Err(err) = terminal.kill_command().await {
|
||||
eprintln!("[ACP] Failed to release terminal during cleanup: {err:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn find_terminal(
|
||||
&self,
|
||||
terminal_id: &str,
|
||||
session_id: &str,
|
||||
) -> Result<Arc<TerminalInstance>, TerminalRuntimeError> {
|
||||
let terminal = {
|
||||
let terminals = self.terminals.lock().await;
|
||||
terminals.get(terminal_id).cloned()
|
||||
}
|
||||
.ok_or_else(|| {
|
||||
TerminalRuntimeError::InvalidParams(format!("terminal {terminal_id} not found"))
|
||||
})?;
|
||||
|
||||
if terminal.session_id != session_id {
|
||||
return Err(TerminalRuntimeError::InvalidParams(format!(
|
||||
"terminal {terminal_id} does not belong to session {session_id}"
|
||||
)));
|
||||
}
|
||||
|
||||
Ok(terminal)
|
||||
}
|
||||
}
|
||||
|
||||
async fn read_stream<R>(mut reader: R, terminal: Arc<TerminalInstance>)
|
||||
where
|
||||
R: AsyncRead + Unpin,
|
||||
{
|
||||
let mut buffer = [0_u8; 4096];
|
||||
let mut pending = Vec::<u8>::new();
|
||||
loop {
|
||||
match reader.read(&mut buffer).await {
|
||||
Ok(0) => {
|
||||
if !pending.is_empty() {
|
||||
let text = String::from_utf8_lossy(&pending).to_string();
|
||||
terminal.append_output(&text).await;
|
||||
pending.clear();
|
||||
}
|
||||
break;
|
||||
}
|
||||
Ok(size) => {
|
||||
pending.extend_from_slice(&buffer[..size]);
|
||||
let decoded = decode_available_utf8(&mut pending);
|
||||
if !decoded.is_empty() {
|
||||
terminal.append_output(&decoded).await;
|
||||
}
|
||||
}
|
||||
Err(_) => break,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn map_exit_status(status: std::process::ExitStatus) -> TerminalExitStatus {
|
||||
#[cfg(unix)]
|
||||
let signal = std::os::unix::process::ExitStatusExt::signal(&status).map(|s| s.to_string());
|
||||
#[cfg(not(unix))]
|
||||
let signal: Option<String> = None;
|
||||
|
||||
let exit_code = status.code().and_then(|code| u32::try_from(code).ok());
|
||||
TerminalExitStatus::new()
|
||||
.exit_code(exit_code)
|
||||
.signal(signal)
|
||||
}
|
||||
|
||||
fn enforce_output_limit(output: &mut String, limit: usize) -> usize {
|
||||
if output.len() <= limit {
|
||||
return 0;
|
||||
}
|
||||
|
||||
let mut start = output.len().saturating_sub(limit);
|
||||
while start < output.len() && !output.is_char_boundary(start) {
|
||||
start += 1;
|
||||
}
|
||||
|
||||
output.drain(..start);
|
||||
start
|
||||
}
|
||||
|
||||
fn decode_available_utf8(pending: &mut Vec<u8>) -> String {
|
||||
let mut output = String::new();
|
||||
let mut consumed = 0usize;
|
||||
let mut remaining = pending.as_slice();
|
||||
|
||||
while !remaining.is_empty() {
|
||||
match std::str::from_utf8(remaining) {
|
||||
Ok(text) => {
|
||||
output.push_str(text);
|
||||
consumed = consumed.saturating_add(remaining.len());
|
||||
break;
|
||||
}
|
||||
Err(err) => {
|
||||
let valid_up_to = err.valid_up_to();
|
||||
if valid_up_to > 0 {
|
||||
if let Ok(text) = std::str::from_utf8(&remaining[..valid_up_to]) {
|
||||
output.push_str(text);
|
||||
}
|
||||
consumed = consumed.saturating_add(valid_up_to);
|
||||
remaining = &remaining[valid_up_to..];
|
||||
}
|
||||
|
||||
match err.error_len() {
|
||||
Some(invalid_len) => {
|
||||
output.push_str(&String::from_utf8_lossy(&remaining[..invalid_len]));
|
||||
consumed = consumed.saturating_add(invalid_len);
|
||||
remaining = &remaining[invalid_len..];
|
||||
}
|
||||
None => break, // keep partial UTF-8 sequence for next chunk
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if consumed > 0 {
|
||||
pending.drain(..consumed);
|
||||
}
|
||||
output
|
||||
}
|
||||
@@ -0,0 +1,257 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub enum PromptInputBlock {
|
||||
Text {
|
||||
text: String,
|
||||
},
|
||||
ResourceLink {
|
||||
uri: String,
|
||||
name: String,
|
||||
#[serde(default)]
|
||||
mime_type: Option<String>,
|
||||
#[serde(default)]
|
||||
description: Option<String>,
|
||||
},
|
||||
}
|
||||
|
||||
/// Events pushed from Rust backend to frontend via Tauri event system.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub enum AcpEvent {
|
||||
/// Agent returned text content (streaming delta)
|
||||
ContentDelta { connection_id: String, text: String },
|
||||
/// Agent thinking/reasoning
|
||||
Thinking { connection_id: String, text: String },
|
||||
/// Agent initiated a tool call
|
||||
ToolCall {
|
||||
connection_id: String,
|
||||
tool_call_id: String,
|
||||
title: String,
|
||||
kind: String,
|
||||
status: String,
|
||||
content: Option<String>,
|
||||
raw_input: Option<String>,
|
||||
raw_output: Option<String>,
|
||||
},
|
||||
/// Tool call status/content updated
|
||||
ToolCallUpdate {
|
||||
connection_id: String,
|
||||
tool_call_id: String,
|
||||
title: Option<String>,
|
||||
status: Option<String>,
|
||||
content: Option<String>,
|
||||
raw_input: Option<String>,
|
||||
raw_output: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
raw_output_append: Option<bool>,
|
||||
},
|
||||
/// Agent requests permission
|
||||
PermissionRequest {
|
||||
connection_id: String,
|
||||
request_id: String,
|
||||
tool_call: serde_json::Value,
|
||||
options: Vec<PermissionOptionInfo>,
|
||||
},
|
||||
/// Turn completed
|
||||
TurnComplete {
|
||||
connection_id: String,
|
||||
stop_reason: String,
|
||||
},
|
||||
/// Session established with agent-assigned session ID
|
||||
SessionStarted {
|
||||
connection_id: String,
|
||||
session_id: String,
|
||||
},
|
||||
/// Session modes are available for this connection
|
||||
SessionModes {
|
||||
connection_id: String,
|
||||
modes: SessionModeStateInfo,
|
||||
},
|
||||
/// Session configuration options are available/updated for this connection
|
||||
SessionConfigOptions {
|
||||
connection_id: String,
|
||||
config_options: Vec<SessionConfigOptionInfo>,
|
||||
},
|
||||
/// Initial selector payloads (modes/config options) have been emitted
|
||||
SelectorsReady { connection_id: String },
|
||||
/// Current session mode changed
|
||||
ModeChanged {
|
||||
connection_id: String,
|
||||
mode_id: String,
|
||||
},
|
||||
/// Agent reported plan update for current turn
|
||||
PlanUpdate {
|
||||
connection_id: String,
|
||||
entries: Vec<PlanEntryInfo>,
|
||||
},
|
||||
/// Connection status changed
|
||||
StatusChanged {
|
||||
connection_id: String,
|
||||
status: ConnectionStatus,
|
||||
},
|
||||
/// Error occurred
|
||||
Error {
|
||||
connection_id: String,
|
||||
message: String,
|
||||
},
|
||||
/// Available slash commands updated
|
||||
AvailableCommands {
|
||||
connection_id: String,
|
||||
commands: Vec<AvailableCommandInfo>,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PermissionOptionInfo {
|
||||
pub option_id: String,
|
||||
pub name: String,
|
||||
pub kind: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SessionModeInfo {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub description: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SessionModeStateInfo {
|
||||
pub current_mode_id: String,
|
||||
pub available_modes: Vec<SessionModeInfo>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SessionConfigSelectOptionInfo {
|
||||
pub value: String,
|
||||
pub name: String,
|
||||
pub description: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SessionConfigSelectGroupInfo {
|
||||
pub group: String,
|
||||
pub name: String,
|
||||
pub options: Vec<SessionConfigSelectOptionInfo>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SessionConfigSelectInfo {
|
||||
pub current_value: String,
|
||||
pub options: Vec<SessionConfigSelectOptionInfo>,
|
||||
pub groups: Vec<SessionConfigSelectGroupInfo>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub enum SessionConfigKindInfo {
|
||||
Select(SessionConfigSelectInfo),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SessionConfigOptionInfo {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub description: Option<String>,
|
||||
pub category: Option<String>,
|
||||
pub kind: SessionConfigKindInfo,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PlanEntryInfo {
|
||||
pub content: String,
|
||||
pub priority: String,
|
||||
pub status: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ConnectionStatus {
|
||||
Connecting,
|
||||
Downloading,
|
||||
Connected,
|
||||
Prompting,
|
||||
Disconnected,
|
||||
Error,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct ConnectionInfo {
|
||||
pub id: String,
|
||||
pub agent_type: crate::models::agent::AgentType,
|
||||
pub status: ConnectionStatus,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct AcpAgentInfo {
|
||||
pub agent_type: crate::models::agent::AgentType,
|
||||
pub registry_id: String,
|
||||
pub registry_version: Option<String>,
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
pub available: bool,
|
||||
pub distribution_type: String,
|
||||
pub enabled: bool,
|
||||
pub sort_order: i32,
|
||||
pub installed_version: Option<String>,
|
||||
pub env: BTreeMap<String, String>,
|
||||
pub config_json: Option<String>,
|
||||
pub config_file_path: Option<String>,
|
||||
pub opencode_auth_json: Option<String>,
|
||||
pub codex_auth_json: Option<String>,
|
||||
pub codex_config_toml: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum AgentSkillScope {
|
||||
Global,
|
||||
Project,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum AgentSkillLayout {
|
||||
MarkdownFile,
|
||||
SkillDirectory,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct AgentSkillLocation {
|
||||
pub scope: AgentSkillScope,
|
||||
pub path: String,
|
||||
pub exists: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct AgentSkillItem {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub scope: AgentSkillScope,
|
||||
pub layout: AgentSkillLayout,
|
||||
pub path: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct AgentSkillsListResult {
|
||||
pub supported: bool,
|
||||
pub message: Option<String>,
|
||||
pub locations: Vec<AgentSkillLocation>,
|
||||
pub skills: Vec<AgentSkillItem>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct AgentSkillContent {
|
||||
pub skill: AgentSkillItem,
|
||||
pub content: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AvailableCommandInfo {
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
pub input_hint: Option<String>,
|
||||
}
|
||||
@@ -0,0 +1,384 @@
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
use crate::db::entities::conversation;
|
||||
use crate::db::service::{conversation_service, folder_service, import_service};
|
||||
use crate::db::AppDatabase;
|
||||
use crate::models::*;
|
||||
use crate::parsers::claude::ClaudeParser;
|
||||
use crate::parsers::codex::CodexParser;
|
||||
use crate::parsers::gemini::GeminiParser;
|
||||
use crate::parsers::opencode::OpenCodeParser;
|
||||
use crate::parsers::{path_eq_for_matching, AgentParser};
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn list_folder_conversations(
|
||||
db: tauri::State<'_, AppDatabase>,
|
||||
folder_id: i32,
|
||||
agent_type: Option<AgentType>,
|
||||
search: Option<String>,
|
||||
sort_by: Option<String>,
|
||||
status: Option<String>,
|
||||
) -> Result<Vec<DbConversationSummary>, String> {
|
||||
conversation_service::list_by_folder(&db.conn, folder_id, agent_type, search, sort_by, status)
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// Synchronous implementation shared by list_conversations, list_folders, and get_stats.
|
||||
fn list_conversations_sync(
|
||||
agent_type: Option<AgentType>,
|
||||
search: Option<String>,
|
||||
sort_by: Option<String>,
|
||||
folder_path: Option<String>,
|
||||
) -> Result<Vec<ConversationSummary>, String> {
|
||||
let mut all_conversations = Vec::new();
|
||||
let mut seen_keys = HashSet::new();
|
||||
|
||||
let parsers: Vec<(AgentType, Box<dyn AgentParser>)> = vec![
|
||||
(AgentType::ClaudeCode, Box::new(ClaudeParser::new())),
|
||||
(AgentType::Codex, Box::new(CodexParser::new())),
|
||||
(AgentType::OpenCode, Box::new(OpenCodeParser::new())),
|
||||
(AgentType::Gemini, Box::new(GeminiParser::new())),
|
||||
];
|
||||
|
||||
for (at, parser) in &parsers {
|
||||
if let Some(ref filter) = agent_type {
|
||||
if filter != at {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
match parser.list_conversations() {
|
||||
Ok(conversations) => {
|
||||
// Deduplicate conversations based on (agent_type, id) combination
|
||||
for conversation in conversations {
|
||||
let key = format!("{:?}-{}", conversation.agent_type, conversation.id);
|
||||
if seen_keys.insert(key) {
|
||||
all_conversations.push(conversation);
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("Error listing {} conversations: {}", at, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Apply search filter
|
||||
if let Some(ref query) = search {
|
||||
let query_lower = query.to_lowercase();
|
||||
all_conversations.retain(|s| {
|
||||
s.title
|
||||
.as_ref()
|
||||
.map(|t| t.to_lowercase().contains(&query_lower))
|
||||
.unwrap_or(false)
|
||||
|| s.folder_name
|
||||
.as_ref()
|
||||
.map(|p| p.to_lowercase().contains(&query_lower))
|
||||
.unwrap_or(false)
|
||||
|| s.folder_path
|
||||
.as_ref()
|
||||
.map(|p| p.to_lowercase().contains(&query_lower))
|
||||
.unwrap_or(false)
|
||||
|| s.git_branch
|
||||
.as_ref()
|
||||
.map(|b| b.to_lowercase().contains(&query_lower))
|
||||
.unwrap_or(false)
|
||||
});
|
||||
}
|
||||
|
||||
// Apply folder path filter
|
||||
if let Some(ref fp) = folder_path {
|
||||
all_conversations.retain(|s| {
|
||||
s.folder_path
|
||||
.as_deref()
|
||||
.map(|p| path_eq_for_matching(p, fp.as_str()))
|
||||
.unwrap_or(false)
|
||||
});
|
||||
}
|
||||
|
||||
// Apply sorting
|
||||
match sort_by.as_deref() {
|
||||
Some("oldest") => all_conversations.sort_by(|a, b| a.started_at.cmp(&b.started_at)),
|
||||
Some("messages") => all_conversations.sort_by(|a, b| b.message_count.cmp(&a.message_count)),
|
||||
_ => all_conversations.sort_by(|a, b| b.started_at.cmp(&a.started_at)), // default: newest first
|
||||
}
|
||||
|
||||
Ok(all_conversations)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn list_conversations(
|
||||
agent_type: Option<AgentType>,
|
||||
search: Option<String>,
|
||||
sort_by: Option<String>,
|
||||
folder_path: Option<String>,
|
||||
) -> Result<Vec<ConversationSummary>, String> {
|
||||
tokio::task::spawn_blocking(move || {
|
||||
list_conversations_sync(agent_type, search, sort_by, folder_path)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| e.to_string())?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_conversation(
|
||||
agent_type: AgentType,
|
||||
conversation_id: String,
|
||||
) -> Result<ConversationDetail, String> {
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let parser: Box<dyn AgentParser> = match agent_type {
|
||||
AgentType::ClaudeCode => Box::new(ClaudeParser::new()),
|
||||
AgentType::Codex => Box::new(CodexParser::new()),
|
||||
AgentType::OpenCode => Box::new(OpenCodeParser::new()),
|
||||
AgentType::Gemini => Box::new(GeminiParser::new()),
|
||||
_ => {
|
||||
return Err(format!(
|
||||
"conversation parsing not supported for {agent_type}"
|
||||
))
|
||||
}
|
||||
};
|
||||
|
||||
parser
|
||||
.get_conversation(&conversation_id)
|
||||
.map_err(|e| e.to_string())
|
||||
})
|
||||
.await
|
||||
.map_err(|e| e.to_string())?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn list_folders() -> Result<Vec<FolderInfo>, String> {
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let all_conversations = list_conversations_sync(None, None, None, None)?;
|
||||
Ok(compute_folders(&all_conversations))
|
||||
})
|
||||
.await
|
||||
.map_err(|e| e.to_string())?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_stats() -> Result<AgentStats, String> {
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let all_conversations = list_conversations_sync(None, None, None, None)?;
|
||||
compute_stats(&all_conversations)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| e.to_string())?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_sidebar_data() -> Result<SidebarData, String> {
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let all_conversations = list_conversations_sync(None, None, None, None)?;
|
||||
let folders = compute_folders(&all_conversations);
|
||||
let stats = compute_stats(&all_conversations)?;
|
||||
Ok(SidebarData { folders, stats })
|
||||
})
|
||||
.await
|
||||
.map_err(|e| e.to_string())?
|
||||
}
|
||||
|
||||
fn compute_folders(all_conversations: &[ConversationSummary]) -> Vec<FolderInfo> {
|
||||
let mut folder_map: HashMap<String, FolderInfo> = HashMap::new();
|
||||
|
||||
for conversation in all_conversations {
|
||||
let path = conversation
|
||||
.folder_path
|
||||
.clone()
|
||||
.unwrap_or_else(|| "unknown".to_string());
|
||||
let name = conversation
|
||||
.folder_name
|
||||
.clone()
|
||||
.unwrap_or_else(|| "unknown".to_string());
|
||||
|
||||
let entry = folder_map
|
||||
.entry(path.clone())
|
||||
.or_insert_with(|| FolderInfo {
|
||||
path: path.clone(),
|
||||
name,
|
||||
agent_types: Vec::new(),
|
||||
conversation_count: 0,
|
||||
});
|
||||
|
||||
entry.conversation_count += 1;
|
||||
if !entry.agent_types.contains(&conversation.agent_type) {
|
||||
entry.agent_types.push(conversation.agent_type);
|
||||
}
|
||||
}
|
||||
|
||||
let mut folders: Vec<FolderInfo> = folder_map.into_values().collect();
|
||||
folders.sort_by(|a, b| b.conversation_count.cmp(&a.conversation_count));
|
||||
folders
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn import_local_conversations(
|
||||
db: tauri::State<'_, AppDatabase>,
|
||||
folder_id: i32,
|
||||
) -> Result<ImportResult, String> {
|
||||
let folder = folder_service::get_folder_by_id(&db.conn, folder_id)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?
|
||||
.ok_or_else(|| format!("Folder not found: {folder_id}"))?;
|
||||
|
||||
import_service::import_local_conversations(&db.conn, folder_id, &folder.path)
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_folder_conversation(
|
||||
db: tauri::State<'_, AppDatabase>,
|
||||
conversation_id: i32,
|
||||
) -> Result<DbConversationDetail, String> {
|
||||
let summary = conversation_service::get_by_id(&db.conn, conversation_id)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
let (turns, session_stats) = if let Some(ref ext_id) = summary.external_id {
|
||||
let at = summary.agent_type;
|
||||
let eid = ext_id.clone();
|
||||
tokio::task::spawn_blocking(move || -> Result<_, String> {
|
||||
let parser: Box<dyn AgentParser> = match at {
|
||||
AgentType::ClaudeCode => Box::new(ClaudeParser::new()),
|
||||
AgentType::Codex => Box::new(CodexParser::new()),
|
||||
AgentType::OpenCode => Box::new(OpenCodeParser::new()),
|
||||
AgentType::Gemini => Box::new(GeminiParser::new()),
|
||||
_ => return Ok((vec![], None)),
|
||||
};
|
||||
// If the external session file doesn't exist yet (e.g., new ACP session
|
||||
// not yet synced to disk), return empty turns instead of failing.
|
||||
match parser.get_conversation(&eid) {
|
||||
Ok(d) => Ok((d.turns, d.session_stats)),
|
||||
Err(crate::parsers::ParseError::ConversationNotFound(_)) => Ok((vec![], None)),
|
||||
Err(e) => Err(e.to_string()),
|
||||
}
|
||||
})
|
||||
.await
|
||||
.map_err(|e| e.to_string())?
|
||||
.map_err(|e: String| e)?
|
||||
} else {
|
||||
(vec![], None)
|
||||
};
|
||||
|
||||
let mut summary = summary;
|
||||
summary.message_count = turns.len() as u32;
|
||||
|
||||
Ok(DbConversationDetail {
|
||||
summary,
|
||||
turns,
|
||||
session_stats,
|
||||
})
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn create_conversation(
|
||||
db: tauri::State<'_, AppDatabase>,
|
||||
folder_id: i32,
|
||||
agent_type: AgentType,
|
||||
title: Option<String>,
|
||||
) -> Result<i32, String> {
|
||||
// Detect current git branch from the folder path
|
||||
let git_branch = if let Some(folder) = folder_service::get_folder_by_id(&db.conn, folder_id)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?
|
||||
{
|
||||
detect_git_branch(&folder.path).await
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let model = conversation_service::create(&db.conn, folder_id, agent_type, title, git_branch)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(model.id)
|
||||
}
|
||||
|
||||
async fn detect_git_branch(path: &str) -> Option<String> {
|
||||
let output = crate::process::tokio_command("git")
|
||||
.args(["rev-parse", "--abbrev-ref", "HEAD"])
|
||||
.current_dir(path)
|
||||
.output()
|
||||
.await
|
||||
.ok()?;
|
||||
|
||||
if !output.status.success() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let branch = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
||||
if branch.is_empty() || branch == "HEAD" {
|
||||
return None;
|
||||
}
|
||||
Some(branch)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn update_conversation_status(
|
||||
db: tauri::State<'_, AppDatabase>,
|
||||
conversation_id: i32,
|
||||
status: String,
|
||||
) -> Result<(), String> {
|
||||
let status_enum: conversation::ConversationStatus =
|
||||
serde_json::from_value(serde_json::Value::String(status)).map_err(|e| e.to_string())?;
|
||||
conversation_service::update_status(&db.conn, conversation_id, status_enum)
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn update_conversation_title(
|
||||
db: tauri::State<'_, AppDatabase>,
|
||||
conversation_id: i32,
|
||||
title: String,
|
||||
) -> Result<(), String> {
|
||||
conversation_service::update_title(&db.conn, conversation_id, title)
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn update_conversation_external_id(
|
||||
db: tauri::State<'_, AppDatabase>,
|
||||
conversation_id: i32,
|
||||
external_id: String,
|
||||
) -> Result<(), String> {
|
||||
conversation_service::update_external_id(&db.conn, conversation_id, external_id)
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn delete_conversation(
|
||||
db: tauri::State<'_, AppDatabase>,
|
||||
conversation_id: i32,
|
||||
) -> Result<(), String> {
|
||||
conversation_service::soft_delete(&db.conn, conversation_id)
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
fn compute_stats(all_conversations: &[ConversationSummary]) -> Result<AgentStats, String> {
|
||||
let mut total_messages: u32 = 0;
|
||||
let mut counts: HashMap<AgentType, u32> = HashMap::new();
|
||||
|
||||
for conversation in all_conversations {
|
||||
total_messages += conversation.message_count;
|
||||
*counts.entry(conversation.agent_type).or_insert(0) += 1;
|
||||
}
|
||||
|
||||
let mut by_agent: Vec<AgentConversationCount> = counts
|
||||
.into_iter()
|
||||
.map(|(agent_type, conversation_count)| AgentConversationCount {
|
||||
agent_type,
|
||||
conversation_count,
|
||||
})
|
||||
.collect();
|
||||
by_agent.sort_by(|a, b| b.conversation_count.cmp(&a.conversation_count));
|
||||
|
||||
Ok(AgentStats {
|
||||
total_conversations: all_conversations.len() as u32,
|
||||
total_messages,
|
||||
by_agent,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
use crate::db::error::DbError;
|
||||
use crate::db::service::folder_command_service;
|
||||
use crate::db::AppDatabase;
|
||||
use crate::models::FolderCommandInfo;
|
||||
use std::path::Path;
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
static BOOTSTRAP_FOLDER_COMMANDS_LOCK: Mutex<()> = Mutex::const_new(());
|
||||
|
||||
fn load_package_scripts_as_commands(folder_path: &str) -> Vec<(String, String)> {
|
||||
let mut has_package_json = false;
|
||||
let mut has_pnpm_lock = false;
|
||||
let mut has_yarn_lock = false;
|
||||
let mut has_bun_lock = false;
|
||||
|
||||
let entries = match std::fs::read_dir(folder_path) {
|
||||
Ok(entries) => entries,
|
||||
Err(_) => return Vec::new(),
|
||||
};
|
||||
|
||||
for entry in entries.flatten() {
|
||||
let Some(file_name) = entry.file_name().to_str().map(|s| s.to_string()) else {
|
||||
continue;
|
||||
};
|
||||
match file_name.as_str() {
|
||||
"package.json" => has_package_json = true,
|
||||
"pnpm-lock.yaml" => has_pnpm_lock = true,
|
||||
"yarn.lock" => has_yarn_lock = true,
|
||||
"bun.lockb" | "bun.lock" => has_bun_lock = true,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
if !has_package_json {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
let package_json_path = Path::new(folder_path).join("package.json");
|
||||
let package_json_content = match std::fs::read_to_string(package_json_path) {
|
||||
Ok(content) => content,
|
||||
Err(_) => return Vec::new(),
|
||||
};
|
||||
|
||||
let package_json: serde_json::Value = match serde_json::from_str(&package_json_content) {
|
||||
Ok(value) => value,
|
||||
Err(_) => return Vec::new(),
|
||||
};
|
||||
|
||||
let package_manager = if has_pnpm_lock {
|
||||
"pnpm"
|
||||
} else if has_yarn_lock {
|
||||
"yarn"
|
||||
} else if has_bun_lock {
|
||||
"bun"
|
||||
} else {
|
||||
"npm"
|
||||
};
|
||||
|
||||
let mut commands = Vec::new();
|
||||
if let Some(scripts) = package_json.get("scripts").and_then(|s| s.as_object()) {
|
||||
for (script_name, script_value) in scripts {
|
||||
if script_name.trim().is_empty() || script_value.as_str().is_none() {
|
||||
continue;
|
||||
}
|
||||
commands.push((
|
||||
script_name.to_string(),
|
||||
format!("{package_manager} run {script_name}"),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
commands
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn list_folder_commands(
|
||||
db: tauri::State<'_, AppDatabase>,
|
||||
folder_id: i32,
|
||||
) -> Result<Vec<FolderCommandInfo>, DbError> {
|
||||
folder_command_service::list_by_folder(&db.conn, folder_id).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn create_folder_command(
|
||||
db: tauri::State<'_, AppDatabase>,
|
||||
folder_id: i32,
|
||||
name: String,
|
||||
command: String,
|
||||
) -> Result<FolderCommandInfo, DbError> {
|
||||
folder_command_service::create(&db.conn, folder_id, &name, &command).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn update_folder_command(
|
||||
db: tauri::State<'_, AppDatabase>,
|
||||
id: i32,
|
||||
name: Option<String>,
|
||||
command: Option<String>,
|
||||
sort_order: Option<i32>,
|
||||
) -> Result<FolderCommandInfo, DbError> {
|
||||
folder_command_service::update(&db.conn, id, name, command, sort_order).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn delete_folder_command(
|
||||
db: tauri::State<'_, AppDatabase>,
|
||||
id: i32,
|
||||
) -> Result<(), DbError> {
|
||||
folder_command_service::delete(&db.conn, id).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn reorder_folder_commands(
|
||||
db: tauri::State<'_, AppDatabase>,
|
||||
folder_id: i32,
|
||||
ids: Vec<i32>,
|
||||
) -> Result<(), DbError> {
|
||||
folder_command_service::reorder(&db.conn, folder_id, ids).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn bootstrap_folder_commands_from_package_json(
|
||||
db: tauri::State<'_, AppDatabase>,
|
||||
folder_id: i32,
|
||||
folder_path: String,
|
||||
) -> Result<Vec<FolderCommandInfo>, DbError> {
|
||||
let existing = folder_command_service::list_by_folder(&db.conn, folder_id).await?;
|
||||
if !existing.is_empty() {
|
||||
return Ok(existing);
|
||||
}
|
||||
|
||||
let path_for_task = folder_path;
|
||||
let commands_to_create =
|
||||
tokio::task::spawn_blocking(move || load_package_scripts_as_commands(&path_for_task))
|
||||
.await
|
||||
.map_err(|e| DbError::Migration(format!("bootstrap task failed: {e}")))?;
|
||||
|
||||
if commands_to_create.is_empty() {
|
||||
return Ok(existing);
|
||||
}
|
||||
|
||||
// Serialize bootstrap so concurrent calls do not create duplicate commands.
|
||||
let _bootstrap_guard = BOOTSTRAP_FOLDER_COMMANDS_LOCK.lock().await;
|
||||
|
||||
let latest = folder_command_service::list_by_folder(&db.conn, folder_id).await?;
|
||||
if !latest.is_empty() {
|
||||
return Ok(latest);
|
||||
}
|
||||
|
||||
folder_command_service::create_many(&db.conn, folder_id, &commands_to_create).await?;
|
||||
|
||||
folder_command_service::list_by_folder(&db.conn, folder_id).await
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
pub mod acp;
|
||||
pub mod conversations;
|
||||
pub mod folder_commands;
|
||||
pub mod folders;
|
||||
pub mod mcp;
|
||||
pub mod system_settings;
|
||||
pub mod terminal;
|
||||
pub mod windows;
|
||||
@@ -0,0 +1,78 @@
|
||||
use sea_orm::DatabaseConnection;
|
||||
use tauri::State;
|
||||
|
||||
use crate::db::service::app_metadata_service;
|
||||
use crate::db::AppDatabase;
|
||||
use crate::models::SystemProxySettings;
|
||||
use crate::network::proxy;
|
||||
|
||||
const SYSTEM_PROXY_SETTINGS_KEY: &str = "system_proxy_settings";
|
||||
|
||||
fn normalize_proxy_settings(settings: SystemProxySettings) -> Result<SystemProxySettings, String> {
|
||||
if !settings.enabled {
|
||||
let proxy_url = settings
|
||||
.proxy_url
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(str::to_string);
|
||||
|
||||
return Ok(SystemProxySettings {
|
||||
enabled: false,
|
||||
proxy_url,
|
||||
});
|
||||
}
|
||||
|
||||
let proxy_url = settings
|
||||
.proxy_url
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.ok_or_else(|| "proxy url is required when proxy is enabled".to_string())?;
|
||||
|
||||
reqwest::Proxy::all(proxy_url).map_err(|e| format!("invalid proxy url: {e}"))?;
|
||||
|
||||
Ok(SystemProxySettings {
|
||||
enabled: true,
|
||||
proxy_url: Some(proxy_url.to_string()),
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) async fn load_system_proxy_settings(
|
||||
conn: &DatabaseConnection,
|
||||
) -> Result<SystemProxySettings, String> {
|
||||
let raw = app_metadata_service::get_value(conn, SYSTEM_PROXY_SETTINGS_KEY)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
let Some(raw) = raw else {
|
||||
return Ok(SystemProxySettings::default());
|
||||
};
|
||||
|
||||
let parsed = serde_json::from_str::<SystemProxySettings>(&raw)
|
||||
.map_err(|e| format!("failed to parse stored proxy settings: {e}"))?;
|
||||
normalize_proxy_settings(parsed)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_system_proxy_settings(
|
||||
db: State<'_, AppDatabase>,
|
||||
) -> Result<SystemProxySettings, String> {
|
||||
load_system_proxy_settings(&db.conn).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn update_system_proxy_settings(
|
||||
settings: SystemProxySettings,
|
||||
db: State<'_, AppDatabase>,
|
||||
) -> Result<SystemProxySettings, String> {
|
||||
let normalized = normalize_proxy_settings(settings)?;
|
||||
let serialized = serde_json::to_string(&normalized).map_err(|e| e.to_string())?;
|
||||
|
||||
app_metadata_service::upsert_value(&db.conn, SYSTEM_PROXY_SETTINGS_KEY, &serialized)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
proxy::apply_system_proxy_settings(&normalized)?;
|
||||
Ok(normalized)
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
use tauri::State;
|
||||
|
||||
use crate::terminal::error::TerminalError;
|
||||
use crate::terminal::manager::TerminalManager;
|
||||
use crate::terminal::types::TerminalInfo;
|
||||
|
||||
#[tauri::command]
|
||||
pub fn terminal_spawn(
|
||||
working_dir: String,
|
||||
initial_command: Option<String>,
|
||||
manager: State<'_, TerminalManager>,
|
||||
app_handle: tauri::AppHandle,
|
||||
window: tauri::WebviewWindow,
|
||||
) -> Result<String, TerminalError> {
|
||||
manager.spawn(
|
||||
working_dir,
|
||||
window.label().to_string(),
|
||||
app_handle,
|
||||
initial_command,
|
||||
)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn terminal_write(
|
||||
terminal_id: String,
|
||||
data: String,
|
||||
manager: State<'_, TerminalManager>,
|
||||
) -> Result<(), TerminalError> {
|
||||
manager.write(&terminal_id, data.as_bytes())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn terminal_resize(
|
||||
terminal_id: String,
|
||||
cols: u16,
|
||||
rows: u16,
|
||||
manager: State<'_, TerminalManager>,
|
||||
) -> Result<(), TerminalError> {
|
||||
manager.resize(&terminal_id, cols, rows)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn terminal_kill(
|
||||
terminal_id: String,
|
||||
manager: State<'_, TerminalManager>,
|
||||
) -> Result<(), TerminalError> {
|
||||
manager.kill(&terminal_id)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn terminal_list(
|
||||
manager: State<'_, TerminalManager>,
|
||||
app_handle: tauri::AppHandle,
|
||||
) -> Result<Vec<TerminalInfo>, TerminalError> {
|
||||
Ok(manager.list_with_exit_check(Some(&app_handle)))
|
||||
}
|
||||
@@ -0,0 +1,365 @@
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::sync::Mutex;
|
||||
|
||||
use tauri::{AppHandle, Manager, WebviewUrl, WebviewWindowBuilder};
|
||||
|
||||
use crate::db::AppDatabase;
|
||||
use crate::models::FolderHistoryEntry;
|
||||
|
||||
pub struct SettingsWindowState {
|
||||
owner_window_label: Mutex<Option<String>>,
|
||||
disabled_windows: Mutex<HashSet<String>>,
|
||||
}
|
||||
|
||||
pub struct CommitWindowState {
|
||||
owner_by_commit_label: Mutex<HashMap<String, String>>,
|
||||
}
|
||||
|
||||
pub(crate) fn apply_platform_window_style<'a, R, M>(
|
||||
builder: WebviewWindowBuilder<'a, R, M>,
|
||||
) -> WebviewWindowBuilder<'a, R, M>
|
||||
where
|
||||
R: tauri::Runtime,
|
||||
M: tauri::Manager<R>,
|
||||
{
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
return builder
|
||||
.hidden_title(true)
|
||||
.title_bar_style(tauri::TitleBarStyle::Overlay);
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
return builder.decorations(false);
|
||||
}
|
||||
|
||||
#[cfg(not(any(target_os = "macos", target_os = "windows")))]
|
||||
{
|
||||
builder
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn ensure_windows_undecorated(window: &tauri::WebviewWindow) {
|
||||
let _ = window.set_decorations(false);
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
fn ensure_windows_undecorated(_window: &tauri::WebviewWindow) {}
|
||||
|
||||
impl SettingsWindowState {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
owner_window_label: Mutex::new(None),
|
||||
disabled_windows: Mutex::new(HashSet::new()),
|
||||
}
|
||||
}
|
||||
|
||||
fn set_owner(&self, label: String) {
|
||||
if let Ok(mut owner) = self.owner_window_label.lock() {
|
||||
*owner = Some(label);
|
||||
}
|
||||
}
|
||||
|
||||
fn take_owner(&self) -> Option<String> {
|
||||
self.owner_window_label
|
||||
.lock()
|
||||
.ok()
|
||||
.and_then(|mut owner| owner.take())
|
||||
}
|
||||
|
||||
fn set_disabled_windows(&self, labels: HashSet<String>) {
|
||||
if let Ok(mut disabled) = self.disabled_windows.lock() {
|
||||
*disabled = labels;
|
||||
}
|
||||
}
|
||||
|
||||
fn take_disabled_windows(&self) -> HashSet<String> {
|
||||
self.disabled_windows
|
||||
.lock()
|
||||
.map(|mut disabled| std::mem::take(&mut *disabled))
|
||||
.unwrap_or_default()
|
||||
}
|
||||
}
|
||||
|
||||
impl CommitWindowState {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
owner_by_commit_label: Mutex::new(HashMap::new()),
|
||||
}
|
||||
}
|
||||
|
||||
fn set_owner(&self, commit_label: String, owner_label: String) {
|
||||
if let Ok(mut owners) = self.owner_by_commit_label.lock() {
|
||||
owners.insert(commit_label, owner_label);
|
||||
}
|
||||
}
|
||||
|
||||
fn take_owner(&self, commit_label: &str) -> Option<String> {
|
||||
self.owner_by_commit_label
|
||||
.lock()
|
||||
.ok()
|
||||
.and_then(|mut owners| owners.remove(commit_label))
|
||||
}
|
||||
}
|
||||
|
||||
fn get_folder_id_from_window(window: &tauri::WebviewWindow) -> Option<i32> {
|
||||
let url = window.url().ok()?;
|
||||
url.query_pairs()
|
||||
.find(|(key, _)| key == "id")
|
||||
.and_then(|(_, value)| value.parse::<i32>().ok())
|
||||
}
|
||||
|
||||
fn resolve_settings_route(section: Option<&str>) -> &'static str {
|
||||
match section {
|
||||
Some("appearance") => "settings/appearance",
|
||||
Some("agents") => "settings/agents",
|
||||
Some("mcp") => "settings/mcp",
|
||||
Some("skills") => "settings/skills",
|
||||
Some("shortcuts") => "settings/shortcuts",
|
||||
Some("system") => "settings/system",
|
||||
_ => "settings/system",
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_agent_query(agent_type: Option<&str>) -> Option<String> {
|
||||
let raw = agent_type?.trim();
|
||||
if raw.is_empty() {
|
||||
return None;
|
||||
}
|
||||
if raw
|
||||
.chars()
|
||||
.all(|ch| ch.is_ascii_lowercase() || ch.is_ascii_digit() || ch == '_')
|
||||
{
|
||||
return Some(raw.to_string());
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn resolve_settings_target(section: Option<&str>, agent_type: Option<&str>) -> String {
|
||||
let route = resolve_settings_route(section);
|
||||
if route == "settings/agents" {
|
||||
if let Some(agent) = normalize_agent_query(agent_type) {
|
||||
return format!("{route}?agent={agent}");
|
||||
}
|
||||
}
|
||||
route.to_string()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn list_open_folders(
|
||||
app: AppHandle,
|
||||
db: tauri::State<'_, AppDatabase>,
|
||||
) -> Result<Vec<FolderHistoryEntry>, String> {
|
||||
let windows = app.webview_windows();
|
||||
let mut folder_ids: Vec<i32> = Vec::new();
|
||||
|
||||
for (label, window) in &windows {
|
||||
if label.starts_with("folder-") {
|
||||
if let Some(id) = get_folder_id_from_window(window) {
|
||||
folder_ids.push(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let all_folders = crate::db::service::folder_service::list_folders(&db.conn)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
let open_folders: Vec<FolderHistoryEntry> = all_folders
|
||||
.into_iter()
|
||||
.filter(|f| folder_ids.contains(&f.id))
|
||||
.collect();
|
||||
|
||||
Ok(open_folders)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn focus_folder_window(app: AppHandle, folder_id: i32) -> Result<(), String> {
|
||||
let windows = app.webview_windows();
|
||||
for (label, window) in &windows {
|
||||
if label.starts_with("folder-") {
|
||||
if let Some(id) = get_folder_id_from_window(window) {
|
||||
if id == folder_id {
|
||||
window.set_focus().map_err(|e| e.to_string())?;
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(format!("No open window for folder {}", folder_id))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn open_folder_window(
|
||||
app: AppHandle,
|
||||
db: tauri::State<'_, AppDatabase>,
|
||||
path: String,
|
||||
) -> Result<(), String> {
|
||||
// Add to history via DB
|
||||
let entry = crate::db::service::folder_service::add_folder(&db.conn, &path)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
// Create folder window with unique label
|
||||
let label = format!("folder-{}", uuid::Uuid::new_v4());
|
||||
let url = WebviewUrl::App(format!("folder?id={}", entry.id).into());
|
||||
let builder = WebviewWindowBuilder::new(&app, &label, url)
|
||||
.title(&entry.name)
|
||||
.inner_size(1260.0, 860.0)
|
||||
.min_inner_size(900.0, 600.0);
|
||||
let folder_window = apply_platform_window_style(builder)
|
||||
.build()
|
||||
.map_err(|e| e.to_string())?;
|
||||
ensure_windows_undecorated(&folder_window);
|
||||
|
||||
// Close welcome window
|
||||
if let Some(w) = app.get_webview_window("welcome") {
|
||||
w.close().map_err(|e| e.to_string())?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn open_commit_window(
|
||||
app: AppHandle,
|
||||
window: tauri::WebviewWindow,
|
||||
db: tauri::State<'_, AppDatabase>,
|
||||
state: tauri::State<'_, CommitWindowState>,
|
||||
folder_id: i32,
|
||||
) -> Result<(), String> {
|
||||
let owner_label = window.label().to_string();
|
||||
let label = format!("commit-{folder_id}");
|
||||
|
||||
if let Some(existing) = app.get_webview_window(&label) {
|
||||
if let Some(owner_window) = app.get_webview_window(&owner_label) {
|
||||
owner_window.set_enabled(false).map_err(|e| e.to_string())?;
|
||||
}
|
||||
state.set_owner(label.clone(), owner_label);
|
||||
let _ = existing.unminimize();
|
||||
existing.set_focus().map_err(|e| e.to_string())?;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let folder = crate::db::service::folder_service::get_folder_by_id(&db.conn, folder_id)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?
|
||||
.ok_or_else(|| format!("Folder {} not found", folder_id))?;
|
||||
|
||||
let url = WebviewUrl::App(format!("commit?folderId={folder_id}").into());
|
||||
let builder = WebviewWindowBuilder::new(&app, &label, url)
|
||||
.title(&format!("提交代码 - {}", folder.name))
|
||||
.inner_size(1220.0, 820.0)
|
||||
.min_inner_size(980.0, 620.0)
|
||||
.always_on_top(true)
|
||||
.center();
|
||||
let commit_window = apply_platform_window_style(builder)
|
||||
.build()
|
||||
.map_err(|e| e.to_string())?;
|
||||
ensure_windows_undecorated(&commit_window);
|
||||
if let Some(owner_window) = app.get_webview_window(&owner_label) {
|
||||
if let Err(err) = owner_window.set_enabled(false) {
|
||||
let _ = commit_window.close();
|
||||
return Err(err.to_string());
|
||||
}
|
||||
}
|
||||
state.set_owner(label, owner_label);
|
||||
commit_window.set_focus().map_err(|e| e.to_string())?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn open_settings_window(
|
||||
app: AppHandle,
|
||||
window: tauri::WebviewWindow,
|
||||
section: Option<String>,
|
||||
agent_type: Option<String>,
|
||||
state: tauri::State<'_, SettingsWindowState>,
|
||||
) -> Result<(), String> {
|
||||
let target_route = resolve_settings_target(section.as_deref(), agent_type.as_deref());
|
||||
if let Some(existing) = app.get_webview_window("settings") {
|
||||
ensure_windows_undecorated(&existing);
|
||||
if section.is_some() || agent_type.is_some() {
|
||||
let target_path = format!("/{target_route}");
|
||||
let target_json = serde_json::to_string(&target_path).map_err(|e| e.to_string())?;
|
||||
let nav_script = format!("window.location.replace({target_json});");
|
||||
existing.eval(&nav_script).map_err(|e| e.to_string())?;
|
||||
}
|
||||
let _ = existing.unminimize();
|
||||
existing.set_focus().map_err(|e| e.to_string())?;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let owner_label = window.label().to_string();
|
||||
let url = WebviewUrl::App(target_route.into());
|
||||
let builder = WebviewWindowBuilder::new(&app, "settings", url)
|
||||
.title("Settings")
|
||||
.inner_size(1080.0, 700.0)
|
||||
.min_inner_size(1080.0, 600.0)
|
||||
.always_on_top(true)
|
||||
.center();
|
||||
let settings_window = apply_platform_window_style(builder)
|
||||
.build()
|
||||
.map_err(|e| e.to_string())?;
|
||||
ensure_windows_undecorated(&settings_window);
|
||||
|
||||
let mut disabled = HashSet::new();
|
||||
for (label, webview) in app.webview_windows() {
|
||||
if label != "settings" {
|
||||
webview.set_enabled(false).map_err(|e| e.to_string())?;
|
||||
disabled.insert(label);
|
||||
}
|
||||
}
|
||||
|
||||
state.set_owner(owner_label);
|
||||
state.set_disabled_windows(disabled);
|
||||
settings_window.set_focus().map_err(|e| e.to_string())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn restore_windows_after_settings(app: &AppHandle, state: &SettingsWindowState) {
|
||||
for label in state.take_disabled_windows() {
|
||||
if let Some(window) = app.get_webview_window(&label) {
|
||||
let _ = window.set_enabled(true);
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(owner_label) = state.take_owner() {
|
||||
if let Some(window) = app.get_webview_window(&owner_label) {
|
||||
let _ = window.set_focus();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn restore_window_after_commit(
|
||||
app: &AppHandle,
|
||||
state: &CommitWindowState,
|
||||
commit_window_label: &str,
|
||||
) {
|
||||
if let Some(owner_label) = state.take_owner(commit_window_label) {
|
||||
if let Some(window) = app.get_webview_window(&owner_label) {
|
||||
let _ = window.set_enabled(true);
|
||||
let _ = window.set_focus();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn open_welcome_window(app: &AppHandle) -> Result<(), String> {
|
||||
if let Some(existing) = app.get_webview_window("welcome") {
|
||||
ensure_windows_undecorated(&existing);
|
||||
return Ok(());
|
||||
}
|
||||
let url = WebviewUrl::App("welcome".into());
|
||||
let builder = WebviewWindowBuilder::new(app, "welcome", url)
|
||||
.title("Codeg")
|
||||
.inner_size(800.0, 520.0)
|
||||
.min_inner_size(600.0, 400.0)
|
||||
.center();
|
||||
let welcome_window = apply_platform_window_style(builder)
|
||||
.build()
|
||||
.map_err(|e| e.to_string())?;
|
||||
ensure_windows_undecorated(&welcome_window);
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
use sea_orm::entity::prelude::*;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, DeriveEntityModel)]
|
||||
#[sea_orm(table_name = "agent_setting")]
|
||||
pub struct Model {
|
||||
#[sea_orm(primary_key)]
|
||||
pub id: i32,
|
||||
pub agent_type: String,
|
||||
pub registry_id: String,
|
||||
pub enabled: bool,
|
||||
pub sort_order: i32,
|
||||
pub installed_version: Option<String>,
|
||||
pub env_json: Option<String>,
|
||||
pub created_at: DateTimeUtc,
|
||||
pub updated_at: DateTimeUtc,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||
pub enum Relation {}
|
||||
|
||||
impl ActiveModelBehavior for ActiveModel {}
|
||||
@@ -0,0 +1,19 @@
|
||||
use sea_orm::entity::prelude::*;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, DeriveEntityModel)]
|
||||
#[sea_orm(table_name = "app_metadata")]
|
||||
pub struct Model {
|
||||
#[sea_orm(primary_key)]
|
||||
pub id: i32,
|
||||
#[sea_orm(unique)]
|
||||
pub key: String,
|
||||
pub value: String,
|
||||
pub created_at: DateTimeUtc,
|
||||
pub updated_at: DateTimeUtc,
|
||||
pub deleted_at: Option<DateTimeUtc>,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||
pub enum Relation {}
|
||||
|
||||
impl ActiveModelBehavior for ActiveModel {}
|
||||
@@ -0,0 +1,53 @@
|
||||
use sea_orm::entity::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, EnumIter, DeriveActiveEnum, Serialize, Deserialize)]
|
||||
#[sea_orm(rs_type = "String", db_type = "String(StringLen::None)")]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ConversationStatus {
|
||||
#[sea_orm(string_value = "in_progress")]
|
||||
InProgress,
|
||||
#[sea_orm(string_value = "pending_review")]
|
||||
PendingReview,
|
||||
#[sea_orm(string_value = "completed")]
|
||||
Completed,
|
||||
#[sea_orm(string_value = "cancelled")]
|
||||
Cancelled,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, DeriveEntityModel)]
|
||||
#[sea_orm(table_name = "conversation")]
|
||||
pub struct Model {
|
||||
#[sea_orm(primary_key)]
|
||||
pub id: i32,
|
||||
pub folder_id: i32,
|
||||
pub title: Option<String>,
|
||||
pub agent_type: String,
|
||||
pub status: ConversationStatus,
|
||||
pub model: Option<String>,
|
||||
pub git_branch: Option<String>,
|
||||
pub external_id: Option<String>,
|
||||
pub parent_id: Option<i32>,
|
||||
pub message_count: i32,
|
||||
pub created_at: DateTimeUtc,
|
||||
pub updated_at: DateTimeUtc,
|
||||
pub deleted_at: Option<DateTimeUtc>,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||
pub enum Relation {
|
||||
#[sea_orm(
|
||||
belongs_to = "super::folder::Entity",
|
||||
from = "Column::FolderId",
|
||||
to = "super::folder::Column::Id"
|
||||
)]
|
||||
Folder,
|
||||
}
|
||||
|
||||
impl Related<super::folder::Entity> for Entity {
|
||||
fn to() -> RelationDef {
|
||||
Relation::Folder.def()
|
||||
}
|
||||
}
|
||||
|
||||
impl ActiveModelBehavior for ActiveModel {}
|
||||
@@ -0,0 +1,51 @@
|
||||
use sea_orm::entity::prelude::*;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, DeriveEntityModel)]
|
||||
#[sea_orm(table_name = "folder")]
|
||||
pub struct Model {
|
||||
#[sea_orm(primary_key)]
|
||||
pub id: i32,
|
||||
pub name: String,
|
||||
#[sea_orm(unique)]
|
||||
pub path: String,
|
||||
pub git_branch: Option<String>,
|
||||
pub default_agent_type: Option<String>,
|
||||
pub last_opened_at: DateTimeUtc,
|
||||
pub created_at: DateTimeUtc,
|
||||
pub updated_at: DateTimeUtc,
|
||||
pub deleted_at: Option<DateTimeUtc>,
|
||||
pub is_open: bool,
|
||||
pub parent_branch: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||
pub enum Relation {
|
||||
#[sea_orm(has_many = "super::conversation::Entity")]
|
||||
Conversations,
|
||||
|
||||
#[sea_orm(has_many = "super::folder_opened_conversation::Entity")]
|
||||
OpenedConversations,
|
||||
|
||||
#[sea_orm(has_many = "super::folder_command::Entity")]
|
||||
FolderCommands,
|
||||
}
|
||||
|
||||
impl Related<super::conversation::Entity> for Entity {
|
||||
fn to() -> RelationDef {
|
||||
Relation::Conversations.def()
|
||||
}
|
||||
}
|
||||
|
||||
impl Related<super::folder_opened_conversation::Entity> for Entity {
|
||||
fn to() -> RelationDef {
|
||||
Relation::OpenedConversations.def()
|
||||
}
|
||||
}
|
||||
|
||||
impl Related<super::folder_command::Entity> for Entity {
|
||||
fn to() -> RelationDef {
|
||||
Relation::FolderCommands.def()
|
||||
}
|
||||
}
|
||||
|
||||
impl ActiveModelBehavior for ActiveModel {}
|
||||
@@ -0,0 +1,32 @@
|
||||
use sea_orm::entity::prelude::*;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, DeriveEntityModel)]
|
||||
#[sea_orm(table_name = "folder_command")]
|
||||
pub struct Model {
|
||||
#[sea_orm(primary_key)]
|
||||
pub id: i32,
|
||||
pub folder_id: i32,
|
||||
pub name: String,
|
||||
pub command: String,
|
||||
pub sort_order: i32,
|
||||
pub created_at: DateTimeUtc,
|
||||
pub updated_at: DateTimeUtc,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||
pub enum Relation {
|
||||
#[sea_orm(
|
||||
belongs_to = "super::folder::Entity",
|
||||
from = "Column::FolderId",
|
||||
to = "super::folder::Column::Id"
|
||||
)]
|
||||
Folder,
|
||||
}
|
||||
|
||||
impl Related<super::folder::Entity> for Entity {
|
||||
fn to() -> RelationDef {
|
||||
Relation::Folder.def()
|
||||
}
|
||||
}
|
||||
|
||||
impl ActiveModelBehavior for ActiveModel {}
|
||||
@@ -0,0 +1,47 @@
|
||||
use sea_orm::entity::prelude::*;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, DeriveEntityModel)]
|
||||
#[sea_orm(table_name = "folder_opened_conversation")]
|
||||
pub struct Model {
|
||||
#[sea_orm(primary_key)]
|
||||
pub id: i32,
|
||||
pub folder_id: i32,
|
||||
pub conversation_id: i32,
|
||||
pub position: i32,
|
||||
pub is_active: bool,
|
||||
pub is_pinned: bool,
|
||||
pub agent_type: String,
|
||||
pub created_at: DateTimeUtc,
|
||||
pub updated_at: DateTimeUtc,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||
pub enum Relation {
|
||||
#[sea_orm(
|
||||
belongs_to = "super::folder::Entity",
|
||||
from = "Column::FolderId",
|
||||
to = "super::folder::Column::Id"
|
||||
)]
|
||||
Folder,
|
||||
|
||||
#[sea_orm(
|
||||
belongs_to = "super::conversation::Entity",
|
||||
from = "Column::ConversationId",
|
||||
to = "super::conversation::Column::Id"
|
||||
)]
|
||||
Conversation,
|
||||
}
|
||||
|
||||
impl Related<super::folder::Entity> for Entity {
|
||||
fn to() -> RelationDef {
|
||||
Relation::Folder.def()
|
||||
}
|
||||
}
|
||||
|
||||
impl Related<super::conversation::Entity> for Entity {
|
||||
fn to() -> RelationDef {
|
||||
Relation::Conversation.def()
|
||||
}
|
||||
}
|
||||
|
||||
impl ActiveModelBehavior for ActiveModel {}
|
||||
@@ -0,0 +1,7 @@
|
||||
pub mod agent_setting;
|
||||
pub mod app_metadata;
|
||||
pub mod conversation;
|
||||
pub mod folder;
|
||||
pub mod folder_command;
|
||||
pub mod folder_opened_conversation;
|
||||
pub mod prelude;
|
||||
@@ -0,0 +1,8 @@
|
||||
#![allow(unused_imports)]
|
||||
|
||||
pub use super::agent_setting::Entity as AgentSetting;
|
||||
pub use super::app_metadata::Entity as AppMetadata;
|
||||
pub use super::conversation::Entity as Conversation;
|
||||
pub use super::folder::Entity as Folder;
|
||||
pub use super::folder_command::Entity as FolderCommand;
|
||||
pub use super::folder_opened_conversation::Entity as FolderOpenedConversation;
|
||||
@@ -0,0 +1,23 @@
|
||||
use serde::Serialize;
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum DbError {
|
||||
#[error("database error: {0}")]
|
||||
Database(#[from] sea_orm::DbErr),
|
||||
#[error("migration error: {0}")]
|
||||
Migration(String),
|
||||
#[allow(dead_code)]
|
||||
#[error("database not initialized")]
|
||||
NotInitialized,
|
||||
#[error("io error: {0}")]
|
||||
Io(#[from] std::io::Error),
|
||||
}
|
||||
|
||||
impl Serialize for DbError {
|
||||
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
|
||||
where
|
||||
S: serde::Serializer,
|
||||
{
|
||||
serializer.serialize_str(&self.to_string())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,378 @@
|
||||
use sea_orm_migration::prelude::*;
|
||||
|
||||
#[derive(DeriveMigrationName)]
|
||||
pub struct Migration;
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl MigrationTrait for Migration {
|
||||
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||
// 1. app_metadata table
|
||||
manager
|
||||
.create_table(
|
||||
Table::create()
|
||||
.table(AppMetadata::Table)
|
||||
.if_not_exists()
|
||||
.col(
|
||||
ColumnDef::new(AppMetadata::Id)
|
||||
.integer()
|
||||
.not_null()
|
||||
.auto_increment()
|
||||
.primary_key(),
|
||||
)
|
||||
.col(
|
||||
ColumnDef::new(AppMetadata::Key)
|
||||
.string()
|
||||
.not_null()
|
||||
.unique_key(),
|
||||
)
|
||||
.col(ColumnDef::new(AppMetadata::Value).string().not_null())
|
||||
.col(
|
||||
ColumnDef::new(AppMetadata::CreatedAt)
|
||||
.timestamp_with_time_zone()
|
||||
.not_null(),
|
||||
)
|
||||
.col(
|
||||
ColumnDef::new(AppMetadata::UpdatedAt)
|
||||
.timestamp_with_time_zone()
|
||||
.not_null(),
|
||||
)
|
||||
.col(
|
||||
ColumnDef::new(AppMetadata::DeletedAt)
|
||||
.timestamp_with_time_zone()
|
||||
.null(),
|
||||
)
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
// 2. folder table
|
||||
manager
|
||||
.create_table(
|
||||
Table::create()
|
||||
.table(Folder::Table)
|
||||
.if_not_exists()
|
||||
.col(
|
||||
ColumnDef::new(Folder::Id)
|
||||
.integer()
|
||||
.not_null()
|
||||
.auto_increment()
|
||||
.primary_key(),
|
||||
)
|
||||
.col(ColumnDef::new(Folder::Name).string().not_null())
|
||||
.col(
|
||||
ColumnDef::new(Folder::Path)
|
||||
.string()
|
||||
.not_null()
|
||||
.unique_key(),
|
||||
)
|
||||
.col(ColumnDef::new(Folder::GitBranch).string().null())
|
||||
.col(ColumnDef::new(Folder::DefaultAgentType).string().null())
|
||||
.col(
|
||||
ColumnDef::new(Folder::LastOpenedAt)
|
||||
.timestamp_with_time_zone()
|
||||
.not_null(),
|
||||
)
|
||||
.col(
|
||||
ColumnDef::new(Folder::CreatedAt)
|
||||
.timestamp_with_time_zone()
|
||||
.not_null(),
|
||||
)
|
||||
.col(
|
||||
ColumnDef::new(Folder::UpdatedAt)
|
||||
.timestamp_with_time_zone()
|
||||
.not_null(),
|
||||
)
|
||||
.col(
|
||||
ColumnDef::new(Folder::DeletedAt)
|
||||
.timestamp_with_time_zone()
|
||||
.null(),
|
||||
)
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
// 3. conversation table
|
||||
manager
|
||||
.create_table(
|
||||
Table::create()
|
||||
.table(Conversation::Table)
|
||||
.if_not_exists()
|
||||
.col(
|
||||
ColumnDef::new(Conversation::Id)
|
||||
.integer()
|
||||
.not_null()
|
||||
.auto_increment()
|
||||
.primary_key(),
|
||||
)
|
||||
.col(ColumnDef::new(Conversation::FolderId).integer().not_null())
|
||||
.col(ColumnDef::new(Conversation::Title).string().null())
|
||||
.col(ColumnDef::new(Conversation::AgentType).string().not_null())
|
||||
.col(
|
||||
ColumnDef::new(Conversation::Status)
|
||||
.string()
|
||||
.not_null()
|
||||
.default("in_progress"),
|
||||
)
|
||||
.col(ColumnDef::new(Conversation::Model).string().null())
|
||||
.col(ColumnDef::new(Conversation::GitBranch).string().null())
|
||||
.col(ColumnDef::new(Conversation::ExternalId).string().null())
|
||||
.col(ColumnDef::new(Conversation::ParentId).integer().null())
|
||||
.col(
|
||||
ColumnDef::new(Conversation::MessageCount)
|
||||
.integer()
|
||||
.not_null()
|
||||
.default(0),
|
||||
)
|
||||
.col(
|
||||
ColumnDef::new(Conversation::CreatedAt)
|
||||
.timestamp_with_time_zone()
|
||||
.not_null(),
|
||||
)
|
||||
.col(
|
||||
ColumnDef::new(Conversation::UpdatedAt)
|
||||
.timestamp_with_time_zone()
|
||||
.not_null(),
|
||||
)
|
||||
.col(
|
||||
ColumnDef::new(Conversation::DeletedAt)
|
||||
.timestamp_with_time_zone()
|
||||
.null(),
|
||||
)
|
||||
.foreign_key(
|
||||
ForeignKey::create()
|
||||
.name("fk_conversation_folder")
|
||||
.from(Conversation::Table, Conversation::FolderId)
|
||||
.to(Folder::Table, Folder::Id)
|
||||
.on_delete(ForeignKeyAction::Cascade)
|
||||
.on_update(ForeignKeyAction::Cascade),
|
||||
)
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
// 4. folder_opened_conversation table
|
||||
manager
|
||||
.create_table(
|
||||
Table::create()
|
||||
.table(FolderOpenedConversation::Table)
|
||||
.if_not_exists()
|
||||
.col(
|
||||
ColumnDef::new(FolderOpenedConversation::Id)
|
||||
.integer()
|
||||
.not_null()
|
||||
.auto_increment()
|
||||
.primary_key(),
|
||||
)
|
||||
.col(
|
||||
ColumnDef::new(FolderOpenedConversation::FolderId)
|
||||
.integer()
|
||||
.not_null(),
|
||||
)
|
||||
.col(
|
||||
ColumnDef::new(FolderOpenedConversation::ConversationId)
|
||||
.integer()
|
||||
.not_null(),
|
||||
)
|
||||
.col(
|
||||
ColumnDef::new(FolderOpenedConversation::Position)
|
||||
.integer()
|
||||
.not_null(),
|
||||
)
|
||||
.col(
|
||||
ColumnDef::new(FolderOpenedConversation::IsActive)
|
||||
.boolean()
|
||||
.not_null()
|
||||
.default(false),
|
||||
)
|
||||
.col(
|
||||
ColumnDef::new(FolderOpenedConversation::IsPinned)
|
||||
.boolean()
|
||||
.not_null()
|
||||
.default(true),
|
||||
)
|
||||
.col(
|
||||
ColumnDef::new(FolderOpenedConversation::AgentType)
|
||||
.string()
|
||||
.not_null(),
|
||||
)
|
||||
.col(
|
||||
ColumnDef::new(FolderOpenedConversation::CreatedAt)
|
||||
.timestamp_with_time_zone()
|
||||
.not_null(),
|
||||
)
|
||||
.col(
|
||||
ColumnDef::new(FolderOpenedConversation::UpdatedAt)
|
||||
.timestamp_with_time_zone()
|
||||
.not_null(),
|
||||
)
|
||||
.foreign_key(
|
||||
ForeignKey::create()
|
||||
.name("fk_foc_folder")
|
||||
.from(
|
||||
FolderOpenedConversation::Table,
|
||||
FolderOpenedConversation::FolderId,
|
||||
)
|
||||
.to(Folder::Table, Folder::Id)
|
||||
.on_delete(ForeignKeyAction::Cascade)
|
||||
.on_update(ForeignKeyAction::Cascade),
|
||||
)
|
||||
.foreign_key(
|
||||
ForeignKey::create()
|
||||
.name("fk_foc_conversation")
|
||||
.from(
|
||||
FolderOpenedConversation::Table,
|
||||
FolderOpenedConversation::ConversationId,
|
||||
)
|
||||
.to(Conversation::Table, Conversation::Id)
|
||||
.on_delete(ForeignKeyAction::Cascade)
|
||||
.on_update(ForeignKeyAction::Cascade),
|
||||
)
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
// 6. indexes
|
||||
manager
|
||||
.create_index(
|
||||
Index::create()
|
||||
.name("idx_folder_deleted_last_opened")
|
||||
.table(Folder::Table)
|
||||
.col(Folder::DeletedAt)
|
||||
.col(Folder::LastOpenedAt)
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
manager
|
||||
.create_index(
|
||||
Index::create()
|
||||
.name("idx_conversation_folder_id")
|
||||
.table(Conversation::Table)
|
||||
.col(Conversation::FolderId)
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
manager
|
||||
.create_index(
|
||||
Index::create()
|
||||
.name("idx_conversation_deleted_created")
|
||||
.table(Conversation::Table)
|
||||
.col(Conversation::DeletedAt)
|
||||
.col(Conversation::CreatedAt)
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
manager
|
||||
.create_index(
|
||||
Index::create()
|
||||
.name("idx_conversation_external_agent")
|
||||
.table(Conversation::Table)
|
||||
.col(Conversation::ExternalId)
|
||||
.col(Conversation::AgentType)
|
||||
.unique()
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
manager
|
||||
.create_index(
|
||||
Index::create()
|
||||
.name("idx_foc_folder_position")
|
||||
.table(FolderOpenedConversation::Table)
|
||||
.col(FolderOpenedConversation::FolderId)
|
||||
.col(FolderOpenedConversation::Position)
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
manager
|
||||
.create_index(
|
||||
Index::create()
|
||||
.name("idx_foc_folder_conversation")
|
||||
.table(FolderOpenedConversation::Table)
|
||||
.col(FolderOpenedConversation::FolderId)
|
||||
.col(FolderOpenedConversation::ConversationId)
|
||||
.unique()
|
||||
.to_owned(),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||
manager
|
||||
.drop_table(
|
||||
Table::drop()
|
||||
.table(FolderOpenedConversation::Table)
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
manager
|
||||
.drop_table(Table::drop().table(Conversation::Table).to_owned())
|
||||
.await?;
|
||||
manager
|
||||
.drop_table(Table::drop().table(Folder::Table).to_owned())
|
||||
.await?;
|
||||
manager
|
||||
.drop_table(Table::drop().table(AppMetadata::Table).to_owned())
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(DeriveIden)]
|
||||
enum AppMetadata {
|
||||
Table,
|
||||
Id,
|
||||
Key,
|
||||
Value,
|
||||
CreatedAt,
|
||||
UpdatedAt,
|
||||
DeletedAt,
|
||||
}
|
||||
|
||||
#[derive(DeriveIden)]
|
||||
enum Folder {
|
||||
Table,
|
||||
Id,
|
||||
Name,
|
||||
Path,
|
||||
GitBranch,
|
||||
DefaultAgentType,
|
||||
LastOpenedAt,
|
||||
CreatedAt,
|
||||
UpdatedAt,
|
||||
DeletedAt,
|
||||
}
|
||||
|
||||
#[derive(DeriveIden)]
|
||||
enum Conversation {
|
||||
Table,
|
||||
Id,
|
||||
FolderId,
|
||||
Title,
|
||||
AgentType,
|
||||
Status,
|
||||
Model,
|
||||
GitBranch,
|
||||
ExternalId,
|
||||
ParentId,
|
||||
MessageCount,
|
||||
CreatedAt,
|
||||
UpdatedAt,
|
||||
DeletedAt,
|
||||
}
|
||||
|
||||
#[derive(DeriveIden)]
|
||||
enum FolderOpenedConversation {
|
||||
Table,
|
||||
Id,
|
||||
FolderId,
|
||||
ConversationId,
|
||||
Position,
|
||||
IsActive,
|
||||
IsPinned,
|
||||
AgentType,
|
||||
CreatedAt,
|
||||
UpdatedAt,
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
use sea_orm_migration::prelude::*;
|
||||
|
||||
#[derive(DeriveMigrationName)]
|
||||
pub struct Migration;
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl MigrationTrait for Migration {
|
||||
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||
manager
|
||||
.create_table(
|
||||
Table::create()
|
||||
.table(FolderCommand::Table)
|
||||
.if_not_exists()
|
||||
.col(
|
||||
ColumnDef::new(FolderCommand::Id)
|
||||
.integer()
|
||||
.not_null()
|
||||
.auto_increment()
|
||||
.primary_key(),
|
||||
)
|
||||
.col(ColumnDef::new(FolderCommand::FolderId).integer().not_null())
|
||||
.col(ColumnDef::new(FolderCommand::Name).string().not_null())
|
||||
.col(ColumnDef::new(FolderCommand::Command).string().not_null())
|
||||
.col(
|
||||
ColumnDef::new(FolderCommand::SortOrder)
|
||||
.integer()
|
||||
.not_null()
|
||||
.default(0),
|
||||
)
|
||||
.col(
|
||||
ColumnDef::new(FolderCommand::CreatedAt)
|
||||
.timestamp_with_time_zone()
|
||||
.not_null(),
|
||||
)
|
||||
.col(
|
||||
ColumnDef::new(FolderCommand::UpdatedAt)
|
||||
.timestamp_with_time_zone()
|
||||
.not_null(),
|
||||
)
|
||||
.foreign_key(
|
||||
ForeignKey::create()
|
||||
.name("fk_folder_command_folder")
|
||||
.from(FolderCommand::Table, FolderCommand::FolderId)
|
||||
.to(Folder::Table, Folder::Id)
|
||||
.on_delete(ForeignKeyAction::Cascade)
|
||||
.on_update(ForeignKeyAction::Cascade),
|
||||
)
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
manager
|
||||
.create_index(
|
||||
Index::create()
|
||||
.name("idx_folder_command_folder_id")
|
||||
.table(FolderCommand::Table)
|
||||
.col(FolderCommand::FolderId)
|
||||
.col(FolderCommand::SortOrder)
|
||||
.to_owned(),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||
manager
|
||||
.drop_table(Table::drop().table(FolderCommand::Table).to_owned())
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(DeriveIden)]
|
||||
enum FolderCommand {
|
||||
Table,
|
||||
Id,
|
||||
FolderId,
|
||||
Name,
|
||||
Command,
|
||||
SortOrder,
|
||||
CreatedAt,
|
||||
UpdatedAt,
|
||||
}
|
||||
|
||||
#[derive(DeriveIden)]
|
||||
enum Folder {
|
||||
Table,
|
||||
Id,
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
use sea_orm_migration::prelude::*;
|
||||
|
||||
#[derive(DeriveMigrationName)]
|
||||
pub struct Migration;
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl MigrationTrait for Migration {
|
||||
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||
manager
|
||||
.alter_table(
|
||||
Table::alter()
|
||||
.table(Folder::Table)
|
||||
.add_column(
|
||||
ColumnDef::new(Folder::IsOpen)
|
||||
.boolean()
|
||||
.not_null()
|
||||
.default(false),
|
||||
)
|
||||
.to_owned(),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||
manager
|
||||
.alter_table(
|
||||
Table::alter()
|
||||
.table(Folder::Table)
|
||||
.drop_column(Folder::IsOpen)
|
||||
.to_owned(),
|
||||
)
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(DeriveIden)]
|
||||
enum Folder {
|
||||
Table,
|
||||
IsOpen,
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
use sea_orm_migration::prelude::*;
|
||||
|
||||
#[derive(DeriveMigrationName)]
|
||||
pub struct Migration;
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl MigrationTrait for Migration {
|
||||
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||
manager
|
||||
.create_table(
|
||||
Table::create()
|
||||
.table(AgentSetting::Table)
|
||||
.if_not_exists()
|
||||
.col(
|
||||
ColumnDef::new(AgentSetting::Id)
|
||||
.integer()
|
||||
.not_null()
|
||||
.auto_increment()
|
||||
.primary_key(),
|
||||
)
|
||||
.col(
|
||||
ColumnDef::new(AgentSetting::AgentType)
|
||||
.string()
|
||||
.not_null()
|
||||
.unique_key(),
|
||||
)
|
||||
.col(ColumnDef::new(AgentSetting::RegistryId).string().not_null())
|
||||
.col(
|
||||
ColumnDef::new(AgentSetting::Enabled)
|
||||
.boolean()
|
||||
.not_null()
|
||||
.default(true),
|
||||
)
|
||||
.col(
|
||||
ColumnDef::new(AgentSetting::SortOrder)
|
||||
.integer()
|
||||
.not_null()
|
||||
.default(0),
|
||||
)
|
||||
.col(
|
||||
ColumnDef::new(AgentSetting::InstalledVersion)
|
||||
.string()
|
||||
.null(),
|
||||
)
|
||||
.col(ColumnDef::new(AgentSetting::EnvJson).text().null())
|
||||
.col(
|
||||
ColumnDef::new(AgentSetting::CreatedAt)
|
||||
.timestamp_with_time_zone()
|
||||
.not_null(),
|
||||
)
|
||||
.col(
|
||||
ColumnDef::new(AgentSetting::UpdatedAt)
|
||||
.timestamp_with_time_zone()
|
||||
.not_null(),
|
||||
)
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
manager
|
||||
.create_index(
|
||||
Index::create()
|
||||
.name("idx_agent_setting_sort_order")
|
||||
.table(AgentSetting::Table)
|
||||
.col(AgentSetting::SortOrder)
|
||||
.to_owned(),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||
manager
|
||||
.drop_table(Table::drop().table(AgentSetting::Table).to_owned())
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(DeriveIden)]
|
||||
enum AgentSetting {
|
||||
Table,
|
||||
Id,
|
||||
AgentType,
|
||||
RegistryId,
|
||||
Enabled,
|
||||
SortOrder,
|
||||
InstalledVersion,
|
||||
EnvJson,
|
||||
CreatedAt,
|
||||
UpdatedAt,
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
use sea_orm_migration::prelude::*;
|
||||
|
||||
#[derive(DeriveMigrationName)]
|
||||
pub struct Migration;
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl MigrationTrait for Migration {
|
||||
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||
manager
|
||||
.alter_table(
|
||||
Table::alter()
|
||||
.table(Folder::Table)
|
||||
.add_column(ColumnDef::new(Folder::ParentBranch).string().null())
|
||||
.to_owned(),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||
manager
|
||||
.alter_table(
|
||||
Table::alter()
|
||||
.table(Folder::Table)
|
||||
.drop_column(Folder::ParentBranch)
|
||||
.to_owned(),
|
||||
)
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(DeriveIden)]
|
||||
enum Folder {
|
||||
Table,
|
||||
ParentBranch,
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
use sea_orm_migration::prelude::*;
|
||||
|
||||
mod m20260211_000001_init;
|
||||
mod m20260219_000001_folder_command;
|
||||
mod m20260221_000001_folder_is_open;
|
||||
mod m20260226_000001_agent_setting;
|
||||
mod m20260227_000001_folder_parent_branch;
|
||||
pub struct Migrator;
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl MigratorTrait for Migrator {
|
||||
fn migrations() -> Vec<Box<dyn MigrationTrait>> {
|
||||
vec![
|
||||
Box::new(m20260211_000001_init::Migration),
|
||||
Box::new(m20260219_000001_folder_command::Migration),
|
||||
Box::new(m20260221_000001_folder_is_open::Migration),
|
||||
Box::new(m20260226_000001_agent_setting::Migration),
|
||||
Box::new(m20260227_000001_folder_parent_branch::Migration),
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
pub mod entities;
|
||||
pub mod error;
|
||||
pub mod migration;
|
||||
pub mod service;
|
||||
|
||||
use std::path::Path;
|
||||
use std::time::Duration;
|
||||
|
||||
use sea_orm::{
|
||||
ConnectOptions, ConnectionTrait, Database, DatabaseConnection, DbBackend, Statement,
|
||||
};
|
||||
use sea_orm_migration::MigratorTrait;
|
||||
|
||||
use error::DbError;
|
||||
use migration::Migrator;
|
||||
|
||||
pub struct AppDatabase {
|
||||
#[allow(dead_code)]
|
||||
pub conn: DatabaseConnection,
|
||||
}
|
||||
|
||||
pub async fn init_database(
|
||||
app_data_dir: impl AsRef<Path>,
|
||||
app_version: &str,
|
||||
) -> Result<AppDatabase, DbError> {
|
||||
let app_data_dir = app_data_dir.as_ref();
|
||||
std::fs::create_dir_all(app_data_dir)?;
|
||||
|
||||
let db_path = app_data_dir.join("codeg.db");
|
||||
let db_url = format!(
|
||||
"sqlite:{}?mode=rwc",
|
||||
urlencoding::encode(&db_path.to_string_lossy())
|
||||
);
|
||||
|
||||
let mut opts = ConnectOptions::new(db_url);
|
||||
opts.max_connections(5)
|
||||
.min_connections(1)
|
||||
.connect_timeout(Duration::from_secs(10))
|
||||
.idle_timeout(Duration::from_secs(300))
|
||||
.sqlx_logging(false);
|
||||
|
||||
let conn = Database::connect(opts).await?;
|
||||
|
||||
// SQLite performance and reliability pragmas
|
||||
conn.execute(Statement::from_string(
|
||||
DbBackend::Sqlite,
|
||||
"PRAGMA journal_mode=WAL;".to_owned(),
|
||||
))
|
||||
.await?;
|
||||
conn.execute(Statement::from_string(
|
||||
DbBackend::Sqlite,
|
||||
"PRAGMA busy_timeout=5000;".to_owned(),
|
||||
))
|
||||
.await?;
|
||||
conn.execute(Statement::from_string(
|
||||
DbBackend::Sqlite,
|
||||
"PRAGMA synchronous=NORMAL;".to_owned(),
|
||||
))
|
||||
.await?;
|
||||
conn.execute(Statement::from_string(
|
||||
DbBackend::Sqlite,
|
||||
"PRAGMA foreign_keys=ON;".to_owned(),
|
||||
))
|
||||
.await?;
|
||||
conn.execute(Statement::from_string(
|
||||
DbBackend::Sqlite,
|
||||
"PRAGMA cache_size=-8000;".to_owned(),
|
||||
))
|
||||
.await?;
|
||||
|
||||
Migrator::up(&conn, None)
|
||||
.await
|
||||
.map_err(|e| DbError::Migration(e.to_string()))?;
|
||||
|
||||
service::app_metadata_service::update_app_version(&conn, app_version).await?;
|
||||
|
||||
Ok(AppDatabase { conn })
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use chrono::Utc;
|
||||
use sea_orm::DatabaseConnection;
|
||||
use sea_orm::{
|
||||
ActiveModelTrait, ActiveValue::NotSet, ColumnTrait, ConnectionTrait, DbBackend, EntityTrait,
|
||||
IntoActiveModel, QueryFilter, QueryOrder, Set, Statement,
|
||||
};
|
||||
|
||||
use crate::db::entities::agent_setting;
|
||||
use crate::db::error::DbError;
|
||||
use crate::models::agent::AgentType;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AgentDefaultInput {
|
||||
pub agent_type: AgentType,
|
||||
pub registry_id: String,
|
||||
pub default_sort_order: i32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AgentSettingsUpdate {
|
||||
pub enabled: bool,
|
||||
pub env_json: Option<String>,
|
||||
}
|
||||
|
||||
fn default_enabled(agent_type: AgentType) -> bool {
|
||||
matches!(
|
||||
agent_type,
|
||||
AgentType::ClaudeCode
|
||||
| AgentType::Codex
|
||||
| AgentType::Gemini
|
||||
| AgentType::OpenCode
|
||||
| AgentType::OpenClaw
|
||||
)
|
||||
}
|
||||
|
||||
pub async fn ensure_defaults(
|
||||
conn: &DatabaseConnection,
|
||||
defaults: &[AgentDefaultInput],
|
||||
) -> Result<(), DbError> {
|
||||
for default in defaults {
|
||||
let agent_type = serde_json::to_string(&default.agent_type)
|
||||
.map_err(|e| DbError::Migration(format!("agent_type serialize failed: {e}")))?;
|
||||
let existing = agent_setting::Entity::find()
|
||||
.filter(agent_setting::Column::AgentType.eq(agent_type.clone()))
|
||||
.one(conn)
|
||||
.await?;
|
||||
|
||||
if let Some(model) = existing {
|
||||
if model.registry_id != default.registry_id {
|
||||
let mut active = model.into_active_model();
|
||||
active.registry_id = Set(default.registry_id.clone());
|
||||
active.updated_at = Set(Utc::now());
|
||||
active.update(conn).await?;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
let now = Utc::now();
|
||||
let active = agent_setting::ActiveModel {
|
||||
id: NotSet,
|
||||
agent_type: Set(agent_type),
|
||||
registry_id: Set(default.registry_id.clone()),
|
||||
enabled: Set(default_enabled(default.agent_type)),
|
||||
sort_order: Set(default.default_sort_order),
|
||||
installed_version: Set(None),
|
||||
env_json: Set(None),
|
||||
created_at: Set(now),
|
||||
updated_at: Set(now),
|
||||
};
|
||||
match active.insert(conn).await {
|
||||
Ok(_) => {}
|
||||
Err(e) if e.to_string().contains("UNIQUE constraint failed") => {
|
||||
// Another concurrent call already inserted this row — safe to ignore.
|
||||
continue;
|
||||
}
|
||||
Err(e) => return Err(e.into()),
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn list(conn: &DatabaseConnection) -> Result<Vec<agent_setting::Model>, DbError> {
|
||||
let rows = agent_setting::Entity::find()
|
||||
.order_by_asc(agent_setting::Column::SortOrder)
|
||||
.all(conn)
|
||||
.await?;
|
||||
Ok(rows)
|
||||
}
|
||||
|
||||
pub async fn list_map_by_agent_type(
|
||||
conn: &DatabaseConnection,
|
||||
) -> Result<HashMap<AgentType, agent_setting::Model>, DbError> {
|
||||
let rows = list(conn).await?;
|
||||
let mut map = HashMap::new();
|
||||
for row in rows {
|
||||
if let Ok(agent_type) = serde_json::from_str::<AgentType>(&row.agent_type) {
|
||||
map.insert(agent_type, row);
|
||||
}
|
||||
}
|
||||
Ok(map)
|
||||
}
|
||||
|
||||
pub async fn get_by_agent_type(
|
||||
conn: &DatabaseConnection,
|
||||
agent_type: AgentType,
|
||||
) -> Result<Option<agent_setting::Model>, DbError> {
|
||||
let agent_type_str = serde_json::to_string(&agent_type)
|
||||
.map_err(|e| DbError::Migration(format!("agent_type serialize failed: {e}")))?;
|
||||
let model = agent_setting::Entity::find()
|
||||
.filter(agent_setting::Column::AgentType.eq(agent_type_str))
|
||||
.one(conn)
|
||||
.await?;
|
||||
Ok(model)
|
||||
}
|
||||
|
||||
pub async fn update(
|
||||
conn: &DatabaseConnection,
|
||||
agent_type: AgentType,
|
||||
patch: AgentSettingsUpdate,
|
||||
) -> Result<(), DbError> {
|
||||
let agent_type_str = serde_json::to_string(&agent_type)
|
||||
.map_err(|e| DbError::Migration(format!("agent_type serialize failed: {e}")))?;
|
||||
let model = agent_setting::Entity::find()
|
||||
.filter(agent_setting::Column::AgentType.eq(agent_type_str.clone()))
|
||||
.one(conn)
|
||||
.await?
|
||||
.ok_or_else(|| DbError::Migration(format!("agent setting not found: {agent_type_str}")))?;
|
||||
|
||||
let mut active = model.into_active_model();
|
||||
active.enabled = Set(patch.enabled);
|
||||
active.env_json = Set(patch.env_json);
|
||||
active.updated_at = Set(Utc::now());
|
||||
active.update(conn).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn set_installed_version(
|
||||
conn: &DatabaseConnection,
|
||||
agent_type: AgentType,
|
||||
installed_version: Option<String>,
|
||||
) -> Result<(), DbError> {
|
||||
let agent_type_str = serde_json::to_string(&agent_type)
|
||||
.map_err(|e| DbError::Migration(format!("agent_type serialize failed: {e}")))?;
|
||||
if let Some(model) = agent_setting::Entity::find()
|
||||
.filter(agent_setting::Column::AgentType.eq(agent_type_str))
|
||||
.one(conn)
|
||||
.await?
|
||||
{
|
||||
let mut active = model.into_active_model();
|
||||
active.installed_version = Set(installed_version);
|
||||
active.updated_at = Set(Utc::now());
|
||||
active.update(conn).await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn reorder(conn: &DatabaseConnection, agent_types: &[AgentType]) -> Result<(), DbError> {
|
||||
if agent_types.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
match reorder_once(conn, agent_types).await {
|
||||
Ok(()) => Ok(()),
|
||||
Err(err) if is_sqlite_full_error(&err) => {
|
||||
// Try truncating WAL once to reclaim space and retry.
|
||||
conn.execute(Statement::from_string(
|
||||
DbBackend::Sqlite,
|
||||
"PRAGMA wal_checkpoint(TRUNCATE);".to_owned(),
|
||||
))
|
||||
.await?;
|
||||
reorder_once(conn, agent_types).await
|
||||
}
|
||||
Err(err) => Err(err),
|
||||
}
|
||||
}
|
||||
|
||||
async fn reorder_once(conn: &DatabaseConnection, agent_types: &[AgentType]) -> Result<(), DbError> {
|
||||
let now = Utc::now();
|
||||
for (index, agent_type) in agent_types.iter().enumerate() {
|
||||
let agent_type_str = serde_json::to_string(agent_type)
|
||||
.map_err(|e| DbError::Migration(format!("agent_type serialize failed: {e}")))?;
|
||||
|
||||
if let Some(model) = agent_setting::Entity::find()
|
||||
.filter(agent_setting::Column::AgentType.eq(agent_type_str))
|
||||
.one(conn)
|
||||
.await?
|
||||
{
|
||||
// Skip unchanged rows to reduce write pressure when repeatedly dragging.
|
||||
if model.sort_order == index as i32 {
|
||||
continue;
|
||||
}
|
||||
let mut active = model.into_active_model();
|
||||
active.sort_order = Set(index as i32);
|
||||
active.updated_at = Set(now.clone());
|
||||
active.update(conn).await?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn is_sqlite_full_error(err: &DbError) -> bool {
|
||||
let message = err.to_string();
|
||||
message.contains("database or disk is full") || message.contains("(code: 13)")
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
use chrono::Utc;
|
||||
use sea_orm::sea_query::OnConflict;
|
||||
use sea_orm::DatabaseConnection;
|
||||
use sea_orm::{ActiveValue::NotSet, ColumnTrait, EntityTrait, QueryFilter, Set};
|
||||
|
||||
use crate::db::entities::app_metadata;
|
||||
use crate::db::error::DbError;
|
||||
|
||||
pub async fn upsert_value(
|
||||
conn: &DatabaseConnection,
|
||||
key: &str,
|
||||
value: &str,
|
||||
) -> Result<(), DbError> {
|
||||
let now = Utc::now();
|
||||
|
||||
app_metadata::Entity::insert(app_metadata::ActiveModel {
|
||||
id: NotSet,
|
||||
key: Set(key.to_string()),
|
||||
value: Set(value.to_string()),
|
||||
created_at: Set(now),
|
||||
updated_at: Set(now),
|
||||
deleted_at: NotSet,
|
||||
})
|
||||
.on_conflict(
|
||||
OnConflict::column(app_metadata::Column::Key)
|
||||
.update_columns([app_metadata::Column::Value, app_metadata::Column::UpdatedAt])
|
||||
.to_owned(),
|
||||
)
|
||||
.exec(conn)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn get_value(conn: &DatabaseConnection, key: &str) -> Result<Option<String>, DbError> {
|
||||
let model = app_metadata::Entity::find()
|
||||
.filter(app_metadata::Column::Key.eq(key))
|
||||
.filter(app_metadata::Column::DeletedAt.is_null())
|
||||
.one(conn)
|
||||
.await?;
|
||||
Ok(model.map(|m| m.value))
|
||||
}
|
||||
|
||||
pub async fn update_app_version(
|
||||
conn: &DatabaseConnection,
|
||||
app_version: &str,
|
||||
) -> Result<(), DbError> {
|
||||
let now = Utc::now();
|
||||
|
||||
app_metadata::Entity::insert(app_metadata::ActiveModel {
|
||||
id: NotSet,
|
||||
key: Set("app_version".to_string()),
|
||||
value: Set(app_version.to_string()),
|
||||
created_at: Set(now),
|
||||
updated_at: Set(now),
|
||||
deleted_at: NotSet,
|
||||
})
|
||||
.on_conflict(
|
||||
OnConflict::column(app_metadata::Column::Key)
|
||||
.update_columns([app_metadata::Column::Value, app_metadata::Column::UpdatedAt])
|
||||
.to_owned(),
|
||||
)
|
||||
.exec(conn)
|
||||
.await?;
|
||||
|
||||
app_metadata::Entity::insert(app_metadata::ActiveModel {
|
||||
id: NotSet,
|
||||
key: Set("db_initialized_at".to_string()),
|
||||
value: Set(now.to_rfc3339()),
|
||||
created_at: Set(now),
|
||||
updated_at: Set(now),
|
||||
deleted_at: NotSet,
|
||||
})
|
||||
.on_conflict(
|
||||
OnConflict::column(app_metadata::Column::Key)
|
||||
.do_nothing()
|
||||
.to_owned(),
|
||||
)
|
||||
.do_nothing()
|
||||
.exec(conn)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
use chrono::Utc;
|
||||
use sea_orm::{
|
||||
ActiveModelTrait, ActiveValue::NotSet, ColumnTrait, DatabaseConnection, EntityTrait,
|
||||
QueryFilter, QueryOrder, Set,
|
||||
};
|
||||
|
||||
use crate::db::entities::conversation;
|
||||
use crate::db::error::DbError;
|
||||
use crate::models::{AgentType, DbConversationSummary};
|
||||
|
||||
pub async fn create(
|
||||
conn: &DatabaseConnection,
|
||||
folder_id: i32,
|
||||
agent_type: AgentType,
|
||||
title: Option<String>,
|
||||
git_branch: Option<String>,
|
||||
) -> Result<conversation::Model, DbError> {
|
||||
let at_str = serde_json::to_value(agent_type)
|
||||
.ok()
|
||||
.and_then(|v| v.as_str().map(String::from))
|
||||
.unwrap_or_default();
|
||||
let now = Utc::now();
|
||||
let model = conversation::ActiveModel {
|
||||
id: NotSet,
|
||||
folder_id: Set(folder_id),
|
||||
title: Set(title),
|
||||
agent_type: Set(at_str),
|
||||
status: Set(conversation::ConversationStatus::InProgress),
|
||||
model: Set(None),
|
||||
git_branch: Set(git_branch),
|
||||
external_id: Set(None),
|
||||
parent_id: Set(None),
|
||||
message_count: Set(0),
|
||||
created_at: Set(now),
|
||||
updated_at: Set(now),
|
||||
deleted_at: Set(None),
|
||||
};
|
||||
Ok(model.insert(conn).await?)
|
||||
}
|
||||
|
||||
pub async fn update_status(
|
||||
conn: &DatabaseConnection,
|
||||
conversation_id: i32,
|
||||
status: conversation::ConversationStatus,
|
||||
) -> Result<(), DbError> {
|
||||
let conv = conversation::Entity::find_by_id(conversation_id)
|
||||
.one(conn)
|
||||
.await?
|
||||
.ok_or_else(|| DbError::Migration(format!("Conversation not found: {conversation_id}")))?;
|
||||
let mut active: conversation::ActiveModel = conv.into();
|
||||
active.status = Set(status);
|
||||
active.updated_at = Set(Utc::now());
|
||||
active.update(conn).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn update_title(
|
||||
conn: &DatabaseConnection,
|
||||
conversation_id: i32,
|
||||
title: String,
|
||||
) -> Result<(), DbError> {
|
||||
let conv = conversation::Entity::find_by_id(conversation_id)
|
||||
.one(conn)
|
||||
.await?
|
||||
.ok_or_else(|| DbError::Migration(format!("Conversation not found: {conversation_id}")))?;
|
||||
let mut active: conversation::ActiveModel = conv.into();
|
||||
active.title = Set(Some(title));
|
||||
active.updated_at = Set(Utc::now());
|
||||
active.update(conn).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn update_external_id(
|
||||
conn: &DatabaseConnection,
|
||||
conversation_id: i32,
|
||||
external_id: String,
|
||||
) -> Result<(), DbError> {
|
||||
let conv = conversation::Entity::find_by_id(conversation_id)
|
||||
.one(conn)
|
||||
.await?
|
||||
.ok_or_else(|| DbError::Migration(format!("Conversation not found: {conversation_id}")))?;
|
||||
let mut active: conversation::ActiveModel = conv.into();
|
||||
active.external_id = Set(Some(external_id));
|
||||
active.updated_at = Set(Utc::now());
|
||||
active.update(conn).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn soft_delete(conn: &DatabaseConnection, conversation_id: i32) -> Result<(), DbError> {
|
||||
let conv = conversation::Entity::find_by_id(conversation_id)
|
||||
.filter(conversation::Column::DeletedAt.is_null())
|
||||
.one(conn)
|
||||
.await?
|
||||
.ok_or_else(|| DbError::Migration(format!("Conversation not found: {conversation_id}")))?;
|
||||
let mut active: conversation::ActiveModel = conv.into();
|
||||
active.deleted_at = Set(Some(Utc::now()));
|
||||
active.update(conn).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn parse_agent_type(s: &str) -> AgentType {
|
||||
serde_json::from_value(serde_json::Value::String(s.to_string()))
|
||||
.unwrap_or(AgentType::ClaudeCode)
|
||||
}
|
||||
|
||||
fn conv_to_summary(r: conversation::Model) -> DbConversationSummary {
|
||||
let status = serde_json::to_value(&r.status)
|
||||
.ok()
|
||||
.and_then(|v| v.as_str().map(String::from))
|
||||
.unwrap_or_else(|| format!("{:?}", r.status));
|
||||
DbConversationSummary {
|
||||
id: r.id,
|
||||
folder_id: r.folder_id,
|
||||
title: r.title,
|
||||
agent_type: parse_agent_type(&r.agent_type),
|
||||
status,
|
||||
model: r.model,
|
||||
git_branch: r.git_branch,
|
||||
external_id: r.external_id,
|
||||
message_count: r.message_count as u32,
|
||||
created_at: r.created_at,
|
||||
updated_at: r.updated_at,
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn get_by_id(
|
||||
conn: &DatabaseConnection,
|
||||
conversation_id: i32,
|
||||
) -> Result<DbConversationSummary, DbError> {
|
||||
let conv = conversation::Entity::find_by_id(conversation_id)
|
||||
.filter(conversation::Column::DeletedAt.is_null())
|
||||
.one(conn)
|
||||
.await?
|
||||
.ok_or_else(|| DbError::Migration(format!("Conversation not found: {conversation_id}")))?;
|
||||
|
||||
Ok(conv_to_summary(conv))
|
||||
}
|
||||
|
||||
pub async fn list_by_folder(
|
||||
conn: &DatabaseConnection,
|
||||
folder_id: i32,
|
||||
agent_type: Option<AgentType>,
|
||||
search: Option<String>,
|
||||
sort_by: Option<String>,
|
||||
status: Option<String>,
|
||||
) -> Result<Vec<DbConversationSummary>, DbError> {
|
||||
let mut query = conversation::Entity::find()
|
||||
.filter(conversation::Column::FolderId.eq(folder_id))
|
||||
.filter(conversation::Column::DeletedAt.is_null());
|
||||
|
||||
// Filter by agent_type
|
||||
if let Some(ref at) = agent_type {
|
||||
let at_str = serde_json::to_value(at)
|
||||
.ok()
|
||||
.and_then(|v| v.as_str().map(String::from))
|
||||
.unwrap_or_default();
|
||||
query = query.filter(conversation::Column::AgentType.eq(at_str));
|
||||
}
|
||||
|
||||
// Search by title
|
||||
if let Some(ref s) = search {
|
||||
if !s.is_empty() {
|
||||
query = query.filter(conversation::Column::Title.contains(s));
|
||||
}
|
||||
}
|
||||
|
||||
// Filter by status
|
||||
if let Some(ref st) = status {
|
||||
if let Ok(status_enum) = serde_json::from_value::<conversation::ConversationStatus>(
|
||||
serde_json::Value::String(st.clone()),
|
||||
) {
|
||||
query = query.filter(conversation::Column::Status.eq(status_enum));
|
||||
}
|
||||
}
|
||||
|
||||
// Sort
|
||||
query = match sort_by.as_deref() {
|
||||
Some("oldest") => query.order_by_asc(conversation::Column::CreatedAt),
|
||||
_ => query.order_by_desc(conversation::Column::CreatedAt),
|
||||
};
|
||||
|
||||
let rows = query.all(conn).await?;
|
||||
|
||||
let summaries: Vec<DbConversationSummary> = rows.into_iter().map(conv_to_summary).collect();
|
||||
|
||||
Ok(summaries)
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
use chrono::Utc;
|
||||
use sea_orm::DatabaseConnection;
|
||||
use sea_orm::{
|
||||
ActiveModelTrait, ActiveValue::NotSet, ColumnTrait, ConnectionTrait, DbBackend, EntityTrait,
|
||||
IntoActiveModel, QueryFilter, QueryOrder, Set, Statement,
|
||||
};
|
||||
|
||||
use crate::db::entities::folder_command;
|
||||
use crate::db::error::DbError;
|
||||
use crate::models::FolderCommandInfo;
|
||||
|
||||
fn to_info(m: folder_command::Model) -> FolderCommandInfo {
|
||||
FolderCommandInfo {
|
||||
id: m.id,
|
||||
folder_id: m.folder_id,
|
||||
name: m.name,
|
||||
command: m.command,
|
||||
sort_order: m.sort_order,
|
||||
created_at: m.created_at,
|
||||
updated_at: m.updated_at,
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn list_by_folder(
|
||||
conn: &DatabaseConnection,
|
||||
folder_id: i32,
|
||||
) -> Result<Vec<FolderCommandInfo>, DbError> {
|
||||
let rows = folder_command::Entity::find()
|
||||
.filter(folder_command::Column::FolderId.eq(folder_id))
|
||||
.order_by_asc(folder_command::Column::SortOrder)
|
||||
.all(conn)
|
||||
.await?;
|
||||
|
||||
Ok(rows.into_iter().map(to_info).collect())
|
||||
}
|
||||
|
||||
pub async fn create(
|
||||
conn: &DatabaseConnection,
|
||||
folder_id: i32,
|
||||
name: &str,
|
||||
command: &str,
|
||||
) -> Result<FolderCommandInfo, DbError> {
|
||||
let now = Utc::now();
|
||||
|
||||
// Get next sort_order
|
||||
let max_order = folder_command::Entity::find()
|
||||
.filter(folder_command::Column::FolderId.eq(folder_id))
|
||||
.order_by_desc(folder_command::Column::SortOrder)
|
||||
.one(conn)
|
||||
.await?
|
||||
.map(|m| m.sort_order)
|
||||
.unwrap_or(-1);
|
||||
|
||||
let active = folder_command::ActiveModel {
|
||||
id: NotSet,
|
||||
folder_id: Set(folder_id),
|
||||
name: Set(name.to_string()),
|
||||
command: Set(command.to_string()),
|
||||
sort_order: Set(max_order + 1),
|
||||
created_at: Set(now),
|
||||
updated_at: Set(now),
|
||||
};
|
||||
|
||||
let model = active.insert(conn).await?;
|
||||
Ok(to_info(model))
|
||||
}
|
||||
|
||||
pub async fn create_many(
|
||||
conn: &DatabaseConnection,
|
||||
folder_id: i32,
|
||||
items: &[(String, String)],
|
||||
) -> Result<(), DbError> {
|
||||
if items.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let now = Utc::now();
|
||||
|
||||
let max_order = folder_command::Entity::find()
|
||||
.filter(folder_command::Column::FolderId.eq(folder_id))
|
||||
.order_by_desc(folder_command::Column::SortOrder)
|
||||
.one(conn)
|
||||
.await?
|
||||
.map(|m| m.sort_order)
|
||||
.unwrap_or(-1);
|
||||
|
||||
let active_models = items
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(idx, (name, command))| folder_command::ActiveModel {
|
||||
id: NotSet,
|
||||
folder_id: Set(folder_id),
|
||||
name: Set(name.clone()),
|
||||
command: Set(command.clone()),
|
||||
sort_order: Set(max_order + idx as i32 + 1),
|
||||
created_at: Set(now),
|
||||
updated_at: Set(now),
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
folder_command::Entity::insert_many(active_models)
|
||||
.exec(conn)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn update(
|
||||
conn: &DatabaseConnection,
|
||||
id: i32,
|
||||
name: Option<String>,
|
||||
command: Option<String>,
|
||||
sort_order: Option<i32>,
|
||||
) -> Result<FolderCommandInfo, DbError> {
|
||||
let row = folder_command::Entity::find_by_id(id)
|
||||
.one(conn)
|
||||
.await?
|
||||
.ok_or_else(|| DbError::Migration(format!("FolderCommand {} not found", id)))?;
|
||||
|
||||
let mut active = row.into_active_model();
|
||||
if let Some(n) = name {
|
||||
active.name = Set(n);
|
||||
}
|
||||
if let Some(c) = command {
|
||||
active.command = Set(c);
|
||||
}
|
||||
if let Some(s) = sort_order {
|
||||
active.sort_order = Set(s);
|
||||
}
|
||||
active.updated_at = Set(Utc::now());
|
||||
|
||||
let model = active.update(conn).await?;
|
||||
Ok(to_info(model))
|
||||
}
|
||||
|
||||
pub async fn delete(conn: &DatabaseConnection, id: i32) -> Result<(), DbError> {
|
||||
folder_command::Entity::delete_by_id(id).exec(conn).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn reorder(
|
||||
conn: &DatabaseConnection,
|
||||
folder_id: i32,
|
||||
ids: Vec<i32>,
|
||||
) -> Result<(), DbError> {
|
||||
if ids.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let now = Utc::now();
|
||||
let now_str = now.format("%Y-%m-%d %H:%M:%S %:z").to_string();
|
||||
let case_expr = ids
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(idx, id)| format!("WHEN {} THEN {}", id, idx))
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ");
|
||||
let id_list = ids
|
||||
.iter()
|
||||
.map(|id| id.to_string())
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
|
||||
let sql = format!(
|
||||
"UPDATE folder_command SET sort_order = CASE id {case_expr} END, updated_at = '{now_str}' WHERE folder_id = {folder_id} AND id IN ({id_list})"
|
||||
);
|
||||
conn.execute(Statement::from_string(DbBackend::Sqlite, sql))
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,246 @@
|
||||
use chrono::Utc;
|
||||
use sea_orm::DatabaseConnection;
|
||||
use sea_orm::{
|
||||
ActiveModelTrait, ActiveValue::NotSet, ColumnTrait, ConnectionTrait, DbBackend, EntityTrait,
|
||||
IntoActiveModel, QueryFilter, QueryOrder, Set, Statement,
|
||||
};
|
||||
|
||||
use crate::db::entities::{folder, folder_opened_conversation};
|
||||
use crate::db::error::DbError;
|
||||
use crate::models::agent::AgentType;
|
||||
use crate::models::{FolderDetail, FolderHistoryEntry, OpenedConversation};
|
||||
|
||||
fn to_entry(m: folder::Model) -> FolderHistoryEntry {
|
||||
FolderHistoryEntry {
|
||||
id: m.id,
|
||||
path: m.path,
|
||||
name: m.name,
|
||||
last_opened_at: m.last_opened_at,
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_agent_type(s: &Option<String>) -> Option<AgentType> {
|
||||
s.as_deref()
|
||||
.and_then(|v| serde_json::from_value(serde_json::Value::String(v.to_string())).ok())
|
||||
}
|
||||
|
||||
fn to_detail(m: folder::Model, opened: Vec<OpenedConversation>) -> FolderDetail {
|
||||
let default_agent_type = parse_agent_type(&m.default_agent_type);
|
||||
FolderDetail {
|
||||
id: m.id,
|
||||
name: m.name,
|
||||
path: m.path,
|
||||
git_branch: m.git_branch,
|
||||
parent_branch: m.parent_branch,
|
||||
default_agent_type,
|
||||
last_opened_at: m.last_opened_at,
|
||||
opened_conversations: opened,
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn get_folder_by_id(
|
||||
conn: &DatabaseConnection,
|
||||
folder_id: i32,
|
||||
) -> Result<Option<FolderDetail>, DbError> {
|
||||
let row = folder::Entity::find_by_id(folder_id)
|
||||
.filter(folder::Column::DeletedAt.is_null())
|
||||
.one(conn)
|
||||
.await?;
|
||||
|
||||
match row {
|
||||
None => Ok(None),
|
||||
Some(folder_model) => {
|
||||
let opened = load_opened_conversations(conn, folder_model.id).await?;
|
||||
Ok(Some(to_detail(folder_model, opened)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn add_folder(
|
||||
conn: &DatabaseConnection,
|
||||
path: &str,
|
||||
) -> Result<FolderHistoryEntry, DbError> {
|
||||
let now = Utc::now();
|
||||
let name = std::path::Path::new(path)
|
||||
.file_name()
|
||||
.map(|n| n.to_string_lossy().to_string())
|
||||
.unwrap_or_else(|| path.to_string());
|
||||
|
||||
let existing = folder::Entity::find()
|
||||
.filter(folder::Column::Path.eq(path))
|
||||
.one(conn)
|
||||
.await?;
|
||||
|
||||
let model = if let Some(row) = existing {
|
||||
let mut active = row.into_active_model();
|
||||
active.name = Set(name);
|
||||
active.last_opened_at = Set(now);
|
||||
active.updated_at = Set(now);
|
||||
active.deleted_at = Set(None);
|
||||
active.is_open = Set(true);
|
||||
active.update(conn).await?
|
||||
} else {
|
||||
let active = folder::ActiveModel {
|
||||
id: NotSet,
|
||||
name: Set(name),
|
||||
path: Set(path.to_string()),
|
||||
git_branch: Set(None),
|
||||
parent_branch: Set(None),
|
||||
default_agent_type: Set(None),
|
||||
last_opened_at: Set(now),
|
||||
created_at: Set(now),
|
||||
updated_at: Set(now),
|
||||
deleted_at: Set(None),
|
||||
is_open: Set(true),
|
||||
};
|
||||
active.insert(conn).await?
|
||||
};
|
||||
|
||||
Ok(to_entry(model))
|
||||
}
|
||||
|
||||
pub async fn list_folders(conn: &DatabaseConnection) -> Result<Vec<FolderHistoryEntry>, DbError> {
|
||||
let rows = folder::Entity::find()
|
||||
.filter(folder::Column::DeletedAt.is_null())
|
||||
.order_by_desc(folder::Column::LastOpenedAt)
|
||||
.all(conn)
|
||||
.await?;
|
||||
|
||||
Ok(rows.into_iter().map(to_entry).collect())
|
||||
}
|
||||
|
||||
pub async fn remove_folder(conn: &DatabaseConnection, path: &str) -> Result<(), DbError> {
|
||||
let now = Utc::now();
|
||||
let row = folder::Entity::find()
|
||||
.filter(folder::Column::Path.eq(path))
|
||||
.filter(folder::Column::DeletedAt.is_null())
|
||||
.one(conn)
|
||||
.await?;
|
||||
|
||||
if let Some(row) = row {
|
||||
let mut active = row.into_active_model();
|
||||
active.deleted_at = Set(Some(now));
|
||||
active.updated_at = Set(now);
|
||||
active.update(conn).await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn save_opened_conversations(
|
||||
conn: &DatabaseConnection,
|
||||
folder_id: i32,
|
||||
items: Vec<OpenedConversation>,
|
||||
) -> Result<(), DbError> {
|
||||
// Delete all existing opened conversations for this folder
|
||||
folder_opened_conversation::Entity::delete_many()
|
||||
.filter(folder_opened_conversation::Column::FolderId.eq(folder_id))
|
||||
.exec(conn)
|
||||
.await?;
|
||||
|
||||
if items.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Batch insert with raw SQL for efficiency
|
||||
let now = Utc::now();
|
||||
let now_str = now.format("%Y-%m-%d %H:%M:%S %:z").to_string();
|
||||
|
||||
let mut values = Vec::with_capacity(items.len());
|
||||
for item in &items {
|
||||
let agent_str = serde_json::to_value(item.agent_type)
|
||||
.ok()
|
||||
.and_then(|v| v.as_str().map(|s| s.to_string()))
|
||||
.unwrap_or_default();
|
||||
values.push(format!(
|
||||
"({}, {}, {}, {}, {}, '{}', '{}', '{}')",
|
||||
folder_id,
|
||||
item.conversation_id,
|
||||
item.position,
|
||||
item.is_active as i32,
|
||||
item.is_pinned as i32,
|
||||
agent_str,
|
||||
now_str,
|
||||
now_str,
|
||||
));
|
||||
}
|
||||
|
||||
let sql = format!(
|
||||
"INSERT INTO folder_opened_conversation (folder_id, conversation_id, position, is_active, is_pinned, agent_type, created_at, updated_at) VALUES {}",
|
||||
values.join(", ")
|
||||
);
|
||||
|
||||
conn.execute(Statement::from_string(DbBackend::Sqlite, sql))
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn load_opened_conversations(
|
||||
conn: &DatabaseConnection,
|
||||
folder_id: i32,
|
||||
) -> Result<Vec<OpenedConversation>, DbError> {
|
||||
let rows = folder_opened_conversation::Entity::find()
|
||||
.filter(folder_opened_conversation::Column::FolderId.eq(folder_id))
|
||||
.order_by_asc(folder_opened_conversation::Column::Position)
|
||||
.all(conn)
|
||||
.await?;
|
||||
|
||||
Ok(rows
|
||||
.into_iter()
|
||||
.filter_map(|r| {
|
||||
let agent_type = parse_agent_type(&Some(r.agent_type))?;
|
||||
Some(OpenedConversation {
|
||||
conversation_id: r.conversation_id,
|
||||
agent_type,
|
||||
position: r.position,
|
||||
is_active: r.is_active,
|
||||
is_pinned: r.is_pinned,
|
||||
})
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
pub async fn set_folder_parent_branch(
|
||||
conn: &DatabaseConnection,
|
||||
folder_id: i32,
|
||||
parent_branch: Option<String>,
|
||||
) -> Result<(), DbError> {
|
||||
let row = folder::Entity::find_by_id(folder_id).one(conn).await?;
|
||||
|
||||
if let Some(row) = row {
|
||||
let mut active = row.into_active_model();
|
||||
active.parent_branch = Set(parent_branch);
|
||||
active.updated_at = Set(Utc::now());
|
||||
active.update(conn).await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn set_folder_open(
|
||||
conn: &DatabaseConnection,
|
||||
folder_id: i32,
|
||||
is_open: bool,
|
||||
) -> Result<(), DbError> {
|
||||
let row = folder::Entity::find_by_id(folder_id).one(conn).await?;
|
||||
|
||||
if let Some(row) = row {
|
||||
let mut active = row.into_active_model();
|
||||
active.is_open = Set(is_open);
|
||||
active.updated_at = Set(Utc::now());
|
||||
active.update(conn).await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn list_open_folders(
|
||||
conn: &DatabaseConnection,
|
||||
) -> Result<Vec<FolderHistoryEntry>, DbError> {
|
||||
let rows = folder::Entity::find()
|
||||
.filter(folder::Column::DeletedAt.is_null())
|
||||
.filter(folder::Column::IsOpen.eq(true))
|
||||
.order_by_desc(folder::Column::LastOpenedAt)
|
||||
.all(conn)
|
||||
.await?;
|
||||
|
||||
Ok(rows.into_iter().map(to_entry).collect())
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
use chrono::Utc;
|
||||
use sea_orm::{
|
||||
ActiveModelTrait, ActiveValue::NotSet, ColumnTrait, DatabaseConnection, EntityTrait,
|
||||
QueryFilter, Set,
|
||||
};
|
||||
|
||||
use crate::db::entities::conversation;
|
||||
use crate::db::error::DbError;
|
||||
use crate::models::{AgentType, ImportResult};
|
||||
use crate::parsers::claude::ClaudeParser;
|
||||
use crate::parsers::codex::CodexParser;
|
||||
use crate::parsers::gemini::GeminiParser;
|
||||
use crate::parsers::opencode::OpenCodeParser;
|
||||
use crate::parsers::{path_eq_for_matching, AgentParser};
|
||||
|
||||
pub async fn import_local_conversations(
|
||||
conn: &DatabaseConnection,
|
||||
folder_id: i32,
|
||||
folder_path: &str,
|
||||
) -> Result<ImportResult, DbError> {
|
||||
let path = folder_path.to_string();
|
||||
|
||||
// Run parsers in blocking task since they do filesystem I/O
|
||||
let summaries = tokio::task::spawn_blocking(move || {
|
||||
let parsers: Vec<(AgentType, Box<dyn AgentParser>)> = vec![
|
||||
(AgentType::ClaudeCode, Box::new(ClaudeParser::new())),
|
||||
(AgentType::Codex, Box::new(CodexParser::new())),
|
||||
(AgentType::OpenCode, Box::new(OpenCodeParser::new())),
|
||||
(AgentType::Gemini, Box::new(GeminiParser::new())),
|
||||
];
|
||||
|
||||
let mut matched = Vec::new();
|
||||
for (at, parser) in &parsers {
|
||||
match parser.list_conversations() {
|
||||
Ok(convs) => {
|
||||
for c in convs {
|
||||
if c.folder_path
|
||||
.as_deref()
|
||||
.map(|p| path_eq_for_matching(p, path.as_str()))
|
||||
.unwrap_or(false)
|
||||
{
|
||||
matched.push((*at, c));
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("Error listing {} conversations: {}", at, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
matched
|
||||
})
|
||||
.await
|
||||
.map_err(|e| DbError::Migration(e.to_string()))?;
|
||||
|
||||
let mut imported = 0u32;
|
||||
let mut skipped = 0u32;
|
||||
|
||||
for (agent_type, summary) in &summaries {
|
||||
let at_str = serde_json::to_value(agent_type)
|
||||
.ok()
|
||||
.and_then(|v| v.as_str().map(String::from))
|
||||
.unwrap_or_default();
|
||||
|
||||
// Check if already imported
|
||||
let exists = conversation::Entity::find()
|
||||
.filter(conversation::Column::ExternalId.eq(&summary.id))
|
||||
.filter(conversation::Column::AgentType.eq(&at_str))
|
||||
.one(conn)
|
||||
.await?;
|
||||
|
||||
if exists.is_some() {
|
||||
skipped += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
let now = Utc::now();
|
||||
let conv = conversation::ActiveModel {
|
||||
id: NotSet,
|
||||
folder_id: Set(folder_id),
|
||||
title: Set(summary.title.clone()),
|
||||
agent_type: Set(at_str.clone()),
|
||||
status: Set(conversation::ConversationStatus::Completed),
|
||||
model: Set(summary.model.clone()),
|
||||
git_branch: Set(summary.git_branch.clone()),
|
||||
external_id: Set(Some(summary.id.clone())),
|
||||
parent_id: Set(None),
|
||||
message_count: Set(summary.message_count as i32),
|
||||
created_at: Set(summary.started_at),
|
||||
updated_at: Set(now),
|
||||
deleted_at: Set(None),
|
||||
};
|
||||
conv.insert(conn).await?;
|
||||
|
||||
imported += 1;
|
||||
}
|
||||
|
||||
Ok(ImportResult { imported, skipped })
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
pub mod agent_setting_service;
|
||||
pub mod app_metadata_service;
|
||||
pub mod conversation_service;
|
||||
pub mod folder_command_service;
|
||||
pub mod folder_service;
|
||||
pub mod import_service;
|
||||
@@ -0,0 +1,272 @@
|
||||
mod acp;
|
||||
mod commands;
|
||||
mod db;
|
||||
mod models;
|
||||
mod network;
|
||||
mod parsers;
|
||||
mod process;
|
||||
mod terminal;
|
||||
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
|
||||
use acp::manager::ConnectionManager;
|
||||
use commands::{
|
||||
acp as acp_commands, conversations, folder_commands, folders, mcp as mcp_commands,
|
||||
system_settings, terminal as terminal_commands, windows,
|
||||
};
|
||||
use tauri::Manager;
|
||||
use terminal::manager::TerminalManager;
|
||||
|
||||
static APP_QUITTING: AtomicBool = AtomicBool::new(false);
|
||||
|
||||
fn get_folder_id_from_url(window: &tauri::Window) -> Option<i32> {
|
||||
let webview = window.get_webview_window(window.label())?;
|
||||
let url = webview.url().ok()?;
|
||||
url.query_pairs()
|
||||
.find(|(key, _)| key == "id")
|
||||
.and_then(|(_, value)| value.parse::<i32>().ok())
|
||||
}
|
||||
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
pub fn run() {
|
||||
let _ = fix_path_env::fix();
|
||||
|
||||
tauri::Builder::default()
|
||||
.plugin(tauri_plugin_window_state::Builder::new().build())
|
||||
.plugin(tauri_plugin_opener::init())
|
||||
.plugin(tauri_plugin_dialog::init())
|
||||
.plugin(tauri_plugin_updater::Builder::new().build())
|
||||
.plugin(tauri_plugin_process::init())
|
||||
.manage(ConnectionManager::new())
|
||||
.manage(TerminalManager::new())
|
||||
.manage(windows::SettingsWindowState::new())
|
||||
.manage(windows::CommitWindowState::new())
|
||||
.setup(|app| {
|
||||
let app_data_dir = app.path().app_data_dir()?;
|
||||
let app_version = env!("CARGO_PKG_VERSION");
|
||||
let database =
|
||||
tauri::async_runtime::block_on(db::init_database(&app_data_dir, app_version))
|
||||
.map_err(|e| e.to_string())?;
|
||||
app.manage(database);
|
||||
|
||||
// Restore and apply saved system proxy settings before any network operation.
|
||||
let db = app.state::<db::AppDatabase>();
|
||||
match tauri::async_runtime::block_on(system_settings::load_system_proxy_settings(
|
||||
&db.conn,
|
||||
)) {
|
||||
Ok(settings) => {
|
||||
let _ = network::proxy::apply_system_proxy_settings(&settings);
|
||||
}
|
||||
Err(err) => {
|
||||
eprintln!("[Settings] failed to load system proxy settings: {err}");
|
||||
}
|
||||
}
|
||||
|
||||
// Restore previously open folders or show welcome
|
||||
let db = app.state::<db::AppDatabase>();
|
||||
let open_folders = tauri::async_runtime::block_on(
|
||||
db::service::folder_service::list_open_folders(&db.conn),
|
||||
)
|
||||
.unwrap_or_default();
|
||||
|
||||
if open_folders.is_empty() {
|
||||
let _ = windows::open_welcome_window(app.handle());
|
||||
} else {
|
||||
for entry in &open_folders {
|
||||
let label = format!("folder-{}", uuid::Uuid::new_v4());
|
||||
let url = tauri::WebviewUrl::App(format!("folder?id={}", entry.id).into());
|
||||
let builder = tauri::WebviewWindowBuilder::new(app, &label, url)
|
||||
.title(&entry.name)
|
||||
.inner_size(1260.0, 860.0)
|
||||
.min_inner_size(900.0, 600.0);
|
||||
let _ = windows::apply_platform_window_style(builder).build();
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
})
|
||||
.on_window_event(|window, event| {
|
||||
let label = window.label().to_string();
|
||||
|
||||
if label == "settings"
|
||||
&& matches!(
|
||||
event,
|
||||
tauri::WindowEvent::CloseRequested { .. } | tauri::WindowEvent::Destroyed
|
||||
)
|
||||
{
|
||||
let app = window.app_handle();
|
||||
if let Some(state) = app.try_state::<windows::SettingsWindowState>() {
|
||||
windows::restore_windows_after_settings(&app, &state);
|
||||
}
|
||||
}
|
||||
|
||||
if label.starts_with("commit-")
|
||||
&& matches!(
|
||||
event,
|
||||
tauri::WindowEvent::CloseRequested { .. } | tauri::WindowEvent::Destroyed
|
||||
)
|
||||
{
|
||||
let app = window.app_handle();
|
||||
if let Some(state) = app.try_state::<windows::CommitWindowState>() {
|
||||
windows::restore_window_after_commit(&app, &state, &label);
|
||||
}
|
||||
}
|
||||
|
||||
if let tauri::WindowEvent::CloseRequested { .. } = event {
|
||||
if label.starts_with("folder-") {
|
||||
let app = window.app_handle();
|
||||
if let Some(cm) = app.try_state::<ConnectionManager>() {
|
||||
let disconnected =
|
||||
tauri::async_runtime::block_on(cm.disconnect_by_owner_window(&label));
|
||||
eprintln!(
|
||||
"[ACP] folder window closing label={} disconnected_connections={}",
|
||||
label, disconnected
|
||||
);
|
||||
}
|
||||
|
||||
// Only mark folder as closed if user is closing individual window,
|
||||
// not when the entire app is quitting (so folders reopen on next launch)
|
||||
if !APP_QUITTING.load(Ordering::Relaxed) {
|
||||
if let Some(folder_id) = get_folder_id_from_url(window) {
|
||||
if let Some(db) = app.try_state::<db::AppDatabase>() {
|
||||
let _ = tauri::async_runtime::block_on(
|
||||
db::service::folder_service::set_folder_open(
|
||||
&db.conn, folder_id, false,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Kill terminal sessions owned by this folder window.
|
||||
if let Some(tm) = app.try_state::<TerminalManager>() {
|
||||
let killed = tm.kill_by_owner_window(&label);
|
||||
eprintln!(
|
||||
"[TERM] folder window closing label={} killed_terminals={}",
|
||||
label, killed
|
||||
);
|
||||
}
|
||||
let has_other_folder = app
|
||||
.webview_windows()
|
||||
.keys()
|
||||
.any(|l| l.starts_with("folder-") && *l != label);
|
||||
if !has_other_folder && !APP_QUITTING.load(Ordering::Relaxed) {
|
||||
let _ = windows::open_welcome_window(app);
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
conversations::list_conversations,
|
||||
conversations::get_conversation,
|
||||
conversations::list_folder_conversations,
|
||||
conversations::import_local_conversations,
|
||||
conversations::get_folder_conversation,
|
||||
conversations::list_folders,
|
||||
conversations::get_stats,
|
||||
conversations::get_sidebar_data,
|
||||
conversations::create_conversation,
|
||||
conversations::update_conversation_status,
|
||||
conversations::update_conversation_title,
|
||||
conversations::update_conversation_external_id,
|
||||
conversations::delete_conversation,
|
||||
folders::load_folder_history,
|
||||
folders::get_folder,
|
||||
folders::add_folder_to_history,
|
||||
folders::set_folder_parent_branch,
|
||||
folders::remove_folder_from_history,
|
||||
folders::create_folder_directory,
|
||||
folders::clone_repository,
|
||||
folders::get_git_branch,
|
||||
folders::git_init,
|
||||
folders::git_pull,
|
||||
folders::git_fetch,
|
||||
folders::git_push,
|
||||
folders::git_new_branch,
|
||||
folders::git_worktree_add,
|
||||
folders::git_checkout,
|
||||
folders::git_list_branches,
|
||||
folders::git_stash,
|
||||
folders::git_stash_pop,
|
||||
folders::git_status,
|
||||
folders::git_is_tracked,
|
||||
folders::git_diff,
|
||||
folders::git_diff_with_branch,
|
||||
folders::git_show_diff,
|
||||
folders::git_show_file,
|
||||
folders::git_commit,
|
||||
folders::git_rollback_file,
|
||||
folders::git_add_files,
|
||||
folders::git_list_all_branches,
|
||||
folders::git_merge,
|
||||
folders::git_rebase,
|
||||
folders::git_delete_branch,
|
||||
folders::save_folder_opened_conversations,
|
||||
folders::start_file_tree_watch,
|
||||
folders::stop_file_tree_watch,
|
||||
folders::get_file_tree,
|
||||
folders::read_file_preview,
|
||||
folders::read_file_for_edit,
|
||||
folders::save_file_content,
|
||||
folders::save_file_copy,
|
||||
folders::rename_file_tree_entry,
|
||||
folders::delete_file_tree_entry,
|
||||
folders::git_log,
|
||||
folders::git_commit_branches,
|
||||
windows::open_folder_window,
|
||||
windows::open_commit_window,
|
||||
windows::open_settings_window,
|
||||
windows::list_open_folders,
|
||||
windows::focus_folder_window,
|
||||
system_settings::get_system_proxy_settings,
|
||||
system_settings::update_system_proxy_settings,
|
||||
acp_commands::acp_preflight,
|
||||
acp_commands::acp_connect,
|
||||
acp_commands::acp_prompt,
|
||||
acp_commands::acp_set_mode,
|
||||
acp_commands::acp_set_config_option,
|
||||
acp_commands::acp_cancel,
|
||||
acp_commands::acp_respond_permission,
|
||||
acp_commands::acp_disconnect,
|
||||
acp_commands::acp_list_connections,
|
||||
acp_commands::acp_list_agents,
|
||||
acp_commands::acp_clear_binary_cache,
|
||||
acp_commands::acp_download_agent_binary,
|
||||
acp_commands::acp_detect_agent_local_version,
|
||||
acp_commands::acp_prepare_npx_agent,
|
||||
acp_commands::acp_prepare_uvx_agent,
|
||||
acp_commands::acp_uninstall_agent,
|
||||
acp_commands::acp_update_agent_preferences,
|
||||
acp_commands::acp_reorder_agents,
|
||||
acp_commands::acp_list_agent_skills,
|
||||
acp_commands::acp_read_agent_skill,
|
||||
acp_commands::acp_save_agent_skill,
|
||||
acp_commands::acp_delete_agent_skill,
|
||||
folder_commands::list_folder_commands,
|
||||
folder_commands::create_folder_command,
|
||||
folder_commands::update_folder_command,
|
||||
folder_commands::delete_folder_command,
|
||||
folder_commands::reorder_folder_commands,
|
||||
folder_commands::bootstrap_folder_commands_from_package_json,
|
||||
terminal_commands::terminal_spawn,
|
||||
terminal_commands::terminal_write,
|
||||
terminal_commands::terminal_resize,
|
||||
terminal_commands::terminal_kill,
|
||||
terminal_commands::terminal_list,
|
||||
mcp_commands::mcp_scan_local,
|
||||
mcp_commands::mcp_list_marketplaces,
|
||||
mcp_commands::mcp_search_marketplace,
|
||||
mcp_commands::mcp_get_marketplace_server_detail,
|
||||
mcp_commands::mcp_install_from_marketplace,
|
||||
mcp_commands::mcp_upsert_local_server,
|
||||
mcp_commands::mcp_set_server_apps,
|
||||
mcp_commands::mcp_remove_server,
|
||||
])
|
||||
.build(tauri::generate_context!())
|
||||
.expect("error while building tauri application")
|
||||
.run(|_app, event| {
|
||||
if let tauri::RunEvent::ExitRequested { .. } = event {
|
||||
APP_QUITTING.store(true, Ordering::Relaxed);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
// Prevents additional console window on Windows in release, DO NOT REMOVE!!
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
fn main() {
|
||||
codeg_lib::run()
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::fmt;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum AgentType {
|
||||
ClaudeCode,
|
||||
Codex,
|
||||
OpenCode,
|
||||
Auggie,
|
||||
Autohand,
|
||||
Cline,
|
||||
CodebuddyCode,
|
||||
CorustAgent,
|
||||
Gemini,
|
||||
GithubCopilot,
|
||||
Goose,
|
||||
Junie,
|
||||
Qoder,
|
||||
QwenCode,
|
||||
FactoryDroid,
|
||||
Kimi,
|
||||
MinionCode,
|
||||
MistralVibe,
|
||||
OpenClaw,
|
||||
Stakpak,
|
||||
}
|
||||
|
||||
impl fmt::Display for AgentType {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
AgentType::ClaudeCode => write!(f, "Claude Code"),
|
||||
AgentType::Codex => write!(f, "Codex CLI"),
|
||||
AgentType::OpenCode => write!(f, "OpenCode"),
|
||||
AgentType::Auggie => write!(f, "Auggie"),
|
||||
AgentType::Autohand => write!(f, "Autohand"),
|
||||
AgentType::Cline => write!(f, "Cline"),
|
||||
AgentType::CodebuddyCode => write!(f, "Codebuddy Code"),
|
||||
AgentType::CorustAgent => write!(f, "Corust Agent"),
|
||||
AgentType::Gemini => write!(f, "Gemini CLI"),
|
||||
AgentType::GithubCopilot => write!(f, "GitHub Copilot"),
|
||||
AgentType::Goose => write!(f, "goose"),
|
||||
AgentType::Junie => write!(f, "Junie"),
|
||||
AgentType::Qoder => write!(f, "Qoder CLI"),
|
||||
AgentType::QwenCode => write!(f, "Qwen Code"),
|
||||
AgentType::FactoryDroid => write!(f, "Factory Droid"),
|
||||
AgentType::Kimi => write!(f, "Kimi CLI"),
|
||||
AgentType::MinionCode => write!(f, "Minion Code"),
|
||||
AgentType::MistralVibe => write!(f, "Mistral Vibe"),
|
||||
AgentType::OpenClaw => write!(f, "OpenClaw"),
|
||||
AgentType::Stakpak => write!(f, "Stakpak"),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use super::agent::AgentType;
|
||||
use super::message::{MessageTurn, TurnUsage};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ConversationSummary {
|
||||
pub id: String,
|
||||
pub agent_type: AgentType,
|
||||
pub folder_path: Option<String>,
|
||||
pub folder_name: Option<String>,
|
||||
pub title: Option<String>,
|
||||
pub started_at: DateTime<Utc>,
|
||||
pub ended_at: Option<DateTime<Utc>>,
|
||||
pub message_count: u32,
|
||||
pub model: Option<String>,
|
||||
pub git_branch: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct DbConversationSummary {
|
||||
pub id: i32,
|
||||
pub folder_id: i32,
|
||||
pub title: Option<String>,
|
||||
pub agent_type: AgentType,
|
||||
pub status: String,
|
||||
pub model: Option<String>,
|
||||
pub git_branch: Option<String>,
|
||||
pub external_id: Option<String>,
|
||||
pub message_count: u32,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SessionStats {
|
||||
pub total_usage: Option<TurnUsage>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub total_tokens: Option<u64>,
|
||||
pub total_duration_ms: u64,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub context_window_used_tokens: Option<u64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub context_window_max_tokens: Option<u64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub context_window_usage_percent: Option<f64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ConversationDetail {
|
||||
pub summary: ConversationSummary,
|
||||
pub turns: Vec<MessageTurn>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub session_stats: Option<SessionStats>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct DbConversationDetail {
|
||||
pub summary: DbConversationSummary,
|
||||
pub turns: Vec<MessageTurn>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub session_stats: Option<SessionStats>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct FolderInfo {
|
||||
pub path: String,
|
||||
pub name: String,
|
||||
pub agent_types: Vec<AgentType>,
|
||||
pub conversation_count: u32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AgentStats {
|
||||
pub total_conversations: u32,
|
||||
pub total_messages: u32,
|
||||
pub by_agent: Vec<AgentConversationCount>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AgentConversationCount {
|
||||
pub agent_type: AgentType,
|
||||
pub conversation_count: u32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SidebarData {
|
||||
pub folders: Vec<FolderInfo>,
|
||||
pub stats: AgentStats,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ImportResult {
|
||||
pub imported: u32,
|
||||
pub skipped: u32,
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use super::agent::AgentType;
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct FolderHistoryEntry {
|
||||
pub id: i32,
|
||||
pub path: String,
|
||||
pub name: String,
|
||||
pub last_opened_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct FolderDetail {
|
||||
pub id: i32,
|
||||
pub name: String,
|
||||
pub path: String,
|
||||
pub git_branch: Option<String>,
|
||||
pub parent_branch: Option<String>,
|
||||
pub default_agent_type: Option<AgentType>,
|
||||
pub last_opened_at: DateTime<Utc>,
|
||||
pub opened_conversations: Vec<OpenedConversation>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct OpenedConversation {
|
||||
pub conversation_id: i32,
|
||||
pub agent_type: AgentType,
|
||||
pub position: i32,
|
||||
pub is_active: bool,
|
||||
pub is_pinned: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct FolderCommandInfo {
|
||||
pub id: i32,
|
||||
pub folder_id: i32,
|
||||
pub name: String,
|
||||
pub command: String,
|
||||
pub sort_order: i32,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum MessageRole {
|
||||
User,
|
||||
Assistant,
|
||||
System,
|
||||
Tool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub enum ContentBlock {
|
||||
Text {
|
||||
text: String,
|
||||
},
|
||||
ToolUse {
|
||||
tool_use_id: Option<String>,
|
||||
tool_name: String,
|
||||
input_preview: Option<String>,
|
||||
},
|
||||
ToolResult {
|
||||
tool_use_id: Option<String>,
|
||||
output_preview: Option<String>,
|
||||
is_error: bool,
|
||||
},
|
||||
Thinking {
|
||||
text: String,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct TurnUsage {
|
||||
pub input_tokens: u64,
|
||||
pub output_tokens: u64,
|
||||
pub cache_creation_input_tokens: u64,
|
||||
pub cache_read_input_tokens: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct UnifiedMessage {
|
||||
pub id: String,
|
||||
pub role: MessageRole,
|
||||
pub content: Vec<ContentBlock>,
|
||||
pub timestamp: DateTime<Utc>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub usage: Option<TurnUsage>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub duration_ms: Option<u64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub model: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum TurnRole {
|
||||
User,
|
||||
Assistant,
|
||||
System,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct MessageTurn {
|
||||
pub id: String,
|
||||
pub role: TurnRole,
|
||||
pub blocks: Vec<ContentBlock>,
|
||||
pub timestamp: DateTime<Utc>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub usage: Option<TurnUsage>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub duration_ms: Option<u64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub model: Option<String>,
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
pub mod agent;
|
||||
pub mod conversation;
|
||||
pub mod folder;
|
||||
pub mod message;
|
||||
pub mod system;
|
||||
|
||||
pub use agent::AgentType;
|
||||
pub use conversation::{
|
||||
AgentConversationCount, AgentStats, ConversationDetail, ConversationSummary,
|
||||
DbConversationDetail, DbConversationSummary, FolderInfo, ImportResult, SessionStats,
|
||||
SidebarData,
|
||||
};
|
||||
pub use folder::{FolderCommandInfo, FolderDetail, FolderHistoryEntry, OpenedConversation};
|
||||
pub use message::{ContentBlock, MessageRole, MessageTurn, TurnRole, TurnUsage, UnifiedMessage};
|
||||
pub use system::SystemProxySettings;
|
||||
@@ -0,0 +1,7 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct SystemProxySettings {
|
||||
pub enabled: bool,
|
||||
pub proxy_url: Option<String>,
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
pub mod proxy;
|
||||
@@ -0,0 +1,55 @@
|
||||
use crate::models::SystemProxySettings;
|
||||
|
||||
const PROXY_ENV_KEYS: [&str; 6] = [
|
||||
"HTTP_PROXY",
|
||||
"HTTPS_PROXY",
|
||||
"ALL_PROXY",
|
||||
"http_proxy",
|
||||
"https_proxy",
|
||||
"all_proxy",
|
||||
];
|
||||
|
||||
pub fn apply_system_proxy_settings(settings: &SystemProxySettings) -> Result<(), String> {
|
||||
if settings.enabled {
|
||||
let proxy_url = settings
|
||||
.proxy_url
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.ok_or_else(|| "proxy url is required when proxy is enabled".to_string())?;
|
||||
|
||||
for key in PROXY_ENV_KEYS {
|
||||
unsafe {
|
||||
std::env::set_var(key, proxy_url);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
clear_proxy_env();
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn clear_proxy_env() {
|
||||
for key in PROXY_ENV_KEYS {
|
||||
unsafe {
|
||||
std::env::remove_var(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn current_proxy_env_vars() -> Vec<(String, String)> {
|
||||
PROXY_ENV_KEYS
|
||||
.iter()
|
||||
.filter_map(|key| {
|
||||
std::env::var(key).ok().and_then(|value| {
|
||||
let trimmed = value.trim();
|
||||
if trimmed.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(((*key).to_string(), trimmed.to_string()))
|
||||
}
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
@@ -0,0 +1,983 @@
|
||||
use std::fs;
|
||||
use std::io::{BufRead, BufReader};
|
||||
use std::path::PathBuf;
|
||||
use std::sync::OnceLock;
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use regex::Regex;
|
||||
|
||||
use crate::models::*;
|
||||
use crate::parsers::{folder_name_from_path, truncate_str, AgentParser, ParseError};
|
||||
|
||||
/// Regex that matches Claude Code system-injected XML tags and their content.
|
||||
/// These tags are internal metadata and should not be displayed to users.
|
||||
/// Note: Rust regex doesn't support backreferences, so each tag is listed explicitly.
|
||||
fn system_tag_regex() -> &'static Regex {
|
||||
static RE: OnceLock<Regex> = OnceLock::new();
|
||||
RE.get_or_init(|| {
|
||||
Regex::new(concat!(
|
||||
r"(?s)",
|
||||
r"<system-reminder>.*?</system-reminder>",
|
||||
r"|<local-command-caveat>.*?</local-command-caveat>",
|
||||
r"|<command-name>.*?</command-name>",
|
||||
r"|<command-message>.*?</command-message>",
|
||||
r"|<command-args>.*?</command-args>",
|
||||
r"|<local-command-stdout>.*?</local-command-stdout>",
|
||||
r"|<user-prompt-submit-hook>.*?</user-prompt-submit-hook>",
|
||||
))
|
||||
.unwrap()
|
||||
})
|
||||
}
|
||||
|
||||
/// Regex that matches an optional model capacity suffix like `[1M]` / `[500k]`.
|
||||
fn model_capacity_suffix_regex() -> &'static Regex {
|
||||
static RE: OnceLock<Regex> = OnceLock::new();
|
||||
RE.get_or_init(|| {
|
||||
Regex::new(r"(?i)\[\s*([0-9]+(?:\.[0-9]+)?)\s*([km])\s*\]\s*$")
|
||||
.expect("valid model capacity regex")
|
||||
})
|
||||
}
|
||||
|
||||
/// Strip system-injected XML tags from text content.
|
||||
/// Returns None if the text becomes empty after stripping.
|
||||
fn strip_system_tags(text: &str) -> Option<String> {
|
||||
let cleaned = system_tag_regex().replace_all(text, "");
|
||||
let trimmed = cleaned.trim();
|
||||
if trimmed.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(trimmed.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if a JSONL entry is a system meta message (isMeta: true).
|
||||
fn is_meta_message(value: &serde_json::Value) -> bool {
|
||||
value
|
||||
.get("isMeta")
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
fn parse_model_capacity_suffix(model: &str) -> Option<u64> {
|
||||
let captures = model_capacity_suffix_regex().captures(model.trim())?;
|
||||
let value = captures.get(1)?.as_str().parse::<f64>().ok()?;
|
||||
if !value.is_finite() || value <= 0.0 {
|
||||
return None;
|
||||
}
|
||||
|
||||
let unit = captures
|
||||
.get(2)
|
||||
.map(|m| m.as_str().to_ascii_lowercase())
|
||||
.unwrap_or_default();
|
||||
let multiplier = match unit.as_str() {
|
||||
"m" => 1_000_000.0,
|
||||
"k" => 1_000.0,
|
||||
_ => return None,
|
||||
};
|
||||
|
||||
Some((value * multiplier) as u64)
|
||||
}
|
||||
|
||||
fn claude_context_window_max_tokens_for_model(model: Option<&str>) -> Option<u64> {
|
||||
let model = model?.trim();
|
||||
if model.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
// If user/model config contains an explicit capacity suffix, prefer it.
|
||||
if let Some(suffixed_limit) = parse_model_capacity_suffix(model) {
|
||||
return Some(suffixed_limit);
|
||||
}
|
||||
|
||||
// Claude models default to 200k when no explicit capacity is provided.
|
||||
if model.to_ascii_lowercase().starts_with("claude") {
|
||||
return Some(200_000);
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
fn claude_context_window_used_tokens_from_usage(usage: &TurnUsage) -> Option<u64> {
|
||||
let used_tokens = usage
|
||||
.input_tokens
|
||||
.saturating_add(usage.cache_creation_input_tokens)
|
||||
.saturating_add(usage.cache_read_input_tokens);
|
||||
if used_tokens > 0 {
|
||||
Some(used_tokens)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn latest_claude_context_window_used_tokens(turns: &[MessageTurn]) -> Option<u64> {
|
||||
turns.iter().rev().find_map(|turn| {
|
||||
turn.usage
|
||||
.as_ref()
|
||||
.and_then(claude_context_window_used_tokens_from_usage)
|
||||
})
|
||||
}
|
||||
|
||||
fn merge_claude_context_window_stats(
|
||||
stats: Option<SessionStats>,
|
||||
used_tokens: Option<u64>,
|
||||
max_tokens: Option<u64>,
|
||||
) -> Option<SessionStats> {
|
||||
if used_tokens.is_none() && max_tokens.is_none() {
|
||||
return stats;
|
||||
}
|
||||
|
||||
let usage_percent = match (used_tokens, max_tokens) {
|
||||
(Some(used), Some(max)) if max > 0 => Some((used as f64 / max as f64) * 100.0),
|
||||
_ => None,
|
||||
};
|
||||
|
||||
match stats {
|
||||
Some(mut s) => {
|
||||
s.context_window_used_tokens = used_tokens;
|
||||
s.context_window_max_tokens = max_tokens;
|
||||
s.context_window_usage_percent = usage_percent;
|
||||
Some(s)
|
||||
}
|
||||
None => Some(SessionStats {
|
||||
total_usage: None,
|
||||
total_tokens: None,
|
||||
total_duration_ms: 0,
|
||||
context_window_used_tokens: used_tokens,
|
||||
context_window_max_tokens: max_tokens,
|
||||
context_window_usage_percent: usage_percent,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
pub struct ClaudeParser {
|
||||
base_dir: PathBuf,
|
||||
}
|
||||
|
||||
impl ClaudeParser {
|
||||
pub fn new() -> Self {
|
||||
let base_dir = resolve_claude_config_dir().join("projects");
|
||||
Self { base_dir }
|
||||
}
|
||||
|
||||
fn decode_folder_path(encoded: &str) -> String {
|
||||
encoded.replace('-', "/")
|
||||
}
|
||||
|
||||
fn parse_jsonl_summary(
|
||||
&self,
|
||||
path: &PathBuf,
|
||||
) -> Result<Option<ConversationSummary>, ParseError> {
|
||||
let file = fs::File::open(path)?;
|
||||
let reader = BufReader::new(file);
|
||||
|
||||
let mut conversation_id: Option<String> = None;
|
||||
let mut cwd: Option<String> = None;
|
||||
let mut git_branch: Option<String> = None;
|
||||
let mut model: Option<String> = None;
|
||||
let mut title: Option<String> = None;
|
||||
let mut first_timestamp: Option<DateTime<Utc>> = None;
|
||||
let mut last_timestamp: Option<DateTime<Utc>> = None;
|
||||
let mut message_count: u32 = 0;
|
||||
|
||||
for line in reader.lines() {
|
||||
let line = match line {
|
||||
Ok(l) => l,
|
||||
Err(_) => continue,
|
||||
};
|
||||
if line.trim().is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let value: serde_json::Value = match serde_json::from_str(&line) {
|
||||
Ok(v) => v,
|
||||
Err(_) => continue,
|
||||
};
|
||||
|
||||
let msg_type = value.get("type").and_then(|t| t.as_str()).unwrap_or("");
|
||||
|
||||
// Skip non-conversation entries
|
||||
if msg_type == "file-history-snapshot" || msg_type == "progress" {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip system meta messages (e.g. local-command-caveat injections)
|
||||
if is_meta_message(&value) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if conversation_id.is_none() {
|
||||
conversation_id = value
|
||||
.get("sessionId")
|
||||
.and_then(|s| s.as_str())
|
||||
.map(|s| s.to_string());
|
||||
}
|
||||
|
||||
if cwd.is_none() {
|
||||
cwd = value
|
||||
.get("cwd")
|
||||
.and_then(|s| s.as_str())
|
||||
.map(|s| s.to_string());
|
||||
}
|
||||
|
||||
if git_branch.is_none() {
|
||||
git_branch = value
|
||||
.get("gitBranch")
|
||||
.and_then(|s| s.as_str())
|
||||
.map(|s| s.to_string());
|
||||
}
|
||||
|
||||
if let Some(ts_str) = value.get("timestamp").and_then(|t| t.as_str()) {
|
||||
if let Ok(ts) = ts_str.parse::<DateTime<Utc>>() {
|
||||
if first_timestamp.is_none() {
|
||||
first_timestamp = Some(ts);
|
||||
}
|
||||
last_timestamp = Some(ts);
|
||||
}
|
||||
}
|
||||
|
||||
if msg_type == "user" || msg_type == "assistant" {
|
||||
message_count += 1;
|
||||
|
||||
// Extract model from assistant messages
|
||||
if msg_type == "assistant" && model.is_none() {
|
||||
model = value
|
||||
.get("message")
|
||||
.and_then(|m| m.get("model"))
|
||||
.and_then(|m| m.as_str())
|
||||
.map(|s| s.to_string());
|
||||
}
|
||||
|
||||
// Extract title from first user message
|
||||
if msg_type == "user" && title.is_none() {
|
||||
title = extract_user_text(&value).map(|t| truncate_str(&t, 100));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let started_at = match first_timestamp {
|
||||
Some(ts) => ts,
|
||||
None => return Ok(None),
|
||||
};
|
||||
|
||||
// Use filename (without .jsonl) as ID fallback
|
||||
let id = conversation_id.unwrap_or_else(|| {
|
||||
path.file_stem()
|
||||
.unwrap_or_default()
|
||||
.to_string_lossy()
|
||||
.to_string()
|
||||
});
|
||||
|
||||
let folder_path = cwd.clone();
|
||||
let folder_name = folder_path.as_ref().map(|p| folder_name_from_path(p));
|
||||
|
||||
Ok(Some(ConversationSummary {
|
||||
id,
|
||||
agent_type: AgentType::ClaudeCode,
|
||||
folder_path,
|
||||
folder_name,
|
||||
title,
|
||||
started_at,
|
||||
ended_at: last_timestamp,
|
||||
message_count,
|
||||
model,
|
||||
git_branch,
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_claude_config_dir() -> PathBuf {
|
||||
resolve_claude_config_dir_from(
|
||||
std::env::var_os("CLAUDE_CONFIG_DIR"),
|
||||
dirs::home_dir(),
|
||||
)
|
||||
}
|
||||
|
||||
fn resolve_claude_config_dir_from(
|
||||
claude_config_dir_env: Option<std::ffi::OsString>,
|
||||
home_dir: Option<PathBuf>,
|
||||
) -> PathBuf {
|
||||
claude_config_dir_env
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(|| home_dir.unwrap_or_default().join(".claude"))
|
||||
}
|
||||
|
||||
impl AgentParser for ClaudeParser {
|
||||
fn list_conversations(&self) -> Result<Vec<ConversationSummary>, ParseError> {
|
||||
let mut conversations = Vec::new();
|
||||
|
||||
if !self.base_dir.exists() {
|
||||
return Ok(conversations);
|
||||
}
|
||||
|
||||
let entries = fs::read_dir(&self.base_dir)?;
|
||||
for entry in entries {
|
||||
let entry = match entry {
|
||||
Ok(e) => e,
|
||||
Err(_) => continue,
|
||||
};
|
||||
let project_dir = entry.path();
|
||||
if !project_dir.is_dir() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let jsonl_files = fs::read_dir(&project_dir)?;
|
||||
for file_entry in jsonl_files {
|
||||
let file_entry = match file_entry {
|
||||
Ok(e) => e,
|
||||
Err(_) => continue,
|
||||
};
|
||||
let file_path = file_entry.path();
|
||||
if file_path.extension().and_then(|e| e.to_str()) != Some("jsonl") {
|
||||
continue;
|
||||
}
|
||||
|
||||
match self.parse_jsonl_summary(&file_path) {
|
||||
Ok(Some(mut summary)) => {
|
||||
// If folder_path is still None, derive from directory name
|
||||
if summary.folder_path.is_none() {
|
||||
let dir_name = project_dir
|
||||
.file_name()
|
||||
.unwrap_or_default()
|
||||
.to_string_lossy()
|
||||
.to_string();
|
||||
let decoded = Self::decode_folder_path(&dir_name);
|
||||
summary.folder_path = Some(decoded.clone());
|
||||
summary.folder_name = Some(folder_name_from_path(&decoded));
|
||||
}
|
||||
conversations.push(summary);
|
||||
}
|
||||
Ok(None) => continue,
|
||||
Err(_) => continue,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
conversations.sort_by(|a, b| b.started_at.cmp(&a.started_at));
|
||||
Ok(conversations)
|
||||
}
|
||||
|
||||
fn get_conversation(&self, conversation_id: &str) -> Result<ConversationDetail, ParseError> {
|
||||
// Find the conversation file by searching all directories
|
||||
if !self.base_dir.exists() {
|
||||
return Err(ParseError::ConversationNotFound(
|
||||
conversation_id.to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
for entry in fs::read_dir(&self.base_dir)? {
|
||||
let entry = match entry {
|
||||
Ok(e) => e,
|
||||
Err(_) => continue,
|
||||
};
|
||||
let project_dir = entry.path();
|
||||
if !project_dir.is_dir() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let file_path = project_dir.join(format!("{}.jsonl", conversation_id));
|
||||
if file_path.exists() {
|
||||
return self.parse_conversation_detail(&file_path, conversation_id);
|
||||
}
|
||||
}
|
||||
|
||||
Err(ParseError::ConversationNotFound(
|
||||
conversation_id.to_string(),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
impl ClaudeParser {
|
||||
fn parse_conversation_detail(
|
||||
&self,
|
||||
path: &PathBuf,
|
||||
conversation_id: &str,
|
||||
) -> Result<ConversationDetail, ParseError> {
|
||||
let file = fs::File::open(path)?;
|
||||
let reader = BufReader::new(file);
|
||||
|
||||
let mut messages = Vec::new();
|
||||
let mut cwd: Option<String> = None;
|
||||
let mut git_branch: Option<String> = None;
|
||||
let mut model: Option<String> = None;
|
||||
let mut title: Option<String> = None;
|
||||
let mut first_timestamp: Option<DateTime<Utc>> = None;
|
||||
let mut last_timestamp: Option<DateTime<Utc>> = None;
|
||||
|
||||
for line in reader.lines() {
|
||||
let line = match line {
|
||||
Ok(l) => l,
|
||||
Err(_) => continue,
|
||||
};
|
||||
if line.trim().is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let value: serde_json::Value = match serde_json::from_str(&line) {
|
||||
Ok(v) => v,
|
||||
Err(_) => continue,
|
||||
};
|
||||
|
||||
let msg_type = value.get("type").and_then(|t| t.as_str()).unwrap_or("");
|
||||
|
||||
if msg_type == "file-history-snapshot" || msg_type == "progress" {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip system meta messages
|
||||
if is_meta_message(&value) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if cwd.is_none() {
|
||||
cwd = value
|
||||
.get("cwd")
|
||||
.and_then(|s| s.as_str())
|
||||
.map(|s| s.to_string());
|
||||
}
|
||||
if git_branch.is_none() {
|
||||
git_branch = value
|
||||
.get("gitBranch")
|
||||
.and_then(|s| s.as_str())
|
||||
.map(|s| s.to_string());
|
||||
}
|
||||
|
||||
if let Some(ts_str) = value.get("timestamp").and_then(|t| t.as_str()) {
|
||||
if let Ok(ts) = ts_str.parse::<DateTime<Utc>>() {
|
||||
if first_timestamp.is_none() {
|
||||
first_timestamp = Some(ts);
|
||||
}
|
||||
last_timestamp = Some(ts);
|
||||
}
|
||||
}
|
||||
|
||||
match msg_type {
|
||||
"user" => {
|
||||
let content = extract_user_content(&value);
|
||||
|
||||
// Skip user messages that are empty after system tag stripping
|
||||
if content.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let timestamp = parse_timestamp(&value).unwrap_or_else(Utc::now);
|
||||
let uuid = value
|
||||
.get("uuid")
|
||||
.and_then(|u| u.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
|
||||
if title.is_none() {
|
||||
if let Some(first_text) = content.iter().find_map(|c| match c {
|
||||
ContentBlock::Text { text } => Some(text.clone()),
|
||||
_ => None,
|
||||
}) {
|
||||
title = Some(truncate_str(&first_text, 100));
|
||||
}
|
||||
}
|
||||
|
||||
messages.push(UnifiedMessage {
|
||||
id: uuid,
|
||||
role: MessageRole::User,
|
||||
content,
|
||||
timestamp,
|
||||
usage: None,
|
||||
duration_ms: None,
|
||||
model: None,
|
||||
});
|
||||
}
|
||||
"assistant" => {
|
||||
let timestamp = parse_timestamp(&value).unwrap_or_else(Utc::now);
|
||||
let uuid = value
|
||||
.get("uuid")
|
||||
.and_then(|u| u.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
|
||||
let msg_model = value
|
||||
.get("message")
|
||||
.and_then(|m| m.get("model"))
|
||||
.and_then(|m| m.as_str())
|
||||
.map(|s| s.to_string());
|
||||
|
||||
if model.is_none() {
|
||||
model = msg_model.clone();
|
||||
}
|
||||
|
||||
let content = extract_assistant_content(&value);
|
||||
let usage = extract_usage(&value);
|
||||
|
||||
messages.push(UnifiedMessage {
|
||||
id: uuid,
|
||||
role: MessageRole::Assistant,
|
||||
content,
|
||||
timestamp,
|
||||
usage,
|
||||
duration_ms: None,
|
||||
model: msg_model,
|
||||
});
|
||||
}
|
||||
"system" => {
|
||||
let subtype = value.get("subtype").and_then(|s| s.as_str()).unwrap_or("");
|
||||
if subtype == "turn_duration" {
|
||||
if let Some(duration) = value.get("durationMs").and_then(|d| d.as_u64()) {
|
||||
// Attach to the last assistant message
|
||||
if let Some(last) = messages
|
||||
.iter_mut()
|
||||
.rev()
|
||||
.find(|m| matches!(m.role, MessageRole::Assistant))
|
||||
{
|
||||
last.duration_ms = Some(duration);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
let folder_path = cwd.clone();
|
||||
let folder_name = folder_path.as_ref().map(|p| folder_name_from_path(p));
|
||||
|
||||
let turns = group_into_turns(messages);
|
||||
let context_window_used_tokens = latest_claude_context_window_used_tokens(&turns);
|
||||
let context_window_max_tokens =
|
||||
claude_context_window_max_tokens_for_model(model.as_deref());
|
||||
let session_stats = merge_claude_context_window_stats(
|
||||
super::compute_session_stats(&turns),
|
||||
context_window_used_tokens,
|
||||
context_window_max_tokens,
|
||||
);
|
||||
|
||||
let summary = ConversationSummary {
|
||||
id: conversation_id.to_string(),
|
||||
agent_type: AgentType::ClaudeCode,
|
||||
folder_path,
|
||||
folder_name,
|
||||
title,
|
||||
started_at: first_timestamp.unwrap_or_else(Utc::now),
|
||||
ended_at: last_timestamp,
|
||||
message_count: turns.len() as u32,
|
||||
model,
|
||||
git_branch,
|
||||
};
|
||||
|
||||
Ok(ConversationDetail {
|
||||
summary,
|
||||
turns,
|
||||
session_stats,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_timestamp(value: &serde_json::Value) -> Option<DateTime<Utc>> {
|
||||
value
|
||||
.get("timestamp")
|
||||
.and_then(|t| t.as_str())
|
||||
.and_then(|s| s.parse::<DateTime<Utc>>().ok())
|
||||
}
|
||||
|
||||
fn extract_user_text(value: &serde_json::Value) -> Option<String> {
|
||||
let message = value.get("message")?;
|
||||
let content = message.get("content")?;
|
||||
|
||||
if let Some(text) = content.as_str() {
|
||||
return strip_system_tags(text);
|
||||
}
|
||||
|
||||
if let Some(arr) = content.as_array() {
|
||||
for item in arr {
|
||||
if item.get("type").and_then(|t| t.as_str()) == Some("text") {
|
||||
if let Some(text) = item.get("text").and_then(|t| t.as_str()) {
|
||||
if let Some(cleaned) = strip_system_tags(text) {
|
||||
return Some(cleaned);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
fn extract_user_content(value: &serde_json::Value) -> Vec<ContentBlock> {
|
||||
let mut blocks = Vec::new();
|
||||
let message = match value.get("message") {
|
||||
Some(m) => m,
|
||||
None => return blocks,
|
||||
};
|
||||
let content = match message.get("content") {
|
||||
Some(c) => c,
|
||||
None => return blocks,
|
||||
};
|
||||
|
||||
if let Some(text) = content.as_str() {
|
||||
if let Some(cleaned) = strip_system_tags(text) {
|
||||
blocks.push(ContentBlock::Text { text: cleaned });
|
||||
}
|
||||
return blocks;
|
||||
}
|
||||
|
||||
if let Some(arr) = content.as_array() {
|
||||
for item in arr {
|
||||
let block_type = item.get("type").and_then(|t| t.as_str()).unwrap_or("");
|
||||
match block_type {
|
||||
"text" => {
|
||||
if let Some(text) = item.get("text").and_then(|t| t.as_str()) {
|
||||
if let Some(cleaned) = strip_system_tags(text) {
|
||||
blocks.push(ContentBlock::Text { text: cleaned });
|
||||
}
|
||||
}
|
||||
}
|
||||
"tool_result" | "server_tool_result" => {
|
||||
let tool_use_id = item
|
||||
.get("tool_use_id")
|
||||
.and_then(|n| n.as_str())
|
||||
.map(|s| s.to_string());
|
||||
let output = extract_tool_result_text(item);
|
||||
let is_error = item
|
||||
.get("is_error")
|
||||
.and_then(|e| e.as_bool())
|
||||
.unwrap_or(false);
|
||||
blocks.push(ContentBlock::ToolResult {
|
||||
tool_use_id,
|
||||
output_preview: output,
|
||||
is_error,
|
||||
});
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
blocks
|
||||
}
|
||||
|
||||
fn extract_assistant_content(value: &serde_json::Value) -> Vec<ContentBlock> {
|
||||
let mut blocks = Vec::new();
|
||||
let message = match value.get("message") {
|
||||
Some(m) => m,
|
||||
None => return blocks,
|
||||
};
|
||||
let content = match message.get("content") {
|
||||
Some(c) => c,
|
||||
None => return blocks,
|
||||
};
|
||||
|
||||
if let Some(arr) = content.as_array() {
|
||||
for item in arr {
|
||||
let block_type = item.get("type").and_then(|t| t.as_str()).unwrap_or("");
|
||||
match block_type {
|
||||
"text" => {
|
||||
if let Some(text) = item.get("text").and_then(|t| t.as_str()) {
|
||||
blocks.push(ContentBlock::Text {
|
||||
text: text.to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
"thinking" => {
|
||||
if let Some(text) = item.get("thinking").and_then(|t| t.as_str()) {
|
||||
blocks.push(ContentBlock::Thinking {
|
||||
text: text.to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
"tool_use" | "server_tool_use" => {
|
||||
let tool_use_id = item
|
||||
.get("id")
|
||||
.and_then(|n| n.as_str())
|
||||
.map(|s| s.to_string());
|
||||
let tool_name = item
|
||||
.get("name")
|
||||
.and_then(|n| n.as_str())
|
||||
.unwrap_or("unknown")
|
||||
.to_string();
|
||||
let input_preview = item.get("input").map(|i| i.to_string());
|
||||
blocks.push(ContentBlock::ToolUse {
|
||||
tool_use_id,
|
||||
tool_name,
|
||||
input_preview,
|
||||
});
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
blocks
|
||||
}
|
||||
|
||||
fn extract_usage(value: &serde_json::Value) -> Option<TurnUsage> {
|
||||
let usage = value.get("message")?.get("usage")?;
|
||||
Some(TurnUsage {
|
||||
input_tokens: usage
|
||||
.get("input_tokens")
|
||||
.and_then(|v| v.as_u64())
|
||||
.unwrap_or(0),
|
||||
output_tokens: usage
|
||||
.get("output_tokens")
|
||||
.and_then(|v| v.as_u64())
|
||||
.unwrap_or(0),
|
||||
cache_creation_input_tokens: usage
|
||||
.get("cache_creation_input_tokens")
|
||||
.and_then(|v| v.as_u64())
|
||||
.unwrap_or(0),
|
||||
cache_read_input_tokens: usage
|
||||
.get("cache_read_input_tokens")
|
||||
.and_then(|v| v.as_u64())
|
||||
.unwrap_or(0),
|
||||
})
|
||||
}
|
||||
|
||||
fn extract_tool_result_text(item: &serde_json::Value) -> Option<String> {
|
||||
let content = item.get("content")?;
|
||||
if let Some(text) = content.as_str() {
|
||||
return Some(text.to_string());
|
||||
}
|
||||
if let Some(arr) = content.as_array() {
|
||||
let texts: Vec<String> = arr
|
||||
.iter()
|
||||
.filter_map(|c| {
|
||||
if c.get("type").and_then(|t| t.as_str()) == Some("text") {
|
||||
c.get("text")
|
||||
.and_then(|t| t.as_str())
|
||||
.map(|s| s.to_string())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
if !texts.is_empty() {
|
||||
return Some(texts.join("\n"));
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Check if a user message contains ONLY tool_result blocks (no text).
|
||||
/// In Claude Code, tool results come back as "user" messages.
|
||||
fn is_tool_result_only(msg: &UnifiedMessage) -> bool {
|
||||
matches!(msg.role, MessageRole::User)
|
||||
&& !msg.content.is_empty()
|
||||
&& msg
|
||||
.content
|
||||
.iter()
|
||||
.all(|b| matches!(b, ContentBlock::ToolResult { .. }))
|
||||
}
|
||||
|
||||
/// Group flat messages into conversation turns.
|
||||
/// Claude Code rule: assistant msg + following tool-result-only user msgs
|
||||
/// merge into one Assistant turn.
|
||||
fn group_into_turns(messages: Vec<UnifiedMessage>) -> Vec<MessageTurn> {
|
||||
let mut turns = Vec::new();
|
||||
let mut i = 0;
|
||||
|
||||
while i < messages.len() {
|
||||
let msg = &messages[i];
|
||||
|
||||
if matches!(msg.role, MessageRole::Assistant) {
|
||||
let mut blocks: Vec<ContentBlock> = msg.content.clone();
|
||||
let timestamp = msg.timestamp;
|
||||
let id = format!("turn-{}", turns.len());
|
||||
let usage = msg.usage.clone();
|
||||
let duration_ms = msg.duration_ms;
|
||||
let turn_model = msg.model.clone();
|
||||
i += 1;
|
||||
|
||||
// Absorb consecutive assistant msgs AND tool-result-only user msgs
|
||||
while i < messages.len()
|
||||
&& (matches!(messages[i].role, MessageRole::Assistant)
|
||||
|| is_tool_result_only(&messages[i]))
|
||||
{
|
||||
blocks.extend(messages[i].content.clone());
|
||||
i += 1;
|
||||
}
|
||||
|
||||
turns.push(MessageTurn {
|
||||
id,
|
||||
role: TurnRole::Assistant,
|
||||
blocks,
|
||||
timestamp,
|
||||
usage,
|
||||
duration_ms,
|
||||
model: turn_model,
|
||||
});
|
||||
} else if matches!(msg.role, MessageRole::System) {
|
||||
turns.push(MessageTurn {
|
||||
id: format!("turn-{}", turns.len()),
|
||||
role: TurnRole::System,
|
||||
blocks: msg.content.clone(),
|
||||
timestamp: msg.timestamp,
|
||||
usage: None,
|
||||
duration_ms: None,
|
||||
model: None,
|
||||
});
|
||||
i += 1;
|
||||
} else {
|
||||
turns.push(MessageTurn {
|
||||
id: format!("turn-{}", turns.len()),
|
||||
role: TurnRole::User,
|
||||
blocks: msg.content.clone(),
|
||||
timestamp: msg.timestamp,
|
||||
usage: None,
|
||||
duration_ms: None,
|
||||
model: None,
|
||||
});
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
|
||||
turns
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::io::Write;
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parses_model_capacity_suffix() {
|
||||
assert_eq!(
|
||||
parse_model_capacity_suffix("claude-sonnet-4-6[1.5M]"),
|
||||
Some(1_500_000)
|
||||
);
|
||||
assert_eq!(
|
||||
parse_model_capacity_suffix("claude-opus-4-6 [500k]"),
|
||||
Some(500_000)
|
||||
);
|
||||
assert_eq!(parse_model_capacity_suffix("claude-sonnet-4-6"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn defaults_context_limit_for_claude_models() {
|
||||
assert_eq!(
|
||||
claude_context_window_max_tokens_for_model(Some("claude-sonnet-4-6")),
|
||||
Some(200_000)
|
||||
);
|
||||
assert_eq!(
|
||||
claude_context_window_max_tokens_for_model(Some("custom-model-x")),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn uses_latest_assistant_usage_for_context_tokens() {
|
||||
let timestamp = Utc::now();
|
||||
let turns = vec![
|
||||
MessageTurn {
|
||||
id: "turn-0".to_string(),
|
||||
role: TurnRole::Assistant,
|
||||
blocks: vec![],
|
||||
timestamp,
|
||||
usage: Some(TurnUsage {
|
||||
input_tokens: 100,
|
||||
output_tokens: 20,
|
||||
cache_creation_input_tokens: 30,
|
||||
cache_read_input_tokens: 40,
|
||||
}),
|
||||
duration_ms: None,
|
||||
model: None,
|
||||
},
|
||||
MessageTurn {
|
||||
id: "turn-1".to_string(),
|
||||
role: TurnRole::Assistant,
|
||||
blocks: vec![],
|
||||
timestamp,
|
||||
usage: Some(TurnUsage {
|
||||
input_tokens: 250,
|
||||
output_tokens: 60,
|
||||
cache_creation_input_tokens: 70,
|
||||
cache_read_input_tokens: 80,
|
||||
}),
|
||||
duration_ms: None,
|
||||
model: None,
|
||||
},
|
||||
];
|
||||
|
||||
assert_eq!(
|
||||
latest_claude_context_window_used_tokens(&turns),
|
||||
Some(250 + 70 + 80)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_detail_sets_claude_context_window_stats() {
|
||||
let path = std::env::temp_dir().join(format!(
|
||||
"codeg-claude-parser-{}.jsonl",
|
||||
uuid::Uuid::new_v4()
|
||||
));
|
||||
let mut file = fs::File::create(&path).expect("create temp jsonl");
|
||||
writeln!(
|
||||
file,
|
||||
"{}",
|
||||
serde_json::json!({
|
||||
"type": "user",
|
||||
"sessionId": "session-test",
|
||||
"timestamp": "2026-03-01T10:00:00Z",
|
||||
"uuid": "u1",
|
||||
"cwd": "/tmp/demo",
|
||||
"gitBranch": "main",
|
||||
"message": {
|
||||
"content": [{"type": "text", "text": "hello"}]
|
||||
}
|
||||
})
|
||||
)
|
||||
.expect("write user line");
|
||||
writeln!(
|
||||
file,
|
||||
"{}",
|
||||
serde_json::json!({
|
||||
"type": "assistant",
|
||||
"sessionId": "session-test",
|
||||
"timestamp": "2026-03-01T10:00:02Z",
|
||||
"uuid": "a1",
|
||||
"message": {
|
||||
"model": "claude-sonnet-4-6",
|
||||
"content": [{"type": "text", "text": "world"}],
|
||||
"usage": {
|
||||
"input_tokens": 1000,
|
||||
"output_tokens": 200,
|
||||
"cache_creation_input_tokens": 300,
|
||||
"cache_read_input_tokens": 400
|
||||
}
|
||||
}
|
||||
})
|
||||
)
|
||||
.expect("write assistant line");
|
||||
|
||||
let parser = ClaudeParser {
|
||||
base_dir: PathBuf::new(),
|
||||
};
|
||||
let detail = parser
|
||||
.parse_conversation_detail(&path, "session-test")
|
||||
.expect("parse conversation detail");
|
||||
fs::remove_file(&path).expect("cleanup temp jsonl");
|
||||
|
||||
let stats = detail.session_stats.expect("session stats");
|
||||
assert_eq!(stats.context_window_used_tokens, Some(1700));
|
||||
assert_eq!(stats.context_window_max_tokens, Some(200_000));
|
||||
let percent = stats
|
||||
.context_window_usage_percent
|
||||
.expect("context window usage percent");
|
||||
assert!((percent - 0.85).abs() < f64::EPSILON);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn claude_config_dir_env_overrides_home() {
|
||||
let resolved = resolve_claude_config_dir_from(
|
||||
Some(std::ffi::OsString::from("/tmp/claude-config")),
|
||||
Some(PathBuf::from("/Users/default")),
|
||||
);
|
||||
assert_eq!(resolved, PathBuf::from("/tmp/claude-config"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn claude_config_dir_defaults_to_home_dot_claude() {
|
||||
let resolved = resolve_claude_config_dir_from(
|
||||
None,
|
||||
Some(PathBuf::from("/Users/default")),
|
||||
);
|
||||
assert_eq!(resolved, PathBuf::from("/Users/default/.claude"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,715 @@
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use walkdir::WalkDir;
|
||||
|
||||
use crate::models::*;
|
||||
use crate::parsers::{folder_name_from_path, truncate_str, AgentParser, ParseError};
|
||||
|
||||
pub struct GeminiParser {
|
||||
base_dir: PathBuf,
|
||||
}
|
||||
|
||||
impl GeminiParser {
|
||||
pub fn new() -> Self {
|
||||
let base_dir = resolve_gemini_base_dir();
|
||||
Self { base_dir }
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn with_base_dir(base_dir: PathBuf) -> Self {
|
||||
Self { base_dir }
|
||||
}
|
||||
|
||||
fn tmp_dir(&self) -> PathBuf {
|
||||
self.base_dir.join("tmp")
|
||||
}
|
||||
|
||||
fn history_dir(&self) -> PathBuf {
|
||||
self.base_dir.join("history")
|
||||
}
|
||||
|
||||
fn projects_json_path(&self) -> PathBuf {
|
||||
self.base_dir.join("projects.json")
|
||||
}
|
||||
|
||||
fn is_chat_file(path: &Path) -> bool {
|
||||
if path.extension().and_then(|e| e.to_str()) != Some("json") {
|
||||
return false;
|
||||
}
|
||||
let file_name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
|
||||
if !file_name.starts_with("session-") {
|
||||
return false;
|
||||
}
|
||||
path.parent()
|
||||
.and_then(|p| p.file_name())
|
||||
.and_then(|n| n.to_str())
|
||||
== Some("chats")
|
||||
}
|
||||
|
||||
fn list_chat_files(&self) -> Vec<PathBuf> {
|
||||
let tmp_dir = self.tmp_dir();
|
||||
if !tmp_dir.exists() {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
let mut files: Vec<PathBuf> = WalkDir::new(&tmp_dir)
|
||||
.into_iter()
|
||||
.filter_map(|e| e.ok())
|
||||
.map(|e| e.path().to_path_buf())
|
||||
.filter(|p| p.is_file() && Self::is_chat_file(p))
|
||||
.collect();
|
||||
files.sort();
|
||||
files
|
||||
}
|
||||
|
||||
fn project_alias_from_chat_path(path: &Path) -> Option<String> {
|
||||
path.parent()?
|
||||
.parent()?
|
||||
.file_name()
|
||||
.map(|n| n.to_string_lossy().to_string())
|
||||
}
|
||||
|
||||
fn read_project_root_file(path: PathBuf) -> Option<String> {
|
||||
let raw = fs::read_to_string(path).ok()?;
|
||||
let trimmed = raw.trim();
|
||||
if trimmed.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(trimmed.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_project_root(&self, alias: &str) -> Option<String> {
|
||||
let tmp_root = self.tmp_dir().join(alias).join(".project_root");
|
||||
if let Some(path) = Self::read_project_root_file(tmp_root) {
|
||||
return Some(path);
|
||||
}
|
||||
|
||||
let history_root = self.history_dir().join(alias).join(".project_root");
|
||||
if let Some(path) = Self::read_project_root_file(history_root) {
|
||||
return Some(path);
|
||||
}
|
||||
|
||||
self.resolve_project_root_from_projects_json(alias)
|
||||
}
|
||||
|
||||
fn resolve_project_root_from_projects_json(&self, alias: &str) -> Option<String> {
|
||||
let raw = fs::read_to_string(self.projects_json_path()).ok()?;
|
||||
let value: serde_json::Value = serde_json::from_str(&raw).ok()?;
|
||||
let projects = value.get("projects")?.as_object()?;
|
||||
projects
|
||||
.iter()
|
||||
.find_map(|(path, mapped_alias)| (mapped_alias.as_str() == Some(alias)).then(|| path))
|
||||
.map(|s| s.to_string())
|
||||
}
|
||||
|
||||
fn parse_timestamp(value: Option<&serde_json::Value>) -> Option<DateTime<Utc>> {
|
||||
value.and_then(|v| v.as_str()?.parse::<DateTime<Utc>>().ok())
|
||||
}
|
||||
|
||||
fn extract_text(value: &serde_json::Value) -> Option<String> {
|
||||
match value {
|
||||
serde_json::Value::String(text) => {
|
||||
let trimmed = text.trim();
|
||||
if trimmed.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(trimmed.to_string())
|
||||
}
|
||||
}
|
||||
serde_json::Value::Array(items) => {
|
||||
let mut parts = Vec::new();
|
||||
for item in items {
|
||||
if let Some(text) = item.get("text").and_then(Self::extract_text) {
|
||||
parts.push(text);
|
||||
} else if let Some(text) = Self::extract_text(item) {
|
||||
parts.push(text);
|
||||
}
|
||||
}
|
||||
if parts.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(parts.join("\n"))
|
||||
}
|
||||
}
|
||||
serde_json::Value::Object(map) => {
|
||||
if let Some(text) = map.get("text").and_then(Self::extract_text) {
|
||||
return Some(text);
|
||||
}
|
||||
if let Some(text) = map.get("message").and_then(Self::extract_text) {
|
||||
return Some(text);
|
||||
}
|
||||
None
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn extract_message_text(message: &serde_json::Value) -> Option<String> {
|
||||
message
|
||||
.get("content")
|
||||
.and_then(Self::extract_text)
|
||||
.or_else(|| message.get("message").and_then(Self::extract_text))
|
||||
}
|
||||
|
||||
fn parse_summary_from_value(
|
||||
&self,
|
||||
path: &Path,
|
||||
value: &serde_json::Value,
|
||||
) -> Option<ConversationSummary> {
|
||||
let id = value.get("sessionId").and_then(|v| v.as_str())?.to_string();
|
||||
let messages = value
|
||||
.get("messages")
|
||||
.and_then(|m| m.as_array())
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
|
||||
let first_message_ts = messages
|
||||
.first()
|
||||
.and_then(|m| Self::parse_timestamp(m.get("timestamp")));
|
||||
let last_message_ts = messages
|
||||
.iter()
|
||||
.rev()
|
||||
.find_map(|m| Self::parse_timestamp(m.get("timestamp")));
|
||||
|
||||
let started_at = Self::parse_timestamp(value.get("startTime"))
|
||||
.or(first_message_ts)
|
||||
.unwrap_or_else(Utc::now);
|
||||
let ended_at = Self::parse_timestamp(value.get("lastUpdated")).or(last_message_ts);
|
||||
|
||||
let title = messages
|
||||
.iter()
|
||||
.filter(|m| m.get("type").and_then(|t| t.as_str()) == Some("user"))
|
||||
.find_map(Self::extract_message_text)
|
||||
.map(|t| truncate_str(&t, 100));
|
||||
|
||||
let model = messages.iter().rev().find_map(|m| {
|
||||
m.get("model")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string())
|
||||
});
|
||||
|
||||
let folder_alias = Self::project_alias_from_chat_path(path);
|
||||
let folder_path = folder_alias
|
||||
.as_deref()
|
||||
.and_then(|alias| self.resolve_project_root(alias));
|
||||
let folder_name = folder_path
|
||||
.as_ref()
|
||||
.map(|p| folder_name_from_path(p))
|
||||
.or(folder_alias);
|
||||
|
||||
Some(ConversationSummary {
|
||||
id,
|
||||
agent_type: AgentType::Gemini,
|
||||
folder_path,
|
||||
folder_name,
|
||||
title,
|
||||
started_at,
|
||||
ended_at,
|
||||
message_count: messages.len() as u32,
|
||||
model,
|
||||
git_branch: None,
|
||||
})
|
||||
}
|
||||
|
||||
fn result_preview(result: Option<&serde_json::Value>) -> Option<String> {
|
||||
let v = result?;
|
||||
if let Some(s) = v.as_str() {
|
||||
let trimmed = s.trim();
|
||||
if trimmed.is_empty() {
|
||||
return None;
|
||||
}
|
||||
return Some(trimmed.to_string());
|
||||
}
|
||||
serde_json::to_string(v).ok()
|
||||
}
|
||||
|
||||
fn tool_call_is_error(call: &serde_json::Value, output_preview: Option<&str>) -> bool {
|
||||
if call
|
||||
.get("status")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| {
|
||||
matches!(
|
||||
s.to_ascii_lowercase().as_str(),
|
||||
"error" | "failed" | "failure" | "cancelled" | "canceled"
|
||||
)
|
||||
})
|
||||
.unwrap_or(false)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if call
|
||||
.get("result")
|
||||
.and_then(|r| r.as_array())
|
||||
.map(|items| {
|
||||
items.iter().any(|item| {
|
||||
item.get("functionResponse")
|
||||
.and_then(|fr| fr.get("response"))
|
||||
.and_then(|resp| resp.get("error"))
|
||||
.is_some()
|
||||
})
|
||||
})
|
||||
.unwrap_or(false)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
output_preview
|
||||
.map(|s| s.trim_start().to_ascii_lowercase().starts_with("error"))
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
fn parse_assistant_blocks(message: &serde_json::Value) -> Vec<ContentBlock> {
|
||||
let mut blocks: Vec<ContentBlock> = Vec::new();
|
||||
|
||||
if let Some(thoughts) = message.get("thoughts").and_then(|v| v.as_array()) {
|
||||
for thought in thoughts {
|
||||
let subject = thought
|
||||
.get("subject")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty());
|
||||
let description = thought
|
||||
.get("description")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty());
|
||||
let text = match (subject, description) {
|
||||
(Some(sub), Some(desc)) => format!("{sub}: {desc}"),
|
||||
(Some(sub), None) => sub.to_string(),
|
||||
(None, Some(desc)) => desc.to_string(),
|
||||
(None, None) => continue,
|
||||
};
|
||||
blocks.push(ContentBlock::Thinking { text });
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(tool_calls) = message.get("toolCalls").and_then(|v| v.as_array()) {
|
||||
for call in tool_calls {
|
||||
let tool_use_id = call
|
||||
.get("id")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string());
|
||||
let tool_name = call
|
||||
.get("displayName")
|
||||
.or_else(|| call.get("name"))
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("unknown")
|
||||
.to_string();
|
||||
let input_preview = call
|
||||
.get("args")
|
||||
.and_then(|v| serde_json::to_string(v).ok())
|
||||
.or_else(|| {
|
||||
call.get("input")
|
||||
.and_then(|v| Self::result_preview(Some(v)))
|
||||
});
|
||||
|
||||
blocks.push(ContentBlock::ToolUse {
|
||||
tool_use_id: tool_use_id.clone(),
|
||||
tool_name,
|
||||
input_preview,
|
||||
});
|
||||
|
||||
let output_preview = call
|
||||
.get("resultDisplay")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(|s| s.to_string())
|
||||
.or_else(|| Self::result_preview(call.get("result")));
|
||||
let is_error = Self::tool_call_is_error(call, output_preview.as_deref());
|
||||
|
||||
blocks.push(ContentBlock::ToolResult {
|
||||
tool_use_id,
|
||||
output_preview,
|
||||
is_error,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(text) = Self::extract_message_text(message) {
|
||||
blocks.push(ContentBlock::Text { text });
|
||||
}
|
||||
|
||||
blocks
|
||||
}
|
||||
|
||||
fn parse_usage(message: &serde_json::Value) -> Option<TurnUsage> {
|
||||
let tokens = message.get("tokens")?;
|
||||
let input_tokens = tokens.get("input").and_then(|v| v.as_u64()).unwrap_or(0);
|
||||
let output_tokens = tokens.get("output").and_then(|v| v.as_u64()).unwrap_or(0);
|
||||
let cached_tokens = tokens.get("cached").and_then(|v| v.as_u64()).unwrap_or(0);
|
||||
Some(TurnUsage {
|
||||
input_tokens,
|
||||
output_tokens,
|
||||
cache_creation_input_tokens: 0,
|
||||
cache_read_input_tokens: cached_tokens,
|
||||
})
|
||||
}
|
||||
|
||||
fn parse_conversation_detail(
|
||||
&self,
|
||||
path: &Path,
|
||||
value: &serde_json::Value,
|
||||
conversation_id: &str,
|
||||
) -> Result<ConversationDetail, ParseError> {
|
||||
let mut summary = self
|
||||
.parse_summary_from_value(path, value)
|
||||
.ok_or_else(|| ParseError::ConversationNotFound(conversation_id.to_string()))?;
|
||||
let messages_raw = value
|
||||
.get("messages")
|
||||
.and_then(|m| m.as_array())
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
|
||||
let mut messages: Vec<UnifiedMessage> = Vec::new();
|
||||
for raw in messages_raw {
|
||||
let msg_id = raw
|
||||
.get("id")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string())
|
||||
.unwrap_or_else(|| format!("msg-{}", messages.len()));
|
||||
let timestamp =
|
||||
Self::parse_timestamp(raw.get("timestamp")).unwrap_or(summary.started_at);
|
||||
let msg_type = raw
|
||||
.get("type")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.to_ascii_lowercase();
|
||||
|
||||
match msg_type.as_str() {
|
||||
"user" => {
|
||||
let Some(text) = Self::extract_message_text(&raw) else {
|
||||
continue;
|
||||
};
|
||||
messages.push(UnifiedMessage {
|
||||
id: msg_id,
|
||||
role: MessageRole::User,
|
||||
content: vec![ContentBlock::Text { text }],
|
||||
timestamp,
|
||||
usage: None,
|
||||
duration_ms: None,
|
||||
model: None,
|
||||
});
|
||||
}
|
||||
"gemini" | "assistant" | "model" => {
|
||||
let blocks = Self::parse_assistant_blocks(&raw);
|
||||
if blocks.is_empty() {
|
||||
continue;
|
||||
}
|
||||
messages.push(UnifiedMessage {
|
||||
id: msg_id,
|
||||
role: MessageRole::Assistant,
|
||||
content: blocks,
|
||||
timestamp,
|
||||
usage: Self::parse_usage(&raw),
|
||||
duration_ms: None,
|
||||
model: raw
|
||||
.get("model")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string()),
|
||||
});
|
||||
}
|
||||
"system" => {
|
||||
let Some(text) = Self::extract_message_text(&raw) else {
|
||||
continue;
|
||||
};
|
||||
messages.push(UnifiedMessage {
|
||||
id: msg_id,
|
||||
role: MessageRole::System,
|
||||
content: vec![ContentBlock::Text { text }],
|
||||
timestamp,
|
||||
usage: None,
|
||||
duration_ms: None,
|
||||
model: None,
|
||||
});
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
// Approximate duration for assistant messages from adjacent timestamps
|
||||
for i in 0..messages.len() {
|
||||
if matches!(messages[i].role, MessageRole::Assistant)
|
||||
&& messages[i].duration_ms.is_none()
|
||||
{
|
||||
if let Some(next) = messages.get(i + 1) {
|
||||
let dur = (next.timestamp - messages[i].timestamp).num_milliseconds();
|
||||
if dur > 0 && dur < 300_000 {
|
||||
messages[i].duration_ms = Some(dur as u64);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let turns = group_into_turns(messages);
|
||||
summary.message_count = turns.len() as u32;
|
||||
summary.id = conversation_id.to_string();
|
||||
let context_window_used_tokens = super::latest_turn_total_usage_tokens(&turns);
|
||||
let context_window_max_tokens =
|
||||
super::infer_context_window_max_tokens(summary.model.as_deref());
|
||||
let session_stats = super::merge_context_window_stats(
|
||||
super::compute_session_stats(&turns),
|
||||
context_window_used_tokens,
|
||||
context_window_max_tokens,
|
||||
);
|
||||
|
||||
Ok(ConversationDetail {
|
||||
summary,
|
||||
turns,
|
||||
session_stats,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_gemini_base_dir() -> PathBuf {
|
||||
resolve_gemini_base_dir_from(
|
||||
std::env::var_os("GEMINI_CLI_HOME"),
|
||||
dirs::home_dir(),
|
||||
)
|
||||
}
|
||||
|
||||
fn resolve_gemini_base_dir_from(
|
||||
gemini_cli_home_env: Option<std::ffi::OsString>,
|
||||
home_dir: Option<PathBuf>,
|
||||
) -> PathBuf {
|
||||
gemini_cli_home_env
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(|| home_dir.unwrap_or_default())
|
||||
.join(".gemini")
|
||||
}
|
||||
|
||||
impl AgentParser for GeminiParser {
|
||||
fn list_conversations(&self) -> Result<Vec<ConversationSummary>, ParseError> {
|
||||
let mut conversations = Vec::new();
|
||||
|
||||
for chat_file in self.list_chat_files() {
|
||||
let raw = match fs::read_to_string(&chat_file) {
|
||||
Ok(raw) => raw,
|
||||
Err(_) => continue,
|
||||
};
|
||||
let value: serde_json::Value = match serde_json::from_str(&raw) {
|
||||
Ok(value) => value,
|
||||
Err(_) => continue,
|
||||
};
|
||||
if let Some(summary) = self.parse_summary_from_value(&chat_file, &value) {
|
||||
conversations.push(summary);
|
||||
}
|
||||
}
|
||||
|
||||
conversations.sort_by(|a, b| b.started_at.cmp(&a.started_at));
|
||||
Ok(conversations)
|
||||
}
|
||||
|
||||
fn get_conversation(&self, conversation_id: &str) -> Result<ConversationDetail, ParseError> {
|
||||
for chat_file in self.list_chat_files() {
|
||||
let raw = match fs::read_to_string(&chat_file) {
|
||||
Ok(raw) => raw,
|
||||
Err(_) => continue,
|
||||
};
|
||||
if !raw.contains(conversation_id) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let value: serde_json::Value = match serde_json::from_str(&raw) {
|
||||
Ok(value) => value,
|
||||
Err(_) => continue,
|
||||
};
|
||||
let session_id = value.get("sessionId").and_then(|v| v.as_str());
|
||||
if session_id != Some(conversation_id) {
|
||||
continue;
|
||||
}
|
||||
|
||||
return self.parse_conversation_detail(&chat_file, &value, conversation_id);
|
||||
}
|
||||
|
||||
Err(ParseError::ConversationNotFound(
|
||||
conversation_id.to_string(),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
fn group_into_turns(messages: Vec<UnifiedMessage>) -> Vec<MessageTurn> {
|
||||
let mut turns = Vec::new();
|
||||
let mut i = 0;
|
||||
|
||||
while i < messages.len() {
|
||||
let msg = &messages[i];
|
||||
|
||||
if matches!(msg.role, MessageRole::User) {
|
||||
turns.push(MessageTurn {
|
||||
id: format!("turn-{}", turns.len()),
|
||||
role: TurnRole::User,
|
||||
blocks: msg.content.clone(),
|
||||
timestamp: msg.timestamp,
|
||||
usage: None,
|
||||
duration_ms: None,
|
||||
model: None,
|
||||
});
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if matches!(msg.role, MessageRole::System) {
|
||||
turns.push(MessageTurn {
|
||||
id: format!("turn-{}", turns.len()),
|
||||
role: TurnRole::System,
|
||||
blocks: msg.content.clone(),
|
||||
timestamp: msg.timestamp,
|
||||
usage: None,
|
||||
duration_ms: None,
|
||||
model: None,
|
||||
});
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
let mut blocks = msg.content.clone();
|
||||
let mut usage = msg.usage.clone();
|
||||
let mut duration_ms = msg.duration_ms;
|
||||
let mut models: Vec<String> = msg.model.iter().cloned().collect();
|
||||
let timestamp = msg.timestamp;
|
||||
i += 1;
|
||||
|
||||
while i < messages.len()
|
||||
&& (matches!(messages[i].role, MessageRole::Assistant)
|
||||
|| matches!(messages[i].role, MessageRole::Tool))
|
||||
{
|
||||
blocks.extend(messages[i].content.clone());
|
||||
if usage.is_none() {
|
||||
usage = messages[i].usage.clone();
|
||||
}
|
||||
if duration_ms.is_none() {
|
||||
duration_ms = messages[i].duration_ms;
|
||||
}
|
||||
if let Some(model) = &messages[i].model {
|
||||
models.push(model.clone());
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
|
||||
let model = models.pop();
|
||||
|
||||
turns.push(MessageTurn {
|
||||
id: format!("turn-{}", turns.len()),
|
||||
role: TurnRole::Assistant,
|
||||
blocks,
|
||||
timestamp,
|
||||
usage,
|
||||
duration_ms,
|
||||
model,
|
||||
});
|
||||
}
|
||||
|
||||
turns
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::GeminiParser;
|
||||
use super::resolve_gemini_base_dir_from;
|
||||
use crate::parsers::AgentParser;
|
||||
use std::env;
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
#[test]
|
||||
fn parses_gemini_session_detail_from_chat_json() {
|
||||
let nanos = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.expect("system time ok")
|
||||
.as_nanos();
|
||||
let base: PathBuf = env::temp_dir().join(format!("codeg-gemini-test-{nanos}"));
|
||||
let chats_dir = base.join("tmp").join("codeg").join("chats");
|
||||
fs::create_dir_all(&chats_dir).expect("create chat dir");
|
||||
fs::write(
|
||||
base.join("tmp").join("codeg").join(".project_root"),
|
||||
"/Users/test/workspace/demo",
|
||||
)
|
||||
.expect("write project root");
|
||||
|
||||
let file_path = chats_dir.join("session-2026-03-02T04-30-32c7d221.json");
|
||||
let content = r#"{
|
||||
"sessionId": "32c7d221-0553-46c8-ba50-e664719cae7f",
|
||||
"projectHash": "abc",
|
||||
"startTime": "2026-03-02T04:30:20.796Z",
|
||||
"lastUpdated": "2026-03-02T04:33:13.631Z",
|
||||
"messages": [
|
||||
{
|
||||
"id": "u1",
|
||||
"timestamp": "2026-03-02T04:30:20.796Z",
|
||||
"type": "user",
|
||||
"content": [{"text": "你会做什么"}]
|
||||
},
|
||||
{
|
||||
"id": "a1",
|
||||
"timestamp": "2026-03-02T04:33:13.631Z",
|
||||
"type": "gemini",
|
||||
"content": "我是一个助手",
|
||||
"toolCalls": [
|
||||
{
|
||||
"id": "cli_help-1",
|
||||
"name": "cli_help",
|
||||
"args": {"question": "你会做什么"},
|
||||
"resultDisplay": "ok",
|
||||
"status": "success"
|
||||
}
|
||||
],
|
||||
"tokens": {"input": 12, "output": 34, "cached": 5},
|
||||
"model": "gemini-3.1-pro-preview"
|
||||
}
|
||||
]
|
||||
}"#;
|
||||
fs::write(&file_path, content).expect("write chat file");
|
||||
|
||||
let parser = GeminiParser::with_base_dir(base.clone());
|
||||
let summaries = parser.list_conversations().expect("list conversations");
|
||||
assert_eq!(summaries.len(), 1);
|
||||
assert_eq!(
|
||||
summaries[0].id,
|
||||
"32c7d221-0553-46c8-ba50-e664719cae7f".to_string()
|
||||
);
|
||||
|
||||
let detail = parser
|
||||
.get_conversation("32c7d221-0553-46c8-ba50-e664719cae7f")
|
||||
.expect("get conversation");
|
||||
assert_eq!(detail.turns.len(), 2);
|
||||
assert_eq!(
|
||||
detail.summary.folder_path.as_deref(),
|
||||
Some("/Users/test/workspace/demo")
|
||||
);
|
||||
assert!(detail.session_stats.is_some());
|
||||
let stats = detail.session_stats.expect("session stats");
|
||||
assert_eq!(stats.context_window_used_tokens, Some(51));
|
||||
assert_eq!(stats.context_window_max_tokens, Some(1_000_000));
|
||||
let percent = stats
|
||||
.context_window_usage_percent
|
||||
.expect("context window percent");
|
||||
assert!((percent - 0.0051).abs() < 1e-9);
|
||||
|
||||
let _ = fs::remove_dir_all(base);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gemini_cli_home_env_overrides_user_home() {
|
||||
let resolved = resolve_gemini_base_dir_from(
|
||||
Some(std::ffi::OsString::from("/tmp/gemini-home")),
|
||||
Some(PathBuf::from("/Users/default")),
|
||||
);
|
||||
assert_eq!(resolved, PathBuf::from("/tmp/gemini-home/.gemini"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gemini_defaults_to_home_dot_gemini() {
|
||||
let resolved = resolve_gemini_base_dir_from(
|
||||
None,
|
||||
Some(PathBuf::from("/Users/default")),
|
||||
);
|
||||
assert_eq!(resolved, PathBuf::from("/Users/default/.gemini"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,356 @@
|
||||
pub mod claude;
|
||||
pub mod codex;
|
||||
pub mod gemini;
|
||||
pub mod opencode;
|
||||
|
||||
use std::sync::OnceLock;
|
||||
|
||||
use regex::Regex;
|
||||
|
||||
use crate::models::{
|
||||
ConversationDetail, ConversationSummary, MessageTurn, SessionStats, TurnUsage,
|
||||
};
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum ParseError {
|
||||
#[error("IO error: {0}")]
|
||||
Io(#[from] std::io::Error),
|
||||
#[error("JSON parse error: {0}")]
|
||||
Json(#[from] serde_json::Error),
|
||||
#[error("Database error: {0}")]
|
||||
Db(#[from] sea_orm::DbErr),
|
||||
#[error("Conversation not found: {0}")]
|
||||
ConversationNotFound(String),
|
||||
#[allow(dead_code)]
|
||||
#[error("Invalid data: {0}")]
|
||||
InvalidData(String),
|
||||
}
|
||||
|
||||
pub trait AgentParser {
|
||||
fn list_conversations(&self) -> Result<Vec<ConversationSummary>, ParseError>;
|
||||
fn get_conversation(&self, conversation_id: &str) -> Result<ConversationDetail, ParseError>;
|
||||
}
|
||||
|
||||
/// Truncate a string to `max_len` characters, appending "..." if truncated.
|
||||
pub fn truncate_str(s: &str, max_len: usize) -> String {
|
||||
if s.chars().count() <= max_len {
|
||||
s.to_string()
|
||||
} else {
|
||||
let truncated: String = s.chars().take(max_len).collect();
|
||||
format!("{}...", truncated)
|
||||
}
|
||||
}
|
||||
|
||||
/// Aggregate turn-level usage and duration into a single `SessionStats`.
|
||||
pub fn compute_session_stats(turns: &[MessageTurn]) -> Option<SessionStats> {
|
||||
let mut total_in = 0u64;
|
||||
let mut total_out = 0u64;
|
||||
let mut total_cache_create = 0u64;
|
||||
let mut total_cache_read = 0u64;
|
||||
let mut total_duration = 0u64;
|
||||
let mut has_data = false;
|
||||
|
||||
for turn in turns {
|
||||
if let Some(ref u) = turn.usage {
|
||||
total_in += u.input_tokens;
|
||||
total_out += u.output_tokens;
|
||||
total_cache_create += u.cache_creation_input_tokens;
|
||||
total_cache_read += u.cache_read_input_tokens;
|
||||
has_data = true;
|
||||
}
|
||||
if let Some(d) = turn.duration_ms {
|
||||
total_duration += d;
|
||||
}
|
||||
}
|
||||
|
||||
if !has_data {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(SessionStats {
|
||||
total_usage: Some(TurnUsage {
|
||||
input_tokens: total_in,
|
||||
output_tokens: total_out,
|
||||
cache_creation_input_tokens: total_cache_create,
|
||||
cache_read_input_tokens: total_cache_read,
|
||||
}),
|
||||
total_tokens: Some(total_in + total_out + total_cache_create + total_cache_read),
|
||||
total_duration_ms: total_duration,
|
||||
context_window_used_tokens: None,
|
||||
context_window_max_tokens: None,
|
||||
context_window_usage_percent: None,
|
||||
})
|
||||
}
|
||||
|
||||
fn model_capacity_suffix_regex() -> &'static Regex {
|
||||
static RE: OnceLock<Regex> = OnceLock::new();
|
||||
RE.get_or_init(|| {
|
||||
Regex::new(r"(?i)\[\s*([0-9]+(?:\.[0-9]+)?)\s*([km])\s*\]\s*$")
|
||||
.expect("valid model capacity regex")
|
||||
})
|
||||
}
|
||||
|
||||
fn parse_model_capacity_suffix(model: &str) -> Option<u64> {
|
||||
let captures = model_capacity_suffix_regex().captures(model.trim())?;
|
||||
let value = captures.get(1)?.as_str().parse::<f64>().ok()?;
|
||||
if !value.is_finite() || value <= 0.0 {
|
||||
return None;
|
||||
}
|
||||
|
||||
let unit = captures
|
||||
.get(2)
|
||||
.map(|m| m.as_str().to_ascii_lowercase())
|
||||
.unwrap_or_default();
|
||||
let multiplier = match unit.as_str() {
|
||||
"m" => 1_000_000.0,
|
||||
"k" => 1_000.0,
|
||||
_ => return None,
|
||||
};
|
||||
|
||||
Some((value * multiplier) as u64)
|
||||
}
|
||||
|
||||
pub fn infer_context_window_max_tokens(model: Option<&str>) -> Option<u64> {
|
||||
let raw = model?.trim();
|
||||
if raw.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
if let Some(suffixed_limit) = parse_model_capacity_suffix(raw) {
|
||||
return Some(suffixed_limit);
|
||||
}
|
||||
|
||||
let normalized = raw
|
||||
.rsplit('/')
|
||||
.next()
|
||||
.unwrap_or(raw)
|
||||
.split(':')
|
||||
.next()
|
||||
.unwrap_or(raw)
|
||||
.trim()
|
||||
.to_ascii_lowercase();
|
||||
|
||||
if normalized.starts_with("claude") {
|
||||
return Some(200_000);
|
||||
}
|
||||
if normalized.starts_with("gemini") {
|
||||
return Some(1_000_000);
|
||||
}
|
||||
|
||||
match normalized.as_str() {
|
||||
"gpt-5.2-codex" | "gpt-5.1-codex-max" | "gpt-5.1-codex-mini" | "gpt-5.2" => Some(258_000),
|
||||
"gpt-5.1" | "gpt-5.1-codex" | "gpt-4o" | "gpt-4o-mini" | "gpt-4-turbo" | "o1-mini"
|
||||
| "o1-preview" => Some(128_000),
|
||||
"gpt-4" => Some(8_192),
|
||||
"o3" | "o3-mini" | "o1" => Some(200_000),
|
||||
_ => {
|
||||
if normalized.starts_with("gpt-5") {
|
||||
Some(258_000)
|
||||
} else if normalized.starts_with("gpt-4o")
|
||||
|| normalized.starts_with("gpt-4.1")
|
||||
|| normalized.starts_with("gpt-4-turbo")
|
||||
{
|
||||
Some(128_000)
|
||||
} else if normalized.starts_with("o3") || normalized == "o1" {
|
||||
Some(200_000)
|
||||
} else if normalized.starts_with("o1-mini") || normalized.starts_with("o1-preview") {
|
||||
Some(128_000)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn latest_turn_total_usage_tokens(turns: &[MessageTurn]) -> Option<u64> {
|
||||
turns.iter().rev().find_map(|turn| {
|
||||
turn.usage.as_ref().map(|usage| {
|
||||
usage
|
||||
.input_tokens
|
||||
.saturating_add(usage.output_tokens)
|
||||
.saturating_add(usage.cache_creation_input_tokens)
|
||||
.saturating_add(usage.cache_read_input_tokens)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
pub fn merge_context_window_stats(
|
||||
stats: Option<SessionStats>,
|
||||
used_tokens: Option<u64>,
|
||||
max_tokens: Option<u64>,
|
||||
) -> Option<SessionStats> {
|
||||
if used_tokens.is_none() && max_tokens.is_none() {
|
||||
return stats;
|
||||
}
|
||||
|
||||
let usage_percent = match (used_tokens, max_tokens) {
|
||||
(Some(used), Some(max)) if max > 0 => Some((used as f64 / max as f64) * 100.0),
|
||||
_ => None,
|
||||
};
|
||||
|
||||
match stats {
|
||||
Some(mut s) => {
|
||||
s.context_window_used_tokens = used_tokens;
|
||||
s.context_window_max_tokens = max_tokens;
|
||||
s.context_window_usage_percent = usage_percent;
|
||||
Some(s)
|
||||
}
|
||||
None => Some(SessionStats {
|
||||
total_usage: None,
|
||||
total_tokens: None,
|
||||
total_duration_ms: 0,
|
||||
context_window_used_tokens: used_tokens,
|
||||
context_window_max_tokens: max_tokens,
|
||||
context_window_usage_percent: usage_percent,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
/// Extract the last path component as the folder name.
|
||||
pub fn folder_name_from_path(path: &str) -> String {
|
||||
path.rsplit(['/', '\\']).next().unwrap_or(path).to_string()
|
||||
}
|
||||
|
||||
/// Normalize a filesystem path string for tolerant cross-platform comparison.
|
||||
/// This intentionally does not hit the filesystem (no canonicalize), and only
|
||||
/// normalizes separators/casing differences that commonly break exact matching.
|
||||
pub fn normalize_path_for_matching(path: &str) -> String {
|
||||
let mut normalized = path.trim().replace('\\', "/");
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
if let Some(stripped) = normalized.strip_prefix("//?/") {
|
||||
normalized = stripped.to_string();
|
||||
}
|
||||
normalized = normalized.to_ascii_lowercase();
|
||||
}
|
||||
|
||||
while normalized.ends_with('/') {
|
||||
if normalized == "/" {
|
||||
break;
|
||||
}
|
||||
// Keep Windows drive root such as "c:/" intact.
|
||||
if normalized.len() == 3
|
||||
&& normalized.as_bytes().get(1) == Some(&b':')
|
||||
&& normalized.as_bytes().get(2) == Some(&b'/')
|
||||
{
|
||||
break;
|
||||
}
|
||||
normalized.pop();
|
||||
}
|
||||
|
||||
normalized
|
||||
}
|
||||
|
||||
pub fn path_eq_for_matching(left: &str, right: &str) -> bool {
|
||||
normalize_path_for_matching(left) == normalize_path_for_matching(right)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use chrono::Utc;
|
||||
|
||||
use super::{
|
||||
infer_context_window_max_tokens, latest_turn_total_usage_tokens, merge_context_window_stats,
|
||||
path_eq_for_matching,
|
||||
};
|
||||
use crate::models::{MessageTurn, SessionStats, TurnRole, TurnUsage};
|
||||
|
||||
#[test]
|
||||
fn infers_model_context_limits() {
|
||||
assert_eq!(
|
||||
infer_context_window_max_tokens(Some("claude-sonnet-4-6")),
|
||||
Some(200_000)
|
||||
);
|
||||
assert_eq!(
|
||||
infer_context_window_max_tokens(Some("gemini-2.5-pro")),
|
||||
Some(1_000_000)
|
||||
);
|
||||
assert_eq!(
|
||||
infer_context_window_max_tokens(Some("claude-sonnet-4-6 [1.5M]")),
|
||||
Some(1_500_000)
|
||||
);
|
||||
assert_eq!(infer_context_window_max_tokens(Some("unknown-model")), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn picks_latest_turn_usage_total_tokens() {
|
||||
let timestamp = Utc::now();
|
||||
let turns = vec![
|
||||
MessageTurn {
|
||||
id: "turn-0".to_string(),
|
||||
role: TurnRole::Assistant,
|
||||
blocks: vec![],
|
||||
timestamp,
|
||||
usage: Some(TurnUsage {
|
||||
input_tokens: 10,
|
||||
output_tokens: 20,
|
||||
cache_creation_input_tokens: 30,
|
||||
cache_read_input_tokens: 40,
|
||||
}),
|
||||
duration_ms: None,
|
||||
model: None,
|
||||
},
|
||||
MessageTurn {
|
||||
id: "turn-1".to_string(),
|
||||
role: TurnRole::Assistant,
|
||||
blocks: vec![],
|
||||
timestamp,
|
||||
usage: Some(TurnUsage {
|
||||
input_tokens: 11,
|
||||
output_tokens: 21,
|
||||
cache_creation_input_tokens: 31,
|
||||
cache_read_input_tokens: 41,
|
||||
}),
|
||||
duration_ms: None,
|
||||
model: None,
|
||||
},
|
||||
];
|
||||
|
||||
assert_eq!(latest_turn_total_usage_tokens(&turns), Some(104));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merges_context_window_stats() {
|
||||
let merged = merge_context_window_stats(None, Some(1500), Some(3000))
|
||||
.expect("context stats should exist");
|
||||
assert_eq!(merged.context_window_used_tokens, Some(1500));
|
||||
assert_eq!(merged.context_window_max_tokens, Some(3000));
|
||||
assert!(merged.total_usage.is_none());
|
||||
let percent = merged
|
||||
.context_window_usage_percent
|
||||
.expect("usage percent should exist");
|
||||
assert!((percent - 50.0).abs() < f64::EPSILON);
|
||||
|
||||
let existing = Some(SessionStats {
|
||||
total_usage: Some(TurnUsage {
|
||||
input_tokens: 1,
|
||||
output_tokens: 2,
|
||||
cache_creation_input_tokens: 3,
|
||||
cache_read_input_tokens: 4,
|
||||
}),
|
||||
total_tokens: Some(10),
|
||||
total_duration_ms: 100,
|
||||
context_window_used_tokens: None,
|
||||
context_window_max_tokens: None,
|
||||
context_window_usage_percent: None,
|
||||
});
|
||||
let merged_existing =
|
||||
merge_context_window_stats(existing, Some(200), Some(1000)).expect("merged");
|
||||
assert_eq!(merged_existing.total_tokens, Some(10));
|
||||
assert_eq!(merged_existing.context_window_used_tokens, Some(200));
|
||||
assert_eq!(merged_existing.context_window_max_tokens, Some(1000));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn path_matching_handles_separator_differences() {
|
||||
assert!(path_eq_for_matching(
|
||||
"/Users/demo/workspace/codeg",
|
||||
"/Users/demo/workspace/codeg/"
|
||||
));
|
||||
assert!(path_eq_for_matching(
|
||||
"C:\\Users\\demo\\workspace\\codeg",
|
||||
"C:/Users/demo/workspace/codeg"
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,646 @@
|
||||
use std::future::Future;
|
||||
use std::path::PathBuf;
|
||||
use std::time::Duration;
|
||||
|
||||
use chrono::{DateTime, TimeZone, Utc};
|
||||
use sea_orm::{
|
||||
ConnectOptions, ConnectionTrait, Database, DatabaseConnection, DbBackend, QueryResult,
|
||||
Statement,
|
||||
};
|
||||
|
||||
use crate::models::*;
|
||||
use crate::parsers::{folder_name_from_path, AgentParser, ParseError};
|
||||
|
||||
pub struct OpenCodeParser {
|
||||
base_dir: PathBuf,
|
||||
}
|
||||
|
||||
impl OpenCodeParser {
|
||||
pub fn new() -> Self {
|
||||
let base_dir = resolve_opencode_base_dir();
|
||||
Self { base_dir }
|
||||
}
|
||||
|
||||
fn sqlite_db_path(&self) -> PathBuf {
|
||||
self.base_dir.join("opencode.db")
|
||||
}
|
||||
|
||||
fn block_on<F, T>(&self, fut: F) -> Result<T, ParseError>
|
||||
where
|
||||
F: Future<Output = Result<T, ParseError>>,
|
||||
{
|
||||
let runtime = tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
.map_err(|e| ParseError::InvalidData(format!("failed to build runtime: {e}")))?;
|
||||
runtime.block_on(fut)
|
||||
}
|
||||
|
||||
async fn open_sqlite_connection(&self) -> Result<DatabaseConnection, ParseError> {
|
||||
let db_path = self.sqlite_db_path();
|
||||
let db_url = format!(
|
||||
"sqlite:{}?mode=ro",
|
||||
urlencoding::encode(&db_path.to_string_lossy())
|
||||
);
|
||||
|
||||
let mut opts = ConnectOptions::new(db_url);
|
||||
opts.max_connections(1)
|
||||
.min_connections(1)
|
||||
.connect_timeout(Duration::from_secs(5))
|
||||
.idle_timeout(Duration::from_secs(30))
|
||||
.sqlx_logging(false);
|
||||
|
||||
let conn = Database::connect(opts).await?;
|
||||
conn.execute(Statement::from_string(
|
||||
DbBackend::Sqlite,
|
||||
"PRAGMA busy_timeout=3000;".to_owned(),
|
||||
))
|
||||
.await?;
|
||||
|
||||
Ok(conn)
|
||||
}
|
||||
|
||||
fn parse_sqlite_summary_row(row: &QueryResult) -> Result<ConversationSummary, ParseError> {
|
||||
let id: String = row.try_get("", "id")?;
|
||||
let directory: Option<String> = row.try_get("", "directory")?;
|
||||
let title: Option<String> = row.try_get("", "title")?;
|
||||
let created_ms: i64 = row.try_get("", "created_ms")?;
|
||||
let updated_ms: i64 = row.try_get("", "updated_ms")?;
|
||||
let message_count_i64: i64 = row.try_get("", "message_count")?;
|
||||
let model: Option<String> = row.try_get("", "model")?;
|
||||
|
||||
let folder_path = normalize_optional_string(directory);
|
||||
let folder_name = folder_path.as_ref().map(|p| folder_name_from_path(p));
|
||||
|
||||
let message_count = if message_count_i64 <= 0 {
|
||||
0
|
||||
} else {
|
||||
u32::try_from(message_count_i64).unwrap_or(u32::MAX)
|
||||
};
|
||||
|
||||
Ok(ConversationSummary {
|
||||
id,
|
||||
agent_type: AgentType::OpenCode,
|
||||
folder_path,
|
||||
folder_name,
|
||||
title: normalize_optional_string(title),
|
||||
started_at: millis_to_datetime(created_ms),
|
||||
ended_at: (updated_ms > 0).then(|| millis_to_datetime(updated_ms)),
|
||||
message_count,
|
||||
model: normalize_optional_string(model),
|
||||
git_branch: None,
|
||||
})
|
||||
}
|
||||
|
||||
async fn list_conversations_from_sqlite(&self) -> Result<Vec<ConversationSummary>, ParseError> {
|
||||
let conn = self.open_sqlite_connection().await?;
|
||||
|
||||
let rows = conn
|
||||
.query_all(Statement::from_string(
|
||||
DbBackend::Sqlite,
|
||||
r#"
|
||||
SELECT
|
||||
s.id AS id,
|
||||
s.directory AS directory,
|
||||
s.title AS title,
|
||||
s.time_created AS created_ms,
|
||||
s.time_updated AS updated_ms,
|
||||
COALESCE((
|
||||
SELECT COUNT(*)
|
||||
FROM message m
|
||||
WHERE m.session_id = s.id
|
||||
), 0) AS message_count,
|
||||
(
|
||||
SELECT json_extract(m2.data, '$.modelID')
|
||||
FROM message m2
|
||||
WHERE m2.session_id = s.id
|
||||
AND json_extract(m2.data, '$.role') = 'assistant'
|
||||
ORDER BY m2.time_created DESC
|
||||
LIMIT 1
|
||||
) AS model
|
||||
FROM session s
|
||||
ORDER BY s.time_created DESC
|
||||
"#
|
||||
.to_string(),
|
||||
))
|
||||
.await?;
|
||||
|
||||
let mut conversations = Vec::with_capacity(rows.len());
|
||||
for row in rows {
|
||||
conversations.push(Self::parse_sqlite_summary_row(&row)?);
|
||||
}
|
||||
|
||||
Ok(conversations)
|
||||
}
|
||||
|
||||
async fn sqlite_summary_by_id(
|
||||
&self,
|
||||
conn: &DatabaseConnection,
|
||||
conversation_id: &str,
|
||||
) -> Result<Option<ConversationSummary>, ParseError> {
|
||||
let row = conn
|
||||
.query_one(Statement::from_sql_and_values(
|
||||
DbBackend::Sqlite,
|
||||
r#"
|
||||
SELECT
|
||||
s.id AS id,
|
||||
s.directory AS directory,
|
||||
s.title AS title,
|
||||
s.time_created AS created_ms,
|
||||
s.time_updated AS updated_ms,
|
||||
COALESCE((
|
||||
SELECT COUNT(*)
|
||||
FROM message m
|
||||
WHERE m.session_id = s.id
|
||||
), 0) AS message_count,
|
||||
(
|
||||
SELECT json_extract(m2.data, '$.modelID')
|
||||
FROM message m2
|
||||
WHERE m2.session_id = s.id
|
||||
AND json_extract(m2.data, '$.role') = 'assistant'
|
||||
ORDER BY m2.time_created DESC
|
||||
LIMIT 1
|
||||
) AS model
|
||||
FROM session s
|
||||
WHERE s.id = ?
|
||||
LIMIT 1
|
||||
"#,
|
||||
[conversation_id.into()],
|
||||
))
|
||||
.await?;
|
||||
|
||||
row.map(|r| Self::parse_sqlite_summary_row(&r)).transpose()
|
||||
}
|
||||
|
||||
async fn get_conversation_from_sqlite(
|
||||
&self,
|
||||
conversation_id: &str,
|
||||
) -> Result<ConversationDetail, ParseError> {
|
||||
let conn = self.open_sqlite_connection().await?;
|
||||
let summary = self
|
||||
.sqlite_summary_by_id(&conn, conversation_id)
|
||||
.await?
|
||||
.ok_or_else(|| ParseError::ConversationNotFound(conversation_id.to_string()))?;
|
||||
|
||||
let messages = self.load_sqlite_messages(&conn, conversation_id).await?;
|
||||
let turns = group_into_turns(messages);
|
||||
let context_window_used_tokens = super::latest_turn_total_usage_tokens(&turns);
|
||||
let context_window_max_tokens =
|
||||
super::infer_context_window_max_tokens(summary.model.as_deref());
|
||||
let session_stats = super::merge_context_window_stats(
|
||||
super::compute_session_stats(&turns),
|
||||
context_window_used_tokens,
|
||||
context_window_max_tokens,
|
||||
);
|
||||
|
||||
Ok(ConversationDetail {
|
||||
summary,
|
||||
turns,
|
||||
session_stats,
|
||||
})
|
||||
}
|
||||
|
||||
async fn load_sqlite_messages(
|
||||
&self,
|
||||
conn: &DatabaseConnection,
|
||||
conversation_id: &str,
|
||||
) -> Result<Vec<UnifiedMessage>, ParseError> {
|
||||
let rows = conn
|
||||
.query_all(Statement::from_sql_and_values(
|
||||
DbBackend::Sqlite,
|
||||
r#"
|
||||
SELECT id, time_created, data
|
||||
FROM message
|
||||
WHERE session_id = ?
|
||||
ORDER BY time_created ASC, id ASC
|
||||
"#,
|
||||
[conversation_id.into()],
|
||||
))
|
||||
.await?;
|
||||
|
||||
let mut messages = Vec::with_capacity(rows.len());
|
||||
|
||||
for row in rows {
|
||||
let msg_id: String = row.try_get("", "id")?;
|
||||
let row_time_created: i64 = row.try_get("", "time_created")?;
|
||||
let data_raw: String = row.try_get("", "data")?;
|
||||
|
||||
let value: serde_json::Value = match serde_json::from_str(&data_raw) {
|
||||
Ok(v) => v,
|
||||
Err(_) => continue,
|
||||
};
|
||||
|
||||
let role = match value.get("role").and_then(|r| r.as_str()) {
|
||||
Some("user") => MessageRole::User,
|
||||
Some("assistant") => MessageRole::Assistant,
|
||||
Some("system") => MessageRole::System,
|
||||
Some("tool") => MessageRole::Tool,
|
||||
_ => continue,
|
||||
};
|
||||
|
||||
let created_ms = value
|
||||
.get("time")
|
||||
.and_then(|t| t.get("created"))
|
||||
.and_then(|c| c.as_i64())
|
||||
.unwrap_or(row_time_created);
|
||||
let timestamp = millis_to_datetime(created_ms);
|
||||
|
||||
let is_assistant = matches!(role, MessageRole::Assistant);
|
||||
let msg_model = if is_assistant {
|
||||
value
|
||||
.get("modelID")
|
||||
.and_then(|m| m.as_str())
|
||||
.map(|s| s.to_string())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let (content_blocks, usage_from_step_finish) =
|
||||
self.load_sqlite_parts(conn, &msg_id).await?;
|
||||
|
||||
let usage = if is_assistant {
|
||||
extract_opencode_usage(&value).or(usage_from_step_finish)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let duration_ms = if is_assistant {
|
||||
let completed_ms = value
|
||||
.get("time")
|
||||
.and_then(|t| t.get("completed"))
|
||||
.and_then(|c| c.as_i64());
|
||||
match completed_ms {
|
||||
Some(done) if done > created_ms => Some((done - created_ms) as u64),
|
||||
_ => None,
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
messages.push(UnifiedMessage {
|
||||
id: msg_id,
|
||||
role,
|
||||
content: content_blocks,
|
||||
timestamp,
|
||||
usage,
|
||||
duration_ms,
|
||||
model: msg_model,
|
||||
});
|
||||
}
|
||||
|
||||
Ok(messages)
|
||||
}
|
||||
|
||||
async fn load_sqlite_parts(
|
||||
&self,
|
||||
conn: &DatabaseConnection,
|
||||
message_id: &str,
|
||||
) -> Result<(Vec<ContentBlock>, Option<TurnUsage>), ParseError> {
|
||||
let rows = conn
|
||||
.query_all(Statement::from_sql_and_values(
|
||||
DbBackend::Sqlite,
|
||||
r#"
|
||||
SELECT data
|
||||
FROM part
|
||||
WHERE message_id = ?
|
||||
ORDER BY time_created ASC, id ASC
|
||||
"#,
|
||||
[message_id.into()],
|
||||
))
|
||||
.await?;
|
||||
|
||||
let mut blocks = Vec::new();
|
||||
let mut usage_from_step_finish: Option<TurnUsage> = None;
|
||||
|
||||
for row in rows {
|
||||
let data_raw: String = row.try_get("", "data")?;
|
||||
let value: serde_json::Value = match serde_json::from_str(&data_raw) {
|
||||
Ok(v) => v,
|
||||
Err(_) => continue,
|
||||
};
|
||||
|
||||
let part_type = value.get("type").and_then(|t| t.as_str()).unwrap_or("");
|
||||
|
||||
match part_type {
|
||||
"text" => {
|
||||
if let Some(text) = value
|
||||
.get("text")
|
||||
.and_then(|t| t.as_str())
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty())
|
||||
{
|
||||
blocks.push(ContentBlock::Text {
|
||||
text: text.to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
"reasoning" => {
|
||||
if let Some(text) = value
|
||||
.get("text")
|
||||
.and_then(|t| t.as_str())
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty())
|
||||
{
|
||||
blocks.push(ContentBlock::Thinking {
|
||||
text: text.to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
"tool" => {
|
||||
let tool_name = value
|
||||
.get("tool")
|
||||
.and_then(|t| t.as_str())
|
||||
.unwrap_or("unknown")
|
||||
.to_string();
|
||||
|
||||
let call_id = value
|
||||
.get("callID")
|
||||
.and_then(|c| c.as_str())
|
||||
.map(|s| s.to_string());
|
||||
|
||||
let status = value
|
||||
.get("state")
|
||||
.and_then(|s| s.get("status"))
|
||||
.and_then(|s| s.as_str())
|
||||
.unwrap_or("");
|
||||
|
||||
let input_preview = value
|
||||
.get("state")
|
||||
.and_then(|s| s.get("input"))
|
||||
.and_then(|v| value_to_preview(Some(v)));
|
||||
|
||||
blocks.push(ContentBlock::ToolUse {
|
||||
tool_use_id: call_id.clone(),
|
||||
tool_name,
|
||||
input_preview,
|
||||
});
|
||||
|
||||
let output_preview = value
|
||||
.get("state")
|
||||
.and_then(|s| s.get("output"))
|
||||
.and_then(|v| value_to_preview(Some(v)));
|
||||
|
||||
let has_error_field = value.get("state").and_then(|s| s.get("error")).is_some();
|
||||
|
||||
blocks.push(ContentBlock::ToolResult {
|
||||
tool_use_id: call_id,
|
||||
output_preview,
|
||||
is_error: is_error_status(status) || has_error_field,
|
||||
});
|
||||
}
|
||||
"file" => {
|
||||
if let Some(file_ref) = extract_file_reference(&value) {
|
||||
blocks.push(ContentBlock::Text {
|
||||
text: format!("@{}", file_ref),
|
||||
});
|
||||
}
|
||||
}
|
||||
"patch" => {
|
||||
let files = value
|
||||
.get("files")
|
||||
.and_then(|v| v.as_array())
|
||||
.map(|arr| {
|
||||
arr.iter()
|
||||
.filter_map(|item| item.as_str())
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
if !files.is_empty() {
|
||||
blocks.push(ContentBlock::Text {
|
||||
text: format!("Applied patch: {}", files.join(", ")),
|
||||
});
|
||||
}
|
||||
}
|
||||
"step-finish" => {
|
||||
if usage_from_step_finish.is_none() {
|
||||
usage_from_step_finish = value
|
||||
.get("tokens")
|
||||
.and_then(extract_opencode_usage_from_tokens);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
Ok((blocks, usage_from_step_finish))
|
||||
}
|
||||
}
|
||||
|
||||
impl AgentParser for OpenCodeParser {
|
||||
fn list_conversations(&self) -> Result<Vec<ConversationSummary>, ParseError> {
|
||||
if !self.sqlite_db_path().exists() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
self.block_on(self.list_conversations_from_sqlite())
|
||||
}
|
||||
|
||||
fn get_conversation(&self, conversation_id: &str) -> Result<ConversationDetail, ParseError> {
|
||||
if !self.sqlite_db_path().exists() {
|
||||
return Err(ParseError::ConversationNotFound(
|
||||
conversation_id.to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
self.block_on(self.get_conversation_from_sqlite(conversation_id))
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_opencode_base_dir() -> PathBuf {
|
||||
resolve_xdg_data_home(std::env::var_os("XDG_DATA_HOME"), dirs::home_dir())
|
||||
.map(|xdg_data_home| xdg_data_home.join("opencode"))
|
||||
.unwrap_or_else(|| PathBuf::from("opencode"))
|
||||
}
|
||||
|
||||
fn resolve_xdg_data_home(
|
||||
xdg_data_home_env: Option<std::ffi::OsString>,
|
||||
home_dir: Option<PathBuf>,
|
||||
) -> Option<PathBuf> {
|
||||
xdg_data_home_env
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(PathBuf::from)
|
||||
.or_else(|| home_dir.map(|home| home.join(".local").join("share")))
|
||||
}
|
||||
|
||||
fn normalize_optional_string(value: Option<String>) -> Option<String> {
|
||||
value.and_then(|s| {
|
||||
let trimmed = s.trim();
|
||||
if trimmed.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(trimmed.to_string())
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn value_to_preview(value: Option<&serde_json::Value>) -> Option<String> {
|
||||
let v = value?;
|
||||
if v.is_null() {
|
||||
return None;
|
||||
}
|
||||
|
||||
if let Some(s) = v.as_str() {
|
||||
let trimmed = s.trim();
|
||||
if trimmed.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(trimmed.to_string())
|
||||
}
|
||||
} else {
|
||||
serde_json::to_string(v).ok()
|
||||
}
|
||||
}
|
||||
|
||||
fn extract_file_reference(value: &serde_json::Value) -> Option<String> {
|
||||
value
|
||||
.get("source")
|
||||
.and_then(|s| s.get("path"))
|
||||
.and_then(|v| v.as_str())
|
||||
.or_else(|| value.get("filename").and_then(|v| v.as_str()))
|
||||
.map(|s| s.trim())
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(|s| s.to_string())
|
||||
}
|
||||
|
||||
fn is_error_status(status: &str) -> bool {
|
||||
matches!(
|
||||
status.to_ascii_lowercase().as_str(),
|
||||
"error" | "failed" | "failure" | "cancelled" | "canceled"
|
||||
)
|
||||
}
|
||||
|
||||
fn extract_opencode_usage(value: &serde_json::Value) -> Option<TurnUsage> {
|
||||
value
|
||||
.get("tokens")
|
||||
.and_then(extract_opencode_usage_from_tokens)
|
||||
}
|
||||
|
||||
fn extract_opencode_usage_from_tokens(tokens: &serde_json::Value) -> Option<TurnUsage> {
|
||||
let input = tokens.get("input").and_then(|v| v.as_u64()).unwrap_or(0);
|
||||
let output = tokens.get("output").and_then(|v| v.as_u64()).unwrap_or(0);
|
||||
let cache = tokens.get("cache");
|
||||
let cache_write = cache
|
||||
.and_then(|c| c.get("write"))
|
||||
.and_then(|v| v.as_u64())
|
||||
.unwrap_or(0);
|
||||
let cache_read = cache
|
||||
.and_then(|c| c.get("read"))
|
||||
.and_then(|v| v.as_u64())
|
||||
.unwrap_or(0);
|
||||
|
||||
if input == 0 && output == 0 && cache_write == 0 && cache_read == 0 {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(TurnUsage {
|
||||
input_tokens: input,
|
||||
output_tokens: output,
|
||||
cache_creation_input_tokens: cache_write,
|
||||
cache_read_input_tokens: cache_read,
|
||||
})
|
||||
}
|
||||
|
||||
fn millis_to_datetime(ms: i64) -> DateTime<Utc> {
|
||||
let secs = ms / 1000;
|
||||
let nsecs = ((ms.rem_euclid(1000)) * 1_000_000) as u32;
|
||||
Utc.timestamp_opt(secs, nsecs)
|
||||
.single()
|
||||
.unwrap_or_else(Utc::now)
|
||||
}
|
||||
|
||||
/// Group flat messages into conversation turns (same strategy as Codex).
|
||||
fn group_into_turns(messages: Vec<UnifiedMessage>) -> Vec<MessageTurn> {
|
||||
let mut turns = Vec::new();
|
||||
let mut i = 0;
|
||||
|
||||
while i < messages.len() {
|
||||
let msg = &messages[i];
|
||||
|
||||
if matches!(msg.role, MessageRole::User) {
|
||||
turns.push(MessageTurn {
|
||||
id: format!("turn-{}", turns.len()),
|
||||
role: TurnRole::User,
|
||||
blocks: msg.content.clone(),
|
||||
timestamp: msg.timestamp,
|
||||
usage: None,
|
||||
duration_ms: None,
|
||||
model: None,
|
||||
});
|
||||
i += 1;
|
||||
} else if matches!(msg.role, MessageRole::System) {
|
||||
turns.push(MessageTurn {
|
||||
id: format!("turn-{}", turns.len()),
|
||||
role: TurnRole::System,
|
||||
blocks: msg.content.clone(),
|
||||
timestamp: msg.timestamp,
|
||||
usage: None,
|
||||
duration_ms: None,
|
||||
model: None,
|
||||
});
|
||||
i += 1;
|
||||
} else {
|
||||
let mut blocks: Vec<ContentBlock> = msg.content.clone();
|
||||
let mut usage = msg.usage.clone();
|
||||
let mut duration_ms = msg.duration_ms;
|
||||
let mut turn_model = msg.model.clone();
|
||||
let timestamp = msg.timestamp;
|
||||
i += 1;
|
||||
|
||||
while i < messages.len()
|
||||
&& (matches!(messages[i].role, MessageRole::Assistant)
|
||||
|| matches!(messages[i].role, MessageRole::Tool))
|
||||
{
|
||||
blocks.extend(messages[i].content.clone());
|
||||
if usage.is_none() {
|
||||
usage = messages[i].usage.clone();
|
||||
}
|
||||
if duration_ms.is_none() {
|
||||
duration_ms = messages[i].duration_ms;
|
||||
}
|
||||
if turn_model.is_none() {
|
||||
turn_model = messages[i].model.clone();
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
|
||||
turns.push(MessageTurn {
|
||||
id: format!("turn-{}", turns.len()),
|
||||
role: TurnRole::Assistant,
|
||||
blocks,
|
||||
timestamp,
|
||||
usage,
|
||||
duration_ms,
|
||||
model: turn_model,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
turns
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::resolve_xdg_data_home;
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[test]
|
||||
fn xdg_data_home_env_overrides_home_fallback() {
|
||||
let resolved = resolve_xdg_data_home(
|
||||
Some(std::ffi::OsString::from("/tmp/xdg-data")),
|
||||
Some(PathBuf::from("/Users/default")),
|
||||
);
|
||||
assert_eq!(resolved, Some(PathBuf::from("/tmp/xdg-data")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn xdg_data_home_falls_back_to_home_local_share() {
|
||||
let resolved = resolve_xdg_data_home(
|
||||
None,
|
||||
Some(PathBuf::from("/Users/default")),
|
||||
);
|
||||
assert_eq!(
|
||||
resolved,
|
||||
Some(PathBuf::from("/Users/default/.local/share"))
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
use std::ffi::{OsStr, OsString};
|
||||
|
||||
#[cfg(windows)]
|
||||
use std::path::Path;
|
||||
|
||||
#[cfg(windows)]
|
||||
const CREATE_NO_WINDOW: u32 = 0x0800_0000;
|
||||
|
||||
pub fn configure_tokio_command(
|
||||
command: &mut tokio::process::Command,
|
||||
) -> &mut tokio::process::Command {
|
||||
#[cfg(windows)]
|
||||
{
|
||||
command.creation_flags(CREATE_NO_WINDOW);
|
||||
}
|
||||
|
||||
command
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn maybe_windows_cmd_shim(program: &OsStr) -> Option<OsString> {
|
||||
let path = Path::new(program);
|
||||
if path.components().count() != 1 || path.extension().is_some() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let raw = program.to_string_lossy();
|
||||
let normalized = raw.to_ascii_lowercase();
|
||||
let needs_cmd_shim = matches!(
|
||||
normalized.as_str(),
|
||||
"npm" | "npx" | "pnpm" | "pnpx" | "yarn" | "yarnpkg" | "corepack"
|
||||
);
|
||||
|
||||
if needs_cmd_shim {
|
||||
Some(OsString::from(format!("{raw}.cmd")))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
pub fn normalized_program<S>(program: S) -> OsString
|
||||
where
|
||||
S: AsRef<OsStr>,
|
||||
{
|
||||
#[cfg(windows)]
|
||||
{
|
||||
if let Some(shimmed) = maybe_windows_cmd_shim(program.as_ref()) {
|
||||
return shimmed;
|
||||
}
|
||||
}
|
||||
|
||||
program.as_ref().to_os_string()
|
||||
}
|
||||
|
||||
pub fn tokio_command<S>(program: S) -> tokio::process::Command
|
||||
where
|
||||
S: AsRef<OsStr>,
|
||||
{
|
||||
let mut command = tokio::process::Command::new(normalized_program(program));
|
||||
configure_tokio_command(&mut command);
|
||||
command
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
use serde::Serialize;
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum TerminalError {
|
||||
#[error("failed to spawn terminal: {0}")]
|
||||
SpawnFailed(String),
|
||||
#[error("terminal not found: {0}")]
|
||||
NotFound(String),
|
||||
#[error("terminal write error: {0}")]
|
||||
WriteFailed(String),
|
||||
#[error("terminal resize error: {0}")]
|
||||
ResizeFailed(String),
|
||||
}
|
||||
|
||||
impl Serialize for TerminalError {
|
||||
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
|
||||
where
|
||||
S: serde::Serializer,
|
||||
{
|
||||
serializer.serialize_str(&self.to_string())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,392 @@
|
||||
use std::collections::HashMap;
|
||||
use std::io::{Read, Write};
|
||||
#[cfg(target_os = "windows")]
|
||||
use std::path::Path;
|
||||
use std::sync::mpsc;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use portable_pty::{native_pty_system, CommandBuilder, MasterPty, PtySize};
|
||||
use tauri::Emitter;
|
||||
|
||||
use super::error::TerminalError;
|
||||
use super::types::{TerminalEvent, TerminalInfo};
|
||||
|
||||
struct TerminalInstance {
|
||||
write_tx: mpsc::Sender<Vec<u8>>,
|
||||
master: Box<dyn MasterPty + Send>,
|
||||
#[allow(dead_code)]
|
||||
child: Box<dyn portable_pty::Child + Send>,
|
||||
title: String,
|
||||
owner_window_label: String,
|
||||
}
|
||||
|
||||
pub struct TerminalManager {
|
||||
terminals: Arc<Mutex<HashMap<String, TerminalInstance>>>,
|
||||
}
|
||||
|
||||
fn resolve_shell() -> String {
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
if let Ok(shell) = std::env::var("SHELL") {
|
||||
let trimmed = shell.trim();
|
||||
if !trimmed.is_empty() {
|
||||
return trimmed.to_string();
|
||||
}
|
||||
}
|
||||
if let Ok(comspec) = std::env::var("COMSPEC") {
|
||||
let trimmed = comspec.trim();
|
||||
if !trimmed.is_empty() {
|
||||
return trimmed.to_string();
|
||||
}
|
||||
}
|
||||
"cmd.exe".to_string()
|
||||
}
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
{
|
||||
std::env::var("SHELL").unwrap_or_else(|_| "/bin/zsh".to_string())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
enum WindowsShellFlavor {
|
||||
Cmd,
|
||||
PowerShell,
|
||||
Posix,
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn detect_windows_shell_flavor(shell: &str) -> WindowsShellFlavor {
|
||||
let shell_name = Path::new(shell)
|
||||
.file_name()
|
||||
.and_then(|value| value.to_str())
|
||||
.unwrap_or(shell)
|
||||
.to_ascii_lowercase();
|
||||
|
||||
if shell_name.contains("pwsh") || shell_name.contains("powershell") {
|
||||
WindowsShellFlavor::PowerShell
|
||||
} else if shell_name.contains("bash")
|
||||
|| shell_name.contains("zsh")
|
||||
|| shell_name.contains("fish")
|
||||
|| shell_name.ends_with("sh.exe")
|
||||
{
|
||||
WindowsShellFlavor::Posix
|
||||
} else {
|
||||
WindowsShellFlavor::Cmd
|
||||
}
|
||||
}
|
||||
|
||||
fn configure_shell_command(cmd: &mut CommandBuilder, shell: &str, initial_command: Option<&str>) {
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
match detect_windows_shell_flavor(shell) {
|
||||
WindowsShellFlavor::Cmd => {
|
||||
if let Some(command) = initial_command {
|
||||
cmd.env("CODEG_CMD", command);
|
||||
cmd.args(["/D", "/S", "/C", "%CODEG_CMD%"]);
|
||||
}
|
||||
}
|
||||
WindowsShellFlavor::PowerShell => {
|
||||
if let Some(command) = initial_command {
|
||||
cmd.env("CODEG_CMD", command);
|
||||
cmd.args([
|
||||
"-NoLogo",
|
||||
"-NoProfile",
|
||||
"-Command",
|
||||
"$ErrorActionPreference = 'Stop'; Invoke-Expression $env:CODEG_CMD",
|
||||
]);
|
||||
} else {
|
||||
cmd.args(["-NoLogo", "-NoProfile"]);
|
||||
}
|
||||
}
|
||||
WindowsShellFlavor::Posix => {
|
||||
cmd.env("TERM", "xterm-256color");
|
||||
cmd.env("COLORTERM", "truecolor");
|
||||
cmd.env("TERM_PROGRAM", "codeg");
|
||||
if let Some(command) = initial_command {
|
||||
cmd.env("CODEG_CMD", command);
|
||||
cmd.args(["-l", "-i", "-c", "eval \"$CODEG_CMD\""]);
|
||||
} else {
|
||||
cmd.args(["-l", "-i"]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
{
|
||||
let _ = shell;
|
||||
// GUI app environments often miss TERM; force a sane terminal type so
|
||||
// readline/zle can redraw lines correctly (history navigation, etc.).
|
||||
cmd.env("TERM", "xterm-256color");
|
||||
cmd.env("COLORTERM", "truecolor");
|
||||
cmd.env("TERM_PROGRAM", "codeg");
|
||||
if let Some(command) = initial_command {
|
||||
// Run command and let this PTY session exit when it completes.
|
||||
cmd.env("CODEG_CMD", command);
|
||||
cmd.args(["-l", "-i", "-c", "eval \"$CODEG_CMD\""]);
|
||||
} else {
|
||||
cmd.args(["-l", "-i"]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl TerminalManager {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
terminals: Arc::new(Mutex::new(HashMap::new())),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn spawn(
|
||||
&self,
|
||||
working_dir: String,
|
||||
owner_window_label: String,
|
||||
app_handle: tauri::AppHandle,
|
||||
initial_command: Option<String>,
|
||||
) -> Result<String, TerminalError> {
|
||||
let pty_system = native_pty_system();
|
||||
|
||||
let pair = pty_system
|
||||
.openpty(PtySize {
|
||||
rows: 24,
|
||||
cols: 80,
|
||||
pixel_width: 0,
|
||||
pixel_height: 0,
|
||||
})
|
||||
.map_err(|e| TerminalError::SpawnFailed(e.to_string()))?;
|
||||
|
||||
let shell = resolve_shell();
|
||||
let mut cmd = CommandBuilder::new(&shell);
|
||||
configure_shell_command(&mut cmd, &shell, initial_command.as_deref());
|
||||
cmd.cwd(&working_dir);
|
||||
|
||||
let child = pair
|
||||
.slave
|
||||
.spawn_command(cmd)
|
||||
.map_err(|e| TerminalError::SpawnFailed(e.to_string()))?;
|
||||
|
||||
drop(pair.slave);
|
||||
|
||||
let writer = pair
|
||||
.master
|
||||
.take_writer()
|
||||
.map_err(|e| TerminalError::SpawnFailed(e.to_string()))?;
|
||||
|
||||
let reader = pair
|
||||
.master
|
||||
.try_clone_reader()
|
||||
.map_err(|e| TerminalError::SpawnFailed(e.to_string()))?;
|
||||
|
||||
let terminal_id = uuid::Uuid::new_v4().to_string();
|
||||
|
||||
let (write_tx, write_rx) = mpsc::channel::<Vec<u8>>();
|
||||
|
||||
let instance = TerminalInstance {
|
||||
write_tx,
|
||||
master: pair.master,
|
||||
child,
|
||||
title: "Terminal".to_string(),
|
||||
owner_window_label,
|
||||
};
|
||||
|
||||
self.terminals
|
||||
.lock()
|
||||
.unwrap()
|
||||
.insert(terminal_id.clone(), instance);
|
||||
|
||||
// Named writer thread
|
||||
let id_for_writer = terminal_id.clone();
|
||||
std::thread::Builder::new()
|
||||
.name(format!("pty-writer-{}", &terminal_id[..8]))
|
||||
.spawn(move || {
|
||||
write_loop(writer, write_rx);
|
||||
})
|
||||
.map_err(|e| TerminalError::SpawnFailed(e.to_string()))?;
|
||||
|
||||
// Named reader thread — emits per-terminal events
|
||||
let id_for_reader = terminal_id.clone();
|
||||
let terminals_ref = self.terminals.clone();
|
||||
std::thread::Builder::new()
|
||||
.name(format!("pty-reader-{}", &id_for_writer[..8]))
|
||||
.spawn(move || {
|
||||
read_loop(reader, id_for_reader, &app_handle, &terminals_ref);
|
||||
})
|
||||
.map_err(|e| TerminalError::SpawnFailed(e.to_string()))?;
|
||||
|
||||
Ok(terminal_id)
|
||||
}
|
||||
|
||||
pub fn write(&self, terminal_id: &str, data: &[u8]) -> Result<(), TerminalError> {
|
||||
let terminals = self.terminals.lock().unwrap();
|
||||
let instance = terminals
|
||||
.get(terminal_id)
|
||||
.ok_or_else(|| TerminalError::NotFound(terminal_id.to_string()))?;
|
||||
instance
|
||||
.write_tx
|
||||
.send(data.to_vec())
|
||||
.map_err(|e| TerminalError::WriteFailed(e.to_string()))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn resize(&self, terminal_id: &str, cols: u16, rows: u16) -> Result<(), TerminalError> {
|
||||
let terminals = self.terminals.lock().unwrap();
|
||||
let instance = terminals
|
||||
.get(terminal_id)
|
||||
.ok_or_else(|| TerminalError::NotFound(terminal_id.to_string()))?;
|
||||
instance
|
||||
.master
|
||||
.resize(PtySize {
|
||||
rows,
|
||||
cols,
|
||||
pixel_width: 0,
|
||||
pixel_height: 0,
|
||||
})
|
||||
.map_err(|e| TerminalError::ResizeFailed(e.to_string()))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn kill(&self, terminal_id: &str) -> Result<(), TerminalError> {
|
||||
let mut instance = self
|
||||
.terminals
|
||||
.lock()
|
||||
.unwrap()
|
||||
.remove(terminal_id)
|
||||
.ok_or_else(|| TerminalError::NotFound(terminal_id.to_string()))?;
|
||||
terminate_terminal(&mut instance);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn list_with_exit_check(&self, app_handle: Option<&tauri::AppHandle>) -> Vec<TerminalInfo> {
|
||||
let mut terminals = self.terminals.lock().unwrap();
|
||||
let mut exited_terminal_ids: Vec<String> = Vec::new();
|
||||
|
||||
// Windows ConPTY may not always surface EOF promptly; reconcile exited
|
||||
// child processes here so frontend running-state can recover reliably.
|
||||
for (id, instance) in terminals.iter_mut() {
|
||||
match instance.child.try_wait() {
|
||||
Ok(Some(_)) => exited_terminal_ids.push(id.clone()),
|
||||
Ok(None) => {}
|
||||
Err(err) => {
|
||||
eprintln!(
|
||||
"[TERM] failed to query child status for terminal {}: {}",
|
||||
id, err
|
||||
);
|
||||
exited_terminal_ids.push(id.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for terminal_id in &exited_terminal_ids {
|
||||
terminals.remove(terminal_id);
|
||||
}
|
||||
|
||||
let infos = terminals
|
||||
.iter()
|
||||
.map(|(id, inst)| TerminalInfo {
|
||||
id: id.clone(),
|
||||
title: inst.title.clone(),
|
||||
})
|
||||
.collect();
|
||||
|
||||
drop(terminals);
|
||||
|
||||
if let Some(handle) = app_handle {
|
||||
for terminal_id in exited_terminal_ids {
|
||||
emit_terminal_exit_event(handle, &terminal_id);
|
||||
}
|
||||
}
|
||||
|
||||
infos
|
||||
}
|
||||
|
||||
pub fn kill_by_owner_window(&self, owner_window_label: &str) -> usize {
|
||||
let mut instances = {
|
||||
let mut terminals = self.terminals.lock().unwrap();
|
||||
let ids: Vec<String> = terminals
|
||||
.iter()
|
||||
.filter_map(|(id, instance)| {
|
||||
if instance.owner_window_label == owner_window_label {
|
||||
Some(id.clone())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
let mut removed = Vec::with_capacity(ids.len());
|
||||
for id in ids {
|
||||
if let Some(instance) = terminals.remove(&id) {
|
||||
removed.push(instance);
|
||||
}
|
||||
}
|
||||
removed
|
||||
};
|
||||
|
||||
let killed = instances.len();
|
||||
for instance in &mut instances {
|
||||
terminate_terminal(instance);
|
||||
}
|
||||
killed
|
||||
}
|
||||
}
|
||||
|
||||
fn terminate_terminal(instance: &mut TerminalInstance) {
|
||||
let _ = instance.child.kill();
|
||||
let _ = instance.child.wait();
|
||||
}
|
||||
|
||||
fn write_loop(mut writer: Box<dyn Write + Send>, rx: mpsc::Receiver<Vec<u8>>) {
|
||||
while let Ok(data) = rx.recv() {
|
||||
if writer.write_all(&data).is_err() {
|
||||
break;
|
||||
}
|
||||
while let Ok(more) = rx.try_recv() {
|
||||
if writer.write_all(&more).is_err() {
|
||||
return;
|
||||
}
|
||||
}
|
||||
if writer.flush().is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn read_loop(
|
||||
mut reader: Box<dyn Read + Send>,
|
||||
terminal_id: String,
|
||||
app_handle: &tauri::AppHandle,
|
||||
terminals: &Arc<Mutex<HashMap<String, TerminalInstance>>>,
|
||||
) {
|
||||
let output_event = format!("terminal://output/{}", terminal_id);
|
||||
let mut buf = [0u8; 8192];
|
||||
|
||||
loop {
|
||||
match reader.read(&mut buf) {
|
||||
Ok(0) => break,
|
||||
Ok(n) => {
|
||||
let data = String::from_utf8_lossy(&buf[..n]).to_string();
|
||||
let event = TerminalEvent {
|
||||
terminal_id: terminal_id.clone(),
|
||||
data,
|
||||
};
|
||||
let _ = app_handle.emit(&output_event, &event);
|
||||
}
|
||||
Err(_) => break,
|
||||
}
|
||||
}
|
||||
|
||||
// Terminal exited — remove from map
|
||||
terminals.lock().unwrap().remove(&terminal_id);
|
||||
|
||||
emit_terminal_exit_event(app_handle, &terminal_id);
|
||||
}
|
||||
|
||||
fn emit_terminal_exit_event(app_handle: &tauri::AppHandle, terminal_id: &str) {
|
||||
let exit_event = format!("terminal://exit/{}", terminal_id);
|
||||
let event = TerminalEvent {
|
||||
terminal_id: terminal_id.to_string(),
|
||||
data: String::new(),
|
||||
};
|
||||
let _ = app_handle.emit(&exit_event, &event);
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
pub mod error;
|
||||
pub mod manager;
|
||||
pub mod types;
|
||||
@@ -0,0 +1,13 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct TerminalEvent {
|
||||
pub terminal_id: String,
|
||||
pub data: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct TerminalInfo {
|
||||
pub id: String,
|
||||
pub title: String,
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "codeg",
|
||||
"version": "0.0.13",
|
||||
"identifier": "app.codeg",
|
||||
"build": {
|
||||
"beforeDevCommand": "pnpm dev",
|
||||
"devUrl": "http://localhost:3000",
|
||||
"beforeBuildCommand": "pnpm build",
|
||||
"frontendDist": "../out"
|
||||
},
|
||||
"app": {
|
||||
"windows": [],
|
||||
"security": {
|
||||
"csp": null
|
||||
}
|
||||
},
|
||||
"bundle": {
|
||||
"active": true,
|
||||
"createUpdaterArtifacts": true,
|
||||
"targets": "all",
|
||||
"icon": [
|
||||
"icons/32x32.png",
|
||||
"icons/128x128.png",
|
||||
"icons/128x128@2x.png",
|
||||
"icons/icon.icns",
|
||||
"icons/icon.ico"
|
||||
]
|
||||
},
|
||||
"plugins": {
|
||||
"updater": {
|
||||
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDQ4OEM3NkMyMTVENjdBODgKUldTSWV0WVZ3bmFNU0NnSzhpdFg2bXFmMHFidWd1eWpuZ2Y2WmU5QmVXdWVrU0ZpOCt2dnd6WW4K",
|
||||
"endpoints": [
|
||||
"https://github.com/xintaofei/codeg/releases/latest/download/latest.json"
|
||||
],
|
||||
"windows": {
|
||||
"installMode": "passive"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
{"v":1}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"git": {
|
||||
"sha1": "ecd5e556739582ffaac5db16aa154732b1b76e03"
|
||||
},
|
||||
"path_in_vcs": "src/sacp-tokio"
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
# Changelog
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [11.0.0](https://github.com/symposium-dev/symposium-acp/compare/sacp-tokio-v10.1.0...sacp-tokio-v11.0.0) - 2026-01-19
|
||||
|
||||
### Other
|
||||
|
||||
- go back from `connect_from` to `builder`
|
||||
- fix unresolved rustdoc link warnings for v11 API
|
||||
- *(sacp)* [**breaking**] rename *_cx variables to descriptive names
|
||||
- *(sacp)* [**breaking**] rename MessageCx to Dispatch for clearer semantics
|
||||
- *(sacp)* [**breaking**] rename Serve to ConnectTo for clearer semantics
|
||||
- *(sacp)* [**breaking**] replace JrLink/JrPeer with unified Role-based API
|
||||
- *(sacp)* rename JrConnectionBuilder to ConnectFrom
|
||||
- *(sacp)* rename Jr* traits to JsonRpc* for clarity
|
||||
|
||||
## [10.1.0](https://github.com/symposium-dev/symposium-acp/compare/sacp-tokio-v10.0.0...sacp-tokio-v10.1.0) - 2025-12-31
|
||||
|
||||
### Added
|
||||
|
||||
- *(elizacp)* implement Eliza algorithm based on the original style
|
||||
|
||||
## [10.0.0-alpha.4](https://github.com/symposium-dev/symposium-acp/compare/sacp-tokio-v10.0.0-alpha.3...sacp-tokio-v10.0.0-alpha.4) - 2025-12-30
|
||||
|
||||
### Added
|
||||
|
||||
- *(deps)* [**breaking**] upgrade agent-client-protocol-schema to 0.10.5
|
||||
|
||||
## [10.0.0-alpha.3](https://github.com/symposium-dev/symposium-acp/compare/sacp-tokio-v10.0.0-alpha.2...sacp-tokio-v10.0.0-alpha.3) - 2025-12-29
|
||||
|
||||
### Other
|
||||
|
||||
- updated the following local packages: sacp
|
||||
|
||||
## [10.0.0-alpha.2](https://github.com/symposium-dev/symposium-acp/compare/sacp-tokio-v10.0.0-alpha.1...sacp-tokio-v10.0.0-alpha.2) - 2025-12-29
|
||||
|
||||
### Other
|
||||
|
||||
- updated the following local packages: sacp
|
||||
|
||||
## [10.0.0-alpha.1](https://github.com/symposium-dev/symposium-acp/compare/sacp-tokio-v9.0.0...sacp-tokio-v10.0.0-alpha.1) - 2025-12-28
|
||||
|
||||
### Other
|
||||
|
||||
- [**breaking**] split peer.rs into separate peer and link modules
|
||||
- [**breaking**] update module and documentation references from role to peer
|
||||
- [**breaking**] give component a link
|
||||
- update UntypedRole to UntypedRole in doc examples
|
||||
- *(sacp)* rename with_client to run_until
|
||||
- update references for renamed methods
|
||||
|
||||
## [9.0.0](https://github.com/symposium-dev/symposium-acp/compare/sacp-tokio-v8.0.0...sacp-tokio-v9.0.0) - 2025-12-19
|
||||
|
||||
### Added
|
||||
|
||||
- *(sacp-tokio)* add convenience constructors for known ACP agents
|
||||
|
||||
## [8.0.0](https://github.com/symposium-dev/symposium-acp/compare/sacp-tokio-v7.0.0...sacp-tokio-v8.0.0) - 2025-12-17
|
||||
|
||||
### Other
|
||||
|
||||
- updated the following local packages: sacp
|
||||
|
||||
## [6.0.1](https://github.com/symposium-dev/symposium-acp/compare/sacp-tokio-v6.0.0...sacp-tokio-v6.0.1) - 2025-12-17
|
||||
|
||||
### Other
|
||||
|
||||
- updated the following local packages: sacp
|
||||
|
||||
## [3.0.1](https://github.com/symposium-dev/symposium-acp/compare/sacp-tokio-v3.0.0...sacp-tokio-v3.0.1) - 2025-12-15
|
||||
|
||||
### Fixed
|
||||
|
||||
- *(sacp-tokio)* report child process errors with stderr in AcpAgent
|
||||
|
||||
## [3.0.0](https://github.com/symposium-dev/symposium-acp/compare/sacp-tokio-v2.0.1...sacp-tokio-v3.0.0) - 2025-12-12
|
||||
|
||||
### Added
|
||||
|
||||
- [**breaking**] introduce role-based connection API
|
||||
|
||||
## [2.0.1](https://github.com/symposium-dev/symposium-acp/compare/sacp-tokio-v2.0.0...sacp-tokio-v2.0.1) - 2025-11-25
|
||||
|
||||
### Other
|
||||
|
||||
- updated the following local packages: sacp
|
||||
|
||||
## [2.0.0](https://github.com/symposium-dev/symposium-acp/compare/sacp-tokio-v1.1.0...sacp-tokio-v2.0.0) - 2025-11-22
|
||||
|
||||
### Added
|
||||
|
||||
- *(sacp-tokio)* add debug callback support to Stdio component
|
||||
- *(sacp-tokio)* capture stderr in AcpAgent debug logging
|
||||
- *(sacp-tokio)* add debug callback support to AcpAgent
|
||||
|
||||
### Changed
|
||||
|
||||
- [**breaking**] `Stdio` is no longer a unit struct. Use `Stdio::new()` instead of `Stdio` to instantiate.
|
||||
|
||||
### Other
|
||||
|
||||
- *(sacp-tokio)* simplify debug callback using Option instead of wrapper type
|
||||
- *(sacp-tokio)* rewrite AcpAgent to use Lines instead of ByteStreams
|
||||
|
||||
## [1.1.0](https://github.com/symposium-dev/symposium-acp/compare/sacp-tokio-v1.0.0...sacp-tokio-v1.1.0) - 2025-11-22
|
||||
|
||||
### Added
|
||||
|
||||
- *(sacp-tokio)* add debug callback support to Stdio component
|
||||
- *(sacp-tokio)* capture stderr in AcpAgent debug logging
|
||||
- *(sacp-tokio)* add debug callback support to AcpAgent
|
||||
|
||||
### Other
|
||||
|
||||
- *(sacp-tokio)* simplify debug callback using Option instead of wrapper type
|
||||
- *(sacp-tokio)* rewrite AcpAgent to use Lines instead of ByteStreams
|
||||
|
||||
## [1.0.0](https://github.com/symposium-dev/symposium-acp/compare/sacp-tokio-v1.0.0-alpha.8...sacp-tokio-v1.0.0) - 2025-11-13
|
||||
|
||||
### Other
|
||||
|
||||
- updated the following local packages: sacp
|
||||
|
||||
## [1.0.0-alpha.8](https://github.com/symposium-dev/symposium-acp/compare/sacp-tokio-v1.0.0-alpha.7...sacp-tokio-v1.0.0-alpha.8) - 2025-11-12
|
||||
|
||||
### Other
|
||||
|
||||
- Merge pull request #30 from nikomatsakis/main
|
||||
- *(sacp)* add Component::serve() and simplify channel API
|
||||
|
||||
## [1.0.0-alpha.7](https://github.com/symposium-dev/symposium-acp/compare/sacp-tokio-v1.0.0-alpha.6...sacp-tokio-v1.0.0-alpha.7) - 2025-11-12
|
||||
|
||||
### Other
|
||||
|
||||
- Merge pull request #28 from nikomatsakis/main
|
||||
|
||||
## [1.0.0-alpha.6](https://github.com/symposium-dev/symposium-acp/compare/sacp-tokio-v1.0.0-alpha.5...sacp-tokio-v1.0.0-alpha.6) - 2025-11-11
|
||||
|
||||
### Other
|
||||
|
||||
- Merge pull request #26 from nikomatsakis/main
|
||||
- [**breaking**] make Component trait ergonomic with async fn and introduce DynComponent
|
||||
- [**breaking**] make Component the primary trait with Transport as blanket impl
|
||||
|
||||
## [1.0.0-alpha.5](https://github.com/symposium-dev/symposium-acp/compare/sacp-tokio-v1.0.0-alpha.4...sacp-tokio-v1.0.0-alpha.5) - 2025-11-11
|
||||
|
||||
### Other
|
||||
|
||||
- convert Stdio to unit struct for easier reference
|
||||
|
||||
## [1.0.0-alpha.4](https://github.com/symposium-dev/symposium-acp/compare/sacp-tokio-v1.0.0-alpha.3...sacp-tokio-v1.0.0-alpha.4) - 2025-11-11
|
||||
|
||||
### Other
|
||||
|
||||
- remove ComponentProvider trait
|
||||
- unify Transport and Component traits with BoxFuture-returning signatures
|
||||
- create selective jsonrpcmsg re-export module
|
||||
- replace jsonrpcmsg::Message with sacp::JsonRpcMessage throughout codebase
|
||||
|
||||
## [1.0.0-alpha.3](https://github.com/symposium-dev/symposium-acp/compare/sacp-tokio-v1.0.0-alpha.2...sacp-tokio-v1.0.0-alpha.3) - 2025-11-09
|
||||
|
||||
### Other
|
||||
|
||||
- updated the following local packages: sacp
|
||||
|
||||
## [1.0.0-alpha.2](https://github.com/symposium-dev/symposium-acp/compare/sacp-tokio-v1.0.0-alpha.1...sacp-tokio-v1.0.0-alpha.2) - 2025-11-08
|
||||
|
||||
### Other
|
||||
|
||||
- fix doctests for API refactoring
|
||||
- wip wip wip
|
||||
- [**breaking**] remove Unpin bounds and simplify transport API
|
||||
|
||||
## [1.0.0-alpha](https://github.com/symposium-dev/symposium-acp/releases/tag/sacp-tokio-v1.0.0-alpha) - 2025-11-05
|
||||
|
||||
### Added
|
||||
|
||||
- *(conductor)* add proxy mode support for hierarchical chains
|
||||
- *(sacp-tokio)* implement JrConnectionExt trait for to_agent
|
||||
- create sacp-tokio crate and improve AcpAgent API
|
||||
|
||||
### Fixed
|
||||
|
||||
- *(sacp-tokio)* correct type path in doctest example
|
||||
- fix github url
|
||||
|
||||
### Other
|
||||
|
||||
- bump all packages to version 1.0.0-alpha
|
||||
- *(sacp)* move handler types to dedicated handler module
|
||||
- *(sacp)* [**breaking**] reorganize modules with flat schema namespace
|
||||
- release
|
||||
- add READMEs for sacp-tokio, sacp-proxy, and sacp-conductor
|
||||
|
||||
## [0.1.1](https://github.com/symposium-dev/symposium-acp/releases/tag/sacp-tokio-v0.1.1) - 2025-11-04
|
||||
|
||||
### Added
|
||||
|
||||
- *(sacp-tokio)* implement JrConnectionExt trait for to_agent
|
||||
- create sacp-tokio crate and improve AcpAgent API
|
||||
|
||||
### Fixed
|
||||
|
||||
- fix github url
|
||||
|
||||
### Other
|
||||
|
||||
- add READMEs for sacp-tokio, sacp-proxy, and sacp-conductor
|
||||
@@ -0,0 +1,77 @@
|
||||
# THIS FILE IS AUTOMATICALLY GENERATED BY CARGO
|
||||
#
|
||||
# When uploading crates to the registry Cargo will automatically
|
||||
# "normalize" Cargo.toml files for maximal compatibility
|
||||
# with all versions of Cargo and also rewrite `path` dependencies
|
||||
# to registry (e.g., crates.io) dependencies.
|
||||
#
|
||||
# If you are reading this file be aware that the original Cargo.toml
|
||||
# will likely look very different (and much more reasonable).
|
||||
# See Cargo.toml.orig for the original contents.
|
||||
|
||||
[package]
|
||||
edition = "2024"
|
||||
name = "sacp-tokio"
|
||||
version = "11.0.0-alpha.1"
|
||||
build = false
|
||||
autolib = false
|
||||
autobins = false
|
||||
autoexamples = false
|
||||
autotests = false
|
||||
autobenches = false
|
||||
description = "Tokio-based utilities for SACP (Symposium's extensions to ACP)"
|
||||
readme = "README.md"
|
||||
keywords = [
|
||||
"acp",
|
||||
"agent",
|
||||
"protocol",
|
||||
"ai",
|
||||
"tokio",
|
||||
]
|
||||
categories = ["development-tools"]
|
||||
license = "MIT OR Apache-2.0"
|
||||
repository = "https://github.com/symposium-dev/symposium-acp"
|
||||
resolver = "2"
|
||||
|
||||
[lib]
|
||||
name = "sacp_tokio"
|
||||
path = "src/lib.rs"
|
||||
|
||||
[[test]]
|
||||
name = "debug_logging"
|
||||
path = "tests/debug_logging.rs"
|
||||
|
||||
[dependencies.futures]
|
||||
version = "0.3.31"
|
||||
|
||||
[dependencies.sacp]
|
||||
version = "11.0.0-alpha.1"
|
||||
|
||||
[dependencies.serde]
|
||||
version = "1.0"
|
||||
features = ["derive"]
|
||||
|
||||
[dependencies.serde_json]
|
||||
version = "1.0"
|
||||
|
||||
[dependencies.shell-words]
|
||||
version = "1.1"
|
||||
|
||||
[dependencies.tokio]
|
||||
version = "1.48"
|
||||
features = ["full"]
|
||||
|
||||
[dependencies.tokio-util]
|
||||
version = "0.7"
|
||||
features = ["compat"]
|
||||
|
||||
[dev-dependencies.expect-test]
|
||||
version = "1.5"
|
||||
|
||||
[dev-dependencies.tokio]
|
||||
version = "1.48"
|
||||
features = [
|
||||
"full",
|
||||
"macros",
|
||||
"rt-multi-thread",
|
||||
]
|
||||
@@ -0,0 +1,24 @@
|
||||
[package]
|
||||
name = "sacp-tokio"
|
||||
version = "11.0.0-alpha.1"
|
||||
edition = "2024"
|
||||
description = "Tokio-based utilities for SACP (Symposium's extensions to ACP)"
|
||||
license = "MIT OR Apache-2.0"
|
||||
repository = "https://github.com/symposium-dev/symposium-acp"
|
||||
keywords = ["acp", "agent", "protocol", "ai", "tokio"]
|
||||
categories = ["development-tools"]
|
||||
|
||||
[dependencies]
|
||||
sacp = { version = "11.0.0-alpha.1", path = "../sacp" }
|
||||
futures.workspace = true
|
||||
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
shell-words = "1.1"
|
||||
tokio.workspace = true
|
||||
tokio-util.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
expect-test.workspace = true
|
||||
sacp-test = { path = "../sacp-test" }
|
||||
tokio = { workspace = true, features = ["macros", "rt-multi-thread"] }
|
||||
@@ -0,0 +1,57 @@
|
||||
# sacp-tokio
|
||||
|
||||
Tokio-specific utilities for working with ACP agents.
|
||||
|
||||
## What's in this crate?
|
||||
|
||||
This crate provides helpers for spawning and connecting to ACP agents using the Tokio async runtime:
|
||||
|
||||
- **`AcpAgent`** - Configuration for spawning agent processes
|
||||
- **`JrConnectionExt`** - Extension trait that adds `JrConnection::to_agent()` for easy agent spawning
|
||||
|
||||
## Usage
|
||||
|
||||
The main use case is spawning an agent process and creating a connection to it in one step:
|
||||
|
||||
```rust
|
||||
use sacp::JrConnection;
|
||||
use sacp_tokio::{AcpAgent, JrConnectionExt};
|
||||
|
||||
let agent = AcpAgent::from_str("python my_agent.py")?;
|
||||
|
||||
JrConnection::to_agent(agent)?
|
||||
.on_receive_notification(|notif: SessionNotification, _cx| async move {
|
||||
println!("Agent update: {:?}", notif);
|
||||
Ok(())
|
||||
})
|
||||
.run_until(|cx| async move {
|
||||
// Initialize and interact with the agent
|
||||
let response = cx.send_request(InitializeRequest { ... })
|
||||
.block_task()
|
||||
.await?;
|
||||
Ok(())
|
||||
})
|
||||
.await?;
|
||||
```
|
||||
|
||||
The agent process is managed automatically - it's spawned when you call `to_agent()`,
|
||||
and killed when the connection is dropped.
|
||||
|
||||
## When to use this crate
|
||||
|
||||
Use `sacp-tokio` when you need to:
|
||||
- Spawn agent processes from your code
|
||||
- Test agents by programmatically launching them
|
||||
- Build tools that orchestrate multiple agents
|
||||
|
||||
If you're implementing an agent that listens on stdin/stdout, you only need the core `sacp` crate.
|
||||
|
||||
## Related Crates
|
||||
|
||||
- **[sacp](../sacp/)** - Core ACP SDK (use this for building agents)
|
||||
- **[sacp-proxy](../sacp-proxy/)** - Framework for building ACP proxies
|
||||
- **[sacp-conductor](../sacp-conductor/)** - Binary for orchestrating proxy chains
|
||||
|
||||
## License
|
||||
|
||||
MIT OR Apache-2.0
|
||||
@@ -0,0 +1,619 @@
|
||||
//! Utilities for connecting to ACP agents and proxies.
|
||||
//!
|
||||
//! This module provides [`AcpAgent`], a convenient wrapper around [`sacp::schema::McpServer`]
|
||||
//! that can be parsed from either a command string or JSON configuration.
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::str::FromStr;
|
||||
use std::sync::Arc;
|
||||
|
||||
use sacp::{Client, Conductor, Role};
|
||||
use tokio::process::Child;
|
||||
use tokio_util::compat::{TokioAsyncReadCompatExt, TokioAsyncWriteCompatExt};
|
||||
|
||||
#[cfg(windows)]
|
||||
const CREATE_NO_WINDOW: u32 = 0x0800_0000;
|
||||
const MAX_STDERR_CAPTURE_BYTES: usize = 1024 * 1024;
|
||||
|
||||
/// Direction of a line being sent or received.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum LineDirection {
|
||||
/// Line being sent to the agent (stdin)
|
||||
Stdin,
|
||||
/// Line being received from the agent (stdout)
|
||||
Stdout,
|
||||
/// Line being received from the agent (stderr)
|
||||
Stderr,
|
||||
}
|
||||
|
||||
/// A component representing an external ACP agent running in a separate process.
|
||||
///
|
||||
/// `AcpAgent` implements the [`sacp::ConnectTo`] trait for spawning and communicating with
|
||||
/// external agents or proxies via stdio. It handles process spawning, stream setup, and
|
||||
/// byte stream serialization automatically. This is the primary way to connect to agents
|
||||
/// that run as separate executables.
|
||||
///
|
||||
/// This is a wrapper around [`sacp::schema::McpServer`] that provides convenient parsing
|
||||
/// from command-line strings or JSON configurations.
|
||||
///
|
||||
/// # Use Cases
|
||||
///
|
||||
/// - **External agents**: Connect to agents written in any language (Python, Node.js, Rust, etc.)
|
||||
/// - **Proxy chains**: Spawn intermediate proxies that transform or intercept messages
|
||||
/// - **Conductor components**: Use with [`sacp_conductor::Conductor`] to build proxy chains
|
||||
/// - **Subprocess isolation**: Run potentially untrusted code in a separate process
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// Parse from a command string:
|
||||
/// ```
|
||||
/// # use sacp_tokio::AcpAgent;
|
||||
/// # use std::str::FromStr;
|
||||
/// let agent = AcpAgent::from_str("python my_agent.py --verbose").unwrap();
|
||||
/// ```
|
||||
///
|
||||
/// Parse from JSON:
|
||||
/// ```
|
||||
/// # use sacp_tokio::AcpAgent;
|
||||
/// # use std::str::FromStr;
|
||||
/// let agent = AcpAgent::from_str(r#"{"type": "stdio", "name": "my-agent", "command": "python", "args": ["my_agent.py"], "env": []}"#).unwrap();
|
||||
/// ```
|
||||
///
|
||||
/// Use as a component to connect to an external agent:
|
||||
/// ```ignore
|
||||
/// use sacp::{Client, Builder};
|
||||
/// use sacp_tokio::AcpAgent;
|
||||
/// use std::str::FromStr;
|
||||
///
|
||||
/// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
|
||||
/// let agent = AcpAgent::from_str("python my_agent.py")?;
|
||||
///
|
||||
/// // The agent process will be spawned automatically when connected
|
||||
/// Client.builder()
|
||||
/// .connect_to(agent)
|
||||
/// .await?
|
||||
/// .connect_with(|cx| async move {
|
||||
/// // Use the connection to communicate with the agent process
|
||||
/// Ok(())
|
||||
/// })
|
||||
/// .await?;
|
||||
/// # Ok(())
|
||||
/// # }
|
||||
/// ```
|
||||
///
|
||||
/// [`sacp_conductor::Conductor`]: https://docs.rs/sacp-conductor/latest/sacp_conductor/struct.Conductor.html
|
||||
pub struct AcpAgent {
|
||||
server: sacp::schema::McpServer,
|
||||
debug_callback: Option<Arc<dyn Fn(&str, LineDirection) + Send + Sync + 'static>>,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for AcpAgent {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("AcpAgent")
|
||||
.field("server", &self.server)
|
||||
.field(
|
||||
"debug_callback",
|
||||
&self.debug_callback.as_ref().map(|_| "..."),
|
||||
)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl AcpAgent {
|
||||
/// Create a new `AcpAgent` from an [`sacp::schema::McpServer`] configuration.
|
||||
pub fn new(server: sacp::schema::McpServer) -> Self {
|
||||
Self {
|
||||
server,
|
||||
debug_callback: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Create an ACP agent for Zed Industries' Claude Code tool.
|
||||
/// Just runs `npx -y @zed-industries/claude-code-acp@latest`.
|
||||
pub fn zed_claude_code() -> Self {
|
||||
Self::from_str("npx -y @zed-industries/claude-code-acp@latest").expect("valid bash command")
|
||||
}
|
||||
|
||||
/// Create an ACP agent for Zed Industries' Codex tool.
|
||||
/// Just runs `npx -y @zed-industries/codex-acp@latest`.
|
||||
pub fn zed_codex() -> Self {
|
||||
Self::from_str("npx -y @zed-industries/codex-acp@latest").expect("valid bash command")
|
||||
}
|
||||
|
||||
/// Create an ACP agent for Google's Gemini CLI.
|
||||
/// Just runs `npx -y -- @google/gemini-cli@latest --experimental-acp`.
|
||||
pub fn google_gemini() -> Self {
|
||||
Self::from_str("npx -y -- @google/gemini-cli@latest --experimental-acp")
|
||||
.expect("valid bash command")
|
||||
}
|
||||
|
||||
/// Get the underlying [`sacp::schema::McpServer`] configuration.
|
||||
pub fn server(&self) -> &sacp::schema::McpServer {
|
||||
&self.server
|
||||
}
|
||||
|
||||
/// Convert into the underlying [`sacp::schema::McpServer`] configuration.
|
||||
pub fn into_server(self) -> sacp::schema::McpServer {
|
||||
self.server
|
||||
}
|
||||
|
||||
/// Add a debug callback that will be invoked for each line sent/received.
|
||||
///
|
||||
/// The callback receives the line content and the direction (stdin/stdout/stderr).
|
||||
/// This is useful for logging, debugging, or monitoring agent communication.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```no_run
|
||||
/// # use sacp_tokio::{AcpAgent, LineDirection};
|
||||
/// # use std::str::FromStr;
|
||||
/// let agent = AcpAgent::from_str("python my_agent.py")
|
||||
/// .unwrap()
|
||||
/// .with_debug(|line, direction| {
|
||||
/// eprintln!("{:?}: {}", direction, line);
|
||||
/// });
|
||||
/// ```
|
||||
pub fn with_debug<F>(mut self, callback: F) -> Self
|
||||
where
|
||||
F: Fn(&str, LineDirection) + Send + Sync + 'static,
|
||||
{
|
||||
self.debug_callback = Some(Arc::new(callback));
|
||||
self
|
||||
}
|
||||
|
||||
/// Spawn the process and get stdio streams.
|
||||
/// Used internally by the Component trait implementation.
|
||||
pub fn spawn_process(
|
||||
&self,
|
||||
) -> Result<
|
||||
(
|
||||
tokio::process::ChildStdin,
|
||||
tokio::process::ChildStdout,
|
||||
tokio::process::ChildStderr,
|
||||
Child,
|
||||
),
|
||||
sacp::Error,
|
||||
> {
|
||||
match &self.server {
|
||||
sacp::schema::McpServer::Stdio(stdio) => {
|
||||
let mut cmd = tokio::process::Command::new(&stdio.command);
|
||||
cmd.args(&stdio.args);
|
||||
for env_var in &stdio.env {
|
||||
cmd.env(&env_var.name, &env_var.value);
|
||||
}
|
||||
#[cfg(windows)]
|
||||
{
|
||||
cmd.creation_flags(CREATE_NO_WINDOW);
|
||||
}
|
||||
cmd.stdin(std::process::Stdio::piped())
|
||||
.stdout(std::process::Stdio::piped())
|
||||
.stderr(std::process::Stdio::piped());
|
||||
|
||||
let mut child = cmd.spawn().map_err(sacp::Error::into_internal_error)?;
|
||||
|
||||
let child_stdin = child
|
||||
.stdin
|
||||
.take()
|
||||
.ok_or_else(|| sacp::util::internal_error("Failed to open stdin"))?;
|
||||
let child_stdout = child
|
||||
.stdout
|
||||
.take()
|
||||
.ok_or_else(|| sacp::util::internal_error("Failed to open stdout"))?;
|
||||
let child_stderr = child
|
||||
.stderr
|
||||
.take()
|
||||
.ok_or_else(|| sacp::util::internal_error("Failed to open stderr"))?;
|
||||
|
||||
Ok((child_stdin, child_stdout, child_stderr, child))
|
||||
}
|
||||
sacp::schema::McpServer::Http(_) => Err(sacp::util::internal_error(
|
||||
"HTTP transport not yet supported by AcpAgent",
|
||||
)),
|
||||
sacp::schema::McpServer::Sse(_) => Err(sacp::util::internal_error(
|
||||
"SSE transport not yet supported by AcpAgent",
|
||||
)),
|
||||
_ => Err(sacp::util::internal_error(
|
||||
"Unknown MCP server transport type",
|
||||
)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A wrapper around Child that kills the process when dropped.
|
||||
struct ChildGuard(Child);
|
||||
|
||||
impl ChildGuard {
|
||||
async fn wait(&mut self) -> std::io::Result<std::process::ExitStatus> {
|
||||
self.0.wait().await
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for ChildGuard {
|
||||
fn drop(&mut self) {
|
||||
let _ = self.0.start_kill();
|
||||
}
|
||||
}
|
||||
|
||||
fn append_limited_utf8(output: &mut String, chunk: &str, limit: usize) -> bool {
|
||||
output.push_str(chunk);
|
||||
if output.len() <= limit {
|
||||
return false;
|
||||
}
|
||||
|
||||
let mut start = output.len().saturating_sub(limit);
|
||||
while start < output.len() && !output.is_char_boundary(start) {
|
||||
start += 1;
|
||||
}
|
||||
|
||||
output.drain(..start);
|
||||
true
|
||||
}
|
||||
|
||||
/// Waits for a child process and returns an error if it exits with non-zero status.
|
||||
///
|
||||
/// The error message includes any stderr output collected by the background task.
|
||||
/// When dropped, the child process is killed.
|
||||
async fn monitor_child(
|
||||
child: Child,
|
||||
stderr_rx: tokio::sync::oneshot::Receiver<String>,
|
||||
) -> Result<(), sacp::Error> {
|
||||
let mut guard = ChildGuard(child);
|
||||
|
||||
// Wait for the child to exit
|
||||
let status = guard
|
||||
.wait()
|
||||
.await
|
||||
.map_err(|e| sacp::util::internal_error(format!("Failed to wait for process: {}", e)))?;
|
||||
|
||||
if status.success() {
|
||||
Ok(())
|
||||
} else {
|
||||
// Get stderr content if available
|
||||
let stderr = stderr_rx.await.unwrap_or_default();
|
||||
|
||||
let message = if stderr.is_empty() {
|
||||
format!("Process exited with {}", status)
|
||||
} else {
|
||||
format!("Process exited with {}: {}", status, stderr)
|
||||
};
|
||||
|
||||
Err(sacp::util::internal_error(message))
|
||||
}
|
||||
}
|
||||
|
||||
/// Roles that an ACP agent executable can potentially serve.
|
||||
pub trait AcpAgentCounterpartRole: Role {}
|
||||
|
||||
impl AcpAgentCounterpartRole for Client {}
|
||||
|
||||
impl AcpAgentCounterpartRole for Conductor {}
|
||||
|
||||
impl<Counterpart: AcpAgentCounterpartRole> sacp::ConnectTo<Counterpart> for AcpAgent {
|
||||
async fn connect_to(
|
||||
self,
|
||||
client: impl sacp::ConnectTo<Counterpart::Counterpart>,
|
||||
) -> Result<(), sacp::Error> {
|
||||
use futures::AsyncBufReadExt;
|
||||
use futures::AsyncWriteExt;
|
||||
use futures::StreamExt;
|
||||
use futures::io::BufReader;
|
||||
|
||||
let (child_stdin, child_stdout, child_stderr, child) = self.spawn_process()?;
|
||||
|
||||
// Create a channel to collect stderr for error reporting
|
||||
let (stderr_tx, stderr_rx) = tokio::sync::oneshot::channel::<String>();
|
||||
|
||||
// Spawn a task to read stderr, optionally calling the debug callback
|
||||
let debug_callback = self.debug_callback.clone();
|
||||
tokio::spawn(async move {
|
||||
let stderr_reader = BufReader::new(child_stderr.compat());
|
||||
let mut stderr_lines = stderr_reader.lines();
|
||||
let mut collected = String::new();
|
||||
let mut truncated = false;
|
||||
while let Some(line_result) = stderr_lines.next().await {
|
||||
if let Ok(line) = line_result {
|
||||
// Call debug callback if present
|
||||
if let Some(ref callback) = debug_callback {
|
||||
callback(&line, LineDirection::Stderr);
|
||||
}
|
||||
// Always collect for error reporting
|
||||
if !collected.is_empty() {
|
||||
truncated |= append_limited_utf8(
|
||||
&mut collected,
|
||||
"\n",
|
||||
MAX_STDERR_CAPTURE_BYTES,
|
||||
);
|
||||
}
|
||||
truncated |=
|
||||
append_limited_utf8(&mut collected, &line, MAX_STDERR_CAPTURE_BYTES);
|
||||
}
|
||||
}
|
||||
if truncated {
|
||||
let prefix = "[stderr truncated to last 1 MiB]\n";
|
||||
let mut marked = String::with_capacity(prefix.len() + collected.len());
|
||||
marked.push_str(prefix);
|
||||
marked.push_str(&collected);
|
||||
collected = marked;
|
||||
}
|
||||
let _ = stderr_tx.send(collected);
|
||||
});
|
||||
|
||||
// Create a future that monitors the child process for early exit
|
||||
let child_monitor = monitor_child(child, stderr_rx);
|
||||
|
||||
// Convert stdio to line streams with optional debug inspection
|
||||
let incoming_lines = if let Some(callback) = self.debug_callback.clone() {
|
||||
Box::pin(
|
||||
BufReader::new(child_stdout.compat())
|
||||
.lines()
|
||||
.inspect(move |result| {
|
||||
if let Ok(line) = result {
|
||||
callback(line, LineDirection::Stdout);
|
||||
}
|
||||
}),
|
||||
)
|
||||
as std::pin::Pin<Box<dyn futures::Stream<Item = std::io::Result<String>> + Send>>
|
||||
} else {
|
||||
Box::pin(BufReader::new(child_stdout.compat()).lines())
|
||||
};
|
||||
|
||||
// Create a sink that writes lines (with newlines) to stdin with optional debug logging
|
||||
let outgoing_sink = if let Some(callback) = self.debug_callback.clone() {
|
||||
Box::pin(futures::sink::unfold(
|
||||
(child_stdin.compat_write(), callback),
|
||||
async move |(mut writer, callback), line: String| {
|
||||
callback(&line, LineDirection::Stdin);
|
||||
let mut bytes = line.into_bytes();
|
||||
bytes.push(b'\n');
|
||||
writer.write_all(&bytes).await?;
|
||||
Ok::<_, std::io::Error>((writer, callback))
|
||||
},
|
||||
))
|
||||
as std::pin::Pin<Box<dyn futures::Sink<String, Error = std::io::Error> + Send>>
|
||||
} else {
|
||||
Box::pin(futures::sink::unfold(
|
||||
child_stdin.compat_write(),
|
||||
async move |mut writer, line: String| {
|
||||
let mut bytes = line.into_bytes();
|
||||
bytes.push(b'\n');
|
||||
writer.write_all(&bytes).await?;
|
||||
Ok::<_, std::io::Error>(writer)
|
||||
},
|
||||
))
|
||||
};
|
||||
|
||||
// Race the protocol against child process exit
|
||||
// If the child exits early (e.g., with an error), we return that error
|
||||
let protocol_future = sacp::ConnectTo::<Counterpart>::connect_to(
|
||||
sacp::Lines::new(outgoing_sink, incoming_lines),
|
||||
client,
|
||||
);
|
||||
|
||||
tokio::select! {
|
||||
result = protocol_future => result,
|
||||
result = child_monitor => result,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl AcpAgent {
|
||||
/// Create an `AcpAgent` from an iterator of command-line arguments.
|
||||
///
|
||||
/// Leading arguments of the form `NAME=value` are parsed as environment variables.
|
||||
/// The first non-env argument is the command, and the rest are arguments.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// # use sacp_tokio::AcpAgent;
|
||||
/// let agent = AcpAgent::from_args([
|
||||
/// "RUST_LOG=debug",
|
||||
/// "cargo",
|
||||
/// "run",
|
||||
/// "-p",
|
||||
/// "my-crate",
|
||||
/// ]).unwrap();
|
||||
/// ```
|
||||
pub fn from_args<I, T>(args: I) -> Result<Self, sacp::Error>
|
||||
where
|
||||
I: IntoIterator<Item = T>,
|
||||
T: ToString,
|
||||
{
|
||||
let args: Vec<String> = args.into_iter().map(|s| s.to_string()).collect();
|
||||
|
||||
if args.is_empty() {
|
||||
return Err(sacp::util::internal_error("Arguments cannot be empty"));
|
||||
}
|
||||
|
||||
let mut env = vec![];
|
||||
let mut command_idx = 0;
|
||||
|
||||
// Parse leading FOO=bar arguments as environment variables
|
||||
for (i, arg) in args.iter().enumerate() {
|
||||
if let Some((name, value)) = parse_env_var(arg) {
|
||||
env.push(sacp::schema::EnvVariable::new(name, value));
|
||||
command_idx = i + 1;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if command_idx >= args.len() {
|
||||
return Err(sacp::util::internal_error(
|
||||
"No command found (only environment variables provided)",
|
||||
));
|
||||
}
|
||||
|
||||
let command = PathBuf::from(&args[command_idx]);
|
||||
let cmd_args = args[command_idx + 1..].to_vec();
|
||||
|
||||
// Generate a name from the command
|
||||
let name = command
|
||||
.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.unwrap_or("agent")
|
||||
.to_string();
|
||||
|
||||
Ok(AcpAgent {
|
||||
server: sacp::schema::McpServer::Stdio(
|
||||
sacp::schema::McpServerStdio::new(name, command)
|
||||
.args(cmd_args)
|
||||
.env(env),
|
||||
),
|
||||
debug_callback: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse a string as an environment variable assignment (NAME=value).
|
||||
/// Returns None if it doesn't match the pattern.
|
||||
fn parse_env_var(s: &str) -> Option<(String, String)> {
|
||||
// Must contain '=' and the part before must be a valid env var name
|
||||
let eq_pos = s.find('=')?;
|
||||
if eq_pos == 0 {
|
||||
return None;
|
||||
}
|
||||
|
||||
let name = &s[..eq_pos];
|
||||
let value = &s[eq_pos + 1..];
|
||||
|
||||
// Env var names must start with a letter or underscore, and contain only
|
||||
// alphanumeric characters and underscores
|
||||
let mut chars = name.chars();
|
||||
let first = chars.next()?;
|
||||
if !first.is_ascii_alphabetic() && first != '_' {
|
||||
return None;
|
||||
}
|
||||
if !chars.all(|c| c.is_ascii_alphanumeric() || c == '_') {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some((name.to_string(), value.to_string()))
|
||||
}
|
||||
|
||||
impl FromStr for AcpAgent {
|
||||
type Err = sacp::Error;
|
||||
|
||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||
let trimmed = s.trim();
|
||||
|
||||
// If it starts with '{', try to parse as JSON
|
||||
if trimmed.starts_with('{') {
|
||||
let server: sacp::schema::McpServer = serde_json::from_str(trimmed)
|
||||
.map_err(|e| sacp::util::internal_error(format!("Failed to parse JSON: {}", e)))?;
|
||||
return Ok(Self {
|
||||
server,
|
||||
debug_callback: None,
|
||||
});
|
||||
}
|
||||
|
||||
// Otherwise, parse as a command string
|
||||
let parts = shell_words::split(trimmed)
|
||||
.map_err(|e| sacp::util::internal_error(format!("Failed to parse command: {}", e)))?;
|
||||
|
||||
Self::from_args(parts)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_parse_simple_command() {
|
||||
let agent = AcpAgent::from_str("python agent.py").unwrap();
|
||||
match agent.server {
|
||||
sacp::schema::McpServer::Stdio(stdio) => {
|
||||
assert_eq!(stdio.name, "python");
|
||||
assert_eq!(stdio.command, PathBuf::from("python"));
|
||||
assert_eq!(stdio.args, vec!["agent.py"]);
|
||||
assert!(stdio.env.is_empty());
|
||||
}
|
||||
_ => panic!("Expected Stdio variant"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_command_with_args() {
|
||||
let agent = AcpAgent::from_str("node server.js --port 8080 --verbose").unwrap();
|
||||
match agent.server {
|
||||
sacp::schema::McpServer::Stdio(stdio) => {
|
||||
assert_eq!(stdio.name, "node");
|
||||
assert_eq!(stdio.command, PathBuf::from("node"));
|
||||
assert_eq!(stdio.args, vec!["server.js", "--port", "8080", "--verbose"]);
|
||||
assert!(stdio.env.is_empty());
|
||||
}
|
||||
_ => panic!("Expected Stdio variant"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_command_with_quotes() {
|
||||
let agent = AcpAgent::from_str(r#"python "my agent.py" --name "Test Agent""#).unwrap();
|
||||
match agent.server {
|
||||
sacp::schema::McpServer::Stdio(stdio) => {
|
||||
assert_eq!(stdio.name, "python");
|
||||
assert_eq!(stdio.command, PathBuf::from("python"));
|
||||
assert_eq!(stdio.args, vec!["my agent.py", "--name", "Test Agent"]);
|
||||
assert!(stdio.env.is_empty());
|
||||
}
|
||||
_ => panic!("Expected Stdio variant"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_json_stdio() {
|
||||
let json = r#"{
|
||||
"type": "stdio",
|
||||
"name": "my-agent",
|
||||
"command": "/usr/bin/python",
|
||||
"args": ["agent.py", "--verbose"],
|
||||
"env": []
|
||||
}"#;
|
||||
let agent = AcpAgent::from_str(json).unwrap();
|
||||
match agent.server {
|
||||
sacp::schema::McpServer::Stdio(stdio) => {
|
||||
assert_eq!(stdio.name, "my-agent");
|
||||
assert_eq!(stdio.command, PathBuf::from("/usr/bin/python"));
|
||||
assert_eq!(stdio.args, vec!["agent.py", "--verbose"]);
|
||||
assert!(stdio.env.is_empty());
|
||||
}
|
||||
_ => panic!("Expected Stdio variant"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_json_http() {
|
||||
let json = r#"{
|
||||
"type": "http",
|
||||
"name": "remote-agent",
|
||||
"url": "https://example.com/agent",
|
||||
"headers": []
|
||||
}"#;
|
||||
let agent = AcpAgent::from_str(json).unwrap();
|
||||
match agent.server {
|
||||
sacp::schema::McpServer::Http(http) => {
|
||||
assert_eq!(http.name, "remote-agent");
|
||||
assert_eq!(http.url, "https://example.com/agent");
|
||||
assert!(http.headers.is_empty());
|
||||
}
|
||||
_ => panic!("Expected Http variant"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_append_limited_utf8_truncates_ascii() {
|
||||
let mut output = String::new();
|
||||
let truncated = append_limited_utf8(&mut output, "abcdefghij", 6);
|
||||
assert!(truncated);
|
||||
assert_eq!(output, "efghij");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_append_limited_utf8_keeps_char_boundaries() {
|
||||
let mut output = String::new();
|
||||
let truncated = append_limited_utf8(&mut output, "A中文B", 5);
|
||||
assert!(truncated);
|
||||
assert_eq!(output, "文B");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
//! Tokio-based utilities for SACP
|
||||
//!
|
||||
//! This crate provides higher-level functionality for working with SACP
|
||||
//! that requires the Tokio async runtime, such as spawning agent processes
|
||||
//! and creating connections.
|
||||
|
||||
mod acp_agent;
|
||||
|
||||
pub use acp_agent::{AcpAgent, LineDirection};
|
||||
use sacp::{ByteStreams, Role, ConnectTo};
|
||||
use std::sync::Arc;
|
||||
use tokio_util::compat::{TokioAsyncReadCompatExt, TokioAsyncWriteCompatExt};
|
||||
|
||||
pub struct Stdio {
|
||||
debug_callback: Option<Arc<dyn Fn(&str, LineDirection) + Send + Sync + 'static>>,
|
||||
}
|
||||
|
||||
impl Stdio {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
debug_callback: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_debug<F>(mut self, callback: F) -> Self
|
||||
where
|
||||
F: Fn(&str, LineDirection) + Send + Sync + 'static,
|
||||
{
|
||||
self.debug_callback = Some(Arc::new(callback));
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for Stdio {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl<Counterpart: Role> ConnectTo<Counterpart> for Stdio {
|
||||
async fn connect_to(self, client: impl ConnectTo<Counterpart::Counterpart>) -> Result<(), sacp::Error> {
|
||||
if let Some(callback) = self.debug_callback {
|
||||
use futures::AsyncBufReadExt;
|
||||
use futures::AsyncWriteExt;
|
||||
use futures::StreamExt;
|
||||
use futures::io::BufReader;
|
||||
|
||||
// With debug: use Lines with interception
|
||||
let stdin = tokio::io::stdin();
|
||||
let stdout = tokio::io::stdout();
|
||||
|
||||
// Convert stdio to line streams with debug inspection
|
||||
let incoming_callback = callback.clone();
|
||||
let incoming_lines = Box::pin(BufReader::new(stdin.compat()).lines().inspect(
|
||||
move |result| {
|
||||
if let Ok(line) = result {
|
||||
incoming_callback(line, LineDirection::Stdin);
|
||||
}
|
||||
},
|
||||
))
|
||||
as std::pin::Pin<Box<dyn futures::Stream<Item = std::io::Result<String>> + Send>>;
|
||||
|
||||
// Create a sink that writes lines with debug logging
|
||||
let outgoing_sink = Box::pin(futures::sink::unfold(
|
||||
(stdout.compat_write(), callback),
|
||||
async move |(mut writer, callback), line: String| {
|
||||
callback(&line, LineDirection::Stdout);
|
||||
let mut bytes = line.into_bytes();
|
||||
bytes.push(b'\n');
|
||||
writer.write_all(&bytes).await?;
|
||||
Ok::<_, std::io::Error>((writer, callback))
|
||||
},
|
||||
))
|
||||
as std::pin::Pin<Box<dyn futures::Sink<String, Error = std::io::Error> + Send>>;
|
||||
|
||||
ConnectTo::<Counterpart>::connect_to(sacp::Lines::new(outgoing_sink, incoming_lines), client)
|
||||
.await
|
||||
} else {
|
||||
// Without debug: use simple ByteStreams
|
||||
ConnectTo::<Counterpart>::connect_to(
|
||||
ByteStreams::new(
|
||||
tokio::io::stdout().compat_write(),
|
||||
tokio::io::stdin().compat(),
|
||||
),
|
||||
client,
|
||||
)
|
||||
.await
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
//! Integration test for AcpAgent debug logging
|
||||
|
||||
use sacp::{Client, ConnectTo};
|
||||
use sacp::schema::InitializeRequest;
|
||||
use sacp_test::test_binaries::elizacp;
|
||||
use sacp_tokio::LineDirection;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
/// Test helper to receive a JSON-RPC response
|
||||
async fn recv<T: sacp::JsonRpcResponse + Send>(
|
||||
response: sacp::SentRequest<T>,
|
||||
) -> Result<T, sacp::Error> {
|
||||
let (tx, rx) = tokio::sync::oneshot::channel();
|
||||
response.on_receiving_result(async move |result| {
|
||||
tx.send(result).map_err(|_| sacp::Error::internal_error())
|
||||
})?;
|
||||
rx.await.map_err(|_| sacp::Error::internal_error())?
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_acp_agent_debug_callback() -> Result<(), Box<dyn std::error::Error>> {
|
||||
use tokio::io::duplex;
|
||||
use tokio_util::compat::{TokioAsyncReadCompatExt, TokioAsyncWriteCompatExt};
|
||||
|
||||
// Collect debug output
|
||||
#[derive(Debug, Clone, Default)]
|
||||
struct DebugLog {
|
||||
lines: Arc<Mutex<Vec<(String, LineDirection)>>>,
|
||||
}
|
||||
|
||||
impl DebugLog {
|
||||
fn log(&self, line: &str, direction: LineDirection) {
|
||||
self.lines
|
||||
.lock()
|
||||
.unwrap()
|
||||
.push((line.to_string(), direction));
|
||||
}
|
||||
|
||||
fn get_lines(&self) -> Vec<(String, LineDirection)> {
|
||||
self.lines.lock().unwrap().clone()
|
||||
}
|
||||
}
|
||||
|
||||
let debug_log = DebugLog::default();
|
||||
|
||||
// Create an agent that runs elizacp
|
||||
let agent = elizacp().with_debug({
|
||||
let debug_log = debug_log.clone();
|
||||
move |line, direction| {
|
||||
debug_log.log(line, direction);
|
||||
}
|
||||
});
|
||||
|
||||
// Set up client <-> agent communication
|
||||
let (client_out, agent_in) = duplex(1024);
|
||||
let (agent_out, client_in) = duplex(1024);
|
||||
|
||||
let transport = sacp::ByteStreams::new(client_out.compat_write(), client_in.compat());
|
||||
|
||||
Client.builder()
|
||||
.name("test-client")
|
||||
.with_spawned(|_cx| async move {
|
||||
ConnectTo::<Client>::connect_to(
|
||||
agent,
|
||||
sacp::ByteStreams::new(agent_out.compat_write(), agent_in.compat()),
|
||||
)
|
||||
.await
|
||||
})
|
||||
.connect_with(transport, async |connection_to_client| {
|
||||
// Send an initialize request
|
||||
let _init_response = recv(connection_to_client.send_request(InitializeRequest::new(
|
||||
sacp::schema::ProtocolVersion::LATEST,
|
||||
)))
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
})
|
||||
.await?;
|
||||
|
||||
// Verify debug output was captured
|
||||
let logged_lines = debug_log.get_lines();
|
||||
|
||||
// Should have at least some stdin and stdout lines
|
||||
let stdin_count = logged_lines
|
||||
.iter()
|
||||
.filter(|(_, dir)| *dir == LineDirection::Stdin)
|
||||
.count();
|
||||
let stdout_count = logged_lines
|
||||
.iter()
|
||||
.filter(|(_, dir)| *dir == LineDirection::Stdout)
|
||||
.count();
|
||||
|
||||
assert!(
|
||||
stdin_count > 0,
|
||||
"Expected at least one stdin line, got {}",
|
||||
stdin_count
|
||||
);
|
||||
assert!(
|
||||
stdout_count > 0,
|
||||
"Expected at least one stdout line, got {}",
|
||||
stdout_count
|
||||
);
|
||||
|
||||
// Check that we logged the initialize request (contains "initialize" method)
|
||||
let has_initialize_request = logged_lines.iter().any(|(line, dir)| {
|
||||
*dir == LineDirection::Stdin && line.contains("\"method\":\"initialize\"")
|
||||
});
|
||||
assert!(
|
||||
has_initialize_request,
|
||||
"Expected to find initialize request in debug log"
|
||||
);
|
||||
|
||||
// Check that we logged the initialize response (contains result field)
|
||||
let has_initialize_response = logged_lines
|
||||
.iter()
|
||||
.any(|(line, dir)| *dir == LineDirection::Stdout && line.contains("\"result\""));
|
||||
assert!(
|
||||
has_initialize_response,
|
||||
"Expected to find initialize response in debug log"
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||