fix(session): preserve history across storage relocation

Co-authored-by: lmzopq <1646888+lmzopq@users.noreply.github.com>
This commit is contained in:
Xubin Ren
2026-08-13 01:41:10 +09:00
co-authored by lmzopq
parent 45245b5e55
commit cd7480945b
18 changed files with 820 additions and 56 deletions
+18 -5
View File
@@ -25,14 +25,27 @@ def _isolate_nanobot_log_activation() -> Iterator[None]:
@pytest.fixture(autouse=True)
def _isolate_sessions_root(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Iterator[None]:
"""Redirect session storage away from the real ~/.nanobot/sessions.
"""Redirect session storage away from the real active config data directory.
Session storage lives under get_legacy_sessions_dir() (outside the workspace,
Session storage lives under the active runtime data root (outside the workspace,
per ADR-0001), so without redirection tests would write into the real home.
"""
root = tmp_path / "sessions-root"
monkeypatch.setattr("nanobot.session.manager.get_legacy_sessions_dir", lambda: root)
monkeypatch.setattr("nanobot.config.paths.get_legacy_sessions_dir", lambda: root)
runtime_root = tmp_path.parent / f"{tmp_path.name}-runtime-root"
legacy_root = tmp_path.parent / f"{tmp_path.name}-legacy-sessions-root"
def runtime_subdir(name: str) -> Path:
path = runtime_root / name
path.mkdir(parents=True, exist_ok=True)
return path
monkeypatch.setattr(
"nanobot.session.manager.get_runtime_subdir",
runtime_subdir,
)
monkeypatch.setattr(
"nanobot.session.manager.get_legacy_sessions_dir",
lambda: legacy_root,
)
yield
+2 -2
View File
@@ -149,7 +149,7 @@ Defaults:
|---|---|
| Config | `~/.nanobot/config.json` |
| Workspace | `~/.nanobot/workspace/` |
| Sessions | `~/.nanobot/sessions/<workspace-hash>/*.jsonl` |
| Sessions | `<config-dir>/sessions/<workspace-id>/*.jsonl` (default: `~/.nanobot/sessions/...`) |
| Memory | `<workspace>/memory/` |
| Cron store | `<workspace>/cron/jobs.json` |
| WebUI/media/log runtime data | config directory subdirectories such as `webui/`, `media/`, and `logs/` |
@@ -180,7 +180,7 @@ Session history is the near-term conversation replay. Memory is the longer-term
| Store | File area |
|---|---|
| Session JSONL files | `~/.nanobot/sessions/<workspace-hash>/` |
| Session JSONL files | `<config-dir>/sessions/<workspace-id>/` |
| Long-term memory | `<workspace>/memory/MEMORY.md` |
| Consolidation source history | `<workspace>/memory/history.jsonl` |
| Bootstrap identity files | `<workspace>/SOUL.md`, `<workspace>/USER.md`, templates under `nanobot/templates/` |
+18
View File
@@ -94,6 +94,24 @@ follow the printed WebUI **Settings → Models** or `nanobot onboard --wizard` r
| `nanobot agent --no-markdown` | Print plain text instead of Rich-rendered Markdown |
| `nanobot agent --logs` | Show runtime logs while chatting |
## Session Storage and Rollback
Session JSONL files live under `<config-dir>/sessions/<workspace-id>/`, outside the
agent-readable workspace. On the first upgraded start, nanobot safely migrates existing
`<workspace>/sessions/*.jsonl` files after verifying an atomic copy. Stop every old nanobot
process that uses the workspace before upgrading; old and new binaries must not write the
same session concurrently.
To prepare a downgrade, stop nanobot and copy the current sessions back to the path understood
by older releases:
```bash
nanobot sessions restore-workspace --config ./bot-a/config.json --workspace ./bot-a/workspace
```
The command never deletes the external store and refuses to overwrite a different existing
workspace file. Back up both the config directory and workspace before changing versions.
In interactive mode, `Enter` sends the current message. Press `Alt+Enter` to add a newline before sending.
Interactive mode exits with `exit`, `quit`, `/exit`, `/quit`, `:q`, or `Ctrl+D`.
+8 -2
View File
@@ -27,7 +27,7 @@ The default instance lives under `~/.nanobot/`:
|---|---|
| `~/.nanobot/config.json` | Instance configuration: providers, model defaults, channels, tools, gateway, API, and runtime options |
| `~/.nanobot/workspace/` | Agent workspace: memory, heartbeat tasks, cron jobs, skills, and generated artifacts |
| `~/.nanobot/sessions/<workspace-hash>/` | Session history stored outside the agent-accessible workspace and namespaced by its canonical path |
| `~/.nanobot/sessions/<workspace-id>/` | Session history stored outside the agent-accessible workspace; the opaque ID follows workspace moves |
You can override both with command flags:
@@ -126,11 +126,17 @@ nanobot uses two related stores:
| Store | Location | Purpose |
|---|---|---|
| Sessions | `~/.nanobot/sessions/<workspace-hash>/*.jsonl` | Recent conversation turns replayed into context |
| Sessions | `<config-dir>/sessions/<workspace-id>/*.jsonl` | Recent conversation turns replayed into context |
| Memory | `<workspace>/memory/MEMORY.md` and `<workspace>/memory/history.jsonl` | Long-term facts and consolidated history |
Dream is a periodic consolidation job. It reads accumulated history and updates workspace memory so useful context can survive beyond short session replay.
The configured workspace contains a `.nanobot/workspace-id` file. It contains only an
opaque random identifier—never conversation content or credentials. Keep it with workspace
backups: it lets nanobot find the same external session namespace after the workspace is
renamed, moved, or restored. A live copy opened alongside the original receives a new ID so
the two workspaces do not share conversations accidentally.
See [`memory.md`](./memory.md) for the detailed design.
## Apps and Agent Plugins
+1 -1
View File
@@ -11,7 +11,7 @@ Check these once before Render, Docker, systemd, or LaunchAgent:
| `nanobot status` shows the expected config and workspace | Confirms the process will read the instance you meant to run |
| `nanobot agent -m "Hello!"` works | Proves install, config, provider, model, and workspace writes before adding a service layer |
| Secrets are in environment variables or protected config files | API keys, bot tokens, OAuth state, and chat credentials should not be world-readable |
| `~/.nanobot/` (including `sessions/`) and any custom config/workspace paths are persistent | Sessions, memory, channel login state, generated artifacts, and cron jobs live there |
| The active config directory (including `sessions/`) and workspace are persistent | Sessions follow `--config`; memory, generated artifacts, and the workspace identity marker follow the workspace |
| Channel access control is intentional | Use `allowFrom`, pairing, WebSocket `token`/`tokenIssueSecret`, or private test channels before exposing the bot |
| Ports are planned | Gateway health defaults to local-only `127.0.0.1:18790`; WebUI/WebSocket defaults to `8765`; `nanobot serve` defaults to `8900` |
| Logs are easy to reach | Use `docker compose logs`, `journalctl`, LaunchAgent log files, or `nanobot gateway --verbose` while diagnosing startup |
+2 -2
View File
@@ -47,8 +47,8 @@ nanobot gateway logs
- Docker Compose is the most repeatable Linux container path.
- systemd user services are useful for Linux user-level gateway deployments.
- macOS LaunchAgent keeps the gateway alive after login.
- Persist `~/.nanobot/sessions/` together with config, workspace, memory files,
channel login state, and generated artifacts.
- Persist the active config directory's `sessions/` folder together with the workspace
(including `.nanobot/workspace-id`), memory files, channel login state, and generated artifacts.
- Restart the gateway after editing `config.json`.
## Security notes
+2 -1
View File
@@ -58,6 +58,7 @@ nanobot agent -c ~/.nanobot-telegram/config.json -w /tmp/nanobot-telegram-test
|-----------|---------------|---------|
| **Config** | `--config` path | `~/.nanobot-A/config.json` |
| **Workspace** | `--workspace` or config | `~/.nanobot-A/workspace/` |
| **Sessions** | config directory + workspace ID | `~/.nanobot-A/sessions/<workspace-id>/` |
| **Cron Jobs** | workspace directory | `~/.nanobot-A/workspace/cron/` |
| **Media / runtime state** | config directory | `~/.nanobot-A/media/` |
@@ -126,6 +127,6 @@ nanobot gateway --config ~/.nanobot-telegram/config.json --workspace /tmp/nanobo
## Notes
- 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
- Session data follows the active config directory; use a different workspace per instance to isolate memory, skills, and the stable session namespace ID
- `--workspace` overrides the workspace defined in the config file
- Cron jobs are stored in the active workspace; runtime media/state is derived from the config directory
+1 -1
View File
@@ -49,7 +49,7 @@ The WebUI launcher creates or updates:
|---|---|
| `~/.nanobot/config.json` | Provider, model, WebUI, channel, tool, and runtime settings |
| `~/.nanobot/workspace/` | Memory, skills, automations, and generated files |
| `~/.nanobot/sessions/<workspace-hash>/` | Recent session history, isolated by canonical workspace path |
| `~/.nanobot/sessions/<workspace-id>/` | Recent session history stored outside the workspace; the ID remains stable across workspace moves |
If the installer did not open the browser, run:
+2 -1
View File
@@ -319,7 +319,8 @@ See [`chat-apps.md`](./chat-apps.md) for channel-specific setup.
|---|---|
| Conversation context seems wrong | Confirm the active workspace and session. WebUI chats and chat app threads may use different sessions. |
| Memory does not update immediately | Dream consolidation is periodic; recent turns still live in session history. |
| Old sessions appear after moving config | Session files are stored under `~/.nanobot/sessions/<workspace-hash>/`; verify the canonical workspace path recorded in the directory's `.workspace` marker. |
| Sessions disappear after changing `--config` | Sessions follow the config directory at `<config-dir>/sessions/<workspace-id>/`; use the original config path or copy that `sessions/` directory into the new config directory while nanobot is stopped. |
| Sessions disappear after moving a workspace | Keep the workspace's `.nanobot/workspace-id` file with the move or backup. If it was lost, restore that marker from backup before starting nanobot. |
| You want one shared session across devices | Set `agents.defaults.unifiedSession` intentionally; otherwise keep separate sessions. |
## Collect Useful Evidence
+6
View File
@@ -476,6 +476,12 @@ class AgentLoop:
if bus is None:
bus = MessageBus()
defaults = config.agents.defaults
if "session_manager" not in extra:
data_dir = config.runtime_data_dir
extra["session_manager"] = SessionManager(
config.workspace_path,
sessions_root=data_dir / "sessions" if data_dir is not None else None,
)
provider = extra.pop("provider", None) or make_provider(config)
resolved = config.resolve_preset()
model = extra.pop("model", None) or resolved.model
+38
View File
@@ -440,6 +440,44 @@ app.add_typer(
app.command(name="agent")(agent)
# ============================================================================
# Session Commands
# ============================================================================
sessions_app = typer.Typer(help="Manage persisted session history")
app.add_typer(sessions_app, name="sessions")
@sessions_app.command("restore-workspace")
def sessions_restore_workspace(
config: str | None = typer.Option(None, "--config", "-c", help="Path to config file"),
workspace: str | None = typer.Option(None, "--workspace", "-w", help="Workspace directory"),
) -> None:
"""Copy sessions back into the workspace before downgrading nanobot."""
from nanobot.session.manager import SessionManager
runtime_config = _load_runtime_config(config, workspace)
data_dir = runtime_config.runtime_data_dir
manager = SessionManager(
runtime_config.workspace_path,
sessions_root=data_dir / "sessions" if data_dir is not None else None,
)
result = manager.restore_sessions_to_workspace()
console.print(
f"Restored {result.restored} session file(s) to "
f"{escape(str(runtime_config.workspace_path / 'sessions'))}; "
f"{result.unchanged} already matched."
)
if result.conflicts:
console.print(
"[red]Rollback is incomplete: existing or invalid files require manual review.[/red]"
)
for path in result.conflicts:
console.print(Text(f"- {path}", style="red"))
raise typer.Exit(1)
# ============================================================================
# Channel Commands
# ============================================================================
+2
View File
@@ -75,6 +75,7 @@ def load_config(config_path: Path | None = None) -> Config:
summary="Environment-based configuration is invalid.",
issues=validation_issues(exc),
) from exc
config.bind_source_path(path)
_apply_ssrf_whitelist(config)
return config
@@ -130,6 +131,7 @@ def load_config(config_path: Path | None = None) -> Config:
issues=issues,
) from exc
config.bind_source_path(path)
_apply_ssrf_whitelist(config)
return config
+12 -1
View File
@@ -4,7 +4,7 @@ from __future__ import annotations
from pathlib import Path
from typing import TYPE_CHECKING, Any, ClassVar, Literal, cast
from pydantic import AliasChoices, ConfigDict, Field, field_validator, model_validator
from pydantic import AliasChoices, ConfigDict, Field, PrivateAttr, field_validator, model_validator
from pydantic_settings import BaseSettings, SettingsConfigDict
from nanobot.config.timezone import detect_system_timezone
@@ -431,6 +431,8 @@ class ToolsConfig(Base):
class Config(BaseSettings):
"""Root configuration for nanobot."""
_source_path: Path | None = PrivateAttr(default=None)
agents: AgentsConfig = Field(default_factory=AgentsConfig)
channels: ChannelsConfig = Field(default_factory=ChannelsConfig)
transcription: TranscriptionConfig = Field(default_factory=TranscriptionConfig)
@@ -449,6 +451,15 @@ class Config(BaseSettings):
_resolve_tool_config_refs()
super().__init__(**values)
def bind_source_path(self, path: Path) -> None:
"""Record the config file that owns instance-level runtime data."""
self._source_path = path.expanduser().resolve(strict=False)
@property
def runtime_data_dir(self) -> Path | None:
"""Return the active instance data directory when loaded from a config path."""
return self._source_path.parent if self._source_path is not None else None
@model_validator(mode="after")
def _validate_model_preset(self) -> "Config":
if "default" in self.model_presets:
+461 -31
View File
@@ -6,7 +6,8 @@ import hashlib
import json
import os
import re
import shutil
import secrets
import stat
from collections import OrderedDict
from contextlib import suppress
from copy import deepcopy
@@ -16,9 +17,10 @@ from pathlib import Path
from typing import Any, Callable, Collection, Protocol, TypedDict, cast
from weakref import WeakValueDictionary
from filelock import FileLock
from loguru import logger
from nanobot.config.paths import get_legacy_sessions_dir
from nanobot.config.paths import get_legacy_sessions_dir, get_runtime_subdir
from nanobot.providers.base import ProviderConversationState
from nanobot.runtime_context import (
RUNTIME_CONTEXT_HISTORY_META,
@@ -59,6 +61,11 @@ _FORK_VOLATILE_METADATA_KEYS = {
"title",
"title_user_edited",
}
_WORKSPACE_STATE_DIR = ".nanobot"
_WORKSPACE_ID_FILE = "workspace-id"
_WORKSPACE_ID_RE = re.compile(r"^[0-9a-f]{32}$")
_SESSION_MIGRATION_LOCK_TIMEOUT_SECONDS = 30
_COPY_CHUNK_SIZE = 1024 * 1024
def _json_object(value: object) -> dict[str, Any]:
@@ -505,6 +512,23 @@ class SessionInfo(TypedDict):
path: str
@dataclass(frozen=True)
class _SessionFileSnapshot:
digest: str
size: int
mtime_ns: int
updated_at: float
device: int
inode: int
@dataclass(frozen=True)
class SessionRestoreResult:
restored: int
unchanged: int
conflicts: tuple[Path, ...]
class SessionStore(Protocol):
def load(self, key: str) -> Session | None: ...
@@ -522,34 +546,356 @@ class SessionStore(Protocol):
class JsonlSessionStore:
"""JSONL implementation of session persistence."""
def __init__(self, workspace: Path):
root = get_legacy_sessions_dir()
self.sessions_dir = ensure_dir(root / self._workspace_hash(workspace))
self.legacy_sessions_dir = root
self._write_workspace_marker(self.sessions_dir, workspace)
self._migrate_from_workspace(workspace)
@staticmethod
def _workspace_hash(workspace: Path) -> str:
canonical = str(Path(workspace).expanduser().resolve(strict=False))
return hashlib.sha256(canonical.encode("utf-8")).hexdigest()[:16]
@staticmethod
def _write_workspace_marker(sessions_dir: Path, workspace: Path) -> None:
marker = sessions_dir / ".workspace"
if marker.exists():
return
try:
marker.write_text(
str(Path(workspace).expanduser().resolve(strict=False)),
encoding="utf-8",
def __init__(self, workspace: Path, *, sessions_root: Path | None = None):
canonical_workspace = Path(workspace).expanduser().resolve(strict=False)
ensure_dir(canonical_workspace)
root = (
Path(sessions_root).expanduser().resolve(strict=False)
if sessions_root is not None
else get_runtime_subdir("sessions").resolve(strict=False)
)
if root == canonical_workspace or root.is_relative_to(canonical_workspace):
raise RuntimeError(
"session storage must be outside the agent workspace; "
"move --config outside --workspace or choose a nested workspace directory"
)
except OSError as exc:
logger.debug("Failed to write sessions workspace marker: {}", exc)
ensure_dir(root)
with suppress(OSError):
os.chmod(root, 0o700)
self.workspace = canonical_workspace
self._migration_lock = FileLock(
str(root / ".workspace-migration.lock"),
timeout=_SESSION_MIGRATION_LOCK_TIMEOUT_SECONDS,
)
with self._migration_lock:
workspace_id = self._load_or_create_workspace_id(canonical_workspace, root)
workspace_id = self._claim_workspace_namespace(
root,
canonical_workspace,
workspace_id,
)
self.sessions_dir = ensure_dir(root / workspace_id)
self.legacy_sessions_dir = get_legacy_sessions_dir()
self._migrate_from_workspace(canonical_workspace)
@staticmethod
def _fsync_directory(path: Path) -> None:
with suppress(PermissionError, NotImplementedError):
fd = os.open(path, os.O_RDONLY)
try:
os.fsync(fd)
except OSError as exc:
if exc.errno != errno.EINVAL:
raise
finally:
os.close(fd)
@classmethod
def _write_text_atomic(cls, path: Path, content: str, *, mode: int = 0o600) -> None:
tmp = path.with_name(f".{path.name}.{secrets.token_hex(8)}.tmp")
try:
with open(tmp, "x", encoding="utf-8") as handle:
os.chmod(tmp, mode)
handle.write(content)
handle.flush()
os.fsync(handle.fileno())
os.replace(tmp, path)
cls._fsync_directory(path.parent)
finally:
tmp.unlink(missing_ok=True)
@classmethod
def _read_workspace_id(cls, marker: Path) -> str:
if marker.is_symlink():
raise RuntimeError(f"workspace identity marker must not be a symlink: {marker}")
value = marker.read_text(encoding="utf-8").strip()
if not _WORKSPACE_ID_RE.fullmatch(value):
raise RuntimeError(
f"workspace identity marker is invalid: {marker}; "
"restore its original 32-character identifier before starting nanobot"
)
return value
@staticmethod
def _workspace_id_path(workspace: Path) -> Path:
state_dir = workspace / _WORKSPACE_STATE_DIR
if state_dir.is_symlink():
raise RuntimeError(f"workspace state directory must not be a symlink: {state_dir}")
ensure_dir(state_dir)
return state_dir / _WORKSPACE_ID_FILE
@classmethod
def _find_workspace_namespace(cls, workspace: Path, root: Path) -> str | None:
"""Recover an identity marker removed by cleanup at the same workspace path."""
matches: list[str] = []
for sessions_dir in root.iterdir():
if (
not _WORKSPACE_ID_RE.fullmatch(sessions_dir.name)
or sessions_dir.is_symlink()
or not sessions_dir.is_dir()
):
continue
marker = sessions_dir / ".workspace"
if marker.is_symlink() or not marker.is_file():
continue
try:
recorded = Path(marker.read_text(encoding="utf-8").strip()).expanduser()
recorded = recorded.resolve(strict=False)
same_workspace = recorded == workspace or (
recorded.exists() and recorded.samefile(workspace)
)
except (OSError, UnicodeError, ValueError):
continue
if same_workspace:
matches.append(sessions_dir.name)
if len(matches) > 1:
raise RuntimeError(
f"multiple session namespaces claim workspace {workspace}; "
"remove the stale namespace marker before starting nanobot"
)
return matches[0] if matches else None
@classmethod
def _load_or_create_workspace_id(cls, workspace: Path, root: Path) -> str:
marker = cls._workspace_id_path(workspace)
if marker.exists() or marker.is_symlink():
return cls._read_workspace_id(marker)
recovered = cls._find_workspace_namespace(workspace, root)
if recovered is not None:
cls._write_text_atomic(marker, f"{recovered}\n")
return recovered
workspace_id = secrets.token_hex(16)
flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL
flags |= getattr(os, "O_NOFOLLOW", 0)
try:
fd = os.open(marker, flags, 0o600)
except FileExistsError:
return cls._read_workspace_id(marker)
try:
payload = f"{workspace_id}\n".encode("ascii")
view = memoryview(payload)
while view:
written = os.write(fd, view)
view = view[written:]
os.fsync(fd)
except BaseException:
with suppress(OSError):
marker.unlink()
raise
finally:
os.close(fd)
cls._fsync_directory(marker.parent)
return workspace_id
@classmethod
def _replace_workspace_id(cls, workspace: Path, workspace_id: str) -> None:
cls._write_text_atomic(cls._workspace_id_path(workspace), f"{workspace_id}\n")
@classmethod
def _write_workspace_marker(cls, sessions_dir: Path, workspace: Path) -> None:
cls._write_text_atomic(sessions_dir / ".workspace", f"{workspace}\n")
@classmethod
def _claim_workspace_namespace(
cls,
root: Path,
workspace: Path,
workspace_id: str,
) -> str:
"""Bind a stable workspace ID, rotating copied live workspaces apart."""
for _attempt in range(3):
sessions_dir = root / workspace_id
marker = sessions_dir / ".workspace"
if sessions_dir.is_symlink():
raise RuntimeError(f"session namespace must not be a symlink: {sessions_dir}")
if not sessions_dir.exists():
ensure_dir(sessions_dir)
cls._write_workspace_marker(sessions_dir, workspace)
return workspace_id
if marker.is_symlink():
raise RuntimeError(f"session workspace marker must not be a symlink: {marker}")
if not marker.exists():
if any(sessions_dir.iterdir()):
raise RuntimeError(
f"session namespace has data but no workspace marker: {sessions_dir}"
)
cls._write_workspace_marker(sessions_dir, workspace)
return workspace_id
recorded_text = marker.read_text(encoding="utf-8").strip()
if not recorded_text:
raise RuntimeError(f"session workspace marker is empty: {marker}")
recorded = Path(recorded_text).expanduser().resolve(strict=False)
if recorded == workspace:
return workspace_id
try:
same_workspace = recorded.exists() and recorded.samefile(workspace)
except OSError:
same_workspace = False
if same_workspace:
cls._write_workspace_marker(sessions_dir, workspace)
return workspace_id
if not recorded.exists():
# The identity marker travelled with a renamed or moved workspace.
cls._write_workspace_marker(sessions_dir, workspace)
return workspace_id
# Both paths exist and are different: this is a copy, not a move.
workspace_id = secrets.token_hex(16)
cls._replace_workspace_id(workspace, workspace_id)
raise RuntimeError(f"could not allocate an isolated session namespace for {workspace}")
@staticmethod
def _session_file_snapshot(path: Path) -> _SessionFileSnapshot | None:
flags = os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0)
try:
fd = os.open(path, flags)
except OSError:
return None
try:
before = os.fstat(fd)
if not stat.S_ISREG(before.st_mode):
return None
digest = hashlib.sha256()
saw_record = False
updated_at: float | None = None
with os.fdopen(fd, "rb", closefd=False) as handle:
for raw_line in handle:
digest.update(raw_line)
if not raw_line.strip():
continue
value: object = json.loads(raw_line.decode("utf-8"))
data = _json_object(value)
saw_record = True
if data.get("_type") == "metadata":
raw_updated_at = cast(object, data.get("updated_at"))
if isinstance(raw_updated_at, str) and raw_updated_at:
updated_at = datetime.fromisoformat(raw_updated_at).timestamp()
after = os.fstat(fd)
if (
not saw_record
or before.st_dev != after.st_dev
or before.st_ino != after.st_ino
or before.st_size != after.st_size
or before.st_mtime_ns != after.st_mtime_ns
):
return None
return _SessionFileSnapshot(
digest=digest.hexdigest(),
size=after.st_size,
mtime_ns=after.st_mtime_ns,
updated_at=(updated_at if updated_at is not None else after.st_mtime_ns / 1e9),
device=after.st_dev,
inode=after.st_ino,
)
except (OSError, UnicodeDecodeError, json.JSONDecodeError, ValueError, TypeError):
return None
finally:
os.close(fd)
@classmethod
def _prepare_copy(
cls,
src: Path,
dst_dir: Path,
snapshot: _SessionFileSnapshot,
) -> Path:
tmp = dst_dir / f".{src.name}.{secrets.token_hex(8)}.tmp"
flags = os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0)
src_fd = os.open(src, flags)
try:
before = os.fstat(src_fd)
if (
before.st_dev != snapshot.device
or before.st_ino != snapshot.inode
or before.st_size != snapshot.size
or before.st_mtime_ns != snapshot.mtime_ns
):
raise OSError("session source changed before migration")
digest = hashlib.sha256()
size = 0
with os.fdopen(src_fd, "rb", closefd=False) as source, open(tmp, "xb") as target:
os.chmod(tmp, 0o600)
while chunk := source.read(_COPY_CHUNK_SIZE):
digest.update(chunk)
size += len(chunk)
target.write(chunk)
target.flush()
os.fsync(target.fileno())
after = os.fstat(src_fd)
if (
digest.hexdigest() != snapshot.digest
or size != snapshot.size
or after.st_dev != snapshot.device
or after.st_ino != snapshot.inode
or after.st_size != snapshot.size
or after.st_mtime_ns != snapshot.mtime_ns
):
raise OSError("session source changed during migration")
return tmp
except BaseException:
tmp.unlink(missing_ok=True)
raise
finally:
os.close(src_fd)
@classmethod
def _install_snapshot(
cls,
src: Path,
dst: Path,
snapshot: _SessionFileSnapshot,
) -> None:
tmp = cls._prepare_copy(src, dst.parent, snapshot)
try:
os.replace(tmp, dst)
cls._fsync_directory(dst.parent)
installed = cls._session_file_snapshot(dst)
if installed is None or installed.digest != snapshot.digest:
raise OSError(f"session migration verification failed: {dst}")
finally:
tmp.unlink(missing_ok=True)
def _archive_conflict(
self,
src: Path,
snapshot: _SessionFileSnapshot,
label: str,
) -> Path:
conflict_dir = ensure_dir(self.sessions_dir / ".migration-conflicts")
conflict = conflict_dir / (
f"{src.stem}.{label}.{snapshot.digest[:12]}.{secrets.token_hex(4)}.jsonl"
)
self._install_snapshot(src, conflict, snapshot)
return conflict
@classmethod
def _remove_migrated_source(
cls,
src: Path,
snapshot: _SessionFileSnapshot,
) -> bool:
try:
current = src.stat(follow_symlinks=False)
if (
current.st_dev != snapshot.device
or current.st_ino != snapshot.inode
or current.st_size != snapshot.size
or current.st_mtime_ns != snapshot.mtime_ns
):
return False
src.unlink()
cls._fsync_directory(src.parent)
return True
except OSError:
return False
def _migrate_from_workspace(self, workspace: Path) -> None:
"""Move legacy in-workspace session files into the out-of-workspace store."""
old_dir = Path(workspace).expanduser() / "sessions"
"""Durably copy legacy sessions out of the workspace, then remove the source."""
old_dir = workspace / "sessions"
if old_dir.is_symlink() or not old_dir.is_dir():
if old_dir.is_symlink():
logger.warning("Skipping symlinked legacy sessions directory: {}", old_dir)
@@ -559,13 +905,87 @@ class JsonlSessionStore:
logger.warning("Skipping unsafe legacy session file: {}", src)
continue
dst = self.sessions_dir / src.name
if dst.exists():
source_snapshot = self._session_file_snapshot(src)
if source_snapshot is None:
logger.warning("Skipping invalid or changing legacy session file: {}", src)
continue
try:
shutil.move(str(src), str(dst))
destination_snapshot = self._session_file_snapshot(dst) if dst.exists() else None
if dst.exists() and destination_snapshot is None:
logger.warning(
"Keeping legacy session because destination is invalid: {}",
dst,
)
continue
if destination_snapshot is None:
self._install_snapshot(src, dst, source_snapshot)
elif destination_snapshot.digest == source_snapshot.digest:
pass
elif source_snapshot.updated_at > destination_snapshot.updated_at:
archived = self._archive_conflict(dst, destination_snapshot, "destination")
self._install_snapshot(src, dst, source_snapshot)
logger.warning("Archived older session migration conflict at {}", archived)
else:
archived = self._archive_conflict(src, source_snapshot, "workspace")
logger.warning("Archived older session migration conflict at {}", archived)
installed = self._session_file_snapshot(dst)
if installed is None:
raise OSError(f"session migration destination is unreadable: {dst}")
selected_digest = (
source_snapshot.digest
if destination_snapshot is None
or source_snapshot.updated_at > destination_snapshot.updated_at
else destination_snapshot.digest
)
if installed.digest != selected_digest:
raise OSError(f"session migration selected unexpected data: {dst}")
if not self._remove_migrated_source(src, source_snapshot):
logger.warning(
"Session migrated but legacy source changed or could not be removed: {}",
src,
)
except OSError as exc:
logger.warning("Failed to migrate session {}: {}", src, exc)
def restore_to_workspace(self) -> SessionRestoreResult:
"""Copy canonical sessions back for an explicit downgrade or rollback."""
restored = 0
unchanged = 0
conflicts: list[Path] = []
old_dir = self.workspace / "sessions"
if old_dir.is_symlink():
raise RuntimeError(f"refusing to restore into symlinked sessions directory: {old_dir}")
ensure_dir(old_dir)
with self._migration_lock:
for src in self.sessions_dir.glob("*.jsonl"):
if self.session_key_from_path(src) is None:
continue
source_snapshot = self._session_file_snapshot(src)
if source_snapshot is None:
conflicts.append(src)
continue
dst = old_dir / src.name
if dst.exists():
destination_snapshot = self._session_file_snapshot(dst)
if (
destination_snapshot is not None
and destination_snapshot.digest == source_snapshot.digest
):
unchanged += 1
else:
conflicts.append(dst)
continue
self._install_snapshot(src, dst, source_snapshot)
restored += 1
return SessionRestoreResult(
restored=restored,
unchanged=unchanged,
conflicts=tuple(conflicts),
)
@staticmethod
def safe_key(key: str) -> str:
return safe_filename(key.replace(":", "_"))
@@ -1033,9 +1453,15 @@ class JsonlSessionStore:
class SessionManager:
"""Manage session identity, caching, retention, and persistence."""
def __init__(self, workspace: Path, *, store: SessionStore | None = None):
def __init__(
self,
workspace: Path,
*,
store: SessionStore | None = None,
sessions_root: Path | None = None,
):
self.workspace = workspace
self._jsonl_store = JsonlSessionStore(workspace)
self._jsonl_store = JsonlSessionStore(workspace, sessions_root=sessions_root)
self._store: SessionStore = store if store is not None else self._jsonl_store
self.sessions_dir = self._jsonl_store.sessions_dir
self.legacy_sessions_dir = self._jsonl_store.legacy_sessions_dir
@@ -1201,6 +1627,10 @@ class SessionManager:
self.invalidate(key)
return self._store.delete(key)
def restore_sessions_to_workspace(self) -> SessionRestoreResult:
"""Restore session files to the pre-relocation path for an explicit rollback."""
return self._jsonl_store.restore_to_workspace()
def fork_session_before_user_index(
self,
source_key: str,
+31
View File
@@ -0,0 +1,31 @@
from pathlib import Path
from typer.testing import CliRunner
from nanobot.cli import commands
from nanobot.config.loader import load_config
from nanobot.session.manager import SessionManager
def test_sessions_restore_workspace_command_prepares_downgrade(
tmp_path: Path,
monkeypatch,
) -> None:
workspace = tmp_path / "workspace"
config_path = tmp_path / "instance" / "config.json"
config = load_config(config_path)
config.agents.defaults.workspace = str(workspace)
manager = SessionManager(workspace, sessions_root=config_path.parent / "sessions")
session = manager.get_or_create("cli:rollback")
session.add_message("user", "restore-me")
manager.save(session, fsync=True)
monkeypatch.setattr(commands, "_load_runtime_config", lambda *_args: config)
result = CliRunner().invoke(commands.app, ["sessions", "restore-workspace"])
assert result.exit_code == 0, result.output
assert "Restored 1 session file(s)" in result.output
restored = workspace / "sessions" / manager._get_session_path(session.key).name
assert restored.exists()
assert "restore-me" in restored.read_text(encoding="utf-8")
assert manager._get_session_path(session.key).exists()
+17 -2
View File
@@ -3,14 +3,29 @@ import json
import pytest
from nanobot.config.errors import ConfigLoadError
from nanobot.config.loader import load_config
from nanobot.config.loader import load_config, resolve_config_env_vars
from nanobot.config.schema import ApiConfig
def test_load_config_missing_file_uses_defaults(tmp_path) -> None:
config = load_config(tmp_path / "missing.json")
config_path = tmp_path / "instance" / "missing.json"
config = load_config(config_path)
assert config.agents.defaults.model
assert config.runtime_data_dir == config_path.parent
def test_env_resolution_preserves_config_runtime_data_dir(
tmp_path, monkeypatch: pytest.MonkeyPatch
) -> None:
config_path = tmp_path / "instance" / "config.json"
config_path.parent.mkdir()
config_path.write_text('{"providers": {"openai": {"apiKey": "${TEST_API_KEY}"}}}')
monkeypatch.setenv("TEST_API_KEY", "resolved")
config = resolve_config_env_vars(load_config(config_path), config_path=config_path)
assert config.runtime_data_dir == config_path.parent
def test_load_config_reports_malformed_environment_safely(
+192 -4
View File
@@ -3,14 +3,24 @@
from __future__ import annotations
import json
import shutil
import uuid
from pathlib import Path
from unittest.mock import patch
import pytest
from nanobot.config.loader import load_config
from nanobot.session.manager import JsonlSessionStore, SessionManager
def _write_legacy_session(old_dir: Path, key: str, content: str) -> Path:
def _write_legacy_session(
old_dir: Path,
key: str,
content: str,
*,
updated_at: str = "2026-01-01T00:00:00",
) -> Path:
"""Write a valid session file in the legacy in-workspace location."""
old_dir.mkdir(parents=True, exist_ok=True)
path = old_dir / f"{JsonlSessionStore.storage_key(key)}.jsonl"
@@ -20,7 +30,7 @@ def _write_legacy_session(old_dir: Path, key: str, content: str) -> Path:
"_type": "metadata",
"key": key,
"created_at": "2026-01-01T00:00:00",
"updated_at": "2026-01-01T00:00:00",
"updated_at": updated_at,
"metadata": {},
"last_consolidated": 0,
}
@@ -45,15 +55,37 @@ def test_sessions_are_stored_outside_workspace(tmp_path: Path) -> None:
workspace_sessions = workspace / "sessions"
assert not workspace_sessions.exists() or not any(workspace_sessions.glob("*.jsonl"))
# The out-of-workspace store records which workspace it belongs to.
# The out-of-workspace store records which workspace it belongs to and the
# workspace carries only a non-secret stable identity marker.
marker = manager.sessions_dir / ".workspace"
assert marker.read_text(encoding="utf-8") == str(workspace.resolve())
assert marker.read_text(encoding="utf-8").strip() == str(workspace.resolve())
workspace_id = (workspace / ".nanobot" / "workspace-id").read_text(encoding="utf-8").strip()
assert manager.sessions_dir.name == workspace_id
assert manager.sessions_dir.parent.name == "sessions"
# And it must still round-trip through a fresh manager for the same workspace.
reloaded = SessionManager(workspace=workspace).get_or_create("telegram:1")
assert reloaded.messages[-1]["content"] == "hello"
def test_workspace_identity_marker_contains_no_session_content(tmp_path: Path) -> None:
workspace = tmp_path / "workspace"
secret = f"session-secret-{uuid.uuid4()}"
manager = SessionManager(workspace=workspace)
session = manager.get_or_create("telegram:secret")
session.add_message("user", secret)
manager.save(session)
marker = workspace / ".nanobot" / "workspace-id"
assert marker.read_text(encoding="utf-8").strip() == manager.sessions_dir.name
assert secret not in marker.read_text(encoding="utf-8")
assert not any(
secret in path.read_text(encoding="utf-8")
for path in workspace.rglob("*")
if path.is_file()
)
def test_different_workspaces_are_isolated(tmp_path: Path) -> None:
workspace_a = tmp_path / "ws_a"
workspace_b = tmp_path / "ws_b"
@@ -69,6 +101,94 @@ def test_different_workspaces_are_isolated(tmp_path: Path) -> None:
assert in_b.messages == []
def test_sessions_follow_active_custom_config_data_root(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
custom_instance = tmp_path / "instance-b"
custom_config = custom_instance / "config.json"
default_home = tmp_path / "read-only-home"
default_home.mkdir()
default_home.chmod(0o500)
monkeypatch.setenv("HOME", str(default_home))
config = load_config(custom_config)
data_dir = config.runtime_data_dir
assert data_dir == custom_instance
manager = SessionManager(
workspace=tmp_path / "workspace-b",
sessions_root=data_dir / "sessions",
)
session = manager.get_or_create("telegram:custom")
session.add_message("user", "custom-instance")
manager.save(session)
assert manager.sessions_dir.parent == custom_instance / "sessions"
assert manager._get_session_path(session.key).exists()
assert not (default_home / ".nanobot" / "sessions").exists()
def test_session_root_inside_workspace_fails_closed(tmp_path: Path) -> None:
workspace = tmp_path / "workspace"
with pytest.raises(RuntimeError, match="must be outside the agent workspace"):
SessionManager(workspace=workspace, sessions_root=workspace / "sessions")
def test_workspace_move_preserves_session_identity(tmp_path: Path) -> None:
original = tmp_path / "project-old"
manager = SessionManager(workspace=original)
session = manager.get_or_create("telegram:1")
session.add_message("user", "survives-move")
manager.save(session)
moved = tmp_path / "project-new"
original.rename(moved)
reloaded = SessionManager(workspace=moved)
assert reloaded.sessions_dir == manager.sessions_dir
assert reloaded.get_or_create("telegram:1").messages[-1]["content"] == "survives-move"
assert (reloaded.sessions_dir / ".workspace").read_text(encoding="utf-8").strip() == str(
moved.resolve()
)
def test_deleted_workspace_identity_marker_is_recovered(tmp_path: Path) -> None:
workspace = tmp_path / "project"
manager = SessionManager(workspace=workspace)
session = manager.get_or_create("telegram:1")
session.add_message("user", "survives-cleanup")
manager.save(session)
shutil.rmtree(workspace / ".nanobot")
reloaded = SessionManager(workspace=workspace)
assert reloaded.sessions_dir == manager.sessions_dir
assert reloaded.get_or_create("telegram:1").messages[-1]["content"] == "survives-cleanup"
assert (workspace / ".nanobot" / "workspace-id").read_text(encoding="utf-8").strip() == (
manager.sessions_dir.name
)
def test_copied_workspace_gets_isolated_session_identity(tmp_path: Path) -> None:
original = tmp_path / "project-a"
original.mkdir()
manager = SessionManager(workspace=original)
session = manager.get_or_create("telegram:1")
session.add_message("user", "secret-for-a")
manager.save(session)
copied = tmp_path / "project-b"
shutil.copytree(original, copied)
copied_manager = SessionManager(workspace=copied)
assert copied_manager.sessions_dir != manager.sessions_dir
assert copied_manager.get_or_create("telegram:1").messages == []
assert (copied / ".nanobot" / "workspace-id").read_text(encoding="utf-8") != (
original / ".nanobot" / "workspace-id"
).read_text(encoding="utf-8")
def test_equivalent_workspace_paths_share_one_store(tmp_path: Path) -> None:
real_workspace = tmp_path / "real_ws"
real_workspace.mkdir()
@@ -105,6 +225,74 @@ def test_legacy_in_workspace_sessions_are_migrated(tmp_path: Path) -> None:
assert again.messages[-1]["content"] == "migrated-msg"
def test_migration_keeps_source_when_install_fails(tmp_path: Path) -> None:
workspace = tmp_path / "workspace"
key = "telegram:partial"
old_file = _write_legacy_session(workspace / "sessions", key, "still-safe")
with patch.object(JsonlSessionStore, "_install_snapshot", side_effect=OSError("disk full")):
manager = SessionManager(workspace=workspace)
assert old_file.exists()
assert not (manager.sessions_dir / old_file.name).exists()
retried = SessionManager(workspace=workspace)
assert retried.get_or_create(key).messages[-1]["content"] == "still-safe"
assert not old_file.exists()
def test_migration_preserves_newest_valid_conflict(tmp_path: Path) -> None:
workspace = tmp_path / "workspace"
key = "telegram:conflict"
old_file = _write_legacy_session(workspace / "sessions", key, "newer-workspace")
manager = SessionManager(workspace=workspace)
# Recreate an older legacy source while a newer destination already exists.
manager_session = manager.get_or_create(key)
manager_session.add_message("assistant", "newer-destination")
manager.save(manager_session)
_write_legacy_session(
workspace / "sessions",
key,
"older-workspace",
updated_at="2025-01-01T00:00:00",
)
retried = SessionManager(workspace=workspace)
loaded = retried.get_or_create(key)
assert loaded.messages[-1]["content"] == "newer-destination"
conflicts = list((retried.sessions_dir / ".migration-conflicts").glob("*.jsonl"))
assert len(conflicts) == 1
assert "older-workspace" in conflicts[0].read_text(encoding="utf-8")
assert not old_file.exists()
def test_explicit_rollback_restore_copies_sessions_back_without_deleting_new_store(
tmp_path: Path,
) -> None:
workspace = tmp_path / "workspace"
manager = SessionManager(workspace=workspace)
session = manager.get_or_create("telegram:rollback")
session.add_message("user", "available-to-old-version")
manager.save(session, fsync=True)
result = manager.restore_sessions_to_workspace()
legacy_file = workspace / "sessions" / manager._get_session_path(session.key).name
assert result.restored == 1
assert result.unchanged == 0
assert result.conflicts == ()
assert legacy_file.exists()
assert manager._get_session_path(session.key).exists()
assert "available-to-old-version" in legacy_file.read_text(encoding="utf-8")
repeated = manager.restore_sessions_to_workspace()
assert repeated.restored == 0
assert repeated.unchanged == 1
assert repeated.conflicts == ()
def test_legacy_migration_rejects_symlinked_session_file(tmp_path: Path) -> None:
workspace = tmp_path / "workspace"
old_dir = workspace / "sessions"
+7 -3
View File
@@ -46,7 +46,9 @@ def _write_config(tmp_path: Path, overrides: dict | None = None) -> Path:
}
if overrides:
data.update(overrides)
config_path = tmp_path / "config.json"
config_dir = tmp_path.parent / f"{tmp_path.name}-instance"
config_dir.mkdir(exist_ok=True)
config_path = config_dir / "config.json"
config_path.write_text(json.dumps(data))
return config_path
@@ -96,9 +98,11 @@ def test_from_config_missing_env_reports_explicit_config_path(
def test_from_config_creates_instance(tmp_path):
config_path = _write_config(tmp_path)
bot = Nanobot.from_config(config_path, workspace=tmp_path)
workspace = tmp_path / "workspace"
bot = Nanobot.from_config(config_path, workspace=workspace)
assert bot._loop is not None
assert bot._loop.workspace == tmp_path
assert bot._loop.workspace == workspace
assert bot._loop.sessions.sessions_dir.parent == config_path.parent / "sessions"
def test_from_config_composes_configured_mcp_outside_agent_loop(tmp_path):