Skip to content

Commit 0842def

Browse files
committed
Initial implementation of WAL in WM
1 parent 72ee70d commit 0842def

8 files changed

Lines changed: 431 additions & 7 deletions

File tree

docs/cli.md

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,11 +55,14 @@ python -m finecode run [options] <action> [<action> ...] [payload] [--config.<ke
5555
| `--project=<name>` | Run only in this project (matched by `[project].name` from `pyproject.toml`). Repeatable for multiple projects. |
5656
| `--concurrently` | Run actions concurrently within each project |
5757
| `--shared-server` | Connect to the shared persistent WM Server instead of starting a dedicated one |
58+
| `--wal` | Enable WM write-ahead log (WAL) for the dedicated WM server started by this run command |
5859
| `--log-level=<level>` | Set log level: `TRACE`, `DEBUG`, `INFO`, `WARNING`, `ERROR` (default: `INFO`) |
5960
| `--no-env-config` | Ignore `FINECODE_CONFIG_*` environment variables |
6061
| `--no-save-results` | Do not write action results to the cache directory |
6162
| `--dev-env=<env>` | Override the detected dev environment. One of: `ai`, `ci`, `cli`, `ide`, `precommit` (default: auto-detected — see [Dev environment detection](#dev-environment-detection)) |
6263

64+
WAL environment variable and storage settings are shared with `start-wm-server` — see [`start-wm-server`](#start-wm-server) for details.
65+
6366
### Payload
6467

6568
Named parameters passed to the action payload. All must use `--<name>=<value>` form:
@@ -88,6 +91,7 @@ See [Configuration](configuration.md) for full details on config precedence.
8891
- With no `--project`: FineCode treats `cwd` (or `--workdir`) as the workspace root, discovers all projects, and runs the action in each project that defines it.
8992
- With `--project`: the action must exist in every specified project.
9093
- Action results are saved to `<venv>/cache/finecode/results/<action>.json` (one entry per project path).
94+
- WAL options on `run` apply only when FineCode starts a dedicated WM server (default mode). In `--shared-server` mode, configure WAL on the shared WM server process.
9195

9296
### Examples
9397

@@ -227,12 +231,23 @@ Typically started automatically by MCP-compatible clients (for example, Claude C
227231
Start the FineCode Workspace Manager Server standalone (TCP JSON-RPC), listen for client connections. Shuts down after the last client disconnects and the disconnect timeout expires.
228232

229233
```text
230-
python -m finecode start-wm-server [--log-level=<level>] [--disconnect-timeout=<seconds>]
234+
python -m finecode start-wm-server [--log-level=<level>] [--disconnect-timeout=<seconds>] [--wal]
231235
```
232236

233237
| Option | Description |
234238
| --- | --- |
235239
| `--log-level=<level>` | Set log level: `TRACE`, `DEBUG`, `INFO`, `WARNING`, `ERROR` (default: `INFO`) |
236240
| `--disconnect-timeout=<seconds>` | Seconds to wait after the last client disconnects before shutting down (default: 30) |
241+
| `--wal` | Enable WM write-ahead log (WAL) for run lifecycle events. |
242+
243+
Environment variable equivalent:
244+
245+
- `FINECODE_WAL_ENABLED=1` (or `true`/`yes`/`on`)
246+
247+
WAL storage and retention are fixed in this version:
248+
249+
- WAL directory: `<venv>/state/finecode/wal/wm`
250+
- Max segment size: `1048576` bytes
251+
- Retention: last `20` segment files
237252

238253
Usually started automatically by `start-lsp` or `start-mcp`. Can also be started manually for debugging.

src/finecode/cli.py

Lines changed: 50 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,13 @@
1717
_VALID_DEV_ENVS = {"ide", "cli", "ai", "precommit", "ci"}
1818

1919

20+
def _parse_env_bool(name: str, default: bool) -> bool:
21+
raw = os.environ.get(name)
22+
if raw is None:
23+
return default
24+
return raw.strip().lower() in {"1", "true", "yes", "on"}
25+
26+
2027
def detect_dev_env() -> str:
2128
"""Detect dev environment from context. CI env var overrides the default 'cli'."""
2229
if os.environ.get("CI"):
@@ -273,6 +280,7 @@ def run(ctx) -> None:
273280
map_payload_fields: set[str] = set()
274281
shared_server: bool = False
275282
dev_env: str = detect_dev_env()
283+
wal_enabled: bool | None = None
276284

277285
# finecode run parameters
278286
for arg in args:
@@ -304,6 +312,8 @@ def run(ctx) -> None:
304312
map_payload_fields = {f.replace("-", "_") for f in fields.split(",")}
305313
elif arg == "--shared-server":
306314
shared_server = True
315+
elif arg == "--wal":
316+
wal_enabled = True
307317
elif arg.startswith("--dev-env"):
308318
dev_env = arg.removeprefix("--dev-env=")
309319
if dev_env not in _VALID_DEV_ENVS:
@@ -318,6 +328,16 @@ def run(ctx) -> None:
318328

319329
logger_utils.init_logger(log_name="cli", log_level=log_level, stdout=True)
320330

331+
if wal_enabled is None:
332+
wal_enabled = _parse_env_bool("FINECODE_WAL_ENABLED", False)
333+
334+
if shared_server and wal_enabled:
335+
click.echo(
336+
"Warning: --wal is ignored in --shared-server mode. "
337+
"Enable WAL on the shared WM server process itself.",
338+
err=True,
339+
)
340+
321341
# Parse handler config from env vars
322342
handler_config_overrides: dict[str, dict[str, dict[str, str]]] = {}
323343
if not no_env_config:
@@ -390,6 +410,7 @@ def run(ctx) -> None:
390410
own_server=not shared_server,
391411
log_level=log_level,
392412
dev_env=dev_env,
413+
wal_enabled=wal_enabled,
393414
)
394415
)
395416
click.echo(result.output)
@@ -576,14 +597,40 @@ def start_mcp(workdir: str | None, log_level: str, wm_port_file: str | None):
576597
show_default=True,
577598
help="Seconds to wait after the last client disconnects before shutting down.",
578599
)
579-
def start_wm_server(log_level: str, port_file: str | None, disconnect_timeout: int):
600+
@click.option(
601+
"--wal",
602+
"wal_enabled",
603+
is_flag=True,
604+
default=None,
605+
help="Enable WM write-ahead log (WAL). Can also be enabled with FINECODE_WAL_ENABLED=1.",
606+
)
607+
def start_wm_server(
608+
log_level: str,
609+
port_file: str | None,
610+
disconnect_timeout: int,
611+
wal_enabled: bool | None,
612+
):
580613
"""Start the FineCode WM Server standalone (TCP JSON-RPC). Auto-stops when all clients disconnect."""
581-
from finecode.wm_server import wm_server
614+
from finecode.wm_server import wal, wm_server
582615

583616
log_file_path = logger_utils.init_logger(log_name="wm_server", log_level=log_level, stdout=False)
584617
wm_server._log_file_path = log_file_path
585618
port_file_path = pathlib.Path(port_file) if port_file else None
586-
asyncio.run(wm_server.start_standalone(port_file=port_file_path, disconnect_timeout=disconnect_timeout))
619+
620+
env_wal_enabled = _parse_env_bool("FINECODE_WAL_ENABLED", False)
621+
final_wal_enabled = wal_enabled if wal_enabled is not None else env_wal_enabled
622+
623+
wal_config = wal.WalConfig(
624+
enabled=final_wal_enabled,
625+
)
626+
627+
asyncio.run(
628+
wm_server.start_standalone(
629+
port_file=port_file_path,
630+
disconnect_timeout=disconnect_timeout,
631+
wal_config=wal_config,
632+
)
633+
)
587634

588635

589636
if __name__ == "__main__":

src/finecode/cli_app/commands/run_cmd.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -89,11 +89,16 @@ async def run_actions(
8989
own_server: bool = False,
9090
log_level: str = "INFO",
9191
dev_env: str = "cli",
92+
wal_enabled: bool = False,
9293
) -> utils.RunActionsResult:
9394
port_file = None
9495
try:
9596
if own_server:
96-
port_file = wm_lifecycle.start_own_server(workdir_path, log_level=log_level)
97+
port_file = wm_lifecycle.start_own_server(
98+
workdir_path,
99+
log_level=log_level,
100+
wal_enabled=wal_enabled,
101+
)
97102
try:
98103
port = await wm_lifecycle.wait_until_ready_from_file(port_file)
99104
except TimeoutError as exc:

src/finecode/wm_server/context.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99

1010
if TYPE_CHECKING:
1111
from finecode_jsonrpc._io_thread import AsyncIOThread
12+
from finecode.wm_server.wal import WalWriter
1213

1314

1415
@dataclass
@@ -48,6 +49,7 @@ class WorkspaceContext:
4849
cached_actions_by_id: dict[str, CachedAction] = field(default_factory=dict)
4950
# payload schema cache: project_path → {action_name: JSON Schema fragment | None}
5051
ws_action_schemas: dict[Path, dict[str, dict | None]] = field(default_factory=dict)
52+
wal_writer: WalWriter | None = None
5153

5254

5355
@dataclass

src/finecode/wm_server/services/run_service/proxy_utils.py

Lines changed: 71 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
from loguru import logger
1111

1212
from finecode import user_messages
13-
from finecode.wm_server import find_project, context, domain, domain_helpers
13+
from finecode.wm_server import find_project, context, domain, domain_helpers, wal
1414
from finecode.wm_server.runner import runner_manager
1515
from finecode.wm_server.runner import runner_client
1616
from finecode.wm_server.runner.runner_manager import RunnerFailedToStart
@@ -674,6 +674,7 @@ async def run_action(
674674
initialize_all_handlers: bool = False,
675675
progress_token: int | str | None = None,
676676
) -> RunActionResponse:
677+
wal_run_id = wal.new_wal_run_id()
677678
formatted_params = str(params)
678679
if len(formatted_params) > 100:
679680
formatted_params = f"{formatted_params[:100]}..."
@@ -685,11 +686,32 @@ async def run_action(
685686
_result_formats = result_formats
686687

687688
if project_def.status != domain.ProjectStatus.CONFIG_VALID:
689+
wal.emit_run_event(
690+
ws_context.wal_writer,
691+
event_type=wal.WalEventType.RUN_REJECTED,
692+
wal_run_id=wal_run_id,
693+
action_name=action_name,
694+
project_path=project_def.dir_path,
695+
run_trigger=run_trigger.value,
696+
dev_env=dev_env.value,
697+
payload=wal.RunRejectedPayload(reason="invalid_project_config"),
698+
)
688699
raise ActionRunFailed(
689700
f"Project {project_def.dir_path} has no valid configuration and finecode."
690701
+ " Please check logs."
691702
)
692703

704+
wal.emit_run_event(
705+
ws_context.wal_writer,
706+
event_type=wal.WalEventType.RUN_ACCEPTED,
707+
wal_run_id=wal_run_id,
708+
action_name=action_name,
709+
project_path=project_def.dir_path,
710+
run_trigger=run_trigger.value,
711+
dev_env=dev_env.value,
712+
payload=wal.RunAcceptedPayload(params_hash=wal.params_hash(params)),
713+
)
714+
693715
payload = params
694716

695717
# cases:
@@ -720,6 +742,7 @@ async def run_action(
720742
result_formats=_result_formats,
721743
initialize_all_handlers=initialize_all_handlers,
722744
progress_token=progress_token,
745+
wal_run_id=wal_run_id,
723746
)
724747
else:
725748
# TODO: concurrent vs sequential, this value should be taken from action config
@@ -741,6 +764,7 @@ async def run_action(
741764
result_formats=_result_formats,
742765
initialize_all_handlers=initialize_all_handlers,
743766
progress_token=progress_token,
767+
wal_run_id=wal_run_id,
744768
)
745769

746770
return response
@@ -757,7 +781,20 @@ async def _run_action_in_env_runner(
757781
result_formats: list[runner_client.RunResultFormat],
758782
initialize_all_handlers: bool = False,
759783
progress_token: int | str | None = None,
784+
wal_run_id: str | None = None,
760785
):
786+
effective_wal_run_id = wal_run_id or wal.new_wal_run_id()
787+
wal.emit_run_event(
788+
ws_context.wal_writer,
789+
event_type=wal.WalEventType.RUNNER_SELECTED,
790+
wal_run_id=effective_wal_run_id,
791+
action_name=action_name,
792+
project_path=project_def.dir_path,
793+
run_trigger=run_trigger.value,
794+
dev_env=dev_env.value,
795+
payload=wal.RunnerSelectedPayload(env_name=env_name),
796+
)
797+
761798
try:
762799
runner = await runner_manager.get_or_start_runner(
763800
project_def=project_def,
@@ -778,13 +815,46 @@ async def _run_action_in_env_runner(
778815
}
779816
if progress_token is not None:
780817
options["progress_token"] = progress_token
818+
wal.emit_run_event(
819+
ws_context.wal_writer,
820+
event_type=wal.WalEventType.RUN_DISPATCHED,
821+
wal_run_id=effective_wal_run_id,
822+
action_name=action_name,
823+
project_path=project_def.dir_path,
824+
run_trigger=run_trigger.value,
825+
dev_env=dev_env.value,
826+
payload=wal.RunDispatchedPayload(
827+
runner_id=runner.readable_id,
828+
env_name=env_name,
829+
),
830+
)
781831
response = await runner_client.run_action(
782832
runner=runner,
783833
action_name=action_name,
784834
params=payload,
785835
options=options,
786836
)
837+
wal.emit_run_event(
838+
ws_context.wal_writer,
839+
event_type=wal.WalEventType.RUN_COMPLETED,
840+
wal_run_id=effective_wal_run_id,
841+
action_name=action_name,
842+
project_path=project_def.dir_path,
843+
run_trigger=run_trigger.value,
844+
dev_env=dev_env.value,
845+
payload=wal.RunCompletedPayload(return_code=response.return_code),
846+
)
787847
except runner_client.BaseRunnerRequestException as error:
848+
wal.emit_run_event(
849+
ws_context.wal_writer,
850+
event_type=wal.WalEventType.RUN_FAILED,
851+
wal_run_id=effective_wal_run_id,
852+
action_name=action_name,
853+
project_path=project_def.dir_path,
854+
run_trigger=run_trigger.value,
855+
dev_env=dev_env.value,
856+
payload=wal.RunFailedPayload(error=error.message, env_name=env_name),
857+
)
788858
await user_messages.error(
789859
f"Action {action_name} failed in {runner.readable_id}: {error.message} . Log file: {runner.logs_path}"
790860
)

0 commit comments

Comments
 (0)