Skip to content

Commit 83c4737

Browse files
committed
Migrate prepare_envs to external API server
1 parent 4dd5ab6 commit 83c4737

5 files changed

Lines changed: 396 additions & 253 deletions

File tree

docs/api-protocol.md

Lines changed: 75 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -97,7 +97,7 @@ The server internally calls
9797
#### `workspace/addDir`
9898

9999
Add a workspace directory. Discovers projects, reads configs, collects actions,
100-
and starts extension runners.
100+
and optionally starts extension runners.
101101

102102
> **Design note:** Ideally, workspace directories would be a single shared
103103
> definition independent of which client connects (LSP, MCP, CLI). Currently,
@@ -108,15 +108,20 @@ and starts extension runners.
108108
> of directories is not environment-specific.
109109
110110
- **Type:** request
111-
- **Clients:** LSP
111+
- **Clients:** LSP, CLI
112112
- **Status:** implemented
113113

114114
**Params:**
115115

116116
```json
117-
{"dir_path": "/path/to/workspace"}
117+
{"dir_path": "/path/to/workspace", "start_runners": true}
118118
```
119119

120+
`start_runners` is optional (default: `true`). When `false`, the server reads
121+
configs and collects actions without starting any extension runners. Use this
122+
when runner environments may not exist yet (e.g. before running `prepare-envs`).
123+
Actions are still available in the result so clients can validate the workspace.
124+
120125
**Result:**
121126

122127
```json
@@ -127,6 +132,31 @@ and starts extension runners.
127132
}
128133
```
129134

135+
`status` values: `"CONFIG_VALID"`, `"CONFIG_INVALID"`
136+
137+
---
138+
139+
#### `workspace/startRunners`
140+
141+
Start extension runners for all (or specified) projects. Only starts runners
142+
that are not already running — complements existing runner state rather than
143+
replacing it. Also resolves preset-defined actions so that `actions/run` can
144+
find them.
145+
146+
- **Type:** request
147+
- **Clients:** CLI
148+
- **Status:** implemented
149+
150+
**Params:**
151+
152+
```json
153+
{"projects": ["my_project"]}
154+
```
155+
156+
`projects` is optional. If omitted, starts runners for all projects.
157+
158+
**Result:** `{}`
159+
130160
---
131161

132162
#### `workspace/setConfigOverrides`
@@ -569,6 +599,48 @@ Restart an extension runner. Optionally start in debug mode.
569599

570600
---
571601

602+
#### `runners/checkEnv`
603+
604+
Check whether the named environment for a project is valid (i.e. the virtualenv
605+
exists and its dependencies are correctly installed).
606+
607+
- **Type:** request
608+
- **Clients:** CLI
609+
- **Status:** implemented
610+
611+
**Params:**
612+
613+
```json
614+
{"project": "my_project", "env_name": "dev_workspace"}
615+
```
616+
617+
**Result:**
618+
619+
```json
620+
{"valid": true}
621+
```
622+
623+
---
624+
625+
#### `runners/removeEnv`
626+
627+
Remove the named environment for a project. If a runner is currently using that
628+
environment, it is stopped first.
629+
630+
- **Type:** request
631+
- **Clients:** CLI
632+
- **Status:** implemented
633+
634+
**Params:**
635+
636+
```json
637+
{"project": "my_project", "env_name": "dev_workspace"}
638+
```
639+
640+
**Result:** `{}`
641+
642+
---
643+
572644
### `server/` — Server Lifecycle & Notifications
573645

574646
#### `server/shutdown`

src/finecode/api_client.py

