diff --git a/.serena/project.yml b/.serena/project.yml index edba1eeb..e74ef79f 100644 --- a/.serena/project.yml +++ b/.serena/project.yml @@ -60,6 +60,5 @@ initial_prompt: "" project_name: "serena" languages: - python -- typescript included_optional_tools: [] encoding: utf-8 diff --git a/src/serena/agent.py b/src/serena/agent.py index 61a56de7..7396fc26 100644 --- a/src/serena/agent.py +++ b/src/serena/agent.py @@ -198,6 +198,15 @@ class SerenaAgent: """ return self._task_executor.get_current_tasks() + def cancel_task(self, task_id: int) -> bool: + """ + Cancels a task with the given ID and removes it from the task queue. + + :param task_id: the ID of the task to cancel + :return: True if the task was found and cancelled, False otherwise + """ + return self._task_executor.cancel_task(task_id) + def get_last_executed_task(self) -> TaskExecutor.TaskInfo | None: """ Gets the last executed task. diff --git a/src/serena/dashboard.py b/src/serena/dashboard.py index 19da3849..6110de76 100644 --- a/src/serena/dashboard.py +++ b/src/serena/dashboard.py @@ -86,11 +86,16 @@ class RequestDeleteMemory(BaseModel): memory_name: str +class RequestCancelTaskExecution(BaseModel): + task_id: int + + class QueuedExecution(BaseModel): task_id: int is_running: bool name: str finished_successfully: bool + logged: bool @classmethod def from_task_info(cls, task_info: TaskExecutor.TaskInfo) -> Self: @@ -99,6 +104,7 @@ class QueuedExecution(BaseModel): is_running=task_info.is_running, name=task_info.name, finished_successfully=task_info.finished_successfully(), + logged=task_info.logged, ) @@ -169,7 +175,7 @@ class SerenaDashboardAPI: @self._app.route("/get_config_overview", methods=["GET"]) def get_config_overview() -> dict[str, Any]: - result = self._agent.execute_task(self._get_config_overview) + result = self._agent.execute_task(self._get_config_overview, logged=False) return result.model_dump() @self._app.route("/shutdown", methods=["PUT"]) @@ -251,6 +257,16 @@ class SerenaDashboardAPI: except Exception as e: return {"status": "error", "message": str(e)} + @self._app.route("/cancel_task_execution", methods=["POST"]) + def cancel_task_execution() -> dict[str, Any]: + request_data = request.get_json() + try: + request_cancel_task = RequestCancelTaskExecution.model_validate(request_data) + task_was_cancelled = self._agent.cancel_task(request_cancel_task.task_id) + return {"status": "success", "was_cancelled": task_was_cancelled} + except Exception as e: + return {"status": "error", "message": str(e), "was_cancelled": False} + @self._app.route("/last_execution", methods=["GET"]) def get_last_execution() -> dict[str, Any]: try: @@ -417,7 +433,7 @@ class SerenaDashboardAPI: return ResponseAvailableLanguages(languages=sorted(available_languages)) - return self._agent.execute_task(run) + return self._agent.execute_task(run, logged=False) def _get_memory(self, request_get_memory: RequestGetMemory) -> ResponseGetMemory: def run() -> ResponseGetMemory: @@ -428,7 +444,7 @@ class SerenaDashboardAPI: content = project.memories_manager.load_memory(request_get_memory.memory_name) return ResponseGetMemory(content=content, memory_name=request_get_memory.memory_name) - return self._agent.execute_task(run) + return self._agent.execute_task(run, logged=False) def _save_memory(self, request_save_memory: RequestSaveMemory) -> None: def run() -> None: @@ -438,7 +454,7 @@ class SerenaDashboardAPI: project.memories_manager.save_memory(request_save_memory.memory_name, request_save_memory.content) - self._agent.execute_task(run) + self._agent.execute_task(run, logged=True, name="SaveMemory") def _delete_memory(self, request_delete_memory: RequestDeleteMemory) -> None: def run() -> None: @@ -448,7 +464,7 @@ class SerenaDashboardAPI: project.memories_manager.delete_memory(request_delete_memory.memory_name) - self._agent.execute_task(run) + self._agent.execute_task(run, logged=True, name="DeleteMemory") def _add_language(self, request_add_language: RequestAddLanguage) -> None: from solidlsp.ls_config import Language diff --git a/src/serena/resources/dashboard/dashboard.css b/src/serena/resources/dashboard/dashboard.css index 8fd4a71f..92f1184d 100644 --- a/src/serena/resources/dashboard/dashboard.css +++ b/src/serena/resources/dashboard/dashboard.css @@ -338,6 +338,190 @@ body { color: var(--text-secondary); } +/* Executions Styles */ +.executions-section { + background-color: var(--bg-secondary); + border: 1px solid var(--border-color); + border-radius: 5px; + padding: 20px; + margin-bottom: 20px; + transition: background-color 0.3s ease, border-color 0.3s ease; +} + +.executions-section h2 { + margin-top: 0; + color: var(--text-secondary); +} + +.execution-list { + display: flex; + flex-direction: column; + gap: 8px; +} + +.execution-item { + display: flex; + align-items: center; + gap: 10px; + background-color: var(--bg-primary); + border: 1px solid var(--border-color); + border-radius: 20px; + padding: 8px 12px; + min-height: 40px; + transition: background-color 0.2s ease, border-color 0.2s ease; +} + +.execution-item.running { + border-color: var(--btn-primary); + background: linear-gradient(to right, rgba(234, 164, 93, 0.1), var(--bg-primary)); +} + +.execution-item.cancelled { + border-color: var(--text-muted); + background-color: var(--bg-primary); + opacity: 0.7; +} + +.execution-item.abandoned { + border-color: var(--log-error); + background: linear-gradient(to right, rgba(255, 0, 0, 0.1), var(--bg-primary)); +} + +.execution-spinner { + width: 16px; + height: 16px; + border: 2px solid var(--border-color); + border-top-color: var(--btn-primary); + border-radius: 50%; + animation: spin 0.7s linear infinite; + flex-shrink: 0; +} + +@keyframes spin { + to { transform: rotate(360deg); } +} + +.execution-name { + flex: 1; + font-size: 13px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + color: var(--text-primary); +} + +.execution-meta { + font-size: 11px; + color: var(--text-muted); + flex-shrink: 0; +} + +.execution-cancel-btn { + background: none; + border: none; + color: var(--text-muted); + font-size: 16px; + cursor: pointer; + width: 24px; + height: 24px; + display: flex; + align-items: center; + justify-content: center; + border-radius: 50%; + transition: background-color 0.15s ease, color 0.15s ease; + flex-shrink: 0; +} + +.execution-cancel-btn:hover { + background-color: rgba(255, 0, 0, 0.1); + color: var(--log-error); +} + +.execution-icon { + width: 16px; + height: 16px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + font-size: 10px; + flex-shrink: 0; +} + +.execution-icon.success { + background-color: rgba(34, 197, 94, 0.2); + border: 1px solid rgba(34, 197, 94, 0.6); + color: #22c55e; +} + +.execution-icon.cancelled { + background-color: var(--border-color); + border: 1px solid var(--text-muted); + color: var(--text-muted); +} + +.execution-icon.abandoned { + background-color: rgba(255, 0, 0, 0.2); + border: 1px solid var(--log-error); + color: var(--log-error); +} + +.execution-icon.error { + background-color: rgba(255, 0, 0, 0.2); + border: 1px solid var(--log-error); + color: var(--log-error); +} + +.last-execution-container { + display: flex; + align-items: center; + gap: 12px; + background: linear-gradient(to right, rgba(34, 197, 94, 0.08), transparent); + border: 1px solid rgba(34, 197, 94, 0.2); + border-radius: 8px; + padding: 12px; +} + +.last-execution-container.error { + background: linear-gradient(to right, rgba(255, 0, 0, 0.08), transparent); + border-color: rgba(255, 0, 0, 0.2); +} + +.last-execution-icon-container { + width: 28px; + height: 28px; + background-color: rgba(34, 197, 94, 0.2); + border: 1px solid rgba(34, 197, 94, 0.6); + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + font-size: 14px; + color: #22c55e; + flex-shrink: 0; +} + +.last-execution-container.error .last-execution-icon-container { + background-color: rgba(255, 0, 0, 0.2); + border-color: var(--log-error); + color: var(--log-error); +} + +.last-execution-body { + flex: 1; +} + +.last-execution-status { + font-size: 11px; + color: var(--text-muted); + margin-bottom: 2px; +} + +.last-execution-name { + font-size: 13px; + color: var(--text-primary); +} + .stat-bar-container { display: flex; align-items: center; @@ -730,7 +914,7 @@ body { } .language-add-btn:hover { - background: var(--bg-tertiary); + background: var(--border-color); border-color: var(--btn-primary); color: var(--btn-primary); } diff --git a/src/serena/resources/dashboard/dashboard.js b/src/serena/resources/dashboard/dashboard.js index 84165cd5..efc525e0 100644 --- a/src/serena/resources/dashboard/dashboard.js +++ b/src/serena/resources/dashboard/dashboard.js @@ -62,11 +62,16 @@ class Dashboard { this.isAddingLanguage = false; this.waitingForPollingResult = false; + // Execution tracking + this.cancelledExecutions = []; + this.executionToCancel = null; + // Tool names and stats this.toolNames = []; this.currentMaxIdx = -1; this.pollInterval = null; this.configPollInterval = null; + this.executionsPollInterval = null; this.failureCount = 0; // jQuery elements @@ -117,6 +122,13 @@ class Dashboard { this.$createMemoryCreateBtn = $('#create-memory-create-btn'); this.$createMemoryCancelBtn = $('#create-memory-cancel-btn'); this.$modalCloseCreateMemory = $('.modal-close-create-memory'); + this.$activeExecutionQueueDisplay = $('#active-executions-display'); + this.$lastExecutionDisplay = $('#last-execution-display'); + this.$cancelledExecutionsDisplay = $('#cancelled-executions-display'); + this.$cancelExecutionModal = $('#cancel-execution-modal'); + this.$cancelExecutionOkBtn = $('#cancel-execution-ok-btn'); + this.$cancelExecutionCancelBtn = $('#cancel-execution-cancel-btn'); + this.$modalCloseCancelExecution = $('.modal-close-cancel-execution'); // Chart references this.countChart = null; @@ -156,6 +168,9 @@ class Dashboard { self.createMemoryFromModal(); } }); + this.$cancelExecutionOkBtn.click(this.confirmCancelExecutionOk.bind(this)); + this.$cancelExecutionCancelBtn.click(this.closeCancelExecutionModal.bind(this)); + this.$modalCloseCancelExecution.click(this.closeCancelExecutionModal.bind(this)); // Page navigation $('[data-page]').click(function(e) { @@ -237,6 +252,7 @@ class Dashboard { // Start on overview page self.loadConfigOverview(); self.startConfigPolling(); + self.startExecutionsPolling(); }); } @@ -268,6 +284,7 @@ class Dashboard { if (page === 'overview') { this.loadConfigOverview(); this.startConfigPolling(); + this.startExecutionsPolling(); } else if (page === 'logs') { this.loadLogs(); } else if (page === 'stats') { @@ -284,6 +301,10 @@ class Dashboard { clearInterval(this.configPollInterval); this.configPollInterval = null; } + if (this.executionsPollInterval) { + clearInterval(this.executionsPollInterval); + this.executionsPollInterval = null; + } } // ===== Config Overview Methods ===== @@ -299,7 +320,6 @@ class Dashboard { url: '/get_config_overview', type: 'GET', success: function(response) { - console.log('Config overview response:', response); self.failureCount = 0; self.configData = response; self.jetbrainsMode = response.jetbrains_mode; @@ -336,6 +356,17 @@ class Dashboard { this.configPollInterval = setInterval(this.loadConfigOverview.bind(this), 2000); } + startExecutionsPolling() { + // Poll every 1 second for executions (independent of config polling) + // This ensures stuck executions can still be cancelled even if config polling is blocked + this.loadExecutions(); + this.loadLastExecution(); + this.executionsPollInterval = setInterval(() => { + this.loadExecutions(); + this.loadLastExecution(); + }, 1000); + } + displayConfig(config) { try { // Check if tools and memories sections are currently expanded @@ -604,6 +635,279 @@ class Dashboard { this.$availableContextsDisplay.html(html); } + // ===== Executions Methods ===== + + loadExecutions() { + let self = this; + $.ajax({ + url: '/queued_task_executions', + type: 'GET', + success: function(response) { + if (response.status === 'success') { + self.displayActiveExecutionsQueue(response.queued_executions || []); + } else { + console.error('Error loading executions:', response.message); + } + }, + error: function(xhr, status, error) { + console.error('Error loading executions:', error); + self.$activeExecutionQueueDisplay.html('
Error loading executions
'); + } + }); + } + + loadLastExecution() { + let self = this; + $.ajax({ + url: '/last_execution', + type: 'GET', + success: function(response) { + if (response.status === 'success') { + if (response.last_execution !== null && response.last_execution.logged) { + self.displayLastExecution(response.last_execution); + } + } else { + console.error('Error loading last execution:', response.message); + } + }, + error: function(xhr, status, error) { + console.error('Error loading last execution:', error); + self.$lastExecutionDisplay.html('
Error loading last execution
'); + } + }); + } + + displayActiveExecutionsQueue(executions) { + if (!executions || executions.length === 0) { + return; + } + + let html = '
'; + let self = this; + + executions.forEach(function(execution) { + const isRunning = execution.is_running; + const logged = execution.logged; + + if (!logged) { + return; // Skip unlogged executions + } + + let itemClass = 'execution-item'; + if (isRunning) { + itemClass += ' running'; + } + + // Escape JSON for HTML attribute - replace single quotes and use HTML entities + const executionJson = JSON.stringify(execution).replace(/'/g, '''); + + html += '
'; + + if (isRunning) { + html += '
'; + } + + html += '
' + self.escapeHtml(execution.name) + '
'; + + if (isRunning) { + html += '
#' + execution.task_id + '
'; + } else { + html += '
queued · #' + execution.task_id + '
'; + } + + html += ''; + html += '
'; + }); + + html += '
'; + this.$activeExecutionQueueDisplay.html(html); + + // Attach event handlers for cancel buttons + $('.execution-cancel-btn').click(function(e) { + e.preventDefault(); + console.log('Cancel button clicked'); + const $item = $(this).closest('.execution-item'); + console.log('Found item:', $item.length); + const executionDataStr = $item.attr('data-execution'); + console.log('Execution data string:', executionDataStr); + if (executionDataStr) { + // Unescape HTML entities + const unescapedStr = executionDataStr.replace(/'/g, "'"); + const executionData = JSON.parse(unescapedStr); + console.log('Parsed execution data:', executionData); + self.confirmCancelExecution(executionData); + } else { + console.error('No execution data found on element'); + } + }); + + // Update cancelled executions display + this.displayCancelledExecutions(executions); + } + + displayLastExecution(execution) { + if (!execution) { + this.$lastExecutionDisplay.html('
No executions yet.
'); + return; + } + + const isSuccess = execution.finished_successfully; + let html = '
'; + + html += '
'; + html += isSuccess ? '✓' : '✕'; + html += '
'; + + html += '
'; + html += '
' + (isSuccess ? 'Succeeded' : 'Failed') + '
'; + html += '
' + this.escapeHtml(execution.name) + '
'; + html += '
'; + + html += '
#' + execution.task_id + '
'; + html += '
'; + + this.$lastExecutionDisplay.html(html); + } + + displayCancelledExecutions() { + let self = this; + const cancelledExecs = self.cancelledExecutions + + if (cancelledExecs.length === 0) { + // Hide the cancelled executions section + $('.executions-section').eq(2).hide(); + return; + } + + // Show the cancelled executions section + $('.executions-section').eq(2).show(); + + let html = '
'; + + cancelledExecs.forEach(function(execution) { + const isAbandoned = execution.is_running; + + html += '
'; + html += '
'; + html += isAbandoned ? '!' : '✕'; + html += '
'; + html += '
' + self.escapeHtml(execution.name) + '
'; + html += '
' + (isAbandoned ? 'abandoned · ' : '') + '#' + execution.task_id + '
'; + html += '
'; + }); + + html += '
'; + this.$cancelledExecutionsDisplay.html(html); + } + + confirmCancelExecution(executionData) { + console.log('confirmCancelExecution called with:', executionData); + this.executionToCancel = executionData; + + if (executionData.is_running) { + // Show modal for running executions + console.log('Showing modal for running execution'); + this.$cancelExecutionModal.fadeIn(200); + } else { + // Directly cancel queued executions + console.log('Directly cancelling queued execution'); + this.cancelExecution(executionData); + } + } + + confirmCancelExecutionOk() { + if (this.executionToCancel) { + this.cancelExecution(this.executionToCancel); + } + this.closeCancelExecutionModal(); + } + + cancelExecution(executionData) { + const self = this; + + console.log('cancelExecution called with full execution data:', executionData); + console.log('Attempting to cancel task:', executionData.task_id); + + // Call backend API to cancel the task + $.ajax({ + url: '/cancel_task_execution', + type: 'POST', + contentType: 'application/json', + data: JSON.stringify({ + task_id: executionData.task_id + }), + success: function(response) { + console.log('Cancel task response:', response); + + if (response.status === 'error') { + console.error('Backend returned error status:', response.message); + alert('Error cancelling task: ' + response.message); + return; + } + + if (response.status === 'success') { + if (response.was_cancelled) { + console.log('Task ' + executionData.task_id + ' was successfully cancelled'); + // Add to cancelled list (only managed in JS, not persisted) + const alreadyCancelled = self.cancelledExecutions.some(function(exec) { + return exec.task_id === executionData.task_id; + }); + if (!alreadyCancelled) { + console.log('Adding execution to cancelled list:', executionData); + self.cancelledExecutions.push(executionData); + console.log('Cancelled executions array now contains:', self.cancelledExecutions); + } else { + console.log('Execution already in cancelled list'); + } + } else { + console.log('Task ' + executionData.task_id + ' could not be cancelled (may have already completed). ' + response.message ); + } + // Refresh display regardless + self.loadExecutions(); + } else { + console.error('Unexpected response status:', response.status); + alert('Unexpected response from server'); + } + }, + error: function(xhr, status, error) { + console.error('AJAX error cancelling task:'); + console.error(' Status:', status); + console.error(' Error:', error); + console.error(' XHR:', xhr); + console.error(' Response:', xhr.responseText); + + let errorMessage = error; + if (xhr.responseJSON && xhr.responseJSON.message) { + errorMessage = xhr.responseJSON.message; + } else if (xhr.responseText) { + errorMessage = xhr.responseText; + } + + alert('Error cancelling task: ' + errorMessage); + } + }); + } + + closeCancelExecutionModal() { + this.$cancelExecutionModal.fadeOut(200); + this.executionToCancel = null; + } + + escapeHtml(text) { + if (typeof text !== 'string') return text; + + const patterns = { + '<': '<', + '>': '>', + '&': '&', + '"': '"', + "'": ''', + '`': '`' + }; + + return text.replace(/[<>&"'`]/g, match => patterns[match]); + } + // ===== Logs Methods ===== displayLogMessage(message) { @@ -1207,7 +1511,7 @@ class Dashboard { // Reload config to show updated language list self.loadConfigOverview(); } else { - alert('Error: ' + response.message); + alert('Error removing language ' + language + ": " + response.message); } }, error: function(xhr, status, error) { @@ -1290,7 +1594,7 @@ class Dashboard { if (response.status === 'success') { console.log("Language added successfully"); } else { - alert('Error: ' + response.message); + alert('Error adding language ' + selectedLanguage + ": " + response.message); // Restore button visibility on error $('#add-language-btn').show(); $('#add-language-spinner').hide(); diff --git a/src/serena/resources/dashboard/index.html b/src/serena/resources/dashboard/index.html index 04c6c832..82e1239c 100644 --- a/src/serena/resources/dashboard/index.html +++ b/src/serena/resources/dashboard/index.html @@ -58,6 +58,27 @@
Loading stats...
+ +
+

Executions Queue

+
+
Loading executions...
+
+
+ +
+

Last Execution

+
+
Loading...
+
+
+ +
@@ -255,6 +276,23 @@
+ + +