Add basic cron scheduler to goose-server (#2621)

This commit is contained in:
Max Novich
2025-05-27 10:36:27 -07:00
committed by GitHub
parent c8e3f6ac69
commit c272b5df95
39 changed files with 3554 additions and 352 deletions

View File

@@ -8,7 +8,6 @@ import { ToastContainer } from 'react-toastify';
import { toastService } from './toasts';
import { extractExtensionName } from './components/settings/extensions/utils';
import { GoosehintsModal } from './components/GoosehintsModal';
import { SessionDetails } from './sessions';
import ChatView from './components/ChatView';
import SuspenseLoader from './suspense-loader';
@@ -18,6 +17,7 @@ import MoreModelsView from './components/settings/models/MoreModelsView';
import ConfigureProvidersView from './components/settings/providers/ConfigureProvidersView';
import SessionsView from './components/sessions/SessionsView';
import SharedSessionView from './components/sessions/SharedSessionView';
import SchedulesView from './components/schedule/SchedulesView';
import ProviderSettings from './components/settings_v2/providers/ProviderSettingsPage';
import RecipeEditor from './components/RecipeEditor';
import { useChat } from './hooks/useChat';
@@ -28,7 +28,8 @@ import { addExtensionFromDeepLink as addExtensionFromDeepLinkV2 } from './compon
import { backupConfig, initConfig, readAllConfig } from './api/sdk.gen';
import PermissionSettingsView from './components/settings_v2/permission/PermissionSetting';
// Views and their options
import { type SessionDetails } from './sessions';
export type View =
| 'welcome'
| 'chat'
@@ -39,6 +40,7 @@ export type View =
| 'ConfigureProviders'
| 'settingsV2'
| 'sessions'
| 'schedules'
| 'sharedSession'
| 'loading'
| 'recipeEditor'
@@ -47,8 +49,7 @@ export type View =
export type ViewOptions =
| SettingsViewOptions
| { resumedSession?: SessionDetails }
// eslint-disable-next-line @typescript-eslint/no-explicit-any
| Record<string, any>;
| Record<string, unknown>;
export type ViewConfig = {
view: View;
@@ -69,7 +70,6 @@ const getInitialView = (): ViewConfig => {
};
}
// Any other URL-specified view
if (viewFromUrl) {
return {
view: viewFromUrl as View,
@@ -77,7 +77,6 @@ const getInitialView = (): ViewConfig => {
};
}
// Default case
return {
view: 'loading',
viewOptions: {},
@@ -93,10 +92,10 @@ export default function App() {
const [extensionConfirmLabel, setExtensionConfirmLabel] = useState<string>('');
const [extensionConfirmTitle, setExtensionConfirmTitle] = useState<string>('');
const [{ view, viewOptions }, setInternalView] = useState<ViewConfig>(getInitialView());
const { getExtensions, addExtension, read } = useConfig();
const initAttemptedRef = useRef(false);
// Utility function to extract the command from the link
function extractCommand(link: string): string {
const url = new URL(link);
const cmd = url.searchParams.get('cmd') || 'Unknown Command';
@@ -104,7 +103,6 @@ export default function App() {
return `${cmd} ${args.join(' ')}`.trim();
}
// Utility function to extract the remote url from the link
function extractRemoteUrl(link: string): string {
const url = new URL(link);
return url.searchParams.get('url');
@@ -116,7 +114,6 @@ export default function App() {
};
useEffect(() => {
// Guard against multiple initialization attempts
if (initAttemptedRef.current) {
console.log('Initialization already attempted, skipping...');
return;
@@ -129,7 +126,6 @@ export default function App() {
const viewType = urlParams.get('view');
const recipeConfig = window.appConfig.get('recipeConfig');
// If we have a specific view type in the URL, use that and skip provider detection
if (viewType) {
if (viewType === 'recipeEditor' && recipeConfig) {
console.log('Setting view to recipeEditor with config:', recipeConfig);
@@ -142,39 +138,31 @@ export default function App() {
const initializeApp = async () => {
try {
// checks if there is a config, and if not creates it
await initConfig();
// now try to read config, if we fail and are migrating backup, then re-init config
try {
await readAllConfig({ throwOnError: true });
} catch (error) {
// NOTE: we do this check here and in providerUtils.ts, be sure to clean up both in the future
const configVersion = localStorage.getItem('configVersion');
const shouldMigrateExtensions = !configVersion || parseInt(configVersion, 10) < 3;
if (shouldMigrateExtensions) {
await backupConfig({ throwOnError: true });
await initConfig();
} else {
// if we've migrated throw this back up
throw new Error('Unable to read config file, it may be malformed');
}
}
// note: if in a non recipe session, recipeConfig is undefined, otherwise null if error
if (recipeConfig === null) {
setFatalError('Cannot read recipe config. Please check the deeplink and try again.');
return;
}
const config = window.electron.getConfig();
const provider = (await read('GOOSE_PROVIDER', false)) ?? config.GOOSE_DEFAULT_PROVIDER;
const model = (await read('GOOSE_MODEL', false)) ?? config.GOOSE_DEFAULT_MODEL;
if (provider && model) {
setView('chat');
try {
await initializeSystem(provider, model, {
getExtensions,
@@ -182,13 +170,9 @@ export default function App() {
});
} catch (error) {
console.error('Error in initialization:', error);
// propagate the error upward so the global ErrorUI shows in cases
// where going through welcome/onboarding wouldn't address the issue
if (error instanceof MalformedConfigError) {
throw error;
}
setView('welcome');
}
} else {
@@ -201,8 +185,6 @@ export default function App() {
);
setView('welcome');
}
// Reset toast service after initialization
toastService.configure({ silent: false });
};
@@ -215,8 +197,7 @@ export default function App() {
setFatalError(`${error instanceof Error ? error.message : 'Unknown error'}`);
}
})();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []); // Empty dependency array since we only want this to run once
}, [read, getExtensions, addExtension]);
const [isGoosehintsModalOpen, setIsGoosehintsModalOpen] = useState(false);
const [isLoadingSession, setIsLoadingSession] = useState(false);
@@ -236,32 +217,26 @@ export default function App() {
}
}, []);
// Handle shared session deep links
useEffect(() => {
const handleOpenSharedSession = async (_event: IpcRendererEvent, link: string) => {
window.electron.logInfo(`Opening shared session from deep link ${link}`);
setIsLoadingSharedSession(true);
setSharedSessionError(null);
try {
await openSharedSessionFromDeepLink(link, setView);
// No need to handle errors here as openSharedSessionFromDeepLink now handles them internally
} catch (error) {
// This should not happen, but just in case
console.error('Unexpected error opening shared session:', error);
setView('sessions'); // Fallback to sessions view
setView('sessions');
} finally {
setIsLoadingSharedSession(false);
}
};
window.electron.on('open-shared-session', handleOpenSharedSession);
return () => {
window.electron.off('open-shared-session', handleOpenSharedSession);
};
}, []);
// Keyboard shortcut handler
useEffect(() => {
console.log('Setting up keyboard shortcuts');
const handleKeyDown = (event: KeyboardEvent) => {
@@ -277,7 +252,6 @@ export default function App() {
}
}
};
window.addEventListener('keydown', handleKeyDown);
return () => {
window.removeEventListener('keydown', handleKeyDown);
@@ -288,17 +262,15 @@ export default function App() {
console.log('Setting up fatal error handler');
const handleFatalError = (_event: IpcRendererEvent, errorMessage: string) => {
console.error('Encountered a fatal error: ', errorMessage);
// Log additional context that might help diagnose the issue
console.error('Current view:', view);
console.error('Is loading session:', isLoadingSession);
setFatalError(errorMessage);
};
window.electron.on('fatal-error', handleFatalError);
return () => {
window.electron.off('fatal-error', handleFatalError);
};
}, [view, isLoadingSession]); // Add dependencies to provide context in error logs
}, [view, isLoadingSession]);
useEffect(() => {
console.log('Setting up view change handler');
@@ -306,14 +278,10 @@ export default function App() {
console.log(`Received view change request to: ${newView}`);
setView(newView);
};
// Get initial view and config
const urlParams = new URLSearchParams(window.location.search);
const viewFromUrl = urlParams.get('view');
if (viewFromUrl) {
// Get the config from the electron window config
const windowConfig = window.electron.getConfig();
if (viewFromUrl === 'recipeEditor') {
const initialViewOptions = {
recipeConfig: windowConfig?.recipeConfig,
@@ -324,12 +292,10 @@ export default function App() {
setView(viewFromUrl);
}
}
window.electron.on('set-view', handleSetView);
return () => window.electron.off('set-view', handleSetView);
}, []);
// Add cleanup for session states when view changes
useEffect(() => {
console.log(`View changed to: ${view}`);
if (view !== 'chat' && view !== 'recipeEditor') {
@@ -338,10 +304,7 @@ export default function App() {
}
}, [view]);
// Configuration for extension security
const config = window.electron.getConfig();
// If GOOSE_ALLOWLIST_WARNING is true, use warning-only mode (STRICT_ALLOWLIST=false)
// If GOOSE_ALLOWLIST_WARNING is not set or false, use strict blocking mode (STRICT_ALLOWLIST=true)
const STRICT_ALLOWLIST = config.GOOSE_ALLOWLIST_WARNING === true ? false : true;
useEffect(() => {
@@ -354,35 +317,24 @@ export default function App() {
const extName = extractExtensionName(link);
window.electron.logInfo(`Adding extension from deep link ${link}`);
setPendingLink(link);
// Default values for confirmation dialog
let warningMessage = '';
let label = 'OK';
let title = 'Confirm Extension Installation';
let isBlocked = false;
let useDetailedMessage = false;
// For SSE extensions (with remoteUrl), always use detailed message
if (remoteUrl) {
useDetailedMessage = true;
} else {
// For command-based extensions, check against allowlist
try {
const allowedCommands = await window.electron.getAllowedExtensions();
// Only check and show warning if we have a non-empty allowlist
if (allowedCommands && allowedCommands.length > 0) {
const isCommandAllowed = allowedCommands.some((allowedCmd) =>
command.startsWith(allowedCmd)
);
if (!isCommandAllowed) {
// Not in allowlist - use detailed message and show warning/block
useDetailedMessage = true;
title = '⛔️ Untrusted Extension ⛔️';
if (STRICT_ALLOWLIST) {
// Block installation completely unless override is active
isBlocked = true;
label = 'Extension Blocked';
warningMessage =
@@ -390,7 +342,6 @@ export default function App() {
'Installation is blocked by your administrator. ' +
'Please contact your administrator if you need this extension.';
} else {
// Allow override (either because STRICT_ALLOWLIST is false or secret key combo was used)
label = 'Override and install';
warningMessage =
'\n\n⚠ WARNING: This extension command is not in the allowed list. ' +
@@ -398,51 +349,38 @@ export default function App() {
'Please contact an admin if you are unsure or want to allow this extension.';
}
}
// If in allowlist, use simple message (useDetailedMessage remains false)
}
// If no allowlist, use simple message (useDetailedMessage remains false)
} catch (error) {
console.error('Error checking allowlist:', error);
}
}
// Set the appropriate message based on the extension type and allowlist status
if (useDetailedMessage) {
// Detailed message for SSE extensions or non-allowlisted command extensions
const detailedMessage = remoteUrl
? `You are about to install the ${extName} extension which connects to:\n\n${remoteUrl}\n\nThis extension will be able to access your conversations and provide additional functionality.`
: `You are about to install the ${extName} extension which runs the command:\n\n${command}\n\nThis extension will be able to access your conversations and provide additional functionality.`;
setModalMessage(`${detailedMessage}${warningMessage}`);
} else {
// Simple message for allowlisted command extensions or when no allowlist exists
const messageDetails = `Command: ${command}`;
setModalMessage(
`Are you sure you want to install the ${extName} extension?\n\n${messageDetails}`
);
}
setExtensionConfirmLabel(label);
setExtensionConfirmTitle(title);
// If blocked, disable the confirmation button functionality by setting a special flag
if (isBlocked) {
setPendingLink(null); // Clear the pending link so confirmation does nothing
setPendingLink(null);
}
setModalVisible(true);
} catch (error) {
console.error('Error handling add-extension event:', error);
}
};
window.electron.on('add-extension', handleAddExtension);
return () => {
window.electron.off('add-extension', handleAddExtension);
};
}, [STRICT_ALLOWLIST]);
// Focus the first found input field
useEffect(() => {
const handleFocusInput = (_event: IpcRendererEvent) => {
const inputField = document.querySelector('input[type="text"], textarea') as HTMLInputElement;
@@ -456,28 +394,24 @@ export default function App() {
};
}, []);
// TODO: modify
const handleConfirm = async () => {
if (pendingLink) {
console.log(`Confirming installation of extension from: ${pendingLink}`);
setModalVisible(false); // Dismiss modal immediately
setModalVisible(false);
try {
await addExtensionFromDeepLinkV2(pendingLink, addExtension, setView);
console.log('Extension installation successful');
} catch (error) {
console.error('Failed to add extension:', error);
// Consider showing a user-visible error notification here
} finally {
setPendingLink(null);
}
} else {
// This case happens when pendingLink was cleared due to blocking
console.log('Extension installation blocked by allowlist restrictions');
setModalVisible(false);
}
};
// TODO: modify
const handleCancel = () => {
console.log('Cancelled extension installation.');
setModalVisible(false);
@@ -566,6 +500,7 @@ export default function App() {
/>
)}
{view === 'sessions' && <SessionsView setView={setView} />}
{view === 'schedules' && <SchedulesView onClose={() => setView('chat')} />}
{view === 'sharedSession' && (
<SharedSessionView
session={viewOptions?.sessionDetails}