Files
董宏凯andCursor 99642fca6c feat(maf): conversation-driven MAF roundtable (R1/R2) + server-owned stage-3 dispatch
Backend:
- Conversation API at /api/maf-roundtable/conversations: create/list/detail,
  paginated messages, atomic send+run start, owner-only isolation,
  admin read-only diagnostic access
- SSE real-time event stream (GET /conversations/{id}/stream) backed by
  ConversationEventHub + conversation_events store; multi-worker deployment doc
- Deterministic server-owned stage-3 dispatch contracts (dispatch_contracts.py):
  routing resolved from trusted MAF deployment config instead of manager-chosen
  agent ids; config.yaml keeps stage-3 routing deployment-owned
  (default_manager_agent_id / seat ids / business_routes / max_seats_per_run)
- Conversation persistence (conversations/messages stores) + v2 migration script
- Tests: conversations, conversation stream, migration, phase3 conversation dispatch

Frontend:
- Vitest test infra for MAF (vitest.maf.config.ts + test:maf script) with
  component tests for ApprovalCard/RunHeader/SessionsPage and use-maf-events
- Session form lib, flow-graph dialog / run-detail / sessions page updates,
  routes and nav tweaks
- Docs: R0 legacy interaction baseline mapping, updated implementation plan

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-03 12:09:47 +08:00

