mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-10-22 16:35:20 +08:00
feat(persistence): enforce image reference storage contract
- Reject structured inline images on ORM and Core writes - Route plugin run results through the media-aware journal - Redact legacy inline images before merging run metadata - Cover storage contracts and regressions on SQLite and PostgreSQL
This commit is contained in:
@@ -12,6 +12,7 @@ from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
from ...persistence.datetime_utils import as_naive_utc
|
||||
from ...storage.media import MediaCache
|
||||
from ...utils.inflight import inflight_hub
|
||||
from ...utils.execution_lease import host_execution_lease
|
||||
|
||||
@@ -384,7 +385,7 @@ class RunLedgerStore:
|
||||
if run is None or run.workspace_id != workspace_id or run.status in TERMINAL_STATUSES:
|
||||
raise ValueError('Skill activation requires an active run in the current Workspace')
|
||||
original = run.metadata_json
|
||||
metadata = _json_loads(original, {})
|
||||
metadata = MediaCache.redact_inline_images(_json_loads(original, {}))
|
||||
bindings = metadata.setdefault('activated_skills', {})
|
||||
if name in bindings:
|
||||
return str(bindings[name])
|
||||
@@ -439,7 +440,8 @@ class RunLedgerStore:
|
||||
if cost is not None:
|
||||
run.cost_json = _json_dumps(cost)
|
||||
if metadata is not None:
|
||||
existing_metadata = _json_loads(run.metadata_json, {})
|
||||
# Discard legacy image bytes before merging; new metadata still uses the write contract.
|
||||
existing_metadata = MediaCache.redact_inline_images(_json_loads(run.metadata_json, {}))
|
||||
if isinstance(existing_metadata, dict):
|
||||
existing_metadata.update(metadata)
|
||||
run.metadata_json = _json_dumps(existing_metadata)
|
||||
|
||||
@@ -580,7 +580,7 @@ class MonitoringService(ExecutionDetailsMixin):
|
||||
'user_id': _normalize_user_id(user_id),
|
||||
'user_name': user_name,
|
||||
'runner_name': runner_name,
|
||||
'variables': variables,
|
||||
'variables': self._sanitize_message_content(variables) if variables is not None else None,
|
||||
'role': role,
|
||||
'event_id': event_id,
|
||||
'run_id': run_id,
|
||||
@@ -888,7 +888,7 @@ class MonitoringService(ExecutionDetailsMixin):
|
||||
if level is not None:
|
||||
update_values['level'] = level
|
||||
if variables is not None:
|
||||
update_values['variables'] = variables
|
||||
update_values['variables'] = self._sanitize_message_content(variables)
|
||||
|
||||
await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.update(persistence_monitoring.MonitoringMessage)
|
||||
|
||||
@@ -6,6 +6,7 @@ import datetime
|
||||
|
||||
import sqlalchemy
|
||||
|
||||
from ...persistence.media import MediaReferenceText
|
||||
from .base import Base
|
||||
|
||||
|
||||
@@ -107,7 +108,7 @@ class AgentRun(Base):
|
||||
authorization_json = sqlalchemy.Column(sqlalchemy.Text, nullable=True)
|
||||
"""Run-scoped authorization snapshot JSON."""
|
||||
|
||||
metadata_json = sqlalchemy.Column(sqlalchemy.Text, nullable=True)
|
||||
metadata_json = sqlalchemy.Column(MediaReferenceText, nullable=True)
|
||||
"""Additional metadata JSON."""
|
||||
|
||||
__table_args__ = (
|
||||
@@ -185,7 +186,7 @@ class AgentRunEvent(Base):
|
||||
type = sqlalchemy.Column(sqlalchemy.String(100), nullable=False, index=True)
|
||||
"""Result event type."""
|
||||
|
||||
data_json = sqlalchemy.Column(sqlalchemy.Text, nullable=True)
|
||||
data_json = sqlalchemy.Column(MediaReferenceText, nullable=True)
|
||||
"""Result event payload JSON."""
|
||||
|
||||
usage_json = sqlalchemy.Column(sqlalchemy.Text, nullable=True)
|
||||
@@ -197,7 +198,7 @@ class AgentRunEvent(Base):
|
||||
source = sqlalchemy.Column(sqlalchemy.String(50), nullable=True)
|
||||
"""Source that appended the event."""
|
||||
|
||||
metadata_json = sqlalchemy.Column(sqlalchemy.Text, nullable=True)
|
||||
metadata_json = sqlalchemy.Column(MediaReferenceText, nullable=True)
|
||||
"""Additional metadata JSON."""
|
||||
|
||||
__table_args__ = (
|
||||
|
||||
@@ -6,6 +6,7 @@ import sqlalchemy
|
||||
import datetime
|
||||
|
||||
from .base import Base
|
||||
from ...persistence.media import MediaReferenceText
|
||||
|
||||
|
||||
class EventLog(Base):
|
||||
@@ -66,7 +67,7 @@ class EventLog(Base):
|
||||
input_summary = sqlalchemy.Column(sqlalchemy.Text, nullable=True)
|
||||
"""Brief summary of input (truncated text, max 1000 chars)."""
|
||||
|
||||
input_json = sqlalchemy.Column(sqlalchemy.Text, nullable=True)
|
||||
input_json = sqlalchemy.Column(MediaReferenceText, nullable=True)
|
||||
"""Full input JSON if reasonably sized (AgentInput as JSON string)."""
|
||||
|
||||
# Raw event reference
|
||||
@@ -82,7 +83,7 @@ class EventLog(Base):
|
||||
created_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=False, default=datetime.datetime.utcnow)
|
||||
"""When this record was created."""
|
||||
|
||||
metadata_json = sqlalchemy.Column(sqlalchemy.Text, nullable=True)
|
||||
metadata_json = sqlalchemy.Column(MediaReferenceText, nullable=True)
|
||||
"""Additional metadata as JSON string."""
|
||||
|
||||
__table_args__ = (sqlalchemy.Index('ix_event_log_media_ttl', 'workspace_id', 'created_at', 'id'),)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import sqlalchemy
|
||||
|
||||
from ...persistence.media import MediaReferenceText
|
||||
from .base import Base
|
||||
|
||||
|
||||
@@ -19,7 +20,7 @@ class MonitoringMessage(Base):
|
||||
bot_name = sqlalchemy.Column(sqlalchemy.String(255), nullable=False)
|
||||
pipeline_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=False, index=True)
|
||||
pipeline_name = sqlalchemy.Column(sqlalchemy.String(255), nullable=False)
|
||||
message_content = sqlalchemy.Column(sqlalchemy.Text, nullable=False)
|
||||
message_content = sqlalchemy.Column(MediaReferenceText, nullable=False)
|
||||
session_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=False, index=True)
|
||||
execution_owner_id = sqlalchemy.Column(sqlalchemy.String(36), nullable=True, index=True)
|
||||
execution_lease_expires_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=True, index=True)
|
||||
@@ -31,7 +32,7 @@ class MonitoringMessage(Base):
|
||||
user_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True)
|
||||
user_name = sqlalchemy.Column(sqlalchemy.String(255), nullable=True) # User display name
|
||||
runner_name = sqlalchemy.Column(sqlalchemy.String(255), nullable=True) # Runner name for this query
|
||||
variables = sqlalchemy.Column(sqlalchemy.Text, nullable=True) # Query variables as JSON string
|
||||
variables = sqlalchemy.Column(MediaReferenceText, nullable=True) # Query variables as JSON string
|
||||
role = sqlalchemy.Column(sqlalchemy.String(50), nullable=True, default='user') # user, assistant
|
||||
event_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True, index=True)
|
||||
run_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True, index=True)
|
||||
|
||||
@@ -6,6 +6,7 @@ import sqlalchemy
|
||||
import datetime
|
||||
|
||||
from .base import Base
|
||||
from ...persistence.media import MediaReferenceText
|
||||
|
||||
|
||||
class Transcript(Base):
|
||||
@@ -48,11 +49,11 @@ class Transcript(Base):
|
||||
content = sqlalchemy.Column(sqlalchemy.Text, nullable=True)
|
||||
"""Text content summary (may be truncated for large messages, max 4000 chars)."""
|
||||
|
||||
content_json = sqlalchemy.Column(sqlalchemy.Text, nullable=True)
|
||||
content_json = sqlalchemy.Column(MediaReferenceText, nullable=True)
|
||||
"""Full structured content as JSON string (Message model dump)."""
|
||||
|
||||
# Attachment references
|
||||
attachment_refs_json = sqlalchemy.Column(sqlalchemy.Text, nullable=True)
|
||||
attachment_refs_json = sqlalchemy.Column(MediaReferenceText, nullable=True)
|
||||
"""Attachment references as JSON string."""
|
||||
|
||||
# Sequence for cursor-based pagination
|
||||
@@ -69,7 +70,7 @@ class Transcript(Base):
|
||||
created_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=False, default=datetime.datetime.utcnow)
|
||||
"""When this item was created."""
|
||||
|
||||
metadata_json = sqlalchemy.Column(sqlalchemy.Text, nullable=True)
|
||||
metadata_json = sqlalchemy.Column(MediaReferenceText, nullable=True)
|
||||
"""Additional metadata as JSON string (sender_id, platform, etc.)."""
|
||||
|
||||
# Indexes
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
"""The storage contract for conversation payloads containing image references."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import Text
|
||||
from sqlalchemy.engine import Dialect
|
||||
from sqlalchemy.sql.operators import OperatorType
|
||||
from sqlalchemy.types import TypeDecorator
|
||||
|
||||
|
||||
class MediaReferenceText(TypeDecorator[str]):
|
||||
"""Keep the existing TEXT format while rejecting structured inline images.
|
||||
|
||||
Writers must externalize retained images through MediaCache or redact
|
||||
metadata-only images before binding a value. Both ORM and Core writes use
|
||||
this contract; reads preserve historical values for incremental cleanup.
|
||||
Plain text remains valid for fields such as monitoring message content.
|
||||
"""
|
||||
|
||||
impl = Text
|
||||
cache_ok = True
|
||||
|
||||
def process_bind_param(self, value: str | None, dialect: Dialect) -> str | None:
|
||||
if value is None:
|
||||
return None
|
||||
try:
|
||||
payload = json.loads(value)
|
||||
except json.JSONDecodeError:
|
||||
return value
|
||||
|
||||
# Model imports must not initialize the media cache's runtime dependencies.
|
||||
from ..storage.media import MediaCache
|
||||
|
||||
if MediaCache.has_inline_images(payload):
|
||||
raise ValueError(
|
||||
'Inline images cannot be persisted; use MediaCache.externalize_chain_dump() '
|
||||
'for retained content or MediaCache.redact_inline_images() for metadata'
|
||||
)
|
||||
return value
|
||||
|
||||
def coerce_compared_value(self, op: OperatorType | None, value: Any) -> Text:
|
||||
# Legacy payloads must remain usable in searches and compare-and-swap updates.
|
||||
return self.impl
|
||||
@@ -19,6 +19,8 @@ from sqlalchemy.dialects.postgresql.dml import OnConflictDoUpdate as PostgreSQLO
|
||||
from sqlalchemy.dialects.sqlite.dml import OnConflictDoNothing as SQLiteOnConflictDoNothing
|
||||
from sqlalchemy.dialects.sqlite.dml import OnConflictDoUpdate as SQLiteOnConflictDoUpdate
|
||||
|
||||
from .media import MediaReferenceText
|
||||
|
||||
|
||||
TENANT_SETTING = 'langbot.workspace_uuid'
|
||||
ACCOUNT_SETTING = 'langbot.account_uuid'
|
||||
@@ -292,7 +294,7 @@ def _validate_scoped_sql_type(
|
||||
|
||||
if type(sql_type) in {Vector, HALFVEC}:
|
||||
return
|
||||
if not type(sql_type).__module__.startswith('sqlalchemy.'):
|
||||
if type(sql_type) is not MediaReferenceText and not type(sql_type).__module__.startswith('sqlalchemy.'):
|
||||
raise ScopedSessionTransactionError('TenantUnitOfWork does not allow custom SQL types in public statements')
|
||||
|
||||
for identifier_attribute in ('name', 'schema', 'collation'):
|
||||
@@ -486,7 +488,7 @@ def _validate_scoped_statement_call(args: tuple[typing.Any, ...], kwargs: dict[s
|
||||
monitoring_metadata = (
|
||||
type(element.type) is sqlalchemy.JSON
|
||||
and isinstance(source, sqlalchemy.Column)
|
||||
and type(source.type) is sqlalchemy.Text
|
||||
and type(source.type) in {sqlalchemy.Text, MediaReferenceText}
|
||||
and getattr(getattr(source, 'table', None), 'name', None) in {'event_log', 'agent_run'}
|
||||
and (
|
||||
source.name == 'metadata_json' or (source.table.name == 'event_log' and source.name == 'input_json')
|
||||
|
||||
@@ -469,12 +469,16 @@ def register(h):
|
||||
if payload_error:
|
||||
return handler.ActionResponse.error(message=payload_error)
|
||||
|
||||
event = await store.append_event(
|
||||
from ..agent.runner.run_journal import AgentRunJournal
|
||||
|
||||
event = await AgentRunJournal(h.ap).append_run_result(
|
||||
run_id=str(target_run_id),
|
||||
sequence=sequence,
|
||||
event_type=str(event_type),
|
||||
data=event_payload,
|
||||
usage=usage if isinstance(usage, dict) else None,
|
||||
result_dict={
|
||||
'type': str(event_type),
|
||||
'data': event_payload,
|
||||
'usage': usage if isinstance(usage, dict) else None,
|
||||
},
|
||||
source=str(data.get('source') or result.get('source') or 'runner'),
|
||||
metadata=metadata,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user