refactor: clean up client side code for creating schedule (#6805)

This commit is contained in:
Lifei Zhou
2026-02-02 19:52:09 +11:00
committed by GitHub
parent 8c7bb43f90
commit dd15fd0dd8
7 changed files with 157 additions and 296 deletions
+52 -6
View File
@@ -1,4 +1,5 @@
use std::sync::Arc;
use tokio::fs;
use axum::{
extract::{Path, Query, State},
@@ -9,13 +10,30 @@ use axum::{
use serde::{Deserialize, Serialize};
use crate::routes::errors::ErrorResponse;
use crate::routes::recipe_utils::validate_recipe;
use crate::state::AppState;
use goose::scheduler::ScheduledJob;
use goose::recipe::Recipe;
use goose::scheduler::{get_default_scheduled_recipes_dir, ScheduledJob};
fn validate_schedule_id(id: &str) -> Result<(), ErrorResponse> {
let is_valid = !id.is_empty()
&& id
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == ' ');
if !is_valid {
return Err(ErrorResponse::bad_request(
"Schedule name must use only alphanumeric characters, hyphens, underscores, or spaces"
.to_string(),
));
}
Ok(())
}
#[derive(Deserialize, Serialize, utoipa::ToSchema)]
pub struct CreateScheduleRequest {
id: String,
recipe_source: String,
recipe: Recipe,
cron: String,
}
@@ -89,11 +107,36 @@ async fn create_schedule(
State(state): State<Arc<AppState>>,
Json(req): Json<CreateScheduleRequest>,
) -> Result<Json<ScheduledJob>, ErrorResponse> {
let scheduler = state.scheduler();
let id = req.id.trim().to_string();
validate_schedule_id(&id)?;
if req.recipe.check_for_security_warnings() {
return Err(ErrorResponse::bad_request(
"This recipe contains hidden characters that could be malicious. Please remove them before trying to save.".to_string(),
));
}
if let Err(err) = validate_recipe(&req.recipe) {
return Err(ErrorResponse {
message: err.message,
status: err.status,
});
}
let scheduled_recipes_dir = get_default_scheduled_recipes_dir().map_err(|e| {
ErrorResponse::internal(format!("Failed to get scheduled recipes directory: {}", e))
})?;
let recipe_path = scheduled_recipes_dir.join(format!("{}.yaml", id));
let yaml_content = req
.recipe
.to_yaml()
.map_err(|e| ErrorResponse::internal(format!("Failed to convert recipe to YAML: {}", e)))?;
fs::write(&recipe_path, yaml_content)
.await
.map_err(|e| ErrorResponse::internal(format!("Failed to save recipe file: {}", e)))?;
let job = ScheduledJob {
id: req.id,
source: req.recipe_source,
id,
source: recipe_path.to_string_lossy().into_owned(),
cron: req.cron,
last_run: None,
currently_running: false,
@@ -101,8 +144,10 @@ async fn create_schedule(
current_session_id: None,
process_start_time: None,
};
let scheduler = state.scheduler();
scheduler
.add_scheduled_job(job.clone(), true)
.add_scheduled_job(job.clone(), false)
.await
.map_err(|e| match e {
goose::scheduler::SchedulerError::CronParseError(msg) => {
@@ -117,6 +162,7 @@ async fn create_schedule(
},
_ => ErrorResponse::internal(format!("Error creating schedule: {}", e)),
})?;
Ok(Json(job))
}
+3 -3
View File
@@ -3437,7 +3437,7 @@
"type": "object",
"required": [
"id",
"recipe_source",
"recipe",
"cron"
],
"properties": {
@@ -3447,8 +3447,8 @@
"id": {
"type": "string"
},
"recipe_source": {
"type": "string"
"recipe": {
"$ref": "#/components/schemas/Recipe"
}
}
},
+1 -1
View File
@@ -135,7 +135,7 @@ export type CreateRecipeResponse = {
export type CreateScheduleRequest = {
cron: string;
id: string;
recipe_source: string;
recipe: Recipe;
};
/**
@@ -4,12 +4,11 @@ import { z } from 'zod';
import { Download } from 'lucide-react';
import { Button } from '../ui/button';
import { Input } from '../ui/input';
import { Recipe, decodeRecipe } from '../../recipe';
import { Recipe, parseDeeplink, parseRecipeFromFile } from '../../recipe';
import { toastSuccess, toastError } from '../../toasts';
import { useEscapeKey } from '../../hooks/useEscapeKey';
import { getRecipeJsonSchema } from '../../recipe/validation';
import { saveRecipe } from '../../recipe/recipe_management';
import { parseRecipe } from '../../api';
import { errorMessage } from '../../utils/conversionUtils';
interface ImportRecipeFormProps {
@@ -46,54 +45,6 @@ export default function ImportRecipeForm({ isOpen, onClose, onSuccess }: ImportR
useEscapeKey(isOpen, onClose);
const parseDeeplink = async (deeplink: string): Promise<Recipe | null> => {
try {
const cleanLink = deeplink.trim();
if (!cleanLink.startsWith('goose://recipe?config=')) {
throw new Error('Invalid deeplink format. Expected: goose://recipe?config=...');
}
const recipeEncoded = cleanLink.replace('goose://recipe?config=', '');
if (!recipeEncoded) {
throw new Error('No recipe configuration found in deeplink');
}
const recipe = await decodeRecipe(recipeEncoded);
if (!recipe.title || !recipe.description) {
throw new Error('Recipe is missing required fields (title, description)');
}
if (!recipe.instructions && !recipe.prompt) {
throw new Error('Recipe must have either instructions or prompt');
}
return recipe;
} catch (error) {
console.error('Failed to parse deeplink:', error);
return null;
}
};
const parseRecipeFromFile = async (fileContent: string): Promise<Recipe> => {
try {
let response = await parseRecipe({
body: {
content: fileContent,
},
throwOnError: true,
});
return response.data.recipe;
} catch (error) {
let error_message = 'unknown error';
if (typeof error === 'object' && error !== null && 'message' in error) {
error_message = error.message as string;
}
throw new Error(error_message);
}
};
const importRecipeForm = useForm({
defaultValues: {
deeplink: '',
@@ -4,16 +4,14 @@ import { Button } from '../ui/button';
import { Input } from '../ui/input';
import { ScheduledJob } from '../../schedule';
import { CronPicker } from './CronPicker';
import { Recipe, decodeRecipe } from '../../recipe';
import { Recipe, parseDeeplink, parseRecipeFromFile } from '../../recipe';
import { getStorageDirectory } from '../../recipe/recipe_management';
import ClockIcon from '../../assets/clock-icon.svg';
import * as yaml from 'yaml';
export interface NewSchedulePayload {
id: string;
recipe_source: string;
recipe: Recipe;
cron: string;
execution_mode?: string;
}
interface ScheduleModalProps {
@@ -28,170 +26,6 @@ interface ScheduleModalProps {
type SourceType = 'file' | 'deeplink';
interface CleanExtension {
name: string;
type: 'stdio' | 'sse' | 'builtin' | 'frontend' | 'streamable_http' | 'platform';
cmd?: string;
args?: string[];
uri?: string;
display_name?: string;
tools?: unknown[];
instructions?: string;
env_keys?: string[];
timeout?: number;
description?: string;
bundled?: boolean;
}
interface CleanRecipe {
title: string;
description: string;
instructions?: string;
prompt?: string;
activities?: string[];
extensions?: CleanExtension[];
author?: {
contact?: string;
metadata?: string;
};
schedule?: {
window_title?: string;
working_directory?: string;
};
}
async function parseDeepLink(deepLink: string): Promise<Recipe | null> {
try {
const url = new URL(deepLink);
if (url.protocol !== 'goose:' || (url.hostname !== 'bot' && url.hostname !== 'recipe')) {
return null;
}
const recipeParam = url.searchParams.get('config');
if (!recipeParam) {
return null;
}
return await decodeRecipe(recipeParam);
} catch (error) {
console.error('Failed to parse deep link:', error);
return null;
}
}
function recipeToYaml(recipe: Recipe): string {
const cleanRecipe: CleanRecipe = {
title: recipe.title,
description: recipe.description,
};
if (recipe.instructions) {
cleanRecipe.instructions = recipe.instructions;
}
if (recipe.prompt) {
cleanRecipe.prompt = recipe.prompt;
}
if (recipe.activities && recipe.activities.length > 0) {
cleanRecipe.activities = recipe.activities;
}
if (recipe.extensions && recipe.extensions.length > 0) {
cleanRecipe.extensions = recipe.extensions.map((ext) => {
const cleanExt: CleanExtension = {
name: ext.name,
type: 'builtin',
};
if ('type' in ext && ext.type) {
cleanExt.type = ext.type as CleanExtension['type'];
const extAny = ext as Record<string, unknown>;
if (ext.type === 'sse' && extAny.uri) {
cleanExt.uri = extAny.uri as string;
} else if (ext.type === 'streamable_http' && extAny.uri) {
cleanExt.uri = extAny.uri as string;
} else if (ext.type === 'stdio') {
if (extAny.cmd) {
cleanExt.cmd = extAny.cmd as string;
}
if (extAny.args) {
cleanExt.args = extAny.args as string[];
}
} else if ((ext.type === 'builtin' || ext.type === 'platform') && extAny.display_name) {
cleanExt.display_name = extAny.display_name as string;
}
if ((ext.type as string) === 'frontend') {
if (extAny.tools) {
cleanExt.tools = extAny.tools as unknown[];
}
if (extAny.instructions) {
cleanExt.instructions = extAny.instructions as string;
}
}
} else {
const extAny = ext as Record<string, unknown>;
if (extAny.cmd) {
cleanExt.type = 'stdio';
cleanExt.cmd = extAny.cmd as string;
if (extAny.args) {
cleanExt.args = extAny.args as string[];
}
} else if (extAny.command) {
cleanExt.type = 'stdio';
cleanExt.cmd = extAny.command as string;
} else if (extAny.uri) {
cleanExt.type = 'streamable_http';
cleanExt.uri = extAny.uri as string;
} else if (extAny.tools) {
cleanExt.type = 'frontend';
cleanExt.tools = extAny.tools as unknown[];
if (extAny.instructions) {
cleanExt.instructions = extAny.instructions as string;
}
} else {
cleanExt.type = 'builtin';
}
}
if ('env_keys' in ext && ext.env_keys && ext.env_keys.length > 0) {
cleanExt.env_keys = ext.env_keys;
}
if ('timeout' in ext && ext.timeout) {
cleanExt.timeout = ext.timeout as number;
}
if ('description' in ext && ext.description) {
cleanExt.description = ext.description as string;
}
if ('bundled' in ext && ext.bundled !== undefined) {
cleanExt.bundled = ext.bundled as boolean;
}
return cleanExt;
});
}
if (recipe.author) {
cleanRecipe.author = {
contact: recipe.author.contact || undefined,
metadata: recipe.author.metadata || undefined,
};
}
cleanRecipe.schedule = {
window_title: `${recipe.title} - Scheduled`,
};
return yaml.stringify(cleanRecipe);
}
const modalLabelClassName = 'block text-sm font-medium text-text-prominent mb-1';
export const ScheduleModal: React.FC<ScheduleModalProps> = ({
@@ -214,33 +48,29 @@ export const ScheduleModal: React.FC<ScheduleModalProps> = ({
const [internalValidationError, setInternalValidationError] = useState<string | null>(null);
const [isValid, setIsValid] = useState(true);
const setScheduleIdFromTitle = (title: string) => {
const cleanId = title
.toLowerCase()
.replace(/[^a-z0-9-]/g, '-')
.replace(/-+/g, '-');
setScheduleId(cleanId);
};
const handleDeepLinkChange = useCallback(async (value: string) => {
setDeepLinkInput(value);
setInternalValidationError(null);
if (value.trim()) {
try {
const recipe = await parseDeepLink(value.trim());
if (recipe) {
setParsedRecipe(recipe);
if (recipe.title) {
const cleanId = recipe.title
.toLowerCase()
.replace(/[^a-z0-9-]/g, '-')
.replace(/-+/g, '-');
setScheduleId(cleanId);
}
} else {
setParsedRecipe(null);
setInternalValidationError(
'Invalid deep link format. Please use a goose://bot or goose://recipe link.'
);
const recipe = await parseDeeplink(value.trim());
if (!recipe) throw new Error();
setParsedRecipe(recipe);
if (recipe.title) {
setScheduleIdFromTitle(recipe.title);
}
} catch {
setParsedRecipe(null);
setInternalValidationError(
'Failed to parse deep link. Please ensure using a goose://bot or goose://recipe link and try again.'
);
setInternalValidationError('Invalid deep link. Please use a goose://recipe link.');
}
} else {
setParsedRecipe(null);
@@ -275,6 +105,26 @@ export const ScheduleModal: React.FC<ScheduleModalProps> = ({
if (filePath.endsWith('.yaml') || filePath.endsWith('.yml')) {
setRecipeSourcePath(filePath);
setInternalValidationError(null);
try {
const fileResponse = await window.electron.readFile(filePath);
if (!fileResponse.found || fileResponse.error) {
throw new Error('Failed to read the selected file.');
}
const recipe = await parseRecipeFromFile(fileResponse.file);
if (!recipe) {
throw new Error('Failed to parse recipe from file.');
}
setParsedRecipe(recipe);
if (recipe.title) {
setScheduleIdFromTitle(recipe.title);
}
} catch (e) {
setParsedRecipe(null);
setInternalValidationError(
e instanceof Error ? e.message : 'Failed to parse recipe from file.'
);
}
} else {
setInternalValidationError('Invalid file type: Please select a YAML file (.yaml or .yml)');
}
@@ -295,47 +145,14 @@ export const ScheduleModal: React.FC<ScheduleModalProps> = ({
return;
}
let finalRecipeSource = '';
if (sourceType === 'file') {
if (!recipeSourcePath) {
setInternalValidationError('Recipe source file is required.');
return;
}
finalRecipeSource = recipeSourcePath;
} else if (sourceType === 'deeplink') {
if (!deepLinkInput.trim()) {
setInternalValidationError('Deep link is required.');
return;
}
if (!parsedRecipe) {
setInternalValidationError('Invalid deep link. Please check the format.');
return;
}
try {
const yamlContent = recipeToYaml(parsedRecipe);
const tempFileName = `schedule-${scheduleId}-${Date.now()}.yaml`;
const tempDir = window.electron.getConfig().GOOSE_WORKING_DIR || '.';
const tempFilePath = `${tempDir}/${tempFileName}`;
const writeSuccess = await window.electron.writeFile(tempFilePath, yamlContent);
if (!writeSuccess) {
setInternalValidationError('Failed to create temporary recipe file.');
return;
}
finalRecipeSource = tempFilePath;
} catch (error) {
console.error('Failed to convert recipe to YAML:', error);
setInternalValidationError('Failed to process the recipe from deep link.');
return;
}
if (!parsedRecipe) {
setInternalValidationError('Please provide a valid recipe source.');
return;
}
const newSchedulePayload: NewSchedulePayload = {
id: scheduleId.trim(),
recipe_source: finalRecipeSource,
recipe: parsedRecipe,
cron: cronExpression,
};
@@ -443,7 +260,7 @@ export const ScheduleModal: React.FC<ScheduleModalProps> = ({
type="text"
value={deepLinkInput}
onChange={(e) => handleDeepLinkChange(e.target.value)}
placeholder="Paste goose://bot or goose://recipe link here..."
placeholder="Paste goose://recipe link here..."
className="rounded-full"
/>
{parsedRecipe && (
+52
View File
@@ -2,6 +2,7 @@ import {
encodeRecipe as apiEncodeRecipe,
decodeRecipe as apiDecodeRecipe,
scanRecipe as apiScanRecipe,
parseRecipe as apiParseRecipe,
} from '../api';
import type { RecipeParameter } from '../api';
@@ -84,3 +85,54 @@ export function stripEmptyExtensions(recipe: Recipe): Recipe {
}
return recipe;
}
export async function parseRecipeFromFile(fileContent: string): Promise<Recipe> {
try {
const response = await apiParseRecipe({
body: { content: fileContent },
throwOnError: true,
});
if (!response.data?.recipe) {
throw new Error('No recipe returned from API');
}
return response.data.recipe as Recipe;
} catch (error) {
let errorMessage = 'unknown error';
if (typeof error === 'object' && error !== null && 'message' in error) {
errorMessage = error.message as string;
}
throw new Error(errorMessage);
}
}
export async function parseDeeplink(deeplink: string): Promise<Recipe | null> {
try {
const cleanLink = deeplink.trim();
if (!cleanLink.startsWith('goose://recipe?config=')) {
throw new Error('Invalid deeplink format. Expected: goose://recipe?config=...');
}
const recipeEncoded = cleanLink.replace('goose://recipe?config=', '');
if (!recipeEncoded) {
throw new Error('No recipe configuration found in deeplink');
}
const recipe = await decodeRecipe(recipeEncoded);
if (!recipe.title || !recipe.description) {
throw new Error('Recipe is missing required fields (title, description)');
}
if (!recipe.instructions && !recipe.prompt) {
throw new Error('Recipe must have either instructions or prompt');
}
return recipe;
} catch (error) {
console.error('Failed to parse deeplink:', error);
return null;
}
}
+7 -12
View File
@@ -11,6 +11,7 @@ import {
inspectRunningJob as apiInspectRunningJob,
SessionDisplayInfo,
} from './api';
import type { Recipe } from './api';
export interface ScheduledJob {
id: string;
@@ -54,21 +55,15 @@ export async function listSchedules(): Promise<ScheduledJob[]> {
export async function createSchedule(request: {
id: string;
recipe_source: string;
recipe: Recipe;
cron: string;
execution_mode?: string;
}): Promise<ScheduledJob> {
try {
const response = await apiCreateSchedule<true>({ body: request });
if (response && response.data) {
return response.data as ScheduledJob;
}
console.error('Unexpected response format from apiCreateSchedule', response);
throw new Error('Failed to create schedule: Unexpected response format');
} catch (error) {
console.error('Error creating schedule:', error);
throw error;
const response = await apiCreateSchedule({ body: request });
if (response.data) {
return response.data as ScheduledJob;
}
const err = response.error as { message?: string } | undefined;
throw new Error(err?.message || 'Failed to create schedule');
}
export async function deleteSchedule(id: string): Promise<void> {