feat: reconcile legacy message identities
Assistant: codex Assistant-Model: gpt-5.6-sol Assistant-Session: 01a0230c-b06c-7641-808a-e191b6d1da49
This commit is contained in:
parent
ab936a1e98
commit
ed3feed310
13 changed files with 693 additions and 10 deletions
136
hub_core/message_identity.py
Normal file
136
hub_core/message_identity.py
Normal file
|
|
@ -0,0 +1,136 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from hub_core.models.agent_message import AgentMessage
|
||||
from hub_core.models.message_identity_alias import MessageIdentityAlias
|
||||
|
||||
|
||||
class MessageIdentityError(ValueError):
|
||||
"""Base error for governed message identity operations."""
|
||||
|
||||
|
||||
class MessageIdentityConflict(MessageIdentityError):
|
||||
"""An alias is already bound to a different canonical message."""
|
||||
|
||||
|
||||
class CanonicalMessageNotFound(MessageIdentityError):
|
||||
"""The proposed canonical message does not exist."""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class MessageAliasRegistration:
|
||||
alias: str
|
||||
message_id: uuid.UUID
|
||||
created: bool
|
||||
source: str
|
||||
reason: str
|
||||
created_by: str
|
||||
|
||||
|
||||
def canonical_message_id(reference: str) -> uuid.UUID | None:
|
||||
"""Return a UUID only when ``reference`` is already a complete UUID."""
|
||||
|
||||
try:
|
||||
return uuid.UUID(reference)
|
||||
except (AttributeError, TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
async def resolve_message_reference(
|
||||
session: AsyncSession,
|
||||
reference: str,
|
||||
*,
|
||||
alias_model: type[MessageIdentityAlias] | None = MessageIdentityAlias,
|
||||
) -> uuid.UUID | None:
|
||||
"""Resolve a canonical UUID or one exact, explicitly registered alias."""
|
||||
|
||||
canonical = canonical_message_id(reference)
|
||||
if canonical is not None:
|
||||
return canonical
|
||||
if alias_model is None:
|
||||
return None
|
||||
alias = await session.get(alias_model, reference)
|
||||
return alias.message_id if alias is not None else None
|
||||
|
||||
|
||||
async def register_message_alias(
|
||||
session: AsyncSession,
|
||||
*,
|
||||
alias: str,
|
||||
message_id: uuid.UUID,
|
||||
source: str,
|
||||
reason: str,
|
||||
created_by: str,
|
||||
message_model: type[AgentMessage] = AgentMessage,
|
||||
alias_model: type[MessageIdentityAlias] = MessageIdentityAlias,
|
||||
) -> MessageAliasRegistration:
|
||||
"""Register one attributable alias without changing the message row."""
|
||||
|
||||
_validate_alias(alias)
|
||||
source = _required_text("source", source, 255)
|
||||
reason = _required_text("reason", reason, None)
|
||||
created_by = _required_text("created_by", created_by, 100)
|
||||
|
||||
message = await session.get(message_model, message_id)
|
||||
if message is None:
|
||||
raise CanonicalMessageNotFound(f"Canonical message {message_id} not found")
|
||||
|
||||
existing: Any = await session.get(alias_model, alias)
|
||||
if existing is not None:
|
||||
if existing.message_id != message_id:
|
||||
raise MessageIdentityConflict(
|
||||
f"Alias {alias!r} is already mapped to {existing.message_id}"
|
||||
)
|
||||
return MessageAliasRegistration(
|
||||
alias=existing.alias,
|
||||
message_id=existing.message_id,
|
||||
created=False,
|
||||
source=existing.source,
|
||||
reason=existing.reason,
|
||||
created_by=existing.created_by,
|
||||
)
|
||||
|
||||
record = alias_model(
|
||||
alias=alias,
|
||||
message_id=message_id,
|
||||
source=source,
|
||||
reason=reason,
|
||||
created_by=created_by,
|
||||
)
|
||||
session.add(record)
|
||||
await session.commit()
|
||||
return MessageAliasRegistration(
|
||||
alias=alias,
|
||||
message_id=message_id,
|
||||
created=True,
|
||||
source=source,
|
||||
reason=reason,
|
||||
created_by=created_by,
|
||||
)
|
||||
|
||||
|
||||
def _validate_alias(alias: str) -> None:
|
||||
if not isinstance(alias, str) or not alias:
|
||||
raise MessageIdentityError("alias must be a non-empty string")
|
||||
if alias != alias.strip():
|
||||
raise MessageIdentityError("alias must not contain leading or trailing whitespace")
|
||||
if len(alias) > 255:
|
||||
raise MessageIdentityError("alias must be at most 255 characters")
|
||||
if "/" in alias:
|
||||
raise MessageIdentityError("alias must not contain a path separator")
|
||||
if canonical_message_id(alias) is not None:
|
||||
raise MessageIdentityError("canonical UUIDs cannot be registered as aliases")
|
||||
|
||||
|
||||
def _required_text(name: str, value: str, maximum: int | None) -> str:
|
||||
if not isinstance(value, str) or not value.strip():
|
||||
raise MessageIdentityError(f"{name} must be a non-empty string")
|
||||
value = value.strip()
|
||||
if maximum is not None and len(value) > maximum:
|
||||
raise MessageIdentityError(f"{name} must be at most {maximum} characters")
|
||||
return value
|
||||
|
|
@ -0,0 +1,51 @@
|
|||
"""governed legacy message identity aliases
|
||||
|
||||
Revision ID: 0005_message_identity_aliases
|
||||
Revises: 0004_workload_projection
|
||||
Create Date: 2026-08-23
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
revision: str = "0005_message_identity_aliases"
|
||||
down_revision: Union[str, None] = "0004_workload_projection"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"agent_message_identity_aliases",
|
||||
sa.Column("alias", sa.String(255), primary_key=True),
|
||||
sa.Column(
|
||||
"message_id",
|
||||
postgresql.UUID(as_uuid=True),
|
||||
sa.ForeignKey("agent_messages.id", ondelete="RESTRICT"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column("source", sa.String(255), nullable=False),
|
||||
sa.Column("reason", sa.Text(), nullable=False),
|
||||
sa.Column("created_by", sa.String(100), nullable=False),
|
||||
sa.Column(
|
||||
"created_at",
|
||||
sa.DateTime(timezone=True),
|
||||
server_default=sa.text("now()"),
|
||||
nullable=False,
|
||||
),
|
||||
)
|
||||
op.create_index(
|
||||
"ix_agent_message_identity_aliases_message_id",
|
||||
"agent_message_identity_aliases",
|
||||
["message_id"],
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index(
|
||||
"ix_agent_message_identity_aliases_message_id",
|
||||
table_name="agent_message_identity_aliases",
|
||||
)
|
||||
op.drop_table("agent_message_identity_aliases")
|
||||
|
|
@ -4,6 +4,7 @@ from hub_core.models.capability_catalog import CapabilityCatalog
|
|||
from hub_core.models.capability_request import CapabilityRequest
|
||||
from hub_core.models.domain import Domain
|
||||
from hub_core.models.managed_repo import ManagedRepo
|
||||
from hub_core.models.message_identity_alias import MessageIdentityAlias
|
||||
from hub_core.models.progress_event import ProgressEvent
|
||||
from hub_core.models.tpsc import TPSCCatalog, TPSCEntry, TPSCSnapshot
|
||||
|
||||
|
|
@ -14,6 +15,7 @@ __all__ = [
|
|||
"CapabilityRequest",
|
||||
"Domain",
|
||||
"ManagedRepo",
|
||||
"MessageIdentityAlias",
|
||||
"ProgressEvent",
|
||||
"TPSCCatalog",
|
||||
"TPSCEntry",
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import DateTime, ForeignKey, String, Text, text
|
||||
from sqlalchemy import DateTime, ForeignKey, String, Text, func
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
|
|
@ -32,7 +32,7 @@ class AgentMessage(Base):
|
|||
)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
server_default=text("now()"),
|
||||
server_default=func.now(),
|
||||
nullable=False,
|
||||
)
|
||||
|
||||
|
|
|
|||
30
hub_core/models/message_identity_alias.py
Normal file
30
hub_core/models/message_identity_alias.py
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import DateTime, ForeignKey, String, Text, func
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from hub_core.models.base import Base
|
||||
|
||||
|
||||
class MessageIdentityAlias(Base):
|
||||
"""Explicit mapping from an historical reference to a canonical message."""
|
||||
|
||||
__tablename__ = "agent_message_identity_aliases"
|
||||
|
||||
alias: Mapped[str] = mapped_column(String(255), primary_key=True)
|
||||
message_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("agent_messages.id", ondelete="RESTRICT"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
source: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
reason: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
created_by: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
server_default=func.now(),
|
||||
nullable=False,
|
||||
)
|
||||
|
|
@ -1,4 +1,3 @@
|
|||
import uuid
|
||||
from collections.abc import Callable
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
|
@ -8,6 +7,8 @@ from sqlalchemy import select
|
|||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from hub_core.models.agent_message import AgentMessage
|
||||
from hub_core.models.message_identity_alias import MessageIdentityAlias
|
||||
from hub_core.message_identity import resolve_message_reference
|
||||
from hub_core.schemas.agent_message import MessageCreate, MessageRead, MessageReply
|
||||
|
||||
|
||||
|
|
@ -15,13 +16,27 @@ def create_messages_router(
|
|||
get_session: Callable[..., AsyncSession],
|
||||
*,
|
||||
message_model: type[AgentMessage] = AgentMessage,
|
||||
message_alias_model: type[MessageIdentityAlias] | None = MessageIdentityAlias,
|
||||
) -> APIRouter:
|
||||
router = APIRouter(prefix="/messages", tags=["messages"])
|
||||
|
||||
async def _get_message(message_id: uuid.UUID, session: AsyncSession) -> Any:
|
||||
async def _get_message(message_reference: str, session: AsyncSession) -> Any:
|
||||
message_id = await resolve_message_reference(
|
||||
session,
|
||||
message_reference,
|
||||
alias_model=message_alias_model,
|
||||
)
|
||||
if message_id is None:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail=f"Message reference {message_reference!r} not found",
|
||||
)
|
||||
msg = await session.get(message_model, message_id)
|
||||
if msg is None:
|
||||
raise HTTPException(status_code=404, detail=f"Message {message_id} not found")
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail=f"Message reference {message_reference!r} not found",
|
||||
)
|
||||
return msg
|
||||
|
||||
@router.post("/", response_model=MessageRead, status_code=status.HTTP_201_CREATED)
|
||||
|
|
@ -62,18 +77,29 @@ def create_messages_router(
|
|||
|
||||
@router.get("/thread/{thread_id}", response_model=list[MessageRead])
|
||||
async def get_thread(
|
||||
thread_id: uuid.UUID,
|
||||
thread_id: str,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> list[Any]:
|
||||
resolved_thread_id = await resolve_message_reference(
|
||||
session,
|
||||
thread_id,
|
||||
alias_model=message_alias_model,
|
||||
)
|
||||
if resolved_thread_id is None:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail=f"Message reference {thread_id!r} not found",
|
||||
)
|
||||
q = select(message_model).where(
|
||||
(message_model.id == thread_id) | (message_model.thread_id == thread_id)
|
||||
(message_model.id == resolved_thread_id)
|
||||
| (message_model.thread_id == resolved_thread_id)
|
||||
).order_by(message_model.created_at)
|
||||
result = await session.execute(q)
|
||||
return list(result.scalars().all())
|
||||
|
||||
@router.patch("/{message_id}/read", response_model=MessageRead)
|
||||
async def mark_read(
|
||||
message_id: uuid.UUID,
|
||||
message_id: str,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> Any:
|
||||
msg = await _get_message(message_id, session)
|
||||
|
|
@ -85,7 +111,7 @@ def create_messages_router(
|
|||
|
||||
@router.patch("/{message_id}/archive", response_model=MessageRead)
|
||||
async def archive_message(
|
||||
message_id: uuid.UUID,
|
||||
message_id: str,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> Any:
|
||||
msg = await _get_message(message_id, session)
|
||||
|
|
@ -98,7 +124,7 @@ def create_messages_router(
|
|||
|
||||
@router.post("/{message_id}/reply", response_model=MessageRead, status_code=status.HTTP_201_CREATED)
|
||||
async def reply_to_message(
|
||||
message_id: uuid.UUID,
|
||||
message_id: str,
|
||||
body: MessageReply,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> Any:
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ from __future__ import annotations
|
|||
|
||||
import argparse
|
||||
import json
|
||||
import uuid
|
||||
from importlib.resources import files
|
||||
from pathlib import Path
|
||||
from typing import Sequence
|
||||
|
|
@ -44,6 +45,30 @@ def build_parser(settings: RuntimeSettings | None = None) -> argparse.ArgumentPa
|
|||
migration_export.add_argument("--source-revision")
|
||||
migration_export.add_argument("--output", type=Path, required=True)
|
||||
|
||||
message_alias = commands.add_parser(
|
||||
"message-alias",
|
||||
help="Manage explicit historical message identity aliases",
|
||||
)
|
||||
message_alias_commands = message_alias.add_subparsers(
|
||||
dest="message_alias_command",
|
||||
required=True,
|
||||
)
|
||||
message_alias_register = message_alias_commands.add_parser(
|
||||
"register",
|
||||
help="Bind one exact historical reference to an existing canonical message",
|
||||
)
|
||||
message_alias_register.add_argument("--alias", required=True)
|
||||
message_alias_register.add_argument("--message-id", type=uuid.UUID, required=True)
|
||||
message_alias_register.add_argument("--source", required=True)
|
||||
message_alias_register.add_argument("--reason", required=True)
|
||||
message_alias_register.add_argument("--created-by", required=True)
|
||||
message_alias_register.add_argument("--database-url", default=resolved.database_url)
|
||||
message_alias_register.add_argument(
|
||||
"--confirm",
|
||||
action="store_true",
|
||||
help="Confirm the attributable database write",
|
||||
)
|
||||
|
||||
conformance = commands.add_parser(
|
||||
"conformance",
|
||||
help="Run the implemented Tier 2/3 profile against an HTTP runtime",
|
||||
|
|
@ -71,6 +96,8 @@ def main(argv: Sequence[str] | None = None) -> int:
|
|||
return 0
|
||||
if args.command == "migration":
|
||||
return _run_migration(args)
|
||||
if args.command == "message-alias":
|
||||
return _run_message_alias(args)
|
||||
if args.command == "conformance":
|
||||
return _run_conformance(args.base_url, args.timeout, args.as_json)
|
||||
raise AssertionError(f"Unhandled command {args.command}")
|
||||
|
|
@ -147,5 +174,57 @@ def _run_migration(args: argparse.Namespace) -> int:
|
|||
return 0 if report.get("ok", True) else 1
|
||||
|
||||
|
||||
def _run_message_alias(args: argparse.Namespace) -> int:
|
||||
import asyncio
|
||||
from dataclasses import asdict
|
||||
|
||||
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
||||
|
||||
from hub_core.message_identity import MessageIdentityError, register_message_alias
|
||||
|
||||
if args.message_alias_command != "register":
|
||||
raise AssertionError(f"Unhandled message alias command {args.message_alias_command}")
|
||||
if not args.confirm:
|
||||
raise SystemExit("message-alias register requires --confirm")
|
||||
if not args.database_url:
|
||||
raise SystemExit(
|
||||
"message-alias register requires --database-url or HUB_CORE_DATABASE_URL"
|
||||
)
|
||||
|
||||
async def run() -> dict:
|
||||
engine = create_async_engine(_async_database_url(args.database_url))
|
||||
sessions = async_sessionmaker(engine, expire_on_commit=False)
|
||||
try:
|
||||
async with sessions() as session:
|
||||
result = await register_message_alias(
|
||||
session,
|
||||
alias=args.alias,
|
||||
message_id=args.message_id,
|
||||
source=args.source,
|
||||
reason=args.reason,
|
||||
created_by=args.created_by,
|
||||
)
|
||||
report = asdict(result)
|
||||
report["message_id"] = str(result.message_id)
|
||||
report["ok"] = True
|
||||
return report
|
||||
finally:
|
||||
await engine.dispose()
|
||||
|
||||
try:
|
||||
report = asyncio.run(run())
|
||||
except MessageIdentityError as exc:
|
||||
print(json.dumps({"ok": False, "error": str(exc)}, sort_keys=True))
|
||||
return 1
|
||||
print(json.dumps(report, indent=2, sort_keys=True))
|
||||
return 0
|
||||
|
||||
|
||||
def _sync_database_url(database_url: str) -> str:
|
||||
return database_url.replace("postgresql+asyncpg://", "postgresql+psycopg2://")
|
||||
|
||||
|
||||
def _async_database_url(database_url: str) -> str:
|
||||
if database_url.startswith("postgresql://"):
|
||||
return database_url.replace("postgresql://", "postgresql+asyncpg://", 1)
|
||||
return database_url.replace("postgresql+psycopg2://", "postgresql+asyncpg://", 1)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue