From 2f82a9eb3371f074c7fb930271015e1f23dd49f1 Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Sun, 20 Jul 2025 12:58:26 +0200 Subject: [PATCH 1/6] Log project config creation time --- src/serena/config/serena_config.py | 50 ++++++++++++++++-------------- 1 file changed, 26 insertions(+), 24 deletions(-) diff --git a/src/serena/config/serena_config.py b/src/serena/config/serena_config.py index b671a8d..0d14564 100644 --- a/src/serena/config/serena_config.py +++ b/src/serena/config/serena_config.py @@ -14,6 +14,7 @@ from typing import TYPE_CHECKING, Any, Optional, Self, TypeVar import yaml from ruamel.yaml.comments import CommentedMap from sensai.util import logging +from sensai.util.logging import LogTime from sensai.util.string import ToStringMixin from serena.constants import ( @@ -163,30 +164,31 @@ class ProjectConfig(ToolInclusionDefinition, ToStringMixin): project_root = Path(project_root).resolve() if not project_root.exists(): raise FileNotFoundError(f"Project root not found: {project_root}") - project_name = project_name or project_root.name - if project_language is None: - language_composition = determine_programming_language_composition(str(project_root)) - if len(language_composition) == 0: - raise ValueError( - f"No source files found in {project_root}\n\n" - f"To use Serena with this project, you need to either:\n" - f"1. Add source files in one of the supported languages (Python, JavaScript/TypeScript, Java, C#, Rust, Go, Ruby, C++, PHP)\n" - f"2. Create a project configuration file manually at:\n" - f" {os.path.join(project_root, cls.rel_path_to_project_yml())}\n\n" - f"Example project.yml:\n" - f" project_name: {project_name}\n" - f" language: python # or typescript, java, csharp, rust, go, ruby, cpp, php\n" - ) - # find the language with the highest percentage - dominant_language = max(language_composition.keys(), key=lambda lang: language_composition[lang]) - else: - dominant_language = project_language.value - config_with_comments = load_yaml(PROJECT_TEMPLATE_FILE, preserve_comments=True) - config_with_comments["project_name"] = project_name - config_with_comments["language"] = dominant_language - if save_to_disk: - save_yaml(str(project_root / cls.rel_path_to_project_yml()), config_with_comments, preserve_comments=True) - return cls._from_dict(config_with_comments) + with LogTime("Project configuration auto-generation", logger=log): + project_name = project_name or project_root.name + if project_language is None: + language_composition = determine_programming_language_composition(str(project_root)) + if len(language_composition) == 0: + raise ValueError( + f"No source files found in {project_root}\n\n" + f"To use Serena with this project, you need to either:\n" + f"1. Add source files in one of the supported languages (Python, JavaScript/TypeScript, Java, C#, Rust, Go, Ruby, C++, PHP)\n" + f"2. Create a project configuration file manually at:\n" + f" {os.path.join(project_root, cls.rel_path_to_project_yml())}\n\n" + f"Example project.yml:\n" + f" project_name: {project_name}\n" + f" language: python # or typescript, java, csharp, rust, go, ruby, cpp, php\n" + ) + # find the language with the highest percentage + dominant_language = max(language_composition.keys(), key=lambda lang: language_composition[lang]) + else: + dominant_language = project_language.value + config_with_comments = load_yaml(PROJECT_TEMPLATE_FILE, preserve_comments=True) + config_with_comments["project_name"] = project_name + config_with_comments["language"] = dominant_language + if save_to_disk: + save_yaml(str(project_root / cls.rel_path_to_project_yml()), config_with_comments, preserve_comments=True) + return cls._from_dict(config_with_comments) @classmethod def rel_path_to_project_yml(cls) -> str: From 318f7124a3e1f9bd1b98a68c76661f4acece6d33 Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Sun, 20 Jul 2025 17:58:19 +0200 Subject: [PATCH 2/6] Capture startup logs (and display them in GUI viewer and/or dashboard) * Create a memory log handler that captures them * Make both dashboard & GUI build on the same type of handler --- pyproject.toml | 1 + src/serena/agent.py | 43 ++++++++++++----------- src/serena/cli.py | 10 +++++- src/serena/dashboard.py | 41 ++-------------------- src/serena/gui_log_viewer.py | 38 +++++++++++--------- src/serena/mcp.py | 8 +++-- src/serena/util/logging.py | 67 ++++++++++++++++++++++++++++++++++++ 7 files changed, 130 insertions(+), 78 deletions(-) create mode 100644 src/serena/util/logging.py diff --git a/pyproject.toml b/pyproject.toml index d1dcf9b..cfa0498 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -229,6 +229,7 @@ ignore = [ "RUF012", # forbids mutable attributes as ClassVar "SIM117", # forbids nested with statements "C400", # wants to unnecessarily force use of list comprehension + "UP037", # can incorrectly (!) convert quoted type to unquoted type, causing an error ] unfixable = ["F841", "F601", "F602", "B018"] extend-fixable = ["F401", "B905", "W291"] diff --git a/src/serena/agent.py b/src/serena/agent.py index 2935218..0dc6c14 100644 --- a/src/serena/agent.py +++ b/src/serena/agent.py @@ -13,7 +13,7 @@ from collections.abc import Callable from concurrent.futures import Future, ThreadPoolExecutor from logging import Logger from pathlib import Path -from typing import TYPE_CHECKING, Any, TypeVar, Union +from typing import TYPE_CHECKING, Any, Optional, TypeVar from sensai.util import logging from sensai.util.logging import LogTime @@ -22,17 +22,15 @@ from serena import serena_version from serena.analytics import RegisteredTokenCountEstimator, ToolUsageStats from serena.config.context_mode import RegisteredContext, SerenaAgentContext, SerenaAgentMode from serena.config.serena_config import SerenaConfig, ToolInclusionDefinition, ToolSet, get_serena_managed_in_project_dir -from serena.constants import ( - SERENA_LOG_FORMAT, -) -from serena.dashboard import MemoryLogHandler, SerenaDashboardAPI +from serena.dashboard import SerenaDashboardAPI from serena.project import Project from serena.prompt_factory import SerenaPromptFactory from serena.tools import ActivateProjectTool, Tool, ToolRegistry +from serena.util.logging import MemoryLogHandler from solidlsp import SolidLanguageServer if TYPE_CHECKING: - from serena.gui_log_viewer import GuiLogViewerHandler + from serena.gui_log_viewer import GuiLogViewer log = logging.getLogger(__name__) TTool = TypeVar("TTool", bound="Tool") @@ -101,6 +99,7 @@ class SerenaAgent: serena_config: SerenaConfig | None = None, context: SerenaAgentContext | None = None, modes: list[SerenaAgentMode] | None = None, + memory_log_handler: MemoryLogHandler | None = None, ): """ :param project: the project to load immediately or None to not load any project; may be a path to the project or a name of @@ -111,6 +110,8 @@ class SerenaAgent: The context may adjust prompts, tool availability, and tool descriptions. :param modes: list of modes in which the agent is operating (they will be combined), None for default modes. The modes may adjust prompts, tool availability, and tool descriptions. + :param memory_log_handler: a MemoryLogHandler instance from which to read log messages; if None, a new one will be created + if necessary. """ # obtain serena configuration using the decoupled factory function self.serena_config = serena_config or SerenaConfig.from_config_file() @@ -121,20 +122,25 @@ class SerenaAgent: log.info(f"Changing the root logger level to {serena_log_level}") Logger.root.setLevel(serena_log_level) + def get_memory_log_handler() -> MemoryLogHandler: + nonlocal memory_log_handler + if memory_log_handler is None: + memory_log_handler = MemoryLogHandler(level=serena_log_level) + Logger.root.addHandler(memory_log_handler) + return memory_log_handler + # open GUI log window if enabled - self._gui_log_handler: Union["GuiLogViewerHandler", None] = None # noqa + self._gui_log_viewer: Optional["GuiLogViewer"] = None if self.serena_config.gui_log_window_enabled: if platform.system() == "Darwin": log.warning("GUI log window is not supported on macOS") else: # even importing on macOS may fail if tkinter dependencies are unavailable (depends on Python interpreter installation # which uv used as a base, unfortunately) - from serena.gui_log_viewer import GuiLogViewer, GuiLogViewerHandler + from serena.gui_log_viewer import GuiLogViewer - self._gui_log_handler = GuiLogViewerHandler( - GuiLogViewer("dashboard", title="Serena Logs"), level=serena_log_level, format_string=SERENA_LOG_FORMAT - ) - Logger.root.addHandler(self._gui_log_handler) + self._gui_log_viewer = GuiLogViewer("dashboard", title="Serena Logs", memory_log_handler=get_memory_log_handler()) + self._gui_log_viewer.start() # set the agent context if context is None: @@ -146,8 +152,8 @@ class SerenaAgent: tool_names = [tool.get_name_from_cls() for tool in self._all_tools.values()] # If GUI log window is enabled, set the tool names for highlighting - if self._gui_log_handler is not None: - self._gui_log_handler.log_viewer.set_tool_names(tool_names) + if self._gui_log_viewer is not None: + self._gui_log_viewer.set_tool_names(tool_names) self._tool_usage_stats: ToolUsageStats | None = None if self.serena_config.record_tool_usage_stats: @@ -157,10 +163,8 @@ class SerenaAgent: # start the dashboard (web frontend), registering its log handler if self.serena_config.web_dashboard: - dashboard_log_handler = MemoryLogHandler(level=serena_log_level) - Logger.root.addHandler(dashboard_log_handler) self._dashboard_thread, port = SerenaDashboardAPI( - dashboard_log_handler, tool_names, tool_usage_stats=self._tool_usage_stats + get_memory_log_handler(), tool_names, tool_usage_stats=self._tool_usage_stats ).run_in_thread() if self.serena_config.web_dashboard_open_on_launch: # open the dashboard URL in the default web browser (using a separate process to control @@ -561,7 +565,6 @@ class SerenaAgent: assert self.language_server is not None self.language_server.save_cache() self.language_server.stop() - if self._gui_log_handler: + if self._gui_log_viewer: log.info("Stopping the GUI log window ...") - self._gui_log_handler.stop_viewer() - Logger.root.removeHandler(self._gui_log_handler) + self._gui_log_viewer.stop() diff --git a/src/serena/cli.py b/src/serena/cli.py index f267011..844aa6e 100644 --- a/src/serena/cli.py +++ b/src/serena/cli.py @@ -2,6 +2,7 @@ import os import shutil import subprocess import sys +from logging import Logger from pathlib import Path from typing import Any, Literal @@ -23,6 +24,7 @@ from serena.constants import ( ) from serena.mcp import SerenaMCPFactorySingleProcess from serena.project import Project +from serena.util.logging import MemoryLogHandler from solidlsp.ls_config import Language log = logging.getLogger(__name__) @@ -135,8 +137,14 @@ class TopLevelCommands(AutoRegisteringGroup): trace_lsp_communication: bool | None, tool_timeout: float | None, ) -> None: + # initialize logging, using INFO level initially (will later be adjusted by SerenaAgent according to the config) + Logger.root.setLevel(logging.INFO) + memory_log_handler = MemoryLogHandler() + Logger.root.addHandler(memory_log_handler) + + log.info("Starting Serena MCP server") project_file = project_file_arg or project - factory = SerenaMCPFactorySingleProcess(context=context, project=project_file) + factory = SerenaMCPFactorySingleProcess(context=context, project=project_file, memory_log_handler=memory_log_handler) server = factory.create_mcp_server( host=host, port=port, diff --git a/src/serena/dashboard.py b/src/serena/dashboard.py index e2be05c..50508ea 100644 --- a/src/serena/dashboard.py +++ b/src/serena/dashboard.py @@ -1,5 +1,4 @@ import os -import queue import socket import threading from collections.abc import Callable @@ -10,7 +9,8 @@ from pydantic import BaseModel from sensai.util import logging from serena.analytics import ToolUsageStats -from serena.constants import SERENA_DASHBOARD_DIR, SERENA_LOG_FORMAT +from serena.constants import SERENA_DASHBOARD_DIR +from serena.util.logging import MemoryLogHandler log = logging.getLogger(__name__) @@ -18,43 +18,6 @@ log = logging.getLogger(__name__) logging.getLogger("werkzeug").setLevel(logging.WARNING) -class MemoryLogHandler(logging.Handler): - def __init__(self, level: int = logging.NOTSET) -> None: - super().__init__(level=level) - self.setFormatter(logging.Formatter(SERENA_LOG_FORMAT)) - self._log_buffer = LogBuffer() - self._log_queue: queue.Queue[str] = queue.Queue() - self._stop_event = threading.Event() - - # start background thread to process logs - self.worker_thread = threading.Thread(target=self._process_queue, daemon=True) - self.worker_thread.start() - - def emit(self, record: logging.LogRecord) -> None: - msg = self.format(record) - self._log_queue.put_nowait(msg) - - def _process_queue(self) -> None: - while not self._stop_event.is_set(): - try: - msg = self._log_queue.get(timeout=1) - self._log_buffer.append(msg) - self._log_queue.task_done() - except queue.Empty: - continue - - def get_log_messages(self) -> list[str]: - return self._log_buffer.logs - - -class LogBuffer: - def __init__(self) -> None: - self.logs: list[str] = [] - - def append(self, msg: str) -> None: - self.logs.append(msg) - - class RequestLog(BaseModel): start_idx: int = 0 diff --git a/src/serena/gui_log_viewer.py b/src/serena/gui_log_viewer.py index 47eede2..7be6b95 100644 --- a/src/serena/gui_log_viewer.py +++ b/src/serena/gui_log_viewer.py @@ -4,7 +4,6 @@ import os import queue import sys import threading -import time import tkinter as tk import traceback from enum import Enum, auto @@ -12,6 +11,7 @@ from pathlib import Path from typing import Literal from serena import constants +from serena.util.logging import MemoryLogHandler log = logging.getLogger(__name__) @@ -31,11 +31,20 @@ class GuiLogViewer: It can also highlight tool names in boldface when they appear in log messages. """ - def __init__(self, mode: Literal["dashboard", "error"], title="Log Viewer", width=800, height=600): + def __init__( + self, + mode: Literal["dashboard", "error"], + title="Log Viewer", + memory_log_handler: MemoryLogHandler | None = None, + width=800, + height=600, + ): """ :param mode: the mode; if "dashboard", run a dashboard with logs and some control options; if "error", run a simple error log viewer (for fatal exceptions) :param title: the window title + :param memory_log_handler: an optional log handler from which to obtain log messages; If not provided, + must pass the instance to a `GuiLogViewerHandler` to add log messages. :param width: the initial window width :param height: the initial window height """ @@ -57,6 +66,11 @@ class GuiLogViewer: LogLevel.DEFAULT: "#000000", # Black } + if memory_log_handler is not None: + for msg in memory_log_handler.get_log_messages(): + self.message_queue.put(msg) + memory_log_handler.add_emit_callback(lambda msg: self.message_queue.put(msg)) + def print_status(self, s): print(s + "\n", file=sys.stderr) @@ -381,23 +395,15 @@ class GuiLogViewerHandler(logging.Handler): self.log_viewer.stop() -def show_fatal_exception(e: Exception, duration_secs: int = 60): +def show_fatal_exception(e: Exception): """ Makes sure the given exception is shown in the GUI log viewer, either an existing instance or a new one. :param e: the exception to display - :param duration_secs: the duration for which to display the error before - terminating the program for the case where the log viewer is already present - in a daemon thread which will terminate when the program does """ - if GuiLogViewerHandler.is_instance_registered(): - # show in existing daemon thread, waiting for the given duration - log.error(f"Fatal error: {e}", exc_info=e) - time.sleep(duration_secs) - else: - # show in new window in main thread (user must close it) - log_viewer = GuiLogViewer("error") - exc_info = "".join(traceback.format_exception(type(e), e, e.__traceback__)) - log_viewer.add_log(f"ERROR Fatal exception: {e}\n{exc_info}") - log_viewer.run_gui() + # show in new window in main thread (user must close it) + log_viewer = GuiLogViewer("error") + exc_info = "".join(traceback.format_exception(type(e), e, e.__traceback__)) + log_viewer.add_log(f"ERROR Fatal exception: {e}\n{exc_info}") + log_viewer.run_gui() diff --git a/src/serena/mcp.py b/src/serena/mcp.py index 5e38ba5..c7d6f8b 100644 --- a/src/serena/mcp.py +++ b/src/serena/mcp.py @@ -25,6 +25,7 @@ from serena.config.context_mode import SerenaAgentContext, SerenaAgentMode from serena.constants import DEFAULT_CONTEXT, DEFAULT_MODES from serena.tools import Tool from serena.util.exception import show_fatal_exception_safe +from serena.util.logging import MemoryLogHandler log = logging.getLogger(__name__) LOG_FORMAT = "%(levelname)-5s %(asctime)-15s %(name)s:%(funcName)s:%(lineno)d - %(message)s" @@ -190,7 +191,7 @@ class SerenaMCPFactorySingleProcess(SerenaMCPFactory): MCP server factory where the SerenaAgent and its language server run in the same process as the MCP server """ - def __init__(self, context: str = DEFAULT_CONTEXT, project: str | None = None): + def __init__(self, context: str = DEFAULT_CONTEXT, project: str | None = None, memory_log_handler: MemoryLogHandler | None = None): """ :param context: The context name or path to context file :param project: Either an absolute path to the project directory or a name of an already registered project. @@ -199,9 +200,12 @@ class SerenaMCPFactorySingleProcess(SerenaMCPFactory): """ super().__init__(context=context, project=project) self.agent: SerenaAgent | None = None + self.memory_log_handler = memory_log_handler def _instantiate_agent(self, serena_config: SerenaConfig, modes: list[SerenaAgentMode]) -> None: - self.agent = SerenaAgent(project=self.project, serena_config=serena_config, context=self.context, modes=modes) + self.agent = SerenaAgent( + project=self.project, serena_config=serena_config, context=self.context, modes=modes, memory_log_handler=self.memory_log_handler + ) def _iter_tools(self) -> Iterator[Tool]: assert self.agent is not None diff --git a/src/serena/util/logging.py b/src/serena/util/logging.py new file mode 100644 index 0000000..75af996 --- /dev/null +++ b/src/serena/util/logging.py @@ -0,0 +1,67 @@ +import queue +import threading +from collections.abc import Callable + +from sensai.util import logging + +from serena.constants import SERENA_LOG_FORMAT + + +class MemoryLogHandler(logging.Handler): + def __init__(self, level: int = logging.NOTSET) -> None: + super().__init__(level=level) + self.setFormatter(logging.Formatter(SERENA_LOG_FORMAT)) + self._log_buffer = LogBuffer() + self._log_queue: queue.Queue[str] = queue.Queue() + self._stop_event = threading.Event() + self._emit_callbacks: list[Callable[[str], None]] = [] + + # start background thread to process logs + self.worker_thread = threading.Thread(target=self._process_queue, daemon=True) + self.worker_thread.start() + + def add_emit_callback(self, callback: Callable[[str], None]) -> None: + """ + Adds a callback that will be called with each log message. + The callback should accept a single string argument (the log message). + """ + self._emit_callbacks.append(callback) + + def emit(self, record: logging.LogRecord) -> None: + msg = self.format(record) + self._log_queue.put_nowait(msg) + + def _process_queue(self) -> None: + while not self._stop_event.is_set(): + try: + msg = self._log_queue.get(timeout=1) + self._log_buffer.append(msg) + for callback in self._emit_callbacks: + try: + callback(msg) + except: + pass + self._log_queue.task_done() + except queue.Empty: + continue + + def get_log_messages(self) -> list[str]: + return self._log_buffer.get_log_messages() + + +class LogBuffer: + """ + A thread-safe buffer for storing log messages. + """ + + def __init__(self) -> None: + self._log_messages: list[str] = [] + self._lock = threading.Lock() + + def append(self, msg: str) -> None: + with self._lock: + self._log_messages.append(msg) + + def get_log_messages(self) -> list[str]: + with self._lock: + return self._log_messages.copy() From cbd2184fdd033135be31e425c9275765bdcbd5dd Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Sun, 20 Jul 2025 18:39:20 +0200 Subject: [PATCH 3/6] Initialize stderr stream logger early and make the fastmcp configuration function do nothing if log handlers already exist --- src/serena/cli.py | 9 ++++++++- src/serena/mcp.py | 14 +++++--------- 2 files changed, 13 insertions(+), 10 deletions(-) diff --git a/src/serena/cli.py b/src/serena/cli.py index 844aa6e..444a053 100644 --- a/src/serena/cli.py +++ b/src/serena/cli.py @@ -16,6 +16,7 @@ from serena.config.serena_config import ProjectConfig, SerenaConfig from serena.constants import ( DEFAULT_CONTEXT, DEFAULT_MODES, + SERENA_LOG_FORMAT, SERENA_MANAGED_DIR_IN_HOME, SERENAS_OWN_CONTEXT_YAMLS_DIR, SERENAS_OWN_MODE_YAMLS_DIR, @@ -138,11 +139,17 @@ class TopLevelCommands(AutoRegisteringGroup): tool_timeout: float | None, ) -> None: # initialize logging, using INFO level initially (will later be adjusted by SerenaAgent according to the config) + # * memory log handler (for use by GUI/Dashboard) + # * stream handler for stderr (for direct console output, which will also be captured by clients like Claude Desktop) + # (Note that stdout must never be used for logging, as it is used by the MCP server to communicate with the client.) Logger.root.setLevel(logging.INFO) memory_log_handler = MemoryLogHandler() Logger.root.addHandler(memory_log_handler) + stderr_handler = logging.StreamHandler(stream=sys.stderr) + stderr_handler.formatter = logging.Formatter(SERENA_LOG_FORMAT) + Logger.root.addHandler(stderr_handler) - log.info("Starting Serena MCP server") + log.info("Initializing Serena MCP server") project_file = project_file_arg or project factory = SerenaMCPFactorySingleProcess(context=context, project=project_file, memory_log_handler=memory_log_handler) server = factory.create_mcp_server( diff --git a/src/serena/mcp.py b/src/serena/mcp.py index c7d6f8b..ff8a11b 100644 --- a/src/serena/mcp.py +++ b/src/serena/mcp.py @@ -7,7 +7,6 @@ from abc import abstractmethod from collections.abc import AsyncIterator, Iterator, Sequence from contextlib import asynccontextmanager from dataclasses import dataclass -from logging import Formatter, Logger, StreamHandler from typing import Any, Literal, cast import docstring_parser @@ -22,22 +21,19 @@ from serena.agent import ( SerenaConfig, ) from serena.config.context_mode import SerenaAgentContext, SerenaAgentMode -from serena.constants import DEFAULT_CONTEXT, DEFAULT_MODES +from serena.constants import DEFAULT_CONTEXT, DEFAULT_MODES, SERENA_LOG_FORMAT from serena.tools import Tool from serena.util.exception import show_fatal_exception_safe from serena.util.logging import MemoryLogHandler log = logging.getLogger(__name__) -LOG_FORMAT = "%(levelname)-5s %(asctime)-15s %(name)s:%(funcName)s:%(lineno)d - %(message)s" -LOG_LEVEL = logging.INFO def configure_logging(*args, **kwargs) -> None: # type: ignore - # configure logging to stderr (will be captured by Claude Desktop); stdio is the MCP communication stream and cannot be used! - Logger.root.setLevel(LOG_LEVEL) - handler = StreamHandler(stream=sys.stderr) - handler.formatter = Formatter(LOG_FORMAT) - Logger.root.addHandler(handler) + # We only do something here if logging has not yet been configured. + # Normally, logging is configured in the MCP server startup script. + if not logging.is_enabled(): + logging.basicConfig(level=logging.INFO, stream=sys.stderr, format=SERENA_LOG_FORMAT) # patch the logging configuration function in fastmcp, because it's hard-coded and broken From 3799b802660379803bfb3c7f65fd900053f141d4 Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Sun, 20 Jul 2025 18:39:36 +0200 Subject: [PATCH 4/6] Remove obsolete status messages --- src/serena/gui_log_viewer.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/serena/gui_log_viewer.py b/src/serena/gui_log_viewer.py index 7be6b95..f46d8c3 100644 --- a/src/serena/gui_log_viewer.py +++ b/src/serena/gui_log_viewer.py @@ -71,13 +71,9 @@ class GuiLogViewer: self.message_queue.put(msg) memory_log_handler.add_emit_callback(lambda msg: self.message_queue.put(msg)) - def print_status(self, s): - print(s + "\n", file=sys.stderr) - def start(self): """Start the log viewer in a separate thread.""" if not self.running: - self.print_status("Starting thread") self.log_thread = threading.Thread(target=self.run_gui) self.log_thread.daemon = True self.log_thread.start() From ec0ae570a58a8d646bc30c9fcdd9750089b858b8 Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Sun, 20 Jul 2025 18:55:21 +0200 Subject: [PATCH 5/6] Fix Dashboard not being opened in browser with MCP server active (join on subprocess is necessary in fastmcp context but isn't without it) --- src/serena/agent.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/serena/agent.py b/src/serena/agent.py index 0dc6c14..4064d08 100644 --- a/src/serena/agent.py +++ b/src/serena/agent.py @@ -166,11 +166,14 @@ class SerenaAgent: self._dashboard_thread, port = SerenaDashboardAPI( get_memory_log_handler(), tool_names, tool_usage_stats=self._tool_usage_stats ).run_in_thread() + dashboard_url = f"http://127.0.0.1:{port}/dashboard/index.html" + log.info("Serena web dashboard started at %s", dashboard_url) if self.serena_config.web_dashboard_open_on_launch: # open the dashboard URL in the default web browser (using a separate process to control # output redirection) - process = multiprocessing.Process(target=self._open_dashboard, args=(port,)) + process = multiprocessing.Process(target=self._open_dashboard, args=(dashboard_url,)) process.start() + process.join(timeout=1) # log fundamental information log.info(f"Starting Serena server (version={serena_version()}, process id={os.getpid()}, parent process id={os.getppid()})") @@ -257,7 +260,7 @@ class SerenaAgent: log.debug(f"Tool usage statistics recording is disabled, not recording usage of '{tool_name}'.") @staticmethod - def _open_dashboard(port: int) -> None: + def _open_dashboard(url: str) -> None: # Redirect stdout and stderr file descriptors to /dev/null, # making sure that nothing can be written to stdout/stderr, even by subprocesses null_fd = os.open(os.devnull, os.O_WRONLY) @@ -266,7 +269,7 @@ class SerenaAgent: os.close(null_fd) # open the dashboard URL in the default web browser - webbrowser.open(f"http://localhost:{port}/dashboard/index.html") + webbrowser.open(url) def get_project_root(self) -> str: """ From 99024b8cc5fbc6d9f92315f2e4cbce359832403c Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Sun, 20 Jul 2025 19:23:48 +0200 Subject: [PATCH 6/6] Add abstraction RegisteredProject, which serves as a factory for Project (allowing projects to be lazy-loaded) --- src/serena/agent.py | 4 +- src/serena/config/serena_config.py | 75 ++++++++++++++++++++++++------ test/serena/test_serena_agent.py | 4 +- 3 files changed, 66 insertions(+), 17 deletions(-) diff --git a/src/serena/agent.py b/src/serena/agent.py index 4064d08..474d219 100644 --- a/src/serena/agent.py +++ b/src/serena/agent.py @@ -419,10 +419,10 @@ class SerenaAgent: """ project_instance: Project | None = self.serena_config.get_project(project_root_or_name) if project_instance is not None: - log.info(f"Found registered project {project_instance.project_name} at path {project_instance.project_root}.") + log.info(f"Found registered project '{project_instance.project_name}' at path {project_instance.project_root}") elif autogenerate and os.path.isdir(project_root_or_name): project_instance = self.serena_config.add_project_from_path(project_root_or_name) - log.info(f"Added new project {project_instance.project_name} for path {project_instance.project_root}.") + log.info(f"Added new project {project_instance.project_name} for path {project_instance.project_root}") return project_instance def activate_project_from_path_or_name(self, project_root_or_name: str) -> Project: diff --git a/src/serena/config/serena_config.py b/src/serena/config/serena_config.py index 0d14564..29993a1 100644 --- a/src/serena/config/serena_config.py +++ b/src/serena/config/serena_config.py @@ -222,7 +222,7 @@ class ProjectConfig(ToolInclusionDefinition, ToStringMixin): ) @classmethod - def load(cls, project_root: Path | str, autogenerate: bool = True) -> Self: + def load(cls, project_root: Path | str, autogenerate: bool = False) -> Self: """ Load a ProjectConfig instance from the path to the project root. """ @@ -240,6 +240,54 @@ class ProjectConfig(ToolInclusionDefinition, ToStringMixin): return cls._from_dict(yaml_data) +class RegisteredProject(ToStringMixin): + def __init__(self, project_root: str, project_config: "ProjectConfig", project_instance: Optional["Project"] = None) -> None: + """ + Represents a registered project in the Serena configuration. + + :param project_root: the root directory of the project + :param project_config: the configuration of the project + """ + self.project_root = Path(project_root).resolve() + self.project_config = project_config + self._project_instance = project_instance + + def _tostring_exclude_private(self) -> bool: + return True + + @property + def project_name(self) -> str: + return self.project_config.project_name + + @classmethod + def from_project_instance(cls, project_instance: "Project") -> "RegisteredProject": + return RegisteredProject( + project_root=project_instance.project_root, + project_config=project_instance.project_config, + project_instance=project_instance, + ) + + def matches_root_path(self, path: str | Path) -> bool: + """ + Check if the given path matches the project root path. + + :param path: the path to check + :return: True if the path matches the project root, False otherwise + """ + return self.project_root == Path(path).resolve() + + def get_project_instance(self) -> "Project": + """ + Returns the project instance for this registered project, loading it if necessary. + """ + if self._project_instance is None: + from ..project import Project + + with LogTime(f"Loading project instance for {self}", logger=log): + self._project_instance = Project(project_root=str(self.project_root), project_config=self.project_config) + return self._project_instance + + @dataclass(kw_only=True) class SerenaConfig(ToolInclusionDefinition, ToStringMixin): """ @@ -248,7 +296,7 @@ class SerenaConfig(ToolInclusionDefinition, ToStringMixin): For testing purposes, it can also be instantiated directly with the desired parameters. """ - projects: list["Project"] = field(default_factory=list) + projects: list[RegisteredProject] = field(default_factory=list) gui_log_window_enabled: bool = False log_level: int = logging.INFO trace_lsp_communication: bool = False @@ -319,8 +367,6 @@ class SerenaConfig(ToolInclusionDefinition, ToStringMixin): """ Static constructor to create SerenaConfig from the configuration file """ - from ..project import Project - config_file_path = cls._determine_config_file_path() # create the configuration file from the template if necessary @@ -357,7 +403,11 @@ class SerenaConfig(ToolInclusionDefinition, ToStringMixin): if path is None: continue num_project_migrations += 1 - project = Project.load(path) + project_config = ProjectConfig.load(path) + project = RegisteredProject( + project_root=str(path), + project_config=project_config, + ) instance.projects.append(project) # set other configuration parameters @@ -413,7 +463,7 @@ class SerenaConfig(ToolInclusionDefinition, ToStringMixin): @cached_property def project_paths(self) -> list[str]: - return sorted(project.project_root for project in self.projects) + return sorted(str(project.project_root) for project in self.projects) @cached_property def project_names(self) -> list[str]: @@ -426,7 +476,7 @@ class SerenaConfig(ToolInclusionDefinition, ToStringMixin): if project.project_config.project_name == project_root_or_name: project_candidates.append(project) if len(project_candidates) == 1: - return project_candidates[0] + return project_candidates[0].get_project_instance() elif len(project_candidates) > 1: raise ValueError( f"Multiple projects found with name '{project_root_or_name}'. Please activate it by location instead. " @@ -434,10 +484,9 @@ class SerenaConfig(ToolInclusionDefinition, ToStringMixin): ) # no project found by name; check if it's a path if os.path.isdir(project_root_or_name): - project_root = Path(project_root_or_name).resolve() for project in self.projects: - if Path(project.project_root).resolve() == project_root: - return project + if project.matches_root_path(project_root_or_name): + return project.get_project_instance() return None def add_project_from_path(self, project_root: Path | str) -> "Project": @@ -465,14 +514,14 @@ class SerenaConfig(ToolInclusionDefinition, ToStringMixin): project_config = ProjectConfig.load(project_root, autogenerate=True) new_project = Project(project_root=str(project_root), project_config=project_config, is_newly_created=True) - self.projects.append(new_project) + self.projects.append(RegisteredProject.from_project_instance(new_project)) self.save() return new_project def remove_project(self, project_name: str) -> None: # find the index of the project with the desired name and remove it - for i, project in enumerate(self.projects): + for i, project in enumerate(list(self.projects)): if project.project_name == project_name: del self.projects[i] break @@ -490,5 +539,5 @@ class SerenaConfig(ToolInclusionDefinition, ToStringMixin): loaded_original_yaml = deepcopy(self.loaded_commented_yaml) # projects are unique absolute paths # we also canonicalize them before saving - loaded_original_yaml["projects"] = sorted({str(Path(project.project_root).resolve()) for project in self.projects}) + loaded_original_yaml["projects"] = sorted({str(project.project_root) for project in self.projects}) save_yaml(self.config_file_path, loaded_original_yaml, preserve_comments=True) diff --git a/test/serena/test_serena_agent.py b/test/serena/test_serena_agent.py index 0c040d6..060dc8e 100644 --- a/test/serena/test_serena_agent.py +++ b/test/serena/test_serena_agent.py @@ -7,7 +7,7 @@ import pytest import test.solidlsp.clojure as clj from serena.agent import SerenaAgent -from serena.config.serena_config import ProjectConfig, SerenaConfig +from serena.config.serena_config import ProjectConfig, RegisteredProject, SerenaConfig from serena.project import Project from serena.tools import FindReferencingSymbolsTool, FindSymbolTool from solidlsp.ls_config import Language @@ -45,7 +45,7 @@ def serena_config(): encoding="utf-8", ), ) - test_projects.append(project) + test_projects.append(RegisteredProject.from_project_instance(project)) config = SerenaConfig(gui_log_window_enabled=False, web_dashboard=False, log_level=logging.ERROR) config.projects = test_projects