diff --git a/.github/workflows/bundle-desktop-intel.yml b/.github/workflows/bundle-desktop-intel.yml index ab8208d2..ee69ed7e 100644 --- a/.github/workflows/bundle-desktop-intel.yml +++ b/.github/workflows/bundle-desktop-intel.yml @@ -82,7 +82,8 @@ jobs: - name: Checkout code uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 with: - ref: ${{ inputs.ref }} + # Only pass ref if it's explicitly set, otherwise let checkout action use its default behavior + ref: ${{ inputs.ref != '' && inputs.ref || '' }} fetch-depth: 0 # Update versions before build diff --git a/.github/workflows/bundle-desktop-linux.yml b/.github/workflows/bundle-desktop-linux.yml index 7bf7a45d..f13a15de 100644 --- a/.github/workflows/bundle-desktop-linux.yml +++ b/.github/workflows/bundle-desktop-linux.yml @@ -28,7 +28,8 @@ jobs: - name: Checkout repository uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # pin@v4 with: - ref: ${{ inputs.ref }} + # Only pass ref if it's explicitly set, otherwise let checkout action use its default behavior + ref: ${{ inputs.ref != '' && inputs.ref || '' }} fetch-depth: 0 # 2) Update versions before build diff --git a/.github/workflows/bundle-desktop-windows.yml b/.github/workflows/bundle-desktop-windows.yml index 29877fea..6dd21d93 100644 --- a/.github/workflows/bundle-desktop-windows.yml +++ b/.github/workflows/bundle-desktop-windows.yml @@ -45,7 +45,8 @@ jobs: - name: Checkout repository uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # pin@v4 with: - ref: ${{ inputs.ref }} + # Only pass ref if it's explicitly set, otherwise let checkout action use its default behavior + ref: ${{ inputs.ref != '' && inputs.ref || '' }} fetch-depth: 0 # 2) Configure AWS credentials for code signing diff --git a/crates/goose-server/src/routes/mod.rs b/crates/goose-server/src/routes/mod.rs index c5e662ec..b757baa0 100644 --- a/crates/goose-server/src/routes/mod.rs +++ b/crates/goose-server/src/routes/mod.rs @@ -5,6 +5,7 @@ pub mod config_management; pub mod context; pub mod extension; pub mod health; +pub mod project; pub mod recipe; pub mod reply; pub mod schedule; @@ -27,4 +28,5 @@ pub fn configure(state: Arc) -> Router { .merge(recipe::routes(state.clone())) .merge(session::routes(state.clone())) .merge(schedule::routes(state.clone())) + .merge(project::routes(state.clone())) } diff --git a/crates/goose-server/src/routes/project.rs b/crates/goose-server/src/routes/project.rs new file mode 100644 index 00000000..a83c1a01 --- /dev/null +++ b/crates/goose-server/src/routes/project.rs @@ -0,0 +1,358 @@ +use super::utils::verify_secret_key; +use std::sync::Arc; + +use crate::state::AppState; +use axum::{ + extract::{Path, State}, + http::{HeaderMap, StatusCode}, + routing::{delete, get, post, put}, + Json, Router, +}; +use goose::project::{Project, ProjectMetadata}; +use serde::{Deserialize, Serialize}; +use utoipa::ToSchema; + +#[derive(Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct CreateProjectRequest { + /// Display name of the project + pub name: String, + /// Optional description of the project + pub description: Option, + /// Default working directory for sessions in this project + #[schema(value_type = String)] + pub default_directory: std::path::PathBuf, +} + +#[derive(Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct UpdateProjectRequest { + /// Display name of the project + pub name: Option, + /// Optional description of the project + pub description: Option>, + /// Default working directory for sessions in this project + #[schema(value_type = String)] + pub default_directory: Option, +} + +#[derive(Serialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct ProjectListResponse { + /// List of available project metadata objects + pub projects: Vec, +} + +#[derive(Serialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct ProjectResponse { + /// Project details + pub project: Project, +} + +#[utoipa::path( + get, + path = "/projects", + responses( + (status = 200, description = "List of available projects retrieved successfully", body = ProjectListResponse), + (status = 401, description = "Unauthorized - Invalid or missing API key"), + (status = 500, description = "Internal server error") + ), + security( + ("api_key" = []) + ), + tag = "Project Management" +)] +// List all available projects +async fn list_projects( + State(state): State>, + headers: HeaderMap, +) -> Result, StatusCode> { + verify_secret_key(&headers, &state)?; + + let projects = + goose::project::list_projects().map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + Ok(Json(ProjectListResponse { projects })) +} + +#[utoipa::path( + get, + path = "/projects/{project_id}", + params( + ("project_id" = String, Path, description = "Unique identifier for the project") + ), + responses( + (status = 200, description = "Project details retrieved successfully", body = ProjectResponse), + (status = 401, description = "Unauthorized - Invalid or missing API key"), + (status = 404, description = "Project not found"), + (status = 500, description = "Internal server error") + ), + security( + ("api_key" = []) + ), + tag = "Project Management" +)] +// Get a specific project details +async fn get_project_details( + State(state): State>, + headers: HeaderMap, + Path(project_id): Path, +) -> Result, StatusCode> { + verify_secret_key(&headers, &state)?; + + let project = goose::project::get_project(&project_id).map_err(|e| { + if e.to_string().contains("not found") { + StatusCode::NOT_FOUND + } else { + StatusCode::INTERNAL_SERVER_ERROR + } + })?; + + Ok(Json(ProjectResponse { project })) +} + +#[utoipa::path( + post, + path = "/projects", + request_body = CreateProjectRequest, + responses( + (status = 201, description = "Project created successfully", body = ProjectResponse), + (status = 401, description = "Unauthorized - Invalid or missing API key"), + (status = 400, description = "Invalid request - Bad input parameters"), + (status = 500, description = "Internal server error") + ), + security( + ("api_key" = []) + ), + tag = "Project Management" +)] +// Create a new project +async fn create_project( + State(state): State>, + headers: HeaderMap, + Json(create_req): Json, +) -> Result, StatusCode> { + verify_secret_key(&headers, &state)?; + + // Validate input + if create_req.name.trim().is_empty() { + return Err(StatusCode::BAD_REQUEST); + } + + let project = goose::project::create_project( + create_req.name, + create_req.description, + create_req.default_directory, + ) + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + Ok(Json(ProjectResponse { project })) +} + +#[utoipa::path( + put, + path = "/projects/{project_id}", + params( + ("project_id" = String, Path, description = "Unique identifier for the project") + ), + request_body = UpdateProjectRequest, + responses( + (status = 200, description = "Project updated successfully", body = ProjectResponse), + (status = 401, description = "Unauthorized - Invalid or missing API key"), + (status = 404, description = "Project not found"), + (status = 500, description = "Internal server error") + ), + security( + ("api_key" = []) + ), + tag = "Project Management" +)] +// Update a project +async fn update_project( + State(state): State>, + headers: HeaderMap, + Path(project_id): Path, + Json(update_req): Json, +) -> Result, StatusCode> { + verify_secret_key(&headers, &state)?; + + let project = goose::project::update_project( + &project_id, + update_req.name, + update_req.description, + update_req.default_directory, + ) + .map_err(|e| { + if e.to_string().contains("not found") { + StatusCode::NOT_FOUND + } else { + StatusCode::INTERNAL_SERVER_ERROR + } + })?; + + Ok(Json(ProjectResponse { project })) +} + +#[utoipa::path( + delete, + path = "/projects/{project_id}", + params( + ("project_id" = String, Path, description = "Unique identifier for the project") + ), + responses( + (status = 204, description = "Project deleted successfully"), + (status = 401, description = "Unauthorized - Invalid or missing API key"), + (status = 404, description = "Project not found"), + (status = 500, description = "Internal server error") + ), + security( + ("api_key" = []) + ), + tag = "Project Management" +)] +// Delete a project +async fn delete_project( + State(state): State>, + headers: HeaderMap, + Path(project_id): Path, +) -> Result { + verify_secret_key(&headers, &state)?; + + goose::project::delete_project(&project_id).map_err(|e| { + if e.to_string().contains("not found") { + StatusCode::NOT_FOUND + } else { + StatusCode::INTERNAL_SERVER_ERROR + } + })?; + + Ok(StatusCode::NO_CONTENT) +} + +#[utoipa::path( + post, + path = "/projects/{project_id}/sessions/{session_id}", + params( + ("project_id" = String, Path, description = "Unique identifier for the project"), + ("session_id" = String, Path, description = "Unique identifier for the session to add") + ), + responses( + (status = 204, description = "Session added to project successfully"), + (status = 401, description = "Unauthorized - Invalid or missing API key"), + (status = 404, description = "Project or session not found"), + (status = 500, description = "Internal server error") + ), + security( + ("api_key" = []) + ), + tag = "Project Management" +)] +// Add session to project +async fn add_session_to_project( + State(state): State>, + headers: HeaderMap, + Path((project_id, session_id)): Path<(String, String)>, +) -> Result { + verify_secret_key(&headers, &state)?; + + // Add the session to project + goose::project::add_session_to_project(&project_id, &session_id).map_err(|e| { + if e.to_string().contains("not found") { + StatusCode::NOT_FOUND + } else { + StatusCode::INTERNAL_SERVER_ERROR + } + })?; + + // Also update session metadata to include the project_id + let session_path = + goose::session::get_path(goose::session::Identifier::Name(session_id.clone())) + .map_err(|_| StatusCode::NOT_FOUND)?; + let mut metadata = + goose::session::read_metadata(&session_path).map_err(|_| StatusCode::NOT_FOUND)?; + metadata.project_id = Some(project_id); + + tokio::task::spawn(async move { + if let Err(e) = goose::session::update_metadata(&session_path, &metadata).await { + tracing::error!("Failed to update session metadata: {}", e); + } + }); + + Ok(StatusCode::NO_CONTENT) +} + +#[utoipa::path( + delete, + path = "/projects/{project_id}/sessions/{session_id}", + params( + ("project_id" = String, Path, description = "Unique identifier for the project"), + ("session_id" = String, Path, description = "Unique identifier for the session to remove") + ), + responses( + (status = 204, description = "Session removed from project successfully"), + (status = 401, description = "Unauthorized - Invalid or missing API key"), + (status = 404, description = "Project or session not found"), + (status = 500, description = "Internal server error") + ), + security( + ("api_key" = []) + ), + tag = "Project Management" +)] +// Remove session from project +async fn remove_session_from_project( + State(state): State>, + headers: HeaderMap, + Path((project_id, session_id)): Path<(String, String)>, +) -> Result { + verify_secret_key(&headers, &state)?; + + // Remove from project + goose::project::remove_session_from_project(&project_id, &session_id).map_err(|e| { + if e.to_string().contains("not found") { + StatusCode::NOT_FOUND + } else { + StatusCode::INTERNAL_SERVER_ERROR + } + })?; + + // Also update session metadata to remove the project_id + let session_path = + goose::session::get_path(goose::session::Identifier::Name(session_id.clone())) + .map_err(|_| StatusCode::NOT_FOUND)?; + let mut metadata = + goose::session::read_metadata(&session_path).map_err(|_| StatusCode::NOT_FOUND)?; + + // Only update if this session was actually in this project + if metadata.project_id.as_deref() == Some(&project_id) { + metadata.project_id = None; + + tokio::task::spawn(async move { + if let Err(e) = goose::session::update_metadata(&session_path, &metadata).await { + tracing::error!("Failed to update session metadata: {}", e); + } + }); + } + + Ok(StatusCode::NO_CONTENT) +} + +// Configure routes for this module +pub fn routes(state: Arc) -> Router { + Router::new() + .route("/projects", get(list_projects)) + .route("/projects", post(create_project)) + .route("/projects/{project_id}", get(get_project_details)) + .route("/projects/{project_id}", put(update_project)) + .route("/projects/{project_id}", delete(delete_project)) + .route( + "/projects/{project_id}/sessions/{session_id}", + post(add_session_to_project), + ) + .route( + "/projects/{project_id}/sessions/{session_id}", + delete(remove_session_from_project), + ) + .with_state(state) +} diff --git a/crates/goose-server/src/routes/session.rs b/crates/goose-server/src/routes/session.rs index ca0d5970..8ed509e4 100644 --- a/crates/goose-server/src/routes/session.rs +++ b/crates/goose-server/src/routes/session.rs @@ -1,4 +1,6 @@ use super::utils::verify_secret_key; +use chrono::{DateTime, Datelike}; +use std::collections::HashMap; use std::sync::Arc; use crate::state::AppState; @@ -13,6 +15,7 @@ use goose::session; use goose::session::info::{get_valid_sorted_sessions, SessionInfo, SortOrder}; use goose::session::SessionMetadata; use serde::Serialize; +use tracing::{error, info}; use utoipa::ToSchema; #[derive(Serialize, ToSchema)] @@ -33,6 +36,29 @@ pub struct SessionHistoryResponse { messages: Vec, } +#[derive(Serialize, ToSchema, Debug)] +#[serde(rename_all = "camelCase")] +pub struct SessionInsights { + /// Total number of sessions + total_sessions: usize, + /// Most active working directories with session counts + most_active_dirs: Vec<(String, usize)>, + /// Average session duration in minutes + avg_session_duration: f64, + /// Total tokens used across all sessions + total_tokens: i64, + /// Activity trend for the last 7 days + recent_activity: Vec<(String, usize)>, +} + +#[derive(Serialize, ToSchema, Debug)] +#[serde(rename_all = "camelCase")] +pub struct ActivityHeatmapCell { + pub week: usize, + pub day: usize, + pub count: usize, +} + #[utoipa::path( get, path = "/sessions", @@ -106,10 +132,174 @@ async fn get_session_history( })) } +#[utoipa::path( + get, + path = "/sessions/insights", + responses( + (status = 200, description = "Session insights retrieved successfully", body = SessionInsights), + (status = 401, description = "Unauthorized - Invalid or missing API key"), + (status = 500, description = "Internal server error") + ), + security( + ("api_key" = []) + ), + tag = "Session Management" +)] +async fn get_session_insights( + State(state): State>, + headers: HeaderMap, +) -> Result, StatusCode> { + info!("Received request for session insights"); + + verify_secret_key(&headers, &state)?; + + let sessions = get_valid_sorted_sessions(SortOrder::Descending).map_err(|e| { + error!("Failed to get session info: {:?}", e); + StatusCode::INTERNAL_SERVER_ERROR + })?; + + // Filter out sessions without descriptions + let sessions: Vec = sessions + .into_iter() + .filter(|session| !session.metadata.description.is_empty()) + .collect(); + + info!("Found {} sessions with descriptions", sessions.len()); + + // Calculate insights + let total_sessions = sessions.len(); + + // Debug: Log if we have very few sessions, which might indicate filtering issues + if total_sessions == 0 { + info!("Warning: No sessions found with descriptions"); + } + + // Track directory usage + let mut dir_counts: HashMap = HashMap::new(); + let mut total_duration = 0.0; + let mut total_tokens = 0; + let mut activity_by_date: HashMap = HashMap::new(); + + for session in &sessions { + // Track directory usage + let dir = session.metadata.working_dir.to_string_lossy().to_string(); + *dir_counts.entry(dir).or_insert(0) += 1; + + // Track tokens - only add positive values to prevent negative totals + if let Some(tokens) = session.metadata.accumulated_total_tokens { + if tokens > 0 { + total_tokens += tokens as i64; + } else if tokens < 0 { + // Log negative token values for debugging + info!( + "Warning: Session {} has negative accumulated_total_tokens: {}", + session.id, tokens + ); + } + } + + // Track activity by date + if let Ok(date) = DateTime::parse_from_str(&session.modified, "%Y-%m-%d %H:%M:%S UTC") { + let date_str = date.format("%Y-%m-%d").to_string(); + *activity_by_date.entry(date_str).or_insert(0) += 1; + } + + // Calculate session duration from messages + let session_path = session::get_path(session::Identifier::Name(session.id.clone())); + if let Ok(session_path) = session_path { + if let Ok(messages) = session::read_messages(&session_path) { + if let (Some(first), Some(last)) = (messages.first(), messages.last()) { + let duration = (last.created - first.created) as f64 / 60.0; // Convert to minutes + total_duration += duration; + } + } + } + } + + // Get top 3 most active directories + let mut dir_vec: Vec<(String, usize)> = dir_counts.into_iter().collect(); + dir_vec.sort_by(|a, b| b.1.cmp(&a.1)); + let most_active_dirs = dir_vec.into_iter().take(3).collect(); + + // Calculate average session duration + let avg_session_duration = if total_sessions > 0 { + total_duration / total_sessions as f64 + } else { + 0.0 + }; + + // Get last 7 days of activity + let mut activity_vec: Vec<(String, usize)> = activity_by_date.into_iter().collect(); + activity_vec.sort_by(|a, b| b.0.cmp(&a.0)); // Sort by date descending + let recent_activity = activity_vec.into_iter().take(7).collect(); + + let insights = SessionInsights { + total_sessions, + most_active_dirs, + avg_session_duration, + total_tokens, + recent_activity, + }; + + info!("Returning insights: {:?}", insights); + Ok(Json(insights)) +} + +#[utoipa::path( + get, + path = "/sessions/activity-heatmap", + responses( + (status = 200, description = "Activity heatmap data", body = [ActivityHeatmapCell]), + (status = 401, description = "Unauthorized - Invalid or missing API key"), + (status = 500, description = "Internal server error") + ), + security(("api_key" = [])), + tag = "Session Management" +)] +async fn get_activity_heatmap( + State(state): State>, + headers: HeaderMap, +) -> Result>, StatusCode> { + verify_secret_key(&headers, &state)?; + + let sessions = get_valid_sorted_sessions(SortOrder::Descending) + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + // Only sessions with a description + let sessions: Vec = sessions + .into_iter() + .filter(|session| !session.metadata.description.is_empty()) + .collect(); + + // Map: (week, day) -> count + let mut heatmap: std::collections::HashMap<(usize, usize), usize> = + std::collections::HashMap::new(); + + for session in &sessions { + if let Ok(date) = + chrono::NaiveDateTime::parse_from_str(&session.modified, "%Y-%m-%d %H:%M:%S UTC") + { + let date = date.date(); + let week = date.iso_week().week() as usize - 1; // 0-based week + let day = date.weekday().num_days_from_sunday() as usize; // 0=Sun, 6=Sat + *heatmap.entry((week, day)).or_insert(0) += 1; + } + } + + let mut result = Vec::new(); + for ((week, day), count) in heatmap { + result.push(ActivityHeatmapCell { week, day, count }); + } + + Ok(Json(result)) +} + // Configure routes for this module pub fn routes(state: Arc) -> Router { Router::new() .route("/sessions", get(list_sessions)) .route("/sessions/{session_id}", get(get_session_history)) + .route("/sessions/insights", get(get_session_insights)) + .route("/sessions/activity-heatmap", get(get_activity_heatmap)) .with_state(state) } diff --git a/crates/goose/src/lib.rs b/crates/goose/src/lib.rs index 80e8e1ab..1a8bf107 100644 --- a/crates/goose/src/lib.rs +++ b/crates/goose/src/lib.rs @@ -4,6 +4,7 @@ pub mod context_mgmt; pub mod message; pub mod model; pub mod permission; +pub mod project; pub mod prompt_template; pub mod providers; pub mod recipe; diff --git a/crates/goose/src/project/mod.rs b/crates/goose/src/project/mod.rs new file mode 100644 index 00000000..601b47df --- /dev/null +++ b/crates/goose/src/project/mod.rs @@ -0,0 +1,68 @@ +pub mod storage; + +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use std::path::PathBuf; +use utoipa::ToSchema; + +/// Main project structure that holds project metadata and associated sessions +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct Project { + /// Unique identifier for the project + pub id: String, + /// Display name of the project + pub name: String, + /// Optional description of the project + pub description: Option, + /// Default working directory for sessions in this project + #[schema(value_type = String, example = "/home/user/projects/my-project")] + pub default_directory: PathBuf, + /// When the project was created + pub created_at: DateTime, + /// When the project was last updated + pub updated_at: DateTime, + /// List of session IDs associated with this project + pub session_ids: Vec, +} + +/// Simplified project metadata for listing +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct ProjectMetadata { + /// Unique identifier for the project + pub id: String, + /// Display name of the project + pub name: String, + /// Optional description of the project + pub description: Option, + /// Default working directory for sessions in this project + #[schema(value_type = String)] + pub default_directory: PathBuf, + /// Number of sessions in this project + pub session_count: usize, + /// When the project was created + pub created_at: DateTime, + /// When the project was last updated + pub updated_at: DateTime, +} + +impl From<&Project> for ProjectMetadata { + fn from(project: &Project) -> Self { + ProjectMetadata { + id: project.id.clone(), + name: project.name.clone(), + description: project.description.clone(), + default_directory: project.default_directory.clone(), + session_count: project.session_ids.len(), + created_at: project.created_at, + updated_at: project.updated_at, + } + } +} + +// Re-export storage functions +pub use storage::{ + add_session_to_project, create_project, delete_project, ensure_project_dir, get_project, + list_projects, remove_session_from_project, update_project, +}; diff --git a/crates/goose/src/project/storage.rs b/crates/goose/src/project/storage.rs new file mode 100644 index 00000000..ef8e70dc --- /dev/null +++ b/crates/goose/src/project/storage.rs @@ -0,0 +1,239 @@ +use crate::project::{Project, ProjectMetadata}; +use anyhow::{anyhow, Context, Result}; +use chrono::Utc; +use etcetera::{choose_app_strategy, AppStrategy, AppStrategyArgs}; +use serde_json; +use std::fs::{self, File}; +use std::io::Write; +use std::path::PathBuf; +use tracing::{error, info}; + +const APP_NAME: &str = "goose"; + +/// Ensure the project directory exists and return its path +pub fn ensure_project_dir() -> Result { + let app_strategy = AppStrategyArgs { + top_level_domain: "Block".to_string(), + author: "Block".to_string(), + app_name: APP_NAME.to_string(), + }; + + let data_dir = choose_app_strategy(app_strategy) + .context("goose requires a home dir")? + .data_dir() + .join("projects"); + + if !data_dir.exists() { + fs::create_dir_all(&data_dir)?; + } + + Ok(data_dir) +} + +/// Generate a unique project ID +fn generate_project_id() -> String { + use rand::Rng; + let timestamp = Utc::now().timestamp(); + let random: u32 = rand::thread_rng().gen(); + format!("proj_{}_{}", timestamp, random) +} + +/// Get the path for a specific project file +fn get_project_path(project_id: &str) -> Result { + let project_dir = ensure_project_dir()?; + Ok(project_dir.join(format!("{}.json", project_id))) +} + +/// Create a new project +pub fn create_project( + name: String, + description: Option, + default_directory: PathBuf, +) -> Result { + let project_dir = ensure_project_dir()?; + + // Validate the default directory exists + if !default_directory.exists() { + return Err(anyhow!( + "Default directory does not exist: {:?}", + default_directory + )); + } + + let now = Utc::now(); + let project = Project { + id: generate_project_id(), + name, + description, + default_directory, + created_at: now, + updated_at: now, + session_ids: Vec::new(), + }; + + // Save the project + let project_path = project_dir.join(format!("{}.json", project.id)); + let mut file = File::create(&project_path)?; + let json = serde_json::to_string_pretty(&project)?; + file.write_all(json.as_bytes())?; + + info!("Created project {} at {:?}", project.id, project_path); + Ok(project) +} + +/// Update an existing project +pub fn update_project( + project_id: &str, + name: Option, + description: Option>, + default_directory: Option, +) -> Result { + let project_path = get_project_path(project_id)?; + + if !project_path.exists() { + return Err(anyhow!("Project not found: {}", project_id)); + } + + // Read existing project + let mut project: Project = serde_json::from_reader(File::open(&project_path)?)?; + + // Update fields + if let Some(new_name) = name { + project.name = new_name; + } + + if let Some(new_description) = description { + project.description = new_description; + } + + if let Some(new_directory) = default_directory { + if !new_directory.exists() { + return Err(anyhow!( + "Default directory does not exist: {:?}", + new_directory + )); + } + project.default_directory = new_directory; + } + + project.updated_at = Utc::now(); + + // Save updated project + let mut file = File::create(&project_path)?; + let json = serde_json::to_string_pretty(&project)?; + file.write_all(json.as_bytes())?; + + info!("Updated project {}", project_id); + Ok(project) +} + +/// Delete a project (does not delete associated sessions) +pub fn delete_project(project_id: &str) -> Result<()> { + let project_path = get_project_path(project_id)?; + + if !project_path.exists() { + return Err(anyhow!("Project not found: {}", project_id)); + } + + fs::remove_file(&project_path)?; + info!("Deleted project {}", project_id); + Ok(()) +} + +/// List all projects +pub fn list_projects() -> Result> { + let project_dir = ensure_project_dir()?; + let mut projects = Vec::new(); + + if let Ok(entries) = fs::read_dir(&project_dir) { + for entry in entries.flatten() { + let path = entry.path(); + if path.extension().and_then(|s| s.to_str()) == Some("json") { + match serde_json::from_reader::<_, Project>(File::open(&path)?) { + Ok(project) => { + projects.push(ProjectMetadata::from(&project)); + } + Err(e) => { + error!("Failed to read project file {:?}: {}", path, e); + } + } + } + } + } + + // Sort by updated_at descending + projects.sort_by(|a, b| b.updated_at.cmp(&a.updated_at)); + + Ok(projects) +} + +/// Get a specific project +pub fn get_project(project_id: &str) -> Result { + let project_path = get_project_path(project_id)?; + + if !project_path.exists() { + return Err(anyhow!("Project not found: {}", project_id)); + } + + let project: Project = serde_json::from_reader(File::open(&project_path)?)?; + Ok(project) +} + +/// Add a session to a project +pub fn add_session_to_project(project_id: &str, session_id: &str) -> Result<()> { + let project_path = get_project_path(project_id)?; + + if !project_path.exists() { + return Err(anyhow!("Project not found: {}", project_id)); + } + + // Read project + let mut project: Project = serde_json::from_reader(File::open(&project_path)?)?; + + // Check if session already exists in project + if project.session_ids.contains(&session_id.to_string()) { + return Ok(()); // Already added + } + + // Add session and update timestamp + project.session_ids.push(session_id.to_string()); + project.updated_at = Utc::now(); + + // Save updated project + let mut file = File::create(&project_path)?; + let json = serde_json::to_string_pretty(&project)?; + file.write_all(json.as_bytes())?; + + info!("Added session {} to project {}", session_id, project_id); + Ok(()) +} + +/// Remove a session from a project +pub fn remove_session_from_project(project_id: &str, session_id: &str) -> Result<()> { + let project_path = get_project_path(project_id)?; + + if !project_path.exists() { + return Err(anyhow!("Project not found: {}", project_id)); + } + + // Read project + let mut project: Project = serde_json::from_reader(File::open(&project_path)?)?; + + // Remove session + let original_len = project.session_ids.len(); + project.session_ids.retain(|id| id != session_id); + + if project.session_ids.len() == original_len { + return Ok(()); // Session wasn't in project + } + + project.updated_at = Utc::now(); + + // Save updated project + let mut file = File::create(&project_path)?; + let json = serde_json::to_string_pretty(&project)?; + file.write_all(json.as_bytes())?; + + info!("Removed session {} from project {}", session_id, project_id); + Ok(()) +} diff --git a/crates/goose/src/scheduler.rs b/crates/goose/src/scheduler.rs index 20c455de..bd1aee12 100644 --- a/crates/goose/src/scheduler.rs +++ b/crates/goose/src/scheduler.rs @@ -1267,6 +1267,7 @@ async fn run_scheduled_job_internal( working_dir: current_dir.clone(), description: String::new(), schedule_id: Some(job.id.clone()), + project_id: None, message_count: all_session_messages.len(), total_tokens: None, input_tokens: None, diff --git a/crates/goose/src/session/storage.rs b/crates/goose/src/session/storage.rs index 2e8ac684..ad9d0096 100644 --- a/crates/goose/src/session/storage.rs +++ b/crates/goose/src/session/storage.rs @@ -41,6 +41,8 @@ pub struct SessionMetadata { pub description: String, /// ID of the schedule that triggered this session, if any pub schedule_id: Option, + /// ID of the project this session belongs to, if any + pub project_id: Option, /// Number of messages in the session pub message_count: usize, /// The total number of tokens used in the session. Retrieved from the provider's last usage. @@ -68,6 +70,7 @@ impl<'de> Deserialize<'de> for SessionMetadata { description: String, message_count: usize, schedule_id: Option, // For backward compatibility + project_id: Option, // For backward compatibility total_tokens: Option, input_tokens: Option, output_tokens: Option, @@ -89,6 +92,7 @@ impl<'de> Deserialize<'de> for SessionMetadata { description: helper.description, message_count: helper.message_count, schedule_id: helper.schedule_id, + project_id: helper.project_id, total_tokens: helper.total_tokens, input_tokens: helper.input_tokens, output_tokens: helper.output_tokens, @@ -113,6 +117,7 @@ impl SessionMetadata { working_dir, description: String::new(), schedule_id: None, + project_id: None, message_count: 0, total_tokens: None, input_tokens: None, diff --git a/crates/goose/tests/test_support.rs b/crates/goose/tests/test_support.rs index cfea855b..a2a3a2e5 100644 --- a/crates/goose/tests/test_support.rs +++ b/crates/goose/tests/test_support.rs @@ -32,6 +32,7 @@ pub struct ConfigurableMockScheduler { sessions_data: Arc>>>, } +#[allow(dead_code)] impl ConfigurableMockScheduler { pub fn new() -> Self { Self { @@ -404,6 +405,7 @@ pub fn create_test_session_metadata(message_count: usize, working_dir: &str) -> working_dir: PathBuf::from(working_dir), description: "Test session".to_string(), schedule_id: Some("test_job".to_string()), + project_id: None, total_tokens: Some(100), input_tokens: Some(50), output_tokens: Some(50), diff --git a/ui/desktop/.goosehints b/ui/desktop/.goosehints index ab102a4c..08f8e40a 100644 --- a/ui/desktop/.goosehints +++ b/ui/desktop/.goosehints @@ -52,7 +52,7 @@ The Goose Desktop App is an Electron application built with TypeScript, React, a 2. Create a main component file (e.g., `YourFeatureView.tsx`) 3. Add your view type to the `View` type in `App.tsx` 4. Import and add your component to the render section in `App.tsx` -5. Add navigation to your view from other components (e.g., adding a button in `BottomMenu.tsx` or `MoreMenu.tsx`) +5. Add navigation to your view from other components (e.g., adding a new route or button in `App.tsx`) ## State Management diff --git a/ui/desktop/forge.config.ts b/ui/desktop/forge.config.ts index c32ce48f..827e124f 100644 --- a/ui/desktop/forge.config.ts +++ b/ui/desktop/forge.config.ts @@ -12,14 +12,14 @@ let cfg = { certificateFile: process.env.WINDOWS_CERTIFICATE_FILE, signingRole: process.env.WINDOW_SIGNING_ROLE, rfc3161TimeStampServer: 'http://timestamp.digicert.com', - signWithParams: '/fd sha256 /tr http://timestamp.digicert.com /td sha256' + signWithParams: '/fd sha256 /tr http://timestamp.digicert.com /td sha256', }, // Protocol registration protocols: [ { - name: "GooseProtocol", - schemes: ["goose"] - } + name: 'GooseProtocol', + schemes: ['goose'], + }, ], // macOS Info.plist extensions for drag-and-drop support extendInfo: { @@ -44,9 +44,9 @@ let cfg = { osxNotarize: { appleId: process.env['APPLE_ID'], appleIdPassword: process.env['APPLE_ID_PASSWORD'], - teamId: process.env['APPLE_TEAM_ID'] + teamId: process.env['APPLE_TEAM_ID'], }, -} +}; if (process.env['APPLE_ID'] === undefined) { delete cfg.osxNotarize; @@ -62,12 +62,12 @@ module.exports = { config: { repository: { owner: 'block', - name: 'goose' + name: 'goose', }, prerelease: false, - draft: true - } - } + draft: true, + }, + }, ], makers: [ { @@ -76,22 +76,22 @@ module.exports = { config: { arch: process.env.ELECTRON_ARCH === 'x64' ? ['x64'] : ['arm64'], options: { - icon: 'src/images/icon.ico' - } - } + icon: 'src/images/icon.ico', + }, + }, }, { name: '@electron-forge/maker-deb', config: { name: 'Goose', - bin: 'Goose' + bin: 'Goose', }, }, { name: '@electron-forge/maker-rpm', config: { name: 'Goose', - bin: 'Goose' + bin: 'Goose', }, }, ], @@ -102,17 +102,17 @@ module.exports = { build: [ { entry: 'src/main.ts', - config: 'vite.main.config.ts', + config: 'vite.main.config.mts', }, { entry: 'src/preload.ts', - config: 'vite.preload.config.ts', + config: 'vite.preload.config.mts', }, ], renderer: [ { name: 'main_window', - config: 'vite.renderer.config.ts', + config: 'vite.renderer.config.mts', }, ], }, diff --git a/ui/desktop/index.html b/ui/desktop/index.html index 564e1f0e..3f8c1751 100644 --- a/ui/desktop/index.html +++ b/ui/desktop/index.html @@ -2,6 +2,7 @@ + Goose