mirror of
https://github.com/tiennm99/serena.git
synced 2026-10-14 10:19:02 +00:00
Merge pull request #717 from oraios/dashboard_extensions
Dashboard extensions, in particular task queue management
This commit is contained in:
9 files changed
+1271
-141
No files matched your search
@@ -60,6 +60,5 @@ initial_prompt: ""
|
||||
project_name: "serena"
|
||||
languages:
|
||||
- python
|
||||
- typescript
|
||||
included_optional_tools: []
|
||||
encoding: utf-8
|
||||
+29
-47
@@ -2,23 +2,17 @@
|
||||
The Serena Model Context Protocol (MCP) Server
|
||||
"""
|
||||
|
||||
import concurrent.futures
|
||||
import multiprocessing
|
||||
import os
|
||||
import platform
|
||||
import sys
|
||||
import threading
|
||||
import webbrowser
|
||||
from collections.abc import Callable
|
||||
from concurrent.futures import Future
|
||||
from logging import Logger
|
||||
from queue import Queue
|
||||
from threading import Thread
|
||||
from typing import TYPE_CHECKING, Any, Optional, TypeVar
|
||||
from typing import TYPE_CHECKING, Optional, TypeVar
|
||||
|
||||
from sensai.util import logging
|
||||
from sensai.util.logging import LogTime
|
||||
from sensai.util.string import ToStringMixin
|
||||
|
||||
from interprompt.jinja_template import JinjaTemplate
|
||||
from serena import serena_version
|
||||
@@ -29,6 +23,7 @@ from serena.dashboard import SerenaDashboardAPI
|
||||
from serena.ls_manager import LanguageServerManager
|
||||
from serena.project import Project
|
||||
from serena.prompt_factory import SerenaPromptFactory
|
||||
from serena.task_executor import TaskExecutor
|
||||
from serena.tools import ActivateProjectTool, GetCurrentConfigTool, Tool, ToolMarker, ToolRegistry
|
||||
from serena.util.inspection import iter_subclasses
|
||||
from serena.util.logging import MemoryLogHandler
|
||||
@@ -156,12 +151,8 @@ class SerenaAgent:
|
||||
log.info(f"Number of exposed tools: {len(self._exposed_tools)}")
|
||||
|
||||
# create executor for starting the language server and running tools in another thread
|
||||
# This executor is used to achieve linear task execution, so it is important to use a single-threaded executor.
|
||||
self._task_executor_queue: Queue = Queue()
|
||||
self._task_executor_thread = Thread(target=self._process_task_queue, name="SerenaAgentTaskExecutor", daemon=True)
|
||||
self._task_executor_thread.start()
|
||||
self._task_executor_lock = threading.Lock()
|
||||
self._task_executor_task_index = 1
|
||||
# This executor is used to achieve linear task execution
|
||||
self._task_executor = TaskExecutor("SerenaAgentTaskExecutor")
|
||||
|
||||
# Initialize the prompt factory
|
||||
self.prompt_factory = SerenaPromptFactory()
|
||||
@@ -198,31 +189,22 @@ class SerenaAgent:
|
||||
process.start()
|
||||
process.join(timeout=1)
|
||||
|
||||
class Task(ToStringMixin):
|
||||
def __init__(self, function: Callable[[], Any], name: str):
|
||||
self.name = name
|
||||
self.future: concurrent.futures.Future = concurrent.futures.Future()
|
||||
self._function = function
|
||||
def get_current_tasks(self) -> list[TaskExecutor.TaskInfo]:
|
||||
"""
|
||||
Gets the list of tasks currently running or queued for execution.
|
||||
The function returns a list of thread-safe TaskInfo objects (specifically created for the caller).
|
||||
|
||||
def _tostring_includes(self) -> list[str]:
|
||||
return ["name"]
|
||||
:return: the list of tasks in the execution order (running task first)
|
||||
"""
|
||||
return self._task_executor.get_current_tasks()
|
||||
|
||||
def execute(self) -> None:
|
||||
"""
|
||||
Execute the task, setting the result or exception on the future.
|
||||
"""
|
||||
try:
|
||||
with LogTime(self.name, logger=log):
|
||||
result = self._function()
|
||||
self.future.set_result(result)
|
||||
except Exception as e:
|
||||
log.error(f"Error during execution of {self.name}: {e}", exc_info=e)
|
||||
self.future.set_exception(e)
|
||||
def get_last_executed_task(self) -> TaskExecutor.TaskInfo | None:
|
||||
"""
|
||||
Gets the last executed task.
|
||||
|
||||
def _process_task_queue(self) -> None:
|
||||
while True:
|
||||
task: SerenaAgent.Task = self._task_executor_queue.get()
|
||||
task.execute()
|
||||
:return: the last executed task info or None if no task has been executed yet
|
||||
"""
|
||||
return self._task_executor.get_last_executed_task()
|
||||
|
||||
def get_language_server_manager(self) -> LanguageServerManager | None:
|
||||
if self._active_project is not None:
|
||||
@@ -396,33 +378,33 @@ class SerenaAgent:
|
||||
|
||||
log.info(f"Active tools ({len(self._active_tools)}): {', '.join(self.get_active_tool_names())}")
|
||||
|
||||
def issue_task(self, task: Callable[[], Any], name: str | None = None) -> Future:
|
||||
def issue_task(
|
||||
self, task: Callable[[], T], name: str | None = None, logged: bool = True, timeout: float | None = None
|
||||
) -> TaskExecutor.Task[T]:
|
||||
"""
|
||||
Issue a task to the executor for asynchronous execution.
|
||||
It is ensured that tasks are executed in the order they are issued, one after another.
|
||||
|
||||
:param task: the task to execute
|
||||
:param name: the name of the task for logging purposes; if None, use the task function's name
|
||||
:return: a Future object representing the execution of the task
|
||||
:param logged: whether to log management of the task; if False, only errors will be logged
|
||||
:param timeout: the maximum time to wait for task completion in seconds, or None to wait indefinitely
|
||||
:return: the task object, through which the task's future result can be accessed
|
||||
"""
|
||||
with self._task_executor_lock:
|
||||
task_name = f"Task-{self._task_executor_task_index}[{name or task.__name__}]"
|
||||
self._task_executor_task_index += 1
|
||||
log.info(f"Scheduling {task_name}")
|
||||
task = SerenaAgent.Task(function=task, name=task_name)
|
||||
self._task_executor_queue.put(task)
|
||||
return task.future
|
||||
return self._task_executor.issue_task(task, name=name, logged=logged, timeout=timeout)
|
||||
|
||||
def execute_task(self, task: Callable[[], T], name: str | None = None) -> T:
|
||||
def execute_task(self, task: Callable[[], T], name: str | None = None, logged: bool = True, timeout: float | None = None) -> T:
|
||||
"""
|
||||
Executes the given task synchronously via the agent's task executor.
|
||||
This is useful for tasks that need to be executed immediately and whose results are needed right away.
|
||||
|
||||
:param task: the task to execute
|
||||
:param name: the name of the task for logging purposes; if None, use the task function's name
|
||||
:param logged: whether to log management of the task; if False, only errors will be logged
|
||||
:param timeout: the maximum time to wait for task completion in seconds, or None to wait indefinitely
|
||||
:return: the result of the task execution
|
||||
"""
|
||||
future = self.issue_task(task, name=name)
|
||||
return future.result()
|
||||
return self._task_executor.execute_task(task, name=name, logged=logged, timeout=timeout)
|
||||
|
||||
def is_using_language_server(self) -> bool:
|
||||
"""
|
||||
|
||||
+96
-31
@@ -2,7 +2,7 @@ import os
|
||||
import socket
|
||||
import threading
|
||||
from collections.abc import Callable
|
||||
from typing import TYPE_CHECKING, Any
|
||||
from typing import TYPE_CHECKING, Any, Self
|
||||
|
||||
from flask import Flask, Response, request, send_from_directory
|
||||
from pydantic import BaseModel
|
||||
@@ -10,6 +10,7 @@ from sensai.util import logging
|
||||
|
||||
from serena.analytics import ToolUsageStats
|
||||
from serena.constants import SERENA_DASHBOARD_DIR
|
||||
from serena.task_executor import TaskExecutor
|
||||
from serena.util.logging import MemoryLogHandler
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -85,6 +86,28 @@ 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:
|
||||
return cls(
|
||||
task_id=task_info.task_id,
|
||||
is_running=task_info.is_running,
|
||||
name=task_info.name,
|
||||
finished_successfully=task_info.finished_successfully(),
|
||||
logged=task_info.logged,
|
||||
)
|
||||
|
||||
|
||||
class SerenaDashboardAPI:
|
||||
log = logging.getLogger(__qualname__)
|
||||
|
||||
@@ -152,7 +175,7 @@ class SerenaDashboardAPI:
|
||||
|
||||
@self._app.route("/get_config_overview", methods=["GET"])
|
||||
def get_config_overview() -> dict[str, Any]:
|
||||
result = 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"])
|
||||
@@ -225,6 +248,41 @@ class SerenaDashboardAPI:
|
||||
except Exception as e:
|
||||
return {"status": "error", "message": str(e)}
|
||||
|
||||
@self._app.route("/queued_task_executions", methods=["GET"])
|
||||
def get_queued_executions() -> dict[str, Any]:
|
||||
try:
|
||||
current_executions = self._agent.get_current_tasks()
|
||||
response = [QueuedExecution.from_task_info(task_info).model_dump() for task_info in current_executions]
|
||||
return {"queued_executions": response, "status": "success"}
|
||||
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)
|
||||
for task in self._agent.get_current_tasks():
|
||||
if task.task_id == request_cancel_task.task_id:
|
||||
task.cancel()
|
||||
return {"status": "success", "was_cancelled": True}
|
||||
return {
|
||||
"status": "success",
|
||||
"was_cancelled": False,
|
||||
"message": f"Task with id {request_data.get('task_id')} not found, maybe execution was already finished",
|
||||
}
|
||||
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:
|
||||
last_execution_info = self._agent.get_last_executed_task()
|
||||
response = QueuedExecution.from_task_info(last_execution_info).model_dump() if last_execution_info is not None else None
|
||||
return {"last_execution": response, "status": "success"}
|
||||
except Exception as e:
|
||||
return {"status": "error", "message": str(e)}
|
||||
|
||||
def _get_log_messages(self, request_log: RequestLog) -> ResponseLog:
|
||||
all_messages = self._memory_log_handler.get_log_messages()
|
||||
requested_messages = all_messages[request_log.start_idx :] if request_log.start_idx <= len(all_messages) else []
|
||||
@@ -369,63 +427,70 @@ class SerenaDashboardAPI:
|
||||
def _get_available_languages(self) -> ResponseAvailableLanguages:
|
||||
from solidlsp.ls_config import Language
|
||||
|
||||
# Get all non-experimental languages
|
||||
all_languages = [lang.value for lang in Language.iter_all(include_experimental=False)]
|
||||
def run() -> ResponseAvailableLanguages:
|
||||
all_languages = [lang.value for lang in Language.iter_all(include_experimental=False)]
|
||||
|
||||
# Filter out already added languages for the active project
|
||||
project = self._agent.get_active_project()
|
||||
if project:
|
||||
current_languages = [lang.value for lang in project.project_config.languages]
|
||||
available_languages = [lang for lang in all_languages if lang not in current_languages]
|
||||
else:
|
||||
available_languages = all_languages
|
||||
# Filter out already added languages for the active project
|
||||
project = self._agent.get_active_project()
|
||||
if project:
|
||||
current_languages = [lang.value for lang in project.project_config.languages]
|
||||
available_languages = [lang for lang in all_languages if lang not in current_languages]
|
||||
else:
|
||||
available_languages = all_languages
|
||||
|
||||
return ResponseAvailableLanguages(languages=sorted(available_languages))
|
||||
return ResponseAvailableLanguages(languages=sorted(available_languages))
|
||||
|
||||
return self._agent.execute_task(run, logged=False)
|
||||
|
||||
def _get_memory(self, request_get_memory: RequestGetMemory) -> ResponseGetMemory:
|
||||
project = self._agent.get_active_project()
|
||||
if project is None:
|
||||
raise ValueError("No active project")
|
||||
def run() -> ResponseGetMemory:
|
||||
project = self._agent.get_active_project()
|
||||
if project is None:
|
||||
raise ValueError("No active project")
|
||||
|
||||
content = project.memories_manager.load_memory(request_get_memory.memory_name)
|
||||
return ResponseGetMemory(content=content, memory_name=request_get_memory.memory_name)
|
||||
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, logged=False)
|
||||
|
||||
def _save_memory(self, request_save_memory: RequestSaveMemory) -> None:
|
||||
project = self._agent.get_active_project()
|
||||
if project is None:
|
||||
raise ValueError("No active project")
|
||||
def run() -> None:
|
||||
project = self._agent.get_active_project()
|
||||
if project is None:
|
||||
raise ValueError("No active project")
|
||||
|
||||
project.memories_manager.save_memory(request_save_memory.memory_name, request_save_memory.content)
|
||||
project.memories_manager.save_memory(request_save_memory.memory_name, request_save_memory.content)
|
||||
|
||||
self._agent.execute_task(run, logged=True, name="SaveMemory")
|
||||
|
||||
def _delete_memory(self, request_delete_memory: RequestDeleteMemory) -> None:
|
||||
project = self._agent.get_active_project()
|
||||
if project is None:
|
||||
raise ValueError("No active project")
|
||||
def run() -> None:
|
||||
project = self._agent.get_active_project()
|
||||
if project is None:
|
||||
raise ValueError("No active project")
|
||||
|
||||
project.memories_manager.delete_memory(request_delete_memory.memory_name)
|
||||
project.memories_manager.delete_memory(request_delete_memory.memory_name)
|
||||
|
||||
self._agent.execute_task(run, logged=True, name="DeleteMemory")
|
||||
|
||||
def _add_language(self, request_add_language: RequestAddLanguage) -> None:
|
||||
from solidlsp.ls_config import Language
|
||||
|
||||
# Convert string to Language enum
|
||||
try:
|
||||
language = Language(request_add_language.language)
|
||||
except ValueError:
|
||||
raise ValueError(f"Invalid language: {request_add_language.language}")
|
||||
|
||||
# Add the language to the active project
|
||||
# add_language is already thread-safe
|
||||
self._agent.add_language(language)
|
||||
|
||||
def _remove_language(self, request_remove_language: RequestRemoveLanguage) -> None:
|
||||
from solidlsp.ls_config import Language
|
||||
|
||||
# Convert string to Language enum
|
||||
try:
|
||||
language = Language(request_remove_language.language)
|
||||
except ValueError:
|
||||
raise ValueError(f"Invalid language: {request_remove_language.language}")
|
||||
|
||||
# Remove the language from the active project
|
||||
# remove_language is already thread-safe
|
||||
self._agent.remove_language(language)
|
||||
|
||||
@staticmethod
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -832,3 +1016,42 @@ body {
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
/* Copy Logs Button */
|
||||
.copy-logs-btn {
|
||||
position: absolute;
|
||||
top: 15px;
|
||||
right: 15px;
|
||||
z-index: 10;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 8px 12px;
|
||||
background-color: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 4px;
|
||||
color: var(--text-primary);
|
||||
cursor: pointer;
|
||||
opacity: 0.8;
|
||||
transition: opacity 0.2s ease, background-color 0.2s ease;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.copy-logs-btn:hover {
|
||||
opacity: 1;
|
||||
background-color: var(--border-color);
|
||||
}
|
||||
|
||||
.copy-logs-btn svg {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.copy-logs-text {
|
||||
display: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.copy-logs-btn:hover .copy-logs-text {
|
||||
display: inline;
|
||||
}
|
||||
@@ -60,18 +60,24 @@ class Dashboard {
|
||||
this.memoryContentDirty = false;
|
||||
this.memoryToDelete = null;
|
||||
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
|
||||
this.$logContainer = $('#log-container');
|
||||
this.$errorContainer = $('#error-container');
|
||||
this.$loadButton = $('#load-logs');
|
||||
this.$copyLogsBtn = $('#copy-logs-btn');
|
||||
this.$menuToggle = $('#menu-toggle');
|
||||
this.$menuDropdown = $('#menu-dropdown');
|
||||
this.$menuShutdown = $('#menu-shutdown');
|
||||
@@ -110,6 +116,19 @@ class Dashboard {
|
||||
this.$deleteMemoryOkBtn = $('#delete-memory-ok-btn');
|
||||
this.$deleteMemoryCancelBtn = $('#delete-memory-cancel-btn');
|
||||
this.$modalCloseDeleteMemory = $('.modal-close-delete-memory');
|
||||
this.$createMemoryModal = $('#create-memory-modal');
|
||||
this.$createMemoryProjectName = $('#create-memory-project-name');
|
||||
this.$createMemoryNameInput = $('#create-memory-name-input');
|
||||
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;
|
||||
@@ -118,7 +137,7 @@ class Dashboard {
|
||||
this.outputChart = null;
|
||||
|
||||
// Register event handlers
|
||||
this.$loadButton.click(this.loadLogs.bind(this));
|
||||
this.$copyLogsBtn.click(this.copyLogs.bind(this));
|
||||
this.$menuShutdown.click(function(e) {
|
||||
e.preventDefault();
|
||||
self.shutdown();
|
||||
@@ -140,6 +159,18 @@ class Dashboard {
|
||||
this.$deleteMemoryOkBtn.click(this.confirmDeleteMemoryOk.bind(this));
|
||||
this.$deleteMemoryCancelBtn.click(this.closeDeleteMemoryModal.bind(this));
|
||||
this.$modalCloseDeleteMemory.click(this.closeDeleteMemoryModal.bind(this));
|
||||
this.$createMemoryCreateBtn.click(this.createMemoryFromModal.bind(this));
|
||||
this.$createMemoryCancelBtn.click(this.closeCreateMemoryModal.bind(this));
|
||||
this.$modalCloseCreateMemory.click(this.closeCreateMemoryModal.bind(this));
|
||||
this.$createMemoryNameInput.keypress(function(e) {
|
||||
if (e.which === 13) { // Enter key
|
||||
e.preventDefault();
|
||||
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) {
|
||||
@@ -180,6 +211,12 @@ class Dashboard {
|
||||
}
|
||||
});
|
||||
|
||||
this.$createMemoryModal.click(function(e) {
|
||||
if ($(e.target).hasClass('modal')) {
|
||||
self.closeCreateMemoryModal();
|
||||
}
|
||||
});
|
||||
|
||||
// Collapsible sections
|
||||
$('.collapsible-header').click(function() {
|
||||
const $header = $(this);
|
||||
@@ -204,6 +241,8 @@ class Dashboard {
|
||||
self.closeEditMemoryModal();
|
||||
} else if (self.$deleteMemoryModal.is(':visible')) {
|
||||
self.closeDeleteMemoryModal();
|
||||
} else if (self.$createMemoryModal.is(':visible')) {
|
||||
self.closeCreateMemoryModal();
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -213,6 +252,7 @@ class Dashboard {
|
||||
// Start on overview page
|
||||
self.loadConfigOverview();
|
||||
self.startConfigPolling();
|
||||
self.startExecutionsPolling();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -244,6 +284,7 @@ class Dashboard {
|
||||
if (page === 'overview') {
|
||||
this.loadConfigOverview();
|
||||
this.startConfigPolling();
|
||||
this.startExecutionsPolling();
|
||||
} else if (page === 'logs') {
|
||||
this.loadLogs();
|
||||
} else if (page === 'stats') {
|
||||
@@ -260,17 +301,25 @@ class Dashboard {
|
||||
clearInterval(this.configPollInterval);
|
||||
this.configPollInterval = null;
|
||||
}
|
||||
if (this.executionsPollInterval) {
|
||||
clearInterval(this.executionsPollInterval);
|
||||
this.executionsPollInterval = null;
|
||||
}
|
||||
}
|
||||
|
||||
// ===== Config Overview Methods =====
|
||||
|
||||
loadConfigOverview() {
|
||||
if (this.waitingForPollingResult) {
|
||||
console.log('Still waiting for previous config poll result, skipping this poll');
|
||||
return;
|
||||
}
|
||||
this.waitingForPollingResult = true;
|
||||
let self = this;
|
||||
$.ajax({
|
||||
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;
|
||||
@@ -295,6 +344,9 @@ class Dashboard {
|
||||
self.$availableToolsDisplay.html('<div class="error-message">Error loading tools</div>');
|
||||
self.$availableModesDisplay.html('<div class="error-message">Error loading modes</div>');
|
||||
self.$availableContextsDisplay.html('<div class="error-message">Error loading contexts</div>');
|
||||
},
|
||||
complete: function() {
|
||||
self.waitingForPollingResult = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -304,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
|
||||
@@ -394,20 +457,25 @@ class Dashboard {
|
||||
html += '</div>';
|
||||
html += '</div>';
|
||||
|
||||
// Available memories - collapsible (only show if memories exist)
|
||||
if (config.available_memories && config.available_memories.length > 0) {
|
||||
// Available memories - collapsible (show if memories exist or if project exists)
|
||||
if (config.active_project && config.active_project.name) {
|
||||
html += '<div style="margin-top: 20px;">';
|
||||
html += '<h3 class="collapsible-header" id="memories-header" style="font-size: 16px; margin: 0;">';
|
||||
html += '<span>Available Memories (' + config.available_memories.length + ')</span>';
|
||||
const memoryCount = (config.available_memories && config.available_memories.length) || 0;
|
||||
html += '<span>Available Memories (' + memoryCount + ')</span>';
|
||||
html += '<span class="toggle-icon' + (wasMemoriesExpanded ? ' expanded' : '') + '">▼</span>';
|
||||
html += '</h3>';
|
||||
html += '<div class="collapsible-content memories-container" id="memories-content" style="' + (wasMemoriesExpanded ? '' : 'display:none;') + ' margin-top: 10px;">';
|
||||
config.available_memories.forEach(function(memory) {
|
||||
html += '<div class="memory-item removable" data-memory="' + memory + '">';
|
||||
html += memory;
|
||||
html += '<span class="memory-remove" data-memory="' + memory + '">×</span>';
|
||||
html += '</div>';
|
||||
});
|
||||
if (config.available_memories && config.available_memories.length > 0) {
|
||||
config.available_memories.forEach(function(memory) {
|
||||
html += '<div class="memory-item removable" data-memory="' + memory + '">';
|
||||
html += memory;
|
||||
html += '<span class="memory-remove" data-memory="' + memory + '">×</span>';
|
||||
html += '</div>';
|
||||
});
|
||||
}
|
||||
// Add Create Memory button
|
||||
html += '<button id="create-memory-btn" class="btn language-add-btn">+ Create Memory</button>';
|
||||
html += '</div>';
|
||||
html += '</div>';
|
||||
}
|
||||
@@ -447,6 +515,9 @@ class Dashboard {
|
||||
self.confirmDeleteMemory(memoryName);
|
||||
});
|
||||
|
||||
// Attach event handler for create memory button
|
||||
$('#create-memory-btn').click(this.openCreateMemoryModal.bind(this));
|
||||
|
||||
// Re-attach collapsible handler for the newly created tools header
|
||||
$('#tools-header').click(function() {
|
||||
const $header = $(this);
|
||||
@@ -564,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('<div class="error-message">Error loading executions</div>');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
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('<div class="error-message">Error loading last execution</div>');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
displayActiveExecutionsQueue(executions) {
|
||||
if (!executions || executions.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
let html = '<div class="execution-list">';
|
||||
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 += '<div class="' + itemClass + '" data-task-id="' + execution.task_id + '" data-execution=\'' + executionJson + '\'>';
|
||||
|
||||
if (isRunning) {
|
||||
html += '<div class="execution-spinner"></div>';
|
||||
}
|
||||
|
||||
html += '<div class="execution-name">' + self.escapeHtml(execution.name) + '</div>';
|
||||
|
||||
if (isRunning) {
|
||||
html += '<div class="execution-meta">#' + execution.task_id + '</div>';
|
||||
} else {
|
||||
html += '<div class="execution-meta">queued · #' + execution.task_id + '</div>';
|
||||
}
|
||||
|
||||
html += '<button class="execution-cancel-btn" data-task-id="' + execution.task_id + '" data-is-running="' + isRunning + '">✕</button>';
|
||||
html += '</div>';
|
||||
});
|
||||
|
||||
html += '</div>';
|
||||
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('<div class="no-stats-message">No executions yet.</div>');
|
||||
return;
|
||||
}
|
||||
|
||||
const isSuccess = execution.finished_successfully;
|
||||
let html = '<div class="last-execution-container' + (isSuccess ? '' : ' error') + '">';
|
||||
|
||||
html += '<div class="last-execution-icon-container">';
|
||||
html += isSuccess ? '✓' : '✕';
|
||||
html += '</div>';
|
||||
|
||||
html += '<div class="last-execution-body">';
|
||||
html += '<div class="last-execution-status">' + (isSuccess ? 'Succeeded' : 'Failed') + '</div>';
|
||||
html += '<div class="last-execution-name">' + this.escapeHtml(execution.name) + '</div>';
|
||||
html += '</div>';
|
||||
|
||||
html += '<div class="execution-meta">#' + execution.task_id + '</div>';
|
||||
html += '</div>';
|
||||
|
||||
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 = '<div class="execution-list">';
|
||||
|
||||
cancelledExecs.forEach(function(execution) {
|
||||
const isAbandoned = execution.is_running;
|
||||
|
||||
html += '<div class="execution-item ' + (isAbandoned ? 'abandoned' : 'cancelled') + '">';
|
||||
html += '<div class="execution-icon ' + (isAbandoned ? 'abandoned' : 'cancelled') + '">';
|
||||
html += isAbandoned ? '!' : '✕';
|
||||
html += '</div>';
|
||||
html += '<div class="execution-name">' + self.escapeHtml(execution.name) + '</div>';
|
||||
html += '<div class="execution-meta">' + (isAbandoned ? 'abandoned · ' : '') + '#' + execution.task_id + '</div>';
|
||||
html += '</div>';
|
||||
});
|
||||
|
||||
html += '</div>';
|
||||
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) {
|
||||
@@ -589,12 +933,34 @@ class Dashboard {
|
||||
document.title = activeProject ? `${activeProject} – Serena Dashboard` : 'Serena Dashboard';
|
||||
}
|
||||
|
||||
copyLogs() {
|
||||
const logText = this.$logContainer.text();
|
||||
|
||||
if (!logText) {
|
||||
alert('No logs to copy');
|
||||
return;
|
||||
}
|
||||
|
||||
// Use the Clipboard API to copy text
|
||||
navigator.clipboard.writeText(logText).then(() => {
|
||||
// Visual feedback - temporarily change icon to grey checkmark
|
||||
const originalHtml = this.$copyLogsBtn.html();
|
||||
const checkmarkSvg = '<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="#888" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"></polyline></svg><span class="copy-logs-text">copy logs</span>';
|
||||
this.$copyLogsBtn.html(checkmarkSvg);
|
||||
|
||||
setTimeout(() => {
|
||||
this.$copyLogsBtn.html(originalHtml);
|
||||
}, 1500);
|
||||
}).catch(err => {
|
||||
console.error('Failed to copy logs:', err);
|
||||
alert('Failed to copy logs to clipboard');
|
||||
});
|
||||
}
|
||||
|
||||
loadLogs() {
|
||||
console.log("Loading logs");
|
||||
let self = this;
|
||||
|
||||
// Disable button and show loading state
|
||||
self.$loadButton.prop('disabled', true).text('Loading...');
|
||||
self.$errorContainer.empty();
|
||||
|
||||
// Make API call
|
||||
@@ -634,10 +1000,6 @@ class Dashboard {
|
||||
console.error('Error loading logs:', error);
|
||||
self.$errorContainer.html('<div class="error-message">Error loading logs: ' +
|
||||
(xhr.responseJSON ? xhr.responseJSON.detail : error) + '</div>');
|
||||
},
|
||||
complete: function() {
|
||||
// Re-enable button
|
||||
self.$loadButton.prop('disabled', false).text('Reload Log');
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -1149,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) {
|
||||
@@ -1232,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();
|
||||
@@ -1398,6 +1760,78 @@ class Dashboard {
|
||||
});
|
||||
}
|
||||
|
||||
openCreateMemoryModal() {
|
||||
// Set project name in modal
|
||||
this.$createMemoryProjectName.text(this.activeProjectName || 'Unknown');
|
||||
|
||||
// Clear the input field
|
||||
this.$createMemoryNameInput.val('');
|
||||
|
||||
// Show modal
|
||||
this.$createMemoryModal.fadeIn(200);
|
||||
|
||||
// Focus on the input field
|
||||
setTimeout(() => {
|
||||
this.$createMemoryNameInput.focus();
|
||||
}, 250);
|
||||
}
|
||||
|
||||
closeCreateMemoryModal() {
|
||||
this.$createMemoryModal.fadeOut(200);
|
||||
this.$createMemoryNameInput.val('');
|
||||
this.$createMemoryCreateBtn.prop('disabled', false).text('Create');
|
||||
}
|
||||
|
||||
createMemoryFromModal() {
|
||||
const memoryName = this.$createMemoryNameInput.val().trim();
|
||||
|
||||
if (!memoryName) {
|
||||
alert('Please enter a memory name');
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate memory name (alphanumeric and underscores only)
|
||||
if (!/^[a-zA-Z0-9_]+$/.test(memoryName)) {
|
||||
alert('Memory name can only contain letters, numbers, and underscores');
|
||||
return;
|
||||
}
|
||||
|
||||
const self = this;
|
||||
|
||||
// Disable button during request
|
||||
self.$createMemoryCreateBtn.prop('disabled', true).text('Creating...');
|
||||
|
||||
$.ajax({
|
||||
url: '/save_memory',
|
||||
type: 'POST',
|
||||
contentType: 'application/json',
|
||||
data: JSON.stringify({
|
||||
memory_name: memoryName,
|
||||
content: ''
|
||||
}),
|
||||
success: function(response) {
|
||||
if (response.status === 'success') {
|
||||
// Close the create modal
|
||||
self.closeCreateMemoryModal();
|
||||
// Reload config to show the new memory
|
||||
self.loadConfigOverview();
|
||||
// Open the edit modal for the newly created memory
|
||||
setTimeout(() => {
|
||||
self.openEditMemoryModal(memoryName);
|
||||
}, 500);
|
||||
} else {
|
||||
alert('Error: ' + response.message);
|
||||
self.$createMemoryCreateBtn.prop('disabled', false).text('Create');
|
||||
}
|
||||
},
|
||||
error: function(xhr, status, error) {
|
||||
console.error('Error creating memory:', error);
|
||||
alert('Error creating memory: ' + (xhr.responseJSON ? xhr.responseJSON.message : error));
|
||||
self.$createMemoryCreateBtn.prop('disabled', false).text('Create');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ===== Shutdown Method =====
|
||||
|
||||
shutdown() {
|
||||
|
||||
@@ -16,29 +16,29 @@
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<header class="header">
|
||||
<div class="logo-container">
|
||||
<img id="serena-logo" src="serena-logs.png" alt="Serena">
|
||||
<header class="header">
|
||||
<div class="logo-container">
|
||||
<img id="serena-logo" src="serena-logs.png" alt="Serena">
|
||||
</div>
|
||||
<nav class="header-nav">
|
||||
<button id="theme-toggle" class="theme-toggle">
|
||||
<span id="theme-icon">🌙</span>
|
||||
<span id="theme-text">Dark</span>
|
||||
</button>
|
||||
<button id="menu-toggle" class="menu-button">
|
||||
<span>☰</span>
|
||||
<span>Menu</span>
|
||||
</button>
|
||||
<div id="menu-dropdown" class="menu-dropdown" style="display:none">
|
||||
<a href="#" data-page="overview" class="active">Overview</a>
|
||||
<a href="#" data-page="logs">Logs</a>
|
||||
<a href="#" data-page="stats">Advanced Stats</a>
|
||||
<hr>
|
||||
<a href="#" id="menu-shutdown">Shutdown Server</a>
|
||||
</div>
|
||||
<nav class="header-nav">
|
||||
<button id="theme-toggle" class="theme-toggle">
|
||||
<span id="theme-icon">🌙</span>
|
||||
<span id="theme-text">Dark</span>
|
||||
</button>
|
||||
<button id="menu-toggle" class="menu-button">
|
||||
<span>☰</span>
|
||||
<span>Menu</span>
|
||||
</button>
|
||||
<div id="menu-dropdown" class="menu-dropdown" style="display:none">
|
||||
<a href="#" data-page="overview" class="active">Overview</a>
|
||||
<a href="#" data-page="logs">Logs</a>
|
||||
<a href="#" data-page="stats">Advanced Stats</a>
|
||||
<hr>
|
||||
<a href="#" id="menu-shutdown">Shutdown Server</a>
|
||||
</div>
|
||||
</nav>
|
||||
</header>
|
||||
<div class="main">
|
||||
</nav>
|
||||
</header>
|
||||
<div class="main">
|
||||
<!-- Overview Page (Landing) -->
|
||||
<div id="page-overview" class="page-view">
|
||||
<div class="overview-container">
|
||||
@@ -58,6 +58,27 @@
|
||||
<div class="loading">Loading stats...</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="executions-section">
|
||||
<h2>Executions Queue</h2>
|
||||
<div id="active-executions-display">
|
||||
<div class="loading">Loading executions...</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="executions-section">
|
||||
<h2>Last Execution</h2>
|
||||
<div id="last-execution-display">
|
||||
<div class="loading">Loading...</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="executions-section" style="display:none">
|
||||
<h2>Cancelled Executions</h2>
|
||||
<div id="cancelled-executions-display">
|
||||
<div class="no-stats-message">No cancelled executions.</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div class="overview-right">
|
||||
@@ -106,11 +127,18 @@
|
||||
|
||||
<!-- Logs Page -->
|
||||
<div id="page-logs" class="page-view" style="display:none">
|
||||
<div class="controls">
|
||||
<button id="load-logs" class="btn">Reload Log</button>
|
||||
</div>
|
||||
<div id="error-container"></div>
|
||||
<div id="log-container" class="log-container"></div>
|
||||
<div style="position: relative;">
|
||||
<button id="copy-logs-btn" class="copy-logs-btn" title="Copy logs">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect>
|
||||
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path>
|
||||
</svg>
|
||||
<span class="copy-logs-text">copy logs</span>
|
||||
</button>
|
||||
<div id="log-container" class="log-container"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Advanced Stats Page -->
|
||||
@@ -159,12 +187,16 @@
|
||||
it may take a few seconds before the LS is responsive.
|
||||
</p>
|
||||
<div style="margin: 20px 0;">
|
||||
<label for="modal-language-select" style="display: block; margin-bottom: 8px; font-weight: 500;">Select Language:</label>
|
||||
<select id="modal-language-select" class="language-select" style="width: 100%; padding: 8px 10px; border-radius: 4px; border: 1px solid var(--border-color); background: var(--bg-primary); color: var(--text-primary); font-size: 14px;">
|
||||
<label for="modal-language-select" style="display: block; margin-bottom: 8px; font-weight: 500;">Select
|
||||
Language:</label>
|
||||
<select id="modal-language-select" class="language-select"
|
||||
style="width: 100%; padding: 8px 10px; border-radius: 4px; border: 1px solid var(--border-color); background: var(--bg-primary); color: var(--text-primary); font-size: 14px;">
|
||||
</select>
|
||||
</div>
|
||||
<div style="display: flex; gap: 10px; justify-content: flex-end; margin-top: 20px;">
|
||||
<button id="modal-cancel-btn" class="btn" style="background: var(--bg-secondary); color: var(--text-primary); padding: 8px 16px;">Cancel</button>
|
||||
<button id="modal-cancel-btn" class="btn"
|
||||
style="background: var(--bg-secondary); color: var(--text-primary); padding: 8px 16px;">Cancel
|
||||
</button>
|
||||
<button id="modal-add-btn" class="btn" style="padding: 8px 16px;">Add Language</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -178,7 +210,9 @@
|
||||
Remove language <strong id="remove-language-name"></strong> from configuration?
|
||||
</p>
|
||||
<div style="display: flex; gap: 10px; justify-content: flex-end; margin-top: 20px;">
|
||||
<button id="remove-modal-cancel-btn" class="btn" style="background: var(--bg-secondary); color: var(--text-primary); padding: 8px 16px;">Cancel</button>
|
||||
<button id="remove-modal-cancel-btn" class="btn"
|
||||
style="background: var(--bg-secondary); color: var(--text-primary); padding: 8px 16px;">Cancel
|
||||
</button>
|
||||
<button id="remove-modal-ok-btn" class="btn" style="padding: 8px 16px;">OK</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -189,9 +223,12 @@
|
||||
<div class="modal-content modal-content-large">
|
||||
<span class="modal-close modal-close-edit-memory">×</span>
|
||||
<h3 style="margin-bottom: 20px;">Memory: <span id="edit-memory-name"></span></h3>
|
||||
<textarea id="edit-memory-content" class="memory-editor" rows="20" style="width: 100%; padding: 10px; border: 1px solid var(--border-color); border-radius: 4px; background: var(--bg-primary); color: var(--text-primary); font-family: 'Courier New', monospace; font-size: 13px; resize: vertical;"></textarea>
|
||||
<textarea id="edit-memory-content" class="memory-editor" rows="20"
|
||||
style="width: 100%; padding: 10px; border: 1px solid var(--border-color); border-radius: 4px; background: var(--bg-primary); color: var(--text-primary); font-family: 'Courier New', monospace; font-size: 13px; resize: vertical;"></textarea>
|
||||
<div style="display: flex; gap: 10px; justify-content: flex-end; margin-top: 20px;">
|
||||
<button id="edit-memory-cancel-btn" class="btn" style="background: var(--bg-secondary); color: var(--text-primary); padding: 8px 16px;">Cancel</button>
|
||||
<button id="edit-memory-cancel-btn" class="btn"
|
||||
style="background: var(--bg-secondary); color: var(--text-primary); padding: 8px 16px;">Cancel
|
||||
</button>
|
||||
<button id="edit-memory-save-btn" class="btn" style="padding: 8px 16px;">Save</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -205,17 +242,64 @@
|
||||
Delete memory <strong id="delete-memory-name"></strong>?
|
||||
</p>
|
||||
<div style="display: flex; gap: 10px; justify-content: flex-end; margin-top: 20px;">
|
||||
<button id="delete-memory-cancel-btn" class="btn" style="background: var(--bg-secondary); color: var(--text-primary); padding: 8px 16px;">Cancel</button>
|
||||
<button id="delete-memory-cancel-btn" class="btn"
|
||||
style="background: var(--bg-secondary); color: var(--text-primary); padding: 8px 16px;">Cancel
|
||||
</button>
|
||||
<button id="delete-memory-ok-btn" class="btn" style="padding: 8px 16px;">OK</button>
|
||||
</div>
|
||||
</div>
|
||||
</div> <!-- End of Main -->
|
||||
</div>
|
||||
|
||||
<script>
|
||||
$(document).ready(function () {
|
||||
const dashboard = new Dashboard();
|
||||
});
|
||||
</script>
|
||||
<!-- Create Memory Modal -->
|
||||
<div id="create-memory-modal" class="modal" style="display:none">
|
||||
<div class="modal-content">
|
||||
<span class="modal-close modal-close-create-memory">×</span>
|
||||
<p id="create-memory-project-info" style="margin: 15px 0; color: var(--text-primary); line-height: 1.5;">
|
||||
Create a new memory for project <strong id="create-memory-project-name"></strong>.
|
||||
</p>
|
||||
<p style="margin: 15px 0; color: var(--text-muted); font-size: 13px; line-height: 1.5;">
|
||||
Memory names should be descriptive and use underscores instead of spaces (e.g., "api_architecture",
|
||||
"testing_guidelines").
|
||||
</p>
|
||||
<div style="margin: 20px 0;">
|
||||
<label for="create-memory-name-input" style="display: block; margin-bottom: 8px; font-weight: 500;">Memory
|
||||
Name:</label>
|
||||
<input type="text" id="create-memory-name-input" class="memory-name-input"
|
||||
placeholder="e.g., project_overview"
|
||||
style="width: 100%; padding: 8px 10px; border-radius: 4px; border: 1px solid var(--border-color); background: var(--bg-primary); color: var(--text-primary); font-size: 14px;">
|
||||
</div>
|
||||
<div style="display: flex; gap: 10px; justify-content: flex-end; margin-top: 20px;">
|
||||
<button id="create-memory-cancel-btn" class="btn"
|
||||
style="background: var(--bg-secondary); color: var(--text-primary); padding: 8px 16px;">Cancel
|
||||
</button>
|
||||
<button id="create-memory-create-btn" class="btn" style="padding: 8px 16px;">Create</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Cancel Execution Confirmation Modal -->
|
||||
<div id="cancel-execution-modal" class="modal" style="display:none">
|
||||
<div class="modal-content">
|
||||
<span class="modal-close modal-close-cancel-execution">×</span>
|
||||
<p style="margin: 20px 0; color: var(--text-primary); line-height: 1.5; font-size: 15px;">
|
||||
Are you sure? The execution will continue running until timeout, it will simply no longer be in the queue.
|
||||
Abandoning a running execution is only advised as a measure for unblocking Serena.
|
||||
</p>
|
||||
<div style="display: flex; gap: 10px; justify-content: flex-end; margin-top: 20px;">
|
||||
<button id="cancel-execution-cancel-btn" class="btn"
|
||||
style="background: var(--bg-secondary); color: var(--text-primary); padding: 8px 16px;">Cancel
|
||||
</button>
|
||||
<button id="cancel-execution-ok-btn" class="btn" style="padding: 8px 16px;">OK</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div> <!-- End of Main -->
|
||||
|
||||
<script>
|
||||
$(document).ready(function () {
|
||||
const dashboard = new Dashboard();
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -0,0 +1,218 @@
|
||||
import concurrent.futures
|
||||
import threading
|
||||
import time
|
||||
from collections.abc import Callable
|
||||
from concurrent.futures import Future
|
||||
from dataclasses import dataclass
|
||||
from threading import Thread
|
||||
from typing import Generic, TypeVar
|
||||
|
||||
from sensai.util import logging
|
||||
from sensai.util.logging import LogTime
|
||||
from sensai.util.string import ToStringMixin
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
T = TypeVar("T")
|
||||
|
||||
|
||||
class TaskExecutor:
|
||||
def __init__(self, name: str):
|
||||
self._task_executor_lock = threading.Lock()
|
||||
self._task_executor_queue: list[TaskExecutor.Task] = []
|
||||
self._task_executor_thread = Thread(target=self._process_task_queue, name=name, daemon=True)
|
||||
self._task_executor_thread.start()
|
||||
self._task_executor_task_index = 1
|
||||
self._task_executor_current_task: TaskExecutor.Task | None = None
|
||||
self._task_executor_last_executed_task_info: TaskExecutor.TaskInfo | None = None
|
||||
|
||||
class Task(ToStringMixin, Generic[T]):
|
||||
def __init__(self, function: Callable[[], T], name: str, logged: bool = True, timeout: float | None = None):
|
||||
"""
|
||||
:param function: the function representing the task to execute
|
||||
:param name: the name of the task
|
||||
:param logged: whether to log management of the task; if False, only errors will be logged
|
||||
:param timeout: the maximum time to wait for task completion in seconds, or None to wait indefinitely
|
||||
"""
|
||||
self.name = name
|
||||
self.future: concurrent.futures.Future = concurrent.futures.Future()
|
||||
self.logged = logged
|
||||
self.timeout = timeout
|
||||
self._function = function
|
||||
|
||||
def _tostring_includes(self) -> list[str]:
|
||||
return ["name"]
|
||||
|
||||
def start(self) -> None:
|
||||
"""
|
||||
Executes the task in a separate thread, setting the result or exception on the future.
|
||||
"""
|
||||
|
||||
def run_task() -> None:
|
||||
try:
|
||||
if self.future.done():
|
||||
if self.logged:
|
||||
log.info(f"Task {self.name} was already completed/cancelled; skipping execution")
|
||||
return
|
||||
with LogTime(self.name, logger=log, enabled=self.logged):
|
||||
result = self._function()
|
||||
if not self.future.done():
|
||||
self.future.set_result(result)
|
||||
except Exception as e:
|
||||
if not self.future.done():
|
||||
log.error(f"Error during execution of {self.name}: {e}", exc_info=e)
|
||||
self.future.set_exception(e)
|
||||
|
||||
thread = Thread(target=run_task, name=self.name)
|
||||
thread.start()
|
||||
|
||||
def is_done(self) -> bool:
|
||||
"""
|
||||
:return: whether the task has completed (either successfully, with failure, or via cancellation)
|
||||
"""
|
||||
return self.future.done()
|
||||
|
||||
def result(self, timeout: float | None = None) -> T:
|
||||
"""
|
||||
Blocks until the task is done or the timeout is reached, and returns the result.
|
||||
If an exception occurred during task execution, it is raised here.
|
||||
If the timeout is reached, a TimeoutError is raised (but the task is not cancelled).
|
||||
If the task is cancelled, a CancelledError is raised.
|
||||
|
||||
:param timeout: the maximum time to wait in seconds; if None, use the task's own timeout
|
||||
(which may be None to wait indefinitely)
|
||||
:return: True if the task is done, False if the timeout was reached
|
||||
"""
|
||||
return self.future.result(timeout=timeout)
|
||||
|
||||
def cancel(self) -> None:
|
||||
"""
|
||||
Cancels the task. If it has not yet started, it will not be executed.
|
||||
If it has already started, its future will be marked as cancelled and will raise a CancelledError
|
||||
when its result is requested.
|
||||
"""
|
||||
self.future.cancel()
|
||||
|
||||
def wait_until_done(self, timeout: float | None = None) -> None:
|
||||
"""
|
||||
Waits until the task is done or the timeout is reached.
|
||||
The task is done if it either completed successfully, failed with an exception, or was cancelled.
|
||||
|
||||
:param timeout: the maximum time to wait in seconds; if None, use the task's own timeout
|
||||
(which may be None to wait indefinitely)
|
||||
"""
|
||||
try:
|
||||
self.future.result(timeout=timeout)
|
||||
except:
|
||||
pass
|
||||
|
||||
def _process_task_queue(self) -> None:
|
||||
while True:
|
||||
# obtain task from the queue
|
||||
task: TaskExecutor.Task | None = None
|
||||
with self._task_executor_lock:
|
||||
if len(self._task_executor_queue) > 0:
|
||||
task = self._task_executor_queue.pop(0)
|
||||
if task is None:
|
||||
time.sleep(0.1)
|
||||
continue
|
||||
|
||||
# start task execution asynchronously
|
||||
with self._task_executor_lock:
|
||||
self._task_executor_current_task = task
|
||||
if task.logged:
|
||||
log.info("Starting execution of %s", task.name)
|
||||
task.start()
|
||||
|
||||
# wait for task completion
|
||||
task.wait_until_done(timeout=task.timeout)
|
||||
with self._task_executor_lock:
|
||||
self._task_executor_current_task = None
|
||||
if task.logged:
|
||||
self._task_executor_last_executed_task_info = self.TaskInfo.from_task(task, is_running=False)
|
||||
|
||||
@dataclass
|
||||
class TaskInfo:
|
||||
name: str
|
||||
is_running: bool
|
||||
future: Future
|
||||
"""
|
||||
future for accessing the task's result
|
||||
"""
|
||||
task_id: int
|
||||
"""
|
||||
unique identifier of the task
|
||||
"""
|
||||
logged: bool
|
||||
|
||||
def finished_successfully(self) -> bool:
|
||||
return self.future.done() and not self.future.cancelled() and self.future.exception() is None
|
||||
|
||||
@staticmethod
|
||||
def from_task(task: "TaskExecutor.Task", is_running: bool) -> "TaskExecutor.TaskInfo":
|
||||
return TaskExecutor.TaskInfo(name=task.name, is_running=is_running, future=task.future, task_id=id(task), logged=task.logged)
|
||||
|
||||
def cancel(self) -> None:
|
||||
self.future.cancel()
|
||||
|
||||
def get_current_tasks(self) -> list[TaskInfo]:
|
||||
"""
|
||||
Gets the list of tasks currently running or queued for execution.
|
||||
The function returns a list of thread-safe TaskInfo objects (specifically created for the caller).
|
||||
|
||||
:return: the list of tasks in the execution order (running task first)
|
||||
"""
|
||||
tasks = []
|
||||
with self._task_executor_lock:
|
||||
if self._task_executor_current_task is not None:
|
||||
tasks.append(self.TaskInfo.from_task(self._task_executor_current_task, True))
|
||||
for task in self._task_executor_queue:
|
||||
if not task.is_done():
|
||||
tasks.append(self.TaskInfo.from_task(task, False))
|
||||
return tasks
|
||||
|
||||
def issue_task(self, task: Callable[[], T], name: str | None = None, logged: bool = True, timeout: float | None = None) -> Task[T]:
|
||||
"""
|
||||
Issue a task to the executor for asynchronous execution.
|
||||
It is ensured that tasks are executed in the order they are issued, one after another.
|
||||
|
||||
:param task: the task to execute
|
||||
:param name: the name of the task for logging purposes; if None, use the task function's name
|
||||
:param logged: whether to log management of the task; if False, only errors will be logged
|
||||
:param timeout: the maximum time to wait for task completion in seconds, or None to wait indefinitely
|
||||
:return: the task object, through which the task's future result can be accessed
|
||||
"""
|
||||
with self._task_executor_lock:
|
||||
if logged:
|
||||
task_prefix_name = f"Task-{self._task_executor_task_index}"
|
||||
self._task_executor_task_index += 1
|
||||
else:
|
||||
task_prefix_name = "BackgroundTask"
|
||||
task_name = f"{task_prefix_name}:{name or task.__name__}"
|
||||
if logged:
|
||||
log.info(f"Scheduling {task_name}")
|
||||
task_obj = self.Task(function=task, name=task_name, logged=logged, timeout=timeout)
|
||||
self._task_executor_queue.append(task_obj)
|
||||
return task_obj
|
||||
|
||||
def execute_task(self, task: Callable[[], T], name: str | None = None, logged: bool = True, timeout: float | None = None) -> T:
|
||||
"""
|
||||
Executes the given task synchronously via the agent's task executor.
|
||||
This is useful for tasks that need to be executed immediately and whose results are needed right away.
|
||||
|
||||
:param task: the task to execute
|
||||
:param name: the name of the task for logging purposes; if None, use the task function's name
|
||||
:param logged: whether to log management of the task; if False, only errors will be logged
|
||||
:param timeout: the maximum time to wait for task completion in seconds, or None to wait indefinitely
|
||||
:return: the result of the task execution
|
||||
"""
|
||||
task_obj = self.issue_task(task, name=name, logged=logged, timeout=timeout)
|
||||
return task_obj.result()
|
||||
|
||||
def get_last_executed_task(self) -> TaskInfo | None:
|
||||
"""
|
||||
Gets information about the last executed task.
|
||||
|
||||
:return: TaskInfo of the last executed task, or None if no task has been executed yet.
|
||||
"""
|
||||
with self._task_executor_lock:
|
||||
return self._task_executor_last_executed_task_info
|
||||
@@ -286,8 +286,14 @@ class Tool(Component):
|
||||
|
||||
return result
|
||||
|
||||
future = self.agent.issue_task(task, name=self.__class__.__name__)
|
||||
return future.result(timeout=self.agent.serena_config.tool_timeout)
|
||||
# execute the tool in the agent's task executor, with timeout
|
||||
try:
|
||||
task_exec = self.agent.issue_task(task, name=self.__class__.__name__)
|
||||
return task_exec.result(timeout=self.agent.serena_config.tool_timeout)
|
||||
except Exception as e: # typically TimeoutError (other exceptions caught in task)
|
||||
msg = f"Error: {e.__class__.__name__} - {e}"
|
||||
log.error(msg)
|
||||
return msg
|
||||
|
||||
|
||||
class EditedFileContext:
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
import time
|
||||
|
||||
import pytest
|
||||
|
||||
from serena.task_executor import TaskExecutor
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def executor():
|
||||
"""
|
||||
Fixture for a basic SerenaAgent without a project
|
||||
"""
|
||||
return TaskExecutor("TestExecutor")
|
||||
|
||||
|
||||
class Task:
|
||||
def __init__(self, delay: float, exception: bool = False):
|
||||
self.delay = delay
|
||||
self.exception = exception
|
||||
self.did_run = False
|
||||
|
||||
def run(self):
|
||||
self.did_run = True
|
||||
time.sleep(self.delay)
|
||||
if self.exception:
|
||||
raise ValueError("Task failed")
|
||||
return True
|
||||
|
||||
|
||||
def test_task_executor_sequence(executor):
|
||||
"""
|
||||
Tests that a sequence of tasks is executed correctly
|
||||
"""
|
||||
future1 = executor.issue_task(Task(1).run, name="task1")
|
||||
future2 = executor.issue_task(Task(1).run, name="task2")
|
||||
assert future1.result() is True
|
||||
assert future2.result() is True
|
||||
|
||||
|
||||
def test_task_executor_exception(executor):
|
||||
"""
|
||||
Tests that tasks that raise exceptions are handled correctly, i.e. that
|
||||
* the exception is propagated,
|
||||
* subsequent tasks are still executed.
|
||||
"""
|
||||
future1 = executor.issue_task(Task(1, exception=True).run, name="task1")
|
||||
future2 = executor.issue_task(Task(1).run, name="task2")
|
||||
have_exception = False
|
||||
try:
|
||||
assert future1.result()
|
||||
except Exception as e:
|
||||
assert isinstance(e, ValueError)
|
||||
have_exception = True
|
||||
assert have_exception
|
||||
assert future2.result() is True
|
||||
|
||||
|
||||
def test_task_executor_cancel_current(executor):
|
||||
"""
|
||||
Tests that tasks that are cancelled are handled correctly, i.e. that
|
||||
* subsequent tasks are executed as soon as cancellation ensues.
|
||||
* the cancelled task raises CancelledError when result() is called.
|
||||
"""
|
||||
start_time = time.time()
|
||||
future1 = executor.issue_task(Task(10).run, name="task1")
|
||||
future2 = executor.issue_task(Task(1).run, name="task2")
|
||||
time.sleep(1)
|
||||
future1.cancel()
|
||||
assert future2.result() is True
|
||||
end_time = time.time()
|
||||
assert (end_time - start_time) < 9, "Cancelled task did not stop in time"
|
||||
have_cancelled_error = False
|
||||
try:
|
||||
future1.result()
|
||||
except Exception as e:
|
||||
assert e.__class__.__name__ == "CancelledError"
|
||||
have_cancelled_error = True
|
||||
assert have_cancelled_error
|
||||
|
||||
|
||||
def test_task_executor_cancel_future(executor):
|
||||
"""
|
||||
Tests that when a future task is cancelled, it is never run at all
|
||||
"""
|
||||
task1 = Task(10)
|
||||
task2 = Task(1)
|
||||
future1 = executor.issue_task(task1.run, name="task1")
|
||||
future2 = executor.issue_task(task2.run, name="task2")
|
||||
time.sleep(1)
|
||||
future2.cancel()
|
||||
future1.cancel()
|
||||
try:
|
||||
future2.result()
|
||||
except:
|
||||
pass
|
||||
assert task1.did_run
|
||||
assert not task2.did_run
|
||||
|
||||
|
||||
def test_task_executor_cancellation_via_task_info(executor):
|
||||
start_time = time.time()
|
||||
executor.issue_task(Task(10).run, "task1")
|
||||
executor.issue_task(Task(1).run, "task2")
|
||||
task_infos = executor.get_current_tasks()
|
||||
task_infos2 = executor.get_current_tasks()
|
||||
|
||||
# test expected tasks
|
||||
assert len(task_infos) == 2
|
||||
assert "task1" in task_infos[0].name
|
||||
assert "task2" in task_infos[1].name
|
||||
|
||||
# test task identifiers being stable
|
||||
assert task_infos2[0].task_id == task_infos[0].task_id
|
||||
|
||||
# test cancellation
|
||||
task_infos[0].cancel()
|
||||
assert task_infos[1].future.result()
|
||||
end_time = time.time()
|
||||
assert (end_time - start_time) < 9, "Cancelled task did not stop in time"
|
||||
Reference in new issue
Block a user