Mnovich/temporal foreground tasks (#2895)
Co-authored-by: Carlos M. Lopez <carlopez@squareup.com>
This commit is contained in:
@@ -64,6 +64,7 @@ export type ContextManageResponse = {
|
||||
|
||||
export type CreateScheduleRequest = {
|
||||
cron: string;
|
||||
execution_mode?: string | null;
|
||||
id: string;
|
||||
recipe_source: string;
|
||||
};
|
||||
@@ -316,6 +317,7 @@ export type ScheduledJob = {
|
||||
cron: string;
|
||||
current_session_id?: string | null;
|
||||
currently_running?: boolean;
|
||||
execution_mode?: string | null;
|
||||
id: string;
|
||||
last_run?: string | null;
|
||||
paused?: boolean;
|
||||
|
||||
@@ -1,89 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Enable strict mode to exit on errors and unset variables
|
||||
set -euo pipefail
|
||||
|
||||
# Set log file
|
||||
LOG_FILE="/tmp/mcp.log"
|
||||
|
||||
# Clear the log file at the start
|
||||
> "$LOG_FILE"
|
||||
|
||||
# Function for logging
|
||||
log() {
|
||||
local MESSAGE="$1"
|
||||
echo "$(date +'%Y-%m-%d %H:%M:%S') - $MESSAGE" | tee -a "$LOG_FILE"
|
||||
}
|
||||
|
||||
# Trap errors and log them before exiting
|
||||
trap 'log "An error occurred. Exiting with status $?."' ERR
|
||||
|
||||
log "Starting jbang setup script."
|
||||
|
||||
# Ensure ~/.config/goose/mcp-hermit/bin exists
|
||||
log "Creating directory ~/.config/goose/mcp-hermit/bin if it does not exist."
|
||||
mkdir -p ~/.config/goose/mcp-hermit/bin
|
||||
|
||||
# Change to the ~/.config/goose/mcp-hermit directory
|
||||
log "Changing to directory ~/.config/goose/mcp-hermit."
|
||||
cd ~/.config/goose/mcp-hermit
|
||||
|
||||
# Check if hermit binary exists and download if not
|
||||
if [ ! -f ~/.config/goose/mcp-hermit/bin/hermit ]; then
|
||||
log "Hermit binary not found. Downloading hermit binary."
|
||||
curl -fsSL "https://github.com/cashapp/hermit/releases/download/stable/hermit-$(uname -s | tr '[:upper:]' '[:lower:]')-$(uname -m | sed 's/x86_64/amd64/' | sed 's/aarch64/arm64/').gz" \
|
||||
| gzip -dc > ~/.config/goose/mcp-hermit/bin/hermit && chmod +x ~/.config/goose/mcp-hermit/bin/hermit
|
||||
log "Hermit binary downloaded and made executable."
|
||||
else
|
||||
log "Hermit binary already exists. Skipping download."
|
||||
fi
|
||||
|
||||
log "setting hermit cache to be local for MCP servers"
|
||||
mkdir -p ~/.config/goose/mcp-hermit/cache
|
||||
export HERMIT_STATE_DIR=~/.config/goose/mcp-hermit/cache
|
||||
|
||||
# Update PATH
|
||||
export PATH=~/.config/goose/mcp-hermit/bin:$PATH
|
||||
log "Updated PATH to include ~/.config/goose/mcp-hermit/bin."
|
||||
|
||||
# Initialize hermit
|
||||
log "Initializing hermit."
|
||||
hermit init >> "$LOG_FILE"
|
||||
|
||||
# Install OpenJDK using hermit
|
||||
log "Installing OpenJDK with hermit."
|
||||
hermit install openjdk@17 >> "$LOG_FILE"
|
||||
|
||||
# Download and install jbang if not present
|
||||
if [ ! -f ~/.config/goose/mcp-hermit/bin/jbang ]; then
|
||||
log "Downloading and installing jbang."
|
||||
curl -Ls https://sh.jbang.dev | bash -s - app setup
|
||||
cp ~/.jbang/bin/jbang ~/.config/goose/mcp-hermit/bin/
|
||||
fi
|
||||
|
||||
# Verify installations
|
||||
log "Verifying installation locations:"
|
||||
log "hermit: $(which hermit)"
|
||||
log "java: $(which java)"
|
||||
log "jbang: $(which jbang)"
|
||||
|
||||
# Check for custom registry settings
|
||||
log "Checking for GOOSE_JBANG_REGISTRY environment variable for custom jbang registry setup..."
|
||||
if [ -n "${GOOSE_JBANG_REGISTRY:-}" ] && curl -s --head --fail "$GOOSE_JBANG_REGISTRY" > /dev/null; then
|
||||
log "Checking custom goose registry availability: $GOOSE_JBANG_REGISTRY"
|
||||
log "$GOOSE_JBANG_REGISTRY is accessible. Setting it as JBANG_REPO."
|
||||
export JBANG_REPO="$GOOSE_JBANG_REGISTRY"
|
||||
else
|
||||
log "GOOSE_JBANG_REGISTRY is not set or not accessible. Using default jbang repository."
|
||||
fi
|
||||
|
||||
# Trust all jbang scripts that a user might install. Without this, Jbang will attempt to
|
||||
# prompt the user to trust each script. However, Goose does not surfact this modal and without
|
||||
# user input, the addExtension method will timeout and fail.
|
||||
jbang --quiet trust add *
|
||||
|
||||
# Final step: Execute jbang with passed arguments, always including --fresh and --quiet
|
||||
log "Executing 'jbang' command with arguments: $*"
|
||||
jbang --fresh --quiet "$@" || log "Failed to execute 'jbang' with arguments: $*"
|
||||
|
||||
log "jbang setup script completed successfully."
|
||||
@@ -1,105 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Enable strict mode to exit on errors and unset variables
|
||||
set -euo pipefail
|
||||
|
||||
# Set log file
|
||||
LOG_FILE="/tmp/mcp.log"
|
||||
|
||||
# Clear the log file at the start
|
||||
> "$LOG_FILE"
|
||||
|
||||
# Function for logging
|
||||
log() {
|
||||
local MESSAGE="$1"
|
||||
echo "$(date +'%Y-%m-%d %H:%M:%S') - $MESSAGE" | tee -a "$LOG_FILE"
|
||||
}
|
||||
|
||||
# Trap errors and log them before exiting
|
||||
trap 'log "An error occurred. Exiting with status $?."' ERR
|
||||
|
||||
log "Starting npx setup script."
|
||||
|
||||
# Ensure ~/.config/goose/mcp-hermit/bin exists
|
||||
log "Creating directory ~/.config/goose/mcp-hermit/bin if it does not exist."
|
||||
mkdir -p ~/.config/goose/mcp-hermit/bin
|
||||
|
||||
# Change to the ~/.config/goose/mcp-hermit directory
|
||||
log "Changing to directory ~/.config/goose/mcp-hermit."
|
||||
cd ~/.config/goose/mcp-hermit
|
||||
|
||||
|
||||
# Check if hermit binary exists and download if not
|
||||
if [ ! -f ~/.config/goose/mcp-hermit/bin/hermit ]; then
|
||||
log "Hermit binary not found. Downloading hermit binary."
|
||||
curl -fsSL "https://github.com/cashapp/hermit/releases/download/stable/hermit-$(uname -s | tr '[:upper:]' '[:lower:]')-$(uname -m | sed 's/x86_64/amd64/' | sed 's/aarch64/arm64/').gz" \
|
||||
| gzip -dc > ~/.config/goose/mcp-hermit/bin/hermit && chmod +x ~/.config/goose/mcp-hermit/bin/hermit
|
||||
log "Hermit binary downloaded and made executable."
|
||||
else
|
||||
log "Hermit binary already exists. Skipping download."
|
||||
fi
|
||||
|
||||
|
||||
log "setting hermit cache to be local for MCP servers"
|
||||
mkdir -p ~/.config/goose/mcp-hermit/cache
|
||||
export HERMIT_STATE_DIR=~/.config/goose/mcp-hermit/cache
|
||||
|
||||
|
||||
# Update PATH
|
||||
export PATH=~/.config/goose/mcp-hermit/bin:$PATH
|
||||
log "Updated PATH to include ~/.config/goose/mcp-hermit/bin."
|
||||
|
||||
|
||||
# Verify hermit installation
|
||||
log "Checking for hermit in PATH."
|
||||
which hermit >> "$LOG_FILE"
|
||||
|
||||
# Initialize hermit
|
||||
log "Initializing hermit."
|
||||
hermit init >> "$LOG_FILE"
|
||||
|
||||
# Install Node.js using hermit
|
||||
log "Installing Node.js with hermit."
|
||||
hermit install node >> "$LOG_FILE"
|
||||
|
||||
# Verify installations
|
||||
log "Verifying installation locations:"
|
||||
log "hermit: $(which hermit)"
|
||||
log "node: $(which node)"
|
||||
log "npx: $(which npx)"
|
||||
|
||||
|
||||
log "Checking for GOOSE_NPM_REGISTRY and GOOSE_NPM_CERT environment variables for custom npm registry setup..."
|
||||
# Check if GOOSE_NPM_REGISTRY is set and accessible
|
||||
if [ -n "${GOOSE_NPM_REGISTRY:-}" ] && curl -s --head --fail "$GOOSE_NPM_REGISTRY" > /dev/null; then
|
||||
log "Checking custom goose registry availability: $GOOSE_NPM_REGISTRY"
|
||||
log "$GOOSE_NPM_REGISTRY is accessible. Using it for npm registry."
|
||||
export NPM_CONFIG_REGISTRY="$GOOSE_NPM_REGISTRY"
|
||||
|
||||
# Check if GOOSE_NPM_CERT is set and accessible
|
||||
if [ -n "${GOOSE_NPM_CERT:-}" ] && curl -s --head --fail "$GOOSE_NPM_CERT" > /dev/null; then
|
||||
log "Downloading certificate from: $GOOSE_NPM_CERT"
|
||||
curl -sSL -o ~/.config/goose/mcp-hermit/cert.pem "$GOOSE_NPM_CERT"
|
||||
if [ $? -eq 0 ]; then
|
||||
log "Certificate downloaded successfully."
|
||||
export NODE_EXTRA_CA_CERTS=~/.config/goose/mcp-hermit/cert.pem
|
||||
else
|
||||
log "Unable to download the certificate. Skipping certificate setup."
|
||||
fi
|
||||
else
|
||||
log "GOOSE_NPM_CERT is either not set or not accessible. Skipping certificate setup."
|
||||
fi
|
||||
|
||||
else
|
||||
log "GOOSE_NPM_REGISTRY is either not set or not accessible. Falling back to default npm registry."
|
||||
export NPM_CONFIG_REGISTRY="https://registry.npmjs.org/"
|
||||
fi
|
||||
|
||||
|
||||
|
||||
|
||||
# Final step: Execute npx with passed arguments
|
||||
log "Executing 'npx' command with arguments: $*"
|
||||
npx "$@" || log "Failed to execute 'npx' with arguments: $*"
|
||||
|
||||
log "npx setup script completed successfully."
|
||||
@@ -1,89 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Enable strict mode to exit on errors and unset variables
|
||||
set -euo pipefail
|
||||
|
||||
# Set log file
|
||||
LOG_FILE="/tmp/mcp.log"
|
||||
|
||||
# Clear the log file at the start
|
||||
> "$LOG_FILE"
|
||||
|
||||
# Function for logging
|
||||
log() {
|
||||
local MESSAGE="$1"
|
||||
echo "$(date +'%Y-%m-%d %H:%M:%S') - $MESSAGE" | tee -a "$LOG_FILE"
|
||||
}
|
||||
|
||||
# Trap errors and log them before exiting
|
||||
trap 'log "An error occurred. Exiting with status $?."' ERR
|
||||
|
||||
log "Starting uvx setup script."
|
||||
|
||||
# Ensure ~/.config/goose/mcp-hermit/bin exists
|
||||
log "Creating directory ~/.config/goose/mcp-hermit/bin if it does not exist."
|
||||
mkdir -p ~/.config/goose/mcp-hermit/bin
|
||||
|
||||
# Change to the ~/.config/goose/mcp-hermit directory
|
||||
log "Changing to directory ~/.config/goose/mcp-hermit."
|
||||
cd ~/.config/goose/mcp-hermit
|
||||
|
||||
# Check if hermit binary exists and download if not
|
||||
if [ ! -f ~/.config/goose/mcp-hermit/bin/hermit ]; then
|
||||
log "Hermit binary not found. Downloading hermit binary."
|
||||
curl -fsSL "https://github.com/cashapp/hermit/releases/download/stable/hermit-$(uname -s | tr '[:upper:]' '[:lower:]')-$(uname -m | sed 's/x86_64/amd64/' | sed 's/aarch64/arm64/').gz" \
|
||||
| gzip -dc > ~/.config/goose/mcp-hermit/bin/hermit && chmod +x ~/.config/goose/mcp-hermit/bin/hermit
|
||||
log "Hermit binary downloaded and made executable."
|
||||
else
|
||||
log "Hermit binary already exists. Skipping download."
|
||||
fi
|
||||
|
||||
|
||||
log "setting hermit cache to be local for MCP servers"
|
||||
mkdir -p ~/.config/goose/mcp-hermit/cache
|
||||
export HERMIT_STATE_DIR=~/.config/goose/mcp-hermit/cache
|
||||
|
||||
# Update PATH
|
||||
export PATH=~/.config/goose/mcp-hermit/bin:$PATH
|
||||
log "Updated PATH to include ~/.config/goose/mcp-hermit/bin."
|
||||
|
||||
|
||||
# Verify hermit installation
|
||||
log "Checking for hermit in PATH."
|
||||
which hermit >> "$LOG_FILE"
|
||||
|
||||
# Initialize hermit
|
||||
log "Initializing hermit."
|
||||
hermit init >> "$LOG_FILE"
|
||||
|
||||
# Initialize python >= 3.10
|
||||
log "hermit install python 3.10"
|
||||
hermit install python3@3.10 >> "$LOG_FILE"
|
||||
|
||||
# Install UV for python using hermit
|
||||
log "Installing UV with hermit."
|
||||
hermit install uv >> "$LOG_FILE"
|
||||
|
||||
# Verify installations
|
||||
log "Verifying installation locations:"
|
||||
log "hermit: $(which hermit)"
|
||||
log "uv: $(which uv)"
|
||||
log "uvx: $(which uvx)"
|
||||
|
||||
|
||||
log "Checking for GOOSE_UV_REGISTRY environment variable for custom python/pip/UV registry setup..."
|
||||
# Check if GOOSE_UV_REGISTRY is set and accessible
|
||||
if [ -n "${GOOSE_UV_REGISTRY:-}" ] && curl -s --head --fail "$GOOSE_UV_REGISTRY" > /dev/null; then
|
||||
log "Checking custom goose registry availability: $GOOSE_UV_REGISTRY"
|
||||
log "$GOOSE_UV_REGISTRY is accessible, setting it as UV_INDEX_URL. Setting UV_NATIVE_TLS to true."
|
||||
export UV_INDEX_URL="$GOOSE_UV_REGISTRY"
|
||||
export UV_NATIVE_TLS=true
|
||||
else
|
||||
log "Neither GOOSE_UV_REGISTRY nor UV_INDEX_URL is set. Falling back to default configuration."
|
||||
fi
|
||||
|
||||
# Final step: Execute uvx with passed arguments
|
||||
log "Executing 'uvx' command with arguments: $*"
|
||||
uvx "$@" || log "Failed to execute 'uvx' with arguments: $*"
|
||||
|
||||
log "uvx setup script completed successfully."
|
||||
@@ -1,4 +1,12 @@
|
||||
import React, { useEffect, useRef, useState, useMemo, useCallback, createContext, useContext } from 'react';
|
||||
import React, {
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
useMemo,
|
||||
useCallback,
|
||||
createContext,
|
||||
useContext,
|
||||
} from 'react';
|
||||
import { getApiUrl } from '../config';
|
||||
import FlappyGoose from './FlappyGoose';
|
||||
import GooseMessage from './GooseMessage';
|
||||
@@ -100,6 +108,7 @@ function ChatContent({
|
||||
const [sessionTokenCount, setSessionTokenCount] = useState<number>(0);
|
||||
const [ancestorMessages, setAncestorMessages] = useState<Message[]>([]);
|
||||
const [droppedFiles, setDroppedFiles] = useState<string[]>([]);
|
||||
const [readyForAutoUserPrompt, setReadyForAutoUserPrompt] = useState(false);
|
||||
|
||||
const scrollRef = useRef<ScrollAreaHandle>(null);
|
||||
|
||||
@@ -119,6 +128,8 @@ function ChatContent({
|
||||
window.electron.logInfo(
|
||||
'Initial messages when resuming session: ' + JSON.stringify(chat.messages, null, 2)
|
||||
);
|
||||
// Set ready for auto user prompt after component initialization
|
||||
setReadyForAutoUserPrompt(true);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []); // Empty dependency array means this runs once on mount;
|
||||
|
||||
@@ -152,7 +163,11 @@ function ChatContent({
|
||||
} = useMessageStream({
|
||||
api: getApiUrl('/reply'),
|
||||
initialMessages: chat.messages,
|
||||
body: { session_id: chat.id, session_working_dir: window.appConfig.get('GOOSE_WORKING_DIR') },
|
||||
body: {
|
||||
session_id: chat.id,
|
||||
session_working_dir: window.appConfig.get('GOOSE_WORKING_DIR'),
|
||||
...(recipeConfig?.scheduledJobId && { scheduled_job_id: recipeConfig.scheduledJobId }),
|
||||
},
|
||||
onFinish: async (_message, _reason) => {
|
||||
window.electron.stopPowerSaveBlocker();
|
||||
|
||||
@@ -297,6 +312,40 @@ function ChatContent({
|
||||
return recipeConfig?.prompt || '';
|
||||
}, [recipeConfig?.prompt]);
|
||||
|
||||
// Auto-send the prompt for scheduled executions
|
||||
useEffect(() => {
|
||||
if (
|
||||
recipeConfig?.isScheduledExecution &&
|
||||
recipeConfig?.prompt &&
|
||||
messages.length === 0 &&
|
||||
!isLoading &&
|
||||
readyForAutoUserPrompt
|
||||
) {
|
||||
console.log('Auto-sending prompt for scheduled execution:', recipeConfig.prompt);
|
||||
|
||||
// Create and send the user message
|
||||
const userMessage = createUserMessage(recipeConfig.prompt);
|
||||
setLastInteractionTime(Date.now());
|
||||
window.electron.startPowerSaveBlocker();
|
||||
append(userMessage);
|
||||
|
||||
// Scroll to bottom after sending
|
||||
setTimeout(() => {
|
||||
if (scrollRef.current?.scrollToBottom) {
|
||||
scrollRef.current.scrollToBottom();
|
||||
}
|
||||
}, 100);
|
||||
}
|
||||
}, [
|
||||
recipeConfig?.isScheduledExecution,
|
||||
recipeConfig?.prompt,
|
||||
messages.length,
|
||||
isLoading,
|
||||
readyForAutoUserPrompt,
|
||||
append,
|
||||
setLastInteractionTime,
|
||||
]);
|
||||
|
||||
// Handle submit
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
window.electron.startPowerSaveBlocker();
|
||||
@@ -512,148 +561,148 @@ function ChatContent({
|
||||
return (
|
||||
<CurrentModelContext.Provider value={currentModelInfo}>
|
||||
<div className="flex flex-col w-full h-screen items-center justify-center">
|
||||
{/* Loader when generating recipe */}
|
||||
{isGeneratingRecipe && <LayingEggLoader />}
|
||||
<MoreMenuLayout
|
||||
hasMessages={hasMessages}
|
||||
setView={setView}
|
||||
setIsGoosehintsModalOpen={setIsGoosehintsModalOpen}
|
||||
/>
|
||||
{/* Loader when generating recipe */}
|
||||
{isGeneratingRecipe && <LayingEggLoader />}
|
||||
<MoreMenuLayout
|
||||
hasMessages={hasMessages}
|
||||
setView={setView}
|
||||
setIsGoosehintsModalOpen={setIsGoosehintsModalOpen}
|
||||
/>
|
||||
|
||||
<Card
|
||||
className="flex flex-col flex-1 rounded-none h-[calc(100vh-95px)] w-full bg-bgApp mt-0 border-none relative"
|
||||
onDrop={handleDrop}
|
||||
onDragOver={handleDragOver}
|
||||
>
|
||||
{recipeConfig?.title && messages.length > 0 && (
|
||||
<AgentHeader
|
||||
title={recipeConfig.title}
|
||||
profileInfo={
|
||||
recipeConfig.profile
|
||||
? `${recipeConfig.profile} - ${recipeConfig.mcps || 12} MCPs`
|
||||
: undefined
|
||||
}
|
||||
onChangeProfile={() => {
|
||||
// Handle profile change
|
||||
console.log('Change profile clicked');
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{messages.length === 0 ? (
|
||||
<Splash
|
||||
append={append}
|
||||
activities={Array.isArray(recipeConfig?.activities) ? recipeConfig!.activities : null}
|
||||
title={recipeConfig?.title}
|
||||
/>
|
||||
) : (
|
||||
<ScrollArea ref={scrollRef} className="flex-1" autoScroll>
|
||||
<SearchView>
|
||||
{filteredMessages.map((message, index) => (
|
||||
<div
|
||||
key={message.id || index}
|
||||
className="mt-4 px-4"
|
||||
data-testid="message-container"
|
||||
>
|
||||
{isUserMessage(message) ? (
|
||||
<>
|
||||
{hasContextHandlerContent(message) ? (
|
||||
<ContextHandler
|
||||
messages={messages}
|
||||
messageId={message.id ?? message.created.toString()}
|
||||
chatId={chat.id}
|
||||
workingDir={window.appConfig.get('GOOSE_WORKING_DIR') as string}
|
||||
contextType={getContextHandlerType(message)}
|
||||
/>
|
||||
) : (
|
||||
<UserMessage message={message} />
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{/* Only render GooseMessage if it's not a message invoking some context management */}
|
||||
{hasContextHandlerContent(message) ? (
|
||||
<ContextHandler
|
||||
messages={messages}
|
||||
messageId={message.id ?? message.created.toString()}
|
||||
chatId={chat.id}
|
||||
workingDir={window.appConfig.get('GOOSE_WORKING_DIR') as string}
|
||||
contextType={getContextHandlerType(message)}
|
||||
/>
|
||||
) : (
|
||||
<GooseMessage
|
||||
messageHistoryIndex={chat?.messageHistoryIndex}
|
||||
message={message}
|
||||
messages={messages}
|
||||
append={append}
|
||||
appendMessage={(newMessage) => {
|
||||
const updatedMessages = [...messages, newMessage];
|
||||
setMessages(updatedMessages);
|
||||
}}
|
||||
toolCallNotifications={toolCallNotifications}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<Card
|
||||
className="flex flex-col flex-1 rounded-none h-[calc(100vh-95px)] w-full bg-bgApp mt-0 border-none relative"
|
||||
onDrop={handleDrop}
|
||||
onDragOver={handleDragOver}
|
||||
>
|
||||
{recipeConfig?.title && messages.length > 0 && (
|
||||
<AgentHeader
|
||||
title={recipeConfig.title}
|
||||
profileInfo={
|
||||
recipeConfig.profile
|
||||
? `${recipeConfig.profile} - ${recipeConfig.mcps || 12} MCPs`
|
||||
: undefined
|
||||
}
|
||||
onChangeProfile={() => {
|
||||
// Handle profile change
|
||||
console.log('Change profile clicked');
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{messages.length === 0 ? (
|
||||
<Splash
|
||||
append={append}
|
||||
activities={Array.isArray(recipeConfig?.activities) ? recipeConfig!.activities : null}
|
||||
title={recipeConfig?.title}
|
||||
/>
|
||||
) : (
|
||||
<ScrollArea ref={scrollRef} className="flex-1" autoScroll>
|
||||
<SearchView>
|
||||
{filteredMessages.map((message, index) => (
|
||||
<div
|
||||
key={message.id || index}
|
||||
className="mt-4 px-4"
|
||||
data-testid="message-container"
|
||||
>
|
||||
{isUserMessage(message) ? (
|
||||
<>
|
||||
{hasContextHandlerContent(message) ? (
|
||||
<ContextHandler
|
||||
messages={messages}
|
||||
messageId={message.id ?? message.created.toString()}
|
||||
chatId={chat.id}
|
||||
workingDir={window.appConfig.get('GOOSE_WORKING_DIR') as string}
|
||||
contextType={getContextHandlerType(message)}
|
||||
/>
|
||||
) : (
|
||||
<UserMessage message={message} />
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{/* Only render GooseMessage if it's not a message invoking some context management */}
|
||||
{hasContextHandlerContent(message) ? (
|
||||
<ContextHandler
|
||||
messages={messages}
|
||||
messageId={message.id ?? message.created.toString()}
|
||||
chatId={chat.id}
|
||||
workingDir={window.appConfig.get('GOOSE_WORKING_DIR') as string}
|
||||
contextType={getContextHandlerType(message)}
|
||||
/>
|
||||
) : (
|
||||
<GooseMessage
|
||||
messageHistoryIndex={chat?.messageHistoryIndex}
|
||||
message={message}
|
||||
messages={messages}
|
||||
append={append}
|
||||
appendMessage={(newMessage) => {
|
||||
const updatedMessages = [...messages, newMessage];
|
||||
setMessages(updatedMessages);
|
||||
}}
|
||||
toolCallNotifications={toolCallNotifications}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</SearchView>
|
||||
|
||||
{error && (
|
||||
<div className="flex flex-col items-center justify-center p-4">
|
||||
<div className="text-red-700 dark:text-red-300 bg-red-400/50 p-3 rounded-lg mb-2">
|
||||
{error.message || 'Honk! Goose experienced an error while responding'}
|
||||
</div>
|
||||
<div
|
||||
className="px-3 py-2 mt-2 text-center whitespace-nowrap cursor-pointer text-textStandard border border-borderSubtle hover:bg-bgSubtle rounded-full inline-block transition-all duration-150"
|
||||
onClick={async () => {
|
||||
// Find the last user message
|
||||
const lastUserMessage = messages.reduceRight(
|
||||
(found, m) => found || (m.role === 'user' ? m : null),
|
||||
null as Message | null
|
||||
);
|
||||
if (lastUserMessage) {
|
||||
append(lastUserMessage);
|
||||
}
|
||||
}}
|
||||
>
|
||||
Retry Last Message
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</SearchView>
|
||||
)}
|
||||
<div className="block h-8" />
|
||||
</ScrollArea>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="flex flex-col items-center justify-center p-4">
|
||||
<div className="text-red-700 dark:text-red-300 bg-red-400/50 p-3 rounded-lg mb-2">
|
||||
{error.message || 'Honk! Goose experienced an error while responding'}
|
||||
</div>
|
||||
<div
|
||||
className="px-3 py-2 mt-2 text-center whitespace-nowrap cursor-pointer text-textStandard border border-borderSubtle hover:bg-bgSubtle rounded-full inline-block transition-all duration-150"
|
||||
onClick={async () => {
|
||||
// Find the last user message
|
||||
const lastUserMessage = messages.reduceRight(
|
||||
(found, m) => found || (m.role === 'user' ? m : null),
|
||||
null as Message | null
|
||||
);
|
||||
if (lastUserMessage) {
|
||||
append(lastUserMessage);
|
||||
}
|
||||
}}
|
||||
>
|
||||
Retry Last Message
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="block h-8" />
|
||||
</ScrollArea>
|
||||
)}
|
||||
<div className="relative p-4 pt-0 z-10 animate-[fadein_400ms_ease-in_forwards]">
|
||||
{isLoading && <LoadingGoose />}
|
||||
<ChatInput
|
||||
handleSubmit={handleSubmit}
|
||||
isLoading={isLoading}
|
||||
onStop={onStopGoose}
|
||||
commandHistory={commandHistory}
|
||||
initialValue={_input || (hasMessages ? _input : initialPrompt)}
|
||||
setView={setView}
|
||||
hasMessages={hasMessages}
|
||||
numTokens={sessionTokenCount}
|
||||
droppedFiles={droppedFiles}
|
||||
messages={messages}
|
||||
setMessages={setMessages}
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<div className="relative p-4 pt-0 z-10 animate-[fadein_400ms_ease-in_forwards]">
|
||||
{isLoading && <LoadingGoose />}
|
||||
<ChatInput
|
||||
handleSubmit={handleSubmit}
|
||||
isLoading={isLoading}
|
||||
onStop={onStopGoose}
|
||||
commandHistory={commandHistory}
|
||||
initialValue={_input || (hasMessages ? _input : initialPrompt)}
|
||||
setView={setView}
|
||||
hasMessages={hasMessages}
|
||||
numTokens={sessionTokenCount}
|
||||
droppedFiles={droppedFiles}
|
||||
messages={messages}
|
||||
setMessages={setMessages}
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
{showGame && <FlappyGoose onClose={() => setShowGame(false)} />}
|
||||
|
||||
{showGame && <FlappyGoose onClose={() => setShowGame(false)} />}
|
||||
|
||||
<SessionSummaryModal
|
||||
isOpen={isSummaryModalOpen}
|
||||
onClose={closeSummaryModal}
|
||||
onSave={(editedContent) => {
|
||||
updateSummary(editedContent);
|
||||
closeSummaryModal();
|
||||
}}
|
||||
summaryContent={summaryContent}
|
||||
/>
|
||||
</div>
|
||||
<SessionSummaryModal
|
||||
isOpen={isSummaryModalOpen}
|
||||
onClose={closeSummaryModal}
|
||||
onSave={(editedContent) => {
|
||||
updateSummary(editedContent);
|
||||
closeSummaryModal();
|
||||
}}
|
||||
summaryContent={summaryContent}
|
||||
/>
|
||||
</div>
|
||||
</CurrentModelContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -332,15 +332,13 @@ export default function RecipeEditor({ config }: RecipeEditorProps) {
|
||||
</div>
|
||||
{/* Action Buttons */}
|
||||
<div className="flex flex-col space-y-2 pt-1">
|
||||
{process.env.ALPHA && (
|
||||
<button
|
||||
onClick={() => setIsScheduleModalOpen(true)}
|
||||
disabled={!requiredFieldsAreFilled()}
|
||||
className="w-full h-[60px] rounded-none border-t text-gray-900 dark:text-white hover:bg-gray-50 dark:border-gray-600 text-lg font-medium"
|
||||
>
|
||||
Create Schedule from Recipe
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => setIsScheduleModalOpen(true)}
|
||||
disabled={!requiredFieldsAreFilled()}
|
||||
className="w-full h-[60px] rounded-none border-t text-gray-900 dark:text-white hover:bg-gray-50 dark:border-gray-600 text-lg font-medium"
|
||||
>
|
||||
Create Schedule from Recipe
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
localStorage.removeItem('recipe_editor_extensions');
|
||||
|
||||
@@ -292,15 +292,13 @@ export default function MoreMenu({
|
||||
Session history
|
||||
</MenuButton>
|
||||
|
||||
{process.env.ALPHA && (
|
||||
<MenuButton
|
||||
onClick={() => setView('schedules')}
|
||||
subtitle="Manage scheduled runs"
|
||||
icon={<Time className="w-4 h-4" />}
|
||||
>
|
||||
Scheduler
|
||||
</MenuButton>
|
||||
)}
|
||||
<MenuButton
|
||||
onClick={() => setView('schedules')}
|
||||
subtitle="Manage scheduled runs"
|
||||
icon={<Time className="w-4 h-4" />}
|
||||
>
|
||||
Scheduler
|
||||
</MenuButton>
|
||||
|
||||
<MenuButton
|
||||
onClick={() => setIsGoosehintsModalOpen(true)}
|
||||
|
||||
@@ -9,7 +9,9 @@ import { Buffer } from 'buffer';
|
||||
import { Recipe } from '../../recipe';
|
||||
import ClockIcon from '../../assets/clock-icon.svg';
|
||||
|
||||
type FrequencyValue = 'once' | 'hourly' | 'daily' | 'weekly' | 'monthly';
|
||||
type FrequencyValue = 'once' | 'every' | 'daily' | 'weekly' | 'monthly';
|
||||
|
||||
type CustomIntervalUnit = 'minute' | 'hour' | 'day';
|
||||
|
||||
interface FrequencyOption {
|
||||
value: FrequencyValue;
|
||||
@@ -20,6 +22,7 @@ export interface NewSchedulePayload {
|
||||
id: string;
|
||||
recipe_source: string;
|
||||
cron: string;
|
||||
execution_mode?: string;
|
||||
}
|
||||
|
||||
interface CreateScheduleModalProps {
|
||||
@@ -61,14 +64,26 @@ interface CleanRecipe {
|
||||
contact?: string;
|
||||
metadata?: string;
|
||||
};
|
||||
schedule?: {
|
||||
foreground: boolean;
|
||||
fallback_to_background: boolean;
|
||||
window_title?: string;
|
||||
working_directory?: string;
|
||||
};
|
||||
}
|
||||
|
||||
const frequencies: FrequencyOption[] = [
|
||||
{ value: 'once', label: 'Once' },
|
||||
{ value: 'hourly', label: 'Hourly' },
|
||||
{ value: 'daily', label: 'Daily' },
|
||||
{ value: 'weekly', label: 'Weekly' },
|
||||
{ value: 'monthly', label: 'Monthly' },
|
||||
{ value: 'every', label: 'Every...' },
|
||||
{ value: 'daily', label: 'Daily (at specific time)' },
|
||||
{ value: 'weekly', label: 'Weekly (at specific time/days)' },
|
||||
{ value: 'monthly', label: 'Monthly (at specific time/day)' },
|
||||
];
|
||||
|
||||
const customIntervalUnits: { value: CustomIntervalUnit; label: string }[] = [
|
||||
{ value: 'minute', label: 'minute(s)' },
|
||||
{ value: 'hour', label: 'hour(s)' },
|
||||
{ value: 'day', label: 'day(s)' },
|
||||
];
|
||||
|
||||
const daysOfWeekOptions: { value: string; label: string }[] = [
|
||||
@@ -89,6 +104,7 @@ const checkboxInputClassName =
|
||||
'h-4 w-4 text-indigo-600 border-gray-300 dark:border-gray-600 rounded focus:ring-indigo-500 mr-2';
|
||||
|
||||
type SourceType = 'file' | 'deeplink';
|
||||
type ExecutionMode = 'background' | 'foreground';
|
||||
|
||||
// Function to parse deep link and extract recipe config
|
||||
function parseDeepLink(deepLink: string): Recipe | null {
|
||||
@@ -111,8 +127,8 @@ function parseDeepLink(deepLink: string): Recipe | null {
|
||||
}
|
||||
}
|
||||
|
||||
// Function to convert recipe to YAML
|
||||
function recipeToYaml(recipe: Recipe): string {
|
||||
// Function to convert recipe to YAML with schedule configuration
|
||||
function recipeToYaml(recipe: Recipe, executionMode: ExecutionMode): string {
|
||||
// Create a clean recipe object for YAML conversion
|
||||
const cleanRecipe: CleanRecipe = {
|
||||
title: recipe.title,
|
||||
@@ -230,6 +246,13 @@ function recipeToYaml(recipe: Recipe): string {
|
||||
cleanRecipe.author = recipe.author;
|
||||
}
|
||||
|
||||
// Add schedule configuration based on execution mode
|
||||
cleanRecipe.schedule = {
|
||||
foreground: executionMode === 'foreground',
|
||||
fallback_to_background: true, // Always allow fallback
|
||||
window_title: executionMode === 'foreground' ? `${recipe.title} - Scheduled` : undefined,
|
||||
};
|
||||
|
||||
return yaml.stringify(cleanRecipe);
|
||||
}
|
||||
|
||||
@@ -242,10 +265,13 @@ export const CreateScheduleModal: React.FC<CreateScheduleModalProps> = ({
|
||||
}) => {
|
||||
const [scheduleId, setScheduleId] = useState<string>('');
|
||||
const [sourceType, setSourceType] = useState<SourceType>('file');
|
||||
const [executionMode, setExecutionMode] = useState<ExecutionMode>('background');
|
||||
const [recipeSourcePath, setRecipeSourcePath] = useState<string>('');
|
||||
const [deepLinkInput, setDeepLinkInput] = useState<string>('');
|
||||
const [parsedRecipe, setParsedRecipe] = useState<Recipe | null>(null);
|
||||
const [frequency, setFrequency] = useState<FrequencyValue>('daily');
|
||||
const [customIntervalValue, setCustomIntervalValue] = useState<number>(1);
|
||||
const [customIntervalUnit, setCustomIntervalUnit] = useState<CustomIntervalUnit>('minute');
|
||||
const [selectedDate, setSelectedDate] = useState<string>(
|
||||
() => new Date().toISOString().split('T')[0]
|
||||
);
|
||||
@@ -302,10 +328,13 @@ export const CreateScheduleModal: React.FC<CreateScheduleModalProps> = ({
|
||||
const resetForm = () => {
|
||||
setScheduleId('');
|
||||
setSourceType('file');
|
||||
setExecutionMode('background');
|
||||
setRecipeSourcePath('');
|
||||
setDeepLinkInput('');
|
||||
setParsedRecipe(null);
|
||||
setFrequency('daily');
|
||||
setCustomIntervalValue(1);
|
||||
setCustomIntervalUnit('minute');
|
||||
setSelectedDate(new Date().toISOString().split('T')[0]);
|
||||
setSelectedTime('09:00');
|
||||
setSelectedMinute('0');
|
||||
@@ -336,14 +365,15 @@ export const CreateScheduleModal: React.FC<CreateScheduleModalProps> = ({
|
||||
if (isNaN(parseInt(minutePart)) || isNaN(parseInt(hourPart))) {
|
||||
return 'Invalid time format.';
|
||||
}
|
||||
const secondsPart = '0';
|
||||
|
||||
// Temporal uses 5-field cron: minute hour day month dayofweek (no seconds)
|
||||
switch (frequency) {
|
||||
case 'once':
|
||||
if (selectedDate && selectedTime) {
|
||||
try {
|
||||
const dateObj = new Date(`${selectedDate}T${selectedTime}`);
|
||||
if (isNaN(dateObj.getTime())) return "Invalid date/time for 'once'.";
|
||||
return `${secondsPart} ${dateObj.getMinutes()} ${dateObj.getHours()} ${dateObj.getDate()} ${
|
||||
return `${dateObj.getMinutes()} ${dateObj.getHours()} ${dateObj.getDate()} ${
|
||||
dateObj.getMonth() + 1
|
||||
} *`;
|
||||
} catch (e) {
|
||||
@@ -351,15 +381,23 @@ export const CreateScheduleModal: React.FC<CreateScheduleModalProps> = ({
|
||||
}
|
||||
}
|
||||
return 'Date and Time are required for "Once" frequency.';
|
||||
case 'hourly': {
|
||||
const sMinute = parseInt(selectedMinute, 10);
|
||||
if (isNaN(sMinute) || sMinute < 0 || sMinute > 59) {
|
||||
return 'Invalid minute (0-59) for hourly frequency.';
|
||||
case 'every': {
|
||||
if (customIntervalValue <= 0) {
|
||||
return 'Custom interval value must be greater than 0.';
|
||||
}
|
||||
switch (customIntervalUnit) {
|
||||
case 'minute':
|
||||
return `*/${customIntervalValue} * * * *`;
|
||||
case 'hour':
|
||||
return `0 */${customIntervalValue} * * *`;
|
||||
case 'day':
|
||||
return `0 0 */${customIntervalValue} * *`;
|
||||
default:
|
||||
return 'Invalid custom interval unit.';
|
||||
}
|
||||
return `${secondsPart} ${sMinute} * * * *`;
|
||||
}
|
||||
case 'daily':
|
||||
return `${secondsPart} ${minutePart} ${hourPart} * * *`;
|
||||
return `${minutePart} ${hourPart} * * *`;
|
||||
case 'weekly': {
|
||||
if (selectedDaysOfWeek.size === 0) {
|
||||
return 'Select at least one day for weekly frequency.';
|
||||
@@ -367,14 +405,14 @@ export const CreateScheduleModal: React.FC<CreateScheduleModalProps> = ({
|
||||
const days = Array.from(selectedDaysOfWeek)
|
||||
.sort((a, b) => parseInt(a) - parseInt(b))
|
||||
.join(',');
|
||||
return `${secondsPart} ${minutePart} ${hourPart} * * ${days}`;
|
||||
return `${minutePart} ${hourPart} * * ${days}`;
|
||||
}
|
||||
case 'monthly': {
|
||||
const sDayOfMonth = parseInt(selectedDayOfMonth, 10);
|
||||
if (isNaN(sDayOfMonth) || sDayOfMonth < 1 || sDayOfMonth > 31) {
|
||||
return 'Invalid day of month (1-31) for monthly frequency.';
|
||||
}
|
||||
return `${secondsPart} ${minutePart} ${hourPart} ${sDayOfMonth} * *`;
|
||||
return `${minutePart} ${hourPart} ${sDayOfMonth} * *`;
|
||||
}
|
||||
default:
|
||||
return 'Invalid frequency selected.';
|
||||
@@ -398,6 +436,8 @@ export const CreateScheduleModal: React.FC<CreateScheduleModalProps> = ({
|
||||
}
|
||||
}, [
|
||||
frequency,
|
||||
customIntervalValue,
|
||||
customIntervalUnit,
|
||||
selectedDate,
|
||||
selectedTime,
|
||||
selectedMinute,
|
||||
@@ -446,7 +486,7 @@ export const CreateScheduleModal: React.FC<CreateScheduleModalProps> = ({
|
||||
|
||||
try {
|
||||
// Convert recipe to YAML and save to a temporary file
|
||||
const yamlContent = recipeToYaml(parsedRecipe);
|
||||
const yamlContent = recipeToYaml(parsedRecipe, executionMode);
|
||||
console.log('Generated YAML content:', yamlContent); // Debug log
|
||||
const tempFileName = `schedule-${scheduleId}-${Date.now()}.yaml`;
|
||||
const tempDir = window.electron.getConfig().GOOSE_WORKING_DIR || '.';
|
||||
@@ -486,6 +526,7 @@ export const CreateScheduleModal: React.FC<CreateScheduleModalProps> = ({
|
||||
id: scheduleId.trim(),
|
||||
recipe_source: finalRecipeSource,
|
||||
cron: derivedCronExpression,
|
||||
execution_mode: executionMode,
|
||||
};
|
||||
|
||||
await onSubmit(newSchedulePayload);
|
||||
@@ -587,6 +628,19 @@ export const CreateScheduleModal: React.FC<CreateScheduleModalProps> = ({
|
||||
Selected: {recipeSourcePath}
|
||||
</p>
|
||||
)}
|
||||
{executionMode === 'foreground' && (
|
||||
<div className="mt-2 p-2 bg-blue-50 dark:bg-blue-900/20 rounded-md border border-blue-200 dark:border-blue-800">
|
||||
<p className="text-xs text-blue-700 dark:text-blue-300">
|
||||
<strong>Note:</strong> For foreground execution with YAML files, add this to
|
||||
your recipe:
|
||||
</p>
|
||||
<pre className="text-xs text-blue-600 dark:text-blue-400 mt-1 font-mono bg-blue-100 dark:bg-blue-900/40 p-1 rounded">
|
||||
{`schedule:
|
||||
foreground: true
|
||||
fallback_to_background: true`}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -617,6 +671,50 @@ export const CreateScheduleModal: React.FC<CreateScheduleModalProps> = ({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className={modalLabelClassName}>Execution Mode:</label>
|
||||
<div className="space-y-2">
|
||||
<div className="flex bg-gray-100 dark:bg-gray-700 rounded-full p-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExecutionMode('background')}
|
||||
className={`flex-1 px-4 py-2 text-sm font-medium rounded-full transition-all ${
|
||||
executionMode === 'background'
|
||||
? 'bg-white dark:bg-gray-800 text-gray-900 dark:text-white shadow-sm'
|
||||
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white'
|
||||
}`}
|
||||
>
|
||||
Background
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExecutionMode('foreground')}
|
||||
className={`flex-1 px-4 py-2 text-sm font-medium rounded-full transition-all ${
|
||||
executionMode === 'foreground'
|
||||
? 'bg-white dark:bg-gray-800 text-gray-900 dark:text-white shadow-sm'
|
||||
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white'
|
||||
}`}
|
||||
>
|
||||
Foreground
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400 px-2">
|
||||
{executionMode === 'background' ? (
|
||||
<p>
|
||||
<strong>Background:</strong> Runs silently in the background without opening a
|
||||
window. Results are saved to session storage.
|
||||
</p>
|
||||
) : (
|
||||
<p>
|
||||
<strong>Foreground:</strong> Opens in a desktop window when the Goose app is
|
||||
running. Falls back to background if the app is not available.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="frequency-modal" className={modalLabelClassName}>
|
||||
Frequency:
|
||||
@@ -633,6 +731,43 @@ export const CreateScheduleModal: React.FC<CreateScheduleModalProps> = ({
|
||||
/>
|
||||
</div>
|
||||
|
||||
{frequency === 'every' && (
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label htmlFor="customIntervalValue-modal" className={modalLabelClassName}>
|
||||
Every:
|
||||
</label>
|
||||
<Input
|
||||
type="number"
|
||||
id="customIntervalValue-modal"
|
||||
min="1"
|
||||
max="999"
|
||||
value={customIntervalValue}
|
||||
onChange={(e) => setCustomIntervalValue(parseInt(e.target.value) || 1)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="customIntervalUnit-modal" className={modalLabelClassName}>
|
||||
Unit:
|
||||
</label>
|
||||
<Select
|
||||
instanceId="custom-interval-unit-select-modal"
|
||||
options={customIntervalUnits}
|
||||
value={customIntervalUnits.find((u) => u.value === customIntervalUnit)}
|
||||
onChange={(newValue: unknown) => {
|
||||
const selectedUnit = newValue as {
|
||||
value: CustomIntervalUnit;
|
||||
label: string;
|
||||
} | null;
|
||||
if (selectedUnit) setCustomIntervalUnit(selectedUnit.value);
|
||||
}}
|
||||
placeholder="Select unit..."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{frequency === 'once' && (
|
||||
<>
|
||||
<div>
|
||||
@@ -661,22 +796,6 @@ export const CreateScheduleModal: React.FC<CreateScheduleModalProps> = ({
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{frequency === 'hourly' && (
|
||||
<div>
|
||||
<label htmlFor="hourlyMinute-modal" className={modalLabelClassName}>
|
||||
Minute of the hour (0-59):
|
||||
</label>
|
||||
<Input
|
||||
type="number"
|
||||
id="hourlyMinute-modal"
|
||||
min="0"
|
||||
max="59"
|
||||
value={selectedMinute}
|
||||
onChange={(e) => setSelectedMinute(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{(frequency === 'daily' || frequency === 'weekly' || frequency === 'monthly') && (
|
||||
<div>
|
||||
<label htmlFor="commonTime-modal" className={modalLabelClassName}>
|
||||
@@ -736,7 +855,9 @@ export const CreateScheduleModal: React.FC<CreateScheduleModalProps> = ({
|
||||
<p className={`${cronPreviewTextColor} mt-2`}>
|
||||
<b>Human Readable:</b> {readableCronExpression}
|
||||
</p>
|
||||
<p className={cronPreviewTextColor}>Syntax: S M H D M DoW. (S=0, DoW: 0/7=Sun)</p>
|
||||
<p className={cronPreviewTextColor}>
|
||||
Syntax: M H D M DoW (M=minute, H=hour, D=day, M=month, DoW=day of week: 0/7=Sun)
|
||||
</p>
|
||||
{frequency === 'once' && (
|
||||
<p className={cronPreviewSpecialNoteColor}>
|
||||
Note: "Once" schedules recur annually. True one-time tasks may need backend deletion
|
||||
|
||||
@@ -6,7 +6,9 @@ import { Select } from '../ui/Select';
|
||||
import { ScheduledJob } from '../../schedule';
|
||||
import cronstrue from 'cronstrue';
|
||||
|
||||
type FrequencyValue = 'once' | 'hourly' | 'daily' | 'weekly' | 'monthly';
|
||||
type FrequencyValue = 'once' | 'every' | 'daily' | 'weekly' | 'monthly';
|
||||
|
||||
type CustomIntervalUnit = 'minute' | 'hour' | 'day';
|
||||
|
||||
interface FrequencyOption {
|
||||
value: FrequencyValue;
|
||||
@@ -24,10 +26,16 @@ interface EditScheduleModalProps {
|
||||
|
||||
const frequencies: FrequencyOption[] = [
|
||||
{ value: 'once', label: 'Once' },
|
||||
{ value: 'hourly', label: 'Hourly' },
|
||||
{ value: 'daily', label: 'Daily' },
|
||||
{ value: 'weekly', label: 'Weekly' },
|
||||
{ value: 'monthly', label: 'Monthly' },
|
||||
{ value: 'every', label: 'Every...' },
|
||||
{ value: 'daily', label: 'Daily (at specific time)' },
|
||||
{ value: 'weekly', label: 'Weekly (at specific time/days)' },
|
||||
{ value: 'monthly', label: 'Monthly (at specific time/day)' },
|
||||
];
|
||||
|
||||
const customIntervalUnits: { value: CustomIntervalUnit; label: string }[] = [
|
||||
{ value: 'minute', label: 'minute(s)' },
|
||||
{ value: 'hour', label: 'hour(s)' },
|
||||
{ value: 'day', label: 'day(s)' },
|
||||
];
|
||||
|
||||
const daysOfWeekOptions: { value: string; label: string }[] = [
|
||||
@@ -50,22 +58,59 @@ const checkboxInputClassName =
|
||||
// Helper function to parse cron expression and determine frequency
|
||||
const parseCronExpression = (cron: string) => {
|
||||
const parts = cron.split(' ');
|
||||
if (parts.length !== 6) return null;
|
||||
if (parts.length !== 5 && parts.length !== 6) return null;
|
||||
|
||||
const [_seconds, minutes, hours, dayOfMonth, month, dayOfWeek] = parts;
|
||||
// Handle both 5-field and 6-field cron expressions
|
||||
const [minutes, hours, dayOfMonth, month, dayOfWeek] =
|
||||
parts.length === 5 ? parts : parts.slice(1); // Skip seconds if present
|
||||
|
||||
// Check for specific patterns
|
||||
if (dayOfMonth !== '*' && month !== '*' && dayOfWeek === '*') {
|
||||
return { frequency: 'once' as FrequencyValue, minutes, hours, dayOfMonth, month };
|
||||
}
|
||||
// Check for custom intervals (every X minutes/hours/days)
|
||||
if (
|
||||
minutes !== '*' &&
|
||||
minutes.startsWith('*/') &&
|
||||
hours === '*' &&
|
||||
dayOfMonth === '*' &&
|
||||
month === '*' &&
|
||||
dayOfWeek === '*'
|
||||
) {
|
||||
return { frequency: 'hourly' as FrequencyValue, minutes };
|
||||
const intervalValue = parseInt(minutes.substring(2));
|
||||
return {
|
||||
frequency: 'every' as FrequencyValue,
|
||||
customIntervalValue: intervalValue,
|
||||
customIntervalUnit: 'minute' as CustomIntervalUnit,
|
||||
};
|
||||
}
|
||||
if (
|
||||
minutes === '0' &&
|
||||
hours.startsWith('*/') &&
|
||||
dayOfMonth === '*' &&
|
||||
month === '*' &&
|
||||
dayOfWeek === '*'
|
||||
) {
|
||||
const intervalValue = parseInt(hours.substring(2));
|
||||
return {
|
||||
frequency: 'every' as FrequencyValue,
|
||||
customIntervalValue: intervalValue,
|
||||
customIntervalUnit: 'hour' as CustomIntervalUnit,
|
||||
};
|
||||
}
|
||||
if (
|
||||
minutes === '0' &&
|
||||
hours === '0' &&
|
||||
dayOfMonth.startsWith('*/') &&
|
||||
month === '*' &&
|
||||
dayOfWeek === '*'
|
||||
) {
|
||||
const intervalValue = parseInt(dayOfMonth.substring(2));
|
||||
return {
|
||||
frequency: 'every' as FrequencyValue,
|
||||
customIntervalValue: intervalValue,
|
||||
customIntervalUnit: 'day' as CustomIntervalUnit,
|
||||
};
|
||||
}
|
||||
|
||||
// Check for specific patterns
|
||||
if (dayOfMonth !== '*' && month !== '*' && dayOfWeek === '*') {
|
||||
return { frequency: 'once' as FrequencyValue, minutes, hours, dayOfMonth, month };
|
||||
}
|
||||
if (
|
||||
minutes !== '*' &&
|
||||
@@ -107,11 +152,13 @@ export const EditScheduleModal: React.FC<EditScheduleModalProps> = ({
|
||||
apiErrorExternally = null,
|
||||
}) => {
|
||||
const [frequency, setFrequency] = useState<FrequencyValue>('daily');
|
||||
const [customIntervalValue, setCustomIntervalValue] = useState<number>(1);
|
||||
const [customIntervalUnit, setCustomIntervalUnit] = useState<CustomIntervalUnit>('minute');
|
||||
const [selectedDate, setSelectedDate] = useState<string>(
|
||||
() => new Date().toISOString().split('T')[0]
|
||||
);
|
||||
const [selectedTime, setSelectedTime] = useState<string>('09:00');
|
||||
const [selectedMinute, setSelectedMinute] = useState<string>('0');
|
||||
const [selectedMinute] = useState<string>('0');
|
||||
const [selectedDaysOfWeek, setSelectedDaysOfWeek] = useState<Set<string>>(new Set(['1']));
|
||||
const [selectedDayOfMonth, setSelectedDayOfMonth] = useState<string>('1');
|
||||
const [derivedCronExpression, setDerivedCronExpression] = useState<string>('');
|
||||
@@ -135,8 +182,13 @@ export const EditScheduleModal: React.FC<EditScheduleModalProps> = ({
|
||||
`${parsed.hours?.padStart(2, '0')}:${parsed.minutes?.padStart(2, '0')}`
|
||||
);
|
||||
break;
|
||||
case 'hourly':
|
||||
setSelectedMinute(parsed.minutes || '0');
|
||||
case 'every':
|
||||
if (parsed.customIntervalValue) {
|
||||
setCustomIntervalValue(parsed.customIntervalValue);
|
||||
}
|
||||
if (parsed.customIntervalUnit) {
|
||||
setCustomIntervalUnit(parsed.customIntervalUnit);
|
||||
}
|
||||
break;
|
||||
case 'daily':
|
||||
setSelectedTime(
|
||||
@@ -177,14 +229,13 @@ export const EditScheduleModal: React.FC<EditScheduleModalProps> = ({
|
||||
if (isNaN(parseInt(minutePart)) || isNaN(parseInt(hourPart))) {
|
||||
return 'Invalid time format.';
|
||||
}
|
||||
const secondsPart = '0';
|
||||
switch (frequency) {
|
||||
case 'once':
|
||||
if (selectedDate && selectedTime) {
|
||||
try {
|
||||
const dateObj = new Date(`${selectedDate}T${selectedTime}`);
|
||||
if (isNaN(dateObj.getTime())) return "Invalid date/time for 'once'.";
|
||||
return `${secondsPart} ${dateObj.getMinutes()} ${dateObj.getHours()} ${dateObj.getDate()} ${
|
||||
return `${dateObj.getMinutes()} ${dateObj.getHours()} ${dateObj.getDate()} ${
|
||||
dateObj.getMonth() + 1
|
||||
} *`;
|
||||
} catch (e) {
|
||||
@@ -192,15 +243,23 @@ export const EditScheduleModal: React.FC<EditScheduleModalProps> = ({
|
||||
}
|
||||
}
|
||||
return 'Date and Time are required for "Once" frequency.';
|
||||
case 'hourly': {
|
||||
const sMinute = parseInt(selectedMinute, 10);
|
||||
if (isNaN(sMinute) || sMinute < 0 || sMinute > 59) {
|
||||
return 'Invalid minute (0-59) for hourly frequency.';
|
||||
case 'every': {
|
||||
if (customIntervalValue <= 0) {
|
||||
return 'Custom interval value must be greater than 0.';
|
||||
}
|
||||
switch (customIntervalUnit) {
|
||||
case 'minute':
|
||||
return `*/${customIntervalValue} * * * *`;
|
||||
case 'hour':
|
||||
return `0 */${customIntervalValue} * * *`;
|
||||
case 'day':
|
||||
return `0 0 */${customIntervalValue} * *`;
|
||||
default:
|
||||
return 'Invalid custom interval unit.';
|
||||
}
|
||||
return `${secondsPart} ${sMinute} * * * *`;
|
||||
}
|
||||
case 'daily':
|
||||
return `${secondsPart} ${minutePart} ${hourPart} * * *`;
|
||||
return `${minutePart} ${hourPart} * * *`;
|
||||
case 'weekly': {
|
||||
if (selectedDaysOfWeek.size === 0) {
|
||||
return 'Select at least one day for weekly frequency.';
|
||||
@@ -208,14 +267,14 @@ export const EditScheduleModal: React.FC<EditScheduleModalProps> = ({
|
||||
const days = Array.from(selectedDaysOfWeek)
|
||||
.sort((a, b) => parseInt(a) - parseInt(b))
|
||||
.join(',');
|
||||
return `${secondsPart} ${minutePart} ${hourPart} * * ${days}`;
|
||||
return `${minutePart} ${hourPart} * * ${days}`;
|
||||
}
|
||||
case 'monthly': {
|
||||
const sDayOfMonth = parseInt(selectedDayOfMonth, 10);
|
||||
if (isNaN(sDayOfMonth) || sDayOfMonth < 1 || sDayOfMonth > 31) {
|
||||
return 'Invalid day of month (1-31) for monthly frequency.';
|
||||
}
|
||||
return `${secondsPart} ${minutePart} ${hourPart} ${sDayOfMonth} * *`;
|
||||
return `${minutePart} ${hourPart} ${sDayOfMonth} * *`;
|
||||
}
|
||||
default:
|
||||
return 'Invalid frequency selected.';
|
||||
@@ -239,6 +298,8 @@ export const EditScheduleModal: React.FC<EditScheduleModalProps> = ({
|
||||
}
|
||||
}, [
|
||||
frequency,
|
||||
customIntervalValue,
|
||||
customIntervalUnit,
|
||||
selectedDate,
|
||||
selectedTime,
|
||||
selectedMinute,
|
||||
@@ -327,6 +388,43 @@ export const EditScheduleModal: React.FC<EditScheduleModalProps> = ({
|
||||
/>
|
||||
</div>
|
||||
|
||||
{frequency === 'every' && (
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label htmlFor="customIntervalValue-modal" className={modalLabelClassName}>
|
||||
Every:
|
||||
</label>
|
||||
<Input
|
||||
type="number"
|
||||
id="customIntervalValue-modal"
|
||||
min="1"
|
||||
max="999"
|
||||
value={customIntervalValue}
|
||||
onChange={(e) => setCustomIntervalValue(parseInt(e.target.value) || 1)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="customIntervalUnit-modal" className={modalLabelClassName}>
|
||||
Unit:
|
||||
</label>
|
||||
<Select
|
||||
instanceId="custom-interval-unit-select-modal"
|
||||
options={customIntervalUnits}
|
||||
value={customIntervalUnits.find((u) => u.value === customIntervalUnit)}
|
||||
onChange={(newValue: unknown) => {
|
||||
const selectedUnit = newValue as {
|
||||
value: CustomIntervalUnit;
|
||||
label: string;
|
||||
} | null;
|
||||
if (selectedUnit) setCustomIntervalUnit(selectedUnit.value);
|
||||
}}
|
||||
placeholder="Select unit..."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{frequency === 'once' && (
|
||||
<>
|
||||
<div>
|
||||
@@ -355,22 +453,6 @@ export const EditScheduleModal: React.FC<EditScheduleModalProps> = ({
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{frequency === 'hourly' && (
|
||||
<div>
|
||||
<label htmlFor="hourlyMinute-modal" className={modalLabelClassName}>
|
||||
Minute of the hour (0-59):
|
||||
</label>
|
||||
<Input
|
||||
type="number"
|
||||
id="hourlyMinute-modal"
|
||||
min="0"
|
||||
max="59"
|
||||
value={selectedMinute}
|
||||
onChange={(e) => setSelectedMinute(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{(frequency === 'daily' || frequency === 'weekly' || frequency === 'monthly') && (
|
||||
<div>
|
||||
<label htmlFor="commonTime-modal" className={modalLabelClassName}>
|
||||
@@ -430,7 +512,9 @@ export const EditScheduleModal: React.FC<EditScheduleModalProps> = ({
|
||||
<p className={`${cronPreviewTextColor} mt-2`}>
|
||||
<b>Human Readable:</b> {readableCronExpression}
|
||||
</p>
|
||||
<p className={cronPreviewTextColor}>Syntax: S M H D M DoW. (S=0, DoW: 0/7=Sun)</p>
|
||||
<p className={cronPreviewTextColor}>
|
||||
Syntax: M H D M DoW (M=minute, H=hour, D=day, M=month, DoW=day of week: 0/7=Sun)
|
||||
</p>
|
||||
{frequency === 'once' && (
|
||||
<p className={cronPreviewSpecialNoteColor}>
|
||||
Note: "Once" schedules recur annually. True one-time tasks may need backend deletion
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import React, { useState, useEffect, useCallback, useMemo } from 'react';
|
||||
import { Button } from '../ui/button';
|
||||
import { ScrollArea } from '../ui/scroll-area';
|
||||
import BackButton from '../ui/BackButton';
|
||||
@@ -21,6 +21,7 @@ import { EditScheduleModal } from './EditScheduleModal';
|
||||
import { toastError, toastSuccess } from '../../toasts';
|
||||
import { Loader2, Pause, Play, Edit, Square, Eye } from 'lucide-react';
|
||||
import cronstrue from 'cronstrue';
|
||||
import { formatToLocalDateWithTimezone } from '../../utils/date';
|
||||
|
||||
interface ScheduleSessionMeta {
|
||||
id: string;
|
||||
@@ -42,6 +43,95 @@ interface ScheduleDetailViewProps {
|
||||
onNavigateBack: () => void;
|
||||
}
|
||||
|
||||
// Memoized ScheduleInfoCard component to prevent unnecessary re-renders of static content
|
||||
const ScheduleInfoCard = React.memo<{
|
||||
scheduleDetails: ScheduledJob;
|
||||
}>(({ scheduleDetails }) => {
|
||||
const readableCron = useMemo(() => {
|
||||
try {
|
||||
return cronstrue.toString(scheduleDetails.cron);
|
||||
} catch (e) {
|
||||
console.warn(`Could not parse cron string "${scheduleDetails.cron}":`, e);
|
||||
return scheduleDetails.cron;
|
||||
}
|
||||
}, [scheduleDetails.cron]);
|
||||
|
||||
const formattedLastRun = useMemo(() => {
|
||||
return formatToLocalDateWithTimezone(scheduleDetails.last_run);
|
||||
}, [scheduleDetails.last_run]);
|
||||
|
||||
const formattedProcessStartTime = useMemo(() => {
|
||||
return scheduleDetails.process_start_time
|
||||
? formatToLocalDateWithTimezone(scheduleDetails.process_start_time)
|
||||
: null;
|
||||
}, [scheduleDetails.process_start_time]);
|
||||
|
||||
return (
|
||||
<Card className="p-4 bg-white dark:bg-gray-800 shadow mb-6">
|
||||
<div className="space-y-2">
|
||||
<div className="flex flex-col md:flex-row md:items-center justify-between">
|
||||
<h3 className="text-base font-semibold text-gray-900 dark:text-white">
|
||||
{scheduleDetails.id}
|
||||
</h3>
|
||||
<div className="mt-2 md:mt-0 flex items-center gap-2">
|
||||
{scheduleDetails.currently_running && (
|
||||
<div className="text-sm text-green-500 dark:text-green-400 font-semibold flex items-center">
|
||||
<span className="inline-block w-2 h-2 bg-green-500 dark:bg-green-400 rounded-full mr-1 animate-pulse"></span>
|
||||
Currently Running
|
||||
</div>
|
||||
)}
|
||||
{scheduleDetails.paused && (
|
||||
<div className="text-sm text-orange-500 dark:text-orange-400 font-semibold flex items-center">
|
||||
<Pause className="w-3 h-3 mr-1" />
|
||||
Paused
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-300">
|
||||
<span className="font-semibold">Schedule:</span> {readableCron}
|
||||
</p>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-300">
|
||||
<span className="font-semibold">Cron Expression:</span> {scheduleDetails.cron}
|
||||
</p>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-300">
|
||||
<span className="font-semibold">Recipe Source:</span> {scheduleDetails.source}
|
||||
</p>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-300">
|
||||
<span className="font-semibold">Last Run:</span> {formattedLastRun}
|
||||
</p>
|
||||
{scheduleDetails.execution_mode && (
|
||||
<p className="text-sm text-gray-600 dark:text-gray-300">
|
||||
<span className="font-semibold">Execution Mode:</span>{' '}
|
||||
<span
|
||||
className={`inline-flex items-center px-2 py-1 rounded-full text-xs font-medium ${
|
||||
scheduleDetails.execution_mode === 'foreground'
|
||||
? 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300'
|
||||
: 'bg-gray-100 text-gray-800 dark:bg-gray-800 dark:text-gray-300'
|
||||
}`}
|
||||
>
|
||||
{scheduleDetails.execution_mode === 'foreground' ? '🖥️ Foreground' : '⚡ Background'}
|
||||
</span>
|
||||
</p>
|
||||
)}
|
||||
{scheduleDetails.currently_running && scheduleDetails.current_session_id && (
|
||||
<p className="text-sm text-gray-600 dark:text-gray-300">
|
||||
<span className="font-semibold">Current Session:</span>{' '}
|
||||
{scheduleDetails.current_session_id}
|
||||
</p>
|
||||
)}
|
||||
{scheduleDetails.currently_running && formattedProcessStartTime && (
|
||||
<p className="text-sm text-gray-600 dark:text-gray-300">
|
||||
<span className="font-semibold">Process Started:</span> {formattedProcessStartTime}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
});
|
||||
|
||||
ScheduleInfoCard.displayName = 'ScheduleInfoCard';
|
||||
|
||||
const ScheduleDetailView: React.FC<ScheduleDetailViewProps> = ({ scheduleId, onNavigateBack }) => {
|
||||
const [sessions, setSessions] = useState<ScheduleSessionMeta[]>([]);
|
||||
const [isLoadingSessions, setIsLoadingSessions] = useState(false);
|
||||
@@ -71,10 +161,14 @@ const ScheduleDetailView: React.FC<ScheduleDetailViewProps> = ({ scheduleId, onN
|
||||
setIsLoadingSessions(true);
|
||||
setSessionsError(null);
|
||||
try {
|
||||
const fetchedSessions = await getScheduleSessions(sId, 20); // MODIFIED
|
||||
// Assuming ScheduleSession from ../../schedule can be cast or mapped to ScheduleSessionMeta
|
||||
// You may need to transform/map fields if they differ significantly
|
||||
setSessions(fetchedSessions as ScheduleSessionMeta[]);
|
||||
const fetchedSessions = await getScheduleSessions(sId, 20);
|
||||
setSessions((prevSessions) => {
|
||||
// Only update if sessions actually changed to prevent unnecessary re-renders
|
||||
if (JSON.stringify(prevSessions) !== JSON.stringify(fetchedSessions)) {
|
||||
return fetchedSessions as ScheduleSessionMeta[];
|
||||
}
|
||||
return prevSessions;
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch schedule sessions:', err);
|
||||
setSessionsError(err instanceof Error ? err.message : 'Failed to fetch schedule sessions');
|
||||
@@ -84,21 +178,26 @@ const ScheduleDetailView: React.FC<ScheduleDetailViewProps> = ({ scheduleId, onN
|
||||
}, []);
|
||||
|
||||
const fetchScheduleDetails = useCallback(
|
||||
async (sId: string) => {
|
||||
async (sId: string, isRefresh = false) => {
|
||||
if (!sId) return;
|
||||
setIsLoadingSchedule(true);
|
||||
if (!isRefresh) setIsLoadingSchedule(true);
|
||||
setScheduleError(null);
|
||||
try {
|
||||
const allSchedules = await listSchedules();
|
||||
const schedule = allSchedules.find((s) => s.id === sId);
|
||||
if (schedule) {
|
||||
// Only reset runNowLoading if we explicitly killed the job
|
||||
// This prevents interfering with natural job completion
|
||||
if (!schedule.currently_running && runNowLoading && jobWasKilled) {
|
||||
setRunNowLoading(false);
|
||||
setJobWasKilled(false); // Reset the flag
|
||||
}
|
||||
setScheduleDetails(schedule);
|
||||
setScheduleDetails((prevDetails) => {
|
||||
// Only update if schedule details actually changed
|
||||
if (!prevDetails || JSON.stringify(prevDetails) !== JSON.stringify(schedule)) {
|
||||
// Only reset runNowLoading if we explicitly killed the job
|
||||
if (!schedule.currently_running && runNowLoading && jobWasKilled) {
|
||||
setRunNowLoading(false);
|
||||
setJobWasKilled(false);
|
||||
}
|
||||
return schedule;
|
||||
}
|
||||
return prevDetails;
|
||||
});
|
||||
} else {
|
||||
setScheduleError('Schedule not found');
|
||||
}
|
||||
@@ -106,21 +205,12 @@ const ScheduleDetailView: React.FC<ScheduleDetailViewProps> = ({ scheduleId, onN
|
||||
console.error('Failed to fetch schedule details:', err);
|
||||
setScheduleError(err instanceof Error ? err.message : 'Failed to fetch schedule details');
|
||||
} finally {
|
||||
setIsLoadingSchedule(false);
|
||||
if (!isRefresh) setIsLoadingSchedule(false);
|
||||
}
|
||||
},
|
||||
[runNowLoading, jobWasKilled]
|
||||
);
|
||||
|
||||
const getReadableCron = (cronString: string) => {
|
||||
try {
|
||||
return cronstrue.toString(cronString);
|
||||
} catch (e) {
|
||||
console.warn(`Could not parse cron string "${cronString}":`, e);
|
||||
return cronString;
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (scheduleId && !selectedSessionDetails) {
|
||||
fetchScheduleSessions(scheduleId);
|
||||
@@ -289,25 +379,42 @@ const ScheduleDetailView: React.FC<ScheduleDetailViewProps> = ({ scheduleId, onN
|
||||
}
|
||||
};
|
||||
|
||||
// Add a periodic refresh for schedule details to keep the running status up to date
|
||||
// Optimized periodic refresh for schedule details to keep the running status up to date
|
||||
useEffect(() => {
|
||||
if (!scheduleId) return;
|
||||
|
||||
// Initial fetch
|
||||
fetchScheduleDetails(scheduleId);
|
||||
|
||||
// Set up periodic refresh every 5 seconds
|
||||
// Set up periodic refresh every 8 seconds (longer to reduce flashing)
|
||||
const intervalId = setInterval(() => {
|
||||
if (scheduleId) {
|
||||
fetchScheduleDetails(scheduleId);
|
||||
if (
|
||||
scheduleId &&
|
||||
!selectedSessionDetails &&
|
||||
!runNowLoading &&
|
||||
!pauseUnpauseLoading &&
|
||||
!killJobLoading &&
|
||||
!inspectJobLoading &&
|
||||
!isEditSubmitting
|
||||
) {
|
||||
fetchScheduleDetails(scheduleId, true); // Pass true to indicate this is a refresh
|
||||
}
|
||||
}, 5000);
|
||||
}, 8000);
|
||||
|
||||
// Clean up on unmount or when scheduleId changes
|
||||
return () => {
|
||||
clearInterval(intervalId);
|
||||
};
|
||||
}, [scheduleId, fetchScheduleDetails]);
|
||||
}, [
|
||||
scheduleId,
|
||||
fetchScheduleDetails,
|
||||
selectedSessionDetails,
|
||||
runNowLoading,
|
||||
pauseUnpauseLoading,
|
||||
killJobLoading,
|
||||
inspectJobLoading,
|
||||
isEditSubmitting,
|
||||
]);
|
||||
|
||||
// Monitor schedule state changes and reset loading states appropriately
|
||||
useEffect(() => {
|
||||
@@ -422,57 +529,7 @@ const ScheduleDetailView: React.FC<ScheduleDetailViewProps> = ({ scheduleId, onN
|
||||
</p>
|
||||
)}
|
||||
{!isLoadingSchedule && !scheduleError && scheduleDetails && (
|
||||
<Card className="p-4 bg-white dark:bg-gray-800 shadow mb-6">
|
||||
<div className="space-y-2">
|
||||
<div className="flex flex-col md:flex-row md:items-center justify-between">
|
||||
<h3 className="text-base font-semibold text-gray-900 dark:text-white">
|
||||
{scheduleDetails.id}
|
||||
</h3>
|
||||
<div className="mt-2 md:mt-0 flex items-center gap-2">
|
||||
{scheduleDetails.currently_running && (
|
||||
<div className="text-sm text-green-500 dark:text-green-400 font-semibold flex items-center">
|
||||
<span className="inline-block w-2 h-2 bg-green-500 dark:bg-green-400 rounded-full mr-1 animate-pulse"></span>
|
||||
Currently Running
|
||||
</div>
|
||||
)}
|
||||
{scheduleDetails.paused && (
|
||||
<div className="text-sm text-orange-500 dark:text-orange-400 font-semibold flex items-center">
|
||||
<Pause className="w-3 h-3 mr-1" />
|
||||
Paused
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-300">
|
||||
<span className="font-semibold">Schedule:</span>{' '}
|
||||
{getReadableCron(scheduleDetails.cron)}
|
||||
</p>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-300">
|
||||
<span className="font-semibold">Cron Expression:</span> {scheduleDetails.cron}
|
||||
</p>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-300">
|
||||
<span className="font-semibold">Recipe Source:</span> {scheduleDetails.source}
|
||||
</p>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-300">
|
||||
<span className="font-semibold">Last Run:</span>{' '}
|
||||
{scheduleDetails.last_run
|
||||
? new Date(scheduleDetails.last_run).toLocaleString()
|
||||
: 'Never'}
|
||||
</p>
|
||||
{scheduleDetails.currently_running && scheduleDetails.current_session_id && (
|
||||
<p className="text-sm text-gray-600 dark:text-gray-300">
|
||||
<span className="font-semibold">Current Session:</span>{' '}
|
||||
{scheduleDetails.current_session_id}
|
||||
</p>
|
||||
)}
|
||||
{scheduleDetails.currently_running && scheduleDetails.process_start_time && (
|
||||
<p className="text-sm text-gray-600 dark:text-gray-300">
|
||||
<span className="font-semibold">Process Started:</span>{' '}
|
||||
{new Date(scheduleDetails.process_start_time).toLocaleString()}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
<ScheduleInfoCard scheduleDetails={scheduleDetails} />
|
||||
)}
|
||||
</section>
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import React, { useState, useEffect, useCallback, useMemo } from 'react';
|
||||
import {
|
||||
listSchedules,
|
||||
createSchedule,
|
||||
@@ -23,11 +23,207 @@ import ScheduleDetailView from './ScheduleDetailView';
|
||||
import { toastError, toastSuccess } from '../../toasts';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '../ui/popover';
|
||||
import cronstrue from 'cronstrue';
|
||||
import { formatToLocalDateWithTimezone } from '../../utils/date';
|
||||
|
||||
interface SchedulesViewProps {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
// Memoized ScheduleCard component to prevent unnecessary re-renders
|
||||
const ScheduleCard = React.memo<{
|
||||
job: ScheduledJob;
|
||||
onNavigateToDetail: (id: string) => void;
|
||||
onEdit: (job: ScheduledJob) => void;
|
||||
onPause: (id: string) => void;
|
||||
onUnpause: (id: string) => void;
|
||||
onKill: (id: string) => void;
|
||||
onInspect: (id: string) => void;
|
||||
onDelete: (id: string) => void;
|
||||
isPausing: boolean;
|
||||
isDeleting: boolean;
|
||||
isKilling: boolean;
|
||||
isInspecting: boolean;
|
||||
isSubmitting: boolean;
|
||||
}>(
|
||||
({
|
||||
job,
|
||||
onNavigateToDetail,
|
||||
onEdit,
|
||||
onPause,
|
||||
onUnpause,
|
||||
onKill,
|
||||
onInspect,
|
||||
onDelete,
|
||||
isPausing,
|
||||
isDeleting,
|
||||
isKilling,
|
||||
isInspecting,
|
||||
isSubmitting,
|
||||
}) => {
|
||||
const readableCron = useMemo(() => {
|
||||
try {
|
||||
return cronstrue.toString(job.cron);
|
||||
} catch (e) {
|
||||
console.warn(`Could not parse cron string "${job.cron}":`, e);
|
||||
return job.cron;
|
||||
}
|
||||
}, [job.cron]);
|
||||
|
||||
const formattedLastRun = useMemo(() => {
|
||||
return formatToLocalDateWithTimezone(job.last_run);
|
||||
}, [job.last_run]);
|
||||
|
||||
return (
|
||||
<Card
|
||||
className="p-4 bg-white dark:bg-gray-800 shadow cursor-pointer hover:shadow-lg transition-shadow duration-200"
|
||||
onClick={() => onNavigateToDetail(job.id)}
|
||||
>
|
||||
<div className="flex justify-between items-start">
|
||||
<div className="flex-grow mr-2 overflow-hidden">
|
||||
<h3
|
||||
className="text-base font-semibold text-gray-900 dark:text-white truncate"
|
||||
title={job.id}
|
||||
>
|
||||
{job.id}
|
||||
</h3>
|
||||
<p
|
||||
className="text-xs text-gray-500 dark:text-gray-400 mt-1 break-all"
|
||||
title={job.source}
|
||||
>
|
||||
Source: {job.source}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1" title={readableCron}>
|
||||
Schedule: {readableCron}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||
Last Run: {formattedLastRun}
|
||||
</p>
|
||||
{job.execution_mode && (
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||
Mode:{' '}
|
||||
<span
|
||||
className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ${
|
||||
job.execution_mode === 'foreground'
|
||||
? 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300'
|
||||
: 'bg-gray-100 text-gray-800 dark:bg-gray-800 dark:text-gray-300'
|
||||
}`}
|
||||
>
|
||||
{job.execution_mode === 'foreground' ? '🖥️ Foreground' : '⚡ Background'}
|
||||
</span>
|
||||
</p>
|
||||
)}
|
||||
{job.currently_running && (
|
||||
<p className="text-xs text-green-500 dark:text-green-400 mt-1 font-semibold flex items-center">
|
||||
<span className="inline-block w-2 h-2 bg-green-500 dark:bg-green-400 rounded-full mr-1 animate-pulse"></span>
|
||||
Currently Running
|
||||
</p>
|
||||
)}
|
||||
{job.paused && (
|
||||
<p className="text-xs text-orange-500 dark:text-orange-400 mt-1 font-semibold flex items-center">
|
||||
<Pause className="w-3 h-3 mr-1" />
|
||||
Paused
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-shrink-0">
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
}}
|
||||
className="text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 hover:bg-gray-100/50 dark:hover:bg-gray-800/50"
|
||||
>
|
||||
<MoreHorizontal className="w-4 h-4" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="w-48 p-1 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-600 shadow-lg"
|
||||
align="end"
|
||||
>
|
||||
<div className="space-y-1">
|
||||
{!job.currently_running && (
|
||||
<>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onEdit(job);
|
||||
}}
|
||||
disabled={isPausing || isDeleting || isSubmitting}
|
||||
className="w-full flex items-center justify-between px-3 py-2 text-sm text-gray-900 dark:text-white hover:bg-gray-100 dark:hover:bg-gray-700 rounded-md disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<span>Edit</span>
|
||||
<Edit className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (job.paused) {
|
||||
onUnpause(job.id);
|
||||
} else {
|
||||
onPause(job.id);
|
||||
}
|
||||
}}
|
||||
disabled={isPausing || isDeleting}
|
||||
className="w-full flex items-center justify-between px-3 py-2 text-sm text-gray-900 dark:text-white hover:bg-gray-100 dark:hover:bg-gray-700 rounded-md disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<span>{job.paused ? 'Resume schedule' : 'Stop schedule'}</span>
|
||||
{job.paused ? <Play className="w-4 h-4" /> : <Pause className="w-4 h-4" />}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{job.currently_running && (
|
||||
<>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onInspect(job.id);
|
||||
}}
|
||||
disabled={isInspecting || isKilling}
|
||||
className="w-full flex items-center justify-between px-3 py-2 text-sm text-gray-900 dark:text-white hover:bg-gray-100 dark:hover:bg-gray-700 rounded-md disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<span>Inspect</span>
|
||||
<Eye className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onKill(job.id);
|
||||
}}
|
||||
disabled={isKilling || isInspecting}
|
||||
className="w-full flex items-center justify-between px-3 py-2 text-sm text-gray-900 dark:text-white hover:bg-gray-100 dark:hover:bg-gray-700 rounded-md disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<span>Kill job</span>
|
||||
<Square className="w-4 h-4" />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
<hr className="border-gray-200 dark:border-gray-600 my-1" />
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDelete(job.id);
|
||||
}}
|
||||
disabled={isPausing || isDeleting || isKilling || isInspecting}
|
||||
className="w-full flex items-center justify-between px-3 py-2 text-sm text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20 rounded-md disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<span>Delete</span>
|
||||
<TrashIcon className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
ScheduleCard.displayName = 'ScheduleCard';
|
||||
|
||||
const SchedulesView: React.FC<SchedulesViewProps> = ({ onClose }) => {
|
||||
const [schedules, setSchedules] = useState<ScheduledJob[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
@@ -47,12 +243,19 @@ const SchedulesView: React.FC<SchedulesViewProps> = ({ onClose }) => {
|
||||
|
||||
const [viewingScheduleId, setViewingScheduleId] = useState<string | null>(null);
|
||||
|
||||
const fetchSchedules = async () => {
|
||||
setIsLoading(true);
|
||||
// Memoized fetch function to prevent unnecessary re-creation
|
||||
const fetchSchedules = useCallback(async (isRefresh = false) => {
|
||||
if (!isRefresh) setIsLoading(true);
|
||||
setApiError(null);
|
||||
try {
|
||||
const fetchedSchedules = await listSchedules();
|
||||
setSchedules(fetchedSchedules);
|
||||
setSchedules((prevSchedules) => {
|
||||
// Only update if schedules actually changed to prevent unnecessary re-renders
|
||||
if (JSON.stringify(prevSchedules) !== JSON.stringify(fetchedSchedules)) {
|
||||
return fetchedSchedules;
|
||||
}
|
||||
return prevSchedules;
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch schedules:', error);
|
||||
setApiError(
|
||||
@@ -61,9 +264,9 @@ const SchedulesView: React.FC<SchedulesViewProps> = ({ onClose }) => {
|
||||
: 'An unknown error occurred while fetching schedules.'
|
||||
);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
if (!isRefresh) setIsLoading(false);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (viewingScheduleId === null) {
|
||||
@@ -77,38 +280,57 @@ const SchedulesView: React.FC<SchedulesViewProps> = ({ onClose }) => {
|
||||
// The CreateScheduleModal will handle the deep link
|
||||
}
|
||||
}
|
||||
}, [viewingScheduleId]);
|
||||
}, [viewingScheduleId, fetchSchedules]);
|
||||
|
||||
// Add a periodic refresh for schedules list to keep the running status up to date
|
||||
// Optimized periodic refresh - only refresh if not actively doing something
|
||||
useEffect(() => {
|
||||
if (viewingScheduleId !== null) return;
|
||||
|
||||
// Set up periodic refresh every 10 seconds
|
||||
// Set up periodic refresh every 15 seconds (increased from 8 to reduce flashing)
|
||||
const intervalId = setInterval(() => {
|
||||
if (viewingScheduleId === null && !isRefreshing && !isLoading) {
|
||||
fetchSchedules();
|
||||
if (
|
||||
viewingScheduleId === null &&
|
||||
!isRefreshing &&
|
||||
!isLoading &&
|
||||
!isSubmitting &&
|
||||
pausingScheduleIds.size === 0 &&
|
||||
deletingScheduleIds.size === 0 &&
|
||||
killingScheduleIds.size === 0 &&
|
||||
inspectingScheduleIds.size === 0
|
||||
) {
|
||||
fetchSchedules(true); // Pass true to indicate this is a refresh
|
||||
}
|
||||
}, 10000);
|
||||
}, 15000); // Increased from 8000 to 15000 (15 seconds)
|
||||
|
||||
// Clean up on unmount
|
||||
return () => {
|
||||
clearInterval(intervalId);
|
||||
};
|
||||
}, [viewingScheduleId, isRefreshing, isLoading]);
|
||||
}, [
|
||||
viewingScheduleId,
|
||||
isRefreshing,
|
||||
isLoading,
|
||||
isSubmitting,
|
||||
pausingScheduleIds.size,
|
||||
deletingScheduleIds.size,
|
||||
killingScheduleIds.size,
|
||||
inspectingScheduleIds.size,
|
||||
fetchSchedules,
|
||||
]);
|
||||
|
||||
const handleOpenCreateModal = () => {
|
||||
setSubmitApiError(null);
|
||||
setIsCreateModalOpen(true);
|
||||
};
|
||||
|
||||
const handleRefresh = async () => {
|
||||
const handleRefresh = useCallback(async () => {
|
||||
setIsRefreshing(true);
|
||||
try {
|
||||
await fetchSchedules();
|
||||
} finally {
|
||||
setIsRefreshing(false);
|
||||
}
|
||||
};
|
||||
}, [fetchSchedules]);
|
||||
|
||||
const handleCloseCreateModal = () => {
|
||||
setIsCreateModalOpen(false);
|
||||
@@ -341,15 +563,6 @@ const SchedulesView: React.FC<SchedulesViewProps> = ({ onClose }) => {
|
||||
setViewingScheduleId(null);
|
||||
};
|
||||
|
||||
const getReadableCron = (cronString: string) => {
|
||||
try {
|
||||
return cronstrue.toString(cronString);
|
||||
} catch (e) {
|
||||
console.warn(`Could not parse cron string "${cronString}":`, e);
|
||||
return cronString;
|
||||
}
|
||||
};
|
||||
|
||||
if (viewingScheduleId) {
|
||||
return (
|
||||
<ScheduleDetailView
|
||||
@@ -412,163 +625,22 @@ const SchedulesView: React.FC<SchedulesViewProps> = ({ onClose }) => {
|
||||
{!isLoading && schedules.length > 0 && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{schedules.map((job) => (
|
||||
<Card
|
||||
<ScheduleCard
|
||||
key={job.id}
|
||||
className="p-4 bg-white dark:bg-gray-800 shadow cursor-pointer hover:shadow-lg transition-shadow duration-200"
|
||||
onClick={() => handleNavigateToScheduleDetail(job.id)}
|
||||
>
|
||||
<div className="flex justify-between items-start">
|
||||
<div className="flex-grow mr-2 overflow-hidden">
|
||||
<h3
|
||||
className="text-base font-semibold text-gray-900 dark:text-white truncate"
|
||||
title={job.id}
|
||||
>
|
||||
{job.id}
|
||||
</h3>
|
||||
<p
|
||||
className="text-xs text-gray-500 dark:text-gray-400 mt-1 break-all"
|
||||
title={job.source}
|
||||
>
|
||||
Source: {job.source}
|
||||
</p>
|
||||
<p
|
||||
className="text-xs text-gray-500 dark:text-gray-400 mt-1"
|
||||
title={getReadableCron(job.cron)}
|
||||
>
|
||||
Schedule: {getReadableCron(job.cron)}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||
Last Run:{' '}
|
||||
{job.last_run ? new Date(job.last_run).toLocaleString() : 'Never'}
|
||||
</p>
|
||||
{job.currently_running && (
|
||||
<p className="text-xs text-green-500 dark:text-green-400 mt-1 font-semibold flex items-center">
|
||||
<span className="inline-block w-2 h-2 bg-green-500 dark:bg-green-400 rounded-full mr-1 animate-pulse"></span>
|
||||
Currently Running
|
||||
</p>
|
||||
)}
|
||||
{job.paused && (
|
||||
<p className="text-xs text-orange-500 dark:text-orange-400 mt-1 font-semibold flex items-center">
|
||||
<Pause className="w-3 h-3 mr-1" />
|
||||
Paused
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-shrink-0">
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
}}
|
||||
className="text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 hover:bg-gray-100/50 dark:hover:bg-gray-800/50"
|
||||
>
|
||||
<MoreHorizontal className="w-4 h-4" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="w-48 p-1 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-600 shadow-lg"
|
||||
align="end"
|
||||
>
|
||||
<div className="space-y-1">
|
||||
{!job.currently_running && (
|
||||
<>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleOpenEditModal(job);
|
||||
}}
|
||||
disabled={
|
||||
pausingScheduleIds.has(job.id) ||
|
||||
deletingScheduleIds.has(job.id) ||
|
||||
isSubmitting
|
||||
}
|
||||
className="w-full flex items-center justify-between px-3 py-2 text-sm text-gray-900 dark:text-white hover:bg-gray-100 dark:hover:bg-gray-700 rounded-md disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<span>Edit</span>
|
||||
<Edit className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (job.paused) {
|
||||
handleUnpauseSchedule(job.id);
|
||||
} else {
|
||||
handlePauseSchedule(job.id);
|
||||
}
|
||||
}}
|
||||
disabled={
|
||||
pausingScheduleIds.has(job.id) ||
|
||||
deletingScheduleIds.has(job.id)
|
||||
}
|
||||
className="w-full flex items-center justify-between px-3 py-2 text-sm text-gray-900 dark:text-white hover:bg-gray-100 dark:hover:bg-gray-700 rounded-md disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<span>{job.paused ? 'Resume schedule' : 'Stop schedule'}</span>
|
||||
{job.paused ? (
|
||||
<Play className="w-4 h-4" />
|
||||
) : (
|
||||
<Pause className="w-4 h-4" />
|
||||
)}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{job.currently_running && (
|
||||
<>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleInspectRunningJob(job.id);
|
||||
}}
|
||||
disabled={
|
||||
inspectingScheduleIds.has(job.id) ||
|
||||
killingScheduleIds.has(job.id)
|
||||
}
|
||||
className="w-full flex items-center justify-between px-3 py-2 text-sm text-gray-900 dark:text-white hover:bg-gray-100 dark:hover:bg-gray-700 rounded-md disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<span>Inspect</span>
|
||||
<Eye className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleKillRunningJob(job.id);
|
||||
}}
|
||||
disabled={
|
||||
killingScheduleIds.has(job.id) ||
|
||||
inspectingScheduleIds.has(job.id)
|
||||
}
|
||||
className="w-full flex items-center justify-between px-3 py-2 text-sm text-gray-900 dark:text-white hover:bg-gray-100 dark:hover:bg-gray-700 rounded-md disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<span>Kill job</span>
|
||||
<Square className="w-4 h-4" />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
<hr className="border-gray-200 dark:border-gray-600 my-1" />
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleDeleteSchedule(job.id);
|
||||
}}
|
||||
disabled={
|
||||
pausingScheduleIds.has(job.id) ||
|
||||
deletingScheduleIds.has(job.id) ||
|
||||
killingScheduleIds.has(job.id) ||
|
||||
inspectingScheduleIds.has(job.id)
|
||||
}
|
||||
className="w-full flex items-center justify-between px-3 py-2 text-sm text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20 rounded-md disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<span>Delete</span>
|
||||
<TrashIcon className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
job={job}
|
||||
onNavigateToDetail={handleNavigateToScheduleDetail}
|
||||
onEdit={handleOpenEditModal}
|
||||
onPause={handlePauseSchedule}
|
||||
onUnpause={handleUnpauseSchedule}
|
||||
onKill={handleKillRunningJob}
|
||||
onInspect={handleInspectRunningJob}
|
||||
onDelete={handleDeleteSchedule}
|
||||
isPausing={pausingScheduleIds.has(job.id)}
|
||||
isDeleting={deletingScheduleIds.has(job.id)}
|
||||
isKilling={killingScheduleIds.has(job.id)}
|
||||
isInspecting={inspectingScheduleIds.has(job.id)}
|
||||
isSubmitting={isSubmitting}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -8,6 +8,7 @@ import { ToolSelectionStrategySection } from './tool_selection_strategy/ToolSele
|
||||
import SessionSharingSection from './sessions/SessionSharingSection';
|
||||
import { ResponseStylesSection } from './response_styles/ResponseStylesSection';
|
||||
import AppSettingsSection from './app/AppSettingsSection';
|
||||
import SchedulerSection from './scheduler/SchedulerSection';
|
||||
import { ExtensionConfig } from '../../api';
|
||||
import MoreMenuLayout from '../more_menu/MoreMenuLayout';
|
||||
|
||||
@@ -47,6 +48,8 @@ export default function SettingsView({
|
||||
deepLinkConfig={viewOptions.deepLinkConfig}
|
||||
showEnvVars={viewOptions.showEnvVars}
|
||||
/>
|
||||
{/* Scheduler Section */}
|
||||
<SchedulerSection />
|
||||
{/* Goose Modes */}
|
||||
<ModeSection setView={setView} />
|
||||
{/*Session sharing*/}
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { SchedulingEngine, Settings } from '../../../utils/settings';
|
||||
|
||||
interface SchedulerSectionProps {
|
||||
onSchedulingEngineChange?: (engine: SchedulingEngine) => void;
|
||||
}
|
||||
|
||||
export default function SchedulerSection({ onSchedulingEngineChange }: SchedulerSectionProps) {
|
||||
const [schedulingEngine, setSchedulingEngine] = useState<SchedulingEngine>('builtin-cron');
|
||||
|
||||
useEffect(() => {
|
||||
// Load current scheduling engine setting
|
||||
const loadSchedulingEngine = async () => {
|
||||
try {
|
||||
const settings = (await window.electron.getSettings()) as Settings | null;
|
||||
if (settings?.schedulingEngine) {
|
||||
setSchedulingEngine(settings.schedulingEngine);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load scheduling engine setting:', error);
|
||||
}
|
||||
};
|
||||
|
||||
loadSchedulingEngine();
|
||||
}, []);
|
||||
|
||||
const handleEngineChange = async (engine: SchedulingEngine) => {
|
||||
try {
|
||||
setSchedulingEngine(engine);
|
||||
|
||||
// Save the setting
|
||||
await window.electron.setSchedulingEngine(engine);
|
||||
|
||||
// Notify parent component
|
||||
if (onSchedulingEngineChange) {
|
||||
onSchedulingEngineChange(engine);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to save scheduling engine setting:', error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="px-8">
|
||||
<div className="mb-4">
|
||||
<h2 className="text-xl font-medium text-textStandard mb-2">Scheduling Engine</h2>
|
||||
<p className="text-sm text-textSubtle mb-4">
|
||||
Choose which scheduling backend to use for scheduled recipes and tasks.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-start space-x-3">
|
||||
<input
|
||||
type="radio"
|
||||
id="builtin-cron"
|
||||
name="schedulingEngine"
|
||||
value="builtin-cron"
|
||||
checked={schedulingEngine === 'builtin-cron'}
|
||||
onChange={() => handleEngineChange('builtin-cron')}
|
||||
className="mt-1 h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300"
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<label htmlFor="builtin-cron" className="block text-sm font-medium text-textStandard">
|
||||
Built-in Cron (Default)
|
||||
</label>
|
||||
<p className="text-xs text-textSubtle mt-1">
|
||||
Uses Goose's built-in cron scheduler. Simple and reliable for basic scheduling needs.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start space-x-3">
|
||||
<input
|
||||
type="radio"
|
||||
id="temporal"
|
||||
name="schedulingEngine"
|
||||
value="temporal"
|
||||
checked={schedulingEngine === 'temporal'}
|
||||
onChange={() => handleEngineChange('temporal')}
|
||||
className="mt-1 h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300"
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<label htmlFor="temporal" className="block text-sm font-medium text-textStandard">
|
||||
Temporal
|
||||
</label>
|
||||
<p className="text-xs text-textSubtle mt-1">
|
||||
Uses Temporal workflow engine for advanced scheduling features. Requires Temporal CLI
|
||||
to be installed.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 p-3 bg-bgSubtle rounded-md">
|
||||
<p className="text-xs text-textSubtle">
|
||||
<strong>Note:</strong> Changing the scheduling engine will apply to new Goose sessions.
|
||||
You will need to restart Goose for the change to take full effect. <br />
|
||||
The scheduling engines do not share the list of schedules.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+63
-1
@@ -32,6 +32,8 @@ import {
|
||||
loadSettings,
|
||||
saveSettings,
|
||||
updateEnvironmentVariables,
|
||||
updateSchedulingEngineEnvironment,
|
||||
SchedulingEngine,
|
||||
} from './utils/settings';
|
||||
import * as crypto from 'crypto';
|
||||
import * as electron from 'electron';
|
||||
@@ -155,6 +157,14 @@ if (process.platform === 'win32') {
|
||||
if (configParam) {
|
||||
try {
|
||||
recipeConfig = JSON.parse(Buffer.from(configParam, 'base64').toString('utf-8'));
|
||||
|
||||
// Check if this is a scheduled job
|
||||
const scheduledJobId = parsedUrl.searchParams.get('scheduledJob');
|
||||
if (scheduledJobId) {
|
||||
console.log(`[main] Opening scheduled job: ${scheduledJobId}`);
|
||||
recipeConfig.scheduledJobId = scheduledJobId;
|
||||
recipeConfig.isScheduledExecution = true;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to parse bot config:', e);
|
||||
}
|
||||
@@ -250,6 +260,14 @@ function processProtocolUrl(parsedUrl: URL, window: BrowserWindow) {
|
||||
if (configParam) {
|
||||
try {
|
||||
recipeConfig = JSON.parse(Buffer.from(configParam, 'base64').toString('utf-8'));
|
||||
|
||||
// Check if this is a scheduled job
|
||||
const scheduledJobId = parsedUrl.searchParams.get('scheduledJob');
|
||||
if (scheduledJobId) {
|
||||
console.log(`[main] Opening scheduled job: ${scheduledJobId}`);
|
||||
recipeConfig.scheduledJobId = scheduledJobId;
|
||||
recipeConfig.isScheduledExecution = true;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to parse bot config:', e);
|
||||
}
|
||||
@@ -274,6 +292,14 @@ app.on('open-url', async (_event, url) => {
|
||||
if (configParam) {
|
||||
try {
|
||||
recipeConfig = JSON.parse(Buffer.from(base64, 'base64').toString('utf-8'));
|
||||
|
||||
// Check if this is a scheduled job
|
||||
const scheduledJobId = parsedUrl.searchParams.get('scheduledJob');
|
||||
if (scheduledJobId) {
|
||||
console.log(`[main] Opening scheduled job: ${scheduledJobId}`);
|
||||
recipeConfig.scheduledJobId = scheduledJobId;
|
||||
recipeConfig.isScheduledExecution = true;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to parse bot config:', e);
|
||||
}
|
||||
@@ -422,8 +448,17 @@ const createChat = async (
|
||||
} else {
|
||||
// Apply current environment settings before creating chat
|
||||
updateEnvironmentVariables(envToggles);
|
||||
|
||||
// Apply scheduling engine setting
|
||||
const settings = loadSettings();
|
||||
updateSchedulingEngineEnvironment(settings.schedulingEngine);
|
||||
|
||||
// Start new Goosed process for regular windows
|
||||
const [newPort, newWorkingDir, newGoosedProcess] = await startGoosed(app, dir);
|
||||
// Pass through scheduling engine environment variables
|
||||
const envVars = {
|
||||
GOOSE_SCHEDULER_TYPE: process.env.GOOSE_SCHEDULER_TYPE,
|
||||
};
|
||||
const [newPort, newWorkingDir, newGoosedProcess] = await startGoosed(app, dir, envVars);
|
||||
port = newPort;
|
||||
working_dir = newWorkingDir;
|
||||
goosedProcess = newGoosedProcess;
|
||||
@@ -750,6 +785,33 @@ ipcMain.handle('directory-chooser', (_event, replace: boolean = false) => {
|
||||
return openDirectoryDialog(replace);
|
||||
});
|
||||
|
||||
// Handle scheduling engine settings
|
||||
ipcMain.handle('get-settings', () => {
|
||||
try {
|
||||
const settings = loadSettings();
|
||||
return settings;
|
||||
} catch (error) {
|
||||
console.error('Error getting settings:', error);
|
||||
return null;
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle('set-scheduling-engine', async (_event, engine: string) => {
|
||||
try {
|
||||
const settings = loadSettings();
|
||||
settings.schedulingEngine = engine as SchedulingEngine;
|
||||
saveSettings(settings);
|
||||
|
||||
// Update the environment variable immediately
|
||||
updateSchedulingEngineEnvironment(settings.schedulingEngine);
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Error setting scheduling engine:', error);
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
// Handle menu bar icon visibility
|
||||
ipcMain.handle('set-menu-bar-icon', async (_event, show: boolean) => {
|
||||
try {
|
||||
|
||||
@@ -79,6 +79,8 @@ type ElectronAPI = {
|
||||
getMenuBarIconState: () => Promise<boolean>;
|
||||
setDockIcon: (show: boolean) => Promise<boolean>;
|
||||
getDockIconState: () => Promise<boolean>;
|
||||
getSettings: () => Promise<unknown | null>;
|
||||
setSchedulingEngine: (engine: string) => Promise<boolean>;
|
||||
setQuitConfirmation: (show: boolean) => Promise<boolean>;
|
||||
getQuitConfirmationState: () => Promise<boolean>;
|
||||
openNotificationsSettings: () => Promise<boolean>;
|
||||
@@ -157,6 +159,8 @@ const electronAPI: ElectronAPI = {
|
||||
getMenuBarIconState: () => ipcRenderer.invoke('get-menu-bar-icon-state'),
|
||||
setDockIcon: (show: boolean) => ipcRenderer.invoke('set-dock-icon', show),
|
||||
getDockIconState: () => ipcRenderer.invoke('get-dock-icon-state'),
|
||||
getSettings: () => ipcRenderer.invoke('get-settings'),
|
||||
setSchedulingEngine: (engine: string) => ipcRenderer.invoke('set-scheduling-engine', engine),
|
||||
setQuitConfirmation: (show: boolean) => ipcRenderer.invoke('set-quit-confirmation', show),
|
||||
getQuitConfirmationState: () => ipcRenderer.invoke('get-quit-confirmation-state'),
|
||||
openNotificationsSettings: () => ipcRenderer.invoke('open-notifications-settings'),
|
||||
|
||||
@@ -17,6 +17,9 @@ export interface Recipe {
|
||||
context?: string[];
|
||||
profile?: string;
|
||||
mcps?: number;
|
||||
// Properties added for scheduled execution
|
||||
scheduledJobId?: string;
|
||||
isScheduledExecution?: boolean;
|
||||
}
|
||||
|
||||
export interface CreateRecipeRequest {
|
||||
|
||||
@@ -20,6 +20,7 @@ export interface ScheduledJob {
|
||||
paused?: boolean;
|
||||
current_session_id?: string | null;
|
||||
process_start_time?: string | null;
|
||||
execution_mode?: string | null; // "foreground" or "background"
|
||||
}
|
||||
|
||||
export interface ScheduleSession {
|
||||
@@ -55,6 +56,7 @@ export async function createSchedule(request: {
|
||||
id: string;
|
||||
recipe_source: string;
|
||||
cron: string;
|
||||
execution_mode?: string;
|
||||
}): Promise<ScheduledJob> {
|
||||
try {
|
||||
const response = await apiCreateSchedule<true>({ body: request });
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
export const formatToLocalDateTime = (dateString?: string | null): string => {
|
||||
if (!dateString) {
|
||||
return 'N/A';
|
||||
}
|
||||
try {
|
||||
const date = new Date(dateString);
|
||||
// Check if the date is valid
|
||||
if (isNaN(date.getTime())) {
|
||||
return 'Invalid Date';
|
||||
}
|
||||
return date.toLocaleString(); // Uses user's locale and timezone
|
||||
} catch (e) {
|
||||
console.error('Error formatting date:', e);
|
||||
return 'Invalid Date';
|
||||
}
|
||||
};
|
||||
|
||||
export const formatToLocalDateWithTimezone = (dateString?: string | null): string => {
|
||||
if (!dateString) {
|
||||
return 'N/A';
|
||||
}
|
||||
try {
|
||||
const date = new Date(dateString);
|
||||
if (isNaN(date.getTime())) {
|
||||
return 'Invalid Date';
|
||||
}
|
||||
// Format: Jan 1, 2023, 10:00:00 AM PST (example)
|
||||
return date.toLocaleString(undefined, {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
timeZoneName: 'short',
|
||||
});
|
||||
} catch (e) {
|
||||
console.error('Error formatting date with timezone:', e);
|
||||
return 'Invalid Date';
|
||||
}
|
||||
};
|
||||
@@ -8,10 +8,13 @@ export interface EnvToggles {
|
||||
GOOSE_SERVER__COMPUTER_CONTROLLER: boolean;
|
||||
}
|
||||
|
||||
export type SchedulingEngine = 'builtin-cron' | 'temporal';
|
||||
|
||||
export interface Settings {
|
||||
envToggles: EnvToggles;
|
||||
showMenuBarIcon: boolean;
|
||||
showDockIcon: boolean;
|
||||
schedulingEngine: SchedulingEngine;
|
||||
showQuitConfirmation: boolean;
|
||||
}
|
||||
|
||||
@@ -25,6 +28,7 @@ const defaultSettings: Settings = {
|
||||
},
|
||||
showMenuBarIcon: true,
|
||||
showDockIcon: true,
|
||||
schedulingEngine: 'builtin-cron',
|
||||
showQuitConfirmation: true,
|
||||
};
|
||||
|
||||
@@ -64,6 +68,15 @@ export function updateEnvironmentVariables(envToggles: EnvToggles): void {
|
||||
}
|
||||
}
|
||||
|
||||
export function updateSchedulingEngineEnvironment(schedulingEngine: SchedulingEngine): void {
|
||||
// Set GOOSE_SCHEDULER_TYPE based on the scheduling engine setting
|
||||
if (schedulingEngine === 'temporal') {
|
||||
process.env.GOOSE_SCHEDULER_TYPE = 'temporal';
|
||||
} else {
|
||||
process.env.GOOSE_SCHEDULER_TYPE = 'legacy';
|
||||
}
|
||||
}
|
||||
|
||||
// Menu management
|
||||
export function createEnvironmentMenu(
|
||||
envToggles: EnvToggles,
|
||||
|
||||
Reference in New Issue
Block a user