From a6b4ccca3c2505afca9a1bd751eafc3539d2f3d8 Mon Sep 17 00:00:00 2001 From: Ubuntu Date: Sun, 3 May 2026 21:48:57 +0000 Subject: [PATCH] feat(simulation): add POST //clone endpoint (Task 6) Implements SimulationManager.clone_simulation() and the corresponding Flask route. The clone copies reddit_profiles.json, twitter_profiles.csv and agent_profiles.json from the source simulation; does not copy simulation_config.json; sets status=PROFILES_READY and records parent_simulation_id. All 3 tests in test_simulation_clone.py pass. Co-Authored-By: Claude Sonnet 4.6 --- backend/app/api/simulation.py | 28 +++++++ backend/app/services/simulation_manager.py | 53 +++++++++++++ backend/tests/test_simulation_clone.py | 90 ++++++++++++++++++++++ 3 files changed, 171 insertions(+) create mode 100644 backend/tests/test_simulation_clone.py diff --git a/backend/app/api/simulation.py b/backend/app/api/simulation.py index 64731222..ea546775 100644 --- a/backend/app/api/simulation.py +++ b/backend/app/api/simulation.py @@ -2875,3 +2875,31 @@ def patch_simulation_config_endpoint(simulation_id: str): except Exception as e: logger.error(f"patch_simulation_config failed: {e}") return jsonify({"success": False, "error": str(e), "traceback": traceback.format_exc()}), 500 + + +@simulation_bp.route('//clone', methods=['POST']) +def clone_simulation(simulation_id: str): + """Clone a simulation: copy agent profiles, set status=profiles_ready.""" + try: + data = request.get_json() or {} + project_id = data.get('project_id') + if not project_id: + return jsonify({"success": False, "error": "project_id is required"}), 400 + + manager = SimulationManager() + try: + new_state = manager.clone_simulation(simulation_id, project_id) + except ValueError as e: + return jsonify({"success": False, "error": str(e)}), 400 + + return jsonify({ + "success": True, + "data": { + "new_simulation_id": new_state.simulation_id, + "parent_simulation_id": simulation_id, + "status": new_state.status.value, + } + }) + except Exception as e: + logger.error(f"clone_simulation failed: {e}") + return jsonify({"success": False, "error": str(e)}), 500 diff --git a/backend/app/services/simulation_manager.py b/backend/app/services/simulation_manager.py index 8fee0992..95ceb43e 100644 --- a/backend/app/services/simulation_manager.py +++ b/backend/app/services/simulation_manager.py @@ -635,6 +635,59 @@ class SimulationManager: os.remove(backup_file) raise + def clone_simulation(self, source_simulation_id: str, project_id: str) -> 'SimulationState': + """ + Clone a simulation by copying its agent profiles to a new simulation. + + The cloned simulation starts at PROFILES_READY status with config_generated=False. + Only profile files are copied; simulation_config.json is NOT copied. + + Args: + source_simulation_id: ID of the simulation to clone + project_id: project ID for the new simulation + + Returns: + New SimulationState + + Raises: + ValueError: if source simulation not found or is in CREATED status + """ + source_state = self.get_simulation(source_simulation_id) + if not source_state: + raise ValueError(f"Source simulation {source_simulation_id} not found") + + if source_state.status == SimulationStatus.CREATED: + raise ValueError("Cannot clone a simulation in 'created' status (no profiles yet)") + + import uuid + new_sim_id = f"sim_{uuid.uuid4().hex[:12]}" + new_state = SimulationState( + simulation_id=new_sim_id, + project_id=project_id, + graph_id=source_state.graph_id, + enable_twitter=source_state.enable_twitter, + enable_reddit=source_state.enable_reddit, + status=SimulationStatus.PROFILES_READY, + entities_count=source_state.entities_count, + profiles_count=source_state.profiles_count, + entity_types=list(source_state.entity_types), + config_generated=False, + parent_simulation_id=source_simulation_id, + ) + + src_dir = self._get_simulation_dir(source_simulation_id) + dst_dir = self._get_simulation_dir(new_sim_id) + os.makedirs(dst_dir, exist_ok=True) + + for fname in ("reddit_profiles.json", "twitter_profiles.csv", "agent_profiles.json"): + src_file = os.path.join(src_dir, fname) + if os.path.exists(src_file): + shutil.copy2(src_file, os.path.join(dst_dir, fname)) + + self._save_simulation_state(new_state) + logger.info(f"Simulation cloned: {source_simulation_id} -> {new_sim_id}, project={project_id}") + return new_state + def patch_simulation_config(self, simulation_id: str, fields: dict) -> dict: """ Update global simulation config parameters (Fase B). diff --git a/backend/tests/test_simulation_clone.py b/backend/tests/test_simulation_clone.py new file mode 100644 index 00000000..6ce66538 --- /dev/null +++ b/backend/tests/test_simulation_clone.py @@ -0,0 +1,90 @@ +import json +import pytest +from pathlib import Path + + +@pytest.fixture +def app_client(tmp_path, monkeypatch): + from backend.app.services.simulation_manager import SimulationManager + monkeypatch.setattr(SimulationManager, 'SIMULATION_DATA_DIR', str(tmp_path)) + from backend.app import create_app + app = create_app({'TESTING': True}) + with app.test_client() as c: + yield c, tmp_path + + +@pytest.fixture +def completed_sim(app_client): + client, tmp_path = app_client + sim_id = "sim_src001" + sim_dir = tmp_path / sim_id + sim_dir.mkdir() + state = { + "simulation_id": sim_id, "project_id": "proj_src", "graph_id": "g1", + "status": "completed", "entities_count": 2, "profiles_count": 2, + "entity_types": [], "config_generated": True, + "parent_simulation_id": None, "graph_id_simulation": None, + } + (sim_dir / "state.json").write_text(json.dumps(state)) + profiles = [ + {"user_id": 0, "name": "Alice", "bio": "Bio A", "manually_edited": False}, + {"user_id": 1, "name": "Bob", "bio": "Bio B", "manually_edited": True}, + ] + (sim_dir / "reddit_profiles.json").write_text(json.dumps(profiles)) + (sim_dir / "twitter_profiles.csv").write_text("user_id,name\n0,Alice\n1,Bob\n") + (sim_dir / "agent_profiles.json").write_text(json.dumps(profiles)) + (sim_dir / "simulation_config.json").write_text(json.dumps({"time_config": {}})) + return client, tmp_path, sim_id + + +def test_clone_creates_new_simulation(completed_sim): + """POST /api/simulation//clone returns 200, success=True, with new_simulation_id and parent_simulation_id.""" + client, tmp_path, src_id = completed_sim + resp = client.post( + f'/api/simulation/{src_id}/clone', + json={"project_id": "proj_clone"}, + content_type='application/json', + ) + assert resp.status_code == 200 + data = resp.get_json() + assert data["success"] is True + assert "new_simulation_id" in data["data"] + assert data["data"]["parent_simulation_id"] == src_id + assert data["data"]["new_simulation_id"] != src_id + + +def test_clone_copies_profiles_not_config(completed_sim): + """Cloned sim dir has reddit_profiles.json + twitter_profiles.csv, but NOT simulation_config.json.""" + client, tmp_path, src_id = completed_sim + resp = client.post( + f'/api/simulation/{src_id}/clone', + json={"project_id": "proj_clone"}, + content_type='application/json', + ) + assert resp.status_code == 200 + data = resp.get_json() + new_id = data["data"]["new_simulation_id"] + + new_dir = tmp_path / new_id + assert (new_dir / "reddit_profiles.json").exists(), "reddit_profiles.json should be copied" + assert (new_dir / "twitter_profiles.csv").exists(), "twitter_profiles.csv should be copied" + assert not (new_dir / "simulation_config.json").exists(), "simulation_config.json must NOT be copied" + + +def test_clone_status_is_profiles_ready(completed_sim): + """Cloned sim state.json has status='profiles_ready' and correct parent_simulation_id.""" + client, tmp_path, src_id = completed_sim + resp = client.post( + f'/api/simulation/{src_id}/clone', + json={"project_id": "proj_clone"}, + content_type='application/json', + ) + assert resp.status_code == 200 + data = resp.get_json() + new_id = data["data"]["new_simulation_id"] + + state_file = tmp_path / new_id / "state.json" + assert state_file.exists(), "state.json must be created for cloned simulation" + state = json.loads(state_file.read_text()) + assert state["status"] == "profiles_ready" + assert state["parent_simulation_id"] == src_id