581 lines
30 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""ORM models for the MAF (Microsoft Agent Framework) roundtable experiment.
Nine independent tables — **no foreign keys to the legacy ``roundtable_*``
tables** (``roundtable_jobs`` / ``roundtable_drafts`` /
``roundtable_task_drafts``). The MAF experiment is fully isolated: it has its
own session / participant / run / event / action / lease / approval tables, keyed by
its own surrogate ids, so the legacy multi-agent roundtable keeps its current
behavior, schema, and recovery semantics untouched.
Tables (see the implementation plan §8):
- ``maf_roundtable_sessions`` — one MAF roundtable session (task + mode + capability snapshot)
- ``maf_roundtable_participants`` — manager + seat agents bound to a session
- ``maf_roundtable_runs`` — one execution run of a session
- ``maf_roundtable_events`` — monotonic per-run event stream (``(run_id, seq)`` unique)
- ``maf_roundtable_actions`` — external write / tool actions (idempotency-keyed)
- ``maf_roundtable_action_leases`` — active target-write leases (one per target key)
- ``maf_roundtable_approvals`` — pending human-approval requests
User isolation: ``maf_roundtable_sessions.user_id`` is the owner; all child
rows are filtered through the owning session. ``user_id`` on child tables
(``actions``) is audit-only. Admins bypass the owner filter when
``maf_roundtable.admin_diagnostic_access`` is true.
All JSON / large-text columns use the portable types so the schema is identical
across sqlite / postgres / mysql (see ``deerflow.persistence.types``).
"""
from __future__ import annotations
from datetime import UTC, datetime
from sqlalchemy import (
CheckConstraint,
ForeignKey,
Integer,
String,
UniqueConstraint,
)
from sqlalchemy.orm import Mapped, mapped_column
from deerflow.persistence.base import Base
from deerflow.persistence.types import BeijingDateTime, PortableJSON, PortableLongText
def _now() -> datetime:
return datetime.now(UTC)
# ── 1. sessions ───────────────────────────────────────────────────────────
class MafRoundtableSessionRow(Base):
"""One MAF roundtable session (the user's task + chosen mode + frozen capability snapshot).
Owner-scoped by ``user_id``. ``capability_snapshot_json`` freezes the
agents / tools / skills configuration at creation time so a run can be
reproduced even if the agent config changes later. ``fact_ledger_json``
holds the shared fact ledger (confirmed facts / tool results / pending /
failed actions) updated as the run progresses.
"""
__tablename__ = "maf_roundtable_sessions"
id: Mapped[str] = mapped_column(String(64), primary_key=True)
user_id: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
title: Mapped[str] = mapped_column(String(255), default="")
task_text: Mapped[str] = mapped_column(PortableLongText, nullable=False)
task_payload_json: Mapped[dict | None] = mapped_column(PortableJSON(), nullable=True)
# structured | group_chat
mode: Mapped[str] = mapped_column(String(16), default="structured", index=True)
# draft | running | waiting_approval | waiting_input | completed | failed | cancelled
status: Mapped[str] = mapped_column(String(32), default="draft", index=True)
model_name: Mapped[str | None] = mapped_column(String(128), nullable=True)
reasoning_effort: Mapped[str | None] = mapped_column(String(32), nullable=True)
manager_agent_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
max_rounds: Mapped[int] = mapped_column(Integer, default=8)
# auto | confirm_high_risk | confirm_all_writes
write_policy: Mapped[str] = mapped_column(String(32), default="confirm_high_risk")
capability_snapshot_json: Mapped[dict | None] = mapped_column(PortableJSON(), nullable=True)
fact_ledger_json: Mapped[dict | None] = mapped_column(PortableJSON(), nullable=True)
final_result_json: Mapped[dict | None] = mapped_column(PortableJSON(), nullable=True)
created_at: Mapped[datetime] = mapped_column(BeijingDateTime(), nullable=False, default=_now)
updated_at: Mapped[datetime] = mapped_column(
BeijingDateTime(), nullable=False, default=_now, onupdate=_now
)
# ── 2. participants ──────────────────────────────────────────────────────
class MafRoundtableParticipantRow(Base):
"""Manager + seat agents bound to a session.
One row per participant agent. ``role`` is ``manager`` / ``seat``.
``thread_id`` / ``sandbox_id`` bind the participant to its own DeerFlow
thread / sandbox (one seat = one thread = one sandbox). ``capability_snapshot_json``
freezes the agent's tools / skills at session creation time.
"""
__tablename__ = "maf_roundtable_participants"
__table_args__ = (
UniqueConstraint("session_id", "agent_id", name="uq_maf_participants_session_agent"),
)
id: Mapped[str] = mapped_column(String(64), primary_key=True)
session_id: Mapped[str] = mapped_column(
String(64), ForeignKey("maf_roundtable_sessions.id", ondelete="CASCADE"), nullable=False, index=True
)
agent_id: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
display_name: Mapped[str] = mapped_column(String(255), default="")
# manager | seat
role: Mapped[str] = mapped_column(String(16), default="seat")
sort_order: Mapped[int] = mapped_column(Integer, default=0)
thread_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
sandbox_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
# idle | running | done | failed | cancelled
status: Mapped[str] = mapped_column(String(16), default="idle")
last_turn_index: Mapped[int | None] = mapped_column(Integer, nullable=True)
capability_snapshot_json: Mapped[dict | None] = mapped_column(PortableJSON(), nullable=True)
created_at: Mapped[datetime] = mapped_column(BeijingDateTime(), nullable=False, default=_now)
updated_at: Mapped[datetime] = mapped_column(
BeijingDateTime(), nullable=False, default=_now, onupdate=_now
)
# ── 3. runs ───────────────────────────────────────────────────────────────
class MafRoundtableRunRow(Base):
"""One execution run of a session.
``maf_checkpoint_json`` stores the MAF workflow checkpoint (or a reference)
for resume-after-restart. ``last_event_seq`` is a denormalized cache of the
latest event seq for fast polling (the authoritative seq lives in
``maf_roundtable_events``). ``token_usage_json`` accumulates per-run token
usage. ``latency_ms`` is the wall-clock duration.
"""
__tablename__ = "maf_roundtable_runs"
id: Mapped[str] = mapped_column(String(64), primary_key=True)
session_id: Mapped[str] = mapped_column(
String(64), ForeignKey("maf_roundtable_sessions.id", ondelete="CASCADE"), nullable=False, index=True
)
# pending | running | waiting_approval | waiting_input | completed | failed | cancelled
status: Mapped[str] = mapped_column(String(32), default="pending", index=True)
workflow_version: Mapped[str | None] = mapped_column(String(64), nullable=True)
maf_checkpoint_json: Mapped[dict | None] = mapped_column(PortableJSON(), nullable=True)
last_event_seq: Mapped[int] = mapped_column(Integer, default=0)
started_at: Mapped[datetime | None] = mapped_column(BeijingDateTime(), nullable=True)
finished_at: Mapped[datetime | None] = mapped_column(BeijingDateTime(), nullable=True)
error_code: Mapped[str | None] = mapped_column(String(64), nullable=True)
error_message_safe: Mapped[str | None] = mapped_column(PortableLongText, nullable=True)
token_usage_json: Mapped[dict | None] = mapped_column(PortableJSON(), nullable=True)
latency_ms: Mapped[int | None] = mapped_column(Integer, nullable=True)
created_at: Mapped[datetime] = mapped_column(BeijingDateTime(), nullable=False, default=_now)
updated_at: Mapped[datetime] = mapped_column(
BeijingDateTime(), nullable=False, default=_now, onupdate=_now
)
# ── 4. events ─────────────────────────────────────────────────────────────
class MafRoundtableEventRow(Base):
"""Monotonic per-run event stream.
``(run_id, seq)`` is unique — seq is monotonically increasing per run. The
store allocates seq by ``SELECT MAX(seq) + 1`` and retries on
``IntegrityError`` so concurrent appends from parallel seats cannot collide
or skip seq values. ``payload_json`` carries the event-specific body (tool
result, agent message delta, fact-recorded, etc.); sensitive fields must
already be redacted by the writer.
"""
__tablename__ = "maf_roundtable_events"
__table_args__ = (
UniqueConstraint("run_id", "seq", name="uq_maf_events_run_seq"),
# Dedup key for at-most-once event persistence. The authoritative
# dedup is this DB constraint (survives process restarts) — not the
# in-process ``_seen_source_ids`` set, which is only a fast-path cache.
# ``source_event_id`` is nullable; rows with NULL are not deduped
# (SQL treats NULLs as distinct in a UNIQUE constraint on sqlite /
# postgres / mysql), so events without a source id stay append-only.
UniqueConstraint("run_id", "source_event_id", name="uq_maf_events_run_source"),
)
id: Mapped[str] = mapped_column(String(64), primary_key=True)
session_id: Mapped[str] = mapped_column(
String(64), ForeignKey("maf_roundtable_sessions.id", ondelete="CASCADE"), nullable=False, index=True
)
run_id: Mapped[str] = mapped_column(
String(64), ForeignKey("maf_roundtable_runs.id", ondelete="CASCADE"), nullable=False, index=True
)
seq: Mapped[int] = mapped_column(Integer, nullable=False)
# run.started | run.status_changed | workflow.checkpointed | manager.decision |
# agent.turn_started | agent.message.delta | agent.message.completed |
# skill.selected | skill.started | skill.completed | tool.requested | tool.started |
# tool.progress | tool.completed | tool.failed | action.waiting_approval |
# action.approved | action.rejected | artifact.created | fact.recorded |
# run.completed | run.failed
event_type: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
agent_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
turn_index: Mapped[int | None] = mapped_column(Integer, nullable=True)
# Upstream identity of the source runtime event (e.g. LangGraph message id
# / tool_call id). Used by the ``(run_id, source_event_id)`` unique
# constraint for durable at-most-once dedup across restarts.
source_event_id: Mapped[str | None] = mapped_column(String(191), nullable=True, index=True)
payload_json: Mapped[dict | None] = mapped_column(PortableJSON(), nullable=True)
created_at: Mapped[datetime] = mapped_column(BeijingDateTime(), nullable=False, default=_now, index=True)
# ── 5. actions ─────────────────────────────────────────────────────────────
class MafRoundtableActionRow(Base):
"""External write / tool action record (idempotency-keyed).
The Action Coordinator (Phase 5) uses ``idempotency_key`` to guarantee
at-most-once execution across retries / restarts. ``target_key`` carries a
per-business-object lease so parallel seats writing the same object are
serialized. ``risk_level`` is R0–R4. ``status`` is the action state machine:
pending | running | succeeded | failed | unknown | waiting_approval |
approved | rejected | compensated. ``arguments_redacted_json`` /
``result_redacted_json`` must already have credentials / tokens / cookies
stripped by the writer.
"""
__tablename__ = "maf_roundtable_actions"
__table_args__ = (
UniqueConstraint("idempotency_key", name="uq_maf_actions_idempotency"),
)
id: Mapped[str] = mapped_column(String(64), primary_key=True)
session_id: Mapped[str] = mapped_column(
String(64), ForeignKey("maf_roundtable_sessions.id", ondelete="CASCADE"), nullable=False, index=True
)
run_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
agent_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
tool_name: Mapped[str] = mapped_column(String(128), nullable=False, index=True)
logical_action_id: Mapped[str | None] = mapped_column(String(128), nullable=True)
# The seat turn that produced this action. Persisted (not only embedded in
# the idempotency_key) so pending-approval matching can key on
# run_id + agent_id + turn_index + logical_action_id precisely — never
# mixing a seat's earlier-turn waiting actions into the current approval.
turn_index: Mapped[int | None] = mapped_column(Integer, nullable=True)
idempotency_key: Mapped[str] = mapped_column(String(191), nullable=False, unique=True)
target_key: Mapped[str | None] = mapped_column(String(255), nullable=True, index=True)
# R0 | R1 | R2 | R3 | R4
risk_level: Mapped[str] = mapped_column(String(8), default="R0")
# pending | running | succeeded | failed | unknown | waiting_approval | approved | rejected | compensated
status: Mapped[str] = mapped_column(String(32), default="pending", index=True)
arguments_redacted_json: Mapped[dict | None] = mapped_column(PortableJSON(), nullable=True)
result_redacted_json: Mapped[dict | None] = mapped_column(PortableJSON(), nullable=True)
external_object_id: Mapped[str | None] = mapped_column(String(255), nullable=True)
approval_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
attempt_count: Mapped[int] = mapped_column(Integer, default=0)
started_at: Mapped[datetime | None] = mapped_column(BeijingDateTime(), nullable=True)
finished_at: Mapped[datetime | None] = mapped_column(BeijingDateTime(), nullable=True)
created_at: Mapped[datetime] = mapped_column(BeijingDateTime(), nullable=False, default=_now)
updated_at: Mapped[datetime] = mapped_column(
BeijingDateTime(), nullable=False, default=_now, onupdate=_now
)
# ── 6. target leases ──────────────────────────────────────────────────────
class MafRoundtableActionLeaseRow(Base):
"""One active, short-lived lease for a server-derived external target key.
The key is opaque (a stable SHA-256 digest) and is never sourced from a
browser/model field. ``target_key`` is the primary key, which makes the
database — not an in-process mutex — arbitrate concurrent writes across
workers and restarts. A lease belongs to exactly one action and is removed
with a known terminal result; an ``unknown`` result deliberately retains a
manual-reconciliation barrier.
"""
__tablename__ = "maf_roundtable_action_leases"
__table_args__ = (
UniqueConstraint("action_id", name="uq_maf_action_leases_action"),
)
target_key: Mapped[str] = mapped_column(String(255), primary_key=True)
action_id: Mapped[str] = mapped_column(
String(64),
ForeignKey("maf_roundtable_actions.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
lease_expires_at: Mapped[datetime] = mapped_column(
BeijingDateTime(), nullable=False, index=True
)
created_at: Mapped[datetime] = mapped_column(BeijingDateTime(), nullable=False, default=_now)
updated_at: Mapped[datetime] = mapped_column(
BeijingDateTime(), nullable=False, default=_now, onupdate=_now
)
# ── 7. approvals ──────────────────────────────────────────────────────────
class MafRoundtableApprovalRow(Base):
"""Pending human-approval request for a high-risk external write.
``status`` is ``pending | approved | rejected | expired``. ``payload_redacted_json``
carries the action summary the user sees (redacted). ``expires_at`` is the
deadline after which the approval auto-expires and the action is marked
``rejected`` (or ``unknown`` if it may already have been sent).
"""
__tablename__ = "maf_roundtable_approvals"
id: Mapped[str] = mapped_column(String(64), primary_key=True)
session_id: Mapped[str] = mapped_column(
String(64), ForeignKey("maf_roundtable_sessions.id", ondelete="CASCADE"), nullable=False, index=True
)
run_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
action_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
requested_by_agent_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
# pending | approved | rejected | expired
status: Mapped[str] = mapped_column(String(16), default="pending", index=True)
summary: Mapped[str] = mapped_column(PortableLongText, default="")
payload_redacted_json: Mapped[dict | None] = mapped_column(PortableJSON(), nullable=True)
decided_by: Mapped[str | None] = mapped_column(String(64), nullable=True)
decided_at: Mapped[datetime | None] = mapped_column(BeijingDateTime(), nullable=True)
expires_at: Mapped[datetime | None] = mapped_column(BeijingDateTime(), nullable=True, index=True)
created_at: Mapped[datetime] = mapped_column(BeijingDateTime(), nullable=False, default=_now)
updated_at: Mapped[datetime] = mapped_column(
BeijingDateTime(), nullable=False, default=_now, onupdate=_now
)
# ── 8. seat runs (Phase 2: MAF↔DeerFlow run mapping) ─────────────────────
class MafRoundtableSeatRunRow(Base):
"""Persistent mapping between a MAF workflow seat turn and its DeerFlow run.
Replaces the in-memory ``_active_runs`` dict so that after a service restart
the system can still locate and cancel outstanding DeerFlow runs, and the
MAF checkpoint can reference which seat turns completed.
Phase-3 unique constraint: ``(maf_run_id, agent_id, turn_index,
execution_stage)``. ``turn_index`` is the business round (kept stable for
frontend display / approval matching / event tracing); ``execution_stage``
disambiguates the distinct DeerFlow executions that happen within one
business round (planner / expert / format_repair / cross_review /
finalizer / group_chat). The business turn_index is NEVER mutated to dodge
a collision.
"""
__tablename__ = "maf_roundtable_seat_runs"
__table_args__ = (
UniqueConstraint(
"maf_run_id",
"agent_id",
"turn_index",
"execution_stage",
name="uq_maf_seat_runs_run_agent_turn_stage",
),
# Phase-3 P1-2: enforce the closed execution_stage enum at the DB layer.
# Mirrors seat_runs.VALID_EXECUTION_STAGES — a typo (e.g. "exprt") would
# otherwise silently bypass the four-column idempotency key by creating
# a brand-new stage. This CHECK keeps both fresh creates and any raw
# migration / manual SQL honest. Keep in sync with VALID_EXECUTION_STAGES.
CheckConstraint(
"execution_stage IN ('legacy','planner','expert','format_repair',"
"'cross_review','cross_review_repair','finalizer','group_chat')",
name="ck_maf_seat_runs_execution_stage",
),
)
id: Mapped[str] = mapped_column(String(64), primary_key=True)
maf_run_id: Mapped[str] = mapped_column(
String(64), ForeignKey("maf_roundtable_runs.id", ondelete="CASCADE"), nullable=False, index=True
)
agent_id: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
turn_index: Mapped[int] = mapped_column(Integer, nullable=False)
# Phase-3: which kind of DeerFlow execution this row represents. Pre-Phase-3
# rows are migrated to ``legacy``. Closed enum (see seat_runs.VALID_EXECUTION_STAGES):
# planner | expert | format_repair | cross_review | cross_review_repair |
# finalizer | group_chat | legacy.
execution_stage: Mapped[str] = mapped_column(
String(32), nullable=False, default="legacy", server_default="legacy"
)
deerflow_run_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
deerflow_thread_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
sandbox_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
workflow_checkpoint_id: Mapped[str | None] = mapped_column(String(128), nullable=True)
# pending | running | completed | failed | cancelled
status: Mapped[str] = mapped_column(String(32), default="pending", index=True)
# Phase-3 P0-1: a sanitized, replayable snapshot of the SeatResponse written
# atomically with the completed transition. Survives a crash that happens
# after the seat run is marked completed but before the workflow checkpoint
# is persisted, so recovery never needs to re-call DeerFlow.
result_snapshot: Mapped[dict | None] = mapped_column(PortableJSON, nullable=True)
started_at: Mapped[datetime | None] = mapped_column(BeijingDateTime(), nullable=True)
finished_at: Mapped[datetime | None] = mapped_column(BeijingDateTime(), nullable=True)
error: Mapped[str | None] = mapped_column(PortableLongText, nullable=True)
created_at: Mapped[datetime] = mapped_column(BeijingDateTime(), nullable=False, default=_now)
updated_at: Mapped[datetime] = mapped_column(
BeijingDateTime(), nullable=False, default=_now, onupdate=_now
)
# ── 9. action secrets (encrypted actual execution params, server-only) ────
class MafRoundtableActionSecretRow(Base):
"""Encrypted actual execution parameters for a protected action.
Stores the **real** (un-redacted) tool-call arguments so that after
approval or a service restart the system can recover and execute the
approved action with the original parameters. This table is **never**
joined in any read query exposed to the API, events, logs, or frontend —
it is server-only. The ActionCoordinator encrypts the payload before
writing and decrypts only at execution time.
One row per action (unique ``action_id``). The encryption key is derived
from the deployment's secret key; the ciphertext is opaque to the store.
"""
__tablename__ = "maf_roundtable_action_secrets"
__table_args__ = (
UniqueConstraint("action_id", name="uq_maf_action_secrets_action"),
)
id: Mapped[str] = mapped_column(String(64), primary_key=True)
action_id: Mapped[str] = mapped_column(
String(64), ForeignKey("maf_roundtable_actions.id", ondelete="CASCADE"),
nullable=False, index=True,
)
arguments_encrypted: Mapped[str] = mapped_column(PortableLongText, nullable=False)
created_at: Mapped[datetime] = mapped_column(BeijingDateTime(), nullable=False, default=_now)
# ── 10. conversations (V2: user-visible conversation aggregation) ─────────
class MafRoundtableConversationRow(Base):
"""One user-visible MAF roundtable conversation.
This is the **only** entity the frontend knows about. A conversation can
span multiple user messages, each of which may trigger an internal
``maf_roundtable_runs`` execution. The user never sees session/run/thread
ids — only ``conversation_id``.
``active_run_id`` points to the currently executing internal run (nullable
when idle). ``safe_status`` is a coarse user-facing status derived from
the active run's state (idle / running / waiting_input / waiting_approval
/ completed / failed / cancelled).
"""
__tablename__ = "maf_roundtable_conversations"
id: Mapped[str] = mapped_column(String(64), primary_key=True)
owner_user_id: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
title: Mapped[str] = mapped_column(String(255), default="")
task_context_json: Mapped[dict | None] = mapped_column(PortableJSON(), nullable=True)
# idle | running | waiting_input | waiting_approval | completed | failed | cancelled
safe_status: Mapped[str] = mapped_column(String(32), default="idle", index=True)
active_run_id: Mapped[str | None] = mapped_column(
String(64),
ForeignKey("maf_roundtable_runs.id", ondelete="SET NULL"),
nullable=True,
)
# Internal session this conversation is linked to (lazy-created on first message).
internal_session_id: Mapped[str | None] = mapped_column(
String(64),
ForeignKey("maf_roundtable_sessions.id", ondelete="SET NULL"),
nullable=True,
)
last_stream_seq: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
created_at: Mapped[datetime] = mapped_column(BeijingDateTime(), nullable=False, default=_now)
updated_at: Mapped[datetime] = mapped_column(
BeijingDateTime(), nullable=False, default=_now, onupdate=_now
)
# ── 11. conversation messages ─────────────────────────────────────────────
class MafRoundtableMessageRow(Base):
"""Stable message history for a conversation.
Every user-visible bubble (user message, manager message, seat message,
clarification card, approval card, result card) has exactly one row here.
``seq`` is a conversation-global monotonic counter (unique per conversation)
used for SSE cursor and ordering. ``message_kind`` classifies the bubble
type for the frontend reducer.
``run_id`` links the message to the internal run that produced it (nullable
for user messages that haven't started a run yet). ``agent_id`` identifies
the speaking agent (null for user messages).
"""
__tablename__ = "maf_roundtable_messages"
__table_args__ = (
UniqueConstraint("conversation_id", "seq", name="uq_maf_messages_conv_seq"),
)
id: Mapped[str] = mapped_column(String(64), primary_key=True)
conversation_id: Mapped[str] = mapped_column(
String(64), ForeignKey("maf_roundtable_conversations.id", ondelete="CASCADE"),
nullable=False, index=True,
)
seq: Mapped[int] = mapped_column(Integer, nullable=False)
# user | manager | seat | system | clarification | approval | result | tool_progress
message_kind: Mapped[str] = mapped_column(String(32), nullable=False, default="user")
role: Mapped[str] = mapped_column(String(16), nullable=False, default="user")
agent_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
run_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
# Stable message identity for SSE delta merging (e.g. LangGraph message id).
message_ref: Mapped[str | None] = mapped_column(String(191), nullable=True, index=True)
content: Mapped[str] = mapped_column(PortableLongText, default="")
metadata_json: Mapped[dict | None] = mapped_column(PortableJSON(), nullable=True)
created_at: Mapped[datetime] = mapped_column(BeijingDateTime(), nullable=False, default=_now, index=True)
# ── 12. conversation events (SSE stream, conversation-global seq) ─────────
class MafRoundtableConversationEventRow(Base):
"""Conversation-dimension event stream for SSE.
Unlike ``maf_roundtable_events`` (per-run seq), this table uses a
**conversation-global** monotonic ``seq`` so the frontend only needs one
cursor (``after_seq``) regardless of how many internal runs happen.
The SSE hub replays from this table on connect/reconnect, then subscribes
to real-time notifications.
``event_type`` follows the §4.3 contract (conversation.snapshot,
user.message.created, manager.intent, clarification.required, etc.).
``data_json`` is the already-sanitized event payload safe for the browser.
"""
__tablename__ = "maf_roundtable_conversation_events"
__table_args__ = (
UniqueConstraint("conversation_id", "seq", name="uq_maf_conv_events_conv_seq"),
# P0-4: dedup key for projection idempotency. The source audit event's
# stable id (e.g. LangGraph event id / tool_call id) prevents duplicate
# conversation events when the bridge retries or the adapter re-projects
# after a restart. NULL source_event_ids are not deduped (SQL treats
# NULLs as distinct in UNIQUE constraints).
UniqueConstraint("conversation_id", "source_event_id", name="uq_maf_conv_events_conv_source"),
)
id: Mapped[str] = mapped_column(String(64), primary_key=True)
conversation_id: Mapped[str] = mapped_column(
String(64), ForeignKey("maf_roundtable_conversations.id", ondelete="CASCADE"),
nullable=False, index=True,
)
seq: Mapped[int] = mapped_column(Integer, nullable=False)
run_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
# Stable upstream id from the source audit event (maf_roundtable_events.id
# or the LangGraph source_event_id). Used for projection dedup via the
# (conversation_id, source_event_id) unique constraint.
source_event_id: Mapped[str | None] = mapped_column(String(191), nullable=True, index=True)
event_type: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
data_json: Mapped[dict | None] = mapped_column(PortableJSON(), nullable=True)
created_at: Mapped[datetime] = mapped_column(BeijingDateTime(), nullable=False, default=_now, index=True)
__all__ = [
"MafRoundtableActionRow",
"MafRoundtableActionLeaseRow",
"MafRoundtableActionSecretRow",
"MafRoundtableApprovalRow",
"MafRoundtableConversationEventRow",
"MafRoundtableConversationRow",
"MafRoundtableEventRow",
"MafRoundtableMessageRow",
"MafRoundtableParticipantRow",
"MafRoundtableRunRow",
"MafRoundtableSeatRunRow",
"MafRoundtableSessionRow",
]