Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions crates/goose-server/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -53,3 +53,4 @@ path = "src/bin/generate_schema.rs"
[dev-dependencies]
tower = "0.5"
async-trait = "0.1"
tempfile = "3.15.0"
7 changes: 6 additions & 1 deletion crates/goose-server/src/openapi.rs
Original file line number Diff line number Diff line change
Expand Up @@ -393,7 +393,9 @@ impl<'__s> ToSchema<'__s> for AnnotatedSchema {
super::routes::recipe::create_recipe,
super::routes::recipe::encode_recipe,
super::routes::recipe::decode_recipe,
super::routes::recipe::scan_recipe
super::routes::recipe::scan_recipe,
super::routes::recipe::list_recipes,
super::routes::recipe::delete_recipe,
),
components(schemas(
super::routes::config_management::UpsertConfigQuery,
Expand Down Expand Up @@ -464,6 +466,9 @@ impl<'__s> ToSchema<'__s> for AnnotatedSchema {
super::routes::recipe::DecodeRecipeResponse,
super::routes::recipe::ScanRecipeRequest,
super::routes::recipe::ScanRecipeResponse,
super::routes::recipe::RecipeManifestResponse,
super::routes::recipe::ListRecipeResponse,
super::routes::recipe::DeleteRecipeRequest,
goose::recipe::Recipe,
goose::recipe::Author,
goose::recipe::Settings,
Expand Down
1 change: 1 addition & 0 deletions crates/goose-server/src/routes/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ pub mod context;
pub mod extension;
pub mod health;
pub mod recipe;
pub mod recipe_utils;
pub mod reply;
pub mod schedule;
pub mod session;
Expand Down
103 changes: 103 additions & 0 deletions crates/goose-server/src/routes/recipe.rs
Original file line number Diff line number Diff line change
@@ -1,12 +1,19 @@
use std::collections::HashMap;
use std::fs;
use std::sync::Arc;

use axum::routing::get;
use axum::{extract::State, http::StatusCode, routing::post, Json, Router};
use goose::conversation::{message::Message, Conversation};
use goose::recipe::Recipe;
use goose::recipe_deeplink;

use http::HeaderMap;
use serde::{Deserialize, Serialize};
use utoipa::ToSchema;

use crate::routes::recipe_utils::get_all_recipes_manifests;
use crate::routes::utils::verify_secret_key;
use crate::state::AppState;

#[derive(Debug, Deserialize, ToSchema)]
Expand Down Expand Up @@ -66,6 +73,27 @@ pub struct ScanRecipeResponse {
has_security_warnings: bool,
}

#[derive(Debug, Serialize, ToSchema)]
pub struct RecipeManifestResponse {
name: String,
#[serde(rename = "isGlobal")]
is_global: bool,
recipe: Recipe,
#[serde(rename = "lastModified")]
last_modified: String,
id: String,
}

#[derive(Debug, Deserialize, ToSchema)]
pub struct DeleteRecipeRequest {
id: String,
}

#[derive(Debug, Serialize, ToSchema)]
pub struct ListRecipeResponse {
recipe_manifest_responses: Vec<RecipeManifestResponse>,
}

#[utoipa::path(
post,
path = "/recipes/create",
Expand Down Expand Up @@ -209,12 +237,87 @@ async fn scan_recipe(
}))
}

