refactor: unify instance path resolution and preserve workspace override
This commit is contained in:
192
README.md
192
README.md
@@ -905,7 +905,7 @@ MCP tools are automatically discovered and registered on startup. The LLM can us
|
||||
|
||||
## Multiple Instances
|
||||
|
||||
Run multiple nanobot instances simultaneously with complete isolation. Each instance has its own configuration, workspace, cron jobs, logs, and media storage.
|
||||
Run multiple nanobot instances simultaneously with separate configs and runtime data. Use `--config` as the main entrypoint, and optionally use `--workspace` to override the workspace for a specific run.
|
||||
|
||||
### Quick Start
|
||||
|
||||
@@ -920,35 +920,31 @@ nanobot gateway --config ~/.nanobot-discord/config.json
|
||||
nanobot gateway --config ~/.nanobot-feishu/config.json --port 18792
|
||||
```
|
||||
|
||||
### Complete Isolation
|
||||
### Path Resolution
|
||||
|
||||
When using `--config` parameter, nanobot automatically derives the data directory from the config file path, ensuring complete isolation:
|
||||
When using `--config`, nanobot derives its runtime data directory from the config file location. The workspace still comes from `agents.defaults.workspace` unless you override it with `--workspace`.
|
||||
|
||||
| Component | Isolation | Example |
|
||||
|-----------|-----------|---------|
|
||||
| **Config** | Separate config files | `~/.nanobot-A/config.json`, `~/.nanobot-B/config.json` |
|
||||
| **Workspace** | Independent memory, sessions, skills | `~/.nanobot-A/workspace/`, `~/.nanobot-B/workspace/` |
|
||||
| **Cron Jobs** | Separate job storage | `~/.nanobot-A/cron/`, `~/.nanobot-B/cron/` |
|
||||
| **Logs** | Independent log files | `~/.nanobot-A/logs/`, `~/.nanobot-B/logs/` |
|
||||
| **Media** | Separate media storage | `~/.nanobot-A/media/`, `~/.nanobot-B/media/` |
|
||||
| Component | Resolved From | Example |
|
||||
|-----------|---------------|---------|
|
||||
| **Config** | `--config` path | `~/.nanobot-A/config.json` |
|
||||
| **Workspace** | `--workspace` or config | `~/.nanobot-A/workspace/` |
|
||||
| **Cron Jobs** | config directory | `~/.nanobot-A/cron/` |
|
||||
| **Media / runtime state** | config directory | `~/.nanobot-A/media/` |
|
||||
|
||||
### Setup Example
|
||||
### How It Works
|
||||
|
||||
**1. Create directory structure for each instance:**
|
||||
- `--config` selects which config file to load
|
||||
- By default, the workspace comes from `agents.defaults.workspace` in that config
|
||||
- If you pass `--workspace`, it overrides the workspace from the config file
|
||||
|
||||
```bash
|
||||
# Instance A
|
||||
mkdir -p ~/.nanobot-telegram/{workspace,cron,logs,media}
|
||||
cp ~/.nanobot/config.json ~/.nanobot-telegram/config.json
|
||||
### Minimal Setup
|
||||
|
||||
# Instance B
|
||||
mkdir -p ~/.nanobot-discord/{workspace,cron,logs,media}
|
||||
cp ~/.nanobot/config.json ~/.nanobot-discord/config.json
|
||||
```
|
||||
1. Copy your base config into a new instance directory.
|
||||
2. Set a different `agents.defaults.workspace` for that instance.
|
||||
3. Start the instance with `--config`.
|
||||
|
||||
**2. Configure each instance:**
|
||||
Example config:
|
||||
|
||||
Edit `~/.nanobot-telegram/config.json`:
|
||||
```json
|
||||
{
|
||||
"agents": {
|
||||
@@ -969,160 +965,32 @@ Edit `~/.nanobot-telegram/config.json`:
|
||||
}
|
||||
```
|
||||
|
||||
Edit `~/.nanobot-discord/config.json`:
|
||||
```json
|
||||
{
|
||||
"agents": {
|
||||
"defaults": {
|
||||
"workspace": "~/.nanobot-discord/workspace",
|
||||
"model": "anthropic/claude-opus-4"
|
||||
}
|
||||
},
|
||||
"channels": {
|
||||
"discord": {
|
||||
"enabled": true,
|
||||
"token": "YOUR_DISCORD_BOT_TOKEN"
|
||||
}
|
||||
},
|
||||
"gateway": {
|
||||
"port": 18791
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**3. Start instances:**
|
||||
Start separate instances:
|
||||
|
||||
```bash
|
||||
# Terminal 1
|
||||
nanobot gateway --config ~/.nanobot-telegram/config.json
|
||||
|
||||
# Terminal 2
|
||||
nanobot gateway --config ~/.nanobot-discord/config.json
|
||||
```
|
||||
|
||||
### Use Cases
|
||||
|
||||
- **Multiple Chat Platforms**: Run separate bots for Telegram, Discord, Feishu, etc.
|
||||
- **Different Models**: Test different LLM models (Claude, GPT, DeepSeek) simultaneously
|
||||
- **Role Separation**: Dedicated instances for different purposes (personal assistant, work bot, research agent)
|
||||
- **Multi-Tenant**: Serve multiple users/teams with isolated configurations
|
||||
|
||||
### Management Scripts
|
||||
|
||||
For production deployments, create management scripts for each instance:
|
||||
Override workspace for one-off runs when needed:
|
||||
|
||||
```bash
|
||||
#!/bin/bash
|
||||
# manage-telegram.sh
|
||||
|
||||
INSTANCE_NAME="telegram"
|
||||
CONFIG_FILE="$HOME/.nanobot-telegram/config.json"
|
||||
LOG_FILE="$HOME/.nanobot-telegram/logs/stderr.log"
|
||||
|
||||
case "$1" in
|
||||
start)
|
||||
nohup nanobot gateway --config "$CONFIG_FILE" >> "$LOG_FILE" 2>&1 &
|
||||
echo "Started $INSTANCE_NAME instance (PID: $!)"
|
||||
;;
|
||||
stop)
|
||||
pkill -f "nanobot gateway.*$CONFIG_FILE"
|
||||
echo "Stopped $INSTANCE_NAME instance"
|
||||
;;
|
||||
restart)
|
||||
$0 stop
|
||||
sleep 2
|
||||
$0 start
|
||||
;;
|
||||
status)
|
||||
pgrep -f "nanobot gateway.*$CONFIG_FILE" > /dev/null
|
||||
if [ $? -eq 0 ]; then
|
||||
echo "$INSTANCE_NAME instance is running"
|
||||
else
|
||||
echo "$INSTANCE_NAME instance is not running"
|
||||
fi
|
||||
;;
|
||||
*)
|
||||
echo "Usage: $0 {start|stop|restart|status}"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
nanobot gateway --config ~/.nanobot-telegram/config.json --workspace /tmp/nanobot-telegram-test
|
||||
```
|
||||
|
||||
### systemd Service (Linux)
|
||||
### Common Use Cases
|
||||
|
||||
For automatic startup and crash recovery, create a systemd service for each instance:
|
||||
|
||||
```ini
|
||||
# ~/.config/systemd/user/nanobot-telegram.service
|
||||
[Unit]
|
||||
Description=Nanobot Telegram Instance
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
ExecStart=%h/.local/bin/nanobot gateway --config %h/.nanobot-telegram/config.json
|
||||
Restart=always
|
||||
RestartSec=10
|
||||
|
||||
[Install]
|
||||
WantedBy=default.target
|
||||
```
|
||||
|
||||
Enable and start:
|
||||
```bash
|
||||
systemctl --user daemon-reload
|
||||
systemctl --user enable --now nanobot-telegram
|
||||
systemctl --user enable --now nanobot-discord
|
||||
```
|
||||
|
||||
### launchd Service (macOS)
|
||||
|
||||
Create a plist file for each instance:
|
||||
|
||||
```xml
|
||||
<!-- ~/Library/LaunchAgents/com.nanobot.telegram.plist -->
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>Label</key>
|
||||
<string>com.nanobot.telegram</string>
|
||||
|
||||
<key>ProgramArguments</key>
|
||||
<array>
|
||||
<string>/path/to/nanobot</string>
|
||||
<string>gateway</string>
|
||||
<string>--config</string>
|
||||
<string>/Users/yourname/.nanobot-telegram/config.json</string>
|
||||
</array>
|
||||
|
||||
<key>RunAtLoad</key>
|
||||
<true/>
|
||||
|
||||
<key>KeepAlive</key>
|
||||
<true/>
|
||||
|
||||
<key>StandardOutPath</key>
|
||||
<string>/Users/yourname/.nanobot-telegram/logs/stdout.log</string>
|
||||
|
||||
<key>StandardErrorPath</key>
|
||||
<string>/Users/yourname/.nanobot-telegram/logs/stderr.log</string>
|
||||
</dict>
|
||||
</plist>
|
||||
```
|
||||
|
||||
Load the service:
|
||||
```bash
|
||||
launchctl load ~/Library/LaunchAgents/com.nanobot.telegram.plist
|
||||
launchctl load ~/Library/LaunchAgents/com.nanobot.discord.plist
|
||||
```
|
||||
- Run separate bots for Telegram, Discord, Feishu, and other platforms
|
||||
- Keep testing and production instances isolated
|
||||
- Use different models or providers for different teams
|
||||
- Serve multiple tenants with separate configs and runtime data
|
||||
|
||||
### Notes
|
||||
|
||||
- Each instance must use a different port (default: 18790)
|
||||
- Instances are completely independent — no shared state or cross-talk
|
||||
- You can run different LLM models, providers, and channel configurations per instance
|
||||
- Memory, sessions, and cron jobs are fully isolated between instances
|
||||
- Each instance must use a different port if they run at the same time
|
||||
- Use a different workspace per instance if you want isolated memory, sessions, and skills
|
||||
- `--workspace` overrides the workspace defined in the config file
|
||||
- Cron jobs and runtime media/state are derived from the config directory
|
||||
|
||||
## CLI Reference
|
||||
|
||||
|
||||
@@ -18,7 +18,6 @@ import qrcode from 'qrcode-terminal';
|
||||
import pino from 'pino';
|
||||
import { writeFile, mkdir } from 'fs/promises';
|
||||
import { join } from 'path';
|
||||
import { homedir } from 'os';
|
||||
import { randomBytes } from 'crypto';
|
||||
|
||||
const VERSION = '0.1.0';
|
||||
@@ -162,7 +161,7 @@ export class WhatsAppClient {
|
||||
|
||||
private async downloadMedia(msg: any, mimetype?: string, fileName?: string): Promise<string | null> {
|
||||
try {
|
||||
const mediaDir = join(homedir(), '.nanobot', 'media');
|
||||
const mediaDir = join(this.options.authDir, '..', 'media');
|
||||
await mkdir(mediaDir, { recursive: true });
|
||||
|
||||
const buffer = await downloadMediaMessage(msg, 'buffer', {}) as Buffer;
|
||||
|
||||
@@ -12,6 +12,7 @@ from loguru import logger
|
||||
from nanobot.bus.events import OutboundMessage
|
||||
from nanobot.bus.queue import MessageBus
|
||||
from nanobot.channels.base import BaseChannel
|
||||
from nanobot.config.paths import get_media_dir
|
||||
from nanobot.config.schema import DiscordConfig
|
||||
from nanobot.utils.helpers import split_message
|
||||
|
||||
@@ -289,7 +290,7 @@ class DiscordChannel(BaseChannel):
|
||||
|
||||
content_parts = [content] if content else []
|
||||
media_paths: list[str] = []
|
||||
media_dir = Path.home() / ".nanobot" / "media"
|
||||
media_dir = get_media_dir("discord")
|
||||
|
||||
for attachment in payload.get("attachments") or []:
|
||||
url = attachment.get("url")
|
||||
|
||||
@@ -14,6 +14,7 @@ from loguru import logger
|
||||
from nanobot.bus.events import OutboundMessage
|
||||
from nanobot.bus.queue import MessageBus
|
||||
from nanobot.channels.base import BaseChannel
|
||||
from nanobot.config.paths import get_media_dir
|
||||
from nanobot.config.schema import FeishuConfig
|
||||
|
||||
import importlib.util
|
||||
@@ -732,8 +733,7 @@ class FeishuChannel(BaseChannel):
|
||||
(file_path, content_text) - file_path is None if download failed
|
||||
"""
|
||||
loop = asyncio.get_running_loop()
|
||||
media_dir = Path.home() / ".nanobot" / "media"
|
||||
media_dir.mkdir(parents=True, exist_ok=True)
|
||||
media_dir = get_media_dir("feishu")
|
||||
|
||||
data, filename = None, None
|
||||
|
||||
|
||||
@@ -38,7 +38,7 @@ except ImportError as e:
|
||||
|
||||
from nanobot.bus.events import OutboundMessage
|
||||
from nanobot.channels.base import BaseChannel
|
||||
from nanobot.config.loader import get_data_dir
|
||||
from nanobot.config.paths import get_data_dir, get_media_dir
|
||||
from nanobot.utils.helpers import safe_filename
|
||||
|
||||
TYPING_NOTICE_TIMEOUT_MS = 30_000
|
||||
@@ -490,9 +490,7 @@ class MatrixChannel(BaseChannel):
|
||||
return False
|
||||
|
||||
def _media_dir(self) -> Path:
|
||||
d = get_data_dir() / "media" / "matrix"
|
||||
d.mkdir(parents=True, exist_ok=True)
|
||||
return d
|
||||
return get_media_dir("matrix")
|
||||
|
||||
@staticmethod
|
||||
def _event_source_content(event: RoomMessage) -> dict[str, Any]:
|
||||
|
||||
@@ -15,8 +15,8 @@ from loguru import logger
|
||||
from nanobot.bus.events import OutboundMessage
|
||||
from nanobot.bus.queue import MessageBus
|
||||
from nanobot.channels.base import BaseChannel
|
||||
from nanobot.config.paths import get_runtime_subdir
|
||||
from nanobot.config.schema import MochatConfig
|
||||
from nanobot.utils.helpers import get_data_path
|
||||
|
||||
try:
|
||||
import socketio
|
||||
@@ -224,7 +224,7 @@ class MochatChannel(BaseChannel):
|
||||
self._socket: Any = None
|
||||
self._ws_connected = self._ws_ready = False
|
||||
|
||||
self._state_dir = get_data_path() / "mochat"
|
||||
self._state_dir = get_runtime_subdir("mochat")
|
||||
self._cursor_path = self._state_dir / "session_cursors.json"
|
||||
self._session_cursor: dict[str, int] = {}
|
||||
self._cursor_save_task: asyncio.Task | None = None
|
||||
|
||||
@@ -15,6 +15,7 @@ from telegram.request import HTTPXRequest
|
||||
from nanobot.bus.events import OutboundMessage
|
||||
from nanobot.bus.queue import MessageBus
|
||||
from nanobot.channels.base import BaseChannel
|
||||
from nanobot.config.paths import get_media_dir
|
||||
from nanobot.config.schema import TelegramConfig
|
||||
from nanobot.utils.helpers import split_message
|
||||
|
||||
@@ -536,10 +537,7 @@ class TelegramChannel(BaseChannel):
|
||||
getattr(media_file, 'mime_type', None),
|
||||
getattr(media_file, 'file_name', None),
|
||||
)
|
||||
# Save to workspace/media/
|
||||
from pathlib import Path
|
||||
media_dir = Path.home() / ".nanobot" / "media"
|
||||
media_dir.mkdir(parents=True, exist_ok=True)
|
||||
media_dir = get_media_dir("telegram")
|
||||
|
||||
file_path = media_dir / f"{media_file.file_id[:16]}{ext}"
|
||||
await file.download_to_drive(str(file_path))
|
||||
|
||||
@@ -30,6 +30,7 @@ from rich.table import Table
|
||||
from rich.text import Text
|
||||
|
||||
from nanobot import __logo__, __version__
|
||||
from nanobot.config.paths import get_workspace_path
|
||||
from nanobot.config.schema import Config
|
||||
from nanobot.utils.helpers import sync_workspace_templates
|
||||
|
||||
@@ -99,7 +100,9 @@ def _init_prompt_session() -> None:
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
history_file = Path.home() / ".nanobot" / "history" / "cli_history"
|
||||
from nanobot.config.paths import get_cli_history_path
|
||||
|
||||
history_file = get_cli_history_path()
|
||||
history_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
_PROMPT_SESSION = PromptSession(
|
||||
@@ -170,7 +173,6 @@ def onboard():
|
||||
"""Initialize nanobot configuration and workspace."""
|
||||
from nanobot.config.loader import get_config_path, load_config, save_config
|
||||
from nanobot.config.schema import Config
|
||||
from nanobot.utils.helpers import get_workspace_path
|
||||
|
||||
config_path = get_config_path()
|
||||
|
||||
@@ -271,8 +273,9 @@ def _make_provider(config: Config):
|
||||
@app.command()
|
||||
def gateway(
|
||||
port: int = typer.Option(18790, "--port", "-p", help="Gateway port"),
|
||||
workspace: str | None = typer.Option(None, "--workspace", "-w", help="Workspace directory"),
|
||||
verbose: bool = typer.Option(False, "--verbose", "-v", help="Verbose output"),
|
||||
config: str = typer.Option(None, "--config", "-c", help="Path to config file"),
|
||||
config: str | None = typer.Option(None, "--config", "-c", help="Path to config file"),
|
||||
):
|
||||
"""Start the nanobot gateway."""
|
||||
# Set config path if provided (must be done before any imports that use get_data_dir)
|
||||
@@ -288,7 +291,8 @@ def gateway(
|
||||
from nanobot.agent.loop import AgentLoop
|
||||
from nanobot.bus.queue import MessageBus
|
||||
from nanobot.channels.manager import ChannelManager
|
||||
from nanobot.config.loader import get_data_dir, load_config
|
||||
from nanobot.config.loader import load_config
|
||||
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
|
||||
@@ -301,13 +305,15 @@ def gateway(
|
||||
console.print(f"{__logo__} Starting nanobot gateway on port {port}...")
|
||||
|
||||
config = load_config()
|
||||
if workspace:
|
||||
config.agents.defaults.workspace = workspace
|
||||
sync_workspace_templates(config.workspace_path)
|
||||
bus = MessageBus()
|
||||
provider = _make_provider(config)
|
||||
session_manager = SessionManager(config.workspace_path)
|
||||
|
||||
# Create cron service first (callback set after agent creation)
|
||||
cron_store_path = get_data_dir() / "cron" / "jobs.json"
|
||||
cron_store_path = get_cron_dir() / "jobs.json"
|
||||
cron = CronService(cron_store_path)
|
||||
|
||||
# Create agent with cron service
|
||||
@@ -476,7 +482,8 @@ def agent(
|
||||
|
||||
from nanobot.agent.loop import AgentLoop
|
||||
from nanobot.bus.queue import MessageBus
|
||||
from nanobot.config.loader import get_data_dir, load_config
|
||||
from nanobot.config.loader import load_config
|
||||
from nanobot.config.paths import get_cron_dir
|
||||
from nanobot.cron.service import CronService
|
||||
|
||||
config = load_config()
|
||||
@@ -486,7 +493,7 @@ def agent(
|
||||
provider = _make_provider(config)
|
||||
|
||||
# Create cron service for tool usage (no callback needed for CLI unless running)
|
||||
cron_store_path = get_data_dir() / "cron" / "jobs.json"
|
||||
cron_store_path = get_cron_dir() / "jobs.json"
|
||||
cron = CronService(cron_store_path)
|
||||
|
||||
if logs:
|
||||
@@ -752,7 +759,9 @@ def _get_bridge_dir() -> Path:
|
||||
import subprocess
|
||||
|
||||
# User's bridge location
|
||||
user_bridge = Path.home() / ".nanobot" / "bridge"
|
||||
from nanobot.config.paths import get_bridge_install_dir
|
||||
|
||||
user_bridge = get_bridge_install_dir()
|
||||
|
||||
# Check if already built
|
||||
if (user_bridge / "dist" / "index.js").exists():
|
||||
@@ -810,6 +819,7 @@ def channels_login():
|
||||
import subprocess
|
||||
|
||||
from nanobot.config.loader import load_config
|
||||
from nanobot.config.paths import get_runtime_subdir
|
||||
|
||||
config = load_config()
|
||||
bridge_dir = _get_bridge_dir()
|
||||
@@ -820,6 +830,7 @@ def channels_login():
|
||||
env = {**os.environ}
|
||||
if config.channels.whatsapp.bridge_token:
|
||||
env["BRIDGE_TOKEN"] = config.channels.whatsapp.bridge_token
|
||||
env["AUTH_DIR"] = str(get_runtime_subdir("whatsapp-auth"))
|
||||
|
||||
try:
|
||||
subprocess.run(["npm", "start"], cwd=bridge_dir, check=True, env=env)
|
||||
|
||||
@@ -1,6 +1,30 @@
|
||||
"""Configuration module for nanobot."""
|
||||
|
||||
from nanobot.config.loader import get_config_path, load_config
|
||||
from nanobot.config.paths import (
|
||||
get_bridge_install_dir,
|
||||
get_cli_history_path,
|
||||
get_cron_dir,
|
||||
get_data_dir,
|
||||
get_legacy_sessions_dir,
|
||||
get_logs_dir,
|
||||
get_media_dir,
|
||||
get_runtime_subdir,
|
||||
get_workspace_path,
|
||||
)
|
||||
from nanobot.config.schema import Config
|
||||
|
||||
__all__ = ["Config", "load_config", "get_config_path"]
|
||||
__all__ = [
|
||||
"Config",
|
||||
"load_config",
|
||||
"get_config_path",
|
||||
"get_data_dir",
|
||||
"get_runtime_subdir",
|
||||
"get_media_dir",
|
||||
"get_cron_dir",
|
||||
"get_logs_dir",
|
||||
"get_workspace_path",
|
||||
"get_cli_history_path",
|
||||
"get_bridge_install_dir",
|
||||
"get_legacy_sessions_dir",
|
||||
]
|
||||
|
||||
@@ -23,14 +23,6 @@ def get_config_path() -> Path:
|
||||
return Path.home() / ".nanobot" / "config.json"
|
||||
|
||||
|
||||
def get_data_dir() -> Path:
|
||||
"""Get the nanobot data directory (derived from config path)."""
|
||||
config_path = get_config_path()
|
||||
# If config is ~/.nanobot-xxx/config.json, data dir is ~/.nanobot-xxx/
|
||||
# If config is ~/.nanobot/config.json, data dir is ~/.nanobot/
|
||||
return config_path.parent
|
||||
|
||||
|
||||
def load_config(config_path: Path | None = None) -> Config:
|
||||
"""
|
||||
Load configuration from file or create default.
|
||||
|
||||
55
nanobot/config/paths.py
Normal file
55
nanobot/config/paths.py
Normal file
@@ -0,0 +1,55 @@
|
||||
"""Runtime path helpers derived from the active config context."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from nanobot.config.loader import get_config_path
|
||||
from nanobot.utils.helpers import ensure_dir
|
||||
|
||||
|
||||
def get_data_dir() -> Path:
|
||||
"""Return the instance-level runtime data directory."""
|
||||
return ensure_dir(get_config_path().parent)
|
||||
|
||||
|
||||
def get_runtime_subdir(name: str) -> Path:
|
||||
"""Return a named runtime subdirectory under the instance data dir."""
|
||||
return ensure_dir(get_data_dir() / name)
|
||||
|
||||
|
||||
def get_media_dir(channel: str | None = None) -> Path:
|
||||
"""Return the media directory, optionally namespaced per channel."""
|
||||
base = get_runtime_subdir("media")
|
||||
return ensure_dir(base / channel) if channel else base
|
||||
|
||||
|
||||
def get_cron_dir() -> Path:
|
||||
"""Return the cron storage directory."""
|
||||
return get_runtime_subdir("cron")
|
||||
|
||||
|
||||
def get_logs_dir() -> Path:
|
||||
"""Return the logs directory."""
|
||||
return get_runtime_subdir("logs")
|
||||
|
||||
|
||||
def get_workspace_path(workspace: str | None = None) -> Path:
|
||||
"""Resolve and ensure the agent workspace path."""
|
||||
path = Path(workspace).expanduser() if workspace else Path.home() / ".nanobot" / "workspace"
|
||||
return ensure_dir(path)
|
||||
|
||||
|
||||
def get_cli_history_path() -> Path:
|
||||
"""Return the shared CLI history file path."""
|
||||
return Path.home() / ".nanobot" / "history" / "cli_history"
|
||||
|
||||
|
||||
def get_bridge_install_dir() -> Path:
|
||||
"""Return the shared WhatsApp bridge installation directory."""
|
||||
return Path.home() / ".nanobot" / "bridge"
|
||||
|
||||
|
||||
def get_legacy_sessions_dir() -> Path:
|
||||
"""Return the legacy global session directory used for migration fallback."""
|
||||
return Path.home() / ".nanobot" / "sessions"
|
||||
@@ -9,6 +9,7 @@ from typing import Any
|
||||
|
||||
from loguru import logger
|
||||
|
||||
from nanobot.config.paths import get_legacy_sessions_dir
|
||||
from nanobot.utils.helpers import ensure_dir, safe_filename
|
||||
|
||||
|
||||
@@ -79,7 +80,7 @@ class SessionManager:
|
||||
def __init__(self, workspace: Path):
|
||||
self.workspace = workspace
|
||||
self.sessions_dir = ensure_dir(self.workspace / "sessions")
|
||||
self.legacy_sessions_dir = Path.home() / ".nanobot" / "sessions"
|
||||
self.legacy_sessions_dir = get_legacy_sessions_dir()
|
||||
self._cache: dict[str, Session] = {}
|
||||
|
||||
def _get_session_path(self, key: str) -> Path:
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
"""Utility functions for nanobot."""
|
||||
|
||||
from nanobot.utils.helpers import ensure_dir, get_data_path, get_workspace_path
|
||||
from nanobot.utils.helpers import ensure_dir
|
||||
|
||||
__all__ = ["ensure_dir", "get_workspace_path", "get_data_path"]
|
||||
__all__ = ["ensure_dir"]
|
||||
|
||||
@@ -24,18 +24,6 @@ def ensure_dir(path: Path) -> Path:
|
||||
return path
|
||||
|
||||
|
||||
def get_data_path() -> Path:
|
||||
"""Get nanobot data directory (derived from config path)."""
|
||||
from nanobot.config.loader import get_data_dir
|
||||
return ensure_dir(get_data_dir())
|
||||
|
||||
|
||||
def get_workspace_path(workspace: str | None = None) -> Path:
|
||||
"""Resolve and ensure workspace path. Defaults to ~/.nanobot/workspace."""
|
||||
path = Path(workspace).expanduser() if workspace else Path.home() / ".nanobot" / "workspace"
|
||||
return ensure_dir(path)
|
||||
|
||||
|
||||
def timestamp() -> str:
|
||||
"""Current ISO timestamp."""
|
||||
return datetime.now().isoformat()
|
||||
|
||||
@@ -14,13 +14,17 @@ from nanobot.providers.registry import find_by_model
|
||||
runner = CliRunner()
|
||||
|
||||
|
||||
class _StopGateway(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_paths():
|
||||
"""Mock config/workspace paths for test isolation."""
|
||||
with patch("nanobot.config.loader.get_config_path") as mock_cp, \
|
||||
patch("nanobot.config.loader.save_config") as mock_sc, \
|
||||
patch("nanobot.config.loader.load_config") as mock_lc, \
|
||||
patch("nanobot.utils.helpers.get_workspace_path") as mock_ws:
|
||||
patch("nanobot.cli.commands.get_workspace_path") as mock_ws:
|
||||
|
||||
base_dir = Path("./test_onboard_data")
|
||||
if base_dir.exists():
|
||||
@@ -128,3 +132,94 @@ def test_litellm_provider_canonicalizes_github_copilot_hyphen_prefix():
|
||||
def test_openai_codex_strip_prefix_supports_hyphen_and_underscore():
|
||||
assert _strip_model_prefix("openai-codex/gpt-5.1-codex") == "gpt-5.1-codex"
|
||||
assert _strip_model_prefix("openai_codex/gpt-5.1-codex") == "gpt-5.1-codex"
|
||||
|
||||
|
||||
def test_gateway_uses_workspace_from_config_by_default(monkeypatch, tmp_path: Path) -> None:
|
||||
config_file = tmp_path / "instance" / "config.json"
|
||||
config_file.parent.mkdir(parents=True)
|
||||
config_file.write_text("{}")
|
||||
|
||||
config = Config()
|
||||
config.agents.defaults.workspace = str(tmp_path / "config-workspace")
|
||||
seen: dict[str, Path] = {}
|
||||
|
||||
monkeypatch.setattr(
|
||||
"nanobot.config.loader.set_config_path",
|
||||
lambda path: seen.__setitem__("config_path", path),
|
||||
)
|
||||
monkeypatch.setattr("nanobot.config.loader.load_config", lambda: config)
|
||||
monkeypatch.setattr(
|
||||
"nanobot.cli.commands.sync_workspace_templates",
|
||||
lambda path: seen.__setitem__("workspace", path),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"nanobot.cli.commands._make_provider",
|
||||
lambda _config: (_ for _ in ()).throw(_StopGateway("stop")),
|
||||
)
|
||||
|
||||
result = runner.invoke(app, ["gateway", "--config", str(config_file)])
|
||||
|
||||
assert isinstance(result.exception, _StopGateway)
|
||||
assert seen["config_path"] == config_file.resolve()
|
||||
assert seen["workspace"] == Path(config.agents.defaults.workspace)
|
||||
|
||||
|
||||
def test_gateway_workspace_option_overrides_config(monkeypatch, tmp_path: Path) -> None:
|
||||
config_file = tmp_path / "instance" / "config.json"
|
||||
config_file.parent.mkdir(parents=True)
|
||||
config_file.write_text("{}")
|
||||
|
||||
config = Config()
|
||||
config.agents.defaults.workspace = str(tmp_path / "config-workspace")
|
||||
override = tmp_path / "override-workspace"
|
||||
seen: dict[str, Path] = {}
|
||||
|
||||
monkeypatch.setattr("nanobot.config.loader.set_config_path", lambda _path: None)
|
||||
monkeypatch.setattr("nanobot.config.loader.load_config", lambda: config)
|
||||
monkeypatch.setattr(
|
||||
"nanobot.cli.commands.sync_workspace_templates",
|
||||
lambda path: seen.__setitem__("workspace", path),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"nanobot.cli.commands._make_provider",
|
||||
lambda _config: (_ for _ in ()).throw(_StopGateway("stop")),
|
||||
)
|
||||
|
||||
result = runner.invoke(
|
||||
app,
|
||||
["gateway", "--config", str(config_file), "--workspace", str(override)],
|
||||
)
|
||||
|
||||
assert isinstance(result.exception, _StopGateway)
|
||||
assert seen["workspace"] == override
|
||||
assert config.workspace_path == override
|
||||
|
||||
|
||||
def test_gateway_uses_config_directory_for_cron_store(monkeypatch, tmp_path: Path) -> None:
|
||||
config_file = tmp_path / "instance" / "config.json"
|
||||
config_file.parent.mkdir(parents=True)
|
||||
config_file.write_text("{}")
|
||||
|
||||
config = Config()
|
||||
config.agents.defaults.workspace = str(tmp_path / "config-workspace")
|
||||
seen: dict[str, Path] = {}
|
||||
|
||||
monkeypatch.setattr("nanobot.config.loader.set_config_path", lambda _path: None)
|
||||
monkeypatch.setattr("nanobot.config.loader.load_config", lambda: 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())
|
||||
monkeypatch.setattr("nanobot.session.manager.SessionManager", lambda _workspace: object())
|
||||
|
||||
class _StopCron:
|
||||
def __init__(self, store_path: Path) -> None:
|
||||
seen["cron_store"] = store_path
|
||||
raise _StopGateway("stop")
|
||||
|
||||
monkeypatch.setattr("nanobot.cron.service.CronService", _StopCron)
|
||||
|
||||
result = runner.invoke(app, ["gateway", "--config", str(config_file)])
|
||||
|
||||
assert isinstance(result.exception, _StopGateway)
|
||||
assert seen["cron_store"] == config_file.parent / "cron" / "jobs.json"
|
||||
|
||||
42
tests/test_config_paths.py
Normal file
42
tests/test_config_paths.py
Normal file
@@ -0,0 +1,42 @@
|
||||
from pathlib import Path
|
||||
|
||||
from nanobot.config.paths import (
|
||||
get_bridge_install_dir,
|
||||
get_cli_history_path,
|
||||
get_cron_dir,
|
||||
get_data_dir,
|
||||
get_legacy_sessions_dir,
|
||||
get_logs_dir,
|
||||
get_media_dir,
|
||||
get_runtime_subdir,
|
||||
get_workspace_path,
|
||||
)
|
||||
|
||||
|
||||
def test_runtime_dirs_follow_config_path(monkeypatch, tmp_path: Path) -> None:
|
||||
config_file = tmp_path / "instance-a" / "config.json"
|
||||
monkeypatch.setattr("nanobot.config.paths.get_config_path", lambda: config_file)
|
||||
|
||||
assert get_data_dir() == config_file.parent
|
||||
assert get_runtime_subdir("cron") == config_file.parent / "cron"
|
||||
assert get_cron_dir() == config_file.parent / "cron"
|
||||
assert get_logs_dir() == config_file.parent / "logs"
|
||||
|
||||
|
||||
def test_media_dir_supports_channel_namespace(monkeypatch, tmp_path: Path) -> None:
|
||||
config_file = tmp_path / "instance-b" / "config.json"
|
||||
monkeypatch.setattr("nanobot.config.paths.get_config_path", lambda: config_file)
|
||||
|
||||
assert get_media_dir() == config_file.parent / "media"
|
||||
assert get_media_dir("telegram") == config_file.parent / "media" / "telegram"
|
||||
|
||||
|
||||
def test_shared_and_legacy_paths_remain_global() -> None:
|
||||
assert get_cli_history_path() == Path.home() / ".nanobot" / "history" / "cli_history"
|
||||
assert get_bridge_install_dir() == Path.home() / ".nanobot" / "bridge"
|
||||
assert get_legacy_sessions_dir() == Path.home() / ".nanobot" / "sessions"
|
||||
|
||||
|
||||
def test_workspace_path_is_explicitly_resolved() -> None:
|
||||
assert get_workspace_path() == Path.home() / ".nanobot" / "workspace"
|
||||
assert get_workspace_path("~/custom-workspace") == Path.home() / "custom-workspace"
|
||||
Reference in New Issue
Block a user