"""Conversation and attachment storage for the v3 delivery boundary. CAD task state deliberately does not live here. Protocol v3 owns mutable task state in ``SqliteTaskRepository`` and immutable task artifacts in ``FileArtifactStore``. """ from __future__ import annotations import json from datetime import datetime, timezone from pathlib import Path import re import secrets 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}$") def now_iso() -> str: return datetime.now(timezone.utc).isoformat() 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_relative_path(value: str) -> str: path = Path(str(value or "")) if not value or path.is_absolute() or ".." in path.parts: raise ValueError("Invalid conversation 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) -> dict[str, Any] | None: if not path.is_file(): return None value = json.loads(path.read_text(encoding="utf-8")) return value if isinstance(value, dict) else None class WorkspaceStore: """A conversation store, intentionally not a CAD task repository.""" def __init__(self, settings: Settings) -> None: self.settings = settings self.settings.conversation_root.mkdir(parents=True, exist_ok=True) 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 ensure_conversation(self, conversation_id: str | None, current_task_id: str | None = None) -> dict[str, Any]: conversation = safe_conversation_id(conversation_id) if conversation_id else f"conv_{secrets.token_hex(6)}" path = self.conversation_path(conversation) current = _read_json(path) if current is not None: if current_task_id and current.get("current_task_id") != safe_task_id(current_task_id): current["current_task_id"] = safe_task_id(current_task_id) current["updated_at"] = now_iso() _write_json(path, current) return current record = { "schema_version": "3.0", "conversation_id": conversation, "created_at": now_iso(), "updated_at": now_iso(), "current_task_id": safe_task_id(current_task_id) if current_task_id else "", "messages": [], "attachments": [], } _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") or "") for item in record["messages"] if isinstance(item, dict)} if str(message.get("id") or "") 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 conversation_attachment_path(self, conversation_id: str, relative_path: str) -> Path: root = self.conversation_dir(conversation_id).resolve() candidate = (root / _safe_relative_path(relative_path)).resolve() if root != candidate and root not in candidate.parents: raise ValueError("Attachment path escapes conversation directory") return candidate def write_conversation_upload(self, conversation_id: str, filename: str, data: bytes) -> tuple[str, Path]: conversation = safe_conversation_id(conversation_id) 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}").as_posix() target = self.conversation_attachment_path(conversation, relative) target.parent.mkdir(parents=True, exist_ok=True) target.write_bytes(data) return relative, target def add_conversation_attachment(self, conversation_id: str, attachment: dict[str, Any]) -> dict[str, Any]: conversation = safe_conversation_id(conversation_id) record = self.read_conversation(conversation) if record is None: raise ValueError("Conversation not found") if str(attachment.get("conversation_id") or "") != conversation: raise ValueError("Attachment does not belong to this conversation") attachment_id = str(attachment.get("id") or "") if not attachment_id: raise ValueError("Attachment id is required") self.conversation_attachment_path(conversation, str(attachment.get("path") or "")) attachments = record.setdefault("attachments", []) if any(str(item.get("id") or "") == attachment_id for item in attachments if isinstance(item, dict)): raise ValueError("Attachment already exists") attachments.append(attachment) record["updated_at"] = now_iso() _write_json(self.conversation_path(conversation), record) return record