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
+319 -7
View File
@@ -10,7 +10,7 @@
"license": {
"name": "Apache-2.0"
},
"version": "1.0.23"
"version": "1.0.24"
},
"paths": {
"/agent/tools": {
@@ -453,6 +453,176 @@
]
}
},
"/schedule/create": {
"post": {
"tags": [
"schedule"
],
"operationId": "create_schedule",
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/CreateScheduleRequest"
}
}
},
"required": true
},
"responses": {
"200": {
"description": "Scheduled job created successfully",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ScheduledJob"
}
}
}
},
"500": {
"description": "Internal server error"
}
}
}
},
"/schedule/delete/{id}": {
"delete": {
"tags": [
"schedule"
],
"operationId": "delete_schedule",
"parameters": [
{
"name": "id",
"in": "path",
"description": "ID of the schedule to delete",
"required": true,
"schema": {
"type": "string"
}
}
],
"responses": {
"204": {
"description": "Scheduled job deleted successfully"
},
"404": {
"description": "Scheduled job not found"
},
"500": {
"description": "Internal server error"
}
}
}
},
"/schedule/list": {
"get": {
"tags": [
"schedule"
],
"operationId": "list_schedules",
"responses": {
"200": {
"description": "A list of scheduled jobs",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ListSchedulesResponse"
}
}
}
},
"500": {
"description": "Internal server error"
}
}
}
},
"/schedule/{id}/run_now": {
"post": {
"tags": [
"schedule"
],
"operationId": "run_now_handler",
"parameters": [
{
"name": "id",
"in": "path",
"description": "ID of the schedule to run",
"required": true,
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "Scheduled job triggered successfully, returns new session ID",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/RunNowResponse"
}
}
}
},
"404": {
"description": "Scheduled job not found"
},
"500": {
"description": "Internal server error when trying to run the job"
}
}
}
},
"/schedule/{id}/sessions": {
"get": {
"tags": [
"schedule"
],
"operationId": "sessions_handler",
"parameters": [
{
"name": "id",
"in": "path",
"description": "ID of the schedule",
"required": true,
"schema": {
"type": "string"
}
},
{
"name": "limit",
"in": "query",
"required": false,
"schema": {
"type": "integer",
"format": "int32",
"minimum": 0
}
}
],
"responses": {
"200": {
"description": "A list of session display info",
"content": {
"application/json": {
"schema": {
"type": "array",
"items": {
"$ref": "#/components/schemas/SessionDisplayInfo"
}
}
}
}
},
"500": {
"description": "Internal server error"
}
}
}
},
"/sessions": {
"get": {
"tags": [
@@ -731,6 +901,25 @@
}
}
},
"CreateScheduleRequest": {
"type": "object",
"required": [
"id",
"recipe_source",
"cron"
],
"properties": {
"cron": {
"type": "string"
},
"id": {
"type": "string"
},
"recipe_source": {
"type": "string"
}
}
},
"EmbeddedResource": {
"type": "object",
"required": [
@@ -1030,6 +1219,20 @@
}
}
},
"ListSchedulesResponse": {
"type": "object",
"required": [
"jobs"
],
"properties": {
"jobs": {
"type": "array",
"items": {
"$ref": "#/components/schemas/ScheduledJob"
}
}
}
},
"Message": {
"type": "object",
"description": "A message to or from an LLM",
@@ -1334,15 +1537,13 @@
],
"properties": {
"is_configured": {
"type": "boolean",
"description": "Indicates whether the provider is fully configured"
"type": "boolean"
},
"metadata": {
"$ref": "#/components/schemas/ProviderMetadata"
},
"name": {
"type": "string",
"description": "Unique identifier and name of the provider"
"type": "string"
}
}
},
@@ -1469,6 +1670,103 @@
"assistant"
]
},
"RunNowResponse": {
"type": "object",
"required": [
"session_id"
],
"properties": {
"session_id": {
"type": "string"
}
}
},
"ScheduledJob": {
"type": "object",
"required": [
"id",
"source",
"cron"
],
"properties": {
"cron": {
"type": "string"
},
"id": {
"type": "string"
},
"last_run": {
"type": "string",
"format": "date-time",
"nullable": true
},
"source": {
"type": "string"
}
}
},
"SessionDisplayInfo": {
"type": "object",
"required": [
"id",
"name",
"createdAt",
"workingDir",
"messageCount"
],
"properties": {
"accumulatedInputTokens": {
"type": "integer",
"format": "int32",
"nullable": true
},
"accumulatedOutputTokens": {
"type": "integer",
"format": "int32",
"nullable": true
},
"accumulatedTotalTokens": {
"type": "integer",
"format": "int32",
"nullable": true
},
"createdAt": {
"type": "string"
},
"id": {
"type": "string"
},
"inputTokens": {
"type": "integer",
"format": "int32",
"nullable": true
},
"messageCount": {
"type": "integer",
"minimum": 0
},
"name": {
"type": "string"
},
"outputTokens": {
"type": "integer",
"format": "int32",
"nullable": true
},
"scheduleId": {
"type": "string",
"nullable": true
},
"totalTokens": {
"type": "integer",
"format": "int32",
"nullable": true
},
"workingDir": {
"type": "string"
}
}
},
"SessionHistoryResponse": {
"type": "object",
"required": [
@@ -1579,6 +1877,11 @@
"description": "The number of output tokens used in the session. Retrieved from the provider's last usage.",
"nullable": true
},
"schedule_id": {
"type": "string",
"description": "ID of the schedule that triggered this session, if any",
"nullable": true
},
"total_tokens": {
"type": "integer",
"format": "int32",
@@ -1592,6 +1895,16 @@
}
}
},
"SessionsQuery": {
"type": "object",
"properties": {
"limit": {
"type": "integer",
"format": "int32",
"minimum": 0
}
}
},
"SummarizationRequested": {
"type": "object",
"required": [
@@ -1757,8 +2070,7 @@
"$ref": "#/components/schemas/PermissionLevel"
},
"tool_name": {
"type": "string",
"description": "Unique identifier and name of the tool, format <extension_name>__<tool_name>"
"type": "string"
}
}
},
+10
View File
@@ -31,6 +31,7 @@
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.1",
"cors": "^2.8.5",
"cronstrue": "^2.48.0",
"dotenv": "^16.4.5",
"electron-log": "^5.2.2",
"electron-squirrel-startup": "^1.0.1",
@@ -6796,6 +6797,15 @@
"node": ">= 6"
}
},
"node_modules/cronstrue": {
"version": "2.61.0",
"resolved": "https://registry.npmjs.org/cronstrue/-/cronstrue-2.61.0.tgz",
"integrity": "sha512-ootN5bvXbIQI9rW94+QsXN5eROtXWwew6NkdGxIRpS/UFWRggL0G5Al7a9GTBFEsuvVhJ2K3CntIIVt7L2ILhA==",
"license": "MIT",
"bin": {
"cronstrue": "bin/cli.js"
}
},
"node_modules/cross-dirname": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/cross-dirname/-/cross-dirname-0.1.0.tgz",
+1
View File
@@ -102,6 +102,7 @@
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.1",
"cors": "^2.8.5",
"cronstrue": "^2.48.0",
"dotenv": "^16.4.5",
"electron-log": "^5.2.2",
"electron-squirrel-startup": "^1.0.1",
+12 -77
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}
+40 -1
View File
@@ -1,7 +1,7 @@
// This file is auto-generated by @hey-api/openapi-ts
import type { Options as ClientOptions, TDataShape, Client } from '@hey-api/client-fetch';
import type { GetToolsData, GetToolsResponse, ReadAllConfigData, ReadAllConfigResponse, BackupConfigData, BackupConfigResponse, GetExtensionsData, GetExtensionsResponse, AddExtensionData, AddExtensionResponse, RemoveExtensionData, RemoveExtensionResponse, InitConfigData, InitConfigResponse, UpsertPermissionsData, UpsertPermissionsResponse, ProvidersData, ProvidersResponse2, ReadConfigData, RemoveConfigData, RemoveConfigResponse, UpsertConfigData, UpsertConfigResponse, ConfirmPermissionData, ManageContextData, ManageContextResponse, ListSessionsData, ListSessionsResponse, GetSessionHistoryData, GetSessionHistoryResponse } from './types.gen';
import type { GetToolsData, GetToolsResponse, ReadAllConfigData, ReadAllConfigResponse, BackupConfigData, BackupConfigResponse, GetExtensionsData, GetExtensionsResponse, AddExtensionData, AddExtensionResponse, RemoveExtensionData, RemoveExtensionResponse, InitConfigData, InitConfigResponse, UpsertPermissionsData, UpsertPermissionsResponse, ProvidersData, ProvidersResponse2, ReadConfigData, RemoveConfigData, RemoveConfigResponse, UpsertConfigData, UpsertConfigResponse, ConfirmPermissionData, ManageContextData, ManageContextResponse, CreateScheduleData, CreateScheduleResponse, DeleteScheduleData, DeleteScheduleResponse, ListSchedulesData, ListSchedulesResponse2, RunNowHandlerData, RunNowHandlerResponse, SessionsHandlerData, SessionsHandlerResponse, ListSessionsData, ListSessionsResponse, GetSessionHistoryData, GetSessionHistoryResponse } from './types.gen';
import { client as _heyApiClient } from './client.gen';
export type Options<TData extends TDataShape = TDataShape, ThrowOnError extends boolean = boolean> = ClientOptions<TData, ThrowOnError> & {
@@ -144,6 +144,45 @@ export const manageContext = <ThrowOnError extends boolean = false>(options: Opt
});
};
export const createSchedule = <ThrowOnError extends boolean = false>(options: Options<CreateScheduleData, ThrowOnError>) => {
return (options.client ?? _heyApiClient).post<CreateScheduleResponse, unknown, ThrowOnError>({
url: '/schedule/create',
...options,
headers: {
'Content-Type': 'application/json',
...options?.headers
}
});
};
export const deleteSchedule = <ThrowOnError extends boolean = false>(options: Options<DeleteScheduleData, ThrowOnError>) => {
return (options.client ?? _heyApiClient).delete<DeleteScheduleResponse, unknown, ThrowOnError>({
url: '/schedule/delete/{id}',
...options
});
};
export const listSchedules = <ThrowOnError extends boolean = false>(options?: Options<ListSchedulesData, ThrowOnError>) => {
return (options?.client ?? _heyApiClient).get<ListSchedulesResponse2, unknown, ThrowOnError>({
url: '/schedule/list',
...options
});
};
export const runNowHandler = <ThrowOnError extends boolean = false>(options: Options<RunNowHandlerData, ThrowOnError>) => {
return (options.client ?? _heyApiClient).post<RunNowHandlerResponse, unknown, ThrowOnError>({
url: '/schedule/{id}/run_now',
...options
});
};
export const sessionsHandler = <ThrowOnError extends boolean = false>(options: Options<SessionsHandlerData, ThrowOnError>) => {
return (options.client ?? _heyApiClient).get<SessionsHandlerResponse, unknown, ThrowOnError>({
url: '/schedule/{id}/sessions',
...options
});
};
export const listSessions = <ThrowOnError extends boolean = false>(options?: Options<ListSessionsData, ThrowOnError>) => {
return (options?.client ?? _heyApiClient).get<ListSessionsResponse, unknown, ThrowOnError>({
url: '/sessions',
+184 -9
View File
@@ -62,6 +62,12 @@ export type ContextManageResponse = {
tokenCounts: Array<number>;
};
export type CreateScheduleRequest = {
cron: string;
id: string;
recipe_source: string;
};
export type EmbeddedResource = {
annotations?: Annotations | null;
resource: ResourceContents;
@@ -166,6 +172,10 @@ export type ImageContent = {
mimeType: string;
};
export type ListSchedulesResponse = {
jobs: Array<ScheduledJob>;
};
/**
* A message to or from an LLM
*/
@@ -228,14 +238,8 @@ export type PermissionLevel = 'always_allow' | 'ask_before' | 'never_allow';
export type PrincipalType = 'Extension' | 'Tool';
export type ProviderDetails = {
/**
* Indicates whether the provider is fully configured
*/
is_configured: boolean;
metadata: ProviderMetadata;
/**
* Unique identifier and name of the provider
*/
name: string;
};
@@ -294,6 +298,32 @@ export type ResourceContents = {
export type Role = 'user' | 'assistant';
export type RunNowResponse = {
session_id: string;
};
export type ScheduledJob = {
cron: string;
id: string;
last_run?: string | null;
source: string;
};
export type SessionDisplayInfo = {
accumulatedInputTokens?: number | null;
accumulatedOutputTokens?: number | null;
accumulatedTotalTokens?: number | null;
createdAt: string;
id: string;
inputTokens?: number | null;
messageCount: number;
name: string;
outputTokens?: number | null;
scheduleId?: string | null;
totalTokens?: number | null;
workingDir: string;
};
export type SessionHistoryResponse = {
/**
* List of messages in the session conversation
@@ -352,6 +382,10 @@ export type SessionMetadata = {
* The number of output tokens used in the session. Retrieved from the provider's last usage.
*/
output_tokens?: number | null;
/**
* ID of the schedule that triggered this session, if any
*/
schedule_id?: string | null;
/**
* The total number of tokens used in the session. Retrieved from the provider's last usage.
*/
@@ -362,6 +396,10 @@ export type SessionMetadata = {
working_dir: string;
};
export type SessionsQuery = {
limit?: number;
};
export type SummarizationRequested = {
msg: string;
};
@@ -464,9 +502,6 @@ export type ToolInfo = {
export type ToolPermission = {
permission: PermissionLevel;
/**
* Unique identifier and name of the tool, format <extension_name>__<tool_name>
*/
tool_name: string;
};
@@ -849,6 +884,146 @@ export type ManageContextResponses = {
export type ManageContextResponse = ManageContextResponses[keyof ManageContextResponses];
export type CreateScheduleData = {
body: CreateScheduleRequest;
path?: never;
query?: never;
url: '/schedule/create';
};
export type CreateScheduleErrors = {
/**
* Internal server error
*/
500: unknown;
};
export type CreateScheduleResponses = {
/**
* Scheduled job created successfully
*/
200: ScheduledJob;
};
export type CreateScheduleResponse = CreateScheduleResponses[keyof CreateScheduleResponses];
export type DeleteScheduleData = {
body?: never;
path: {
/**
* ID of the schedule to delete
*/
id: string;
};
query?: never;
url: '/schedule/delete/{id}';
};
export type DeleteScheduleErrors = {
/**
* Scheduled job not found
*/
404: unknown;
/**
* Internal server error
*/
500: unknown;
};
export type DeleteScheduleResponses = {
/**
* Scheduled job deleted successfully
*/
204: void;
};
export type DeleteScheduleResponse = DeleteScheduleResponses[keyof DeleteScheduleResponses];
export type ListSchedulesData = {
body?: never;
path?: never;
query?: never;
url: '/schedule/list';
};
export type ListSchedulesErrors = {
/**
* Internal server error
*/
500: unknown;
};
export type ListSchedulesResponses = {
/**
* A list of scheduled jobs
*/
200: ListSchedulesResponse;
};
export type ListSchedulesResponse2 = ListSchedulesResponses[keyof ListSchedulesResponses];
export type RunNowHandlerData = {
body?: never;
path: {
/**
* ID of the schedule to run
*/
id: string;
};
query?: never;
url: '/schedule/{id}/run_now';
};
export type RunNowHandlerErrors = {
/**
* Scheduled job not found
*/
404: unknown;
/**
* Internal server error when trying to run the job
*/
500: unknown;
};
export type RunNowHandlerResponses = {
/**
* Scheduled job triggered successfully, returns new session ID
*/
200: RunNowResponse;
};
export type RunNowHandlerResponse = RunNowHandlerResponses[keyof RunNowHandlerResponses];
export type SessionsHandlerData = {
body?: never;
path: {
/**
* ID of the schedule
*/
id: string;
};
query?: {
limit?: number;
};
url: '/schedule/{id}/sessions';
};
export type SessionsHandlerErrors = {
/**
* Internal server error
*/
500: unknown;
};
export type SessionsHandlerResponses = {
/**
* A list of session display info
*/
200: Array<SessionDisplayInfo>;
};
export type SessionsHandlerResponse = SessionsHandlerResponses[keyof SessionsHandlerResponses];
export type ListSessionsData = {
body?: never;
path?: never;
@@ -0,0 +1,19 @@
// /Users/mnovich/Development/goose-1.0/ui/desktop/src/components/icons/TrashIcon.tsx
import React from 'react';
interface IconProps extends React.SVGProps<globalThis.SVGSVGElement> {}
export const TrashIcon: React.FC<IconProps> = (props) => (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
{...props} // Allows passing className, w-5, h-5, etc.
>
<path
fillRule="evenodd"
d="M9 2a1 1 0 00-.894.553L7.382 4H4a1 1 0 000 2v10a2 2 0 002 2h8a2 2 0 002-2V6a1 1 0 100-2h-3.382l-.724-1.447A1 1 0 0011 2H9zM7 8a1 1 0 012 0v6a1 1 0 11-2 0V8zm5-1a1 1 0 00-1 1v6a1 1 0 102 0V8a1 1 0 00-1-1z"
clipRule="evenodd"
/>
</svg>
);
@@ -125,17 +125,14 @@ export default function MoreMenu({
useEffect(() => {
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
// Handler for system theme changes
const handleThemeChange = (e: { matches: boolean }) => {
if (themeMode === 'system') {
setDarkMode(e.matches);
}
};
// Add listener for system theme changes
mediaQuery.addEventListener('change', handleThemeChange);
// Initial setup
if (themeMode === 'system') {
setDarkMode(mediaQuery.matches);
localStorage.setItem('use_system_theme', 'true');
@@ -145,7 +142,6 @@ export default function MoreMenu({
localStorage.setItem('theme', themeMode);
}
// Cleanup
return () => mediaQuery.removeEventListener('change', handleThemeChange);
}, [themeMode]);
@@ -221,6 +217,16 @@ 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={() => setIsGoosehintsModalOpen(true)}
subtitle="Customize instructions"
@@ -0,0 +1,439 @@
import React, { useState, useEffect, FormEvent } from 'react';
import { Card } from '../ui/card';
import { Button } from '../ui/button';
import { Input } from '../ui/input';
import { Select } from '../ui/select';
import cronstrue from 'cronstrue';
type FrequencyValue = 'once' | 'hourly' | 'daily' | 'weekly' | 'monthly';
interface FrequencyOption {
value: FrequencyValue;
label: string;
}
export interface NewSchedulePayload {
id: string;
recipe_source: string;
cron: string;
}
interface CreateScheduleModalProps {
isOpen: boolean;
onClose: () => void;
onSubmit: (payload: NewSchedulePayload) => Promise<void>;
isLoadingExternally: boolean;
apiErrorExternally: string | null;
}
const frequencies: FrequencyOption[] = [
{ value: 'once', label: 'Once' },
{ value: 'hourly', label: 'Hourly' },
{ value: 'daily', label: 'Daily' },
{ value: 'weekly', label: 'Weekly' },
{ value: 'monthly', label: 'Monthly' },
];
const daysOfWeekOptions: { value: string; label: string }[] = [
{ value: '1', label: 'Mon' },
{ value: '2', label: 'Tue' },
{ value: '3', label: 'Wed' },
{ value: '4', label: 'Thu' },
{ value: '5', label: 'Fri' },
{ value: '6', label: 'Sat' },
{ value: '0', label: 'Sun' },
];
const modalLabelClassName = 'block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1';
const cronPreviewTextColor = 'text-xs text-gray-500 dark:text-gray-400 mt-1';
const cronPreviewSpecialNoteColor = 'text-xs text-yellow-600 dark:text-yellow-500 mt-1';
const checkboxLabelClassName = 'flex items-center text-sm text-textStandard dark:text-gray-300';
const checkboxInputClassName =
'h-4 w-4 text-indigo-600 border-gray-300 dark:border-gray-600 rounded focus:ring-indigo-500 mr-2';
export const CreateScheduleModal: React.FC<CreateScheduleModalProps> = ({
isOpen,
onClose,
onSubmit,
isLoadingExternally,
apiErrorExternally,
}) => {
const [scheduleId, setScheduleId] = useState<string>('');
const [recipeSourcePath, setRecipeSourcePath] = useState<string>('');
const [frequency, setFrequency] = useState<FrequencyValue>('daily');
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 [selectedDaysOfWeek, setSelectedDaysOfWeek] = useState<Set<string>>(new Set(['1']));
const [selectedDayOfMonth, setSelectedDayOfMonth] = useState<string>('1');
const [derivedCronExpression, setDerivedCronExpression] = useState<string>('');
const [readableCronExpression, setReadableCronExpression] = useState<string>('');
const [internalValidationError, setInternalValidationError] = useState<string | null>(null);
const resetForm = () => {
setScheduleId('');
setRecipeSourcePath('');
setFrequency('daily');
setSelectedDate(new Date().toISOString().split('T')[0]);
setSelectedTime('09:00');
setSelectedMinute('0');
setSelectedDaysOfWeek(new Set(['1']));
setSelectedDayOfMonth('1');
setInternalValidationError(null);
setReadableCronExpression('');
};
const handleBrowseFile = async () => {
const filePath = await window.electron.selectFileOrDirectory();
if (filePath) {
if (filePath.endsWith('.yaml') || filePath.endsWith('.yml')) {
setRecipeSourcePath(filePath);
setInternalValidationError(null);
} else {
setInternalValidationError('Invalid file type: Please select a YAML file (.yaml or .yml)');
console.warn('Invalid file type: Please select a YAML file (.yaml or .yml)');
}
}
};
useEffect(() => {
const generateCronExpression = (): string => {
const timeParts = selectedTime.split(':');
const minutePart = timeParts.length > 1 ? String(parseInt(timeParts[1], 10)) : '0';
const hourPart = timeParts.length > 0 ? String(parseInt(timeParts[0], 10)) : '0';
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()} ${
dateObj.getMonth() + 1
} *`;
} catch (e) {
return "Error parsing date/time for 'once'.";
}
}
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.';
}
return `${secondsPart} ${sMinute} * * * *`;
}
case 'daily':
return `${secondsPart} ${minutePart} ${hourPart} * * *`;
case 'weekly': {
if (selectedDaysOfWeek.size === 0) {
return 'Select at least one day for weekly frequency.';
}
const days = Array.from(selectedDaysOfWeek)
.sort((a, b) => parseInt(a) - parseInt(b))
.join(',');
return `${secondsPart} ${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} * *`;
}
default:
return 'Invalid frequency selected.';
}
};
const cron = generateCronExpression();
setDerivedCronExpression(cron);
try {
if (
cron.includes('Invalid') ||
cron.includes('required') ||
cron.includes('Error') ||
cron.includes('Select at least one')
) {
setReadableCronExpression('Invalid cron details provided.');
} else {
setReadableCronExpression(cronstrue.toString(cron));
}
} catch (e) {
setReadableCronExpression('Could not parse cron string.');
}
}, [
frequency,
selectedDate,
selectedTime,
selectedMinute,
selectedDaysOfWeek,
selectedDayOfMonth,
]);
const handleDayOfWeekChange = (dayValue: string) => {
setSelectedDaysOfWeek((prev) => {
const newSet = new Set(prev);
if (newSet.has(dayValue)) {
newSet.delete(dayValue);
} else {
newSet.add(dayValue);
}
return newSet;
});
};
const handleLocalSubmit = async (event: FormEvent) => {
event.preventDefault();
setInternalValidationError(null);
if (!scheduleId.trim()) {
setInternalValidationError('Schedule ID is required.');
return;
}
if (!recipeSourcePath) {
setInternalValidationError('Recipe source file is required.');
return;
}
if (
!derivedCronExpression ||
derivedCronExpression.includes('Invalid') ||
derivedCronExpression.includes('required') ||
derivedCronExpression.includes('Error') ||
derivedCronExpression.includes('Select at least one')
) {
setInternalValidationError(`Invalid cron expression: ${derivedCronExpression}`);
return;
}
if (frequency === 'weekly' && selectedDaysOfWeek.size === 0) {
setInternalValidationError('For weekly frequency, select at least one day.');
return;
}
const newSchedulePayload: NewSchedulePayload = {
id: scheduleId.trim(),
recipe_source: recipeSourcePath,
cron: derivedCronExpression,
};
await onSubmit(newSchedulePayload);
};
const handleClose = () => {
resetForm();
onClose();
};
if (!isOpen) return null;
return (
<div className="fixed inset-0 bg-black/20 backdrop-blur-sm z-40 flex items-center justify-center p-4">
<Card className="w-full max-w-md bg-bgApp shadow-xl rounded-lg z-50 flex flex-col max-h-[90vh] overflow-hidden">
<div className="px-6 pt-6 pb-4 flex-shrink-0">
<h2 className="text-xl font-semibold text-gray-900 dark:text-white">
Create New Schedule
</h2>
</div>
<form
id="new-schedule-form"
onSubmit={handleLocalSubmit}
className="px-6 py-4 space-y-4 flex-grow overflow-y-auto"
>
{apiErrorExternally && (
<p className="text-red-500 text-sm mb-3 p-2 bg-red-100 dark:bg-red-900/30 rounded-md border border-red-500/50">
{apiErrorExternally}
</p>
)}
{internalValidationError && (
<p className="text-red-500 text-sm mb-3 p-2 bg-red-100 dark:bg-red-900/30 rounded-md border border-red-500/50">
{internalValidationError}
</p>
)}
<div>
<label htmlFor="scheduleId-modal" className={modalLabelClassName}>
Schedule ID:
</label>
<Input
type="text"
id="scheduleId-modal"
value={scheduleId}
onChange={(e) => setScheduleId(e.target.value)}
placeholder="e.g., daily-summary-job"
required
/>
</div>
<div>
<label className={modalLabelClassName}>Recipe Source (YAML File):</label>
<Button
type="button"
variant="outline"
onClick={handleBrowseFile}
className="w-full justify-center"
>
Browse...
</Button>
{recipeSourcePath && (
<p className="mt-2 text-xs text-gray-500 dark:text-gray-400 italic">
Selected: {recipeSourcePath}
</p>
)}
</div>
<div>
<label htmlFor="frequency-modal" className={modalLabelClassName}>
Frequency:
</label>
<Select
instanceId="frequency-select-modal"
options={frequencies}
value={frequencies.find((f) => f.value === frequency)}
onChange={(selectedOption: FrequencyOption | null) => {
if (selectedOption) setFrequency(selectedOption.value);
}}
placeholder="Select frequency..."
/>
</div>
{frequency === 'once' && (
<>
<div>
<label htmlFor="onceDate-modal" className={modalLabelClassName}>
Date:
</label>
<Input
type="date"
id="onceDate-modal"
value={selectedDate}
onChange={(e) => setSelectedDate(e.target.value)}
required
/>
</div>
<div>
<label htmlFor="onceTime-modal" className={modalLabelClassName}>
Time:
</label>
<Input
type="time"
id="onceTime-modal"
value={selectedTime}
onChange={(e) => setSelectedTime(e.target.value)}
required
/>
</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}>
Time:
</label>
<Input
type="time"
id="commonTime-modal"
value={selectedTime}
onChange={(e) => setSelectedTime(e.target.value)}
required
/>
</div>
)}
{frequency === 'weekly' && (
<div>
<label className={modalLabelClassName}>Days of Week:</label>
<div className="grid grid-cols-3 sm:grid-cols-4 gap-2 mt-1">
{daysOfWeekOptions.map((day) => (
<label key={day.value} className={checkboxLabelClassName}>
<input
type="checkbox"
value={day.value}
checked={selectedDaysOfWeek.has(day.value)}
onChange={() => handleDayOfWeekChange(day.value)}
className={checkboxInputClassName}
/>
{day.label}
</label>
))}
</div>
</div>
)}
{frequency === 'monthly' && (
<div>
<label htmlFor="monthlyDay-modal" className={modalLabelClassName}>
Day of Month (1-31):
</label>
<Input
type="number"
id="monthlyDay-modal"
min="1"
max="31"
value={selectedDayOfMonth}
onChange={(e) => setSelectedDayOfMonth(e.target.value)}
required
/>
</div>
)}
<div className="mt-4 p-3 bg-gray-100 dark:bg-gray-700/50 rounded-md border border-gray-200 dark:border-gray-600">
<p className="text-sm font-medium text-gray-700 dark:text-gray-300">
Generated Cron:{' '}
<code className="text-xs bg-gray-200 dark:bg-gray-600 p-1 rounded">
{derivedCronExpression}
</code>
</p>
<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>
{frequency === 'once' && (
<p className={cronPreviewSpecialNoteColor}>
Note: "Once" schedules recur annually. True one-time tasks may need backend deletion
after execution.
</p>
)}
</div>
</form>
{/* Actions */}
<div className="mt-[8px] ml-[-24px] mr-[-24px] pt-[16px]">
<Button
type="button"
variant="ghost"
onClick={handleClose}
disabled={isLoadingExternally}
className="w-full h-[60px] rounded-none border-t dark:border-gray-600 text-gray-400 hover:bg-gray-50 dark:border-gray-600 text-lg font-regular"
>
Cancel
</Button>
<Button
type="submit"
form="new-schedule-form"
variant="default"
disabled={isLoadingExternally}
className="w-full h-[60px] rounded-none border-t dark:border-gray-600 text-lg hover:bg-gray-50 hover:dark:text-black dark:text-white dark:border-gray-600 font-regular"
>
{isLoadingExternally ? 'Creating...' : 'Create Schedule'}
</Button>
</div>
</Card>
</div>
);
};
@@ -0,0 +1,260 @@
import React, { useState, useEffect, useCallback } from 'react';
import { Button } from '../ui/button';
import { ScrollArea } from '../ui/scroll-area';
import BackButton from '../ui/BackButton';
import { Card } from '../ui/card';
import MoreMenuLayout from '../more_menu/MoreMenuLayout';
import { fetchSessionDetails, SessionDetails } from '../../sessions';
import { getScheduleSessions, runScheduleNow } from '../../schedule';
import SessionHistoryView from '../sessions/SessionHistoryView';
import { toastError, toastSuccess } from '../../toasts';
interface ScheduleSessionMeta {
id: string;
name: string;
createdAt: string;
workingDir?: string;
scheduleId?: string | null;
messageCount?: number;
totalTokens?: number | null;
inputTokens?: number | null;
outputTokens?: number | null;
accumulatedTotalTokens?: number | null;
accumulatedInputTokens?: number | null;
accumulatedOutputTokens?: number | null;
}
interface ScheduleDetailViewProps {
scheduleId: string | null;
onNavigateBack: () => void;
}
const ScheduleDetailView: React.FC<ScheduleDetailViewProps> = ({ scheduleId, onNavigateBack }) => {
const [sessions, setSessions] = useState<ScheduleSessionMeta[]>([]);
const [isLoadingSessions, setIsLoadingSessions] = useState(false);
const [sessionsError, setSessionsError] = useState<string | null>(null);
const [runNowLoading, setRunNowLoading] = useState(false);
const [selectedSessionDetails, setSelectedSessionDetails] = useState<SessionDetails | null>(null);
const [isLoadingSessionDetails, setIsLoadingSessionDetails] = useState(false);
const [sessionDetailsError, setSessionDetailsError] = useState<string | null>(null);
const fetchScheduleSessions = useCallback(async (sId: string) => {
if (!sId) return;
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[]);
} catch (err) {
console.error('Failed to fetch schedule sessions:', err);
setSessionsError(err instanceof Error ? err.message : 'Failed to fetch schedule sessions');
} finally {
setIsLoadingSessions(false);
}
}, []);
useEffect(() => {
if (scheduleId && !selectedSessionDetails) {
fetchScheduleSessions(scheduleId);
} else if (!scheduleId) {
setSessions([]);
setSessionsError(null);
setRunNowLoading(false);
setSelectedSessionDetails(null);
}
}, [scheduleId, fetchScheduleSessions, selectedSessionDetails]);
const handleRunNow = async () => {
if (!scheduleId) return;
setRunNowLoading(true);
try {
const newSessionId = await runScheduleNow(scheduleId); // MODIFIED
toastSuccess({
title: 'Schedule Triggered',
msg: `Successfully triggered schedule. New session ID: ${newSessionId}`,
});
setTimeout(() => {
if (scheduleId) fetchScheduleSessions(scheduleId);
}, 1000);
} catch (err) {
console.error('Failed to run schedule now:', err);
const errorMsg = err instanceof Error ? err.message : 'Failed to trigger schedule';
toastError({ title: 'Run Schedule Error', msg: errorMsg });
} finally {
setRunNowLoading(false);
}
};
const loadAndShowSessionDetails = async (sessionId: string) => {
setIsLoadingSessionDetails(true);
setSessionDetailsError(null);
setSelectedSessionDetails(null);
try {
const details = await fetchSessionDetails(sessionId);
setSelectedSessionDetails(details);
} catch (err) {
console.error(`Failed to load session details for ${sessionId}:`, err);
const errorMsg = err instanceof Error ? err.message : 'Failed to load session details.';
setSessionDetailsError(errorMsg);
toastError({
title: 'Failed to load session details',
msg: errorMsg,
});
} finally {
setIsLoadingSessionDetails(false);
}
};
const handleSessionCardClick = (sessionIdFromCard: string) => {
loadAndShowSessionDetails(sessionIdFromCard);
};
const handleResumeViewedSession = () => {
if (selectedSessionDetails) {
const { session_id, metadata } = selectedSessionDetails;
if (metadata.working_dir) {
console.log(
`Resuming session ID ${session_id} in new chat window. Dir: ${metadata.working_dir}`
);
window.electron.createChatWindow(undefined, metadata.working_dir, undefined, session_id);
} else {
console.error('Cannot resume session: working directory is missing.');
toastError({ title: 'Cannot Resume Session', msg: 'Working directory is missing.' });
}
}
};
if (selectedSessionDetails) {
return (
<SessionHistoryView
session={selectedSessionDetails}
isLoading={isLoadingSessionDetails}
error={sessionDetailsError}
onBack={() => {
setSelectedSessionDetails(null);
setSessionDetailsError(null);
}}
onResume={handleResumeViewedSession}
onRetry={() => loadAndShowSessionDetails(selectedSessionDetails.session_id)}
showActionButtons={true}
/>
);
}
if (!scheduleId) {
return (
<div className="h-screen w-full flex flex-col items-center justify-center bg-app text-textStandard p-8">
<MoreMenuLayout showMenu={false} />
<BackButton onClick={onNavigateBack} />
<h1 className="text-2xl font-medium text-gray-900 dark:text-white mt-4">
Schedule Not Found
</h1>
<p className="text-gray-600 dark:text-gray-400 mt-2">
No schedule ID was provided. Please return to the schedules list and select a schedule.
</p>
</div>
);
}
return (
<div className="h-screen w-full flex flex-col bg-app text-textStandard">
<MoreMenuLayout showMenu={false} />
<div className="px-8 pt-6 pb-4 border-b border-borderSubtle flex-shrink-0">
<BackButton onClick={onNavigateBack} />
<h1 className="text-3xl font-medium text-gray-900 dark:text-white mt-1">
Schedule Details
</h1>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
Viewing Schedule ID: {scheduleId}
</p>
</div>
<ScrollArea className="flex-grow">
<div className="p-8 space-y-6">
<section>
<h2 className="text-xl font-semibold text-gray-900 dark:text-white mb-3">Actions</h2>
<Button onClick={handleRunNow} disabled={runNowLoading} className="w-full md:w-auto">
{runNowLoading ? 'Triggering...' : 'Run Schedule Now'}
</Button>
</section>
<section>
<h2 className="text-xl font-semibold text-gray-900 dark:text-white mb-4">
Recent Sessions for this Schedule
</h2>
{isLoadingSessions && (
<p className="text-gray-500 dark:text-gray-400">Loading sessions...</p>
)}
{sessionsError && (
<p className="text-red-500 dark:text-red-400 text-sm p-3 bg-red-100 dark:bg-red-900/30 border border-red-500 dark:border-red-700 rounded-md">
Error: {sessionsError}
</p>
)}
{!isLoadingSessions && !sessionsError && sessions.length === 0 && (
<p className="text-gray-500 dark:text-gray-400 text-center py-4">
No sessions found for this schedule.
</p>
)}
{!isLoadingSessions && sessions.length > 0 && (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{sessions.map((session) => (
<Card
key={session.id}
className="p-4 bg-white dark:bg-gray-800 shadow cursor-pointer hover:shadow-lg transition-shadow duration-200"
onClick={() => handleSessionCardClick(session.id)}
role="button"
tabIndex={0}
onKeyPress={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
handleSessionCardClick(session.id);
}
}}
>
<h3
className="text-sm font-semibold text-gray-900 dark:text-white truncate"
title={session.name || session.id}
>
{session.name || `Session ID: ${session.id}`}{' '}
</h3>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
Created:{' '}
{session.createdAt ? new Date(session.createdAt).toLocaleString() : 'N/A'}
</p>
{session.messageCount !== undefined && (
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
Messages: {session.messageCount}
</p>
)}
{session.workingDir && (
<p
className="text-xs text-gray-500 dark:text-gray-400 mt-1 truncate"
title={session.workingDir}
>
Dir: {session.workingDir}
</p>
)}
{session.accumulatedTotalTokens !== undefined &&
session.accumulatedTotalTokens !== null && (
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
Tokens: {session.accumulatedTotalTokens}
</p>
)}
<p className="text-xs text-gray-600 dark:text-gray-500 mt-1">
ID: <span className="font-mono">{session.id}</span>
</p>
</Card>
))}
</div>
)}
</section>
</div>
</ScrollArea>
</div>
);
};
export default ScheduleDetailView;
@@ -0,0 +1,230 @@
import React, { useState, useEffect } from 'react';
import { listSchedules, createSchedule, deleteSchedule, ScheduledJob } from '../../schedule';
import BackButton from '../ui/BackButton';
import { ScrollArea } from '../ui/scroll-area';
import MoreMenuLayout from '../more_menu/MoreMenuLayout';
import { Card } from '../ui/card';
import { Button } from '../ui/button';
import { TrashIcon } from '../icons/TrashIcon';
import Plus from '../ui/Plus';
import { CreateScheduleModal, NewSchedulePayload } from './CreateScheduleModal';
import ScheduleDetailView from './ScheduleDetailView';
import cronstrue from 'cronstrue';
interface SchedulesViewProps {
onClose: () => void;
}
const SchedulesView: React.FC<SchedulesViewProps> = ({ onClose }) => {
const [schedules, setSchedules] = useState<ScheduledJob[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
const [apiError, setApiError] = useState<string | null>(null);
const [submitApiError, setSubmitApiError] = useState<string | null>(null);
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
const [viewingScheduleId, setViewingScheduleId] = useState<string | null>(null);
const fetchSchedules = async () => {
setIsLoading(true);
setApiError(null);
try {
const fetchedSchedules = await listSchedules();
setSchedules(fetchedSchedules);
} catch (error) {
console.error('Failed to fetch schedules:', error);
setApiError(
error instanceof Error
? error.message
: 'An unknown error occurred while fetching schedules.'
);
} finally {
setIsLoading(false);
}
};
useEffect(() => {
if (viewingScheduleId === null) {
fetchSchedules();
}
}, [viewingScheduleId]);
const handleOpenCreateModal = () => {
setSubmitApiError(null);
setIsCreateModalOpen(true);
};
const handleCloseCreateModal = () => {
setIsCreateModalOpen(false);
setSubmitApiError(null);
};
const handleCreateScheduleSubmit = async (payload: NewSchedulePayload) => {
setIsSubmitting(true);
setSubmitApiError(null);
try {
await createSchedule(payload);
await fetchSchedules();
setIsCreateModalOpen(false);
} catch (error) {
console.error('Failed to create schedule:', error);
const errorMessage =
error instanceof Error ? error.message : 'Unknown error creating schedule.';
setSubmitApiError(errorMessage);
} finally {
setIsSubmitting(false);
}
};
const handleDeleteSchedule = async (idToDelete: string) => {
if (!window.confirm(`Are you sure you want to delete schedule "${idToDelete}"?`)) return;
if (viewingScheduleId === idToDelete) {
setViewingScheduleId(null);
}
setIsLoading(true);
setApiError(null);
try {
await deleteSchedule(idToDelete);
await fetchSchedules();
} catch (error) {
console.error(`Failed to delete schedule "${idToDelete}":`, error);
setApiError(
error instanceof Error ? error.message : `Unknown error deleting "${idToDelete}".`
);
} finally {
setIsLoading(false);
}
};
const handleNavigateToScheduleDetail = (scheduleId: string) => {
setViewingScheduleId(scheduleId);
};
const handleNavigateBackFromDetail = () => {
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
scheduleId={viewingScheduleId}
onNavigateBack={handleNavigateBackFromDetail}
/>
);
}
return (
<div className="h-screen w-full flex flex-col bg-app text-textStandard">
<MoreMenuLayout showMenu={false} />
<div className="px-8 pt-6 pb-4 border-b border-borderSubtle flex-shrink-0">
<BackButton onClick={onClose} />
<h1 className="text-2xl font-semibold text-gray-900 dark:text-white mt-2">
Schedules Management
</h1>
</div>
<ScrollArea className="flex-grow">
<div className="p-8">
<Button
onClick={handleOpenCreateModal}
className="w-full md:w-auto flex items-center gap-2 justify-center text-white dark:text-black bg-bgAppInverse hover:bg-bgStandardInverse [&>svg]:!size-4 mb-8"
>
<Plus className="h-4 w-4" /> Create New Schedule
</Button>
{apiError && (
<p className="text-red-500 dark:text-red-400 text-sm p-4 bg-red-100 dark:bg-red-900/30 border border-red-500 dark:border-red-700 rounded-md">
Error: {apiError}
</p>
)}
<section>
<h2 className="text-xl font-semibold text-gray-900 dark:text-white mb-4">
Existing Schedules
</h2>
{isLoading && schedules.length === 0 && (
<p className="text-gray-500 dark:text-gray-400">Loading schedules...</p>
)}
{!isLoading && !apiError && schedules.length === 0 && (
<p className="text-gray-500 dark:text-gray-400 text-center py-4">
No schedules found. Create one to get started!
</p>
)}
{!isLoading && schedules.length > 0 && (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{schedules.map((job) => (
<Card
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>
</div>
<div className="flex-shrink-0">
<Button
variant="ghost"
size="icon"
onClick={(e) => {
e.stopPropagation();
handleDeleteSchedule(job.id);
}}
className="text-gray-500 dark:text-gray-400 hover:text-red-500 dark:hover:text-red-400 hover:bg-red-100/50 dark:hover:bg-red-900/30"
title={`Delete schedule ${job.id}`}
disabled={isLoading}
>
<TrashIcon className="w-5 h-5" />
</Button>
</div>
</div>
</Card>
))}
</div>
)}
</section>
</div>
</ScrollArea>
<CreateScheduleModal
isOpen={isCreateModalOpen}
onClose={handleCloseCreateModal}
onSubmit={handleCreateScheduleSubmit}
isLoadingExternally={isSubmitting}
apiErrorExternally={submitApiError}
/>
</div>
);
};
export default SchedulesView;
@@ -26,6 +26,7 @@ interface SessionHistoryViewProps {
onBack: () => void;
onResume: () => void;
onRetry: () => void;
showActionButtons?: boolean;
}
const SessionHistoryView: React.FC<SessionHistoryViewProps> = ({
@@ -35,6 +36,7 @@ const SessionHistoryView: React.FC<SessionHistoryViewProps> = ({
onBack,
onResume,
onRetry,
showActionButtons = true,
}) => {
const [isShareModalOpen, setIsShareModalOpen] = useState(false);
const [shareLink, setShareLink] = useState<string>('');
@@ -47,7 +49,6 @@ const SessionHistoryView: React.FC<SessionHistoryViewProps> = ({
if (savedSessionConfig) {
try {
const config = JSON.parse(savedSessionConfig);
// If config.enabled is true and config.baseUrl is non-empty, we can share
if (config.enabled && config.baseUrl) {
setCanShare(true);
}
@@ -61,7 +62,6 @@ const SessionHistoryView: React.FC<SessionHistoryViewProps> = ({
setIsSharing(true);
try {
// Get the session sharing configuration from localStorage
const savedSessionConfig = localStorage.getItem('session_sharing_config');
if (!savedSessionConfig) {
throw new Error('Session sharing is not configured. Please configure it in settings.');
@@ -72,7 +72,6 @@ const SessionHistoryView: React.FC<SessionHistoryViewProps> = ({
throw new Error('Session sharing is not enabled or base URL is not configured.');
}
// Create a shared session
const shareToken = await createSharedSession(
config.baseUrl,
session.metadata.working_dir,
@@ -81,7 +80,6 @@ const SessionHistoryView: React.FC<SessionHistoryViewProps> = ({
session.metadata.total_tokens
);
// Create the shareable link
const shareableLink = `goose://sessions/${shareToken}`;
setShareLink(shareableLink);
setIsShareModalOpen(true);
@@ -112,9 +110,7 @@ const SessionHistoryView: React.FC<SessionHistoryViewProps> = ({
<div className="h-screen w-full flex flex-col">
<MoreMenuLayout showMenu={false} />
{/* Top Row - back, info, reopen thread (fixed) */}
<SessionHeaderCard onBack={onBack}>
{/* Session info row */}
<div className="ml-8">
<h1 className="text-lg text-textStandardInverse">
{session.metadata.description || session.session_id}
@@ -143,37 +139,39 @@ const SessionHistoryView: React.FC<SessionHistoryViewProps> = ({
</div>
</div>
<div className="ml-auto flex items-center space-x-4">
<button
onClick={handleShare}
title="Share Session"
disabled={!canShare || isSharing}
className={`flex items-center text-textStandardInverse px-2 py-1 ${
canShare
? 'hover:font-bold hover:scale-110 transition-all duration-150'
: 'cursor-not-allowed opacity-50'
}`}
>
{isSharing ? (
<>
<LoaderCircle className="w-7 h-7 animate-spin mr-2" />
<span>Sharing...</span>
</>
) : (
<>
<Share2 className="w-7 h-7" />
</>
)}
</button>
{showActionButtons && (
<div className="ml-auto flex items-center space-x-4">
<button
onClick={handleShare}
title="Share Session"
disabled={!canShare || isSharing}
className={`flex items-center text-textStandardInverse px-2 py-1 ${
canShare
? 'hover:font-bold hover:scale-110 transition-all duration-150'
: 'cursor-not-allowed opacity-50'
}`}
>
{isSharing ? (
<>
<LoaderCircle className="w-7 h-7 animate-spin mr-2" />
<span>Sharing...</span>
</>
) : (
<>
<Share2 className="w-7 h-7" />
</>
)}
</button>
<button
onClick={onResume}
title="Resume Session"
className="flex items-center text-textStandardInverse px-2 py-1 hover:font-bold hover:scale-110 transition-all duration-150"
>
<Sparkles className="w-7 h-7" />
</button>
</div>
<button
onClick={onResume}
title="Resume Session"
className="flex items-center text-textStandardInverse px-2 py-1 hover:font-bold hover:scale-110 transition-all duration-150"
>
<Sparkles className="w-7 h-7" />
</button>
</div>
)}
</SessionHeaderCard>
<SessionMessages
@@ -183,20 +181,16 @@ const SessionHistoryView: React.FC<SessionHistoryViewProps> = ({
onRetry={onRetry}
/>
{/* Share Link Modal */}
<Modal open={isShareModalOpen} onOpenChange={setIsShareModalOpen}>
<ModalContent className="sm:max-w-md p-0 bg-bgApp dark:bg-bgApp dark:border-borderSubtle">
{/* Share Icon */}
<div className="flex justify-center mt-4">
<Share2 className="w-6 h-6 text-textStandard" />
</div>
{/* Centered Title */}
<div className="mt-2 px-6 text-center">
<h2 className="text-lg font-semibold text-textStandard">Share Session (beta)</h2>
</div>
{/* Description & Link */}
<div className="px-6 flex flex-col gap-4 mt-2">
<p className="text-sm text-center text-textSubtle">
Share this session link to give others a read only view of your goose chat.
@@ -219,7 +213,6 @@ const SessionHistoryView: React.FC<SessionHistoryViewProps> = ({
</div>
</div>
{/* Footer */}
<div>
<Button
type="button"
+4 -4
View File
@@ -347,8 +347,8 @@ const createChat = async (
preload: path.join(__dirname, 'preload.js'),
additionalArguments: [
JSON.stringify({
...appConfig,
GOOSE_PORT: port,
...appConfig, // Use the potentially updated appConfig
GOOSE_PORT: port, // Ensure this specific window gets the correct port
GOOSE_WORKING_DIR: working_dir,
REQUEST_DIR: dir,
GOOSE_BASE_URL_SHARE: sharingUrl,
@@ -399,8 +399,8 @@ const createChat = async (
// Store config in localStorage for future windows
const windowConfig = {
...appConfig,
GOOSE_PORT: port,
...appConfig, // Use the potentially updated appConfig here as well
GOOSE_PORT: port, // Ensure this specific window's config gets the correct port
GOOSE_WORKING_DIR: working_dir,
REQUEST_DIR: dir,
GOOSE_BASE_URL_SHARE: sharingUrl,
+1 -1
View File
@@ -44,7 +44,7 @@ type ElectronAPI = {
fetchMetadata: (url: string) => Promise<string>;
reloadApp: () => void;
checkForOllama: () => Promise<boolean>;
selectFileOrDirectory: () => Promise<string>;
selectFileOrDirectory: () => Promise<string | null>;
startPowerSaveBlocker: () => Promise<number>;
stopPowerSaveBlocker: () => Promise<void>;
getBinaryPath: (binaryName: string) => Promise<string>;
+108
View File
@@ -0,0 +1,108 @@
import {
listSchedules as apiListSchedules,
createSchedule as apiCreateSchedule,
deleteSchedule as apiDeleteSchedule,
sessionsHandler as apiGetScheduleSessions,
runNowHandler as apiRunScheduleNow,
} from './api';
export interface ScheduledJob {
id: string;
source: string;
cron: string;
last_run?: string | null;
}
export interface ScheduleSession {
id: string;
name: string;
createdAt: string; // ISO 8601 date string
workingDir: string;
scheduleId: string;
messageCount: number;
totalTokens: number;
inputTokens: number;
outputTokens: number;
accumulatedTotalTokens: number;
accumulatedInputTokens: number;
accumulatedOutputTokens: number;
}
export async function listSchedules(): Promise<ScheduledJob[]> {
try {
const response = await apiListSchedules<true>();
if (response && response.data && Array.isArray(response.data.jobs)) {
return response.data.jobs as ScheduledJob[];
}
console.error('Unexpected response format from apiListSchedules', response);
throw new Error('Failed to list schedules: Unexpected response format');
} catch (error) {
console.error('Error listing schedules:', error);
throw error;
}
}
export async function createSchedule(request: {
id: string;
recipe_source: string;
cron: string;
}): Promise<ScheduledJob> {
try {
const response = await apiCreateSchedule<true>({ data: request });
if (response && response.data) {
return response.data as ScheduledJob;
}
console.error('Unexpected response format from apiCreateSchedule', response);
throw new Error('Failed to create schedule: Unexpected response format');
} catch (error) {
console.error('Error creating schedule:', error);
throw error;
}
}
export async function deleteSchedule(id: string): Promise<void> {
try {
await apiDeleteSchedule<true>({ path: { schedule_id: id } });
} catch (error) {
console.error(`Error deleting schedule ${id}:`, error);
throw error;
}
}
export async function getScheduleSessions(
scheduleId: string,
limit?: number
): Promise<ScheduleSession[]> {
try {
const response = await apiGetScheduleSessions<true>({
path: { id: scheduleId },
query: { limit },
});
if (response && response.data) {
return response.data as ScheduleSession[];
}
console.error('Unexpected response format from apiGetScheduleSessions', response);
throw new Error('Failed to get schedule sessions: Unexpected response format');
} catch (error) {
console.error(`Error fetching sessions for schedule ${scheduleId}:`, error);
throw error;
}
}
export async function runScheduleNow(scheduleId: string): Promise<string> {
try {
const response = await apiRunScheduleNow<true>({
path: { id: scheduleId },
});
if (response && response.data && response.data.session_id) {
return response.data.session_id;
}
console.error('Unexpected response format from apiRunScheduleNow', response);
throw new Error('Failed to run schedule now: Unexpected response format');
} catch (error) {
console.error(`Error running schedule ${scheduleId} now:`, error);
throw error;
}
}