From c33e01ee621aece07d2d1f614a261c02628fb4cf Mon Sep 17 00:00:00 2001 From: MiguelPF Date: Wed, 18 Mar 2026 10:11:01 +0100 Subject: [PATCH 1/2] fix(cron): scope cron job store to workspace instead of global directory Replace `get_cron_dir()` with `config.workspace_path / "cron"` so each workspace keeps its own `jobs.json`. This lets users run multiple nanobot instances with independent cron schedules without cross-talk. Co-Authored-By: Claude Opus 4.6 --- nanobot/cli/commands.py | 10 ++++------ tests/test_commands.py | 6 +----- 2 files changed, 5 insertions(+), 11 deletions(-) diff --git a/nanobot/cli/commands.py b/nanobot/cli/commands.py index 0d4bb3d..cde1436 100644 --- a/nanobot/cli/commands.py +++ b/nanobot/cli/commands.py @@ -465,7 +465,6 @@ def gateway( from nanobot.agent.loop import AgentLoop from nanobot.bus.queue import MessageBus from nanobot.channels.manager import ChannelManager - from nanobot.config.paths import get_cron_dir from nanobot.cron.service import CronService from nanobot.cron.types import CronJob from nanobot.heartbeat.service import HeartbeatService @@ -485,8 +484,8 @@ def gateway( provider = _make_provider(config) session_manager = SessionManager(config.workspace_path) - # Create cron service first (callback set after agent creation) - cron_store_path = get_cron_dir() / "jobs.json" + # Create cron service with workspace-scoped store + cron_store_path = config.workspace_path / "cron" / "jobs.json" cron = CronService(cron_store_path) # Create agent with cron service @@ -663,7 +662,6 @@ def agent( from nanobot.agent.loop import AgentLoop from nanobot.bus.queue import MessageBus - from nanobot.config.paths import get_cron_dir from nanobot.cron.service import CronService config = _load_runtime_config(config, workspace) @@ -673,8 +671,8 @@ def agent( bus = MessageBus() provider = _make_provider(config) - # Create cron service for tool usage (no callback needed for CLI unless running) - cron_store_path = get_cron_dir() / "jobs.json" + # Create cron service with workspace-scoped store + cron_store_path = config.workspace_path / "cron" / "jobs.json" cron = CronService(cron_store_path) if logs: diff --git a/tests/test_commands.py b/tests/test_commands.py index a820e77..fcb2f6a 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -275,10 +275,8 @@ def mock_agent_runtime(tmp_path): """Mock agent command dependencies for focused CLI tests.""" config = Config() config.agents.defaults.workspace = str(tmp_path / "default-workspace") - cron_dir = tmp_path / "data" / "cron" with patch("nanobot.config.loader.load_config", return_value=config) as mock_load_config, \ - patch("nanobot.config.paths.get_cron_dir", return_value=cron_dir), \ patch("nanobot.cli.commands.sync_workspace_templates") as mock_sync_templates, \ patch("nanobot.cli.commands._make_provider", return_value=object()), \ patch("nanobot.cli.commands._print_agent_response") as mock_print_response, \ @@ -351,7 +349,6 @@ def test_agent_config_sets_active_path(monkeypatch, tmp_path: Path) -> None: lambda path: seen.__setitem__("config_path", path), ) monkeypatch.setattr("nanobot.config.loader.load_config", lambda _path=None: config) - monkeypatch.setattr("nanobot.config.paths.get_cron_dir", lambda: config_file.parent / "cron") monkeypatch.setattr("nanobot.cli.commands.sync_workspace_templates", lambda _path: None) monkeypatch.setattr("nanobot.cli.commands._make_provider", lambda _config: object()) monkeypatch.setattr("nanobot.bus.queue.MessageBus", lambda: object()) @@ -508,7 +505,6 @@ def test_gateway_uses_config_directory_for_cron_store(monkeypatch, tmp_path: Pat monkeypatch.setattr("nanobot.config.loader.set_config_path", lambda _path: None) monkeypatch.setattr("nanobot.config.loader.load_config", lambda _path=None: config) - monkeypatch.setattr("nanobot.config.paths.get_cron_dir", lambda: config_file.parent / "cron") monkeypatch.setattr("nanobot.cli.commands.sync_workspace_templates", lambda _path: None) monkeypatch.setattr("nanobot.cli.commands._make_provider", lambda _config: object()) monkeypatch.setattr("nanobot.bus.queue.MessageBus", lambda: object()) @@ -524,7 +520,7 @@ def test_gateway_uses_config_directory_for_cron_store(monkeypatch, tmp_path: Pat result = runner.invoke(app, ["gateway", "--config", str(config_file)]) assert isinstance(result.exception, _StopGateway) - assert seen["cron_store"] == config_file.parent / "cron" / "jobs.json" + assert seen["cron_store"] == config.workspace_path / "cron" / "jobs.json" def test_gateway_uses_configured_port_when_cli_flag_is_missing(monkeypatch, tmp_path: Path) -> None: From 4e56481f0ba59ce53bfed03e01c941722fdcae20 Mon Sep 17 00:00:00 2001 From: MiguelPF Date: Wed, 18 Mar 2026 10:16:06 +0100 Subject: [PATCH 2/2] add one-time migration for legacy global cron store When upgrading, if jobs.json exists at the old global path and not yet at the workspace path, move it automatically. Prevents silent loss of existing cron jobs. Co-Authored-By: Claude Opus 4.6 --- nanobot/cli/commands.py | 18 ++++++++++++++++++ tests/test_commands.py | 41 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 59 insertions(+) diff --git a/nanobot/cli/commands.py b/nanobot/cli/commands.py index cde1436..17fe7b8 100644 --- a/nanobot/cli/commands.py +++ b/nanobot/cli/commands.py @@ -449,6 +449,18 @@ def _print_deprecated_memory_window_notice(config: Config) -> None: ) +def _migrate_cron_store(config: "Config") -> None: + """One-time migration: move legacy global cron store into the workspace.""" + from nanobot.config.paths import get_cron_dir + + legacy_path = get_cron_dir() / "jobs.json" + new_path = config.workspace_path / "cron" / "jobs.json" + if legacy_path.is_file() and not new_path.exists(): + new_path.parent.mkdir(parents=True, exist_ok=True) + import shutil + shutil.move(str(legacy_path), str(new_path)) + + # ============================================================================ # Gateway / Server # ============================================================================ @@ -484,6 +496,9 @@ def gateway( provider = _make_provider(config) session_manager = SessionManager(config.workspace_path) + # Migrate legacy global cron store into workspace (one-time) + _migrate_cron_store(config) + # Create cron service with workspace-scoped store cron_store_path = config.workspace_path / "cron" / "jobs.json" cron = CronService(cron_store_path) @@ -671,6 +686,9 @@ def agent( bus = MessageBus() provider = _make_provider(config) + # Migrate legacy global cron store into workspace (one-time) + _migrate_cron_store(config) + # Create cron service with workspace-scoped store cron_store_path = config.workspace_path / "cron" / "jobs.json" cron = CronService(cron_store_path) diff --git a/tests/test_commands.py b/tests/test_commands.py index fcb2f6a..9875644 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -523,6 +523,47 @@ def test_gateway_uses_config_directory_for_cron_store(monkeypatch, tmp_path: Pat assert seen["cron_store"] == config.workspace_path / "cron" / "jobs.json" +def test_migrate_cron_store_moves_legacy_file(tmp_path: Path) -> None: + """Legacy global jobs.json is moved into the workspace on first run.""" + from nanobot.cli.commands import _migrate_cron_store + + legacy_dir = tmp_path / "global" / "cron" + legacy_dir.mkdir(parents=True) + legacy_file = legacy_dir / "jobs.json" + legacy_file.write_text('{"jobs": []}') + + config = Config() + config.agents.defaults.workspace = str(tmp_path / "workspace") + workspace_cron = config.workspace_path / "cron" / "jobs.json" + + with patch("nanobot.config.paths.get_cron_dir", return_value=legacy_dir): + _migrate_cron_store(config) + + assert workspace_cron.exists() + assert workspace_cron.read_text() == '{"jobs": []}' + assert not legacy_file.exists() + + +def test_migrate_cron_store_skips_when_workspace_file_exists(tmp_path: Path) -> None: + """Migration does not overwrite an existing workspace cron store.""" + from nanobot.cli.commands import _migrate_cron_store + + legacy_dir = tmp_path / "global" / "cron" + legacy_dir.mkdir(parents=True) + (legacy_dir / "jobs.json").write_text('{"old": true}') + + config = Config() + config.agents.defaults.workspace = str(tmp_path / "workspace") + workspace_cron = config.workspace_path / "cron" / "jobs.json" + workspace_cron.parent.mkdir(parents=True) + workspace_cron.write_text('{"new": true}') + + with patch("nanobot.config.paths.get_cron_dir", return_value=legacy_dir): + _migrate_cron_store(config) + + assert workspace_cron.read_text() == '{"new": true}' + + def test_gateway_uses_configured_port_when_cli_flag_is_missing(monkeypatch, tmp_path: Path) -> None: config_file = tmp_path / "instance" / "config.json" config_file.parent.mkdir(parents=True)