Skip to content

Commit dd15fd0

Browse files
authored
refactor: clean up client side code for creating schedule (#6805)
1 parent 8c7bb43 commit dd15fd0

7 files changed

Lines changed: 157 additions & 296 deletions

File tree

crates/goose-server/src/routes/schedule.rs

Lines changed: 52 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
use std::sync::Arc;
2+
use tokio::fs;
23

34
use axum::{
45
extract::{Path, Query, State},
@@ -9,13 +10,30 @@ use axum::{
910
use serde::{Deserialize, Serialize};
1011

1112
use crate::routes::errors::ErrorResponse;
13+
use crate::routes::recipe_utils::validate_recipe;
1214
use crate::state::AppState;
13-
use goose::scheduler::ScheduledJob;
15+
use goose::recipe::Recipe;
16+
use goose::scheduler::{get_default_scheduled_recipes_dir, ScheduledJob};
17+
18+
fn validate_schedule_id(id: &str) -> Result<(), ErrorResponse> {
19+
let is_valid = !id.is_empty()
20+
&& id
21+
.chars()
22+
.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == ' ');
23+
24+
if !is_valid {
25+
return Err(ErrorResponse::bad_request(
26+
"Schedule name must use only alphanumeric characters, hyphens, underscores, or spaces"
27+
.to_string(),
28+
));
29+
}
30+
Ok(())
31+
}
1432

1533
#[derive(Deserialize, Serialize, utoipa::ToSchema)]
1634
pub struct CreateScheduleRequest {
1735
id: String,
18-
recipe_source: String,
36+
recipe: Recipe,
1937
cron: String,
2038
}
2139

@@ -89,20 +107,47 @@ async fn create_schedule(
89107
State(state): State<Arc<AppState>>,
90108
Json(req): Json<CreateScheduleRequest>,
91109
) -> Result<Json<ScheduledJob>, ErrorResponse> {
92-
let scheduler = state.scheduler();
110+
let id = req.id.trim().to_string();
111+
validate_schedule_id(&id)?;
112+
113+
if req.recipe.check_for_security_warnings() {
114+
return Err(ErrorResponse::bad_request(
115+
"This recipe contains hidden characters that could be malicious. Please remove them before trying to save.".to_string(),
116+
));
117+
}
118+
if let Err(err) = validate_recipe(&req.recipe) {
119+
return Err(ErrorResponse {
120+
message: err.message,
121+
status: err.status,
122+
});
123+
}
124+
let scheduled_recipes_dir = get_default_scheduled_recipes_dir().map_err(|e| {
125+
ErrorResponse::internal(format!("Failed to get scheduled recipes directory: {}", e))
126+
})?;
127+
128+
let recipe_path = scheduled_recipes_dir.join(format!("{}.yaml", id));
129+
let yaml_content = req
130+
.recipe
131+
.to_yaml()
132+
.map_err(|e| ErrorResponse::internal(format!("Failed to convert recipe to YAML: {}", e)))?;
133+
fs::write(&recipe_path, yaml_content)
134+
.await
135+
.map_err(|e| ErrorResponse::internal(format!("Failed to save recipe file: {}", e)))?;
93136

94137
let job = ScheduledJob {
95-
id: req.id,
96-
source: req.recipe_source,
138+
id,
139+
source: recipe_path.to_string_lossy().into_owned(),
97140
cron: req.cron,
98141
last_run: None,
99142
currently_running: false,
100143
paused: false,
101144
current_session_id: None,
102145
process_start_time: None,
103146
};
147+
148+
let scheduler = state.scheduler();
104149
scheduler
105-
.add_scheduled_job(job.clone(), true)
150+
.add_scheduled_job(job.clone(), false)
106151
.await
107152
.map_err(|e| match e {
108153
goose::scheduler::SchedulerError::CronParseError(msg) => {
@@ -117,6 +162,7 @@ async fn create_schedule(
117162
},
118163
_ => ErrorResponse::internal(format!("Error creating schedule: {}", e)),
119164
})?;
165+
120166
Ok(Json(job))
121167
}
122168

ui/desktop/openapi.json

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3437,7 +3437,7 @@
34373437
"type": "object",
34383438
"required": [
34393439
"id",
3440-
"recipe_source",
3440+
"recipe",
34413441
"cron"
34423442
],
34433443
"properties": {
@@ -3447,8 +3447,8 @@
34473447
"id": {
34483448
"type": "string"
34493449
},
3450-
"recipe_source": {
3451-
"type": "string"
3450+
"recipe": {
3451+
"$ref": "#/components/schemas/Recipe"
34523452
}
34533453
}
34543454
},

ui/desktop/src/api/types.gen.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -135,7 +135,7 @@ export type CreateRecipeResponse = {
135135
export type CreateScheduleRequest = {
136136
cron: string;
137137
id: string;
138-
recipe_source: string;
138+
recipe: Recipe;
139139
};
140140

141141
/**

ui/desktop/src/components/recipes/ImportRecipeForm.tsx

Lines changed: 1 addition & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,11 @@ import { z } from 'zod';
44
import { Download } from 'lucide-react';
55
import { Button } from '../ui/button';
66
import { Input } from '../ui/input';
7-
import { Recipe, decodeRecipe } from '../../recipe';
7+
import { Recipe, parseDeeplink, parseRecipeFromFile } from '../../recipe';
88
import { toastSuccess, toastError } from '../../toasts';
99
import { useEscapeKey } from '../../hooks/useEscapeKey';
1010
import { getRecipeJsonSchema } from '../../recipe/validation';
1111
import { saveRecipe } from '../../recipe/recipe_management';
12-
import { parseRecipe } from '../../api';
1312
import { errorMessage } from '../../utils/conversionUtils';
1413

1514
interface ImportRecipeFormProps {
@@ -46,54 +45,6 @@ export default function ImportRecipeForm({ isOpen, onClose, onSuccess }: ImportR
4645

4746
useEscapeKey(isOpen, onClose);
4847

49-
const parseDeeplink = async (deeplink: string): Promise<Recipe | null> => {
50-
try {
51-
const cleanLink = deeplink.trim();
52-
53-
if (!cleanLink.startsWith('goose://recipe?config=')) {
54-
throw new Error('Invalid deeplink format. Expected: goose://recipe?config=...');
55-
}
56-
57-
const recipeEncoded = cleanLink.replace('goose://recipe?config=', '');
58-
59-
if (!recipeEncoded) {
60-
throw new Error('No recipe configuration found in deeplink');
61-
}
62-
const recipe = await decodeRecipe(recipeEncoded);
63-
64-
if (!recipe.title || !recipe.description) {
65-
throw new Error('Recipe is missing required fields (title, description)');
66-
}
67-
68-
if (!recipe.instructions && !recipe.prompt) {
69-
throw new Error('Recipe must have either instructions or prompt');
70-
}
71-
72-
return recipe;
73-
} catch (error) {
74-
console.error('Failed to parse deeplink:', error);
75-
return null;
76-
}
77-
};
78-
79-
const parseRecipeFromFile = async (fileContent: string): Promise<Recipe> => {
80-
try {
81-
let response = await parseRecipe({
82-
body: {
83-
content: fileContent,
84-
},
85-
throwOnError: true,
86-
});
87-
return response.data.recipe;
88-
} catch (error) {
89-
let error_message = 'unknown error';
90-
if (typeof error === 'object' && error !== null && 'message' in error) {
91-
error_message = error.message as string;
92-
}
93-
throw new Error(error_message);
94-
}
95-
};
96-
9748
const importRecipeForm = useForm({
9849
defaultValues: {
9950
deeplink: '',

0 commit comments

Comments
 (0)