Add basic cron scheduler to goose-server (#2621)
This commit is contained in:
+319
-7
@@ -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"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
Generated
+10
@@ -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",
|
||||
|
||||
@@ -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
@@ -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}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user