3854 lines
139 KiB
Python
3854 lines
139 KiB
Python
"""OpenSquilla self-migration: legacy home import into the current home.
|
|
|
|
All homes here are synthetic (dummy values only) and built in tmp_path; the
|
|
config content reuses the golden cli-0.1 fixture so the import exercises a
|
|
real released-era config shape.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import multiprocessing
|
|
import os
|
|
import shutil
|
|
import sqlite3
|
|
import subprocess
|
|
import sys
|
|
import threading
|
|
import time
|
|
import tomllib
|
|
import uuid
|
|
from pathlib import Path
|
|
from types import SimpleNamespace
|
|
from typing import Any
|
|
|
|
import pytest
|
|
import tomli_w
|
|
|
|
import opensquilla.gateway.config_migration as config_migration_module
|
|
import opensquilla.migration.opensquilla_home as migration_module
|
|
import opensquilla.recovery as recovery_module
|
|
from opensquilla.artifacts import ArtifactStore
|
|
from opensquilla.attachment_refs import (
|
|
make_attachment_ref,
|
|
read_attachment_ref_bytes,
|
|
write_transcript_material,
|
|
)
|
|
from opensquilla.migration import orchestrator
|
|
from opensquilla.migration.opensquilla_home import (
|
|
IMPORT_MARKER_FILENAME,
|
|
OpenSquillaHomeMigrator,
|
|
OpenSquillaMigrationOptions,
|
|
detect_legacy_cli_home,
|
|
enumerate_portable_homes,
|
|
inspect_opensquilla_home_candidate,
|
|
is_valid_opensquilla_home,
|
|
)
|
|
from opensquilla.persistence.migrator import apply_pending
|
|
from opensquilla.recovery.locking import ProfileOperationLock
|
|
from opensquilla.recovery.restore import restore_profile
|
|
from opensquilla.recovery.transaction import recover_profile_transaction
|
|
from opensquilla.session.manager import SessionManager
|
|
from opensquilla.session.models import TranscriptEntry
|
|
from opensquilla.session.storage import SessionStorage
|
|
|
|
FIXTURE_CONFIG = Path(__file__).parent / "fixtures" / "homes" / "cli-0.1" / "config.toml"
|
|
MIGRATIONS_DIR = Path(__file__).resolve().parents[2] / "migrations"
|
|
PORTABLE_RELEASE_MANIFEST = (
|
|
Path(__file__).resolve().parents[1]
|
|
/ "test_recovery"
|
|
/ "fixtures"
|
|
/ "portable"
|
|
/ "released-profiles.json"
|
|
)
|
|
PORTABLE_RELEASES = json.loads(
|
|
PORTABLE_RELEASE_MANIFEST.read_text(encoding="utf-8")
|
|
)["published_releases"]
|
|
|
|
DUMMY_INLINE_KEY = "dummy-inline-key-123"
|
|
|
|
REPORT_KEYS = {
|
|
"source",
|
|
"source_kind",
|
|
"target",
|
|
"output_dir",
|
|
"apply",
|
|
"items",
|
|
"candidates",
|
|
"config_transforms",
|
|
"secret_relocations",
|
|
"paused_jobs",
|
|
"preflight",
|
|
"notes",
|
|
}
|
|
|
|
|
|
def _normalized_path(path: Path) -> str:
|
|
return os.path.normcase(os.path.normpath(str(path.resolve())))
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _isolate_profile_operation_locks(
|
|
tmp_path: Path,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""Never leave migration test locks in the runner's real user-state tree."""
|
|
|
|
monkeypatch.setenv("OPENSQUILLA_TEST", "1")
|
|
monkeypatch.setenv("OPENSQUILLA_USER_STATE_DIR", str(tmp_path / "user-state"))
|
|
|
|
|
|
def _probe_gateway_lock(state_dir: str, queue: multiprocessing.Queue) -> None:
|
|
from opensquilla.gateway.pidlock import GatewayPidLock
|
|
|
|
lock = GatewayPidLock(state_dir)
|
|
try:
|
|
lock.acquire()
|
|
except SystemExit:
|
|
queue.put("busy")
|
|
else:
|
|
queue.put("acquired")
|
|
lock.release()
|
|
|
|
# Base scheduler_jobs DDL as scheduler/persistence.py creates it (the
|
|
# ``enabled`` column arrived later via a conditional column add).
|
|
_SCHEDULER_JOBS_DDL = """
|
|
CREATE TABLE IF NOT EXISTS scheduler_jobs (
|
|
id TEXT PRIMARY KEY,
|
|
name TEXT NOT NULL DEFAULT '',
|
|
cron_expr TEXT NOT NULL,
|
|
handler_key TEXT NOT NULL,
|
|
payload TEXT NOT NULL DEFAULT '{}',
|
|
status TEXT NOT NULL DEFAULT 'pending',
|
|
created_at TEXT NOT NULL,
|
|
updated_at TEXT NOT NULL,
|
|
last_run_at TEXT,
|
|
next_run_at TEXT,
|
|
run_count INTEGER NOT NULL DEFAULT 0,
|
|
error_count INTEGER NOT NULL DEFAULT 0,
|
|
last_error TEXT,
|
|
max_retries INTEGER NOT NULL DEFAULT 3,
|
|
jitter_seconds REAL NOT NULL DEFAULT 0.0
|
|
)
|
|
"""
|
|
|
|
|
|
def _write_config(home: Path) -> None:
|
|
payload = tomllib.loads(FIXTURE_CONFIG.read_text(encoding="utf-8"))
|
|
payload["llm"]["api_key"] = DUMMY_INLINE_KEY
|
|
(home / "config.toml").write_text(tomli_w.dumps(payload), encoding="utf-8")
|
|
|
|
|
|
def _write_sessions_db(home: Path, applied_ids: tuple[str, ...]) -> None:
|
|
db_path = home / "state" / "sessions.db"
|
|
db_path.parent.mkdir(parents=True, exist_ok=True)
|
|
connection = sqlite3.connect(db_path)
|
|
try:
|
|
connection.execute(
|
|
"CREATE TABLE _yoyo_migration ("
|
|
"migration_hash TEXT PRIMARY KEY, "
|
|
"migration_id TEXT, "
|
|
"applied_at_utc TIMESTAMP)"
|
|
)
|
|
for index, migration_id in enumerate(applied_ids):
|
|
connection.execute(
|
|
"INSERT INTO _yoyo_migration VALUES (?, ?, ?)",
|
|
(f"dummy-hash-{index}", migration_id, "2026-01-01 00:00:00"),
|
|
)
|
|
connection.execute(
|
|
"CREATE TABLE synthetic_sessions (session_id TEXT PRIMARY KEY, transcript TEXT)"
|
|
)
|
|
connection.execute(
|
|
"INSERT INTO synthetic_sessions VALUES (?, ?)",
|
|
("synthetic-session", "synthetic portable conversation"),
|
|
)
|
|
connection.commit()
|
|
finally:
|
|
connection.close()
|
|
|
|
|
|
def _write_scheduler_db(home: Path, *, with_enabled_column: bool) -> None:
|
|
db_path = home / "state" / "scheduler.db"
|
|
db_path.parent.mkdir(parents=True, exist_ok=True)
|
|
connection = sqlite3.connect(db_path)
|
|
try:
|
|
connection.execute(_SCHEDULER_JOBS_DDL)
|
|
if with_enabled_column:
|
|
connection.execute(
|
|
"ALTER TABLE scheduler_jobs ADD COLUMN enabled INTEGER NOT NULL DEFAULT 1"
|
|
)
|
|
connection.execute(
|
|
"INSERT INTO scheduler_jobs "
|
|
"(id, name, cron_expr, handler_key, payload, status, created_at, updated_at) "
|
|
"VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
|
|
(
|
|
"job-1",
|
|
"dummy daily job",
|
|
"0 9 * * *",
|
|
"agent_turn",
|
|
"{}",
|
|
"pending",
|
|
"2026-01-01 00:00:00",
|
|
"2026-01-01 00:00:00",
|
|
),
|
|
)
|
|
connection.commit()
|
|
finally:
|
|
connection.close()
|
|
|
|
|
|
def _build_source_home(
|
|
root: Path,
|
|
*,
|
|
with_enabled_column: bool = True,
|
|
applied_ids: tuple[str, ...] = ("V001__initial_schema",),
|
|
) -> Path:
|
|
home = root / "legacy-home"
|
|
(home / "workspace" / "memory").mkdir(parents=True)
|
|
identity_documents = {
|
|
"USER.md": "# Synthetic user\n\nCall me Portable Tester.\n",
|
|
"SOUL.md": "# Synthetic soul\n\nBe precise and reversible.\n",
|
|
"IDENTITY.md": "# Synthetic identity\n\nName: Import Squilla.\n",
|
|
"MEMORY.md": "# Memory index\n\n- dummy entry\n",
|
|
}
|
|
for name, content in identity_documents.items():
|
|
(home / "workspace" / name).write_text(content, encoding="utf-8")
|
|
(home / "workspace" / "memory" / "2026-01-01.md").write_text(
|
|
"- dated dummy note\n", encoding="utf-8"
|
|
)
|
|
(home / "skills" / "dummy-skill").mkdir(parents=True)
|
|
(home / "skills" / "dummy-skill" / "SKILL.md").write_text(
|
|
"---\nname: dummy-skill\ndescription: a dummy skill\n---\n\nBody.\n",
|
|
encoding="utf-8",
|
|
)
|
|
(home / ".env").write_text("OPENROUTER_API_KEY=dummy\n", encoding="utf-8")
|
|
(home / "profiles" / "dummy-profile").mkdir(parents=True)
|
|
(home / "profiles" / "dummy-profile" / "config.toml").write_text(
|
|
"port = 18791\n", encoding="utf-8"
|
|
)
|
|
_write_config(home)
|
|
_write_sessions_db(home, applied_ids)
|
|
_write_scheduler_db(home, with_enabled_column=with_enabled_column)
|
|
return home
|
|
|
|
|
|
def _run(
|
|
source: Path,
|
|
target: Path,
|
|
*,
|
|
apply: bool = False,
|
|
overwrite: bool = False,
|
|
replace_target: bool = False,
|
|
confirm_replace_target: Path | None = None,
|
|
) -> dict[str, Any]:
|
|
options = OpenSquillaMigrationOptions(
|
|
source=source,
|
|
target=target,
|
|
apply=apply,
|
|
overwrite=overwrite,
|
|
replace_target=replace_target,
|
|
confirm_replace_target=confirm_replace_target,
|
|
)
|
|
return OpenSquillaHomeMigrator(options).migrate()
|
|
|
|
|
|
def _errors(report: dict[str, Any]) -> list[dict[str, Any]]:
|
|
return [item for item in report["items"] if item["status"] == "error"]
|
|
|
|
|
|
def _scheduler_enabled_values(db_path: Path) -> list[int]:
|
|
connection = sqlite3.connect(db_path)
|
|
try:
|
|
return [row[0] for row in connection.execute("SELECT enabled FROM scheduler_jobs")]
|
|
finally:
|
|
connection.close()
|
|
|
|
|
|
def _file_bytes(root: Path) -> dict[str, bytes]:
|
|
return {
|
|
str(path.relative_to(root)): path.read_bytes()
|
|
for path in sorted(root.rglob("*"))
|
|
if path.is_file()
|
|
}
|
|
|
|
|
|
def _assert_imported_identity_chat_and_config(target: Path) -> None:
|
|
expected_documents = {
|
|
"USER.md": "# Synthetic user\n\nCall me Portable Tester.\n",
|
|
"SOUL.md": "# Synthetic soul\n\nBe precise and reversible.\n",
|
|
"IDENTITY.md": "# Synthetic identity\n\nName: Import Squilla.\n",
|
|
"MEMORY.md": "# Memory index\n\n- dummy entry\n",
|
|
}
|
|
for name, content in expected_documents.items():
|
|
assert (target / "workspace" / name).read_text(encoding="utf-8") == content
|
|
|
|
with sqlite3.connect(
|
|
f"file:{target / 'state' / 'sessions.db'}?mode=ro",
|
|
uri=True,
|
|
) as connection:
|
|
assert connection.execute("PRAGMA quick_check").fetchone() == ("ok",)
|
|
assert connection.execute(
|
|
"SELECT session_id, transcript FROM synthetic_sessions"
|
|
).fetchone() == ("synthetic-session", "synthetic portable conversation")
|
|
|
|
config = tomllib.loads((target / "config.toml").read_text(encoding="utf-8"))
|
|
assert "state_dir" not in config
|
|
assert "workspace_dir" not in config
|
|
assert config["port"] == 18791
|
|
assert config["llm"]["provider"] == "openrouter"
|
|
assert config["llm"]["api_key_env"] == "OPENROUTER_API_KEY"
|
|
assert "api_key" not in config["llm"]
|
|
env_text = (target / ".env").read_text(encoding="utf-8")
|
|
assert f"OPENROUTER_API_KEY={DUMMY_INLINE_KEY}" in env_text
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 1. Dry-run
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_dry_run_produces_full_report_and_writes_nothing(tmp_path: Path) -> None:
|
|
source = _build_source_home(tmp_path)
|
|
target = tmp_path / "target-home"
|
|
|
|
report = _run(source, target, apply=False)
|
|
|
|
assert set(report) == REPORT_KEYS
|
|
assert report["apply"] is False
|
|
assert report["output_dir"] == ""
|
|
assert not _errors(report)
|
|
|
|
# Planned home entries, with profiles/ excluded.
|
|
planned = {
|
|
Path(item["source"]).name
|
|
for item in report["items"]
|
|
if item["kind"] == "home-entry" and item["status"] == "planned"
|
|
}
|
|
assert {"workspace", "skills", "state", "config.toml", ".env"} <= planned
|
|
skipped = [
|
|
item
|
|
for item in report["items"]
|
|
if item["kind"] == "home-entry" and item["status"] == "skipped"
|
|
]
|
|
assert any(Path(item["source"]).name == "profiles" for item in skipped)
|
|
|
|
# Paused-jobs preview read from the source scheduler.db.
|
|
assert report["paused_jobs"] == [
|
|
{"id": "job-1", "name": "dummy daily job", "cron_expr": "0 9 * * *"}
|
|
]
|
|
|
|
# Transform and secret plans are present, redacted.
|
|
assert any("port: 18790 -> 18791" in entry for entry in report["config_transforms"])
|
|
assert {"config_path": "llm.api_key", "env_key": "OPENROUTER_API_KEY", "moved": True} in (
|
|
report["secret_relocations"]
|
|
)
|
|
assert DUMMY_INLINE_KEY not in json.dumps(report)
|
|
|
|
# Nothing was written anywhere: no target, no output dir, no staging,
|
|
# and the source home is untouched.
|
|
assert not target.exists()
|
|
assert not list(tmp_path.glob(".opensquilla-import-*"))
|
|
assert not (source / IMPORT_MARKER_FILENAME).exists()
|
|
source_config = tomllib.loads((source / "config.toml").read_text(encoding="utf-8"))
|
|
assert source_config["port"] == 18790
|
|
assert source_config["llm"]["api_key"] == DUMMY_INLINE_KEY
|
|
|
|
|
|
def test_dry_run_reports_only_the_session_count_for_candidate_metadata(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
source = _build_source_home(tmp_path)
|
|
connection = sqlite3.connect(source / "state" / "sessions.db")
|
|
try:
|
|
connection.execute("CREATE TABLE sessions (id TEXT PRIMARY KEY)")
|
|
connection.executemany(
|
|
"INSERT INTO sessions (id) VALUES (?)",
|
|
[("synthetic-session-a",), ("synthetic-session-b",)],
|
|
)
|
|
connection.commit()
|
|
finally:
|
|
connection.close()
|
|
|
|
report = _run(source, tmp_path / "target-home", apply=False)
|
|
|
|
assert report["preflight"]["session_count"] == 2
|
|
serialized = json.dumps(report)
|
|
assert "synthetic-session-a" not in serialized
|
|
assert "synthetic-session-b" not in serialized
|
|
|
|
|
|
def test_direct_migrator_rejects_config_path_without_writing(tmp_path: Path) -> None:
|
|
source = _build_source_home(tmp_path)
|
|
target = tmp_path / "target-home"
|
|
|
|
report = OpenSquillaHomeMigrator(
|
|
OpenSquillaMigrationOptions(
|
|
source=source,
|
|
target=target,
|
|
config_path=tmp_path / "unsupported.toml",
|
|
apply=True,
|
|
)
|
|
).migrate()
|
|
|
|
assert any(
|
|
item["kind"] == "options" and "config_path is not supported" in item["reason"]
|
|
for item in _errors(report)
|
|
)
|
|
assert not target.exists()
|
|
assert not (source / IMPORT_MARKER_FILENAME).exists()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 2. Apply
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_apply_imports_home_with_transforms(tmp_path: Path) -> None:
|
|
source = _build_source_home(tmp_path, with_enabled_column=True)
|
|
target = tmp_path / "target-home"
|
|
|
|
report = _run(source, target, apply=True)
|
|
|
|
assert not _errors(report)
|
|
assert report["apply"] is True
|
|
_assert_imported_identity_chat_and_config(target)
|
|
|
|
# Entries landed; profiles/ excluded; runtime pid locks excluded.
|
|
assert (target / "workspace" / "MEMORY.md").is_file()
|
|
assert (target / "workspace" / "memory" / "2026-01-01.md").is_file()
|
|
assert (target / "skills" / "dummy-skill" / "SKILL.md").is_file()
|
|
assert (target / "state" / "sessions.db").is_file()
|
|
assert not (target / "profiles").exists()
|
|
assert not (target / "state" / "gateway.pid").exists()
|
|
assert not (target / "desktop-layout-v2.json").exists()
|
|
|
|
# Config transforms: absolute path pins dropped, port coerced, secret
|
|
# relocated to .env with the env pointer left behind.
|
|
config = tomllib.loads((target / "config.toml").read_text(encoding="utf-8"))
|
|
assert "state_dir" not in config
|
|
assert "workspace_dir" not in config
|
|
assert config["port"] == 18791
|
|
assert "api_key" not in config["llm"]
|
|
assert config["llm"]["api_key_env"] == "OPENROUTER_API_KEY"
|
|
|
|
env_text = (target / ".env").read_text(encoding="utf-8")
|
|
assert f"OPENROUTER_API_KEY={DUMMY_INLINE_KEY}" in env_text
|
|
assert env_text.count("OPENROUTER_API_KEY=") == 1
|
|
|
|
# Imported scheduler jobs all arrive paused.
|
|
assert _scheduler_enabled_values(target / "state" / "scheduler.db") == [0]
|
|
assert report["paused_jobs"] == [
|
|
{"id": "job-1", "name": "dummy daily job", "cron_expr": "0 9 * * *"}
|
|
]
|
|
|
|
# The report directory contains only narrow protocol/summary artifacts,
|
|
# never a duplicate database or identity/memory document.
|
|
output_dir = Path(report["output_dir"])
|
|
assert {
|
|
path.relative_to(output_dir).as_posix()
|
|
for path in output_dir.rglob("*")
|
|
if path.is_file()
|
|
} == {"layout-receipt.json", "report.json", "summary.md"}
|
|
|
|
# No staging dir left behind.
|
|
assert not list(tmp_path.glob(".opensquilla-import-*"))
|
|
|
|
# RC4 keeps the source strictly read-only; target receipts are authoritative.
|
|
assert not (source / IMPORT_MARKER_FILENAME).exists()
|
|
source_config = tomllib.loads((source / "config.toml").read_text(encoding="utf-8"))
|
|
assert source_config["port"] == 18790
|
|
assert source_config["llm"]["api_key"] == DUMMY_INLINE_KEY
|
|
assert _scheduler_enabled_values(source / "state" / "scheduler.db") == [1]
|
|
|
|
# The report never carries the secret value.
|
|
assert DUMMY_INLINE_KEY not in json.dumps(report)
|
|
|
|
|
|
def test_profile_import_preserves_unmodified_toml_bytes_and_comments(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
source = _build_source_home(tmp_path)
|
|
source_config = (
|
|
b"# operator header\n"
|
|
b"port = 18791 # keep exact spacing\n"
|
|
b"config_version = 1\n"
|
|
)
|
|
(source / "config.toml").write_bytes(source_config)
|
|
target = tmp_path / "target-home"
|
|
|
|
report = _run(source, target, apply=True)
|
|
|
|
assert not _errors(report)
|
|
assert (target / "config.toml").read_bytes() == source_config
|
|
|
|
|
|
def test_profile_import_losslessly_patches_legacy_paths_and_secret_comments(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
source = _build_source_home(tmp_path)
|
|
config_path = source / "config.toml"
|
|
text = config_path.read_text(encoding="utf-8")
|
|
original = tomllib.loads(text)
|
|
text = text.replace(
|
|
f"state_dir = {json.dumps(original['state_dir'])}",
|
|
f"state_dir = {json.dumps(original['state_dir'])} # state pin note",
|
|
).replace(
|
|
f"workspace_dir = {json.dumps(original['workspace_dir'])}",
|
|
f"workspace_dir = {json.dumps(original['workspace_dir'])} # identity pin note",
|
|
).replace(
|
|
"port = 18790",
|
|
"port = 18790 # legacy port note",
|
|
).replace(
|
|
f'api_key = "{DUMMY_INLINE_KEY}"',
|
|
f'api_key = "{DUMMY_INLINE_KEY}" # credential relocation note',
|
|
)
|
|
config_path.write_text("# profile comments survive\n" + text, encoding="utf-8")
|
|
target = tmp_path / "target-home"
|
|
|
|
report = _run(source, target, apply=True)
|
|
|
|
assert not _errors(report)
|
|
imported_text = (target / "config.toml").read_text(encoding="utf-8")
|
|
imported = tomllib.loads(imported_text)
|
|
assert "state_dir" not in imported
|
|
assert "workspace_dir" not in imported
|
|
assert imported["port"] == 18791
|
|
assert imported["llm"]["api_key_env"] == "OPENROUTER_API_KEY"
|
|
assert "profile comments survive" in imported_text
|
|
assert "state pin note" in imported_text
|
|
assert "identity pin note" in imported_text
|
|
assert "legacy port note" in imported_text
|
|
assert "credential relocation note" in imported_text
|
|
|
|
|
|
def test_external_profile_pin_is_rebased_without_reformatting_other_toml(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
source = _build_source_home(tmp_path)
|
|
shutil.rmtree(source / "workspace")
|
|
external = tmp_path / "external-workspace"
|
|
external.mkdir()
|
|
(external / "IDENTITY.md").write_text("external\n", encoding="utf-8")
|
|
config_path = source / "config.toml"
|
|
payload = tomllib.loads(config_path.read_text(encoding="utf-8"))
|
|
payload["workspace_dir"] = str(external)
|
|
rendered = "# external workspace must be snapshotted\n" + tomli_w.dumps(payload)
|
|
rendered = rendered.replace(
|
|
f'workspace_dir = {json.dumps(str(external))}',
|
|
f'workspace_dir = {json.dumps(str(external))} # external pin note',
|
|
)
|
|
config_path.write_text(rendered, encoding="utf-8")
|
|
target = tmp_path / "target-home"
|
|
|
|
report = _run(source, target, apply=True)
|
|
|
|
assert not _errors(report)
|
|
imported_text = (target / "config.toml").read_text(encoding="utf-8")
|
|
imported = tomllib.loads(imported_text)
|
|
assert "workspace_dir" not in imported
|
|
assert "external workspace must be snapshotted" in imported_text
|
|
assert "external pin note" in imported_text
|
|
assert (target / "workspace" / "IDENTITY.md").read_text(encoding="utf-8") == (
|
|
"external\n"
|
|
)
|
|
|
|
|
|
def test_desktop_import_finalizes_rc3_layout_marker_only_after_commit(
|
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
source = _build_source_home(tmp_path)
|
|
target = tmp_path / "desktop-home"
|
|
monkeypatch.setenv("OPENSQUILLA_PROFILE_KIND", "desktop-primary")
|
|
|
|
report = _run(source, target, apply=True)
|
|
|
|
assert not _errors(report)
|
|
marker = json.loads((target / "desktop-layout-v2.json").read_text(encoding="utf-8"))
|
|
assert marker["schema_version"] == 2
|
|
assert marker["protectedBy"] == "rc4"
|
|
assert not (tmp_path / ".desktop-home.profile-replace.json").exists()
|
|
|
|
|
|
def test_imported_config_and_env_are_owner_only_even_without_secret_rewrite(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
source = _build_source_home(tmp_path)
|
|
payload = tomllib.loads((source / "config.toml").read_text(encoding="utf-8"))
|
|
payload["llm"].pop("api_key", None)
|
|
payload["llm"]["api_key_env"] = "OPENROUTER_API_KEY"
|
|
(source / "config.toml").write_text(tomli_w.dumps(payload), encoding="utf-8")
|
|
os.chmod(source / "config.toml", 0o644)
|
|
os.chmod(source / ".env", 0o644)
|
|
target = tmp_path / "target-home"
|
|
|
|
report = _run(source, target, apply=True)
|
|
|
|
assert not _errors(report)
|
|
if os.name != "nt":
|
|
assert (target / "config.toml").stat().st_mode & 0o777 == 0o600
|
|
assert (target / ".env").stat().st_mode & 0o777 == 0o600
|
|
|
|
|
|
def test_read_only_source_directories_produce_writable_imported_runtime(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
source = _build_source_home(tmp_path)
|
|
source_sessions = source / "state" / "sessions.db"
|
|
source_sessions_bytes = source_sessions.read_bytes()
|
|
os.chmod(source_sessions, 0o444)
|
|
os.chmod(source / "state", 0o555)
|
|
os.chmod(source / "workspace", 0o555)
|
|
target = tmp_path / "target-home"
|
|
|
|
report = _run(source, target, apply=True)
|
|
|
|
assert not _errors(report)
|
|
assert source_sessions.read_bytes() == source_sessions_bytes
|
|
assert source_sessions.stat().st_mode & 0o200 == 0
|
|
if os.name != "nt":
|
|
assert (target / "state").stat().st_mode & 0o700 == 0o700
|
|
assert (target / "workspace").stat().st_mode & 0o700 == 0o700
|
|
assert (target / "state" / "sessions.db").stat().st_mode & 0o600 == 0o600
|
|
connection = sqlite3.connect(target / "state" / "sessions.db")
|
|
try:
|
|
connection.execute("CREATE TABLE writable_after_import (id INTEGER)")
|
|
connection.commit()
|
|
finally:
|
|
connection.close()
|
|
|
|
|
|
def test_apply_pauses_jobs_when_enabled_column_is_absent(tmp_path: Path) -> None:
|
|
source = _build_source_home(tmp_path, with_enabled_column=False)
|
|
target = tmp_path / "target-home"
|
|
|
|
report = _run(source, target, apply=True)
|
|
|
|
assert not _errors(report)
|
|
# The column was added pre-seeded at 0, so JobStore's later conditional
|
|
# add (default 1) never fires and imported jobs arrive paused.
|
|
assert _scheduler_enabled_values(target / "state" / "scheduler.db") == [0]
|
|
assert report["paused_jobs"] == [
|
|
{"id": "job-1", "name": "dummy daily job", "cron_expr": "0 9 * * *"}
|
|
]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 3. Non-empty target gate
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_non_empty_target_refused_without_overwrite(tmp_path: Path) -> None:
|
|
source = _build_source_home(tmp_path)
|
|
target = tmp_path / "target-home"
|
|
(target / "state").mkdir(parents=True)
|
|
(target / "state" / "sessions.db").write_bytes(b"dummy existing db")
|
|
|
|
report = _run(source, target, apply=True)
|
|
|
|
errors = _errors(report)
|
|
assert errors
|
|
assert any("--replace-target" in item["reason"] for item in errors)
|
|
# Nothing was imported.
|
|
assert not (target / "workspace").exists()
|
|
assert (target / "state" / "sessions.db").read_bytes() == b"dummy existing db"
|
|
assert not (source / IMPORT_MARKER_FILENAME).exists()
|
|
|
|
|
|
def test_overwrite_takes_timestamped_backups(tmp_path: Path) -> None:
|
|
source = _build_source_home(tmp_path)
|
|
target = tmp_path / "target-home"
|
|
(target / "state").mkdir(parents=True)
|
|
(target / "state" / "sessions.db").write_bytes(b"dummy existing db")
|
|
(target / "old-only.txt").write_text("must not be merged", encoding="utf-8")
|
|
|
|
report = _run(
|
|
source,
|
|
target,
|
|
apply=True,
|
|
overwrite=True,
|
|
confirm_replace_target=target.resolve(),
|
|
)
|
|
|
|
assert not _errors(report)
|
|
_assert_imported_identity_chat_and_config(target)
|
|
backups = list(tmp_path.glob("target-home.backup.*"))
|
|
assert len(backups) == 1
|
|
assert (backups[0] / "state" / "sessions.db").read_bytes() == b"dummy existing db"
|
|
assert (backups[0] / "old-only.txt").read_text(encoding="utf-8") == "must not be merged"
|
|
# The imported store replaced the old one.
|
|
assert (target / "state" / "sessions.db").read_bytes() != b"dummy existing db"
|
|
assert not (target / "old-only.txt").exists()
|
|
persisted = json.loads(
|
|
(Path(report["output_dir"]) / "report.json").read_text(encoding="utf-8")
|
|
)
|
|
returned_backups = [item for item in report["items"] if item["kind"] == "backup"]
|
|
assert returned_backups
|
|
# The interactive report can explain the retained backup, but the durable
|
|
# diagnostic is intentionally counts-only and never stores item rows.
|
|
assert "items" not in persisted
|
|
assert persisted["item_counts"]["migrated"] >= len(returned_backups)
|
|
history = json.loads(
|
|
(tmp_path / "profile-replacement-history.json").read_text(encoding="utf-8")
|
|
)
|
|
assert history["schema_version"] == 1
|
|
record = history["backups"][0]
|
|
assert record["backup"] == _normalized_path(backups[0])
|
|
assert str(uuid.UUID(record["transaction_id"])) == record["transaction_id"]
|
|
for identity_key in ("source_identity", "target_identity", "backup_identity"):
|
|
assert {"device", "inode", "file_type", "mode", "size", "modified_at_ns"} <= set(
|
|
record[identity_key]
|
|
)
|
|
|
|
|
|
def test_published_target_validation_never_ignores_a_replaced_journal(
|
|
tmp_path: Path,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
source = _build_source_home(tmp_path)
|
|
source_before = _file_bytes(source)
|
|
target = tmp_path / "target-home"
|
|
journal = tmp_path / ".target-home.profile-replace.json"
|
|
original_inspect = recovery_module.inspect_profile
|
|
|
|
def replace_journal_before_inspection(*args: Any, **kwargs: Any) -> Any:
|
|
unrelated = json.loads(journal.read_text(encoding="utf-8"))
|
|
unrelated["transaction_id"] = str(uuid.uuid4())
|
|
unrelated["source"] = str((tmp_path / "unrelated-source").resolve())
|
|
journal.write_text(json.dumps(unrelated), encoding="utf-8")
|
|
return original_inspect(*args, **kwargs)
|
|
|
|
monkeypatch.setattr(
|
|
recovery_module,
|
|
"inspect_profile",
|
|
replace_journal_before_inspection,
|
|
)
|
|
|
|
report = _run(source, target, apply=True)
|
|
|
|
assert _errors(report)
|
|
assert any(
|
|
"journal" in item["reason"] and "transaction was not completed" in item["reason"]
|
|
for item in _errors(report)
|
|
)
|
|
assert not target.exists()
|
|
assert journal.is_file(), "an unrelated journal must never be removed"
|
|
assert _file_bytes(source) == source_before
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("authority_name", "initial", "changed"),
|
|
[
|
|
("gateway.pid", None, b"not-a-pid\n"),
|
|
("gateway.pid.lock", None, b"appeared\n"),
|
|
("gateway.pid", b"stale-pid\n", b"changed-pid\n"),
|
|
("gateway.pid.lock", b"existing\n", b"changed-lock\n"),
|
|
],
|
|
)
|
|
def test_excluded_source_gateway_authority_change_blocks_publication(
|
|
tmp_path: Path,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
authority_name: str,
|
|
initial: bytes | None,
|
|
changed: bytes,
|
|
) -> None:
|
|
source = _build_source_home(tmp_path)
|
|
authority = source / "state" / authority_name
|
|
if initial is not None:
|
|
authority.write_bytes(initial)
|
|
target = tmp_path / "target-home"
|
|
original_write_env = OpenSquillaHomeMigrator._write_staged_env
|
|
mutation_errors: list[OSError] = []
|
|
|
|
def mutate_excluded_authority(
|
|
migrator: OpenSquillaHomeMigrator,
|
|
staging: Path,
|
|
) -> None:
|
|
original_write_env(migrator, staging)
|
|
try:
|
|
authority.write_bytes(changed)
|
|
except OSError as exc:
|
|
mutation_errors.append(exc)
|
|
raise
|
|
|
|
monkeypatch.setattr(
|
|
OpenSquillaHomeMigrator,
|
|
"_write_staged_env",
|
|
mutate_excluded_authority,
|
|
)
|
|
|
|
report = _run(source, target, apply=True)
|
|
|
|
assert _errors(report)
|
|
assert not target.exists(), _errors(report)
|
|
if mutation_errors:
|
|
# Windows can reject a write to the byte-range-locked authority before
|
|
# the post-copy digest check runs. That is the stronger fail-closed
|
|
# outcome and is valid only for a lock leaf that already existed.
|
|
assert sys.platform == "win32"
|
|
assert authority_name == "gateway.pid.lock"
|
|
assert initial is not None
|
|
assert authority.read_bytes() == initial
|
|
assert any(
|
|
"import failed before completion" in item["reason"]
|
|
for item in _errors(report)
|
|
)
|
|
else:
|
|
if sys.platform == "win32" and authority_name == "gateway.pid.lock":
|
|
# A same-process Windows rewrite of the byte-range-locked leaf can
|
|
# either persist or be refused without surfacing an OSError to the
|
|
# Python write call. The safety contract is that the source remains
|
|
# one of those two complete values and no target is published; the
|
|
# exact lower-level diagnostic is not stable.
|
|
assert authority.read_bytes() in {initial, changed}
|
|
else:
|
|
assert authority.read_bytes() == changed
|
|
assert any(
|
|
"gateway authority changed" in item["reason"]
|
|
for item in _errors(report)
|
|
)
|
|
|
|
|
|
@pytest.mark.parametrize("alias", [False, True])
|
|
def test_replacement_requires_exact_target_confirmation(
|
|
tmp_path: Path, alias: bool
|
|
) -> None:
|
|
source = _build_source_home(tmp_path)
|
|
target = tmp_path / "target-home"
|
|
target.mkdir()
|
|
existing = target / "existing.txt"
|
|
existing.write_text("preserve", encoding="utf-8")
|
|
|
|
report = _run(
|
|
source,
|
|
target,
|
|
apply=True,
|
|
overwrite=alias,
|
|
replace_target=not alias,
|
|
confirm_replace_target=tmp_path / "wrong-target",
|
|
)
|
|
|
|
assert any("exact confirmation" in item["reason"] for item in _errors(report))
|
|
assert existing.read_text(encoding="utf-8") == "preserve"
|
|
assert not list(tmp_path.glob("target-home.backup.*"))
|
|
|
|
|
|
def test_existing_empty_target_is_parked_and_published_transactionally(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
source = _build_source_home(tmp_path)
|
|
target = tmp_path / "target-home"
|
|
target.mkdir()
|
|
|
|
report = _run(source, target, apply=True)
|
|
|
|
assert not _errors(report)
|
|
assert (target / "workspace" / "MEMORY.md").is_file()
|
|
backups = list(tmp_path.glob("target-home.backup.*"))
|
|
assert len(backups) == 1
|
|
assert not any(backups[0].iterdir())
|
|
history = json.loads(
|
|
(tmp_path / "profile-replacement-history.json").read_text(encoding="utf-8")
|
|
)
|
|
assert history["backups"][0]["backup"] == _normalized_path(backups[0])
|
|
|
|
|
|
def test_committed_replacement_history_can_restore_complete_previous_profile(
|
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
monkeypatch.setenv("OPENSQUILLA_USER_STATE_DIR", str(tmp_path / "user-state"))
|
|
source = _build_source_home(tmp_path / "source-root")
|
|
target = tmp_path / "target-home"
|
|
(target / "workspace").mkdir(parents=True)
|
|
(target / "workspace" / "SOUL.md").write_text("previous profile\n", encoding="utf-8")
|
|
(target / "state").mkdir()
|
|
(target / "config.toml").write_text("port = 18791\n", encoding="utf-8")
|
|
|
|
imported = _run(
|
|
source,
|
|
target,
|
|
apply=True,
|
|
replace_target=True,
|
|
confirm_replace_target=target,
|
|
)
|
|
assert not _errors(imported)
|
|
backup = next(tmp_path.glob("target-home.backup.*"))
|
|
|
|
restored = restore_profile(backup)
|
|
|
|
assert restored.outcome == "ready"
|
|
assert (target / "workspace" / "SOUL.md").read_text(encoding="utf-8") == (
|
|
"previous profile\n"
|
|
)
|
|
history = json.loads(
|
|
(tmp_path / "profile-replacement-history.json").read_text(encoding="utf-8")
|
|
)
|
|
assert history["backups"][0]["restored_to"] == _normalized_path(target)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 4.-6. Pre-flight refusals
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_live_source_gateway_refused(tmp_path: Path) -> None:
|
|
source = _build_source_home(tmp_path)
|
|
(source / "state" / "gateway.pid").write_text(
|
|
json.dumps({"pid": os.getpid(), "start_ts": "2026-01-01T00:00:00+00:00"}),
|
|
encoding="utf-8",
|
|
)
|
|
target = tmp_path / "target-home"
|
|
|
|
report = _run(source, target, apply=True)
|
|
|
|
assert report["preflight"]["source_gateway_running"] is True
|
|
errors = _errors(report)
|
|
assert any("gateway is running on the source home" in item["reason"] for item in errors)
|
|
assert not target.exists()
|
|
|
|
|
|
def test_schema_ahead_source_refused(tmp_path: Path) -> None:
|
|
source = _build_source_home(
|
|
tmp_path, applied_ids=("V001__initial_schema", "V999__future_thing")
|
|
)
|
|
target = tmp_path / "target-home"
|
|
|
|
report = _run(source, target, apply=True)
|
|
|
|
assert report["preflight"]["schema_ahead"] is True
|
|
errors = _errors(report)
|
|
assert any("newer OpenSquilla" in item["reason"] for item in errors)
|
|
assert any("V999__future_thing" in item["reason"] for item in errors)
|
|
assert not target.exists()
|
|
|
|
|
|
def test_insufficient_disk_space_refused(tmp_path: Path, monkeypatch) -> None:
|
|
source = _build_source_home(tmp_path)
|
|
target = tmp_path / "target-home"
|
|
monkeypatch.setattr(
|
|
"shutil.disk_usage",
|
|
lambda _path: SimpleNamespace(total=1000, used=990, free=10),
|
|
)
|
|
|
|
report = _run(source, target, apply=True)
|
|
|
|
assert report["preflight"]["disk_free_bytes"] == 10
|
|
assert report["preflight"]["disk_required_bytes"] > 10
|
|
errors = _errors(report)
|
|
assert any("not enough free disk space" in item["reason"] for item in errors)
|
|
assert not target.exists()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 7. CLI-home detection guard
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_detect_legacy_cli_home_guard(tmp_path: Path, monkeypatch) -> None:
|
|
fake_home = tmp_path / "userhome"
|
|
legacy = fake_home / ".opensquilla"
|
|
legacy.mkdir(parents=True)
|
|
(legacy / "config.toml").write_text("port = 18790\n", encoding="utf-8")
|
|
(legacy / "workspace").mkdir()
|
|
(legacy / "state").mkdir()
|
|
monkeypatch.setattr(Path, "home", classmethod(lambda cls: fake_home))
|
|
|
|
# The active home IS ~/.opensquilla: never offered as a source.
|
|
assert detect_legacy_cli_home(legacy) is None
|
|
# Symlink-equivalent spelling of the same home is also rejected.
|
|
assert detect_legacy_cli_home(fake_home / ".opensquilla" / ".." / ".opensquilla") is None
|
|
# A different target (desktop spawn, relocated state dir): offered.
|
|
assert detect_legacy_cli_home(tmp_path / "electron-home") == legacy
|
|
|
|
|
|
def test_detect_legacy_cli_home_keeps_previously_imported_source_visible(
|
|
tmp_path: Path, monkeypatch
|
|
) -> None:
|
|
fake_home = tmp_path / "userhome"
|
|
legacy = fake_home / ".opensquilla"
|
|
legacy.mkdir(parents=True)
|
|
(legacy / "config.toml").write_text("port = 18790\n", encoding="utf-8")
|
|
(legacy / "workspace").mkdir()
|
|
(legacy / "state").mkdir()
|
|
target = tmp_path / "desktop-home"
|
|
monkeypatch.setattr(Path, "home", classmethod(lambda cls: fake_home))
|
|
|
|
report = _run(legacy, target, apply=True)
|
|
assert not _errors(report)
|
|
# A committed target receipt is a display hint, never authority to hide a
|
|
# still-valid source. The user must remain able to choose it again.
|
|
assert detect_legacy_cli_home(target) == legacy
|
|
assert migration_module._matching_import_receipt(legacy, target) is not None
|
|
assert detect_legacy_cli_home(tmp_path / "different-target") == legacy
|
|
|
|
# A stale source marker also remains non-authoritative after uninstall.
|
|
shutil.rmtree(target)
|
|
assert detect_legacy_cli_home(target) == legacy
|
|
|
|
|
|
def test_is_valid_opensquilla_home_shapes(tmp_path: Path) -> None:
|
|
assert not is_valid_opensquilla_home(tmp_path / "missing")
|
|
empty = tmp_path / "empty"
|
|
empty.mkdir()
|
|
assert not is_valid_opensquilla_home(empty)
|
|
with_state = tmp_path / "with-state"
|
|
(with_state / "state").mkdir(parents=True)
|
|
assert is_valid_opensquilla_home(with_state)
|
|
with_workspace = tmp_path / "with-workspace"
|
|
(with_workspace / "workspace").mkdir(parents=True)
|
|
assert is_valid_opensquilla_home(with_workspace)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 8. Portable enumeration
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_enumerate_portable_homes_orders_and_era_hints(tmp_path: Path) -> None:
|
|
base = tmp_path / "appdata"
|
|
portable = base / "OpenSquilla" / "portable"
|
|
older = portable / "dummy-release-a"
|
|
newer = portable / "dummy-release-b"
|
|
older.mkdir(parents=True)
|
|
newer.mkdir(parents=True)
|
|
(older / "config.toml").write_text("port = 18790\n", encoding="utf-8")
|
|
(older / "install-receipt.json").write_text(
|
|
json.dumps({"version": "0.4.1"}), encoding="utf-8"
|
|
)
|
|
(newer / "config.toml").write_text("port = 18790\n", encoding="utf-8")
|
|
(newer / "state").mkdir()
|
|
(newer / "state" / "update_check.json").write_text("{}", encoding="utf-8")
|
|
|
|
now = time.time()
|
|
os.utime(older / "config.toml", (now - 1000, now - 1000))
|
|
os.utime(newer / "config.toml", (now, now))
|
|
os.utime(older, (now - 1000, now - 1000))
|
|
os.utime(newer, (now, now))
|
|
|
|
candidates = enumerate_portable_homes([base])
|
|
|
|
assert [candidate.path for candidate in candidates] == [newer, older]
|
|
assert candidates[0].era_hint == "0.5.0rc2+"
|
|
assert candidates[1].era_hint == "0.4.1"
|
|
assert candidates[0].last_used > candidates[1].last_used
|
|
assert all(candidate.size_bytes > 0 for candidate in candidates)
|
|
assert all(candidate.previously_imported is False for candidate in candidates)
|
|
|
|
|
|
def test_candidate_metadata_is_privacy_narrow_stable_and_read_only(tmp_path: Path) -> None:
|
|
source = _build_source_home(tmp_path / "source")
|
|
config = tomllib.loads((source / "config.toml").read_text(encoding="utf-8"))
|
|
config["state_dir"] = str(source / "state")
|
|
(source / "config.toml").write_text(tomli_w.dumps(config), encoding="utf-8")
|
|
(source / "install-receipt.json").write_text(
|
|
json.dumps({"version": "0.5.0rc3"}),
|
|
encoding="utf-8",
|
|
)
|
|
connection = sqlite3.connect(source / "state" / "sessions.db")
|
|
try:
|
|
connection.execute("CREATE TABLE sessions (session_key TEXT PRIMARY KEY, title TEXT)")
|
|
connection.executemany(
|
|
"INSERT INTO sessions VALUES (?, ?)",
|
|
[("private-session-a", "private title a"), ("private-session-b", "private title b")],
|
|
)
|
|
connection.commit()
|
|
finally:
|
|
connection.close()
|
|
before = _file_bytes(source)
|
|
|
|
candidate = inspect_opensquilla_home_candidate(
|
|
source,
|
|
kind="cli-home",
|
|
target=tmp_path / "target",
|
|
)
|
|
|
|
assert candidate is not None
|
|
payload = candidate.as_payload()
|
|
assert payload == {
|
|
"kind": "cli-home",
|
|
"path": str(source),
|
|
"version": "0.5.0rc3",
|
|
"estimated_activity_at": payload["estimated_activity_at"],
|
|
"session_count": 2,
|
|
"size_bytes": payload["size_bytes"],
|
|
"previously_imported": False,
|
|
}
|
|
assert payload["estimated_activity_at"] is not None
|
|
assert isinstance(payload["size_bytes"], int) and payload["size_bytes"] > 0
|
|
serialized = json.dumps(payload)
|
|
assert "private-session-a" not in serialized
|
|
assert "private title a" not in serialized
|
|
assert _file_bytes(source) == before
|
|
|
|
|
|
def test_candidate_size_does_not_collapse_when_directory_identity_is_unavailable(
|
|
tmp_path: Path,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
source = _build_source_home(tmp_path / "source")
|
|
monkeypatch.setattr(migration_module, "_advisory_identity", lambda _result: None)
|
|
|
|
candidate = inspect_opensquilla_home_candidate(source, kind="cli-home")
|
|
|
|
assert candidate is not None
|
|
assert isinstance(candidate.size_bytes, int)
|
|
assert candidate.size_bytes > 0
|
|
|
|
|
|
def test_candidate_metadata_size_is_bounded_and_never_follows_links(
|
|
tmp_path: Path,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
source = tmp_path / "source"
|
|
source.mkdir()
|
|
(source / "config.toml").write_text("version = \"0.5.0rc3\"\n", encoding="utf-8")
|
|
outside = tmp_path / "outside"
|
|
outside.mkdir()
|
|
(outside / "private.txt").write_text("must-not-be-read", encoding="utf-8")
|
|
(source / "linked").symlink_to(outside, target_is_directory=True)
|
|
monkeypatch.setattr(migration_module, "_CANDIDATE_METADATA_MAX_ENTRIES", 1)
|
|
|
|
candidate = inspect_opensquilla_home_candidate(source, kind="cli-home")
|
|
|
|
assert candidate is not None
|
|
assert candidate.size_bytes is None
|
|
assert candidate.version == "0.5.0rc3"
|
|
|
|
|
|
def test_portable_candidate_enumeration_has_a_hard_display_bound(
|
|
tmp_path: Path,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
portable = tmp_path / "base" / "OpenSquilla" / "portable"
|
|
for name in ("one", "two", "three"):
|
|
home = portable / name
|
|
home.mkdir(parents=True)
|
|
(home / "config.toml").write_text("port = 18791\n", encoding="utf-8")
|
|
monkeypatch.setattr(migration_module, "_CANDIDATE_ENUMERATION_MAX_CANDIDATES", 1)
|
|
|
|
candidates = enumerate_portable_homes([tmp_path / "base"])
|
|
|
|
assert len(candidates) == 1
|
|
|
|
|
|
def test_candidate_session_count_rejects_a_source_changed_during_snapshot(
|
|
tmp_path: Path,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
source = _build_source_home(tmp_path / "source")
|
|
config = tomllib.loads((source / "config.toml").read_text(encoding="utf-8"))
|
|
config["state_dir"] = str(source / "state")
|
|
(source / "config.toml").write_text(tomli_w.dumps(config), encoding="utf-8")
|
|
connection = sqlite3.connect(source / "state" / "sessions.db")
|
|
try:
|
|
connection.execute("CREATE TABLE sessions (session_key TEXT PRIMARY KEY)")
|
|
connection.execute("INSERT INTO sessions VALUES ('synthetic-session')")
|
|
connection.commit()
|
|
finally:
|
|
connection.close()
|
|
original_digest = migration_module._digest_regular_file
|
|
changed = False
|
|
|
|
def mutate_after_copy(*args: Any, **kwargs: Any) -> str:
|
|
nonlocal changed
|
|
digest = original_digest(*args, **kwargs)
|
|
path = Path(args[0])
|
|
if not changed and kwargs.get("destination") is not None and path.name == "sessions.db":
|
|
changed = True
|
|
with path.open("ab") as handle:
|
|
handle.write(b"changed-after-copy")
|
|
return digest
|
|
|
|
monkeypatch.setattr(migration_module, "_digest_regular_file", mutate_after_copy)
|
|
|
|
candidate = inspect_opensquilla_home_candidate(source, kind="cli-home")
|
|
|
|
assert changed is True
|
|
assert candidate is not None
|
|
assert candidate.session_count is None
|
|
|
|
|
|
def test_candidate_metadata_does_not_open_unapproved_external_state(
|
|
tmp_path: Path,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
source = tmp_path / "source"
|
|
source.mkdir()
|
|
external_state = tmp_path / "external-state"
|
|
external_state.mkdir()
|
|
(source / "config.toml").write_text(
|
|
f"state_dir = {json.dumps(str(external_state))}\n",
|
|
encoding="utf-8",
|
|
)
|
|
calls: list[Path] = []
|
|
|
|
def unexpected_count(path: Path) -> int:
|
|
calls.append(path)
|
|
return 99
|
|
|
|
monkeypatch.setattr(migration_module, "_read_session_count", unexpected_count)
|
|
|
|
candidate = inspect_opensquilla_home_candidate(source, kind="cli-home")
|
|
|
|
assert candidate is not None
|
|
assert candidate.session_count is None
|
|
assert calls == []
|
|
|
|
|
|
def test_portable_candidate_reports_previous_import_without_hiding_source(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
base = tmp_path / "appdata"
|
|
seed = _build_source_home(tmp_path / "seed")
|
|
portable = base / "OpenSquilla" / "portable" / "dummy-release"
|
|
portable.parent.mkdir(parents=True)
|
|
seed.rename(portable)
|
|
target = tmp_path / "target-home"
|
|
|
|
first = OpenSquillaHomeMigrator(
|
|
OpenSquillaMigrationOptions(
|
|
source=portable,
|
|
kind="windows-portable",
|
|
target=target,
|
|
apply=True,
|
|
)
|
|
).migrate()
|
|
assert not _errors(first)
|
|
|
|
candidates = enumerate_portable_homes([base], target=target)
|
|
assert [candidate.path for candidate in candidates] == [portable]
|
|
assert candidates[0].previously_imported is True
|
|
|
|
|
|
def test_previous_import_hint_is_bound_to_the_selected_source_kind(tmp_path: Path) -> None:
|
|
source = _build_source_home(tmp_path / "source")
|
|
target = tmp_path / "target-home"
|
|
first = OpenSquillaHomeMigrator(
|
|
OpenSquillaMigrationOptions(
|
|
source=source,
|
|
kind="windows-portable",
|
|
target=target,
|
|
apply=True,
|
|
)
|
|
).migrate()
|
|
assert not _errors(first)
|
|
|
|
portable = inspect_opensquilla_home_candidate(
|
|
source,
|
|
kind="windows-portable",
|
|
target=target,
|
|
)
|
|
cli = inspect_opensquilla_home_candidate(source, kind="cli-home", target=target)
|
|
|
|
assert portable is not None and portable.previously_imported is True
|
|
assert cli is not None and cli.previously_imported is False
|
|
|
|
|
|
def test_single_portable_candidate_is_reported_but_never_auto_selected(
|
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
base = tmp_path / "appdata"
|
|
portable = base / "OpenSquilla" / "portable" / "dummy-release"
|
|
portable.mkdir(parents=True)
|
|
(portable / "config.toml").write_text("port = 18791\n", encoding="utf-8")
|
|
monkeypatch.setenv("LOCALAPPDATA", str(base))
|
|
monkeypatch.delenv("TEMP", raising=False)
|
|
|
|
report = OpenSquillaHomeMigrator(
|
|
OpenSquillaMigrationOptions(
|
|
kind="windows-portable",
|
|
target=tmp_path / "target-home",
|
|
)
|
|
).migrate()
|
|
|
|
assert report["source"] == ""
|
|
assert [candidate["path"] for candidate in report["candidates"]] == [str(portable)]
|
|
assert report["candidates"][0]["previously_imported"] is False
|
|
assert {
|
|
"kind",
|
|
"path",
|
|
"version",
|
|
"estimated_activity_at",
|
|
"session_count",
|
|
"size_bytes",
|
|
"previously_imported",
|
|
} <= set(report["candidates"][0])
|
|
assert any("explicitly confirm" in item["reason"] for item in _errors(report))
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"release",
|
|
PORTABLE_RELEASES,
|
|
ids=lambda release: release["release_tag"],
|
|
)
|
|
def test_every_published_portable_release_completes_full_profile_apply(
|
|
tmp_path: Path,
|
|
release: dict[str, Any],
|
|
) -> None:
|
|
"""Historical Portable coverage must prove apply, not just enumeration."""
|
|
|
|
seed = _build_source_home(
|
|
tmp_path / f"seed-{release['release_id']}",
|
|
applied_ids=(release["source"]["latest_migration_id"],),
|
|
)
|
|
source = (
|
|
tmp_path
|
|
/ "LocalApplicationData"
|
|
/ "OpenSquilla"
|
|
/ "portable"
|
|
/ release["release_id"]
|
|
)
|
|
source.parent.mkdir(parents=True)
|
|
seed.rename(source)
|
|
config = tomllib.loads((source / "config.toml").read_text(encoding="utf-8"))
|
|
config["version"] = release["release_tag"].removeprefix("v")
|
|
config["state_dir"] = str(source / "state")
|
|
config["workspace_dir"] = str(source / "workspace")
|
|
(source / "config.toml").write_text(tomli_w.dumps(config), encoding="utf-8")
|
|
(source / "install-receipt.json").write_text(
|
|
json.dumps({"version": release["release_tag"]}),
|
|
encoding="utf-8",
|
|
)
|
|
source_before = _file_bytes(source)
|
|
target = tmp_path / f"imported-{release['release_id']}"
|
|
|
|
report = OpenSquillaHomeMigrator(
|
|
OpenSquillaMigrationOptions(
|
|
source=source,
|
|
kind="windows-portable",
|
|
target=target,
|
|
apply=True,
|
|
)
|
|
).migrate()
|
|
|
|
assert not _errors(report)
|
|
_assert_imported_identity_chat_and_config(target)
|
|
assert _file_bytes(source) == source_before
|
|
assert not (source / IMPORT_MARKER_FILENAME).exists()
|
|
receipt = json.loads(
|
|
(Path(report["output_dir"]) / "layout-receipt.json").read_text(encoding="utf-8")
|
|
)
|
|
assert set(receipt) == {
|
|
"schema_version",
|
|
"transaction_id",
|
|
"imported_at",
|
|
"source",
|
|
"source_identity",
|
|
"source_kind",
|
|
"source_version",
|
|
"target",
|
|
"candidate_identity",
|
|
"recovery_outcome",
|
|
"recovery_stable_code",
|
|
"layout",
|
|
}
|
|
assert receipt["source_kind"] == "windows-portable"
|
|
assert receipt["source_version"] == release["release_tag"]
|
|
assert receipt["source"] == _normalized_path(source)
|
|
assert receipt["target"] == _normalized_path(target)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 9. WAL sidecars
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_sqlite_snapshot_normalizes_empty_wal_sidecar(tmp_path: Path) -> None:
|
|
source = _build_source_home(tmp_path)
|
|
(source / "state" / "sessions.db-wal").write_bytes(b"")
|
|
target = tmp_path / "target-home"
|
|
|
|
report = _run(source, target, apply=True)
|
|
|
|
assert not _errors(report)
|
|
assert not (target / "state" / "sessions.db-wal").exists()
|
|
connection = sqlite3.connect(target / "state" / "sessions.db")
|
|
try:
|
|
assert connection.execute("PRAGMA quick_check").fetchone() == ("ok",)
|
|
finally:
|
|
connection.close()
|
|
|
|
|
|
def test_dry_run_does_not_create_sidecars_for_checkpointed_wal_store(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
source = _build_source_home(tmp_path)
|
|
db_path = source / "state" / "sessions.db"
|
|
connection = sqlite3.connect(db_path)
|
|
try:
|
|
assert connection.execute("PRAGMA journal_mode=WAL").fetchone() == ("wal",)
|
|
connection.commit()
|
|
finally:
|
|
connection.close()
|
|
assert not db_path.with_name("sessions.db-wal").exists()
|
|
assert not db_path.with_name("sessions.db-shm").exists()
|
|
before = _file_bytes(source)
|
|
|
|
report = _run(source, tmp_path / "target-home", apply=False)
|
|
|
|
assert not _errors(report)
|
|
assert _file_bytes(source) == before
|
|
|
|
|
|
def test_dry_run_reads_committed_wal_without_mutating_source_bundle(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
source = _build_source_home(tmp_path)
|
|
db_path = source / "state" / "scheduler.db"
|
|
writer = sqlite3.connect(db_path)
|
|
try:
|
|
assert writer.execute("PRAGMA journal_mode=WAL").fetchone() == ("wal",)
|
|
writer.execute("PRAGMA wal_autocheckpoint=0")
|
|
writer.execute("PRAGMA wal_checkpoint(TRUNCATE)")
|
|
writer.execute(
|
|
"INSERT INTO scheduler_jobs "
|
|
"(id, name, cron_expr, handler_key, payload, status, created_at, updated_at) "
|
|
"VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
|
|
(
|
|
"job-wal",
|
|
"committed only in WAL",
|
|
"30 10 * * *",
|
|
"agent_turn",
|
|
"{}",
|
|
"pending",
|
|
"2026-01-02 00:00:00",
|
|
"2026-01-02 00:00:00",
|
|
),
|
|
)
|
|
writer.commit()
|
|
assert db_path.with_name("scheduler.db-wal").stat().st_size > 0
|
|
before = _file_bytes(source)
|
|
|
|
report = _run(source, tmp_path / "target-home", apply=False)
|
|
|
|
assert not _errors(report)
|
|
assert {job["id"] for job in report["paused_jobs"]} == {"job-1", "job-wal"}
|
|
assert _file_bytes(source) == before
|
|
finally:
|
|
writer.close()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Safety regressions: split roots, fail-closed transforms, atomic publish
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _point_config_at_split_roots(
|
|
source: Path,
|
|
*,
|
|
state: Path,
|
|
workspace: Path,
|
|
media: Path,
|
|
) -> None:
|
|
payload = tomllib.loads((source / "config.toml").read_text(encoding="utf-8"))
|
|
payload["state_dir"] = str(state)
|
|
payload["workspace_dir"] = str(workspace)
|
|
attachments = payload.setdefault("attachments", {})
|
|
attachments["media_root"] = str(media)
|
|
(source / "config.toml").write_text(tomli_w.dumps(payload), encoding="utf-8")
|
|
|
|
|
|
def test_external_configured_roots_are_copied_to_canonical_target_paths(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
source = _build_source_home(tmp_path)
|
|
shutil.rmtree(source / "state")
|
|
shutil.rmtree(source / "workspace")
|
|
external_state = tmp_path / "external-state"
|
|
external_workspace = tmp_path / "external-workspace"
|
|
external_media = tmp_path / "external-media"
|
|
external_state.mkdir()
|
|
external_workspace.mkdir()
|
|
external_media.mkdir()
|
|
(external_state / "state-sentinel.bin").write_bytes(b"state-data")
|
|
(external_workspace / "workspace-sentinel.txt").write_text(
|
|
"workspace-data", encoding="utf-8"
|
|
)
|
|
(external_media / "media-sentinel.bin").write_bytes(b"media-data")
|
|
_point_config_at_split_roots(
|
|
source,
|
|
state=external_state,
|
|
workspace=external_workspace,
|
|
media=external_media,
|
|
)
|
|
target = tmp_path / "target-home"
|
|
|
|
report = _run(source, target, apply=True)
|
|
|
|
assert not _errors(report)
|
|
assert (target / "state" / "state-sentinel.bin").read_bytes() == b"state-data"
|
|
assert (target / "workspace" / "workspace-sentinel.txt").read_text(
|
|
encoding="utf-8"
|
|
) == "workspace-data"
|
|
assert (target / "media" / "media-sentinel.bin").read_bytes() == b"media-data"
|
|
config = tomllib.loads((target / "config.toml").read_text(encoding="utf-8"))
|
|
assert "state_dir" not in config
|
|
assert "workspace_dir" not in config
|
|
assert "media_root" not in config.get("attachments", {})
|
|
|
|
|
|
def test_existing_external_dot_opensquilla_path_is_never_mistaken_for_internal(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
source = _build_source_home(tmp_path)
|
|
shutil.rmtree(source / "workspace")
|
|
external = tmp_path / "mounted-backup" / ".opensquilla" / "workspace"
|
|
external.mkdir(parents=True)
|
|
(external / "IDENTITY.md").write_text("external identity", encoding="utf-8")
|
|
payload = tomllib.loads((source / "config.toml").read_text(encoding="utf-8"))
|
|
payload["workspace_dir"] = str(external)
|
|
(source / "config.toml").write_text(tomli_w.dumps(payload), encoding="utf-8")
|
|
target = tmp_path / "target-home"
|
|
|
|
report = _run(source, target, apply=True)
|
|
|
|
assert not _errors(report)
|
|
assert (target / "workspace" / "IDENTITY.md").read_text(
|
|
encoding="utf-8"
|
|
) == "external identity"
|
|
|
|
|
|
def test_missing_external_pin_blocks_even_when_canonical_root_exists(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
source = _build_source_home(tmp_path)
|
|
missing = tmp_path / "missing-external-workspace"
|
|
payload = tomllib.loads((source / "config.toml").read_text(encoding="utf-8"))
|
|
payload["workspace_dir"] = str(missing)
|
|
(source / "config.toml").write_text(tomli_w.dumps(payload), encoding="utf-8")
|
|
target = tmp_path / "target-home"
|
|
|
|
report = _run(source, target, apply=True)
|
|
|
|
assert any(
|
|
item["kind"] == "preflight/data-root"
|
|
and item["source"] == str(missing)
|
|
for item in _errors(report)
|
|
)
|
|
assert not target.exists()
|
|
|
|
|
|
def test_external_agent_workspace_is_snapshotted_and_rebased(tmp_path: Path) -> None:
|
|
source = _build_source_home(tmp_path)
|
|
external = tmp_path / "external-agent-workspace"
|
|
external.mkdir()
|
|
(external / "IDENTITY.md").write_text("synthetic agent", encoding="utf-8")
|
|
payload = tomllib.loads((source / "config.toml").read_text(encoding="utf-8"))
|
|
payload["agents"] = [{"id": "research", "workspace": str(external)}]
|
|
(source / "config.toml").write_text(tomli_w.dumps(payload), encoding="utf-8")
|
|
target = tmp_path / "target-home"
|
|
|
|
report = _run(source, target, apply=True)
|
|
|
|
assert not _errors(report)
|
|
imported = target / "workspace" / "agents" / "research" / "IDENTITY.md"
|
|
assert imported.read_text(encoding="utf-8") == "synthetic agent"
|
|
config = tomllib.loads((target / "config.toml").read_text(encoding="utf-8"))
|
|
assert config["agents"][0]["workspace"] == str(
|
|
target / "workspace" / "agents" / "research"
|
|
)
|
|
|
|
|
|
def test_dotenv_external_data_roots_are_snapshotted_rebased_and_never_shared(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
source = _build_source_home(tmp_path / "source-root")
|
|
external_state = tmp_path / "external-state"
|
|
external_workspace = tmp_path / "external-workspace"
|
|
external_media = tmp_path / "external-media"
|
|
shutil.move(source / "state", external_state)
|
|
shutil.move(source / "workspace", external_workspace)
|
|
external_media.mkdir()
|
|
(external_media / "artifact.bin").write_bytes(b"synthetic-media")
|
|
payload = tomllib.loads((source / "config.toml").read_text(encoding="utf-8"))
|
|
payload.pop("state_dir", None)
|
|
payload.pop("workspace_dir", None)
|
|
(source / "config.toml").write_text(tomli_w.dumps(payload), encoding="utf-8")
|
|
env_path = source / ".env"
|
|
env_path.write_text(
|
|
env_path.read_text(encoding="utf-8")
|
|
+ f"OPENSQUILLA_GATEWAY_STATE_DIR={external_state}\n"
|
|
+ f"OPENSQUILLA_GATEWAY_WORKSPACE_DIR={external_workspace}\n"
|
|
+ f"OPENSQUILLA_GATEWAY_ATTACHMENTS__MEDIA_ROOT={external_media}\n",
|
|
encoding="utf-8",
|
|
)
|
|
source_env_before = env_path.read_bytes()
|
|
state_before = _file_bytes(external_state)
|
|
workspace_before = _file_bytes(external_workspace)
|
|
media_before = _file_bytes(external_media)
|
|
target = tmp_path / "target-home"
|
|
|
|
report = _run(source, target, apply=True)
|
|
|
|
assert not _errors(report)
|
|
assert _file_bytes(external_state) == state_before
|
|
assert _file_bytes(external_workspace) == workspace_before
|
|
assert _file_bytes(external_media) == media_before
|
|
assert env_path.read_bytes() == source_env_before
|
|
assert (target / "state" / "sessions.db").is_file()
|
|
assert (target / "workspace" / "IDENTITY.md").is_file()
|
|
assert (target / "media" / "artifact.bin").read_bytes() == b"synthetic-media"
|
|
imported_env = (target / ".env").read_text(encoding="utf-8")
|
|
assert "OPENROUTER_API_KEY=dummy" in imported_env
|
|
for key in (
|
|
"OPENSQUILLA_GATEWAY_STATE_DIR",
|
|
"OPENSQUILLA_GATEWAY_WORKSPACE_DIR",
|
|
"OPENSQUILLA_GATEWAY_ATTACHMENTS__MEDIA_ROOT",
|
|
):
|
|
assert key not in imported_env
|
|
imported_config = tomllib.loads(
|
|
(target / "config.toml").read_text(encoding="utf-8")
|
|
)
|
|
assert "state_dir" not in imported_config
|
|
assert "workspace_dir" not in imported_config
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"key",
|
|
[
|
|
"OPENSQUILLA_GATEWAY_STATE_DIR",
|
|
"OPENSQUILLA_GATEWAY_WORKSPACE_DIR",
|
|
"OPENSQUILLA_GATEWAY_ATTACHMENTS__MEDIA_ROOT",
|
|
],
|
|
)
|
|
def test_missing_dotenv_external_data_root_blocks_without_publication(
|
|
tmp_path: Path,
|
|
key: str,
|
|
) -> None:
|
|
source = _build_source_home(tmp_path)
|
|
missing = tmp_path / f"missing-{key.lower()}"
|
|
env_path = source / ".env"
|
|
env_path.write_text(
|
|
env_path.read_text(encoding="utf-8") + f"{key}={missing}\n",
|
|
encoding="utf-8",
|
|
)
|
|
source_before = _file_bytes(source)
|
|
target = tmp_path / "target-home"
|
|
|
|
report = _run(source, target, apply=True)
|
|
|
|
assert any(item["kind"] == "preflight/data-root" for item in _errors(report))
|
|
assert _file_bytes(source) == source_before
|
|
assert not target.exists()
|
|
assert not list(tmp_path.glob(".target-home.profile-staging.*"))
|
|
|
|
|
|
def test_internal_dotenv_home_selectors_are_removed_before_cli_target_boot(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
fake_home = tmp_path / "user-home"
|
|
fake_home.mkdir()
|
|
source = _build_source_home(tmp_path / "source-root")
|
|
env_path = source / ".env"
|
|
env_path.write_text(
|
|
env_path.read_text(encoding="utf-8")
|
|
+ f"OPENSQUILLA_STATE_DIR={source}\n"
|
|
+ f"OPENSQUILLA_GATEWAY_CONFIG_PATH={source / 'config.toml'}\n",
|
|
encoding="utf-8",
|
|
)
|
|
target = fake_home / ".opensquilla"
|
|
|
|
report = _run(source, target, apply=True)
|
|
|
|
assert not _errors(report)
|
|
imported_env = (target / ".env").read_text(encoding="utf-8")
|
|
assert "OPENSQUILLA_STATE_DIR" not in imported_env
|
|
assert "OPENSQUILLA_GATEWAY_CONFIG_PATH" not in imported_env
|
|
environment = os.environ.copy()
|
|
environment["HOME"] = str(fake_home)
|
|
for key in (
|
|
"OPENSQUILLA_STATE_DIR",
|
|
"OPENSQUILLA_HOME",
|
|
"OPENSQUILLA_PROFILE",
|
|
"OPENSQUILLA_GATEWAY_CONFIG_PATH",
|
|
):
|
|
environment.pop(key, None)
|
|
completed = subprocess.run(
|
|
[
|
|
sys.executable,
|
|
"-c",
|
|
(
|
|
"from opensquilla.env import load_env; "
|
|
"from opensquilla.paths import default_opensquilla_home; "
|
|
"home=default_opensquilla_home(); "
|
|
"load_env(cwd=home.parent, home=home); "
|
|
"print(default_opensquilla_home())"
|
|
),
|
|
],
|
|
cwd=tmp_path,
|
|
env=environment,
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=20,
|
|
check=False,
|
|
)
|
|
assert completed.returncode == 0, completed.stderr
|
|
assert Path(completed.stdout.strip()) == target
|
|
|
|
|
|
def test_external_dotenv_home_selector_blocks_instead_of_reusing_live_profile(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
source = _build_source_home(tmp_path)
|
|
other_home = tmp_path / "other-live-home"
|
|
other_home.mkdir()
|
|
env_path = source / ".env"
|
|
env_path.write_text(
|
|
env_path.read_text(encoding="utf-8")
|
|
+ f"OPENSQUILLA_STATE_DIR={other_home}\n",
|
|
encoding="utf-8",
|
|
)
|
|
source_before = _file_bytes(source)
|
|
target = tmp_path / "target-home"
|
|
|
|
report = _run(source, target, apply=True)
|
|
|
|
assert any(
|
|
item["kind"] == "preflight/env"
|
|
and "another live profile" in item["reason"]
|
|
for item in _errors(report)
|
|
)
|
|
assert _file_bytes(source) == source_before
|
|
assert not target.exists()
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"agent_ids",
|
|
[
|
|
["{absolute}"],
|
|
["../../../../escape"],
|
|
["Agent_A", "agent_a"],
|
|
["Research"],
|
|
],
|
|
)
|
|
def test_unsafe_or_noncanonical_agent_ids_block_before_staging(
|
|
tmp_path: Path,
|
|
agent_ids: list[str],
|
|
) -> None:
|
|
source = _build_source_home(tmp_path)
|
|
outside = tmp_path / "agent-id-escape"
|
|
outside.write_text("preserve", encoding="utf-8")
|
|
outside.chmod(0o640)
|
|
before = outside.stat()
|
|
payload = tomllib.loads((source / "config.toml").read_text(encoding="utf-8"))
|
|
payload["agents"] = [
|
|
{
|
|
"id": str(outside) if agent_id == "{absolute}" else agent_id,
|
|
"workspace": str(source / "workspace"),
|
|
}
|
|
for agent_id in agent_ids
|
|
]
|
|
(source / "config.toml").write_text(tomli_w.dumps(payload), encoding="utf-8")
|
|
target = tmp_path / "target-home"
|
|
|
|
preview = _run(source, target)
|
|
applied = _run(source, target, apply=True)
|
|
|
|
assert any(item["kind"] == "preflight/config" for item in _errors(preview))
|
|
assert any(item["kind"] == "preflight/config" for item in _errors(applied))
|
|
assert not target.exists()
|
|
assert not list(tmp_path.glob(".target-home.profile-staging.*"))
|
|
assert outside.read_text(encoding="utf-8") == "preserve"
|
|
after = outside.stat()
|
|
assert (after.st_mode, after.st_mtime_ns) == (before.st_mode, before.st_mtime_ns)
|
|
|
|
|
|
def test_windows_absolute_path_pins_are_dropped_on_posix_import(tmp_path: Path) -> None:
|
|
source = _build_source_home(tmp_path)
|
|
(source / "media").mkdir()
|
|
(source / "media" / "sentinel.bin").write_bytes(b"media")
|
|
payload = tomllib.loads((source / "config.toml").read_text(encoding="utf-8"))
|
|
payload["state_dir"] = r"E:\Users\synthetic\.opensquilla\state"
|
|
payload["workspace_dir"] = r"E:\Users\synthetic\.opensquilla\workspace"
|
|
payload.setdefault("attachments", {})["media_root"] = (
|
|
r"E:\Users\synthetic\.opensquilla\media"
|
|
)
|
|
(source / "config.toml").write_text(tomli_w.dumps(payload), encoding="utf-8")
|
|
target = tmp_path / "target-home"
|
|
|
|
report = _run(source, target, apply=True)
|
|
|
|
assert not _errors(report)
|
|
config = tomllib.loads((target / "config.toml").read_text(encoding="utf-8"))
|
|
assert "state_dir" not in config
|
|
assert "workspace_dir" not in config
|
|
assert "media_root" not in config.get("attachments", {})
|
|
assert (target / "state" / "sessions.db").is_file()
|
|
assert (target / "workspace" / "MEMORY.md").is_file()
|
|
assert (target / "media" / "sentinel.bin").read_bytes() == b"media"
|
|
|
|
|
|
def test_missing_unc_workspace_is_treated_as_external_and_blocks_import(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
source = _build_source_home(tmp_path)
|
|
payload = tomllib.loads((source / "config.toml").read_text(encoding="utf-8"))
|
|
payload["workspace_dir"] = r"\\synthetic.invalid\share\.opensquilla\workspace"
|
|
(source / "config.toml").write_text(tomli_w.dumps(payload), encoding="utf-8")
|
|
target = tmp_path / "target-home"
|
|
|
|
report = _run(source, target, apply=True)
|
|
|
|
assert any(
|
|
item["kind"] == "preflight/data-root"
|
|
and "does not exist" in item["reason"]
|
|
for item in _errors(report)
|
|
)
|
|
assert not target.exists()
|
|
|
|
|
|
def test_configured_data_root_overlapping_target_is_rejected(tmp_path: Path) -> None:
|
|
source = _build_source_home(tmp_path)
|
|
target = tmp_path / "target-home"
|
|
payload = tomllib.loads((source / "config.toml").read_text(encoding="utf-8"))
|
|
payload["state_dir"] = str(tmp_path)
|
|
(source / "config.toml").write_text(tomli_w.dumps(payload), encoding="utf-8")
|
|
|
|
report = _run(source, target, apply=True)
|
|
|
|
assert any(
|
|
item["kind"] == "preflight/data-root"
|
|
and "overlaps the target home" in item["reason"]
|
|
for item in _errors(report)
|
|
)
|
|
assert not target.exists()
|
|
assert not list(tmp_path.glob(".opensquilla-import-*"))
|
|
|
|
|
|
def test_external_roots_are_included_in_disk_preflight(tmp_path: Path) -> None:
|
|
source = _build_source_home(tmp_path)
|
|
external_state = tmp_path / "external-state"
|
|
external_workspace = tmp_path / "external-workspace"
|
|
external_media = tmp_path / "external-media"
|
|
for root in (external_state, external_workspace, external_media):
|
|
root.mkdir()
|
|
(root / "payload.bin").write_bytes(b"x" * 4096)
|
|
_point_config_at_split_roots(
|
|
source,
|
|
state=external_state,
|
|
workspace=external_workspace,
|
|
media=external_media,
|
|
)
|
|
|
|
report = _run(source, tmp_path / "target-home")
|
|
|
|
source_only = migration_module._tree_size_bytes(source)
|
|
assert report["preflight"]["disk_required_bytes"] >= (
|
|
source_only + (3 * 4096) + migration_module._DISK_MARGIN_BYTES
|
|
)
|
|
|
|
|
|
@pytest.mark.parametrize("root_name", ["state", "workspace", "media"])
|
|
@pytest.mark.parametrize("invalid_shape", ["missing", "file"])
|
|
def test_invalid_configured_data_root_without_canonical_fallback_blocks_apply(
|
|
tmp_path: Path,
|
|
root_name: str,
|
|
invalid_shape: str,
|
|
) -> None:
|
|
source = _build_source_home(tmp_path)
|
|
canonical = source / root_name
|
|
if canonical.exists():
|
|
shutil.rmtree(canonical)
|
|
configured = tmp_path / f"invalid-{root_name}"
|
|
if invalid_shape == "file":
|
|
configured.write_text("not a directory", encoding="utf-8")
|
|
payload = tomllib.loads((source / "config.toml").read_text(encoding="utf-8"))
|
|
if root_name == "media":
|
|
payload.setdefault("attachments", {})["media_root"] = str(configured)
|
|
else:
|
|
payload[f"{root_name}_dir"] = str(configured)
|
|
(source / "config.toml").write_text(tomli_w.dumps(payload), encoding="utf-8")
|
|
target = tmp_path / "target-home"
|
|
|
|
report = _run(source, target, apply=True)
|
|
|
|
data_root_errors = [
|
|
item
|
|
for item in _errors(report)
|
|
if item["kind"] == "preflight/data-root" and item["source"] == str(configured)
|
|
]
|
|
assert data_root_errors
|
|
assert "refusing to drop its config pin" in data_root_errors[0]["reason"]
|
|
assert not target.exists()
|
|
assert not (source / IMPORT_MARKER_FILENAME).exists()
|
|
|
|
|
|
def test_malformed_config_blocks_apply_without_target_or_marker(tmp_path: Path) -> None:
|
|
source = _build_source_home(tmp_path)
|
|
(source / "config.toml").write_text("[llm\nprovider =", encoding="utf-8")
|
|
target = tmp_path / "target-home"
|
|
|
|
report = _run(source, target, apply=True)
|
|
|
|
assert any(item["kind"] == "preflight/config" for item in _errors(report))
|
|
assert not target.exists()
|
|
assert not (source / IMPORT_MARKER_FILENAME).exists()
|
|
|
|
|
|
def test_schema_invalid_config_blocks_apply_without_target_or_marker(tmp_path: Path) -> None:
|
|
source = _build_source_home(tmp_path)
|
|
payload = tomllib.loads((source / "config.toml").read_text(encoding="utf-8"))
|
|
payload["port"] = "not-a-port"
|
|
(source / "config.toml").write_text(tomli_w.dumps(payload), encoding="utf-8")
|
|
target = tmp_path / "target-home"
|
|
|
|
report = _run(source, target, apply=True)
|
|
|
|
assert any(item["kind"] == "preflight/config" for item in _errors(report))
|
|
assert not target.exists()
|
|
assert not (source / IMPORT_MARKER_FILENAME).exists()
|
|
|
|
|
|
@pytest.mark.parametrize("nested", [False, True])
|
|
def test_unknown_config_field_blocks_without_silently_deleting_it(
|
|
tmp_path: Path,
|
|
nested: bool,
|
|
) -> None:
|
|
source = _build_source_home(tmp_path)
|
|
payload = tomllib.loads((source / "config.toml").read_text(encoding="utf-8"))
|
|
if nested:
|
|
payload["llm"]["future_provider_option"] = "preserve-me"
|
|
else:
|
|
payload["future_profile_option"] = {"value": "preserve-me"}
|
|
config_bytes = tomli_w.dumps(payload).encode()
|
|
(source / "config.toml").write_bytes(config_bytes)
|
|
target = tmp_path / "target-home"
|
|
|
|
preview = _run(source, target)
|
|
applied = _run(source, target, apply=True)
|
|
|
|
for report in (preview, applied):
|
|
assert any(
|
|
item["kind"] == "preflight/config"
|
|
and "cannot be preserved losslessly" in item["reason"]
|
|
for item in _errors(report)
|
|
)
|
|
assert (source / "config.toml").read_bytes() == config_bytes
|
|
assert not target.exists()
|
|
assert not list(tmp_path.glob(".target-home.profile-staging.*"))
|
|
|
|
|
|
def test_unreadable_sessions_database_blocks_apply(tmp_path: Path) -> None:
|
|
source = _build_source_home(tmp_path)
|
|
(source / "state" / "sessions.db").write_bytes(b"not a sqlite database")
|
|
target = tmp_path / "target-home"
|
|
|
|
report = _run(source, target, apply=True)
|
|
|
|
assert any(item["kind"] == "preflight/schema" for item in _errors(report))
|
|
assert not target.exists()
|
|
assert not (source / IMPORT_MARKER_FILENAME).exists()
|
|
|
|
|
|
def test_corrupt_secondary_sqlite_store_blocks_dry_run_and_apply(tmp_path: Path) -> None:
|
|
source = _build_source_home(tmp_path)
|
|
corrupt = source / "state" / "approval_queue.sqlite"
|
|
corrupt.write_bytes(b"not a sqlite database")
|
|
target = tmp_path / "target-home"
|
|
|
|
preview = _run(source, target, apply=False)
|
|
applied = _run(source, target, apply=True)
|
|
|
|
for report in (preview, applied):
|
|
assert any(item["kind"] == "preflight/sqlite" for item in _errors(report))
|
|
assert not target.exists()
|
|
assert not (source / IMPORT_MARKER_FILENAME).exists()
|
|
|
|
|
|
def test_scheduler_pause_failure_aborts_without_target_or_marker(tmp_path: Path) -> None:
|
|
source = _build_source_home(tmp_path)
|
|
connection = sqlite3.connect(source / "state" / "scheduler.db")
|
|
try:
|
|
connection.execute(
|
|
"CREATE TRIGGER reject_pause BEFORE UPDATE OF enabled ON scheduler_jobs "
|
|
"BEGIN SELECT RAISE(ABORT, 'pause rejected'); END"
|
|
)
|
|
connection.commit()
|
|
finally:
|
|
connection.close()
|
|
target = tmp_path / "target-home"
|
|
|
|
report = _run(source, target, apply=True)
|
|
|
|
assert any(item["kind"] == "scheduler" for item in _errors(report))
|
|
assert not target.exists()
|
|
assert not (source / IMPORT_MARKER_FILENAME).exists()
|
|
|
|
|
|
def test_symlink_in_source_tree_is_rejected_without_publication(tmp_path: Path) -> None:
|
|
source = _build_source_home(tmp_path)
|
|
outside = tmp_path / "outside.txt"
|
|
outside.write_text("must not be followed", encoding="utf-8")
|
|
(source / "workspace" / "linked.txt").symlink_to(outside)
|
|
target = tmp_path / "target-home"
|
|
|
|
report = _run(source, target, apply=True)
|
|
|
|
assert any(item["kind"] == "preflight/manifest" for item in _errors(report))
|
|
assert not target.exists()
|
|
|
|
|
|
@pytest.mark.skipif(os.name == "nt", reason="mkfifo is unavailable on Windows")
|
|
def test_special_file_in_source_tree_is_rejected_without_publication(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
source = _build_source_home(tmp_path)
|
|
os.mkfifo(source / "workspace" / "synthetic.fifo")
|
|
target = tmp_path / "target-home"
|
|
|
|
report = _run(source, target, apply=True)
|
|
|
|
assert any(item["kind"] == "preflight/manifest" for item in _errors(report))
|
|
assert not target.exists()
|
|
|
|
|
|
def test_source_change_after_copy_aborts_before_target_publication(
|
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
source = _build_source_home(tmp_path)
|
|
target = tmp_path / "target-home"
|
|
target.mkdir()
|
|
existing = target / "existing.txt"
|
|
existing.write_text("preserve", encoding="utf-8")
|
|
migrator = OpenSquillaHomeMigrator(
|
|
OpenSquillaMigrationOptions(
|
|
source=source,
|
|
target=target,
|
|
apply=True,
|
|
replace_target=True,
|
|
confirm_replace_target=target,
|
|
)
|
|
)
|
|
original_copy = migrator._copy_source_snapshots
|
|
|
|
def copy_then_change(staging: Path) -> None:
|
|
original_copy(staging)
|
|
(source / "workspace" / "MEMORY.md").write_text(
|
|
"source changed during copy", encoding="utf-8"
|
|
)
|
|
|
|
monkeypatch.setattr(migrator, "_copy_source_snapshots", copy_then_change)
|
|
|
|
report = migrator.migrate()
|
|
|
|
assert any("source changed during import" in item["reason"] for item in _errors(report))
|
|
assert existing.read_text(encoding="utf-8") == "preserve"
|
|
assert not list(tmp_path.glob("target-home.backup.*"))
|
|
|
|
|
|
@pytest.mark.skipif(os.name == "nt", reason="POSIX parent-component symlink race")
|
|
@pytest.mark.parametrize("replacement", ["relocated", "external"])
|
|
def test_workspace_parent_swap_after_preflight_fails_closed_without_artifacts(
|
|
tmp_path: Path,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
replacement: str,
|
|
) -> None:
|
|
source = _build_source_home(tmp_path / "source-root")
|
|
config = tomllib.loads((source / "config.toml").read_text(encoding="utf-8"))
|
|
config.pop("state_dir", None)
|
|
config.pop("workspace_dir", None)
|
|
(source / "config.toml").write_text(tomli_w.dumps(config), encoding="utf-8")
|
|
target = tmp_path / "target-home"
|
|
migrator = OpenSquillaHomeMigrator(
|
|
OpenSquillaMigrationOptions(source=source, target=target, apply=True)
|
|
)
|
|
original_copy = migrator._copy_source_snapshots
|
|
original_parent = source / "workspace" / "memory"
|
|
relocated_parent = source / "workspace" / "memory-relocated"
|
|
swap_performed = False
|
|
|
|
def swap_parent_then_copy(staging: Path) -> None:
|
|
nonlocal swap_performed
|
|
original_parent.rename(relocated_parent)
|
|
if replacement == "relocated":
|
|
link_target = Path(relocated_parent.name)
|
|
else:
|
|
link_target = tmp_path / "outside-memory"
|
|
shutil.copytree(relocated_parent, link_target)
|
|
original_parent.symlink_to(link_target, target_is_directory=True)
|
|
swap_performed = True
|
|
original_copy(staging)
|
|
|
|
monkeypatch.setattr(migrator, "_copy_source_snapshots", swap_parent_then_copy)
|
|
|
|
report = migrator.migrate()
|
|
|
|
assert swap_performed, _errors(report)
|
|
assert any(item["kind"] == "apply" for item in _errors(report))
|
|
assert not target.exists()
|
|
assert not list(tmp_path.glob(".target-home.profile-staging.*"))
|
|
assert not list(tmp_path.glob("target-home.backup.*"))
|
|
assert not (tmp_path / ".target-home.profile-replace.json").exists()
|
|
assert not (tmp_path / "profile-replacement-history.json").exists()
|
|
assert not (source / IMPORT_MARKER_FILENAME).exists()
|
|
|
|
|
|
@pytest.mark.skipif(os.name == "nt", reason="POSIX parent-component symlink race")
|
|
def test_sqlite_state_parent_swap_never_reopens_source_bundle_or_publishes(
|
|
tmp_path: Path,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
source = _build_source_home(tmp_path / "source-root")
|
|
config = tomllib.loads((source / "config.toml").read_text(encoding="utf-8"))
|
|
config.pop("state_dir", None)
|
|
config.pop("workspace_dir", None)
|
|
(source / "config.toml").write_text(tomli_w.dumps(config), encoding="utf-8")
|
|
source_db = source / "state" / "sessions.db"
|
|
writer = sqlite3.connect(source_db)
|
|
try:
|
|
assert writer.execute("PRAGMA journal_mode=WAL").fetchone() == ("wal",)
|
|
writer.execute("PRAGMA wal_autocheckpoint=0")
|
|
writer.execute("CREATE TABLE parent_swap_payload (value TEXT NOT NULL)")
|
|
writer.commit()
|
|
writer.execute("PRAGMA wal_checkpoint(TRUNCATE)")
|
|
writer.execute("INSERT INTO parent_swap_payload VALUES ('committed-in-wal')")
|
|
writer.commit()
|
|
assert source_db.with_name("sessions.db-wal").stat().st_size > 32
|
|
|
|
target = tmp_path / "target-home"
|
|
migrator = OpenSquillaHomeMigrator(
|
|
OpenSquillaMigrationOptions(source=source, target=target, apply=True)
|
|
)
|
|
original_snapshot_sqlite = migrator._snapshot_sqlite_stores
|
|
original_copy_sqlite_bundle = migration_module._copy_sqlite_bundle
|
|
original_state = source / "state"
|
|
relocated_state = source / "state-relocated"
|
|
state_swapped = False
|
|
reopened_source_bundle = False
|
|
|
|
def track_sqlite_bundle_reopen(
|
|
database: Path,
|
|
destination_dir: Path,
|
|
*,
|
|
verify_stable_bundle: bool = False,
|
|
) -> Path:
|
|
nonlocal reopened_source_bundle
|
|
if state_swapped and database.is_relative_to(original_state):
|
|
reopened_source_bundle = True
|
|
return original_copy_sqlite_bundle(
|
|
database,
|
|
destination_dir,
|
|
verify_stable_bundle=verify_stable_bundle,
|
|
)
|
|
|
|
def swap_state_then_snapshot(staging: Path) -> None:
|
|
nonlocal state_swapped
|
|
original_state.rename(relocated_state)
|
|
original_state.symlink_to(relocated_state, target_is_directory=True)
|
|
state_swapped = True
|
|
original_snapshot_sqlite(staging)
|
|
|
|
monkeypatch.setattr(
|
|
migration_module,
|
|
"_copy_sqlite_bundle",
|
|
track_sqlite_bundle_reopen,
|
|
)
|
|
monkeypatch.setattr(migrator, "_snapshot_sqlite_stores", swap_state_then_snapshot)
|
|
|
|
report = migrator.migrate()
|
|
finally:
|
|
writer.close()
|
|
|
|
assert state_swapped, _errors(report)
|
|
assert not reopened_source_bundle
|
|
assert any(item["kind"] == "apply" for item in _errors(report))
|
|
assert not target.exists()
|
|
assert not list(tmp_path.glob(".target-home.profile-staging.*"))
|
|
assert not list(tmp_path.glob("target-home.backup.*"))
|
|
assert not (tmp_path / ".target-home.profile-replace.json").exists()
|
|
assert not (tmp_path / "profile-replacement-history.json").exists()
|
|
assert not (source / IMPORT_MARKER_FILENAME).exists()
|
|
|
|
|
|
def test_source_gateway_appearing_during_published_validation_rolls_back(
|
|
tmp_path: Path,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
source = _build_source_home(tmp_path / "source-root")
|
|
target = tmp_path / "target-home"
|
|
(target / "workspace").mkdir(parents=True)
|
|
original = target / "workspace" / "SOUL.md"
|
|
original.write_text("original target\n", encoding="utf-8")
|
|
original_validate = OpenSquillaHomeMigrator._validate_published_target
|
|
|
|
def validate_then_start_old_gateway(
|
|
migrator: OpenSquillaHomeMigrator,
|
|
journal_snapshot: Any,
|
|
journal_payload: dict[str, Any],
|
|
) -> dict[str, int]:
|
|
identity = original_validate(migrator, journal_snapshot, journal_payload)
|
|
(source / "state" / "gateway.pid.lock").write_bytes(
|
|
b"synthetic old gateway authority\n"
|
|
)
|
|
return identity
|
|
|
|
monkeypatch.setattr(
|
|
OpenSquillaHomeMigrator,
|
|
"_validate_published_target",
|
|
validate_then_start_old_gateway,
|
|
)
|
|
|
|
report = _run(
|
|
source,
|
|
target,
|
|
apply=True,
|
|
replace_target=True,
|
|
confirm_replace_target=target,
|
|
)
|
|
|
|
assert any(
|
|
"source legacy gateway authority changed" in item["reason"]
|
|
for item in _errors(report)
|
|
)
|
|
assert original.read_text(encoding="utf-8") == "original target\n"
|
|
assert not list(tmp_path.glob("target-home.backup.*"))
|
|
assert not list(tmp_path.glob(".target-home.profile-staging.*"))
|
|
assert not (tmp_path / ".target-home.profile-replace.json").exists()
|
|
|
|
|
|
def test_source_authority_files_are_never_copied_or_modified(tmp_path: Path) -> None:
|
|
source = _build_source_home(tmp_path)
|
|
legacy_marker = source / IMPORT_MARKER_FILENAME
|
|
legacy_marker.write_text('{"legacy": true}\n', encoding="utf-8")
|
|
(source / "desktop-layout-v2.json").write_text("{}\n", encoding="utf-8")
|
|
old_authority = source / "migration" / "opensquilla" / "old" / "report.json"
|
|
old_authority.parent.mkdir(parents=True)
|
|
old_authority.write_text("{}\n", encoding="utf-8")
|
|
before = _file_bytes(source)
|
|
target = tmp_path / "target-home"
|
|
|
|
report = _run(source, target, apply=True)
|
|
|
|
assert not _errors(report)
|
|
assert _file_bytes(source) == before
|
|
assert not (target / IMPORT_MARKER_FILENAME).exists()
|
|
assert not (target / "desktop-layout-v2.json").exists()
|
|
assert not (target / "migration" / "opensquilla" / "old").exists()
|
|
|
|
|
|
def test_non_session_target_collision_requires_overwrite(tmp_path: Path) -> None:
|
|
source = _build_source_home(tmp_path)
|
|
target = tmp_path / "target-home"
|
|
(target / "workspace").mkdir(parents=True)
|
|
existing = target / "workspace" / "existing.txt"
|
|
existing.write_text("keep-me", encoding="utf-8")
|
|
|
|
report = _run(source, target, apply=True)
|
|
|
|
assert any(item["kind"] == "preflight/target" for item in _errors(report))
|
|
assert existing.read_text(encoding="utf-8") == "keep-me"
|
|
assert not (source / IMPORT_MARKER_FILENAME).exists()
|
|
|
|
|
|
@pytest.mark.parametrize("external_role", ["state", "workspace", "media", "agent"])
|
|
def test_replacement_blocks_target_external_roots_until_complete_backup_exists(
|
|
tmp_path: Path,
|
|
external_role: str,
|
|
) -> None:
|
|
source = _build_source_home(tmp_path / "source-root")
|
|
target = tmp_path / "target-home"
|
|
(target / "state").mkdir(parents=True)
|
|
(target / "workspace").mkdir()
|
|
(target / "workspace" / "SOUL.md").write_text("current\n", encoding="utf-8")
|
|
external = tmp_path / f"external-target-{external_role}"
|
|
external.mkdir()
|
|
(external / "sentinel.txt").write_text("do-not-touch\n", encoding="utf-8")
|
|
payload: dict[str, Any] = {
|
|
"state_dir": str(external if external_role == "state" else target / "state"),
|
|
"workspace_dir": str(
|
|
external if external_role == "workspace" else target / "workspace"
|
|
),
|
|
"port": 18791,
|
|
}
|
|
if external_role == "media":
|
|
payload["attachments"] = {"media_root": str(external)}
|
|
if external_role == "agent":
|
|
payload["agents"] = [{"id": "research", "workspace": str(external)}]
|
|
(target / "config.toml").write_text(tomli_w.dumps(payload), encoding="utf-8")
|
|
target_before = _file_bytes(target)
|
|
external_before = _file_bytes(external)
|
|
|
|
report = _run(
|
|
source,
|
|
target,
|
|
apply=True,
|
|
replace_target=True,
|
|
confirm_replace_target=target,
|
|
)
|
|
|
|
assert any(
|
|
item["kind"] == "preflight/target"
|
|
and "would not contain all current profile data" in item["reason"]
|
|
for item in _errors(report)
|
|
)
|
|
assert _file_bytes(target) == target_before
|
|
assert _file_bytes(external) == external_before
|
|
assert not list(tmp_path.glob(".target-home.profile-staging.*"))
|
|
assert not list(tmp_path.glob("target-home.backup.*"))
|
|
assert not (tmp_path / ".target-home.profile-replace.json").exists()
|
|
|
|
|
|
def test_replacement_guard_reads_legacy_target_state_dotenv(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
source = _build_source_home(tmp_path / "source-root")
|
|
target = tmp_path / "target-home"
|
|
(target / "state").mkdir(parents=True)
|
|
(target / "workspace").mkdir()
|
|
(target / "workspace" / "SOUL.md").write_text("current\n", encoding="utf-8")
|
|
external = tmp_path / "external-legacy-state"
|
|
external.mkdir()
|
|
(external / "sentinel.txt").write_text("preserve\n", encoding="utf-8")
|
|
(target / "state" / ".env").write_text(
|
|
f"OPENSQUILLA_GATEWAY_STATE_DIR={external}\n",
|
|
encoding="utf-8",
|
|
)
|
|
target_before = _file_bytes(target)
|
|
|
|
report = _run(
|
|
source,
|
|
target,
|
|
apply=True,
|
|
replace_target=True,
|
|
confirm_replace_target=target,
|
|
)
|
|
|
|
assert any(
|
|
"would not contain all current profile data" in item["reason"]
|
|
for item in _errors(report)
|
|
)
|
|
assert _file_bytes(target) == target_before
|
|
assert (external / "sentinel.txt").read_text(encoding="utf-8") == "preserve\n"
|
|
|
|
|
|
def test_empty_target_import_rejects_ambient_external_workspace_override(
|
|
tmp_path: Path,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
source = _build_source_home(tmp_path)
|
|
external = tmp_path / "ambient-live-workspace"
|
|
external.mkdir()
|
|
(external / "SOUL.md").write_text("unrelated live identity\n", encoding="utf-8")
|
|
target = tmp_path / "target-home"
|
|
monkeypatch.setenv("OPENSQUILLA_GATEWAY_WORKSPACE_DIR", str(external))
|
|
|
|
report = _run(source, target, apply=True)
|
|
|
|
assert any(
|
|
item["kind"] == "preflight/target"
|
|
and "cannot share another live data root" in item["reason"]
|
|
for item in _errors(report)
|
|
)
|
|
assert not target.exists()
|
|
assert not list(tmp_path.glob(".target-home.profile-staging.*"))
|
|
assert (external / "SOUL.md").read_text(encoding="utf-8") == (
|
|
"unrelated live identity\n"
|
|
)
|
|
|
|
|
|
def test_overwrite_publish_failure_restores_complete_original_target(
|
|
tmp_path: Path, monkeypatch
|
|
) -> None:
|
|
source = _build_source_home(tmp_path)
|
|
target = tmp_path / "target-home"
|
|
(target / "state").mkdir(parents=True)
|
|
(target / "state" / "sessions.db").write_bytes(b"original-session-store")
|
|
(target / "workspace").mkdir()
|
|
(target / "workspace" / "original.txt").write_text("original", encoding="utf-8")
|
|
original_move = recovery_module.native_move_no_replace
|
|
|
|
def fail_staging_publish(
|
|
src: str | Path,
|
|
dst: str | Path,
|
|
**move_options: object,
|
|
) -> None:
|
|
source_path = Path(src)
|
|
destination_path = Path(dst)
|
|
if ".profile-staging." in source_path.name and destination_path == target:
|
|
raise OSError("synthetic publish failure")
|
|
original_move(src, dst, **move_options)
|
|
|
|
monkeypatch.setattr(recovery_module, "native_move_no_replace", fail_staging_publish)
|
|
|
|
report = _run(
|
|
source,
|
|
target,
|
|
apply=True,
|
|
overwrite=True,
|
|
confirm_replace_target=target,
|
|
)
|
|
|
|
assert _errors(report)
|
|
assert (target / "state" / "sessions.db").read_bytes() == b"original-session-store"
|
|
assert (target / "workspace" / "original.txt").read_text(encoding="utf-8") == "original"
|
|
assert not (source / IMPORT_MARKER_FILENAME).exists()
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"failure_phase",
|
|
["target_parking", "first_publication", "replacement_publication"],
|
|
)
|
|
def test_post_move_unknown_state_preserves_journal_and_all_observed_paths(
|
|
tmp_path: Path,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
failure_phase: str,
|
|
) -> None:
|
|
source = _build_source_home(tmp_path)
|
|
source_before = _file_bytes(source)
|
|
target = tmp_path / "target-home"
|
|
replacing = failure_phase in {"target_parking", "replacement_publication"}
|
|
if replacing:
|
|
(target / "workspace").mkdir(parents=True)
|
|
(target / "workspace" / "original.txt").write_text(
|
|
"original", encoding="utf-8"
|
|
)
|
|
original_move = recovery_module.native_move_no_replace
|
|
|
|
def move_then_lose_post_state(
|
|
src: str | Path,
|
|
dst: str | Path,
|
|
**move_options: object,
|
|
) -> None:
|
|
source_path = Path(src)
|
|
destination_path = Path(dst)
|
|
is_parking = source_path == target and ".backup." in destination_path.name
|
|
is_publication = (
|
|
".profile-staging." in source_path.name and destination_path == target
|
|
)
|
|
should_fail = (
|
|
failure_phase == "target_parking"
|
|
and is_parking
|
|
or failure_phase == "first_publication"
|
|
and not replacing
|
|
and is_publication
|
|
or failure_phase == "replacement_publication"
|
|
and replacing
|
|
and is_publication
|
|
)
|
|
original_move(src, dst, **move_options)
|
|
if should_fail:
|
|
raise recovery_module.AtomicStateUnknownError(
|
|
"synthetic post-move state is unknown"
|
|
)
|
|
|
|
monkeypatch.setattr(
|
|
recovery_module,
|
|
"native_move_no_replace",
|
|
move_then_lose_post_state,
|
|
)
|
|
|
|
report = _run(
|
|
source,
|
|
target,
|
|
apply=True,
|
|
replace_target=replacing,
|
|
confirm_replace_target=target if replacing else None,
|
|
)
|
|
|
|
assert _errors(report)
|
|
journal = tmp_path / ".target-home.profile-replace.json"
|
|
assert journal.is_file()
|
|
payload = json.loads(journal.read_text(encoding="utf-8"))
|
|
staging = Path(payload["staging"])
|
|
backup = Path(payload["backup"])
|
|
if failure_phase == "target_parking":
|
|
assert not target.exists()
|
|
assert staging.is_dir()
|
|
assert (backup / "workspace" / "original.txt").read_text(
|
|
encoding="utf-8"
|
|
) == "original"
|
|
elif failure_phase == "first_publication":
|
|
assert target.is_dir()
|
|
assert not staging.exists()
|
|
assert not backup.exists()
|
|
else:
|
|
assert target.is_dir()
|
|
assert not staging.exists()
|
|
assert (backup / "workspace" / "original.txt").read_text(
|
|
encoding="utf-8"
|
|
) == "original"
|
|
assert _file_bytes(source) == source_before
|
|
inspected = recovery_module.inspect_profile(
|
|
target,
|
|
profile_kind="desktop-primary",
|
|
)
|
|
assert inspected.outcome == "recovery_required"
|
|
assert inspected.stable_code == "transaction_incomplete"
|
|
|
|
|
|
@pytest.mark.skipif(
|
|
sys.platform == "win32",
|
|
reason="Windows reacquire failures are intentionally state-unknown",
|
|
)
|
|
def test_lock_handoff_failure_after_publish_rolls_back_complete_original_target(
|
|
tmp_path: Path,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
import opensquilla.recovery.locking as locking_module
|
|
|
|
source = _build_source_home(tmp_path)
|
|
source_before = _file_bytes(source)
|
|
target = tmp_path / "target-home"
|
|
(target / "state").mkdir(parents=True)
|
|
(target / "state" / "sessions.db").write_bytes(b"original-session-store")
|
|
(target / "workspace").mkdir()
|
|
(target / "workspace" / "original.txt").write_text("original", encoding="utf-8")
|
|
|
|
def fail_handoff(source_state: Path, _destination_state: Path) -> None:
|
|
if ".profile-staging." in source_state.parent.name:
|
|
raise recovery_module.UnsafePathError("synthetic lock handoff failure")
|
|
|
|
monkeypatch.setattr(
|
|
locking_module,
|
|
"rebind_legacy_gateway_lock",
|
|
fail_handoff,
|
|
)
|
|
|
|
report = _run(
|
|
source,
|
|
target,
|
|
apply=True,
|
|
overwrite=True,
|
|
confirm_replace_target=target,
|
|
)
|
|
|
|
assert _errors(report)
|
|
assert (target / "state" / "sessions.db").read_bytes() == b"original-session-store"
|
|
assert (target / "workspace" / "original.txt").read_text(encoding="utf-8") == "original"
|
|
assert not list(tmp_path.glob(".target-home.profile-staging.*"))
|
|
assert not (tmp_path / ".target-home.profile-replace.json").exists()
|
|
assert not (source / IMPORT_MARKER_FILENAME).exists()
|
|
assert _file_bytes(source) == source_before
|
|
|
|
|
|
@pytest.mark.skipif(sys.platform != "win32", reason="requires Windows lock handoff")
|
|
def test_windows_lock_reacquire_failure_preserves_profile_transaction(
|
|
tmp_path: Path,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
import opensquilla.recovery.locking as locking_module
|
|
|
|
source = _build_source_home(tmp_path)
|
|
source_before = _file_bytes(source)
|
|
target = tmp_path / "target-home"
|
|
(target / "state").mkdir(parents=True)
|
|
(target / "state" / "sessions.db").write_bytes(b"original-session-store")
|
|
(target / "workspace").mkdir()
|
|
(target / "workspace" / "original.txt").write_text("original", encoding="utf-8")
|
|
original_reacquire = locking_module._reacquire_suspended_legacy_locks
|
|
|
|
def fail_candidate_reacquire(moves, *, destination: bool) -> None:
|
|
if destination and any(
|
|
".profile-staging." in item.source_state.parent.name for item in moves
|
|
):
|
|
raise recovery_module.LegacyGatewayRunningError(
|
|
"synthetic old gateway won the lock handoff"
|
|
)
|
|
original_reacquire(moves, destination=destination)
|
|
|
|
monkeypatch.setattr(
|
|
locking_module,
|
|
"_reacquire_suspended_legacy_locks",
|
|
fail_candidate_reacquire,
|
|
)
|
|
|
|
report = _run(
|
|
source,
|
|
target,
|
|
apply=True,
|
|
overwrite=True,
|
|
confirm_replace_target=target,
|
|
)
|
|
|
|
assert _errors(report)
|
|
journal = tmp_path / ".target-home.profile-replace.json"
|
|
payload = json.loads(journal.read_text(encoding="utf-8"))
|
|
backup = Path(payload["backup"])
|
|
assert payload["phase"] == "target_parked"
|
|
assert (backup / "state" / "sessions.db").read_bytes() == b"original-session-store"
|
|
assert (target / "workspace" / "SOUL.md").is_file()
|
|
assert _file_bytes(source) == source_before
|
|
inspected = recovery_module.inspect_profile(
|
|
target,
|
|
profile_kind="desktop-primary",
|
|
)
|
|
assert inspected.outcome == "recovery_required"
|
|
assert inspected.stable_code == "transaction_incomplete"
|
|
|
|
|
|
def test_published_candidate_holds_legacy_gateway_lock_during_validation(
|
|
tmp_path: Path,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
source = _build_source_home(tmp_path)
|
|
target = tmp_path / "target-home"
|
|
observed: list[str] = []
|
|
original_validate = OpenSquillaHomeMigrator._validate_published_target
|
|
|
|
def validate_while_old_gateway_contends(
|
|
migrator: OpenSquillaHomeMigrator,
|
|
journal_snapshot: Any,
|
|
journal_payload: dict[str, Any],
|
|
) -> dict[str, Any]:
|
|
context = multiprocessing.get_context("spawn" if sys.platform == "win32" else "fork")
|
|
queue = context.Queue()
|
|
process = context.Process(
|
|
target=_probe_gateway_lock,
|
|
args=(str(target / "state"), queue),
|
|
)
|
|
process.start()
|
|
process.join(timeout=10)
|
|
assert process.exitcode == 0
|
|
observed.append(queue.get(timeout=1))
|
|
return original_validate(migrator, journal_snapshot, journal_payload)
|
|
|
|
monkeypatch.setattr(
|
|
OpenSquillaHomeMigrator,
|
|
"_validate_published_target",
|
|
validate_while_old_gateway_contends,
|
|
)
|
|
|
|
report = _run(source, target, apply=True)
|
|
|
|
assert not _errors(report)
|
|
assert observed == ["busy"]
|
|
|
|
|
|
def test_profile_import_contends_with_desktop_global_cleanup_lock(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
source = _build_source_home(tmp_path)
|
|
target = tmp_path / "target-home"
|
|
source_before = _file_bytes(source)
|
|
acquired = threading.Event()
|
|
release = threading.Event()
|
|
|
|
def hold_cleanup_authority() -> None:
|
|
with ProfileOperationLock(target.parent):
|
|
acquired.set()
|
|
release.wait(timeout=10)
|
|
|
|
holder = threading.Thread(target=hold_cleanup_authority)
|
|
holder.start()
|
|
assert acquired.wait(timeout=5)
|
|
try:
|
|
report = _run(source, target, apply=True)
|
|
finally:
|
|
release.set()
|
|
holder.join(timeout=5)
|
|
|
|
assert any(item["kind"] == "preflight/lock" for item in _errors(report))
|
|
assert _file_bytes(source) == source_before
|
|
assert not target.exists()
|
|
assert not list(tmp_path.glob(".target-home.profile-staging.*"))
|
|
assert not (tmp_path / ".target-home.profile-replace.json").exists()
|
|
assert not (tmp_path / "profile-replacement-history.json").exists()
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"failed_phase",
|
|
["target_parked", "candidate_published_unvalidated", "committed"],
|
|
)
|
|
def test_journal_phase_write_failure_rolls_back_complete_original_target(
|
|
tmp_path: Path,
|
|
monkeypatch,
|
|
failed_phase: str,
|
|
) -> None:
|
|
source = _build_source_home(tmp_path)
|
|
target = tmp_path / "target-home"
|
|
(target / "state").mkdir(parents=True)
|
|
(target / "state" / "sessions.db").write_bytes(b"original-session-store")
|
|
(target / "workspace").mkdir()
|
|
(target / "workspace" / "original.txt").write_text("original", encoding="utf-8")
|
|
original_journal_write = migration_module._cas_publish_json
|
|
|
|
def fail_phase_write(snapshot: Any, payload: dict[str, Any]) -> Any:
|
|
if payload.get("phase") == failed_phase:
|
|
raise OSError(f"synthetic {failed_phase} journal failure")
|
|
return original_journal_write(snapshot, payload)
|
|
|
|
monkeypatch.setattr(migration_module, "_cas_publish_json", fail_phase_write)
|
|
|
|
report = _run(
|
|
source,
|
|
target,
|
|
apply=True,
|
|
overwrite=True,
|
|
confirm_replace_target=target,
|
|
)
|
|
|
|
assert _errors(report)
|
|
assert (target / "state" / "sessions.db").read_bytes() == b"original-session-store"
|
|
assert (target / "workspace" / "original.txt").read_text(encoding="utf-8") == "original"
|
|
assert not list(tmp_path.glob("target-home.backup.*"))
|
|
assert not (tmp_path / ".target-home.profile-replace.json").exists()
|
|
assert not list(tmp_path.glob(".target-home.profile-staging.*"))
|
|
assert not (tmp_path / "profile-replacement-history.json").exists()
|
|
assert not (source / IMPORT_MARKER_FILENAME).exists()
|
|
|
|
|
|
@pytest.mark.parametrize("existing", [False, True])
|
|
def test_import_journal_post_publish_sync_failure_is_atomic_unknown(
|
|
tmp_path: Path,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
existing: bool,
|
|
) -> None:
|
|
from opensquilla.recovery.config_patch import ConfigSnapshot
|
|
|
|
journal = tmp_path / ".target.profile-replace.json"
|
|
if existing:
|
|
journal.write_text('{"phase":"prepared"}\n', encoding="utf-8")
|
|
snapshot = ConfigSnapshot.capture(journal)
|
|
payload = {"phase": "validated", "transaction_id": str(uuid.uuid4())}
|
|
|
|
def fail_directory_sync(_path: Path) -> None:
|
|
raise OSError("synthetic post-publication directory fsync failure")
|
|
|
|
monkeypatch.setattr(migration_module, "_fsync_directory", fail_directory_sync)
|
|
|
|
with pytest.raises(recovery_module.AtomicStateUnknownError):
|
|
migration_module._cas_publish_json(snapshot, payload)
|
|
|
|
assert json.loads(journal.read_text(encoding="utf-8")) == payload
|
|
|
|
|
|
def test_crash_after_prepared_journal_is_cleaned_before_retry(
|
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
source = _build_source_home(tmp_path)
|
|
target = tmp_path / "target-home"
|
|
original_journal_write = migration_module._cas_publish_json
|
|
|
|
with monkeypatch.context() as scoped:
|
|
def crash_after_prepared(snapshot: Any, payload: dict[str, Any]) -> Any:
|
|
published = original_journal_write(snapshot, payload)
|
|
if payload.get("phase") == "prepared":
|
|
raise KeyboardInterrupt("synthetic crash after prepared")
|
|
return published
|
|
|
|
scoped.setattr(migration_module, "_cas_publish_json", crash_after_prepared)
|
|
with pytest.raises(KeyboardInterrupt, match="synthetic crash"):
|
|
_run(source, target, apply=True)
|
|
|
|
journal = tmp_path / ".target-home.profile-replace.json"
|
|
old_staging = next(tmp_path.glob(".target-home.profile-staging.*"))
|
|
assert journal.is_file()
|
|
assert not target.exists()
|
|
|
|
report = _run(source, target, apply=True)
|
|
|
|
assert not _errors(report)
|
|
assert any(item["kind"] == "recovery" for item in report["items"])
|
|
assert not journal.exists()
|
|
assert not old_staging.exists()
|
|
assert (target / "workspace" / "MEMORY.md").is_file()
|
|
|
|
|
|
def test_crash_after_empty_target_park_restores_before_retry(
|
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
source = _build_source_home(tmp_path)
|
|
target = tmp_path / "target-home"
|
|
target.mkdir()
|
|
original_journal_write = migration_module._cas_publish_json
|
|
|
|
with monkeypatch.context() as scoped:
|
|
def crash_before_parked_phase(snapshot: Any, payload: dict[str, Any]) -> Any:
|
|
if payload.get("phase") == "target_parked":
|
|
raise KeyboardInterrupt("synthetic crash after empty target park")
|
|
return original_journal_write(snapshot, payload)
|
|
|
|
scoped.setattr(
|
|
migration_module,
|
|
"_cas_publish_json",
|
|
crash_before_parked_phase,
|
|
)
|
|
with pytest.raises(KeyboardInterrupt, match="empty target park"):
|
|
_run(source, target, apply=True)
|
|
|
|
journal = tmp_path / ".target-home.profile-replace.json"
|
|
assert json.loads(journal.read_text(encoding="utf-8"))["phase"] == "prepared"
|
|
assert not target.exists()
|
|
assert len(list(tmp_path.glob("target-home.backup.*"))) == 1
|
|
|
|
report = _run(source, target, apply=True)
|
|
|
|
assert not _errors(report)
|
|
assert any(item["kind"] == "recovery" for item in report["items"])
|
|
assert not journal.exists()
|
|
assert (target / "workspace" / "MEMORY.md").is_file()
|
|
|
|
|
|
def test_published_recovery_required_candidate_is_rolled_back(
|
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
source = _build_source_home(tmp_path)
|
|
target = tmp_path / "target-home"
|
|
target.mkdir()
|
|
original = target / "original.txt"
|
|
original.write_text("preserve", encoding="utf-8")
|
|
|
|
monkeypatch.setattr(
|
|
recovery_module,
|
|
"inspect_profile",
|
|
lambda *_args, **_kwargs: SimpleNamespace(
|
|
outcome="recovery_required",
|
|
stable_code="synthetic_layout_failure",
|
|
allowed_actions=(),
|
|
),
|
|
)
|
|
|
|
report = _run(
|
|
source,
|
|
target,
|
|
apply=True,
|
|
replace_target=True,
|
|
confirm_replace_target=target,
|
|
)
|
|
|
|
assert any("synthetic_layout_failure" in item["reason"] for item in _errors(report))
|
|
assert original.read_text(encoding="utf-8") == "preserve"
|
|
assert not list(tmp_path.glob("target-home.backup.*"))
|
|
assert not list(tmp_path.glob(".target-home.profile-staging.*"))
|
|
assert not (tmp_path / ".target-home.profile-replace.json").exists()
|
|
assert not (tmp_path / "profile-replacement-history.json").exists()
|
|
|
|
|
|
def test_history_publication_failure_rolls_back_target_and_backup(
|
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
source = _build_source_home(tmp_path)
|
|
target = tmp_path / "target-home"
|
|
target.mkdir()
|
|
original = target / "original.txt"
|
|
original.write_text("preserve", encoding="utf-8")
|
|
original_publish = migration_module._cas_publish_bytes
|
|
|
|
def fail_history(snapshot: Any, data: bytes, *, mode: int) -> Any:
|
|
if snapshot.path.name == "profile-replacement-history.json":
|
|
raise OSError("synthetic history publication failure")
|
|
return original_publish(snapshot, data, mode=mode)
|
|
|
|
monkeypatch.setattr(migration_module, "_cas_publish_bytes", fail_history)
|
|
|
|
report = _run(
|
|
source,
|
|
target,
|
|
apply=True,
|
|
replace_target=True,
|
|
confirm_replace_target=target,
|
|
)
|
|
|
|
assert any("history publication failure" in item["reason"] for item in _errors(report))
|
|
assert original.read_text(encoding="utf-8") == "preserve"
|
|
assert not list(tmp_path.glob("target-home.backup.*"))
|
|
assert not list(tmp_path.glob(".target-home.profile-staging.*"))
|
|
assert not (tmp_path / ".target-home.profile-replace.json").exists()
|
|
assert not (tmp_path / "profile-replacement-history.json").exists()
|
|
|
|
|
|
def test_source_change_after_history_publication_rolls_back_before_commit(
|
|
tmp_path: Path,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
source = _build_source_home(tmp_path / "source-root")
|
|
target = tmp_path / "target-home"
|
|
target.mkdir()
|
|
original = target / "original.txt"
|
|
original.write_text("preserve", encoding="utf-8")
|
|
original_history = OpenSquillaHomeMigrator._write_replacement_history
|
|
|
|
def publish_history_then_change_source(
|
|
migrator: OpenSquillaHomeMigrator,
|
|
backup: Path,
|
|
journal_payload: dict[str, Any],
|
|
*,
|
|
allow_existing: bool,
|
|
) -> Any:
|
|
publication = original_history(
|
|
migrator,
|
|
backup,
|
|
journal_payload,
|
|
allow_existing=allow_existing,
|
|
)
|
|
(source / "workspace" / "MEMORY.md").write_text(
|
|
"changed after history publication\n",
|
|
encoding="utf-8",
|
|
)
|
|
return publication
|
|
|
|
monkeypatch.setattr(
|
|
OpenSquillaHomeMigrator,
|
|
"_write_replacement_history",
|
|
publish_history_then_change_source,
|
|
)
|
|
|
|
report = _run(
|
|
source,
|
|
target,
|
|
apply=True,
|
|
replace_target=True,
|
|
confirm_replace_target=target,
|
|
)
|
|
|
|
assert any("source changed during import" in item["reason"] for item in _errors(report))
|
|
assert original.read_text(encoding="utf-8") == "preserve"
|
|
assert not (tmp_path / ".target-home.profile-replace.json").exists()
|
|
|
|
|
|
@pytest.mark.parametrize("target_existed", [False, True])
|
|
def test_validated_import_crash_recovers_by_rolling_back_not_committing(
|
|
tmp_path: Path,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
target_existed: bool,
|
|
) -> None:
|
|
source = _build_source_home(tmp_path / "source-root")
|
|
target = tmp_path / "target-home"
|
|
if target_existed:
|
|
(target / "workspace").mkdir(parents=True)
|
|
(target / "state").mkdir()
|
|
(target / "config.toml").write_text(
|
|
'state_dir = "state"\nworkspace_dir = "workspace"\n',
|
|
encoding="utf-8",
|
|
)
|
|
(target / "workspace" / "original.txt").write_text(
|
|
"preserve\n",
|
|
encoding="utf-8",
|
|
)
|
|
original_publish = migration_module._cas_publish_json
|
|
|
|
def crash_before_committed_publish(snapshot: Any, payload: dict[str, Any]) -> Any:
|
|
if payload.get("phase") == "committed":
|
|
raise recovery_module.AtomicStateUnknownError(
|
|
"synthetic process loss after validated phase"
|
|
)
|
|
return original_publish(snapshot, payload)
|
|
|
|
with monkeypatch.context() as scoped:
|
|
scoped.setattr(
|
|
migration_module,
|
|
"_cas_publish_json",
|
|
crash_before_committed_publish,
|
|
)
|
|
report = _run(
|
|
source,
|
|
target,
|
|
apply=True,
|
|
replace_target=target_existed,
|
|
confirm_replace_target=target if target_existed else None,
|
|
)
|
|
|
|
assert _errors(report)
|
|
journal = tmp_path / ".target-home.profile-replace.json"
|
|
journal_payload = json.loads(journal.read_text(encoding="utf-8"))
|
|
assert journal_payload["phase"] == "validated"
|
|
before = recovery_module.inspect_profile(target, profile_kind="desktop-primary")
|
|
recovered = recover_profile_transaction(
|
|
target,
|
|
transaction_id=before.transaction_id,
|
|
expected_revision=before.revision,
|
|
import_recoverer=migration_module.recover_interrupted_profile_import,
|
|
)
|
|
|
|
assert recovered.outcome == "ready"
|
|
assert not journal.exists()
|
|
if target_existed:
|
|
assert (target / "workspace" / "original.txt").read_text(encoding="utf-8") == (
|
|
"preserve\n"
|
|
)
|
|
history = json.loads(
|
|
(tmp_path / "profile-replacement-history.json").read_text(encoding="utf-8")
|
|
)
|
|
assert all(
|
|
item.get("transaction_id") != journal_payload["transaction_id"]
|
|
for item in history["backups"]
|
|
)
|
|
else:
|
|
assert not target.exists()
|
|
assert not (tmp_path / "profile-replacement-history.json").exists()
|
|
|
|
|
|
def test_committed_journal_cannot_bypass_missing_history(
|
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
source = _build_source_home(tmp_path)
|
|
target = tmp_path / "target-home"
|
|
(target / "workspace").mkdir(parents=True)
|
|
(target / "workspace" / "SOUL.md").write_text("previous\n", encoding="utf-8")
|
|
(target / "config.toml").write_text("port = 18791\n", encoding="utf-8")
|
|
transaction_id = "00000000-0000-0000-0000-000000000101"
|
|
monkeypatch.setattr(
|
|
migration_module.uuid,
|
|
"uuid4",
|
|
lambda: uuid.UUID(transaction_id),
|
|
)
|
|
finalized = tmp_path / (
|
|
f".target-home.profile-replace.{transaction_id}.committed.json"
|
|
)
|
|
collision = b"existing recovery authority\n"
|
|
finalized.write_bytes(collision)
|
|
report = _run(
|
|
source,
|
|
target,
|
|
apply=True,
|
|
replace_target=True,
|
|
confirm_replace_target=target,
|
|
)
|
|
assert not _errors(report)
|
|
journal = tmp_path / ".target-home.profile-replace.json"
|
|
assert json.loads(journal.read_text(encoding="utf-8"))["phase"] == "committed"
|
|
assert finalized.read_bytes() == collision
|
|
(tmp_path / "profile-replacement-history.json").unlink()
|
|
|
|
inspected = recovery_module.inspect_profile(target, profile_kind="desktop-primary")
|
|
|
|
assert inspected.outcome == "recovery_required"
|
|
assert inspected.stable_code == "transaction_incomplete"
|
|
|
|
|
|
def test_desktop_marker_after_hardened_journal_finalize_keeps_transaction_complete(
|
|
tmp_path: Path,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
source = _build_source_home(tmp_path)
|
|
target = tmp_path / "target-home"
|
|
|
|
monkeypatch.setenv("OPENSQUILLA_PROFILE_KIND", "desktop-primary")
|
|
|
|
report = _run(source, target, apply=True)
|
|
|
|
assert not _errors(report)
|
|
assert (target / "desktop-layout-v2.json").is_file()
|
|
journal = tmp_path / ".target-home.profile-replace.json"
|
|
assert not journal.exists()
|
|
assert len(list(tmp_path.glob(".target-home.profile-replace.*.committed.json"))) == 1
|
|
restarted = recovery_module.inspect_profile(
|
|
target,
|
|
profile_kind="desktop-primary",
|
|
)
|
|
assert restarted.outcome == "ready"
|
|
assert restarted.stable_code == "canonical_workspace"
|
|
|
|
|
|
def test_reconcile_finalizes_retained_commit_after_target_metadata_changes(
|
|
tmp_path: Path,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
source = _build_source_home(tmp_path / "first-source")
|
|
target = tmp_path / "target-home"
|
|
transaction_id = "00000000-0000-0000-0000-000000000102"
|
|
finalized = tmp_path / (
|
|
f".target-home.profile-replace.{transaction_id}.committed.json"
|
|
)
|
|
finalized.write_bytes(b"existing recovery authority\n")
|
|
with monkeypatch.context() as scoped:
|
|
scoped.setattr(
|
|
migration_module.uuid,
|
|
"uuid4",
|
|
lambda: uuid.UUID(transaction_id),
|
|
)
|
|
first = _run(source, target, apply=True)
|
|
|
|
assert not _errors(first)
|
|
journal = tmp_path / ".target-home.profile-replace.json"
|
|
assert journal.is_file()
|
|
(target / "operator-note.txt").write_text("post-commit change\n", encoding="utf-8")
|
|
finalized.unlink()
|
|
|
|
reconciled = recovery_module.reconcile_profile(
|
|
target,
|
|
profile_kind="desktop-primary",
|
|
)
|
|
|
|
assert reconciled.outcome in {"ready", "attention"}
|
|
assert not journal.exists()
|
|
assert json.loads(finalized.read_text(encoding="utf-8"))["phase"] == "committed"
|
|
|
|
second_source = _build_source_home(tmp_path / "second-source")
|
|
(second_source / "workspace" / "MEMORY.md").write_text(
|
|
"# second source\n",
|
|
encoding="utf-8",
|
|
)
|
|
second = _run(
|
|
second_source,
|
|
target,
|
|
apply=True,
|
|
replace_target=True,
|
|
confirm_replace_target=target,
|
|
)
|
|
|
|
assert not _errors(second)
|
|
assert (target / "workspace" / "MEMORY.md").read_text(encoding="utf-8") == (
|
|
"# second source\n"
|
|
)
|
|
|
|
|
|
def test_dry_run_config_compatibility_pass_has_no_global_side_effects(
|
|
tmp_path: Path, monkeypatch
|
|
) -> None:
|
|
source = _build_source_home(tmp_path)
|
|
target = tmp_path / "target-home"
|
|
diagnostics_home = tmp_path / "diagnostics-home"
|
|
monkeypatch.setattr(
|
|
config_migration_module,
|
|
"default_opensquilla_home",
|
|
lambda: diagnostics_home,
|
|
)
|
|
memory_warned = config_migration_module._LEGACY_MEMORY_FIELDS_WARNED
|
|
memory_seen = set(config_migration_module._LEGACY_MEMORY_FIELDS_SEEN)
|
|
token_warned = config_migration_module._LEGACY_AGENT_TOKEN_SAVING_FIELDS_WARNED
|
|
token_seen = set(config_migration_module._LEGACY_AGENT_TOKEN_SAVING_FIELDS_SEEN)
|
|
|
|
report = _run(source, target)
|
|
|
|
assert not _errors(report)
|
|
assert not diagnostics_home.exists()
|
|
assert config_migration_module._LEGACY_MEMORY_FIELDS_WARNED is memory_warned
|
|
assert config_migration_module._LEGACY_MEMORY_FIELDS_SEEN == memory_seen
|
|
assert config_migration_module._LEGACY_AGENT_TOKEN_SAVING_FIELDS_WARNED is token_warned
|
|
assert config_migration_module._LEGACY_AGENT_TOKEN_SAVING_FIELDS_SEEN == token_seen
|
|
|
|
|
|
def _write_simple_sqlite(path: Path, value: str) -> None:
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
connection = sqlite3.connect(path)
|
|
try:
|
|
connection.execute("CREATE TABLE payload (value TEXT NOT NULL)")
|
|
connection.execute("INSERT INTO payload VALUES (?)", (value,))
|
|
connection.commit()
|
|
finally:
|
|
connection.close()
|
|
|
|
|
|
def test_all_imported_sqlite_stores_are_consistent_without_report_copies(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
source = _build_source_home(tmp_path)
|
|
_write_simple_sqlite(source / "state" / "approval_queue.sqlite", "approval")
|
|
_write_simple_sqlite(source / "state" / "sandbox_user_grants.sqlite", "sandbox")
|
|
_write_simple_sqlite(source / "state" / "agents" / "main" / "memory.db", "memory")
|
|
target = tmp_path / "target-home"
|
|
|
|
report = _run(source, target, apply=True)
|
|
|
|
assert not _errors(report)
|
|
expected = [
|
|
Path("sessions.db"),
|
|
Path("scheduler.db"),
|
|
Path("approval_queue.sqlite"),
|
|
Path("sandbox_user_grants.sqlite"),
|
|
Path("agents/main/memory.db"),
|
|
]
|
|
for relative in expected:
|
|
imported = target / "state" / relative
|
|
assert imported.is_file(), relative
|
|
connection = sqlite3.connect(imported)
|
|
try:
|
|
assert connection.execute("PRAGMA quick_check").fetchone() == ("ok",)
|
|
finally:
|
|
connection.close()
|
|
output_dir = Path(report["output_dir"])
|
|
assert not any(
|
|
path.suffix in {".db", ".sqlite", ".sqlite3"}
|
|
for path in output_dir.rglob("*")
|
|
if path.is_file()
|
|
)
|
|
|
|
|
|
def test_wal_only_committed_row_survives_as_normalized_sqlite_snapshot(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
source = _build_source_home(tmp_path)
|
|
db_path = source / "state" / "sessions.db"
|
|
writer = sqlite3.connect(db_path)
|
|
try:
|
|
assert writer.execute("PRAGMA journal_mode=WAL").fetchone() == ("wal",)
|
|
writer.execute("PRAGMA wal_autocheckpoint=0")
|
|
writer.execute("CREATE TABLE wal_payload (value TEXT NOT NULL)")
|
|
writer.commit()
|
|
writer.execute("PRAGMA wal_checkpoint(TRUNCATE)")
|
|
writer.execute("INSERT INTO wal_payload VALUES ('committed-in-wal')")
|
|
writer.commit()
|
|
assert db_path.with_name("sessions.db-wal").stat().st_size > 0
|
|
source_before = _file_bytes(source)
|
|
|
|
target = tmp_path / "target-home"
|
|
report = _run(source, target, apply=True)
|
|
source_after = _file_bytes(source)
|
|
finally:
|
|
writer.close()
|
|
|
|
assert not _errors(report)
|
|
assert source_after == source_before
|
|
target_db = target / "state" / "sessions.db"
|
|
assert not target_db.with_name("sessions.db-wal").exists()
|
|
assert not target_db.with_name("sessions.db-shm").exists()
|
|
connection = sqlite3.connect(target_db)
|
|
try:
|
|
assert connection.execute("SELECT value FROM wal_payload").fetchall() == [
|
|
("committed-in-wal",)
|
|
]
|
|
assert connection.execute("PRAGMA quick_check").fetchone() == ("ok",)
|
|
finally:
|
|
connection.close()
|
|
|
|
|
|
def test_complete_wal_bundle_wins_over_identical_duplicate_base_database(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
source = _build_source_home(tmp_path)
|
|
canonical_db = source / "state" / "sessions.db"
|
|
connection = sqlite3.connect(canonical_db)
|
|
try:
|
|
assert connection.execute("PRAGMA journal_mode=WAL").fetchone() == ("wal",)
|
|
connection.execute("PRAGMA wal_autocheckpoint=0")
|
|
connection.execute("CREATE TABLE wal_bundle_payload (value TEXT NOT NULL)")
|
|
connection.commit()
|
|
connection.execute("PRAGMA wal_checkpoint(TRUNCATE)")
|
|
finally:
|
|
connection.close()
|
|
|
|
external_state = tmp_path / "external-state"
|
|
external_state.mkdir()
|
|
external_db = external_state / "sessions.db"
|
|
shutil.copy2(canonical_db, external_db)
|
|
writer = sqlite3.connect(external_db)
|
|
try:
|
|
writer.execute("PRAGMA wal_autocheckpoint=0")
|
|
writer.execute("INSERT INTO wal_bundle_payload VALUES ('committed-in-external-wal')")
|
|
writer.commit()
|
|
assert external_db.with_name("sessions.db-wal").stat().st_size > 32
|
|
assert canonical_db.read_bytes() == external_db.read_bytes()
|
|
payload = tomllib.loads((source / "config.toml").read_text(encoding="utf-8"))
|
|
payload["state_dir"] = str(external_state)
|
|
(source / "config.toml").write_text(tomli_w.dumps(payload), encoding="utf-8")
|
|
target = tmp_path / "target-home"
|
|
|
|
report = _run(source, target, apply=True)
|
|
finally:
|
|
writer.close()
|
|
|
|
assert not _errors(report)
|
|
target_connection = sqlite3.connect(target / "state" / "sessions.db")
|
|
try:
|
|
assert target_connection.execute("SELECT value FROM wal_bundle_payload").fetchall() == [
|
|
("committed-in-external-wal",)
|
|
]
|
|
finally:
|
|
target_connection.close()
|
|
|
|
|
|
def test_logically_conflicting_duplicate_sqlite_roots_fail_closed(tmp_path: Path) -> None:
|
|
source = _build_source_home(tmp_path)
|
|
canonical_db = source / "state" / "sessions.db"
|
|
external_state = tmp_path / "external-state"
|
|
external_state.mkdir()
|
|
external_db = external_state / "sessions.db"
|
|
shutil.copy2(canonical_db, external_db)
|
|
connection = sqlite3.connect(external_db)
|
|
try:
|
|
connection.execute("CREATE TABLE divergent_payload (value TEXT NOT NULL)")
|
|
connection.execute("INSERT INTO divergent_payload VALUES ('external-only')")
|
|
connection.commit()
|
|
finally:
|
|
connection.close()
|
|
payload = tomllib.loads((source / "config.toml").read_text(encoding="utf-8"))
|
|
payload["state_dir"] = str(external_state)
|
|
(source / "config.toml").write_text(tomli_w.dumps(payload), encoding="utf-8")
|
|
target = tmp_path / "target-home"
|
|
|
|
report = _run(source, target, apply=True)
|
|
|
|
assert any(
|
|
item["kind"] == "preflight/data-root"
|
|
and "conflicting logical SQLite stores" in item["reason"]
|
|
for item in _errors(report)
|
|
)
|
|
assert not target.exists()
|
|
assert not (source / IMPORT_MARKER_FILENAME).exists()
|
|
|
|
|
|
def test_conflicting_split_state_roots_fail_without_publishing(tmp_path: Path) -> None:
|
|
source = _build_source_home(tmp_path)
|
|
(source / "state" / "collision.bin").write_bytes(b"canonical")
|
|
external_state = tmp_path / "external-state"
|
|
external_state.mkdir()
|
|
(external_state / "collision.bin").write_bytes(b"external")
|
|
payload = tomllib.loads((source / "config.toml").read_text(encoding="utf-8"))
|
|
payload["state_dir"] = str(external_state)
|
|
(source / "config.toml").write_text(tomli_w.dumps(payload), encoding="utf-8")
|
|
target = tmp_path / "target-home"
|
|
|
|
preview = _run(source, target, apply=False)
|
|
report = _run(source, target, apply=True)
|
|
|
|
for result in (preview, report):
|
|
assert any(item["kind"] == "preflight/data-root" for item in _errors(result))
|
|
assert not target.exists()
|
|
assert not (source / IMPORT_MARKER_FILENAME).exists()
|
|
|
|
|
|
def test_interrupted_overwrite_is_restored_before_retry(tmp_path: Path) -> None:
|
|
source = _build_source_home(tmp_path)
|
|
target = tmp_path / "target-home"
|
|
transaction_id = "123e4567-e89b-42d3-a456-426614174000"
|
|
interrupted_backup = tmp_path / f"target-home.backup.{transaction_id}"
|
|
(interrupted_backup / "workspace").mkdir(parents=True)
|
|
(interrupted_backup / "workspace" / "original.txt").write_text(
|
|
"original", encoding="utf-8"
|
|
)
|
|
interrupted_staging = tmp_path / f".target-home.profile-staging.{transaction_id}"
|
|
interrupted_staging.mkdir()
|
|
(interrupted_staging / "partial.txt").write_text("partial", encoding="utf-8")
|
|
journal = tmp_path / ".target-home.profile-replace.json"
|
|
journal.write_text(
|
|
json.dumps(
|
|
{
|
|
"schema_version": 1,
|
|
"operation": "profile-import",
|
|
"transaction_id": transaction_id,
|
|
"source": _normalized_path(source),
|
|
"source_kind": "cli-home",
|
|
"target": _normalized_path(target),
|
|
"staging": _normalized_path(interrupted_staging),
|
|
"backup": _normalized_path(interrupted_backup),
|
|
"phase": "target_parked",
|
|
"target_existed": True,
|
|
"target_had_real_data": True,
|
|
"target_was_empty": False,
|
|
"identities": {
|
|
"source": migration_module._path_identity_payload(source),
|
|
"original_target": migration_module._path_identity_payload(
|
|
interrupted_backup
|
|
),
|
|
"staging": migration_module._path_identity_payload(
|
|
interrupted_staging
|
|
),
|
|
"backup": migration_module._path_identity_payload(
|
|
interrupted_backup
|
|
),
|
|
"candidate": None,
|
|
},
|
|
}
|
|
),
|
|
encoding="utf-8",
|
|
)
|
|
|
|
report = _run(
|
|
source,
|
|
target,
|
|
apply=True,
|
|
overwrite=True,
|
|
confirm_replace_target=target,
|
|
)
|
|
|
|
assert not _errors(report)
|
|
assert any(item["kind"] == "recovery" for item in report["items"])
|
|
assert not journal.exists()
|
|
assert not interrupted_staging.exists()
|
|
assert not interrupted_backup.exists()
|
|
backups = list(tmp_path.glob("target-home.backup.*"))
|
|
assert len(backups) == 1
|
|
assert (backups[0] / "workspace" / "original.txt").read_text(
|
|
encoding="utf-8"
|
|
) == "original"
|
|
assert (target / "workspace" / "MEMORY.md").is_file()
|
|
|
|
|
|
def test_explicit_retry_never_reuses_old_receipt_and_imports_new_source_state(
|
|
tmp_path: Path,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
fake_home = tmp_path / "userhome"
|
|
fake_home.mkdir()
|
|
source = _build_source_home(fake_home)
|
|
source.rename(fake_home / ".opensquilla")
|
|
source = fake_home / ".opensquilla"
|
|
target = tmp_path / "target-home"
|
|
monkeypatch.setattr(Path, "home", classmethod(lambda cls: fake_home))
|
|
legacy_marker = source / IMPORT_MARKER_FILENAME
|
|
legacy_marker.write_text(
|
|
json.dumps({"target": str(tmp_path / "stale-target"), "transaction_id": "stale"}),
|
|
encoding="utf-8",
|
|
)
|
|
first = _run(source, target, apply=True)
|
|
assert not _errors(first)
|
|
first_transaction_id = Path(first["output_dir"]).name
|
|
|
|
assert detect_legacy_cli_home(target) == source
|
|
(source / "workspace" / "MEMORY.md").write_text(
|
|
"# Memory index\n\n- updated after first import\n",
|
|
encoding="utf-8",
|
|
)
|
|
connection = sqlite3.connect(source / "state" / "sessions.db")
|
|
try:
|
|
connection.execute(
|
|
"INSERT INTO synthetic_sessions VALUES (?, ?)",
|
|
("new-session", "new synthetic conversation"),
|
|
)
|
|
connection.commit()
|
|
finally:
|
|
connection.close()
|
|
source_before_retry = _file_bytes(source)
|
|
|
|
refused = _run(source, target, apply=True)
|
|
|
|
assert any(item["kind"] == "preflight/target" for item in _errors(refused))
|
|
assert Path(first["output_dir"]).is_dir()
|
|
|
|
retried = _run(
|
|
source,
|
|
target,
|
|
apply=True,
|
|
replace_target=True,
|
|
confirm_replace_target=target,
|
|
)
|
|
|
|
assert not _errors(retried)
|
|
assert Path(retried["output_dir"]).name != first_transaction_id
|
|
assert (target / "workspace" / "MEMORY.md").read_text(encoding="utf-8") == (
|
|
"# Memory index\n\n- updated after first import\n"
|
|
)
|
|
target_connection = sqlite3.connect(target / "state" / "sessions.db")
|
|
try:
|
|
imported_ids = {
|
|
row[0]
|
|
for row in target_connection.execute(
|
|
"SELECT session_id FROM synthetic_sessions"
|
|
)
|
|
}
|
|
finally:
|
|
target_connection.close()
|
|
assert imported_ids == {"synthetic-session", "new-session"}
|
|
assert not (tmp_path / ".target-home.profile-replace.json").exists()
|
|
assert _file_bytes(source) == source_before_retry
|
|
assert detect_legacy_cli_home(target) == source
|
|
|
|
|
|
@pytest.mark.parametrize("tamper", ["schema-forward", "extra", "wrong-operation"])
|
|
def test_cli_retry_never_mutates_an_inexact_replacement_journal(
|
|
tmp_path: Path,
|
|
tamper: str,
|
|
) -> None:
|
|
source = _build_source_home(tmp_path)
|
|
target = tmp_path / "target-home"
|
|
transaction_id = str(uuid.uuid4())
|
|
backup = tmp_path / f"target-home.backup.{transaction_id}"
|
|
(backup / "workspace").mkdir(parents=True)
|
|
(backup / "workspace" / "original.txt").write_text("original\n", encoding="utf-8")
|
|
staging = tmp_path / f".target-home.profile-staging.{transaction_id}"
|
|
staging.mkdir()
|
|
(staging / "candidate.txt").write_text("candidate\n", encoding="utf-8")
|
|
payload: dict[str, Any] = {
|
|
"schema_version": 1,
|
|
"operation": "profile-import",
|
|
"source_kind": "cli-home",
|
|
"transaction_id": transaction_id,
|
|
"source": _normalized_path(source),
|
|
"target": _normalized_path(target),
|
|
"staging": _normalized_path(staging),
|
|
"backup": _normalized_path(backup),
|
|
"phase": "target_parked",
|
|
"target_existed": True,
|
|
"target_had_real_data": True,
|
|
"target_was_empty": False,
|
|
"identities": {
|
|
"source": migration_module._path_identity_payload(source),
|
|
"original_target": migration_module._path_identity_payload(backup),
|
|
"staging": migration_module._path_identity_payload(staging),
|
|
"backup": migration_module._path_identity_payload(backup),
|
|
"candidate": None,
|
|
},
|
|
}
|
|
if tamper == "schema-forward":
|
|
payload["schema_version"] = 2
|
|
elif tamper == "extra":
|
|
payload["future_field"] = True
|
|
else:
|
|
payload["operation"] = "restore-profile"
|
|
journal = tmp_path / ".target-home.profile-replace.json"
|
|
journal.write_text(json.dumps(payload) + "\n", encoding="utf-8")
|
|
journal_before = journal.read_bytes()
|
|
source_before = _file_bytes(source)
|
|
backup_before = _file_bytes(backup)
|
|
staging_before = _file_bytes(staging)
|
|
|
|
report = _run(
|
|
source,
|
|
target,
|
|
apply=True,
|
|
replace_target=True,
|
|
confirm_replace_target=target,
|
|
)
|
|
|
|
assert any(item["kind"] == "preflight/recovery" for item in _errors(report))
|
|
assert journal.read_bytes() == journal_before
|
|
assert _file_bytes(source) == source_before
|
|
assert _file_bytes(backup) == backup_before
|
|
assert _file_bytes(staging) == staging_before
|
|
assert not target.exists()
|
|
|
|
|
|
def test_layout_receipt_is_only_completion_authority(tmp_path: Path) -> None:
|
|
source = _build_source_home(tmp_path)
|
|
target = tmp_path / "target-home"
|
|
|
|
first = _run(source, target, apply=True)
|
|
assert not _errors(first)
|
|
output_dir = Path(first["output_dir"])
|
|
layout_receipt = output_dir / "layout-receipt.json"
|
|
report_path = output_dir / "report.json"
|
|
|
|
# Arbitrary report content cannot revoke or forge the committed import.
|
|
report_path.write_text(
|
|
json.dumps(
|
|
{
|
|
"paused_jobs": [
|
|
{"id": "private-id", "name": "private name", "cron_expr": "* * * * *"}
|
|
]
|
|
}
|
|
),
|
|
encoding="utf-8",
|
|
)
|
|
assert migration_module._matching_import_receipt(source, target) is not None
|
|
retried = _run(source, target, apply=True)
|
|
assert any(item["kind"] == "preflight/target" for item in _errors(retried))
|
|
assert Path(first["output_dir"]) == output_dir
|
|
|
|
# The authority schema is exact: detailed scheduler rows are not accepted
|
|
# even if injected into the otherwise-valid layout receipt.
|
|
receipt = json.loads(layout_receipt.read_text(encoding="utf-8"))
|
|
receipt["paused_jobs"] = [
|
|
{"id": "private-id", "name": "private name", "cron_expr": "* * * * *"}
|
|
]
|
|
layout_receipt.write_text(json.dumps(receipt), encoding="utf-8")
|
|
assert migration_module._matching_import_receipt(source, target) is None
|
|
|
|
receipt.pop("paused_jobs")
|
|
receipt["source_identity"]["private_scheduler_name"] = "must not persist"
|
|
layout_receipt.write_text(json.dumps(receipt), encoding="utf-8")
|
|
assert migration_module._matching_import_receipt(source, target) is None
|
|
|
|
# Conversely, a detailed report cannot replace a missing layout receipt.
|
|
layout_receipt.unlink()
|
|
assert migration_module._matching_import_receipt(source, target) is None
|
|
|
|
|
|
def test_committed_import_verifier_returns_only_locked_protocol_metadata(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
source = _build_source_home(tmp_path)
|
|
target = tmp_path / "target-home"
|
|
report = _run(source, target, apply=True)
|
|
transaction_id = Path(report["output_dir"]).name
|
|
|
|
verified = migration_module.verify_committed_profile_import(
|
|
source,
|
|
target,
|
|
source_kind="cli-home",
|
|
transaction_id=transaction_id,
|
|
)
|
|
|
|
assert set(verified) == {
|
|
"schema_version",
|
|
"outcome",
|
|
"stable_code",
|
|
"source",
|
|
"source_kind",
|
|
"target",
|
|
"transaction_id",
|
|
"matching_transaction_ids",
|
|
"provider_connection",
|
|
"report",
|
|
}
|
|
assert verified["outcome"] == "verified"
|
|
assert verified["transaction_id"] == transaction_id
|
|
assert verified["matching_transaction_ids"] == [transaction_id]
|
|
assert verified["provider_connection"] == {
|
|
"provider": "openrouter",
|
|
"model": "deepseek/deepseek-v4-flash",
|
|
"base_url": "https://openrouter.ai/api/v1",
|
|
"api_key_env": "OPENROUTER_API_KEY",
|
|
}
|
|
assert _normalized_path(Path(verified["report"]["output_dir"])) == _normalized_path(
|
|
Path(report["output_dir"])
|
|
)
|
|
serialized = json.dumps(verified, ensure_ascii=False)
|
|
assert DUMMY_INLINE_KEY not in serialized
|
|
assert "dummy session content" not in serialized
|
|
|
|
excluded = migration_module.verify_committed_profile_import(
|
|
source,
|
|
target,
|
|
source_kind="cli-home",
|
|
excluded_transaction_ids=(transaction_id,),
|
|
)
|
|
assert excluded["outcome"] == "not_found"
|
|
assert excluded["matching_transaction_ids"] == []
|
|
assert excluded["report"] is None
|
|
|
|
|
|
def test_committed_import_verifier_never_emits_private_provider_url(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
source = _build_source_home(tmp_path)
|
|
target = tmp_path / "target-home"
|
|
report = _run(source, target, apply=True)
|
|
config_path = target / "config.toml"
|
|
config = config_path.read_text(encoding="utf-8")
|
|
config_path.write_text(
|
|
config.replace(
|
|
"https://openrouter.ai/api/v1",
|
|
"https://user:secret@private.invalid/v1?token=hidden",
|
|
),
|
|
encoding="utf-8",
|
|
)
|
|
|
|
verified = migration_module.verify_committed_profile_import(
|
|
source,
|
|
target,
|
|
source_kind="cli-home",
|
|
transaction_id=Path(report["output_dir"]).name,
|
|
)
|
|
|
|
serialized = json.dumps(verified, ensure_ascii=False)
|
|
assert verified["outcome"] == "unsafe"
|
|
assert verified["stable_code"] == "profile_import_provider_connection_unsafe"
|
|
assert "secret" not in serialized
|
|
assert "hidden" not in serialized
|
|
|
|
|
|
def test_committed_import_verifier_rejects_non_string_or_invalid_provider_fields(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
source = _build_source_home(tmp_path)
|
|
target = tmp_path / "target-home"
|
|
report = _run(source, target, apply=True)
|
|
transaction_id = Path(report["output_dir"]).name
|
|
config_path = target / "config.toml"
|
|
original = tomllib.loads(config_path.read_text(encoding="utf-8"))
|
|
invalid_values: tuple[tuple[str, object], ...] = (
|
|
("provider", {"secret": "TOPSECRET"}),
|
|
("model", ["TOPSECRET"]),
|
|
("base_url", {"secret": "TOPSECRET"}),
|
|
("api_key_env", ["TOPSECRET"]),
|
|
("api_key_env", "INVALID-ENV-NAME"),
|
|
("api_key_env", "PATH"),
|
|
("api_key_env", "PYTHONPATH"),
|
|
("api_key_env", "NODE_OPTIONS"),
|
|
("api_key_env", "LD_PRELOAD"),
|
|
)
|
|
|
|
for field, value in invalid_values:
|
|
candidate = json.loads(json.dumps(original))
|
|
candidate["llm"][field] = value
|
|
config_path.write_text(tomli_w.dumps(candidate), encoding="utf-8")
|
|
|
|
verified = migration_module.verify_committed_profile_import(
|
|
source,
|
|
target,
|
|
source_kind="cli-home",
|
|
transaction_id=transaction_id,
|
|
)
|
|
|
|
serialized = json.dumps(verified, ensure_ascii=False)
|
|
assert verified["outcome"] == "unsafe"
|
|
assert verified["stable_code"] == "profile_import_provider_connection_unsafe"
|
|
assert "TOPSECRET" not in serialized
|
|
assert "INVALID-ENV-NAME" not in serialized
|
|
|
|
for accepted_env in ("CUSTOM_LLM_KEY", "HF_TOKEN"):
|
|
candidate = json.loads(json.dumps(original))
|
|
candidate["llm"]["api_key_env"] = accepted_env
|
|
config_path.write_text(tomli_w.dumps(candidate), encoding="utf-8")
|
|
verified = migration_module.verify_committed_profile_import(
|
|
source,
|
|
target,
|
|
source_kind="cli-home",
|
|
transaction_id=transaction_id,
|
|
)
|
|
assert verified["outcome"] == "verified"
|
|
assert verified["provider_connection"]["api_key_env"] == accepted_env
|
|
|
|
|
|
def test_persisted_migration_reports_do_not_store_scheduler_rows(tmp_path: Path) -> None:
|
|
source = _build_source_home(tmp_path)
|
|
target = tmp_path / "target-home"
|
|
|
|
report = _run(source, target, apply=True)
|
|
|
|
assert not _errors(report)
|
|
# The stdout report remains detailed for the active user interaction.
|
|
assert report["paused_jobs"] == [
|
|
{"id": "job-1", "name": "dummy daily job", "cron_expr": "0 9 * * *"}
|
|
]
|
|
output_dir = Path(report["output_dir"])
|
|
persisted_report = json.loads((output_dir / "report.json").read_text(encoding="utf-8"))
|
|
persisted_bytes = json.dumps(persisted_report, ensure_ascii=False)
|
|
summary = (output_dir / "summary.md").read_text(encoding="utf-8")
|
|
|
|
assert persisted_report["paused_job_count"] == 1
|
|
assert "paused_jobs" not in persisted_report
|
|
for private_value in ("job-1", "dummy daily job", "0 9 * * *"):
|
|
assert private_value not in persisted_bytes
|
|
assert private_value not in summary
|
|
|
|
|
|
def test_dry_run_reports_interrupted_commit_without_mutating_it(tmp_path: Path) -> None:
|
|
source = _build_source_home(tmp_path)
|
|
target = tmp_path / "target-home"
|
|
transaction_id = "123e4567-e89b-42d3-a456-426614174000"
|
|
backup = tmp_path / f"target-home.backup.{transaction_id}"
|
|
backup.mkdir()
|
|
staging = tmp_path / f".target-home.profile-staging.{transaction_id}"
|
|
staging.mkdir()
|
|
journal = tmp_path / ".target-home.profile-replace.json"
|
|
payload = {
|
|
"schema_version": 1,
|
|
"transaction_id": transaction_id,
|
|
"source": _normalized_path(source),
|
|
"target": _normalized_path(target),
|
|
"staging": _normalized_path(staging),
|
|
"backup": _normalized_path(backup),
|
|
"phase": "target_parked",
|
|
"target_existed": True,
|
|
"target_was_empty": False,
|
|
"identities": {
|
|
"source": migration_module._path_identity_payload(source),
|
|
"original_target": migration_module._path_identity_payload(backup),
|
|
"staging": migration_module._path_identity_payload(staging),
|
|
"backup": migration_module._path_identity_payload(backup),
|
|
"candidate": None,
|
|
},
|
|
}
|
|
journal.write_text(json.dumps(payload), encoding="utf-8")
|
|
|
|
report = _run(source, target, apply=False)
|
|
|
|
assert any(item["kind"] == "preflight/recovery" for item in _errors(report))
|
|
assert json.loads(journal.read_text(encoding="utf-8")) == payload
|
|
assert backup.is_dir()
|
|
assert staging.is_dir()
|
|
assert not target.exists()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_real_session_transcript_workspace_and_media_survive_import(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
source = tmp_path / "legacy-home"
|
|
source.mkdir()
|
|
_write_config(source)
|
|
source_db = source / "state" / "sessions.db"
|
|
source_db.parent.mkdir(parents=True)
|
|
apply_pending(f"sqlite:///{source_db}", MIGRATIONS_DIR)
|
|
storage = SessionStorage(str(source_db))
|
|
await storage.connect()
|
|
try:
|
|
manager = SessionManager(storage, inject_time_prefix=False, media_root=source / "media")
|
|
session = await manager.create("agent:main:direct:migration-e2e")
|
|
for role, content in (("user", "synthetic user prompt"), ("assistant", "synthetic reply")):
|
|
await storage.append_transcript_entry(
|
|
TranscriptEntry(
|
|
session_id=session.session_id,
|
|
session_key=session.session_key,
|
|
role=role,
|
|
content=content,
|
|
token_count=3,
|
|
)
|
|
)
|
|
finally:
|
|
await storage.close()
|
|
|
|
(source / "workspace").mkdir()
|
|
(source / "workspace" / "important.txt").write_text(
|
|
"workspace survives", encoding="utf-8"
|
|
)
|
|
attachment_sha, _path, _wrote = write_transcript_material(
|
|
media_root=source / "media",
|
|
session_id=session.session_id,
|
|
payload=b"attachment survives",
|
|
)
|
|
artifact = ArtifactStore(source / "media").publish_bytes(
|
|
b"artifact survives",
|
|
session_id=session.session_id,
|
|
session_key=session.session_key,
|
|
name="result.bin",
|
|
mime="application/octet-stream",
|
|
source="migration-test",
|
|
)
|
|
_write_scheduler_db(source, with_enabled_column=True)
|
|
target = tmp_path / "target-home"
|
|
|
|
report = _run(source, target, apply=True)
|
|
|
|
assert not _errors(report)
|
|
apply_pending(f"sqlite:///{target / 'state' / 'sessions.db'}", MIGRATIONS_DIR)
|
|
reopened = SessionStorage(str(target / "state" / "sessions.db"))
|
|
await reopened.connect()
|
|
try:
|
|
restored = await reopened.get_session(session.session_key)
|
|
assert restored is not None
|
|
transcript = await reopened.get_transcript(session.session_id)
|
|
assert [(entry.role, entry.content) for entry in transcript] == [
|
|
("user", "synthetic user prompt"),
|
|
("assistant", "synthetic reply"),
|
|
]
|
|
finally:
|
|
await reopened.close()
|
|
|
|
assert (target / "workspace" / "important.txt").read_text(
|
|
encoding="utf-8"
|
|
) == "workspace survives"
|
|
attachment_ref = make_attachment_ref(
|
|
sha256=attachment_sha,
|
|
name="upload.bin",
|
|
mime="application/octet-stream",
|
|
size=len(b"attachment survives"),
|
|
session_id=session.session_id,
|
|
source="transcript",
|
|
)
|
|
assert read_attachment_ref_bytes(attachment_ref, media_root=target / "media") == (
|
|
b"attachment survives"
|
|
)
|
|
_ref, artifact_path = ArtifactStore(target / "media").resolve_for_download(
|
|
artifact.id, session_id=session.session_id
|
|
)
|
|
assert artifact_path.read_bytes() == b"artifact survives"
|
|
assert _scheduler_enabled_values(target / "state" / "scheduler.db") == [0]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Orchestrator wiring
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_orchestrator_rejects_non_default_item_options_for_opensquilla() -> None:
|
|
options = orchestrator.MigrationBatchOptions(preset="user-data")
|
|
try:
|
|
orchestrator.validate_batch_options(("opensquilla",), options)
|
|
except orchestrator.MigrationOptionError as exc:
|
|
assert "does not take preset/include/exclude" in str(exc)
|
|
else:
|
|
raise AssertionError("non-default preset must be rejected for opensquilla")
|
|
|
|
|
|
def test_orchestrator_accepts_wizard_defaults_for_opensquilla() -> None:
|
|
options = orchestrator.MigrationBatchOptions(
|
|
preset="full", skill_conflict="skip", persona_conflict="use-opensquilla"
|
|
)
|
|
orchestrator.validate_batch_options(("opensquilla",), options)
|
|
|
|
|
|
def test_orchestrator_runs_opensquilla_source(tmp_path: Path, monkeypatch) -> None:
|
|
source = _build_source_home(tmp_path)
|
|
target = tmp_path / "target-home"
|
|
monkeypatch.setenv("OPENSQUILLA_STATE_DIR", str(target))
|
|
|
|
report = orchestrator.run_one_migration(
|
|
"opensquilla", source, orchestrator.MigrationBatchOptions(apply=False)
|
|
)
|
|
|
|
assert report["source_kind"] == "cli-home"
|
|
assert _normalized_path(Path(report["target"])) == _normalized_path(target)
|
|
assert not any(item["status"] == "error" for item in report["items"])
|