#[utoipa::path(
get,
path = "/recipes/list",
responses(
(status = 200, description = "Get recipe list successfully", body = ListRecipeResponse),
(status = 401, description = "Unauthorized - Invalid or missing API key"),
(status = 500, description = "Internal server error")
),
tag = "Recipe Management"
)]
async fn list_recipes(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
) -> Result<Json<ListRecipeResponse>, StatusCode> {
verify_secret_key(&headers, &state)?;

let recipe_manifest_with_paths = get_all_recipes_manifests().unwrap();
let mut recipe_file_hash_map = HashMap::new();
let recipe_manifest_responses = recipe_manifest_with_paths
.iter()
.map(|recipe_manifest_with_path| {
let id = &recipe_manifest_with_path.id;
let file_path = recipe_manifest_with_path.file_path.clone();
recipe_file_hash_map.insert(id.clone(), file_path);
RecipeManifestResponse {
name: recipe_manifest_with_path.name.clone(),
is_global: recipe_manifest_with_path.is_global,
recipe: recipe_manifest_with_path.recipe.clone(),
id: id.clone(),
last_modified: recipe_manifest_with_path.last_modified.clone(),
}
})
.collect::<Vec<RecipeManifestResponse>>();
state.set_recipe_file_hash_map(recipe_file_hash_map).await;

Ok(Json(ListRecipeResponse {
recipe_manifest_responses,
}))
}

#[utoipa::path(
post,
path = "/recipes/delete",
request_body = DeleteRecipeRequest,
responses(
(status = 204, description = "Recipe deleted successfully"),
(status = 401, description = "Unauthorized - Invalid or missing API key"),
(status = 404, description = "Recipe not found"),
(status = 500, description = "Internal server error")
),
tag = "Recipe Management"
)]
async fn delete_recipe(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
Json(request): Json<DeleteRecipeRequest>,
) -> StatusCode {
if verify_secret_key(&headers, &state).is_err() {
return StatusCode::UNAUTHORIZED;
}
let recipe_file_hash_map = state.recipe_file_hash_map.lock().await;
let file_path = match recipe_file_hash_map.get(&request.id) {
Some(path) => path,
None => return StatusCode::NOT_FOUND,
};

if fs::remove_file(file_path).is_err() {
return StatusCode::INTERNAL_SERVER_ERROR;
}

StatusCode::NO_CONTENT
}

pub fn routes(state: Arc<AppState>) -> Router {
Router::new()
.route("/recipes/create", post(create_recipe))
.route("/recipes/encode", post(encode_recipe))
.route("/recipes/decode", post(decode_recipe))
.route("/recipes/scan", post(scan_recipe))
.route("/recipes/list", get(list_recipes))
.route("/recipes/delete", post(delete_recipe))
.with_state(state)
}

Expand Down
130 changes: 130 additions & 0 deletions crates/goose-server/src/routes/recipe_utils.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
use std::fs;
use std::hash::DefaultHasher;
use std::hash::{Hash, Hasher};
use std::path::PathBuf;

use anyhow::Result;
use etcetera::{choose_app_strategy, AppStrategy};

use goose::config::APP_STRATEGY;
use goose::recipe::read_recipe_file_content::read_recipe_file;
use goose::recipe::Recipe;

use std::path::Path;

use serde::{Deserialize, Serialize};
use utoipa::ToSchema;

pub struct RecipeManifestWithPath {
pub id: String,
pub name: String,
pub is_global: bool,
pub recipe: Recipe,
pub file_path: PathBuf,
pub last_modified: String,
}

fn short_id_from_path(path: &str) -> String {
let mut hasher = DefaultHasher::new();
path.hash(&mut hasher);
let h = hasher.finish();
format!("{:016x}", h)
}

fn load_recipes_from_path(path: &PathBuf) -> Result<Vec<RecipeManifestWithPath>> {
let mut recipe_manifests_with_path = Vec::new();
if path.exists() {
for entry in fs::read_dir(path)? {
let path = entry?.path();
if path.extension() == Some("yaml".as_ref()) {
let Ok(recipe_file) = read_recipe_file(path.clone()) else {
continue;
};
let Ok(recipe) = Recipe::from_content(&recipe_file.content) else {
continue;
};
let Ok(recipe_metadata) = RecipeManifestMetadata::from_yaml_file(&path) else {
continue;
};
let Ok(last_modified) = fs::metadata(path.clone()).map(|m| {
chrono::DateTime::<chrono::Utc>::from(m.modified().unwrap()).to_rfc3339()
}) else {
continue;
};

let manifest_with_path = RecipeManifestWithPath {
id: short_id_from_path(recipe_file.file_path.to_string_lossy().as_ref()),
name: recipe_metadata.name,
is_global: recipe_metadata.is_global,
recipe,
file_path: recipe_file.file_path,
last_modified,
};
recipe_manifests_with_path.push(manifest_with_path);
}
}
}
Ok(recipe_manifests_with_path)
}

