feat(simulation): add POST /<simulation_id>/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 <noreply@anthropic.com>
This commit is contained in:
parent
ed71afe442
commit
a6b4ccca3c
|
|
@ -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('/<simulation_id>/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
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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/<src_id>/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
|
||||
Loading…
Reference in New Issue