Lines changed: 39 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -236,9 +236,45 @@ async def run_action(
236236
body["params"] = params
237237
return await self.request("actions/run", body)
238238

239-
async def add_dir(self, dir_path: pathlib.Path) -> dict:
240-
"""Add a workspace directory. Returns {projects: [...]}."""
241-
return await self.request("workspace/addDir", {"dir_path": str(dir_path)})
239+
async def add_dir(self, dir_path: pathlib.Path, start_runners: bool = True) -> dict:
240+
"""Add a workspace directory. Returns {projects: [...]}.
241+
242+
When ``start_runners=False`` the server reads configs and collects
243+
actions without starting any extension runners. Use this when runner
244+
environments may not exist yet (e.g. before ``prepare-envs``).
245+
"""
246+
return await self.request(
247+
"workspace/addDir",
248+
{"dir_path": str(dir_path), "start_runners": start_runners},
249+
)
250+
251+
async def start_runners(self, projects: list[str] | None = None) -> None:
252+
"""Start extension runners for all (or specified) projects.
253+
254+
Complements any already-running runners — only missing runners are
255+
started. Also resolves presets so ``project.actions`` is up to date.
256+
"""
257+
params: dict = {}
258+
if projects is not None:
259+
params["projects"] = projects
260+
await self.request("workspace/startRunners", params)
261+
262+
async def check_env(self, project: str, env_name: str) -> bool:
263+
"""Return whether the named environment is valid for a project."""
264+
result = await self.request(
265+
"runners/checkEnv", {"project": project, "env_name": env_name}
266+
)
267+
if not isinstance(result, dict) or "valid" not in result:
268+
raise ApiResponseError(
269+
"runners/checkEnv", f"missing 'valid' field, got {result!r}"
270+
)
271+
return result["valid"]
272+
273+
async def remove_env(self, project: str, env_name: str) -> None:
274+
"""Remove the named environment for a project."""
275+
await self.request(
276+
"runners/removeEnv", {"project": project, "env_name": env_name}
277+
)
242278

243279
async def remove_dir(self, dir_path: pathlib.Path) -> None:
244280
"""Remove a workspace directory."""

src/finecode/api_server/api_server.py

Lines changed: 125 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -241,12 +241,22 @@ async def _handle_find_project_for_file(
241241
async def _handle_add_dir(
242242
params: dict | None, ws_context: context.WorkspaceContext
243243
) -> dict:
244-
"""Add a workspace directory. Discovers projects, reads configs, starts runners."""
245-
from finecode.api_server.config import read_configs
244+
"""Add a workspace directory. Discovers projects, reads configs, starts runners.
245+
246+
Params:
247+
dir_path: str - absolute path to the workspace directory
248+
start_runners: bool - whether to start extension runners (default true).
249+
When false, configs are read and actions collected without starting any
250+
runners. Useful when runner environments may not exist yet (e.g. before
251+
running prepare-envs).
252+
"""
253+
from finecode.api_server.config import collect_actions, read_configs
246254
from finecode.api_server.runner import runner_manager
247255
from finecode.api_server.runner.runner_client import RunnerStatus
248256

257+
params = params or {}
249258
dir_path = pathlib.Path(params["dir_path"])
259+
start_runners: bool = params.get("start_runners", True)
250260
logger.trace(f"Add ws dir: {dir_path}")
251261

252262
if dir_path in ws_context.ws_dirs_paths:
@@ -260,6 +270,21 @@ async def _handle_add_dir(
260270
project=project, ws_context=ws_context, resolve_presets=False
261271
)
262272

273+
if not start_runners:
274+
# Collect actions directly from raw config without needing runners.
275+
from finecode.api_server.config import config_models
276+
for project in new_projects:
277+
if project.status == domain.ProjectStatus.CONFIG_VALID:
278+
try:
279+
collect_actions.collect_actions(
280+
project_path=project.dir_path, ws_context=ws_context
281+
)
282+
except config_models.ConfigurationError as exc:
283+
logger.warning(
284+
f"Failed to collect actions for {project.name}: {exc.message}"
285+
)
286+
return {"projects": [_project_to_dict(p) for p in new_projects]}
287+
263288
try:
264289
await runner_manager.start_runners_with_presets(
265290
projects=new_projects,
@@ -492,6 +517,101 @@ async def _handle_runners_restart(
492517
return {}
493518

494519

520+
async def _handle_start_runners(
521+
params: dict | None, ws_context: context.WorkspaceContext
522+
) -> dict:
523+
"""Start extension runners for all (or specified) projects.
524+
525+
Complements any runners already running — only missing runners are started.
526+
Resolves presets so that ``project.actions`` reflects preset-defined handlers.
527+
528+
Params: ``{"projects": ["project_name", ...]}`` (optional, default: all projects)
529+
Result: ``{}``
530+
"""
531+
from finecode.api_server.runner import runner_manager
532+
533+
params = params or {}
534+
project_names: list[str] | None = params.get("projects")
535+
536+
projects = list(ws_context.ws_projects.values())
537+
if project_names is not None:
538+
projects = [p for p in projects if p.name in project_names]
539+
540+
try:
541+
await runner_manager.start_runners_with_presets(
542+
projects=projects,
543+
ws_context=ws_context,
544+
)
545+
except runner_manager.RunnerFailedToStart as exc:
546+
raise ValueError(f"Starting runners failed: {exc.message}") from exc
547+
548+
return {}
549+
550+
551+
async def _handle_runners_check_env(
552+
params: dict | None, ws_context: context.WorkspaceContext
553+
) -> dict:
554+
"""Check whether an environment is valid for a given project.
555+
556+
Params: ``{"project": "project_name", "env_name": "dev_workspace"}``
557+
Result: ``{"valid": bool}``
558+
"""
559+
from finecode.api_server.runner import runner_manager
560+
561+
params = params or {}
562+
project_name = params.get("project")
563+
env_name = params.get("env_name")
564+
565+
if not project_name or not env_name:
566+
raise ValueError("project and env_name are required")
567+
568+
project = next(
569+
(p for p in ws_context.ws_projects.values() if p.name == project_name), None
570+
)
571+
if project is None:
572+
raise ValueError(f"Project '{project_name}' not found")
573+
574+
valid = await runner_manager.check_runner(
575+
runner_dir=project.dir_path, env_name=env_name
576+
)
577+
return {"valid": valid}
578+
579+
580+
async def _handle_runners_remove_env(
581+
params: dict | None, ws_context: context.WorkspaceContext
582+
) -> dict:
583+
"""Remove an environment for a given project.
584+
585+
Stops the runner if running, then deletes the environment directory.
586+
587+
Params: ``{"project": "project_name", "env_name": "dev_workspace"}``
588+
Result: ``{}``
589+
"""
590+
from finecode.api_server.runner import runner_manager
591+
592+
params = params or {}
593+
project_name = params.get("project")
594+
env_name = params.get("env_name")
595+
596+
if not project_name or not env_name:
597+
raise ValueError("project and env_name are required")
598+
599+
project = next(
600+
(p for p in ws_context.ws_projects.values() if p.name == project_name), None
601+
)
602+
if project is None:
603+
raise ValueError(f"Project '{project_name}' not found")
604+
605+
# Stop the runner if it is currently running.
606+
runners = ws_context.ws_projects_extension_runners.get(project.dir_path, {})
607+
runner = runners.get(env_name)
608+
if runner is not None:
609+
await runner_manager.stop_extension_runner(runner=runner)
610+
611+
runner_manager.remove_runner_venv(runner_dir=project.dir_path, env_name=env_name)
612+
return {}
613+
614+
495615
async def _handle_server_reset(
496616
params: dict | None, ws_context: context.WorkspaceContext
497617
) -> dict:
@@ -814,6 +934,7 @@ async def _handle_run_with_partial_results_task(
814934
"workspace/removeDir": _handle_remove_dir,
815935
"workspace/setConfigOverrides": _handle_set_config_overrides,
816936
"workspace/getProjectRawConfig": _handle_get_project_raw_config,
937+
"workspace/startRunners": _handle_start_runners,
817938
# actions/
818939
"actions/list": _handle_list_actions,
819940
"actions/getTree": _handle_get_tree,
@@ -824,6 +945,8 @@ async def _handle_run_with_partial_results_task(
824945
# runners:
825946
"runners/list": _handle_runners_list,
826947
"runners/restart": _handle_runners_restart,
948+
"runners/checkEnv": _handle_runners_check_env,
949+
"runners/removeEnv": _handle_runners_remove_env,
827950
# server/
828951
"server/reset": _handle_server_reset,
829952
"server/shutdown": _stub("server/shutdown"),

src/finecode/cli.py

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -387,7 +387,8 @@ def run(ctx) -> None:
387387
@click.option("--trace", "trace", is_flag=True, default=False)
388388
@click.option("--debug", "debug", is_flag=True, default=False)
389389
@click.option("--recreate", "recreate", is_flag=True, default=False)
390-
def prepare_envs(trace: bool, debug: bool, recreate: bool) -> None:
390+
@click.option("--shared-server", "shared_server", is_flag=True, default=False)
391+
def prepare_envs(trace: bool, debug: bool, recreate: bool, shared_server: bool) -> None:
391392
"""
392393
`prepare-envs` should be called from workspace/project root directory.
393394
"""
@@ -407,11 +408,13 @@ def prepare_envs(trace: bool, debug: bool, recreate: bool) -> None:
407408
try:
408409
asyncio.run(
409410
prepare_envs_cmd.prepare_envs(
410-
workdir_path=pathlib.Path(os.getcwd()), recreate=recreate
411+
workdir_path=pathlib.Path(os.getcwd()),
412+
recreate=recreate,
413+
own_server=not shared_server,
411414
)
412415
)
413416
except prepare_envs_cmd.PrepareEnvsFailed as exception:
414-
click.echo(exception.args[0], err=True)
417+
click.echo(exception.message, err=True)
415418
sys.exit(1)
416419
except Exception as exception:
417420
logger.exception(exception)

0 commit comments

Comments
 (0)