pub fn get_all_recipes_manifests() -> Result<Vec<RecipeManifestWithPath>> {
let current_dir = std::env::current_dir()?;
let local_recipe_path = current_dir.join(".goose/recipes");

let global_recipe_path = choose_app_strategy(APP_STRATEGY.clone())
.expect("goose requires a home dir")
.config_dir()
.join("recipes");

let mut recipe_manifests_with_path = Vec::new();

recipe_manifests_with_path.extend(load_recipes_from_path(&local_recipe_path)?);
recipe_manifests_with_path.extend(load_recipes_from_path(&global_recipe_path)?);
recipe_manifests_with_path.sort_by(|a, b| b.last_modified.cmp(&a.last_modified));

Ok(recipe_manifests_with_path)
}

// this is a temporary struct to deserilize the UI recipe files. should not be used for other purposes.
#[derive(Serialize, Deserialize, Debug, Clone, ToSchema)]
struct RecipeManifestMetadata {
pub name: String,
#[serde(rename = "isGlobal")]
pub is_global: bool,
}

impl RecipeManifestMetadata {
pub fn from_yaml_file(path: &Path) -> Result<Self> {
let content = fs::read_to_string(path)
.map_err(|e| anyhow::anyhow!("Failed to read file {}: {}", path.display(), e))?;
let metadata = serde_yaml::from_str::<Self>(&content)
.map_err(|e| anyhow::anyhow!("Failed to parse YAML: {}", e))?;
Ok(metadata)
}
}

#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::tempdir;

#[test]
fn test_from_yaml_file_success() {
let temp_dir = tempdir().unwrap();
let file_path = temp_dir.path().join("test_recipe.yaml");

let yaml_content = r#"
name: "Test Recipe"
isGlobal: true
recipe: recipe_content
"#;

fs::write(&file_path, yaml_content).unwrap();

let result = RecipeManifestMetadata::from_yaml_file(&file_path).unwrap();

assert_eq!(result.name, "Test Recipe");
assert_eq!(result.is_global, true);
}
}
9 changes: 9 additions & 0 deletions crates/goose-server/src/state.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
use goose::agents::Agent;
use goose::scheduler_trait::SchedulerTrait;
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::Arc;
use tokio::sync::Mutex;

Expand All @@ -10,6 +12,7 @@ pub struct AppState {
agent: Option<AgentRef>,
pub secret_key: String,
pub scheduler: Arc<Mutex<Option<Arc<dyn SchedulerTrait>>>>,
pub recipe_file_hash_map: Arc<Mutex<HashMap<String, PathBuf>>>,
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This in memory map maintains the mapping of recipe id (hashed based on recipe path) and recipe path. recipe id is the key of map

}

impl AppState {
Expand All @@ -18,6 +21,7 @@ impl AppState {
agent: Some(agent.clone()),
secret_key,
scheduler: Arc::new(Mutex::new(None)),
recipe_file_hash_map: Arc::new(Mutex::new(HashMap::new())),
})
}

Expand All @@ -39,4 +43,9 @@ impl AppState {
.clone()
.ok_or_else(|| anyhow::anyhow!("Scheduler not initialized"))
}

pub async fn set_recipe_file_hash_map(&self, hash_map: HashMap<String, PathBuf>) {
let mut map = self.recipe_file_hash_map.lock().await;
*map = hash_map;
}
}
Loading
Loading