Mnovich/temporal foreground tasks (#2895)

Co-authored-by: Carlos M. Lopez <carlopez@squareup.com>
This commit is contained in:
Max Novich
2025-06-20 16:19:58 -07:00
committed by GitHub
parent b3aed4bc11
commit 180b1df25d
58 changed files with 4009 additions and 1920 deletions
+2
View File
@@ -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;
-89
View File
@@ -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."
-105
View File
@@ -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."
-89
View File
@@ -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."
+188 -139
View File
@@ -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>
);
}
+7 -9
View File
@@ -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
View File
@@ -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 {
+4
View File
@@ -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'),
+3
View File
@@ -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 {
+2
View File
@@ -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 });
+41
View File
@@ -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';
}
};
+13
View File
@@ -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,