"""Plan versioning — save and restore named snapshots of a ProjectPlan.""" import copy import uuid from datetime import datetime, timezone from core.planner import ProjectPlan MAX_VERSIONS = 20 def save_version(state: dict, label: str = "") -> dict: """ Snapshot the current plan into state["versions"]. Each version: {"id": uuid, "label": str, "timestamp": ISO str, "snapshot": plan_dict} If label is empty, auto-generate: "Version N" where N = len(versions)+1 Returns updated state dict (deep copy). Max 20 versions kept (drop oldest if exceeded). """ state = copy.deepcopy(state) versions: list = state.setdefault("versions", []) if not label: label = f"Version {len(versions) + 1}" plan = state.get("plan") if isinstance(plan, ProjectPlan): snapshot = plan.model_dump(exclude_none=True) elif isinstance(plan, dict): snapshot = plan else: snapshot = {} version_entry = { "id": str(uuid.uuid4()), "label": label, "timestamp": datetime.now(timezone.utc).isoformat(), "snapshot": snapshot, } versions.append(version_entry) if len(versions) > MAX_VERSIONS: versions[:] = versions[-MAX_VERSIONS:] return state def restore_version(version_id: str, state: dict) -> dict: """ Restore a saved version by ID into state["plan"]. Returns updated state dict. If version_id not found, return state unchanged. """ versions: list = state.get("versions", []) match = next((v for v in versions if v["id"] == version_id), None) if match is None: return state state = copy.deepcopy(state) # snapshot is already a dict — use model_validate, not parse_plan_json (which expects a string) state["plan"] = ProjectPlan.model_validate(match["snapshot"]).model_dump() return state def list_versions(state: dict) -> list[dict]: """ Return list of version summaries: [{"id", "label", "timestamp"}] Most recent first. """ versions: list = state.get("versions", []) summaries = [ {"id": v["id"], "label": v["label"], "timestamp": v["timestamp"]} for v in versions ] return list(reversed(summaries)) def versions_html(state: dict) -> str: """ Render version history as an HTML list for display in Gradio. Each entry shows label + timestamp. Empty state shows placeholder text. Uses dark theme colors (#111111 bg, #e8e8e8 text, #2a2a2a border). """ summaries = list_versions(state) container_style = ( "background:#111111;color:#e8e8e8;border:1px solid #2a2a2a;" "border-radius:6px;padding:12px;font-family:sans-serif;" ) if not summaries: return ( f'