From e0186cced54282937e3d2d09bd1ee7250d6c9e49 Mon Sep 17 00:00:00 2001 From: Vladyslav Hnatiuk Date: Sun, 31 Aug 2025 20:43:06 +0200 Subject: [PATCH 1/5] Rework dump_config to start only needed runners, not all --- src/finecode/cli_app/dump_config.py | 48 ++++++++++++++++++++++------- 1 file changed, 37 insertions(+), 11 deletions(-) diff --git a/src/finecode/cli_app/dump_config.py b/src/finecode/cli_app/dump_config.py index 3e0742d..35ed216 100644 --- a/src/finecode/cli_app/dump_config.py +++ b/src/finecode/cli_app/dump_config.py @@ -3,8 +3,8 @@ from loguru import logger -from finecode import context, services -from finecode.config import config_models, read_configs +from finecode import context, services, proxy_utils +from finecode.config import config_models, read_configs, collect_actions from finecode.runner import manager as runner_manager @@ -40,23 +40,49 @@ async def dump_config(workdir_path: pathlib.Path, project_name: str): f"Reading project configs(without presets) in {project.dir_path} failed: {exception.message}" ) + # Some tools like IDE extensions for syntax highlighting rely on + # file name. Keep file name of config the same and save in subdirectory + project_dir_path = list(ws_context.ws_projects.keys())[0] + dump_dir_path = project_dir_path / "finecode_config_dump" + dump_file_path = dump_dir_path / "pyproject.toml" + project_def = ws_context.ws_projects[project_dir_path] + actions_by_projects = {project_dir_path:["dump_config"]} + # start runner to init project config try: + # reread projects configs, now with resolved presets + # to be able to resolve presets, start runners with presets first try: - await runner_manager.update_runners(ws_context) + await runner_manager.start_runners_with_presets( + projects=[project_def], ws_context=ws_context + ) except runner_manager.RunnerFailedToStart as exception: raise DumpFailed( - f"One or more projects are misconfigured, runners for them didn't" - f" start: {exception.message}. Check logs for details." + f"Starting runners with presets failed: {exception.message}" + ) + + try: + await read_configs.read_project_config( + project=project, ws_context=ws_context + ) + collect_actions.collect_actions( + project_path=project.dir_path, ws_context=ws_context + ) + except config_models.ConfigurationError as exception: + raise DumpFailed( + f"Rereading project config with presets and collecting actions in {project.dir_path} failed: {exception.message}" + ) + + try: + await proxy_utils.start_required_environments( + actions_by_projects, ws_context + ) + except proxy_utils.StartingEnvironmentsFailed as exception: + raise DumpFailed( + f"Failed to start environments for running 'dump_config': {exception.message}" ) - # Some tools like IDE extensions for syntax highlighting rely on - # file name. Keep file name of config the same and save in subdirectory - project_dir_path = list(ws_context.ws_projects.keys())[0] - dump_dir_path = project_dir_path / "finecode_config_dump" - dump_file_path = dump_dir_path / "pyproject.toml" project_raw_config = ws_context.ws_projects_raw_configs[project_dir_path] - project_def = ws_context.ws_projects[project_dir_path] await services.run_action( action_name="dump_config", From b7f5a2afc62b864c115487948833c168d3ad8fc6 Mon Sep 17 00:00:00 2001 From: Vladyslav Hnatiuk Date: Mon, 1 Sep 2025 13:39:54 +0200 Subject: [PATCH 2/5] Remove platformdirs in finecode package and update pygls to 2.0.0-a6 --- finecode_extension_runner/pyproject.toml | 2 +- pyproject.toml | 3 +-- src/finecode/app_dirs.py | 8 -------- 3 files changed, 2 insertions(+), 11 deletions(-) delete mode 100644 src/finecode/app_dirs.py diff --git a/finecode_extension_runner/pyproject.toml b/finecode_extension_runner/pyproject.toml index 7edcaec..f74cd4e 100644 --- a/finecode_extension_runner/pyproject.toml +++ b/finecode_extension_runner/pyproject.toml @@ -11,7 +11,7 @@ dependencies = [ "click==8.1.*", "pydantic==2.11.*", "platformdirs==4.3.*", - "pygls==2.0.0-a2", + "pygls==2.0.0-a6", "finecode_extension_api==0.3.*", "deepmerge==2.0.*", ] diff --git a/pyproject.toml b/pyproject.toml index 724113d..5734c56 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -11,8 +11,7 @@ dependencies = [ "watchdog==4.0.*", "click==8.1.*", "pydantic==2.11.*", - "platformdirs==4.3.*", - "pygls==2.0.0-a2", + "pygls==2.0.0-a6", "finecode_extension_api==0.3.*", "finecode_extension_runner==0.3.*", "ordered-set==4.1.*", diff --git a/src/finecode/app_dirs.py b/src/finecode/app_dirs.py deleted file mode 100644 index ae46d02..0000000 --- a/src/finecode/app_dirs.py +++ /dev/null @@ -1,8 +0,0 @@ -from platformdirs import PlatformDirs - - -def get_app_dirs(): - # ensure best practice: use versioned path - return PlatformDirs( - appname="FineCode_Workspace_Manager", appauthor="FineCode", version="1.0" - ) From b18f46bbeebff4b0768d6d4868d08471cc4c2d01 Mon Sep 17 00:00:00 2001 From: Vladyslav Hnatiuk Date: Mon, 1 Sep 2025 13:41:45 +0200 Subject: [PATCH 3/5] Save WM logs in venv logs --- src/finecode/logger_utils.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/finecode/logger_utils.py b/src/finecode/logger_utils.py index 19dc4c9..99b9488 100644 --- a/src/finecode/logger_utils.py +++ b/src/finecode/logger_utils.py @@ -1,5 +1,6 @@ import inspect import logging +import sys from pathlib import Path from loguru import logger @@ -9,7 +10,9 @@ def init_logger(trace: bool, stdout: bool = False): - log_dir_path = Path(app_dirs.get_app_dirs().user_log_dir) + venv_dir_path = Path(sys.executable) / '..' / '..' + logs_dir_path = venv_dir_path / 'logs' + logger.remove() # disable logging raw messages # TODO: make configurable @@ -20,7 +23,7 @@ def init_logger(trace: bool, stdout: bool = False): ] ) logs.save_logs_to_file( - file_path=log_dir_path / "execution.log", + file_path=logs_dir_path / "workspace_manager.log", log_level="TRACE" if trace else "INFO", stdout=stdout, ) From c12a7b7815284f98d9a236d4e349676d61563b8c Mon Sep 17 00:00:00 2001 From: Vladyslav Hnatiuk Date: Tue, 2 Sep 2025 08:51:28 +0200 Subject: [PATCH 4/5] Fix sending action results with pathes as keys. Fix diagnostics in WM lsp server: results were converted to classes not recursively. Get rid of manager.update_runners, now all runners are started only on demand. Add INITIALIZING status of runner. Introduce get_or_start_runner to unify getting/starting runners. --- .../_services/run_action.py | 7 +- .../finecode_extension_runner/lsp_server.py | 16 +- .../src/finecode_extension_runner/schemas.py | 2 +- src/finecode/cli_app/dump_config.py | 10 +- src/finecode/config/read_configs.py | 4 +- src/finecode/logger_utils.py | 5 +- .../lsp_server/endpoints/action_tree.py | 9 +- .../lsp_server/endpoints/diagnostics.py | 31 +++- src/finecode/lsp_server/services.py | 97 +++++++--- src/finecode/proxy_utils.py | 18 +- src/finecode/runner/manager.py | 174 +++++------------- src/finecode/runner/runner_client.py | 4 +- src/finecode/runner/runner_info.py | 1 + src/finecode/services.py | 32 +--- 14 files changed, 205 insertions(+), 205 deletions(-) diff --git a/finecode_extension_runner/src/finecode_extension_runner/_services/run_action.py b/finecode_extension_runner/src/finecode_extension_runner/_services/run_action.py index 84f9fbe..32841d6 100644 --- a/finecode_extension_runner/src/finecode_extension_runner/_services/run_action.py +++ b/finecode_extension_runner/src/finecode_extension_runner/_services/run_action.py @@ -269,7 +269,10 @@ async def run_action( f"R{run_id} | Run action end '{request.action_name}', duration: {duration}ms" ) - if not isinstance(action_result, code_action.RunActionResult): + # if partial results were sent, `action_result` may be None + if action_result is not None and not isinstance( + action_result, code_action.RunActionResult + ): logger.error( f"R{run_id} | Unexpected result type: {type(action_result).__name__}" ) @@ -284,7 +287,7 @@ async def run_action( def action_result_to_run_action_response( - action_result: code_action.RunActionResult, + action_result: code_action.RunActionResult | None, asked_result_format: typing.Literal["json"] | typing.Literal["string"], ) -> schemas.RunActionResponse: serialized_result: dict[str, typing.Any] | str | None = None diff --git a/finecode_extension_runner/src/finecode_extension_runner/lsp_server.py b/finecode_extension_runner/src/finecode_extension_runner/lsp_server.py index e6328d1..e7cabb5 100644 --- a/finecode_extension_runner/src/finecode_extension_runner/lsp_server.py +++ b/finecode_extension_runner/src/finecode_extension_runner/lsp_server.py @@ -207,6 +207,16 @@ async def update_config(ls: lsp_server.LanguageServer, params): raise e +def convert_path_keys( + obj: dict[str | pathlib.Path, typing.Any] | list[typing.Any], +) -> dict[str, typing.Any] | list[typing.Any]: + if isinstance(obj, dict): + return {str(k): convert_path_keys(v) for k, v in obj.items()} + elif isinstance(obj, list): + return [convert_path_keys(item) for item in obj] + return obj + + class CustomJSONEncoder(json.JSONEncoder): # add support of serializing pathes to json.dumps def default(self, obj): @@ -240,7 +250,11 @@ async def run_action(ls: lsp_server.LanguageServer, params): # dict key can be path, but pygls fails to handle slashes in dict keys, use strings # representation of result instead until the problem is properly solved - result_str = json.dumps(response.to_dict()["result"], cls=CustomJSONEncoder) + # + # custom json encoder converts dict values and `convert_path_keys` is used to + # convert dict keys + result_dict = convert_path_keys(response.to_dict()["result"]) + result_str = json.dumps(result_dict, cls=CustomJSONEncoder) return { "status": status, "result": result_str, diff --git a/finecode_extension_runner/src/finecode_extension_runner/schemas.py b/finecode_extension_runner/src/finecode_extension_runner/schemas.py index 71f7fc1..b8a1ff9 100644 --- a/finecode_extension_runner/src/finecode_extension_runner/schemas.py +++ b/finecode_extension_runner/src/finecode_extension_runner/schemas.py @@ -52,5 +52,5 @@ class RunActionOptions(BaseSchema): class RunActionResponse(BaseSchema): return_code: int # result can be empty(=None) e.g. if it was sent as a list of partial results - result: dict[str, Any] | None + result: dict[str, Any] | str | None format: Literal["json"] | Literal["string"] | Literal["styled_text_json"] = "json" diff --git a/src/finecode/cli_app/dump_config.py b/src/finecode/cli_app/dump_config.py index 35ed216..37b0c2a 100644 --- a/src/finecode/cli_app/dump_config.py +++ b/src/finecode/cli_app/dump_config.py @@ -3,8 +3,8 @@ from loguru import logger -from finecode import context, services, proxy_utils -from finecode.config import config_models, read_configs, collect_actions +from finecode import context, proxy_utils, services +from finecode.config import collect_actions, config_models, read_configs from finecode.runner import manager as runner_manager @@ -46,11 +46,11 @@ async def dump_config(workdir_path: pathlib.Path, project_name: str): dump_dir_path = project_dir_path / "finecode_config_dump" dump_file_path = dump_dir_path / "pyproject.toml" project_def = ws_context.ws_projects[project_dir_path] - actions_by_projects = {project_dir_path:["dump_config"]} + actions_by_projects = {project_dir_path: ["dump_config"]} # start runner to init project config try: - # reread projects configs, now with resolved presets + # reread projects configs, now with resolved presets # to be able to resolve presets, start runners with presets first try: await runner_manager.start_runners_with_presets( @@ -72,7 +72,7 @@ async def dump_config(workdir_path: pathlib.Path, project_name: str): raise DumpFailed( f"Rereading project config with presets and collecting actions in {project.dir_path} failed: {exception.message}" ) - + try: await proxy_utils.start_required_environments( actions_by_projects, ws_context diff --git a/src/finecode/config/read_configs.py b/src/finecode/config/read_configs.py index 81455c8..6842d6f 100644 --- a/src/finecode/config/read_configs.py +++ b/src/finecode/config/read_configs.py @@ -59,8 +59,10 @@ async def read_projects_in_dir( actions=actions, env_configs={}, ) + is_new_project = not def_file.parent in ws_context.ws_projects ws_context.ws_projects[def_file.parent] = new_project - new_projects.append(new_project) + if is_new_project: + new_projects.append(new_project) return new_projects diff --git a/src/finecode/logger_utils.py b/src/finecode/logger_utils.py index 99b9488..a9e0df3 100644 --- a/src/finecode/logger_utils.py +++ b/src/finecode/logger_utils.py @@ -5,13 +5,12 @@ from loguru import logger -from finecode import app_dirs from finecode_extension_runner import logs def init_logger(trace: bool, stdout: bool = False): - venv_dir_path = Path(sys.executable) / '..' / '..' - logs_dir_path = venv_dir_path / 'logs' + venv_dir_path = Path(sys.executable) / ".." / ".." + logs_dir_path = venv_dir_path / "logs" logger.remove() # disable logging raw messages diff --git a/src/finecode/lsp_server/endpoints/action_tree.py b/src/finecode/lsp_server/endpoints/action_tree.py index b04fcc5..7d77a54 100644 --- a/src/finecode/lsp_server/endpoints/action_tree.py +++ b/src/finecode/lsp_server/endpoints/action_tree.py @@ -293,9 +293,12 @@ async def __reload_action(action_node_id: str) -> None: ) for env in all_handlers_envs: # parallel to speed up? - runner = global_state.ws_context.ws_projects_extension_runners[project_path][ - env - ] + try: + runner = global_state.ws_context.ws_projects_extension_runners[ + project_path + ][env] + except KeyError: + continue try: await runner_client.reload_action(runner, action_name) diff --git a/src/finecode/lsp_server/endpoints/diagnostics.py b/src/finecode/lsp_server/endpoints/diagnostics.py index 8005807..f83de92 100644 --- a/src/finecode/lsp_server/endpoints/diagnostics.py +++ b/src/finecode/lsp_server/endpoints/diagnostics.py @@ -8,6 +8,7 @@ from loguru import logger from lsprotocol import types +from pydantic.dataclasses import dataclass as pydantic_dataclass from finecode import ( context, @@ -78,9 +79,11 @@ async def document_diagnostic_with_full_result( if response is None: return None - lint_result: lint_action.LintRunResult = lint_action.LintRunResult( - **response.result - ) + # use pydantic dataclass to convert dict to dataclass instance recursively + # (default dataclass constructor doesn't handle nested items, it stores them just + # as dict) + result_type = pydantic_dataclass(lint_action.LintRunResult) + lint_result: lint_action.LintRunResult = result_type(**response.result) try: requested_file_messages = lint_result.messages.pop(str(file_path)) @@ -142,8 +145,14 @@ async def document_diagnostic_with_partial_results( related_documents: dict[str, types.FullDocumentDiagnosticReport] = {} got_response_for_requested_file: bool = False requested_file_path_str = str(file_path) + # use pydantic dataclass to convert dict to dataclass instance recursively + # (default dataclass constructor doesn't handle nested items, it stores them just + # as dict) + result_type = pydantic_dataclass(lint_action.LintRunResult) async for partial_response in response: - lint_subresult = lint_action.LintRunResult(**partial_response) + lint_subresult: lint_action.LintRunResult = result_type( + **partial_response + ) for file_path_str, lint_messages in lint_subresult.messages.items(): if requested_file_path_str == file_path_str: if got_response_for_requested_file: @@ -244,8 +253,14 @@ async def run_workspace_diagnostic_with_partial_results( project_dir_path=exec_info.project_dir_path, ws_context=global_state.ws_context, ) as response: + # use pydantic dataclass to convert dict to dataclass instance recursively + # (default dataclass constructor doesn't handle nested items, it stores them just + # as dict) + result_type = pydantic_dataclass(lint_action.LintRunResult) async for partial_response in response: - lint_subresult = lint_action.LintRunResult(**partial_response) + lint_subresult: lint_action.LintRunResult = result_type( + **partial_response + ) lsp_subresult = types.WorkspaceDiagnosticReportPartialResult( items=[ types.WorkspaceFullDocumentDiagnosticReport( @@ -310,12 +325,16 @@ async def workspace_diagnostic_with_full_result( responses = [task.result().result for task in send_tasks] + # use pydantic dataclass to convert dict to dataclass instance recursively + # (default dataclass constructor doesn't handle nested items, it stores them just + # as dict) + result_type = pydantic_dataclass(lint_action.LintRunResult) items: list[types.WorkspaceDocumentDiagnosticReport] = [] for response in responses: if response is None: continue else: - lint_result = lint_action.LintRunResult(**response) + lint_result: lint_action.LintRunResult = result_type(**response) for file_path_str, lint_messages in lint_result.messages.items(): new_report = types.WorkspaceFullDocumentDiagnosticReport( uri=pygls_types_utils.path_to_uri_str(Path(file_path_str)), diff --git a/src/finecode/lsp_server/services.py b/src/finecode/lsp_server/services.py index 26a7c85..c408221 100644 --- a/src/finecode/lsp_server/services.py +++ b/src/finecode/lsp_server/services.py @@ -3,7 +3,7 @@ from loguru import logger from finecode import domain, user_messages -from finecode.config import read_configs +from finecode.config import collect_actions, config_models, read_configs from finecode.lsp_server import global_state, schemas from finecode.runner import manager as runner_manager @@ -60,42 +60,85 @@ async def add_workspace_dir( raise ValueError("Directory is already added") global_state.ws_context.ws_dirs_paths.append(dir_path) - await read_configs.read_projects_in_dir(dir_path, global_state.ws_context) + new_projects = await read_configs.read_projects_in_dir( + dir_path, global_state.ws_context + ) + + for new_project in new_projects: + await read_configs.read_project_config( + project=new_project, + ws_context=global_state.ws_context, + resolve_presets=False, + ) + try: - await runner_manager.update_runners(global_state.ws_context) - except runner_manager.RunnerFailedToStart: - # user sees status in client(IDE), no need to raise explicit error - ... + await runner_manager.start_runners_with_presets( + projects=new_projects, ws_context=global_state.ws_context + ) + except runner_manager.RunnerFailedToStart as exception: + raise ValueError(f"Starting runners with presets failed: {exception.message}") + + for project in new_projects: + try: + await read_configs.read_project_config( + project=project, ws_context=global_state.ws_context + ) + collect_actions.collect_actions( + project_path=project.dir_path, ws_context=global_state.ws_context + ) + except config_models.ConfigurationError as exception: + raise ValueError( + f"Rereading project config with presets and collecting actions in {project.dir_path} failed: {exception.message}" + ) + return schemas.AddWorkspaceDirResponse() async def delete_workspace_dir( request: schemas.DeleteWorkspaceDirRequest, ) -> schemas.DeleteWorkspaceDirResponse: - global_state.ws_context.ws_dirs_paths.remove(Path(request.dir_path)) - try: - await runner_manager.update_runners(global_state.ws_context) - except runner_manager.RunnerFailedToStart: - # user sees status in client(IDE), no need to raise explicit error - ... + ws_dir_path_to_remove = Path(request.dir_path) + global_state.ws_context.ws_dirs_paths.remove(ws_dir_path_to_remove) + + # find all projects affected by removing of this ws dir + project_dir_pathes = global_state.ws_context.ws_projects.keys() + projects_to_remove: list[Path] = [] + for project_dir_path in project_dir_pathes: + if not project_dir_path.is_relative_to(ws_dir_path_to_remove): + continue + + # project_dir_path is now candidate to remove + remove_project_dir_path = True + for ws_dir_path in global_state.ws_context.ws_dirs_paths: + if project_dir_path.is_relative_to(ws_dir_path): + # project is also in another ws_dir, keep it + remove_project_dir_path = False + break + + if remove_project_dir_path: + project_runners = global_state.ws_context.ws_projects_extension_runners[ + project_dir_path + ].values() + for runner in project_runners: + await runner_manager.stop_extension_runner(runner=runner) + del global_state.ws_context.ws_projects[project_dir_path] + try: + del global_state.ws_context.ws_projects_raw_configs[project_dir_path] + except KeyError: + ... + return schemas.DeleteWorkspaceDirResponse() async def handle_changed_ws_dirs(added: list[Path], removed: list[Path]) -> None: - for added_ws_dir_path in added: - global_state.ws_context.ws_dirs_paths.append(added_ws_dir_path) - for removed_ws_dir_path in removed: - try: - global_state.ws_context.ws_dirs_paths.remove(removed_ws_dir_path) - except ValueError: - logger.warning( - f"Ws Directory {removed_ws_dir_path} was removed from ws," - " but not found in ws context" - ) + delete_request = schemas.DeleteWorkspaceDirRequest( + dir_path=removed_ws_dir_path.as_posix() + ) + await delete_workspace_dir(request=delete_request) - try: - await runner_manager.update_runners(global_state.ws_context) - except runner_manager.RunnerFailedToStart: - # user sees status in client(IDE), no need to raise explicit error - ... + for added_ws_dir_path in added: + add_request = schemas.AddWorkspaceDirRequest( + dir_path=added_ws_dir_path.as_posix() + ) + await add_workspace_dir(request=add_request) diff --git a/src/finecode/proxy_utils.py b/src/finecode/proxy_utils.py index eedf32f..0333e1e 100644 --- a/src/finecode/proxy_utils.py +++ b/src/finecode/proxy_utils.py @@ -175,6 +175,7 @@ async def run_with_partial_results( logger.trace(f"Run {action_name} in project {project_dir_path}") result: AsyncList[domain.PartialResultRawValue] = AsyncList() + project = ws_context.ws_projects[project_dir_path] try: async with asyncio.TaskGroup() as tg: partial_results_task = tg.create_task( @@ -182,14 +183,21 @@ async def run_with_partial_results( result_list=result, partial_result_token=partial_result_token ) ) - project = ws_context.ws_projects[project_dir_path] action = next(action for action in project.actions if action.name == "lint") action_envs = ordered_set.OrderedSet( [handler.env for handler in action.handlers] ) runners_by_env = ws_context.ws_projects_extension_runners[project_dir_path] - for env in action_envs: - runner = runners_by_env[env] + for env_name in action_envs: + try: + runner = await runner_manager.get_or_start_runner( + project_def=project, env_name=env_name, ws_context=ws_context + ) + except runner_manager.RunnerFailedToStart as exception: + raise ActionRunFailed( + f"Runner {env_name} in project {project.dir_path} failed: {exception.message}" + ) + tg.create_task( run_action_and_notify( action_name=action_name, @@ -205,7 +213,9 @@ async def run_with_partial_results( except ExceptionGroup as eg: for exc in eg.exceptions: logger.exception(exc) - raise ActionRunFailed(eg) + raise ActionRunFailed( + f"Run of {action_name} in {project.dir_path} failed. See logs for more details" + ) @contextlib.asynccontextmanager diff --git a/src/finecode/runner/manager.py b/src/finecode/runner/manager.py index 97ecfd5..97811a6 100644 --- a/src/finecode/runner/manager.py +++ b/src/finecode/runner/manager.py @@ -64,7 +64,7 @@ def map_change_object(change): return await apply_workspace_edit(converted_params) -async def start_extension_runner( +async def _start_extension_runner_process( runner_dir: Path, env_name: str, ws_context: context.WorkspaceContext ) -> runner_info.ExtensionRunnerInfo | None: runner_info_instance = runner_info.ExtensionRunnerInfo( @@ -201,100 +201,6 @@ def stop_extension_runner_sync(runner: runner_info.ExtensionRunnerInfo) -> None: logger.trace("Extension runner was not running") -async def kill_extension_runner(runner: runner_info.ExtensionRunnerInfo) -> None: - if runner.client is not None: - if runner.client._server is not None: - runner.client._server.terminate() - await runner.client.stop() - - -async def update_runners(ws_context: context.WorkspaceContext) -> None: - # starts runners for new(=which don't have runner yet) projects in `ws_context` - # and stops runners for projects which are not in `ws_context` anymore - # - # during initialization of new runners it also reads their configurations and - # actions - # - # this function should handle all possible statuses of projects and they either - # start of fail to start, only projects without finecode are ignored - extension_runners_paths = list(ws_context.ws_projects_extension_runners.keys()) - new_dirs, deleted_dirs = dirs_utils.find_changed_dirs( - [*ws_context.ws_projects.keys()], - extension_runners_paths, - ) - for deleted_dir in deleted_dirs: - runners_by_env = ws_context.ws_projects_extension_runners[deleted_dir] - for runner in runners_by_env.values(): - await stop_extension_runner(runner) - del ws_context.ws_projects_extension_runners[deleted_dir] - - projects = [ws_context.ws_projects[new_dir] for new_dir in new_dirs] - # first start runners with presets to be able to resolve presets - await start_runners_with_presets(projects, ws_context) - - new_runners_tasks: list[asyncio.Task] = [] - try: - # only then start runners for all other envs - new_runners_tasks = [] - async with asyncio.TaskGroup() as tg: - for new_dir in new_dirs: - project = ws_context.ws_projects[new_dir] - project_status = project.status - if ( - ws_context.ws_projects_extension_runners.get(new_dir, {}).get( - "dev_workspace", None - ) - is not None - ): - # start only if dev_workspace started successfully - for env in project.envs: - if env == "dev_workspace": - # this env has already started above - continue - - runner_task = tg.create_task( - start_extension_runner( - runner_dir=new_dir, env_name=env, ws_context=ws_context - ) - ) - new_runners_tasks.append(runner_task) - - except ExceptionGroup as eg: - for exception in eg.exceptions: - if isinstance( - exception, runner_client.BaseRunnerRequestException - ) or isinstance(exception, RunnerFailedToStart): - logger.error(exception.message) - else: - logger.error("Unexpected exception:") - logger.exception(exception) - raise RunnerFailedToStart("Failed to start runner") - - save_runners_from_tasks_in_context(tasks=new_runners_tasks, ws_context=ws_context) - extension_runners: list[runner_info.ExtensionRunnerInfo] = [ - runner.result() for runner in new_runners_tasks if runner is not None - ] - - try: - async with asyncio.TaskGroup() as tg: - for runner in extension_runners: - tg.create_task( - _init_runner( - runner, - ws_context.ws_projects[runner.working_dir_path], - ws_context, - ) - ) - except ExceptionGroup as eg: - for exception in eg.exceptions: - if isinstance(exception, runner_client.BaseRunnerRequestException): - logger.error(exception.message) - else: - logger.error("Unexpected exception:") - logger.exception(exception) - raise RunnerFailedToStart("Failed to initialize runner") - - async def start_runners_with_presets( projects: list[domain.Project], ws_context: context.WorkspaceContext ) -> None: @@ -337,7 +243,8 @@ async def start_runners_with_presets( async def start_runner( project_def: domain.Project, env_name: str, ws_context: context.WorkspaceContext ) -> runner_info.ExtensionRunnerInfo: - runner = await start_extension_runner( + # this function manages status of the runner and initialized event + runner = await _start_extension_runner_process( runner_dir=project_def.dir_path, env_name=env_name, ws_context=ws_context ) @@ -346,12 +253,15 @@ async def start_runner( f"Runner '{env_name}' in project {project_def.name} failed to start" ) + runner.status = runner_info.RunnerStatus.INITIALIZING save_runner_in_context(runner=runner, ws_context=ws_context) - - # we cannot reuse '_init_runner' here because we need to start lsp client first, - # read config(=also resolve presets) and only then we can update runner config, - # because this requires resolved project config with presets - await _init_lsp_client(runner=runner, project=project_def) + try: + await _init_lsp_client(runner=runner, project=project_def) + except RunnerFailedToStart as exception: + runner.status = runner_info.RunnerStatus.FAILED + await notify_project_changed(project_def) + runner.initialized_event.set() + raise exception if ( project_def.dir_path not in ws_context.ws_projects_raw_configs @@ -365,6 +275,9 @@ async def start_runner( project_path=project_def.dir_path, ws_context=ws_context ) except config_models.ConfigurationError as exception: + runner.status = runner_info.RunnerStatus.FAILED + runner.initialized_event.set() + await notify_project_changed(project_def) raise RunnerFailedToStart( f"Found problem in configuration of {project_def.dir_path}: {exception.message}" ) @@ -372,6 +285,39 @@ async def start_runner( await update_runner_config(runner=runner, project=project_def) await _finish_runner_init(runner=runner, project=project_def, ws_context=ws_context) + runner.status = runner_info.RunnerStatus.RUNNING + await notify_project_changed(project_def) + runner.initialized_event.set() + + return runner + + +async def get_or_start_runner( + project_def: domain.Project, env_name: str, ws_context: context.WorkspaceContext +) -> runner_info.ExtensionRunnerInfo: + runners_by_env = ws_context.ws_projects_extension_runners[project_def.dir_path] + + try: + runner = runners_by_env[env_name] + except KeyError: + runner = await start_runner( + project_def=project_def, env_name=env_name, ws_context=ws_context + ) + + if runner.status != runner_info.RunnerStatus.RUNNING: + runner_error = False + if runner.status == runner_info.RunnerStatus.INITIALIZING: + await runner.initialized_event.wait() + if runner.status != runner_info.RunnerStatus.RUNNING: + runner_error = True + else: + runner_error = True + + if runner_error: + raise RunnerFailedToStart( + f"Runner {env_name} in project {project_def.dir_path} is not running. Status: {runner.status}" + ) + return runner @@ -383,21 +329,6 @@ async def _start_dev_workspace_runner( ) -async def _init_runner( - runner: runner_info.ExtensionRunnerInfo, - project: domain.Project, - ws_context: context.WorkspaceContext, -) -> None: - # initialization is required to be able to perform other requests - logger.trace(f"Init runner {runner.working_dir_path}") - assert project.actions is not None - - await _init_lsp_client(runner=runner, project=project) - - await update_runner_config(runner=runner, project=project) - await _finish_runner_init(runner=runner, project=project, ws_context=ws_context) - - async def _init_lsp_client( runner: runner_info.ExtensionRunnerInfo, project: domain.Project ) -> None: @@ -409,18 +340,12 @@ async def _init_lsp_client( client_version="0.1.0", ) except runner_client.BaseRunnerRequestException as error: - runner.status = runner_info.RunnerStatus.FAILED - await notify_project_changed(project) - runner.initialized_event.set() raise RunnerFailedToStart(f"Runner failed to initialize: {error.message}") try: await runner_client.notify_initialized(runner) except Exception as error: logger.error(f"Failed to notify runner about initialization: {error}") - runner.status = runner_info.RunnerStatus.FAILED - await notify_project_changed(project) - runner.initialized_event.set() logger.exception(error) raise RunnerFailedToStart( f"Runner failed to notify about initialization: {error}" @@ -457,15 +382,10 @@ async def _finish_runner_init( project: domain.Project, ws_context: context.WorkspaceContext, ) -> None: - runner.status = runner_info.RunnerStatus.RUNNING - await notify_project_changed(project) - await send_opened_files( runner=runner, opened_files=list(ws_context.opened_documents.values()) ) - runner.initialized_event.set() - def save_runners_from_tasks_in_context( tasks: list[asyncio.Task], ws_context: context.WorkspaceContext diff --git a/src/finecode/runner/runner_client.py b/src/finecode/runner/runner_client.py index 52c5ef9..ea03d83 100644 --- a/src/finecode/runner/runner_client.py +++ b/src/finecode/runner/runner_client.py @@ -81,7 +81,7 @@ async def send_request( f" runner {runner.working_dir_path} in env {runner.env_name}" ) except pygls_exceptions.JsonRpcInternalError as error: - logger.error(f"JsonRpcInternalError: {error.message}") + logger.error(f"JsonRpcInternalError: {error.message} {error.data}") raise NoResponse( f"Extension runner {runner.working_dir_path} returned no response," " check it logs" @@ -119,7 +119,7 @@ def send_request_sync( f" to runner {runner.working_dir_path}" ) except pygls_exceptions.JsonRpcInternalError as error: - logger.error(f"JsonRpcInternalError: {error.message}") + logger.error(f"JsonRpcInternalError: {error.message} {error.data}") raise NoResponse( f"Extension runner {runner.working_dir_path} returned no response," " check it logs" diff --git a/src/finecode/runner/runner_info.py b/src/finecode/runner/runner_info.py index b644038..d56a73b 100644 --- a/src/finecode/runner/runner_info.py +++ b/src/finecode/runner/runner_info.py @@ -86,6 +86,7 @@ def process_id(self) -> int: class RunnerStatus(enum.Enum): READY_TO_START = enum.auto() NO_VENV = enum.auto() + INITIALIZING = enum.auto() FAILED = enum.auto() RUNNING = enum.auto() EXITED = enum.auto() diff --git a/src/finecode/services.py b/src/finecode/services.py index 4e1c7f6..c3ca635 100644 --- a/src/finecode/services.py +++ b/src/finecode/services.py @@ -21,31 +21,15 @@ async def restart_extension_runners( logger.error(f"Cannot find runner for {runner_working_dir_path}") return - new_runners_by_env: dict[str, runner_info.ExtensionRunnerInfo] = {} for runner in runners_by_env.values(): await runner_manager.stop_extension_runner(runner) - new_runner = await runner_manager.start_extension_runner( - runner_dir=runner_working_dir_path, + project_def = ws_context.ws_projects[runner.working_dir_path] + new_runner = await runner_manager.start_runner( + project_def=project_def, env_name=runner.env_name, ws_context=ws_context, ) - if new_runner is None: - logger.error("Extension runner didn't start") - continue - new_runners_by_env[runner.env_name] = new_runner - - ws_context.ws_projects_extension_runners[runner_working_dir_path] = ( - new_runners_by_env - ) - - # parallel? - for runner in new_runners_by_env.values(): - await runner_manager._init_runner( - runner, - ws_context.ws_projects[runner.working_dir_path], - ws_context, - ) def on_shutdown(ws_context: context.WorkspaceContext): @@ -157,11 +141,13 @@ async def _run_action_in_env_runner( ws_context: context.WorkspaceContext, result_format: RunResultFormat = RunResultFormat.JSON, ): - runners_by_env = ws_context.ws_projects_extension_runners[project_def.dir_path] - runner = runners_by_env[env_name] - if runner.status != runner_info.RunnerStatus.RUNNING: + try: + runner = await runner_manager.get_or_start_runner( + project_def=project_def, env_name=env_name, ws_context=ws_context + ) + except runner_manager.RunnerFailedToStart as exception: raise ActionRunFailed( - f"Runner {env_name} in project {project_def.dir_path} is not running. Status: {runner.status}" + f"Runner {env_name} in project {project_def.dir_path} failed: {exception.message}" ) try: From e0104c1d38ad0b4f75dff3c09ff4e2457ba6e423 Mon Sep 17 00:00:00 2001 From: Vladyslav Hnatiuk Date: Tue, 2 Sep 2025 08:51:48 +0200 Subject: [PATCH 5/5] Format code --- .../fine_python_module_exports/extension.py | 1 + 1 file changed, 1 insertion(+) diff --git a/extensions/fine_python_module_exports/fine_python_module_exports/extension.py b/extensions/fine_python_module_exports/fine_python_module_exports/extension.py index 5d35f83..7a86e26 100644 --- a/extensions/fine_python_module_exports/fine_python_module_exports/extension.py +++ b/extensions/fine_python_module_exports/fine_python_module_exports/extension.py @@ -13,6 +13,7 @@ # from finecode.extension_runner.interfaces import icache + def uri_str_to_path(uri_str: str) -> Path: return Path(uri_str.replace("file://", ""))