refactor: clean up client side code for creating schedule (#6805)
This commit is contained in:
@@ -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 && (
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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> {
|
||||
|
||||
Reference in New Issue
Block a user