"""Persistence models for the standalone position-collaboration workspace. This deliberately does **not** reuse ``roundtable_drafts`` or ``roundtable_jobs``. A session captures the selected business chain and the confirmed task intent; every chain seat then has one durable node record. LangGraph threads remain the execution source, while these tables also retain database-owned recovery copies of conversations and text deliverables so the workspace survives replacement of container-local checkpoint/output volumes. """ from __future__ import annotations from datetime import UTC, datetime from sqlalchemy import ForeignKey, Integer, String, UniqueConstraint from sqlalchemy.orm import Mapped, mapped_column from deerflow.persistence.base import Base from deerflow.persistence.types import BeijingDateTime, PortableLongText class PositionRoundtableSessionRow(Base): __tablename__ = "position_roundtable_sessions" id: Mapped[str] = mapped_column(String(64), primary_key=True) # Phase one uses normal account isolation. A future membership / role ACL # can be added without changing the session or thread identifiers. user_id: Mapped[str] = mapped_column(String(64), nullable=False, index=True) external_task_id: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True) task_snapshot: Mapped[str | None] = mapped_column(PortableLongText, nullable=True) chain_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True) chain_snapshot: Mapped[str | None] = mapped_column(PortableLongText, nullable=True) intent_thread_id: Mapped[str | None] = mapped_column(String(128), nullable=True) intent_snapshot: Mapped[str | None] = mapped_column(PortableLongText, nullable=True) # Database-owned recovery copy for the complete Step-1 conversation. The # normal LangGraph checkpoint remains the execution source, while this # snapshot lets the position workspace recover after a checkpoint volume # or container-local store has been replaced. intent_conversation: Mapped[str | None] = mapped_column(PortableLongText, nullable=True) # The two built-in closing agents own normal LangGraph threads too. Keep # their ids on the position session so a refresh can continue the same # report/planning dialogue instead of silently starting a fresh context. summary_thread_id: Mapped[str | None] = mapped_column(String(128), nullable=True) summary_snapshot: Mapped[str | None] = mapped_column(PortableLongText, nullable=True) summary_conversation: Mapped[str | None] = mapped_column(PortableLongText, nullable=True) action_plan_thread_id: Mapped[str | None] = mapped_column(String(128), nullable=True) action_plan_snapshot: Mapped[str | None] = mapped_column(PortableLongText, nullable=True) action_plan_conversation: Mapped[str | None] = mapped_column(PortableLongText, nullable=True) # intent_pending -> active -> completed / archived. The router owns legal # transitions; the DB keeps it as a concise, portable string. status: Mapped[str] = mapped_column(String(32), nullable=False, default="intent_pending", index=True) created_at: Mapped[datetime] = mapped_column( BeijingDateTime(), nullable=False, default=lambda: datetime.now(UTC) ) updated_at: Mapped[datetime] = mapped_column( BeijingDateTime(), nullable=False, default=lambda: datetime.now(UTC), onupdate=lambda: datetime.now(UTC), ) class PositionRoundtableNodeRow(Base): __tablename__ = "position_roundtable_nodes" __table_args__ = ( UniqueConstraint("session_id", "node_key", name="uq_position_roundtable_nodes_session_key"), ) id: Mapped[str] = mapped_column(String(64), primary_key=True) session_id: Mapped[str] = mapped_column( String(64), ForeignKey("position_roundtable_sessions.id", ondelete="CASCADE"), nullable=False, index=True, ) # Stable inside one frozen chain snapshot; e.g. ``stage-1-seat-2-agent``. node_key: Mapped[str] = mapped_column(String(256), nullable=False) stage_index: Mapped[int] = mapped_column(Integer, nullable=False) seat_index: Mapped[int] = mapped_column(Integer, nullable=False) agent_id: Mapped[str] = mapped_column(String(128), nullable=False, index=True) position_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True) # The LangGraph/checkpoint store remains the execution source for messages. thread_id: Mapped[str | None] = mapped_column(String(128), nullable=True, unique=True) # locked / ready / running / done / stale / rejected / error status: Mapped[str] = mapped_column(String(32), nullable=False, default="locked", index=True) latest_answer: Mapped[str | None] = mapped_column(PortableLongText, nullable=True) artifact_manifest: Mapped[str | None] = mapped_column(PortableLongText, nullable=True) # A complete, JSON-serialisable copy of the node chat plus its composer # draft. It is intentionally colocated with the node so deleting a # position Session removes every recovery snapshot through the FK cascade. conversation_snapshot: Mapped[str | None] = mapped_column(PortableLongText, nullable=True) revision: Mapped[int] = mapped_column(Integer, nullable=False, default=0) upstream_revision: Mapped[str | None] = mapped_column(PortableLongText, nullable=True) rejection_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0, server_default="0") last_rejection: Mapped[str | None] = mapped_column(PortableLongText, nullable=True) invalidated_by: Mapped[str | None] = mapped_column(String(256), nullable=True) created_at: Mapped[datetime] = mapped_column( BeijingDateTime(), nullable=False, default=lambda: datetime.now(UTC) ) updated_at: Mapped[datetime] = mapped_column( BeijingDateTime(), nullable=False, default=lambda: datetime.now(UTC), onupdate=lambda: datetime.now(UTC), )