mirror of
https://github.com/aljazceru/goose.git
synced 2025-12-18 22:54:24 +01:00
UI update with sidebar and settings tabs (#3288)
Co-authored-by: Nahiyan Khan <nahiyan@squareup.com> Co-authored-by: Taylor Ho <taylorkmho@gmail.com> Co-authored-by: Lily Delalande <119957291+lily-de@users.noreply.github.com> Co-authored-by: Spence <spencrmartin@gmail.com> Co-authored-by: spencrmartin <spencermartin@squareup.com> Co-authored-by: Judson Stephenson <Jud@users.noreply.github.com> Co-authored-by: Max Novich <mnovich@squareup.com> Co-authored-by: Best Codes <106822363+The-Best-Codes@users.noreply.github.com> Co-authored-by: caroline-a-mckenzie <cmckenzie@squareup.com> Co-authored-by: Michael Neale <michael.neale@gmail.com>
This commit is contained in:
@@ -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<crate::state::AppState>) -> Router {
|
||||
.merge(recipe::routes(state.clone()))
|
||||
.merge(session::routes(state.clone()))
|
||||
.merge(schedule::routes(state.clone()))
|
||||
.merge(project::routes(state.clone()))
|
||||
}
|
||||
|
||||
358
crates/goose-server/src/routes/project.rs
Normal file
358
crates/goose-server/src/routes/project.rs
Normal file
@@ -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<String>,
|
||||
/// 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<String>,
|
||||
/// Optional description of the project
|
||||
pub description: Option<Option<String>>,
|
||||
/// Default working directory for sessions in this project
|
||||
#[schema(value_type = String)]
|
||||
pub default_directory: Option<std::path::PathBuf>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, ToSchema)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ProjectListResponse {
|
||||
/// List of available project metadata objects
|
||||
pub projects: Vec<ProjectMetadata>,
|
||||
}
|
||||
|
||||
#[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<Arc<AppState>>,
|
||||
headers: HeaderMap,
|
||||
) -> Result<Json<ProjectListResponse>, 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<Arc<AppState>>,
|
||||
headers: HeaderMap,
|
||||
Path(project_id): Path<String>,
|
||||
) -> Result<Json<ProjectResponse>, 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<Arc<AppState>>,
|
||||
headers: HeaderMap,
|
||||
Json(create_req): Json<CreateProjectRequest>,
|
||||
) -> Result<Json<ProjectResponse>, 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<Arc<AppState>>,
|
||||
headers: HeaderMap,
|
||||
Path(project_id): Path<String>,
|
||||
Json(update_req): Json<UpdateProjectRequest>,
|
||||
) -> Result<Json<ProjectResponse>, 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<Arc<AppState>>,
|
||||
headers: HeaderMap,
|
||||
Path(project_id): Path<String>,
|
||||
) -> Result<StatusCode, StatusCode> {
|
||||
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<Arc<AppState>>,
|
||||
headers: HeaderMap,
|
||||
Path((project_id, session_id)): Path<(String, String)>,
|
||||
) -> Result<StatusCode, StatusCode> {
|
||||
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<Arc<AppState>>,
|
||||
headers: HeaderMap,
|
||||
Path((project_id, session_id)): Path<(String, String)>,
|
||||
) -> Result<StatusCode, StatusCode> {
|
||||
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<AppState>) -> 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)
|
||||
}
|
||||
@@ -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<Message>,
|
||||
}
|
||||
|
||||
#[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<Arc<AppState>>,
|
||||
headers: HeaderMap,
|
||||
) -> Result<Json<SessionInsights>, 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<SessionInfo> = 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<String, usize> = HashMap::new();
|
||||
let mut total_duration = 0.0;
|
||||
let mut total_tokens = 0;
|
||||
let mut activity_by_date: HashMap<String, usize> = 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<Arc<AppState>>,
|
||||
headers: HeaderMap,
|
||||
) -> Result<Json<Vec<ActivityHeatmapCell>>, 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<SessionInfo> = 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<AppState>) -> 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)
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
68
crates/goose/src/project/mod.rs
Normal file
68
crates/goose/src/project/mod.rs
Normal file
@@ -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<String>,
|
||||
/// 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<Utc>,
|
||||
/// When the project was last updated
|
||||
pub updated_at: DateTime<Utc>,
|
||||
/// List of session IDs associated with this project
|
||||
pub session_ids: Vec<String>,
|
||||
}
|
||||
|
||||
/// 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<String>,
|
||||
/// 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<Utc>,
|
||||
/// When the project was last updated
|
||||
pub updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
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,
|
||||
};
|
||||
239
crates/goose/src/project/storage.rs
Normal file
239
crates/goose/src/project/storage.rs
Normal file
@@ -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<PathBuf> {
|
||||
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<PathBuf> {
|
||||
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<String>,
|
||||
default_directory: PathBuf,
|
||||
) -> Result<Project> {
|
||||
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<String>,
|
||||
description: Option<Option<String>>,
|
||||
default_directory: Option<PathBuf>,
|
||||
) -> Result<Project> {
|
||||
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<Vec<ProjectMetadata>> {
|
||||
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<Project> {
|
||||
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(())
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -41,6 +41,8 @@ pub struct SessionMetadata {
|
||||
pub description: String,
|
||||
/// ID of the schedule that triggered this session, if any
|
||||
pub schedule_id: Option<String>,
|
||||
/// ID of the project this session belongs to, if any
|
||||
pub project_id: Option<String>,
|
||||
/// 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<String>, // For backward compatibility
|
||||
project_id: Option<String>, // For backward compatibility
|
||||
total_tokens: Option<i32>,
|
||||
input_tokens: Option<i32>,
|
||||
output_tokens: Option<i32>,
|
||||
@@ -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,
|
||||
|
||||
@@ -32,6 +32,7 @@ pub struct ConfigurableMockScheduler {
|
||||
sessions_data: Arc<Mutex<HashMap<String, Vec<(String, SessionMetadata)>>>>,
|
||||
}
|
||||
|
||||
#[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),
|
||||
|
||||
Reference in New Issue
Block a user