refactor(copilot): replace dryRun Boolean column with metadata Json column

Replace the single-purpose `dryRun Boolean` column on `AgentChatSession`
with an extensible `metadata Json` column backed by a typed Pydantic model
(`ChatSessionMetadata`). This avoids DB migrations for future session-level
flags — new fields can be added to the model with defaults.

- Schema: `dryRun Boolean @default(false)` -> `metadata Json @default("{}")`
- Model: `ChatSessionMetadata(dry_run=False)` with convenience property
- API: `SessionDetailResponse.dry_run` -> `.metadata`
- Migration: data-preserving (migrates existing dryRun=true rows)
- All existing `session.dry_run` reads still work via the property
This commit is contained in:
Zamil Majdy
2026-03-27 09:41:25 +07:00
parent 40f6087f52
commit 1e9a8cb072
6 changed files with 64 additions and 19 deletions

View File

@@ -20,6 +20,7 @@ from backend.copilot.executor.utils import enqueue_cancel_task, enqueue_copilot_
from backend.copilot.model import (
ChatMessage,
ChatSession,
ChatSessionMetadata,
append_and_save_message,
create_chat_session,
delete_chat_session,
@@ -124,7 +125,7 @@ class CreateSessionResponse(BaseModel):
id: str
created_at: str
user_id: str | None
dry_run: bool = False
metadata: ChatSessionMetadata = ChatSessionMetadata()
class ActiveStreamInfo(BaseModel):
@@ -145,7 +146,7 @@ class SessionDetailResponse(BaseModel):
active_stream: ActiveStreamInfo | None = None # Present if stream is still active
total_prompt_tokens: int = 0
total_completion_tokens: int = 0
dry_run: bool = False
metadata: ChatSessionMetadata = ChatSessionMetadata()
class SessionSummaryResponse(BaseModel):
@@ -286,7 +287,7 @@ async def create_session(
id=session.session_id,
created_at=session.started_at.isoformat(),
user_id=session.user_id,
dry_run=session.dry_run,
metadata=session.metadata,
)
@@ -435,7 +436,7 @@ async def get_session(
active_stream=active_stream_info,
total_prompt_tokens=total_prompt,
total_completion_tokens=total_completion,
dry_run=session.dry_run,
metadata=session.metadata,
)

View File

@@ -18,7 +18,7 @@ from prisma.types import (
from backend.data import db
from backend.util.json import SafeJson, sanitize_string
from .model import ChatMessage, ChatSession, ChatSessionInfo
from .model import ChatMessage, ChatSession, ChatSessionInfo, ChatSessionMetadata
logger = logging.getLogger(__name__)
@@ -35,7 +35,7 @@ async def get_chat_session(session_id: str) -> ChatSession | None:
async def create_chat_session(
session_id: str,
user_id: str,
dry_run: bool = False,
metadata: ChatSessionMetadata | None = None,
) -> ChatSessionInfo:
"""Create a new chat session in the database."""
data = ChatSessionCreateInput(
@@ -44,7 +44,7 @@ async def create_chat_session(
credentials=SafeJson({}),
successfulAgentRuns=SafeJson({}),
successfulAgentSchedules=SafeJson({}),
dryRun=dry_run,
metadata=SafeJson((metadata or ChatSessionMetadata()).model_dump()),
)
prisma_session = await PrismaChatSession.prisma().create(data=data)
return ChatSessionInfo.from_db(prisma_session)

View File

@@ -46,6 +46,16 @@ def _get_session_cache_key(session_id: str) -> str:
# ===================== Chat data models ===================== #
class ChatSessionMetadata(BaseModel):
"""Typed metadata stored in the ``metadata`` JSON column of ChatSession.
Add new session-level flags here instead of adding DB columns —
no migration required for new fields as long as a default is provided.
"""
dry_run: bool = False
class ChatMessage(BaseModel):
role: str
content: str | None = None
@@ -88,7 +98,12 @@ class ChatSessionInfo(BaseModel):
updated_at: datetime
successful_agent_runs: dict[str, int] = {}
successful_agent_schedules: dict[str, int] = {}
dry_run: bool = False
metadata: ChatSessionMetadata = ChatSessionMetadata()
@property
def dry_run(self) -> bool:
"""Convenience accessor for ``metadata.dry_run``."""
return self.metadata.dry_run
@classmethod
def from_db(cls, prisma_session: PrismaChatSession) -> Self:
@@ -102,6 +117,10 @@ class ChatSessionInfo(BaseModel):
prisma_session.successfulAgentSchedules, default={}
)
# Parse typed metadata from the JSON column.
raw_metadata = _parse_json_field(prisma_session.metadata, default={})
metadata = ChatSessionMetadata.model_validate(raw_metadata or {})
# Calculate usage from token counts.
# NOTE: Per-turn cache_read_tokens / cache_creation_tokens breakdown
# is lost after persistence — the DB only stores aggregate prompt and
@@ -127,7 +146,7 @@ class ChatSessionInfo(BaseModel):
updated_at=prisma_session.updatedAt,
successful_agent_runs=successful_agent_runs,
successful_agent_schedules=successful_agent_schedules,
dry_run=prisma_session.dryRun,
metadata=metadata,
)
@@ -145,7 +164,7 @@ class ChatSession(ChatSessionInfo):
credentials={},
started_at=datetime.now(UTC),
updated_at=datetime.now(UTC),
dry_run=dry_run,
metadata=ChatSessionMetadata(dry_run=dry_run),
)
@classmethod
@@ -533,7 +552,7 @@ async def _save_session_to_db(
await db.create_chat_session(
session_id=session.session_id,
user_id=session.user_id,
dry_run=session.dry_run,
metadata=session.metadata,
)
existing_message_count = 0
@@ -631,7 +650,7 @@ async def create_chat_session(user_id: str, *, dry_run: bool = False) -> ChatSes
await chat_db().create_chat_session(
session_id=session.session_id,
user_id=user_id,
dry_run=dry_run,
metadata=session.metadata,
)
except Exception as e:
logger.error(f"Failed to create session {session.session_id} in database: {e}")

View File

@@ -1,2 +1,13 @@
-- AlterTable
ALTER TABLE "ChatSession" ADD COLUMN "dryRun" BOOLEAN NOT NULL DEFAULT false;
-- Replace dryRun Boolean column with extensible metadata Json column.
-- Migrate existing dryRun values into the new metadata JSON.
-- Step 1: Add the new metadata column with a default empty JSON object.
ALTER TABLE "ChatSession" ADD COLUMN "metadata" JSONB NOT NULL DEFAULT '{}';
-- Step 2: Migrate existing dryRun=true rows into the metadata column.
UPDATE "ChatSession"
SET "metadata" = jsonb_build_object('dry_run', true)
WHERE "dryRun" = true;
-- Step 3: Drop the old dryRun column.
ALTER TABLE "ChatSession" DROP COLUMN "dryRun";

View File

@@ -220,9 +220,9 @@ model ChatSession {
successfulAgentRuns Json @default("{}") // Map of graph_id -> count
successfulAgentSchedules Json @default("{}") // Map of graph_id -> count
// Dry-run mode: when true, all tool calls (run_block, run_agent) are forced
// to use dry-run simulation — no real API calls or side effects.
dryRun Boolean @default(false)
// Extensible session metadata (typed via ChatSessionMetadata in Python).
// Avoids DB migrations for each new flag (e.g. dry_run, future fields).
metadata Json @default("{}")
// Usage tracking
totalPromptTokens Int @default(0)

View File

@@ -8384,6 +8384,14 @@
"required": ["query", "conversation_history", "message_id"],
"title": "ChatRequest"
},
"ChatSessionMetadata": {
"properties": {
"dry_run": { "type": "boolean", "title": "Dry Run", "default": false }
},
"type": "object",
"title": "ChatSessionMetadata",
"description": "Typed metadata stored in the ``metadata`` JSON column of ChatSession.\n\nAdd new session-level flags here instead of adding DB columns —\nno migration required for new fields as long as a default is provided."
},
"ClarificationNeededResponse": {
"properties": {
"type": {
@@ -8529,7 +8537,10 @@
"anyOf": [{ "type": "string" }, { "type": "null" }],
"title": "User Id"
},
"dry_run": { "type": "boolean", "title": "Dry Run", "default": false }
"metadata": {
"$ref": "#/components/schemas/ChatSessionMetadata",
"default": { "dry_run": false }
}
},
"type": "object",
"required": ["id", "created_at", "user_id"],
@@ -12128,7 +12139,10 @@
"title": "Total Completion Tokens",
"default": 0
},
"dry_run": { "type": "boolean", "title": "Dry Run", "default": false }
"metadata": {
"$ref": "#/components/schemas/ChatSessionMetadata",
"default": { "dry_run": false }
}
},
"type": "object",
"required": ["id", "created_at", "updated_at", "user_id", "messages"],