diff --git a/crates/goose-server/src/openapi.rs b/crates/goose-server/src/openapi.rs index 42df6681..695762df 100644 --- a/crates/goose-server/src/openapi.rs +++ b/crates/goose-server/src/openapi.rs @@ -466,6 +466,7 @@ derive_utoipa!(Icon as IconSchema); super::routes::recipe::ListRecipeResponse, super::routes::recipe::DeleteRecipeRequest, super::routes::recipe::SaveRecipeRequest, + super::routes::recipe::SaveRecipeResponse, super::routes::errors::ErrorResponse, super::routes::recipe::ParseRecipeRequest, super::routes::recipe::ParseRecipeResponse, @@ -489,7 +490,6 @@ derive_utoipa!(Icon as IconSchema); super::routes::agent::UpdateRouterToolSelectorRequest, super::routes::agent::StartAgentRequest, super::routes::agent::ResumeAgentRequest, - super::routes::agent::ErrorResponse, super::routes::setup::SetupResponse, )) )] diff --git a/crates/goose-server/src/routes/agent.rs b/crates/goose-server/src/routes/agent.rs index a6c76009..d488167a 100644 --- a/crates/goose-server/src/routes/agent.rs +++ b/crates/goose-server/src/routes/agent.rs @@ -1,3 +1,5 @@ +use crate::routes::errors::ErrorResponse; +use crate::routes::recipe_utils::{load_recipe_by_id, validate_recipe}; use crate::state::AppState; use axum::{ extract::{Query, State}, @@ -10,6 +12,7 @@ use goose::config::PermissionManager; use goose::model::ModelConfig; use goose::providers::create; use goose::recipe::{Recipe, Response}; +use goose::recipe_deeplink; use goose::session::{Session, SessionManager}; use goose::{ agents::{extension::ToolInfo, extension_manager::get_parameter_names}, @@ -20,6 +23,7 @@ use serde::{Deserialize, Serialize}; use std::path::PathBuf; use std::sync::atomic::Ordering; use std::sync::Arc; +use tracing::error; #[derive(Deserialize, utoipa::ToSchema)] pub struct ExtendPromptRequest { @@ -70,7 +74,12 @@ pub struct UpdateRouterToolSelectorRequest { #[derive(Deserialize, utoipa::ToSchema)] pub struct StartAgentRequest { working_dir: String, + #[serde(default)] recipe: Option, + #[serde(default)] + recipe_id: Option, + #[serde(default)] + recipe_deeplink: Option, } #[derive(Deserialize, utoipa::ToSchema)] @@ -78,44 +87,92 @@ pub struct ResumeAgentRequest { session_id: String, } -#[derive(Serialize, utoipa::ToSchema)] -pub struct ErrorResponse { - error: String, -} - #[utoipa::path( post, path = "/agent/start", request_body = StartAgentRequest, responses( (status = 200, description = "Agent started successfully", body = Session), - (status = 400, description = "Bad request - invalid working directory"), + (status = 400, description = "Bad request", body = ErrorResponse), (status = 401, description = "Unauthorized - invalid secret key"), - (status = 500, description = "Internal server error") + (status = 500, description = "Internal server error", body = ErrorResponse) ) )] async fn start_agent( State(state): State>, Json(payload): Json, -) -> Result, StatusCode> { +) -> Result, ErrorResponse> { + let StartAgentRequest { + working_dir, + recipe, + recipe_id, + recipe_deeplink, + } = payload; + + let resolved_recipe = if let Some(deeplink) = recipe_deeplink { + match recipe_deeplink::decode(&deeplink) { + Ok(recipe) => Some(recipe), + Err(err) => { + error!("Failed to decode recipe deeplink: {}", err); + return Err(ErrorResponse { + message: err.to_string(), + status: StatusCode::BAD_REQUEST, + }); + } + } + } else if let Some(id) = recipe_id { + match load_recipe_by_id(state.as_ref(), &id).await { + Ok(recipe) => Some(recipe), + Err(err) => return Err(err), + } + } else { + recipe + }; + + if let Some(ref recipe) = resolved_recipe { + if let Err(err) = validate_recipe(recipe) { + return Err(ErrorResponse { + message: err.message, + status: err.status, + }); + } + } + let counter = state.session_counter.fetch_add(1, Ordering::SeqCst) + 1; let description = format!("New session {}", counter); - let mut session = - SessionManager::create_session(PathBuf::from(&payload.working_dir), description) - .await - .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + let mut session = SessionManager::create_session(PathBuf::from(&working_dir), description) + .await + .map_err(|err| { + error!("Failed to create session: {}", err); + ErrorResponse { + message: format!("Failed to create session: {}", err), + status: StatusCode::BAD_REQUEST, + } + })?; - if let Some(recipe) = payload.recipe { + if let Some(recipe) = resolved_recipe { SessionManager::update_session(&session.id) .recipe(Some(recipe)) .apply() .await - .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + .map_err(|err| { + error!("Failed to update session with recipe: {}", err); + ErrorResponse { + message: format!("Failed to update session with recipe: {}", err), + status: StatusCode::INTERNAL_SERVER_ERROR, + } + })?; session = SessionManager::get_session(&session.id, false) .await - .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + .map_err(|err| { + error!("Failed to get updated session: {}", err); + ErrorResponse { + message: format!("Failed to get updated session: {}", err), + status: StatusCode::INTERNAL_SERVER_ERROR, + } + })?; } Ok(Json(session)) @@ -134,10 +191,16 @@ async fn start_agent( )] async fn resume_agent( Json(payload): Json, -) -> Result, StatusCode> { +) -> Result, ErrorResponse> { let session = SessionManager::get_session(&payload.session_id, true) .await - .map_err(|_| StatusCode::NOT_FOUND)?; + .map_err(|err| { + error!("Failed to resume session {}: {}", payload.session_id, err); + ErrorResponse { + message: format!("Failed to resume session: {}", err), + status: StatusCode::NOT_FOUND, + } + })?; Ok(Json(session)) } diff --git a/crates/goose-server/src/routes/recipe.rs b/crates/goose-server/src/routes/recipe.rs index 1c6da4db..5b39ccba 100644 --- a/crates/goose-server/src/routes/recipe.rs +++ b/crates/goose-server/src/routes/recipe.rs @@ -1,6 +1,5 @@ use std::collections::HashMap; use std::fs; -use std::path::PathBuf; use std::sync::Arc; use axum::extract::rejection::JsonRejection; @@ -38,7 +37,10 @@ fn clean_data_error(err: &axum::extract::rejection::JsonDataError) -> String { } use crate::routes::errors::ErrorResponse; -use crate::routes::recipe_utils::get_all_recipes_manifests; +use crate::routes::recipe_utils::{ + get_all_recipes_manifests, get_recipe_file_path_by_id, short_id_from_path, validate_recipe, + RecipeValidationError, +}; use crate::state::AppState; #[derive(Debug, Deserialize, ToSchema)] @@ -97,6 +99,11 @@ pub struct SaveRecipeRequest { recipe: Recipe, id: Option, } + +#[derive(Debug, Serialize, ToSchema)] +pub struct SaveRecipeResponse { + id: String, +} #[derive(Debug, Deserialize, ToSchema)] pub struct ParseRecipeRequest { pub content: String, @@ -231,7 +238,10 @@ async fn decode_recipe( Json(request): Json, ) -> Result, StatusCode> { match recipe_deeplink::decode(&request.deeplink) { - Ok(recipe) => Ok(Json(DecodeRecipeResponse { recipe })), + Ok(recipe) => match validate_recipe(&recipe) { + Ok(_) => Ok(Json(DecodeRecipeResponse { recipe })), + Err(RecipeValidationError { status, .. }) => Err(status), + }, Err(err) => { tracing::error!("Failed to decode deeplink: {}", err); Err(StatusCode::BAD_REQUEST) @@ -309,7 +319,7 @@ async fn delete_recipe( State(state): State>, Json(request): Json, ) -> StatusCode { - let file_path = match get_recipe_file_path_by_id(state.clone(), &request.id).await { + let file_path = match get_recipe_file_path_by_id(state.as_ref(), &request.id).await { Ok(path) => path, Err(err) => return err.status, }; @@ -326,8 +336,10 @@ async fn delete_recipe( path = "/recipes/save", request_body = SaveRecipeRequest, responses( - (status = 204, description = "Recipe saved to file successfully"), + (status = 204, description = "Recipe saved to file successfully", body = SaveRecipeResponse), (status = 401, description = "Unauthorized - Invalid or missing API key"), + (status = 401, description = "Unauthorized", body = ErrorResponse), + (status = 404, description = "Not found", body = ErrorResponse), (status = 500, description = "Internal server error", body = ErrorResponse) ), tag = "Recipe Management" @@ -335,18 +347,20 @@ async fn delete_recipe( async fn save_recipe( State(state): State>, payload: Result, JsonRejection>, -) -> Result { +) -> Result, ErrorResponse> { let Json(raw_json) = payload.map_err(json_rejection_to_error_response)?; let request = deserialize_save_recipe_request(raw_json)?; - validate_recipe(&request.recipe)?; + ensure_recipe_valid(&request.recipe)?; let file_path = match request.id.as_ref() { - Some(id) => Some(get_recipe_file_path_by_id(state.clone(), id).await?), + Some(id) => Some(get_recipe_file_path_by_id(state.as_ref(), id).await?), None => None, }; - match local_recipes::save_recipe_to_file(request.recipe, file_path) { - Ok(_) => Ok(StatusCode::NO_CONTENT), + match local_recipes::save_recipe_to_file(request.recipe, file_path.clone()) { + Ok(save_file_path) => Ok(Json(SaveRecipeResponse { + id: short_id_from_path(&save_file_path.display().to_string()), + })), Err(e) => Err(ErrorResponse { message: e.to_string(), status: StatusCode::INTERNAL_SERVER_ERROR, @@ -361,16 +375,13 @@ fn json_rejection_to_error_response(rejection: JsonRejection) -> ErrorResponse { } } -fn validate_recipe(recipe: &Recipe) -> Result<(), ErrorResponse> { - let recipe_json = serde_json::to_string(recipe).map_err(|err| ErrorResponse { - message: err.to_string(), - status: StatusCode::BAD_REQUEST, - })?; - - validate_recipe_template_from_content(&recipe_json, None).map_err(|err| ErrorResponse { - message: err.to_string(), - status: StatusCode::BAD_REQUEST, - })?; +fn ensure_recipe_valid(recipe: &Recipe) -> Result<(), ErrorResponse> { + if let Err(err) = validate_recipe(recipe) { + return Err(ErrorResponse { + message: err.message, + status: err.status, + }); + } Ok(()) } @@ -398,41 +409,6 @@ fn deserialize_save_recipe_request(value: Value) -> Result, - id: &str, -) -> Result { - let cached_path = { - let map = state.recipe_file_hash_map.lock().await; - map.get(id).cloned() - }; - - if let Some(path) = cached_path { - return Ok(path); - } - - let recipe_manifest_with_paths = get_all_recipes_manifests().unwrap_or_default(); - let mut recipe_file_hash_map = HashMap::new(); - let mut resolved_path: Option = None; - - for recipe_manifest_with_path in &recipe_manifest_with_paths { - if recipe_manifest_with_path.id == id { - resolved_path = Some(recipe_manifest_with_path.file_path.clone()); - } - recipe_file_hash_map.insert( - recipe_manifest_with_path.id.clone(), - recipe_manifest_with_path.file_path.clone(), - ); - } - - state.set_recipe_file_hash_map(recipe_file_hash_map).await; - - resolved_path.ok_or_else(|| ErrorResponse { - message: format!("Recipe not found: {}", id), - status: StatusCode::NOT_FOUND, - }) -} - #[utoipa::path( post, path = "/recipes/parse", diff --git a/crates/goose-server/src/routes/recipe_utils.rs b/crates/goose-server/src/routes/recipe_utils.rs index 49181a24..2c4b02a3 100644 --- a/crates/goose-server/src/routes/recipe_utils.rs +++ b/crates/goose-server/src/routes/recipe_utils.rs @@ -1,12 +1,24 @@ +use std::collections::HashMap; use std::fs; use std::hash::DefaultHasher; use std::hash::{Hash, Hasher}; use std::path::PathBuf; use anyhow::Result; +use axum::http::StatusCode; +use crate::routes::errors::ErrorResponse; +use crate::state::AppState; use goose::recipe::local_recipes::list_local_recipes; +use goose::recipe::validate_recipe::validate_recipe_template_from_content; use goose::recipe::Recipe; +use serde_json; +use tracing::error; + +pub struct RecipeValidationError { + pub status: StatusCode, + pub message: String, +} pub struct RecipeManifestWithPath { pub id: String, @@ -15,7 +27,7 @@ pub struct RecipeManifestWithPath { pub last_modified: String, } -fn short_id_from_path(path: &str) -> String { +pub fn short_id_from_path(path: &str) -> String { let mut hasher = DefaultHasher::new(); path.hash(&mut hasher); let h = hasher.finish(); @@ -44,3 +56,69 @@ pub fn get_all_recipes_manifests() -> Result> { Ok(recipe_manifests_with_path) } + +pub fn validate_recipe(recipe: &Recipe) -> Result<(), RecipeValidationError> { + let recipe_json = serde_json::to_string(recipe).map_err(|err| { + let message = err.to_string(); + error!("Failed to serialize recipe for validation: {}", message); + RecipeValidationError { + status: StatusCode::BAD_REQUEST, + message, + } + })?; + + validate_recipe_template_from_content(&recipe_json, None).map_err(|err| { + let message = err.to_string(); + error!("Recipe validation failed: {}", message); + RecipeValidationError { + status: StatusCode::BAD_REQUEST, + message, + } + })?; + + Ok(()) +} + +pub async fn get_recipe_file_path_by_id( + state: &AppState, + id: &str, +) -> Result { + let cached_path = { + let map = state.recipe_file_hash_map.lock().await; + map.get(id).cloned() + }; + + if let Some(path) = cached_path { + return Ok(path); + } + + let recipe_manifest_with_paths = get_all_recipes_manifests().unwrap_or_default(); + let mut recipe_file_hash_map = HashMap::new(); + let mut resolved_path: Option = None; + + for recipe_manifest_with_path in &recipe_manifest_with_paths { + if recipe_manifest_with_path.id == id { + resolved_path = Some(recipe_manifest_with_path.file_path.clone()); + } + recipe_file_hash_map.insert( + recipe_manifest_with_path.id.clone(), + recipe_manifest_with_path.file_path.clone(), + ); + } + + state.set_recipe_file_hash_map(recipe_file_hash_map).await; + + resolved_path.ok_or_else(|| ErrorResponse { + message: format!("Recipe not found: {}", id), + status: StatusCode::NOT_FOUND, + }) +} + +pub async fn load_recipe_by_id(state: &AppState, id: &str) -> Result { + let path = get_recipe_file_path_by_id(state, id).await?; + + Recipe::from_file_path(&path).map_err(|err| ErrorResponse { + message: format!("Failed to load recipe: {}", err), + status: StatusCode::INTERNAL_SERVER_ERROR, + }) +} diff --git a/crates/goose/src/recipe_deeplink.rs b/crates/goose/src/recipe_deeplink.rs index 7123f4ef..fc6779be 100644 --- a/crates/goose/src/recipe_deeplink.rs +++ b/crates/goose/src/recipe_deeplink.rs @@ -6,7 +6,7 @@ use crate::recipe::Recipe; #[derive(Error, Debug)] pub enum DecodeError { - #[error("All decoding methods failed")] + #[error("Failed to decode recipe deeplink")] AllMethodsFailed, } diff --git a/ui/desktop/openapi.json b/ui/desktop/openapi.json index cb12e582..29cc112d 100644 --- a/ui/desktop/openapi.json +++ b/ui/desktop/openapi.json @@ -191,13 +191,27 @@ } }, "400": { - "description": "Bad request - invalid working directory" + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } }, "401": { "description": "Unauthorized - invalid secret key" }, "500": { - "description": "Internal server error" + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } } } } @@ -1235,10 +1249,34 @@ }, "responses": { "204": { - "description": "Recipe saved to file successfully" + "description": "Recipe saved to file successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SaveRecipeResponse" + } + } + } }, "401": { - "description": "Unauthorized - Invalid or missing API key" + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } }, "500": { "description": "Internal server error", @@ -2485,10 +2523,10 @@ "ErrorResponse": { "type": "object", "required": [ - "error" + "message" ], "properties": { - "error": { + "message": { "type": "string" } } @@ -3978,6 +4016,17 @@ } } }, + "SaveRecipeResponse": { + "type": "object", + "required": [ + "id" + ], + "properties": { + "id": { + "type": "string" + } + } + }, "ScanRecipeRequest": { "type": "object", "required": [ @@ -4310,6 +4359,14 @@ ], "nullable": true }, + "recipe_deeplink": { + "type": "string", + "nullable": true + }, + "recipe_id": { + "type": "string", + "nullable": true + }, "working_dir": { "type": "string" } diff --git a/ui/desktop/src/api/types.gen.ts b/ui/desktop/src/api/types.gen.ts index a20a4f34..e8e9797d 100644 --- a/ui/desktop/src/api/types.gen.ts +++ b/ui/desktop/src/api/types.gen.ts @@ -177,7 +177,7 @@ export type Envs = { }; export type ErrorResponse = { - error: string; + message: string; }; export type ExtendPromptRequest = { @@ -667,6 +667,10 @@ export type SaveRecipeRequest = { recipe: Recipe; }; +export type SaveRecipeResponse = { + id: string; +}; + export type ScanRecipeRequest = { recipe: Recipe; }; @@ -764,6 +768,8 @@ export type SetupResponse = { export type StartAgentRequest = { recipe?: Recipe | null; + recipe_deeplink?: string | null; + recipe_id?: string | null; working_dir: string; }; @@ -1041,9 +1047,9 @@ export type StartAgentData = { export type StartAgentErrors = { /** - * Bad request - invalid working directory + * Bad request */ - 400: unknown; + 400: ErrorResponse; /** * Unauthorized - invalid secret key */ @@ -1051,9 +1057,11 @@ export type StartAgentErrors = { /** * Internal server error */ - 500: unknown; + 500: ErrorResponse; }; +export type StartAgentError = StartAgentErrors[keyof StartAgentErrors]; + export type StartAgentResponses = { /** * Agent started successfully @@ -1873,9 +1881,13 @@ export type SaveRecipeData = { export type SaveRecipeErrors = { /** - * Unauthorized - Invalid or missing API key + * Unauthorized */ - 401: unknown; + 401: ErrorResponse; + /** + * Not found + */ + 404: ErrorResponse; /** * Internal server error */ @@ -1888,10 +1900,10 @@ export type SaveRecipeResponses = { /** * Recipe saved to file successfully */ - 204: void; + 204: SaveRecipeResponse; }; -export type SaveRecipeResponse = SaveRecipeResponses[keyof SaveRecipeResponses]; +export type SaveRecipeResponse2 = SaveRecipeResponses[keyof SaveRecipeResponses]; export type ScanRecipeData = { body: ScanRecipeRequest; diff --git a/ui/desktop/src/components/recipes/CreateEditRecipeModal.tsx b/ui/desktop/src/components/recipes/CreateEditRecipeModal.tsx index 3f02f062..2e0eadb5 100644 --- a/ui/desktop/src/components/recipes/CreateEditRecipeModal.tsx +++ b/ui/desktop/src/components/recipes/CreateEditRecipeModal.tsx @@ -295,7 +295,7 @@ export default function CreateEditRecipeModal({ try { const recipe = getCurrentRecipe(); - await saveRecipe(recipe, recipeId); + let saved_recipe_id = await saveRecipe(recipe, recipeId); // Close modal first onClose(true); @@ -306,9 +306,9 @@ export default function CreateEditRecipeModal({ undefined, undefined, undefined, - recipe, undefined, - recipeId ?? undefined + undefined, + saved_recipe_id ); toastSuccess({ diff --git a/ui/desktop/src/components/recipes/CreateRecipeFromSessionModal.tsx b/ui/desktop/src/components/recipes/CreateRecipeFromSessionModal.tsx index 2c6c2b91..9f36ff1d 100644 --- a/ui/desktop/src/components/recipes/CreateRecipeFromSessionModal.tsx +++ b/ui/desktop/src/components/recipes/CreateRecipeFromSessionModal.tsx @@ -183,13 +183,21 @@ export default function CreateRecipeFromSessionModal({ extensions: [], // Will be populated based on current extensions }; - await saveRecipe(recipe, null); + let recipeId = await saveRecipe(recipe, null); onRecipeCreated?.(recipe); onClose(); if (runAfterSave) { - window.electron.createChatWindow(undefined, undefined, undefined, undefined, recipe); + window.electron.createChatWindow( + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + recipeId + ); } } catch (error) { console.error('Failed to create recipe:', error); diff --git a/ui/desktop/src/hooks/useAgent.ts b/ui/desktop/src/hooks/useAgent.ts index cb9716cf..b5907ec9 100644 --- a/ui/desktop/src/hooks/useAgent.ts +++ b/ui/desktop/src/hooks/useAgent.ts @@ -47,15 +47,23 @@ export function useAgent(): UseAgentReturn { const [agentState, setAgentState] = useState(AgentState.UNINITIALIZED); const [sessionId, setSessionId] = useState(null); const initPromiseRef = useRef | null>(null); - const [recipeFromAppConfig, setRecipeFromAppConfig] = useState( - (window.appConfig.get('recipe') as Recipe) || null + const recipeIdFromConfig = useRef( + (window.appConfig.get('recipeId') as string | null | undefined) ?? null + ); + const recipeDeeplinkFromConfig = useRef( + (window.appConfig.get('recipeDeeplink') as string | null | undefined) ?? null + ); + const scheduledJobIdFromConfig = useRef( + (window.appConfig.get('scheduledJobId') as string | null | undefined) ?? null ); const { getExtensions, addExtension, read } = useConfig(); const resetChat = useCallback(() => { setSessionId(null); setAgentState(AgentState.UNINITIALIZED); - setRecipeFromAppConfig(null); + recipeIdFromConfig.current = null; + recipeDeeplinkFromConfig.current = null; + scheduledJobIdFromConfig.current = null; }, []); const agentIsInitialized = agentState === AgentState.INITIALIZED; @@ -110,7 +118,11 @@ export function useAgent(): UseAgentReturn { : await startAgent({ body: { working_dir: window.appConfig.get('GOOSE_WORKING_DIR') as string, - recipe: recipeFromAppConfig ?? initContext.recipe, + ...buildRecipeInput( + initContext.recipe, + recipeIdFromConfig.current, + recipeDeeplinkFromConfig.current + ), }, throwOnError: true, }); @@ -121,6 +133,18 @@ export function useAgent(): UseAgentReturn { } setSessionId(agentSession.id); + if (!initContext.recipe && agentSession.recipe && scheduledJobIdFromConfig.current) { + agentSession.recipe = { + ...agentSession.recipe, + scheduledJobId: scheduledJobIdFromConfig.current, + isScheduledExecution: true, + } as Recipe; + scheduledJobIdFromConfig.current = null; + } + + recipeIdFromConfig.current = null; + recipeDeeplinkFromConfig.current = null; + agentWaitingMessage('Agent is loading config'); await initConfig(); @@ -169,10 +193,17 @@ export function useAgent(): UseAgentReturn { return initChat; } catch (error) { - if ((error + '').includes('Failed to create provider')) { + if ( + (error + '').includes('Failed to create provider') || + error instanceof NoProviderOrModelError + ) { setAgentState(AgentState.NO_PROVIDER); - } else { - setAgentState(AgentState.ERROR); + throw error; + } + setAgentState(AgentState.ERROR); + if (typeof error === 'object' && error !== null && 'message' in error) { + let error_message = error.message as string; + throw new Error(error_message); } throw error; } finally { @@ -184,7 +215,7 @@ export function useAgent(): UseAgentReturn { initPromiseRef.current = initPromise; return initPromise; }, - [agentIsInitialized, sessionId, read, recipeFromAppConfig, getExtensions, addExtension] + [agentIsInitialized, sessionId, read, getExtensions, addExtension] ); return { @@ -220,3 +251,23 @@ const handleConfigRecovery = async () => { } } }; + +const buildRecipeInput = ( + recipeOverride?: Recipe, + recipeId?: string | null, + recipeDeeplink?: string | null +) => { + if (recipeId) { + return { recipe_id: recipeId }; + } + + if (recipeDeeplink) { + return { recipe_deeplink: recipeDeeplink }; + } + + if (recipeOverride) { + return { recipe: recipeOverride }; + } + + return {}; +}; diff --git a/ui/desktop/src/hooks/useRecipeManager.ts b/ui/desktop/src/hooks/useRecipeManager.ts index 4b8bd5ce..c9801af0 100644 --- a/ui/desktop/src/hooks/useRecipeManager.ts +++ b/ui/desktop/src/hooks/useRecipeManager.ts @@ -68,13 +68,6 @@ export const useRecipeManager = (chat: ChatType, recipe?: Recipe | null) => { } return; } - - // If we have a recipe from app config (deeplink), persist it - // But only if the chat context doesn't explicitly have null (which indicates it was cleared) - const appRecipe = window.appConfig.get('recipe') as Recipe | null; - if (appRecipe && chatContext.chat.recipe === undefined) { - chatContext.setRecipe(appRecipe); - } }, [chatContext, recipe]); useEffect(() => { @@ -87,18 +80,6 @@ export const useRecipeManager = (chat: ChatType, recipe?: Recipe | null) => { if (finalRecipe) { hasCheckedRecipeRef.current = true; - // If the recipe comes from session metadata (not from navigation state), - // it means it was already accepted in a previous session, so auto-accept it - const hasMessages = chat.messages.length > 0; - const isFromSessionMetadata = !recipe && finalRecipe && hasMessages; - - if (isFromSessionMetadata) { - // Recipe loaded from session metadata should be automatically accepted - setRecipeAccepted(true); - setIsRecipeWarningModalOpen(false); - return; - } - try { const hasAccepted = await window.electron.hasAcceptedRecipeBefore(finalRecipe); diff --git a/ui/desktop/src/main.ts b/ui/desktop/src/main.ts index f32d5734..6907e824 100644 --- a/ui/desktop/src/main.ts +++ b/ui/desktop/src/main.ts @@ -50,25 +50,9 @@ import { import { UPDATES_ENABLED } from './updates'; import { Recipe } from './recipe'; import './utils/recipeHash'; -import { decodeRecipe } from './api'; import { Client, createClient, createConfig } from './api/client'; import installExtension, { REACT_DEVELOPER_TOOLS } from 'electron-devtools-installer'; -async function decodeRecipeMain(client: Client, deeplink: string): Promise { - try { - return ( - await decodeRecipe({ - client, - throwOnError: true, - body: { deeplink }, - }) - ).data.recipe; - } catch (e) { - console.error('Failed to decode recipe:', e); - } - return null; -} - // Updater functions (moved here to keep updates.ts minimal for release replacement) function shouldSetupUpdater(): boolean { // Setup updater if either the flag is enabled OR dev updates are enabled @@ -544,12 +528,6 @@ const createChat = async ( } // Create window config with loading state for recipe deeplinks - let isLoadingRecipe = false; - if (!recipe && recipeDeeplink) { - isLoadingRecipe = true; - console.log('[Main] Creating window with recipe loading state for deeplink:', recipeDeeplink); - } - // Load and manage window state const mainWindowState = windowStateKeeper({ defaultWidth: 940, // large enough to show the sidebar on launch @@ -584,8 +562,9 @@ const createChat = async ( REQUEST_DIR: dir, GOOSE_BASE_URL_SHARE: baseUrlShare, GOOSE_VERSION: version, - recipe: recipe, recipeId: recipeId, + recipeDeeplink: recipeDeeplink, + scheduledJobId: scheduledJobId, }), ], partition: 'persist:goose', // Add this line to ensure persistence @@ -697,7 +676,10 @@ const createChat = async ( if (viewType) { appPath = routeMap[viewType] || '/'; } - if (appPath === '/' && (recipe !== undefined || recipeDeeplink !== undefined)) { + if ( + appPath === '/' && + (recipe !== undefined || recipeDeeplink !== undefined || recipeId !== undefined) + ) { appPath = '/pair'; } @@ -747,37 +729,6 @@ const createChat = async ( windowMap.set(windowId, mainWindow); - // Handle recipe decoding in the background after window is created - if (isLoadingRecipe && recipeDeeplink) { - console.log('[Main] Starting background recipe decoding for:', recipeDeeplink); - - // Decode recipe asynchronously after window is created - decodeRecipeMain(goosedClient, recipeDeeplink) - .then((decodedRecipe) => { - if (decodedRecipe) { - console.log('[Main] Recipe decoded successfully, updating window config'); - - // Handle scheduled job parameters if present - if (scheduledJobId) { - decodedRecipe.scheduledJobId = scheduledJobId; - decodedRecipe.isScheduledExecution = true; - } - - // Send the decoded recipe to the renderer process - mainWindow.webContents.send('recipe-decoded', decodedRecipe); - } else { - console.error('[Main] Failed to decode recipe from deeplink'); - // Send error to renderer - mainWindow.webContents.send('recipe-decode-error', 'Failed to decode recipe'); - } - }) - .catch((error) => { - console.error('[Main] Error decoding recipe:', error); - // Send error to renderer - mainWindow.webContents.send('recipe-decode-error', error.message || 'Unknown error'); - }); - } - // Handle window closure mainWindow.on('closed', () => { windowMap.delete(windowId); diff --git a/ui/desktop/src/preload.ts b/ui/desktop/src/preload.ts index 4c103a61..58f1b6c5 100644 --- a/ui/desktop/src/preload.ts +++ b/ui/desktop/src/preload.ts @@ -255,11 +255,6 @@ const appConfigAPI: AppConfigAPI = { getAll: () => config, }; -// Listen for recipe updates and update config directly -ipcRenderer.on('recipe-decoded', (_, decodedRecipe) => { - config.recipe = decodedRecipe; -}); - // Expose the APIs contextBridge.exposeInMainWorld('electron', electronAPI); contextBridge.exposeInMainWorld('appConfig', appConfigAPI); diff --git a/ui/desktop/src/recipe/recipe_management.ts b/ui/desktop/src/recipe/recipe_management.ts index 35083f44..6117827b 100644 --- a/ui/desktop/src/recipe/recipe_management.ts +++ b/ui/desktop/src/recipe/recipe_management.ts @@ -1,14 +1,15 @@ import { Recipe, saveRecipe as saveRecipeApi, listRecipes, RecipeManifestResponse } from '../api'; -export async function saveRecipe(recipe: Recipe, recipeId?: string | null): Promise { +export async function saveRecipe(recipe: Recipe, recipeId?: string | null): Promise { try { - await saveRecipeApi({ + let response = await saveRecipeApi({ body: { recipe, id: recipeId, }, throwOnError: true, }); + return response.data.id; } catch (error) { let error_message = 'unknown error'; if (typeof error === 'object' && error !== null && 'message' in error) { diff --git a/ui/desktop/src/utils/providerUtils.ts b/ui/desktop/src/utils/providerUtils.ts index d496a895..e707e08f 100644 --- a/ui/desktop/src/utils/providerUtils.ts +++ b/ui/desktop/src/utils/providerUtils.ts @@ -225,7 +225,7 @@ export const initializeSystem = async ( } // Get recipe - prefer from options (session metadata) over app config - const recipe = options?.recipe || window.appConfig?.get?.('recipe'); + const recipe = options?.recipe; const recipe_instructions = (recipe as { instructions?: string })?.instructions; const responseConfig = (recipe as { response?: { json_schema?: unknown } })?.response; const subRecipes = (recipe as { sub_recipes?: SubRecipe[] })?.sub_recipes;