chore: move list recipes and archive recipe to goose server (#4422)

This commit is contained in:
Lifei Zhou
2025-09-01 11:28:28 +10:00
committed by GitHub
parent cded5ae56b
commit ce69b396ce
16 changed files with 1039 additions and 801 deletions
+6 -1
View File
@@ -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,
+1
View File
@@ -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;
+103
View File
@@ -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);
}
}
+9
View File
@@ -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;
}
}