mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-10-29 06:30:36 +00:00
* feat(channels): generalize IM group targets and add task/goal run notifications
- Generalize proactive group notification capability (proactive_group_notifications), binding validation, and scheduled task group selection across Feishu, QQ, and WeCom while keeping WeChat personal-only.
- Add QQ group active notification delivery via POST /v2/groups/{group_openid}/messages (msg_type=2) and WeCom group active notification delivery via WS Client.sendMessage(chatid, body) with bot_id:group:chatid workspace isolation.
- Generalize notification outbox sources via migration 0043_runtime_im_notices (source_kind, source_key, run_id, nullable task_id/task_run_id) with backward-compatible SQLite/PostgreSQL upgrades.
- Add opt-in ordinary task & goal run IM notifications (run_completed, run_failed, human_input_requested) on the shared outbox, delivery worker, renderer, and settings UI.
* fix(notifications): address review feedback and group/runtime notification edge cases
* test(migrations): update CURRENT_HEAD to 0043_runtime_im_notices across migration chain tests
* fix(channels): preserve group binding names and legacy authorization
Carry owner-chosen group names through existing connect state, keep private bindings out of legacy group fallback, and restore completion metadata retries. Cover real binding and worker finalization paths and synchronize notification docs.
---------
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
137 lines
5.7 KiB
Python
137 lines
5.7 KiB
Python
"""Migration 0034: durable run-event sequence watermark."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import os
|
|
import uuid
|
|
from datetime import UTC, datetime
|
|
from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
|
|
|
|
import pytest
|
|
import sqlalchemy as sa
|
|
from alembic import command
|
|
from alembic.script import ScriptDirectory
|
|
from sqlalchemy.ext.asyncio import create_async_engine
|
|
|
|
from deerflow.persistence import bootstrap
|
|
from deerflow.persistence.postgres_schema import build_asyncpg_connect_args
|
|
|
|
REVISION = "0034_run_event_seq_watermark"
|
|
PREVIOUS = "0033_batch_result_artifact"
|
|
LOGIN_THROTTLE = "0035_login_throttle"
|
|
IDEMPOTENCY = "0036_run_idempotency_request"
|
|
DOCUMENT_SUMMARIES = "0037_project_document_summaries"
|
|
CHANNEL_BINDINGS = "0038_channel_thread_bindings"
|
|
USER_DISABLED = "0039_user_disabled"
|
|
CURRENT_HEAD = "0043_runtime_im_notices"
|
|
TABLE = "run_event_thread_seq"
|
|
pytestmark = pytest.mark.asyncio
|
|
|
|
|
|
async def test_0034_remains_in_the_single_migration_chain():
|
|
script = ScriptDirectory(str(bootstrap._MIGRATIONS_DIR))
|
|
assert script.get_heads() == [CURRENT_HEAD]
|
|
assert script.get_revision(REVISION).down_revision == PREVIOUS
|
|
assert script.get_revision(LOGIN_THROTTLE).down_revision == REVISION
|
|
assert script.get_revision(IDEMPOTENCY).down_revision == LOGIN_THROTTLE
|
|
assert script.get_revision(DOCUMENT_SUMMARIES).down_revision == IDEMPOTENCY
|
|
assert script.get_revision(CHANNEL_BINDINGS).down_revision == DOCUMENT_SUMMARIES
|
|
assert script.get_revision(USER_DISABLED).down_revision == CHANNEL_BINDINGS
|
|
assert len(REVISION) <= 32
|
|
|
|
|
|
def _engine(tmp_path, backend):
|
|
if backend == "sqlite":
|
|
return create_async_engine(f"sqlite+aiosqlite:///{tmp_path / 'migration.db'}"), None
|
|
uri = os.environ.get("TEST_POSTGRES_URI")
|
|
if not uri:
|
|
pytest.skip("requires TEST_POSTGRES_URI (real Postgres migration 0034)")
|
|
parts = urlsplit(uri)
|
|
scheme = "postgresql+asyncpg" if parts.scheme in {"postgres", "postgresql"} else parts.scheme
|
|
query = urlencode([(key, value) for key, value in parse_qsl(parts.query, keep_blank_values=True) if key not in {"sslmode", "channel_binding"}])
|
|
schema = f"run_event_seq_{uuid.uuid4().hex}"
|
|
return create_async_engine(urlunsplit(parts._replace(scheme=scheme, query=query)), connect_args=build_asyncpg_connect_args(schema)), schema
|
|
|
|
|
|
@pytest.mark.parametrize("backend", ["sqlite", "postgres"])
|
|
async def test_0034_backfills_and_round_trips(tmp_path, backend):
|
|
engine, schema = _engine(tmp_path, backend)
|
|
cfg = bootstrap._get_alembic_config(engine, postgres_schema=schema or "")
|
|
try:
|
|
if schema:
|
|
async with engine.begin() as conn:
|
|
await conn.execute(sa.text(f'CREATE SCHEMA "{schema}"'))
|
|
await asyncio.to_thread(command.upgrade, cfg, PREVIOUS)
|
|
|
|
run_events = sa.table(
|
|
"run_events",
|
|
sa.column("thread_id", sa.String(64)),
|
|
sa.column("run_id", sa.String(64)),
|
|
sa.column("user_id", sa.String(64)),
|
|
sa.column("event_type", sa.String(32)),
|
|
sa.column("category", sa.String(16)),
|
|
sa.column("content", sa.Text()),
|
|
sa.column("event_metadata", sa.JSON()),
|
|
sa.column("seq", sa.Integer()),
|
|
sa.column("created_at", sa.DateTime(timezone=True)),
|
|
)
|
|
async with engine.begin() as conn:
|
|
await conn.execute(
|
|
sa.insert(run_events),
|
|
[
|
|
{
|
|
"thread_id": "t1",
|
|
"run_id": "r1",
|
|
"user_id": None,
|
|
"event_type": "human_message",
|
|
"category": "message",
|
|
"content": "",
|
|
"event_metadata": {},
|
|
"seq": 1,
|
|
"created_at": datetime.now(UTC),
|
|
},
|
|
{
|
|
"thread_id": "t1",
|
|
"run_id": "r2",
|
|
"user_id": None,
|
|
"event_type": "human_message",
|
|
"category": "message",
|
|
"content": "",
|
|
"event_metadata": {},
|
|
"seq": 2,
|
|
"created_at": datetime.now(UTC),
|
|
},
|
|
{
|
|
"thread_id": "t2",
|
|
"run_id": "r1",
|
|
"user_id": None,
|
|
"event_type": "human_message",
|
|
"category": "message",
|
|
"content": "",
|
|
"event_metadata": {},
|
|
"seq": 5,
|
|
"created_at": datetime.now(UTC),
|
|
},
|
|
],
|
|
)
|
|
|
|
await asyncio.to_thread(command.upgrade, cfg, REVISION)
|
|
async with engine.connect() as conn:
|
|
assert TABLE in await conn.run_sync(lambda sync: set(sa.inspect(sync).get_table_names()))
|
|
rows = (await conn.execute(sa.text(f"SELECT thread_id, seq FROM {TABLE} ORDER BY thread_id"))).all()
|
|
assert [tuple(row) for row in rows] == [("t1", 2), ("t2", 5)]
|
|
|
|
await asyncio.to_thread(command.downgrade, cfg, PREVIOUS)
|
|
async with engine.connect() as conn:
|
|
assert TABLE not in await conn.run_sync(lambda sync: set(sa.inspect(sync).get_table_names()))
|
|
|
|
await asyncio.to_thread(command.upgrade, cfg, REVISION)
|
|
async with engine.connect() as conn:
|
|
assert TABLE in await conn.run_sync(lambda sync: set(sa.inspect(sync).get_table_names()))
|
|
finally:
|
|
if schema:
|
|
async with engine.begin() as conn:
|
|
await conn.execute(sa.text(f'DROP SCHEMA IF EXISTS "{schema}" CASCADE'))
|
|
await engine.dispose()
|