chore: move list recipes and archive recipe to goose server (#4422)
This commit is contained in:
@@ -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,
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)]
|
||||
@@ -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",
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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>>>,
|
||||
}
|
||||
|
||||
impl AppState {
|
||||
@@ -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())),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user