from __future__ import annotations import json import re import secrets from datetime import datetime, timezone from pathlib import Path from typing import Any from app.settings import Settings TASK_ID = re.compile(r"^cad_[a-z0-9]{12}$") CONVERSATION_ID = re.compile(r"^conv_[a-z0-9]{12}$") DESIGN_INTENT_ID = re.compile(r"^intent_[a-z0-9]{12}$") def now_iso() -> str: return datetime.now(timezone.utc).isoformat() def new_id(prefix: str) -> str: return f"{prefix}_{secrets.token_hex(6)}" def safe_task_id(task_id: str) -> str: value = str(task_id or "").strip() if not TASK_ID.fullmatch(value): raise ValueError("Invalid task id") return value def safe_conversation_id(conversation_id: str) -> str: value = str(conversation_id or "").strip() if not CONVERSATION_ID.fullmatch(value): raise ValueError("Invalid conversation id") return value def safe_design_intent_id(intent_id: str) -> str: value = str(intent_id or "").strip() if not DESIGN_INTENT_ID.fullmatch(value): raise ValueError("Invalid design intent id") return value def safe_relative_path(value: str) -> str: path = Path(str(value or "")) if not value or path.is_absolute() or ".." in path.parts: raise ValueError("Invalid artifact path") return path.as_posix() def write_json(path: Path, payload: Any) -> None: path.parent.mkdir(parents=True, exist_ok=True) path.write_text(json.dumps(payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") def read_json(path: Path, fallback: Any = None) -> Any: if not path.is_file(): return fallback return json.loads(path.read_text(encoding="utf-8")) class WorkspaceStore: def __init__(self, settings: Settings) -> None: self.settings = settings self.settings.task_root.mkdir(parents=True, exist_ok=True) self.settings.conversation_root.mkdir(parents=True, exist_ok=True) def task_dir(self, task_id: str) -> Path: return self.settings.task_root / safe_task_id(task_id) def task_path(self, task_id: str) -> Path: return self.task_dir(task_id) / "task.json" def conversation_dir(self, conversation_id: str) -> Path: return self.settings.conversation_root / safe_conversation_id(conversation_id) def conversation_path(self, conversation_id: str) -> Path: return self.conversation_dir(conversation_id) / "conversation.json" def write_tool_call_diagnostic(self, conversation_id: str, payload: dict[str, Any]) -> str: """Persist one failed model tool call without creating a CAD revision.""" conversation = safe_conversation_id(conversation_id) relative = Path("diagnostics") / f"tool_call_{secrets.token_hex(8)}.json" path = self.conversation_dir(conversation) / relative write_json(path, payload) return (Path(conversation) / relative).as_posix() def ensure_conversation( self, conversation_id: str | None, current_task_id: str | None = None, attachments: list[dict[str, Any]] | None = None, ) -> dict[str, Any]: cid = safe_conversation_id(conversation_id) if conversation_id else new_id("conv") path = self.conversation_path(cid) current = read_json(path) if current: changed = False if current_task_id: current["current_task_id"] = safe_task_id(current_task_id) changed = True if attachments is not None: current["attachments"] = attachments changed = True if changed: current["updated_at"] = now_iso() write_json(path, current) return current record = { "schema_version": "1.1", "conversation_id": cid, "created_at": now_iso(), "updated_at": now_iso(), "current_task_id": safe_task_id(current_task_id) if current_task_id else "", "messages": [], "attachments": attachments or [], } write_json(path, record) return record def read_conversation(self, conversation_id: str) -> dict[str, Any] | None: return read_json(self.conversation_path(conversation_id)) def append_conversation_message(self, conversation_id: str, message: dict[str, Any], current_task_id: str | None = None) -> dict[str, Any]: record = self.ensure_conversation(conversation_id, current_task_id) known = {str(item.get("id")) for item in record["messages"]} if str(message.get("id")) not in known: record["messages"].append(message) if current_task_id: record["current_task_id"] = safe_task_id(current_task_id) record["updated_at"] = now_iso() write_json(self.conversation_path(record["conversation_id"]), record) return record def write_upload(self, task_id: str, filename: str, data: bytes) -> tuple[str, Path]: safe_name = re.sub(r"[^a-zA-Z0-9._-]+", "_", Path(filename).name).strip("._") or "attachment" relative = Path("uploads") / f"upload_{secrets.token_hex(6)}_{safe_name}" target = self.artifact_path(task_id, relative.as_posix()) target.parent.mkdir(parents=True, exist_ok=True) target.write_bytes(data) return relative.as_posix(), target def ensure_task(self, task_id: str | None, request: str) -> dict[str, Any]: tid = safe_task_id(task_id) if task_id else new_id("cad") path = self.task_path(tid) current = read_json(path) if current: return current task_dir = self.task_dir(tid) (task_dir / "revisions").mkdir(parents=True, exist_ok=True) record = { "schema_version": "1.1", "task_id": tid, "request": request, "created_at": now_iso(), "updated_at": now_iso(), "current_revision": "", "current_design_intent_id": "", "design_intents": [], "revisions": [], } write_json(path, record) return record def next_revision(self, task_id: str) -> tuple[str, Path]: task = self.ensure_task(task_id, "") revision_id = f"rev_{len(task['revisions']) + 1:03d}" revision_dir = self.task_dir(task_id) / "revisions" / revision_id revision_dir.mkdir(parents=True, exist_ok=False) return revision_id, revision_dir def update_task(self, task_id: str, revision: dict[str, Any]) -> dict[str, Any]: task = self.ensure_task(task_id, "") task["revisions"].append(revision) if revision.get("status") == "success": task["current_revision"] = revision["revision_id"] task["updated_at"] = now_iso() write_json(self.task_path(task_id), task) return task def create_design_intent( self, task_id: str | None, request: str, intent: dict[str, Any], part_skill_selection: dict[str, Any], ) -> dict[str, Any]: """Persist a validated plan and atomically make it the current plan.""" task = self.ensure_task(task_id, request) task.setdefault("design_intents", []) task.setdefault("current_design_intent_id", "") task["schema_version"] = "1.1" previous_id = str(task.get("current_design_intent_id") or "") if previous_id: for record in task["design_intents"]: if str(record.get("intent_id") or "") == previous_id and record.get("status") != "superseded": record["status"] = "superseded" intent_id = new_id("intent") created_at = now_iso() relative = Path("planning") / f"design-intent-{intent_id}.json" skill_ids = [str(item) for item in part_skill_selection.get("skill_ids") or [] if str(item)] persisted = dict(intent) persisted.update({ "intent_id": intent_id, "created_at": created_at, "part_skill_ids": skill_ids, "part_skill_selection": part_skill_selection, }) write_json(self.artifact_path(task["task_id"], relative.as_posix()), persisted) record = { "intent_id": intent_id, "status": "accepted" if persisted.get("status") == "ready" else "pending", "created_at": created_at, "path": relative.as_posix(), "part_skill_ids": skill_ids, "base_revision_id": str(persisted.get("base_revision_id") or ""), } task["design_intents"].append(record) task["current_design_intent_id"] = intent_id task["updated_at"] = now_iso() write_json(self.task_path(task["task_id"]), task) return {"task_id": task["task_id"], **record, "intent": persisted} def read_design_intent(self, task_id: str, intent_id: str | None = None) -> dict[str, Any] | None: task = self.read_task(task_id) if not task: return None chosen = safe_design_intent_id(intent_id) if intent_id else str(task.get("current_design_intent_id") or "") if not chosen: return None record = next( (item for item in task.get("design_intents") or [] if str(item.get("intent_id") or "") == chosen), None, ) if not isinstance(record, dict): return None path = self.artifact_path(task_id, str(record.get("path") or "")) intent = read_json(path) if not isinstance(intent, dict): return None return {"task_id": task_id, "record": record, "intent": intent} def update_design_intent_status(self, task_id: str, intent_id: str, status: str) -> dict[str, Any]: safe_id = safe_design_intent_id(intent_id) task = self.ensure_task(task_id, "") records = task.setdefault("design_intents", []) record = next((item for item in records if str(item.get("intent_id") or "") == safe_id), None) if not isinstance(record, dict): raise ValueError("Design intent not found") record["status"] = str(status) task["updated_at"] = now_iso() write_json(self.task_path(task_id), task) return record def read_task(self, task_id: str) -> dict[str, Any] | None: return read_json(self.task_path(task_id)) def current_cdsl_path(self, task_id: str) -> Path | None: task = self.read_task(task_id) revision_id = str((task or {}).get("current_revision") or "") if not revision_id: return None candidate = self.task_dir(task_id) / "revisions" / revision_id / "model.cdsl.json" return candidate if candidate.is_file() else None def artifact_path(self, task_id: str, relative_path: str) -> Path: safe = safe_relative_path(relative_path) root = self.task_dir(task_id).resolve() target = (root / safe).resolve() if root != target and root not in target.parents: raise ValueError("Artifact path escapes task directory") return target