diff --git a/api/models.py b/api/models.py index eca27ab5..8f4dc0da 100644 --- a/api/models.py +++ b/api/models.py @@ -6712,7 +6712,7 @@ CRON_PROJECT_NAME = 'Cron Jobs' _CRON_PROJECT_LOCK = threading.Lock() -def ensure_cron_project(create: bool = True) -> str | None: +def ensure_cron_project(create: bool = True, profile: str | None = None) -> str | None: """Return the project_id of the system "Cron Jobs" project for the active profile. Each profile gets its own "Cron Jobs" project so cron-spawned sessions in @@ -6734,7 +6734,10 @@ def ensure_cron_project(create: bool = True) -> str | None: """ from api.profiles import get_active_profile_name, _is_root_profile - active = get_active_profile_name() or 'default' + # `profile` is the owner of the state.db being projected; the active + # profile is only a fallback so a cross-profile scan never tags another + # profile's system project onto the one currently selected. + active = profile or get_active_profile_name() or 'default' with _CRON_PROJECT_LOCK: projects = load_projects() # Look for an existing per-profile cron project. Match either an exact @@ -6774,11 +6777,12 @@ WEBHOOK_PROJECT_NAME = 'Webhooks' _WEBHOOK_PROJECT_LOCK = threading.Lock() -def ensure_webhook_project() -> str: - """Return the project_id of the system "Webhooks" project for the active profile.""" +def ensure_webhook_project(profile: str | None = None) -> str: + """Return the project_id of the system "Webhooks" project for `profile` (default: active).""" from api.profiles import get_active_profile_name, _is_root_profile - active = get_active_profile_name() or 'default' + # Owner of the scanned state.db wins over the selected profile (see ensure_cron_project). + active = profile or get_active_profile_name() or 'default' with _WEBHOOK_PROJECT_LOCK: projects = load_projects() for p in projects: @@ -6806,7 +6810,7 @@ def ensure_webhook_project() -> str: return project_id -def _profile_has_user_projects() -> bool: +def _profile_has_user_projects(profile: str | None = None) -> bool: """True if the active profile already has at least one real (non-system) project. "Opted into project organization" means `load_projects()` contains a @@ -6819,7 +6823,7 @@ def _profile_has_user_projects() -> bool: """ from api.profiles import get_active_profile_name, _is_root_profile - active = get_active_profile_name() or 'default' + active = profile or get_active_profile_name() or 'default' reserved = {CRON_PROJECT_NAME, WEBHOOK_PROJECT_NAME} for p in load_projects(): if p.get('name') in reserved: @@ -7746,7 +7750,9 @@ def _load_cli_sessions_uncached( def _cron_pid(): if not _cron_pid_cache[0]: _cron_pid_cache[0] = True - _cron_pid_cache[1] = ensure_cron_project(create=_profile_has_user_projects()) + _cron_pid_cache[1] = ensure_cron_project( + create=_profile_has_user_projects(_cli_profile), profile=_cli_profile, + ) return _cron_pid_cache[1] # Memoize the cron jobs.json job_id -> name map for this scan. The two row @@ -7797,7 +7803,7 @@ def _load_cli_sessions_uncached( _webhook_pid_cache: list[str | None] = [None] def _webhook_pid(): if _webhook_pid_cache[0] is None: - _webhook_pid_cache[0] = ensure_webhook_project() + _webhook_pid_cache[0] = ensure_webhook_project(profile=_cli_profile) return _webhook_pid_cache[0] def _state_row_project_id(sid: str, source: str | None) -> str | None: diff --git a/tests/test_cross_profile_system_project_tagging.py b/tests/test_cross_profile_system_project_tagging.py new file mode 100644 index 00000000..8f1bdd9e --- /dev/null +++ b/tests/test_cross_profile_system_project_tagging.py @@ -0,0 +1,178 @@ +"""System projects minted by the all-profiles sidebar scan must belong to the +profile whose state.db produced the rows, not the profile currently selected. + +`get_cli_sessions(all_profiles=True)` walks every profile's state.db in one +request. When it reached a profile with webhook (or cron) sessions, the lazily +created "Webhooks" / "Cron Jobs" system project used to be tagged with +`get_active_profile_name()` — the UI-selected profile — so a webhook-only +profile leaked a "Webhooks" project onto whichever profile the user happened +to be viewing. Deleting the stray entry did not help: the next sidebar poll +re-minted it, and switching profiles minted another one per profile. +""" + +import json +import sqlite3 +import threading +import time + +import pytest + + +def _make_state_db(path, *, source, count=1): + conn = sqlite3.connect(str(path)) + conn.executescript( + """ + CREATE TABLE sessions ( + id TEXT PRIMARY KEY, + source TEXT, + session_source TEXT, + title TEXT, + model TEXT, + started_at REAL NOT NULL, + message_count INTEGER DEFAULT 0, + parent_session_id TEXT, + ended_at REAL, + end_reason TEXT + ); + CREATE TABLE messages ( + id TEXT PRIMARY KEY, + session_id TEXT, + role TEXT, + content TEXT, + timestamp REAL + ); + """ + ) + now = time.time() + for i in range(count): + sid = f"{source}_{i:04d}_{int(now) + i}" + conn.execute( + "INSERT INTO sessions (id, source, session_source, title, model," + " started_at, message_count) VALUES (?, ?, ?, ?, 'test-model', ?, 1)", + (sid, source, source, f"{source} run", now + i), + ) + conn.execute( + "INSERT INTO messages (id, session_id, role, content, timestamp)" + " VALUES (?, ?, 'user', 'payload', ?)", + (f"{source}_msg_{i:04d}", sid, now + i), + ) + conn.commit() + conn.close() + + +@pytest.fixture(autouse=True) +def _isolate_projects(tmp_path, monkeypatch): + import api.config as cfg + import api.models as models + import api.profiles as profiles + + projects_file = tmp_path / "projects.json" + monkeypatch.setattr(cfg, "PROJECTS_FILE", projects_file) + monkeypatch.setattr(models, "PROJECTS_FILE", projects_file) + monkeypatch.setattr(models, "_projects_migrated", True) + monkeypatch.setattr(models, "_CRON_PROJECT_LOCK", threading.Lock()) + monkeypatch.setattr(models, "_WEBHOOK_PROJECT_LOCK", threading.Lock()) + monkeypatch.setattr(models, "get_last_workspace", lambda *_a, **_kw: tmp_path) + monkeypatch.setattr(profiles, "list_profiles_api", lambda: []) + monkeypatch.setattr(profiles, "_active_profile", "default") + profiles._invalidate_root_profile_cache() + yield projects_file + profiles._invalidate_root_profile_cache() + + +def _projects(path): + return json.loads(path.read_text(encoding="utf-8")) if path.exists() else [] + + +def _scan(models, home, db, profile): + return models._load_cli_sessions_uncached( + home, db, profile, + source_filter=None, + visible_session_limit=None, + cron_project_limit=None, + webhook_project_limit=None, + kanban_project_limit=None, + include_claude_code=False, + ) + + +def test_webhook_project_is_tagged_to_scanned_profile_not_selected_one(tmp_path, monkeypatch): + import api.models as models + import api.profiles as profiles + + projects_file = tmp_path / "projects.json" + hook_home = tmp_path / "profiles" / "hooks" + hook_home.mkdir(parents=True) + hook_db = hook_home / "state.db" + _make_state_db(hook_db, source="webhook", count=3) + + # UI has `default` selected; the all-profiles walk reaches the hooks profile. + monkeypatch.setattr(profiles, "_active_profile", "default") + rows = _scan(models, hook_home, hook_db, "hooks") + assert sum(1 for r in rows if r["source_tag"] == "webhook") == 3 + + webhook_projects = [p for p in _projects(projects_file) if p["name"] == models.WEBHOOK_PROJECT_NAME] + assert [p["profile"] for p in webhook_projects] == ["hooks"], ( + "Webhooks project must be tagged to the profile that owns the sessions" + ) + assert all(r["project_id"] == webhook_projects[0]["project_id"] + for r in rows if r["source_tag"] == "webhook") + + # Switching the selected profile and rescanning must reuse the same project + # rather than minting one per selected profile. + monkeypatch.setattr(profiles, "_active_profile", "deepseek") + _scan(models, hook_home, hook_db, "hooks") + webhook_projects = [p for p in _projects(projects_file) if p["name"] == models.WEBHOOK_PROJECT_NAME] + assert [p["profile"] for p in webhook_projects] == ["hooks"] + + +def test_scanning_a_profile_without_webhook_rows_mints_nothing(tmp_path, monkeypatch): + import api.models as models + import api.profiles as profiles + + projects_file = tmp_path / "projects.json" + home = tmp_path / "profiles" / "quiet" + home.mkdir(parents=True) + db = home / "state.db" + _make_state_db(db, source="cli", count=2) + + monkeypatch.setattr(profiles, "_active_profile", "default") + _scan(models, home, db, "quiet") + _scan(models, home, db, "default") + + assert not [p for p in _projects(projects_file) if p["name"] == models.WEBHOOK_PROJECT_NAME] + + +def test_cron_project_gate_and_tag_follow_scanned_profile(tmp_path, monkeypatch): + """The opt-in gate and the tag for Cron Jobs must both key on the scanned + profile: a user project on the *selected* profile must not unlock cron + project creation for a different scanned profile.""" + import api.models as models + import api.profiles as profiles + + projects_file = tmp_path / "projects.json" + projects_file.write_text(json.dumps([ + {"project_id": "u1", "name": "Mine", "profile": "default", "created_at": 1.0}, + ]), encoding="utf-8") + + cron_home = tmp_path / "profiles" / "jobs" + cron_home.mkdir(parents=True) + cron_db = cron_home / "state.db" + _make_state_db(cron_db, source="cron", count=1) + + monkeypatch.setattr(profiles, "_active_profile", "default") + rows = _scan(models, cron_home, cron_db, "jobs") + cron_rows = [r for r in rows if r["source_tag"] == "cron"] + assert len(cron_rows) == 1 + assert cron_rows[0]["project_id"] is None + assert not [p for p in _projects(projects_file) if p["name"] == models.CRON_PROJECT_NAME] + + # Once the scanned profile itself opts in, its cron project is tagged to it. + projects_file.write_text(json.dumps([ + {"project_id": "u1", "name": "Mine", "profile": "default", "created_at": 1.0}, + {"project_id": "u2", "name": "Theirs", "profile": "jobs", "created_at": 2.0}, + ]), encoding="utf-8") + rows = _scan(models, cron_home, cron_db, "jobs") + cron_projects = [p for p in _projects(projects_file) if p["name"] == models.CRON_PROJECT_NAME] + assert [p["profile"] for p in cron_projects] == ["jobs"] + assert rows[0]["project_id"] == cron_projects[0]["project_id"] diff --git a/tests/test_issue3930_source_filter_pushdown.py b/tests/test_issue3930_source_filter_pushdown.py index ade4885d..61c39f23 100644 --- a/tests/test_issue3930_source_filter_pushdown.py +++ b/tests/test_issue3930_source_filter_pushdown.py @@ -242,7 +242,7 @@ def test_load_cli_sessions_uncached_pushes_specific_source_into_state_db_scan(mo monkeypatch.setattr(models, "get_claude_code_sessions", lambda: claude_calls.append(True) or []) monkeypatch.setattr(models, "read_importable_agent_session_rows", fake_read_rows) monkeypatch.setattr(models, "get_last_workspace", lambda profile=None: tmp_path) - monkeypatch.setattr(models, "_profile_has_user_projects", lambda: False) + monkeypatch.setattr(models, "_profile_has_user_projects", lambda *_a, **_kw: False) monkeypatch.setattr(models, "ensure_cron_project", lambda **_: "cron-project-id") monkeypatch.setattr(models.Session, "load_metadata_only", lambda _sid: None) @@ -284,7 +284,7 @@ def test_cron_source_filter_uses_cron_rescue_limit(monkeypatch, tmp_path): monkeypatch.setattr(models, "read_importable_agent_session_rows", fake_read_rows) monkeypatch.setattr(models, "get_last_workspace", lambda profile=None: tmp_path) - monkeypatch.setattr(models, "_profile_has_user_projects", lambda: False) + monkeypatch.setattr(models, "_profile_has_user_projects", lambda *_a, **_kw: False) monkeypatch.setattr(models, "ensure_cron_project", lambda **_: "cron-project-id") monkeypatch.setattr(models.Session, "load_metadata_only", lambda _sid: None) diff --git a/tests/test_issue4385_cron_archive_reappears.py b/tests/test_issue4385_cron_archive_reappears.py index f2fe6052..c1ea8209 100644 --- a/tests/test_issue4385_cron_archive_reappears.py +++ b/tests/test_issue4385_cron_archive_reappears.py @@ -123,7 +123,7 @@ def test_cron_state_projection_preserves_archived_sidecar(monkeypatch, tmp_path) encoding="utf-8", ) monkeypatch.setattr(models, "SESSION_DIR", session_dir) - monkeypatch.setattr(models, "_profile_has_user_projects", lambda: False) + monkeypatch.setattr(models, "_profile_has_user_projects", lambda *_a, **_kw: False) monkeypatch.setattr(models, "ensure_cron_project", lambda **_: "cron-project") models.clear_sidecar_metadata_cache() diff --git a/tests/test_webhook_project_sessions.py b/tests/test_webhook_project_sessions.py index 0370e059..0afdd5ff 100644 --- a/tests/test_webhook_project_sessions.py +++ b/tests/test_webhook_project_sessions.py @@ -109,8 +109,8 @@ def test_webhook_rows_get_webhook_project_id(monkeypatch, tmp_path): monkeypatch.setattr(models, "read_importable_agent_session_rows", lambda *_a, **_kw: [_agent_row()]) monkeypatch.setattr(models, "get_last_workspace", lambda profile=None: tmp_path) - monkeypatch.setattr(models, "ensure_cron_project", lambda: "cron-project-id") - monkeypatch.setattr(models, "ensure_webhook_project", lambda: "webhook-project-id", raising=False) + monkeypatch.setattr(models, "ensure_cron_project", lambda **_kw: "cron-project-id") + monkeypatch.setattr(models, "ensure_webhook_project", lambda **_kw: "webhook-project-id", raising=False) monkeypatch.setattr(models.Session, "load_metadata_only", lambda _sid: None) rows = models._load_cli_sessions_uncached(tmp_path, db, _cli_profile=None, cron_project_limit=False) @@ -138,8 +138,8 @@ def test_webhook_second_pass_keeps_older_project_rows_available(monkeypatch, tmp monkeypatch.setattr(models, "read_importable_agent_session_rows", fake_read_rows) monkeypatch.setattr(models, "get_last_workspace", lambda profile=None: tmp_path) - monkeypatch.setattr(models, "ensure_cron_project", lambda: "cron-project-id") - monkeypatch.setattr(models, "ensure_webhook_project", lambda: "webhook-project-id", raising=False) + monkeypatch.setattr(models, "ensure_cron_project", lambda **_kw: "cron-project-id") + monkeypatch.setattr(models, "ensure_webhook_project", lambda **_kw: "webhook-project-id", raising=False) monkeypatch.setattr(models.Session, "load_metadata_only", lambda _sid: None) rows = models._load_cli_sessions_uncached(tmp_path, db, _cli_profile=None, cron_project_limit=False)