refactor: move workflow execution writes to uow

This commit is contained in:
jxxghp
2026-08-22 13:21:52 +08:00
parent fa2bf1fe4d
commit e61b5168ad
17 changed files with 389 additions and 52 deletions
+115 -1
View File
@@ -4,7 +4,7 @@ from dataclasses import dataclass
import json
from collections.abc import Awaitable
from datetime import datetime
from typing import Any, Callable, Mapping, Optional, Protocol
from typing import Any, Callable, Mapping, Optional, Protocol, TypeVar
WORKFLOW_TRIGGER_TIMER = "timer"
@@ -101,6 +101,117 @@ class UnitOfWork(Protocol):
...
class WorkflowExecutionRepository(Protocol):
"""工作流执行状态写入所需的最小暂存端口。"""
def stage_start(self, workflow_id: int) -> bool:
"""暂存运行中状态。"""
...
def stage_success(
self,
workflow_id: int,
result: Optional[str] = None,
) -> bool:
"""暂存成功状态和执行次数。"""
...
def stage_fail(self, workflow_id: int, result: str) -> bool:
"""暂存失败状态和错误信息。"""
...
def stage_step(
self,
workflow_id: int,
action_id: str,
context: dict[str, Any],
execution_state: Optional[dict[str, Any]] = None,
) -> bool:
"""暂存动作进度和执行上下文。"""
...
def stage_execution_reset(
self,
workflow_id: int,
reset_count: bool = False,
) -> bool:
"""暂存执行状态重置。"""
...
_ExecutionResult = TypeVar("_ExecutionResult")
class WorkflowExecutionCommand:
"""在一个显式 UnitOfWork 中提交单次工作流执行状态变更。"""
def __init__(
self,
*,
repository: WorkflowExecutionRepository,
unit_of_work: UnitOfWork,
) -> None:
"""保存工作流执行仓储和事务端口。"""
self._repository = repository
self._unit_of_work = unit_of_work
def start(self, workflow_id: int) -> bool:
"""提交工作流运行中状态。"""
return self._commit(lambda: self._repository.stage_start(workflow_id))
def success(
self,
workflow_id: int,
result: Optional[str] = None,
) -> bool:
"""提交工作流成功状态。"""
return self._commit(
lambda: self._repository.stage_success(workflow_id, result)
)
def fail(self, workflow_id: int, result: str) -> bool:
"""提交工作流失败状态。"""
return self._commit(
lambda: self._repository.stage_fail(workflow_id, result)
)
def step(
self,
workflow_id: int,
action_id: str,
context: dict[str, Any],
execution_state: Optional[dict[str, Any]] = None,
) -> bool:
"""提交工作流动作进度。"""
return self._commit(
lambda: self._repository.stage_step(
workflow_id,
action_id,
context,
execution_state,
)
)
def reset(self, workflow_id: int, reset_count: bool = False) -> bool:
"""提交工作流执行状态重置。"""
return self._commit(
lambda: self._repository.stage_execution_reset(
workflow_id,
reset_count,
)
)
def _commit(self, operation: Callable[[], _ExecutionResult]) -> _ExecutionResult:
"""提交暂存操作;失败时回滚并原样传播异常。"""
try:
result = operation()
self._unit_of_work.commit()
return result
except Exception:
self._unit_of_work.rollback()
raise
class WorkflowMutationCommand:
"""协调工作流状态、定义、调度和事件注册变更。"""
@@ -175,6 +286,9 @@ class WorkflowMutationCommand:
values["trigger_type"] = WORKFLOW_TRIGGER_TIMER
updated = self._repository.stage_update(workflow_id, values)
if not updated:
self._unit_of_work.rollback()
return WorkflowMutationResult(False, "工作流不存在")
self._commit()
self._remove_timer(updated)
if (
+1 -7
View File
@@ -7,7 +7,7 @@ from sqlalchemy.orm import Mapped, mapped_column
from sqlalchemy.ext.asyncio import AsyncSession
from app.db.base import Base, get_id_column
from app.db.decorators import db_query, db_update, async_db_query, async_db_update
from app.db.decorators import db_query, async_db_query, async_db_update
class Workflow(Base):
@@ -130,7 +130,6 @@ class Workflow(Base):
return result.scalars().first()
@classmethod
@db_update
def update_state(cls, db, wid: int, state: str):
db.execute(update(cls).where(cls.id == wid).values(state=state))
return True
@@ -142,7 +141,6 @@ class Workflow(Base):
return True
@classmethod
@db_update
def start(cls, db, wid: int):
db.execute(update(cls).where(cls.id == wid).values(state='R'))
return True
@@ -154,7 +152,6 @@ class Workflow(Base):
return True
@classmethod
@db_update
def fail(cls, db, wid: int, result: str):
db.execute(update(cls).where(
and_(cls.id == wid, cls.state != "P")
@@ -178,7 +175,6 @@ class Workflow(Base):
return True
@classmethod
@db_update
def success(cls, db, wid: int, result: Optional[str] = None):
db.execute(update(cls).where(
and_(cls.id == wid, cls.state != "P")
@@ -204,7 +200,6 @@ class Workflow(Base):
return True
@classmethod
@db_update
def reset(cls, db, wid: int, reset_count: Optional[bool] = False):
db.execute(update(cls).where(cls.id == wid).values(
state='W',
@@ -230,7 +225,6 @@ class Workflow(Base):
return True
@classmethod
@db_update
def update_current_action(cls, db, wid: int, action_id: str, context: dict,
execution_state: Optional[dict] = None):
workflow = db.execute(select(cls).where(cls.id == wid)).scalars().first()
+103 -2
View File
@@ -1,4 +1,4 @@
from typing import List, Mapping, Tuple, Optional, Any
from typing import List, Mapping, Tuple, Optional, Any, Protocol
from sqlalchemy import delete as sqlalchemy_delete
@@ -6,6 +6,56 @@ from app.db.base import DbOper
from app.db.models.workflow import Workflow
class WorkflowLegacyWriter(Protocol):
"""无显式 Session 的旧 Oper 写入口所需事务服务。"""
def start(self, workflow_id: int) -> bool:
"""提交工作流运行中状态。"""
...
def success(
self,
workflow_id: int,
result: Optional[str] = None,
) -> bool:
"""提交工作流成功状态。"""
...
def fail(self, workflow_id: int, result: str) -> bool:
"""提交工作流失败状态。"""
...
def step(
self,
workflow_id: int,
action_id: str,
context: dict[str, Any],
execution_state: Optional[dict[str, Any]] = None,
) -> bool:
"""提交工作流动作进度。"""
...
def reset(self, workflow_id: int, reset_count: bool = False) -> bool:
"""提交工作流执行状态重置。"""
...
_legacy_writer: Optional[WorkflowLegacyWriter] = None
def configure_workflow_legacy_writer(writer: WorkflowLegacyWriter) -> None:
"""由启动组合根为旧的无 Session Oper 写入口注入事务服务。"""
global _legacy_writer
_legacy_writer = writer
def _get_workflow_legacy_writer() -> WorkflowLegacyWriter:
"""返回已装配的兼容事务服务,避免 Oper 自行创建会话。"""
if _legacy_writer is None:
raise RuntimeError("工作流兼容写服务尚未配置")
return _legacy_writer
class WorkflowOper(DbOper):
"""
工作流管理
@@ -132,24 +182,65 @@ class WorkflowOper(DbOper):
"""
启动
"""
if self._db is None:
return _get_workflow_legacy_writer().start(wid)
return self.stage_start(wid)
def stage_start(self, wid: int) -> bool:
"""在调用方持有的会话中暂存运行中状态。"""
return Workflow.start(self._db, wid)
def success(self, wid: int, result: Optional[str] = None) -> bool:
"""
成功
"""
if self._db is None:
return _get_workflow_legacy_writer().success(wid, result)
return self.stage_success(wid, result)
def stage_success(self, wid: int, result: Optional[str] = None) -> bool:
"""在调用方持有的会话中暂存成功状态。"""
return Workflow.success(self._db, wid, result)
def fail(self, wid: int, result: str) -> bool:
"""
失败
"""
if self._db is None:
return _get_workflow_legacy_writer().fail(wid, result)
return self.stage_fail(wid, result)
def stage_fail(self, wid: int, result: str) -> bool:
"""在调用方持有的会话中暂存失败状态。"""
return Workflow.fail(self._db, wid, result)
def step(self, wid: int, action_id: str, context: dict, execution_state: Optional[dict] = None) -> bool:
def step(
self,
wid: int,
action_id: str,
context: dict[str, Any],
execution_state: Optional[dict[str, Any]] = None,
) -> bool:
"""
步进
"""
if self._db is None:
return _get_workflow_legacy_writer().step(
wid,
action_id,
context,
execution_state,
)
return self.stage_step(wid, action_id, context, execution_state)
def stage_step(
self,
wid: int,
action_id: str,
context: dict[str, Any],
execution_state: Optional[dict[str, Any]] = None,
) -> bool:
"""在调用方持有的会话中暂存动作进度。"""
return Workflow.update_current_action(
self._db,
wid,
@@ -162,4 +253,14 @@ class WorkflowOper(DbOper):
"""
重置
"""
if self._db is None:
return _get_workflow_legacy_writer().reset(wid, reset_count)
return self.stage_execution_reset(wid, reset_count)
def stage_execution_reset(
self,
wid: int,
reset_count: bool = False,
) -> bool:
"""在调用方持有的会话中暂存执行状态重置。"""
return Workflow.reset(self._db, wid, reset_count=reset_count)
+4 -1
View File
@@ -96,7 +96,7 @@ from app.db.oper.message import MessageOper
from app.db.oper.subscribehistory import SubscribeHistoryOper
from app.db.oper.plugindata import PluginDataOper
from app.db.oper.systemconfig import SystemConfigOper
from app.db.oper.workflow import WorkflowOper
from app.db.oper.workflow import WorkflowOper, configure_workflow_legacy_writer
from app.command import CommandChain
from app.schemas.message import Message
from app.schemas.message import MessageType
@@ -113,6 +113,7 @@ from app.startup.subscription import (
)
from app.startup.chain_events import TransactionalChainDurableEventWriter
from app.startup.download_failure import TransactionalDownloadFailureRepository
from app.startup.workflow import TransactionalWorkflowExecutionService
from app.startup.context import AgentChatRuntime, HostRuntime, SubscriptionRuntime
from app.adapters.web.security.access import set_superuser_token_payload_provider
from app.application.security.auth import build_superuser_token_payload
@@ -570,6 +571,8 @@ async def init_modules() -> HostRuntime:
configure_runtime_configuration(host_runtime.configuration)
configure_api_data_runtime(host_runtime.compatibility_api_data)
configure_runtime_data_providers()
workflow_execution = TransactionalWorkflowExecutionService(SessionFactory)
configure_workflow_legacy_writer(workflow_execution)
configure_chain_data_ports(
site=lambda: SiteOper(),
subscribe=lambda: SubscribeOper(),
+71
View File
@@ -0,0 +1,71 @@
"""工作流执行状态事务适配器。"""
from collections.abc import Callable
from typing import Any, TypeVar
from sqlalchemy.orm import Session
from app.application.workflow import WorkflowExecutionCommand
from app.db.oper.workflow import WorkflowOper
from app.db.uow import SqlAlchemyUnitOfWork
_Result = TypeVar("_Result")
class TransactionalWorkflowExecutionService:
"""为每次工作流执行状态写入创建独立短会话和 UnitOfWork。"""
def __init__(self, session_factory: Callable[[], Session]) -> None:
"""保存由启动组合根提供的同步 Session 工厂。"""
self._session_factory = session_factory
def start(self, workflow_id: int) -> bool:
"""以独立事务提交运行中状态。"""
return self._run(lambda command: command.start(workflow_id))
def success(self, workflow_id: int, result: str | None = None) -> bool:
"""以独立事务提交成功状态。"""
return self._run(lambda command: command.success(workflow_id, result))
def fail(self, workflow_id: int, result: str) -> bool:
"""以独立事务提交失败状态。"""
return self._run(lambda command: command.fail(workflow_id, result))
def step(
self,
workflow_id: int,
action_id: str,
context: dict[str, Any],
execution_state: dict[str, Any] | None = None,
) -> bool:
"""以独立事务提交动作进度。"""
return self._run(
lambda command: command.step(
workflow_id,
action_id,
context,
execution_state,
)
)
def reset(self, workflow_id: int, reset_count: bool = False) -> bool:
"""以独立事务提交执行状态重置。"""
return self._run(
lambda command: command.reset(workflow_id, reset_count)
)
def _run(
self,
operation: Callable[[WorkflowExecutionCommand], _Result],
) -> _Result:
"""创建短会话并把提交/回滚交给 Application command。"""
session = self._session_factory()
try:
command = WorkflowExecutionCommand(
repository=WorkflowOper(db=session),
unit_of_work=SqlAlchemyUnitOfWork(session),
)
return operation(command)
finally:
session.close()
+1 -1
View File
@@ -370,7 +370,7 @@ flowchart LR
成功后执行。订阅新增样板由 `startup/subscription.py` 创建独占 Session
`application/subscription/write.py` 决定事务与 post-commit 边界,`SubscribeOper.stage_add()`
只查重、`add``flush`。旧 SDK 显式构造的无会话 Oper 暂留兼容自动短会话,不得被新代码复用。
`transaction-debt-baseline.json` 将存量 178 个 Model 事务装饰器冻结为只降不增低水位。
`transaction-debt-baseline.json` 将存量 168 个 Model 事务装饰器冻结为只降不增低水位。
- 站点、历史、工作流、Agent 会话删除和插件数据重置已经形成同构事务切片;对应 Application
Command/Service 持有 UoWOper 的 `stage_*` 方法只修改当前会话。插件数据重置从
`startup/plugins_initializer.py` 创建独占会话,插件直接使用 `PluginDataOper` 的旧 ABI 仅作兼容。
@@ -772,6 +772,10 @@ ADR 必须逐个映射当前 Event、BackgroundTasks、Scheduler job、Agent tas
- 下载失败冷却切片继续迁移到 `TransactionalDownloadFailureRepository`:Chain 每次读写使用独立短会话,
写成功由显式 `SqlAlchemyUnitOfWork` commit,异常 rollback`DownloadFailure` 查询和记录方法不再拥有
自动会话/提交装饰器。Model decorator 基线继续从 176 降到 174Oper 内显式 commit/rollback 仍为 0。
- Workflow 执行状态切片新增 `WorkflowExecutionCommand` 与短会话事务适配器;运行中、动作进度、成功、
失败和重置均由 Application command 显式 commit/rollback。`WorkflowOper()` 的旧方法名、参数和返回值
继续可用,无 Session 调用委托组合根服务,显式 Session 调用只暂存;同步 Model 自动提交装饰器移除 6 个,
事务低水位从 174 降到 168Oper 仍不创建 Session、也不直接 commit/rollback。
**禁止**:本阶段不引入 Celery、Kafka、RabbitMQ 等新基础设施。
@@ -886,6 +890,9 @@ OTel 初始化只能位于 Startup/AdapterDomain/Application 只依赖 no-op-
**扩展实施记录(2026-08-22**:mypy 目标运行时更新到 Python 3.14,严格清单扩大到 20 个源文件;
新增纳管配置快照和下载失败事务适配器,仍保持零错误、无全局 ignore。
Workflow 执行状态 UoW 切片将 `app/application/workflow.py``app/startup/workflow.py` 纳入 strict 清单,
治理范围扩大到 22 个源文件;事务命令、仓储 Protocol 和短会话适配器保持零错误。
#### ARCH-271:复杂度和端点预算 ratchet
**目标**:阻止大方法继续增长,并让拆分对应真实阶段,而不是机械 helper 化。
+1 -1
View File
@@ -84,7 +84,7 @@ Oper classes accept and return persistence values. Turning a `MediaInfo` or
### Transaction ownership ratchet
- `tests/fixtures/architecture/transaction-debt-baseline.json` records the
existing Model transaction decorators. The current 178 legacy decorators are
existing Model transaction decorators. The current 168 legacy decorators are
migration debt: they may decrease but must never increase or move to a new
Model method.
- New Model methods must not use `db_query`, `db_update`, `async_db_query`, or
+2
View File
@@ -17,6 +17,7 @@ files =
app/runtime/extensions/module/contracts.py,
app/application/outbox.py,
app/application/configuration.py,
app/application/workflow.py,
app/application/chain/context.py,
app/application/chain/durable_events.py,
app/application/subscription/delete.py,
@@ -25,5 +26,6 @@ files =
app/startup/context.py,
app/startup/chain_events.py,
app/startup/download_failure.py,
app/startup/workflow.py,
app/api/context.py,
app/api/dependencies/subscription.py
+6 -1
View File
@@ -108,10 +108,15 @@ def configure_plugin_system_services():
from app.db.oper.transferhistory import TransferHistoryOper
from app.db.oper.transferpending import TransferPendingOper
from app.db.oper.user import UserOper
from app.db.oper.workflow import WorkflowOper
from app.db.oper.workflow import WorkflowOper, configure_workflow_legacy_writer
from app.db.oper.message import MessageOper
from app.db.oper.passkey import PassKeyOper
from app.startup.subscription import TransactionalSubscribeWriter
from app.startup.workflow import TransactionalWorkflowExecutionService
configure_workflow_legacy_writer(
TransactionalWorkflowExecutionService(SessionFactory)
)
configure_api_data_ports(
sync_session=get_db,
+11 -3
View File
@@ -13,8 +13,8 @@
"runtime_to_db": [],
"workflow_to_db": []
},
"edge_count": 6360,
"edge_sha256": "09c923d8b167a889e22829c320e05f8801c62400f672b1386d6524c55b064564",
"edge_count": 6367,
"edge_sha256": "62c413857cd12dbaf4d31e4fad4c6b6c80f7dc747d0d5a74ecb61c44b4996822",
"edges": [
"app -> app.runtime",
"app -> app.runtime.compat",
@@ -6120,6 +6120,7 @@
"app.startup.modules_initializer -> app.startup.managed_resources_initializer",
"app.startup.modules_initializer -> app.startup.outbox",
"app.startup.modules_initializer -> app.startup.subscription",
"app.startup.modules_initializer -> app.startup.workflow",
"app.startup.monitor_initializer -> app.monitor",
"app.startup.outbox -> app.application",
"app.startup.outbox -> app.application.outbox",
@@ -6200,6 +6201,12 @@
"app.startup.subscription -> app.startup.outbox",
"app.startup.transfer_initializer -> app.chain",
"app.startup.transfer_initializer -> app.chain.transfer",
"app.startup.workflow -> app.application",
"app.startup.workflow -> app.application.workflow",
"app.startup.workflow -> app.db",
"app.startup.workflow -> app.db.oper",
"app.startup.workflow -> app.db.oper.workflow",
"app.startup.workflow -> app.db.uow",
"app.startup.workflow_initializer -> app.workflow",
"app.testing -> app.testing.stub",
"app.testing.bootstrap -> app.application",
@@ -6377,7 +6384,7 @@
"app.workflow.actions.transfer_file -> app.workflow",
"app.workflow.actions.transfer_file -> app.workflow.actions"
],
"module_count": 789,
"module_count": 790,
"modules": [
"app",
"app.adapters",
@@ -7146,6 +7153,7 @@
"app.startup.scheduler_initializer",
"app.startup.subscription",
"app.startup.transfer_initializer",
"app.startup.workflow",
"app.startup.workflow_initializer",
"app.testing",
"app.testing.bootstrap",
+2 -32
View File
@@ -4,9 +4,9 @@
"async_db_query": 49,
"async_db_update": 12,
"db_query": 74,
"db_update": 39
"db_update": 33
},
"count": 174,
"count": 168,
"methods": [
{
"decorator": "async_db_query",
@@ -828,11 +828,6 @@
"file": "app/db/models/workflow.py",
"method": "Workflow.async_update_state"
},
{
"decorator": "db_update",
"file": "app/db/models/workflow.py",
"method": "Workflow.fail"
},
{
"decorator": "db_query",
"file": "app/db/models/workflow.py",
@@ -852,31 +847,6 @@
"decorator": "db_query",
"file": "app/db/models/workflow.py",
"method": "Workflow.get_timer_triggered_workflows"
},
{
"decorator": "db_update",
"file": "app/db/models/workflow.py",
"method": "Workflow.reset"
},
{
"decorator": "db_update",
"file": "app/db/models/workflow.py",
"method": "Workflow.start"
},
{
"decorator": "db_update",
"file": "app/db/models/workflow.py",
"method": "Workflow.success"
},
{
"decorator": "db_update",
"file": "app/db/models/workflow.py",
"method": "Workflow.update_current_action"
},
{
"decorator": "db_update",
"file": "app/db/models/workflow.py",
"method": "Workflow.update_state"
}
]
},
+2 -2
View File
@@ -125,8 +125,8 @@ def test_transaction_debt_baseline_is_a_model_and_oper_ratchet() -> None:
baseline = json.loads(baseline_path.read_text(encoding="utf-8"))
assert baseline["schema_version"] == 1
assert baseline["model_decorators"]["count"] == 174
assert sum(baseline["model_decorators"]["by_kind"].values()) == 174
assert baseline["model_decorators"]["count"] == 168
assert sum(baseline["model_decorators"]["by_kind"].values()) == 168
assert baseline["model_transaction_calls"] == {"count": 0, "calls": []}
assert baseline["model_session_factories"] == {"count": 0, "calls": []}
assert baseline["oper_transaction_calls"] == {"count": 0, "calls": []}
+10
View File
@@ -357,6 +357,16 @@ def test_workflow_oper_exposes_lists_and_lifecycle(db):
assert (oper.get(flow.id).state, oper.get(flow.id).run_count) == ("W", 0)
def test_workflow_oper_no_session_uses_configured_uow_writer(db):
"""旧的无 Session Oper 写入口仍可用,但事务由组合根服务持有。"""
flow = db.add(Workflow(**_workflow_kwargs("op-wf-legacy")))
assert WorkflowOper().start(flow.id) is True
db.session.expire_all()
assert WorkflowOper(db=db.session).get(flow.id).state == "R"
def test_workflow_oper_event_list_and_async_accessors(db):
"""
事件触发列表与异步访问器同样可用。
+2
View File
@@ -228,6 +228,8 @@ def test_update_current_action_matches_async_twin(db):
for action in ("a1", "a2", "a1"):
Workflow.update_current_action(db.session, sync_flow.id, action, {})
# 同步 Model 方法只暂存 SQL;由测试持有的事务边界先提交,避免与异步会话争锁。
db.session.commit()
asyncio.run(Workflow.async_update_current_action(
wid=async_flow.id, action_id=action, context={}))
+3 -1
View File
@@ -25,8 +25,10 @@ def test_mypy_gate_has_explicit_strict_scope_without_global_ignore() -> None:
assert "app/runtime/extensions/module/contracts.py" in governed_files
assert "app/startup/context.py" in governed_files
assert "app/startup/download_failure.py" in governed_files
assert "app/startup/workflow.py" in governed_files
assert "app/application/workflow.py" in governed_files
assert "app/api/context.py" in governed_files
assert len(governed_files) >= 20
assert len(governed_files) >= 22
assert any(path.startswith("app/domain/") for path in governed_files)
assert "ignore_errors" not in MYPY_CONFIG.read_text(encoding="utf-8")
+48
View File
@@ -5,6 +5,7 @@ import pytest
from app.application.workflow import (
WorkflowDefinitionCommand,
WorkflowExecutionCommand,
WorkflowMutationCommand,
WorkflowQueryService,
)
@@ -44,6 +45,53 @@ def _command(workflow=None, commit_error=None):
return WorkflowMutationCommand(**dependencies), dependencies
def _execution_command(commit_error=None):
"""构造可观察的工作流执行状态事务命令。"""
repository = Mock()
repository.stage_start = Mock(return_value=True)
repository.stage_success = Mock(return_value=True)
repository.stage_fail = Mock(return_value=True)
repository.stage_step = Mock(return_value=True)
repository.stage_execution_reset = Mock(return_value=True)
unit_of_work = Mock()
unit_of_work.commit = Mock(side_effect=commit_error)
unit_of_work.rollback = Mock()
return WorkflowExecutionCommand(
repository=repository,
unit_of_work=unit_of_work,
), repository, unit_of_work
def test_execution_step_is_staged_before_unit_of_work_commit():
"""工作流进度写入必须由应用命令暂存后统一提交。"""
command, repository, unit_of_work = _execution_command()
result = command.step(7, "action-1", {"value": 1}, {"runtime": {}})
assert result is True
repository.stage_step.assert_called_once_with(
7,
"action-1",
{"value": 1},
{"runtime": {}},
)
unit_of_work.commit.assert_called_once_with()
unit_of_work.rollback.assert_not_called()
def test_execution_commit_failure_rolls_back():
"""执行状态提交失败时必须回滚并保留原始异常。"""
command, repository, unit_of_work = _execution_command(
RuntimeError("commit failed")
)
with pytest.raises(RuntimeError, match="commit failed"):
command.fail(7, "failed")
repository.stage_fail.assert_called_once_with(7, "failed")
unit_of_work.rollback.assert_called_once_with()
@pytest.mark.asyncio
async def test_workflow_query_service_delegates_list_and_get_to_repository():
"""工作流查询服务只调用读取端口,不持有数据库会话或事务。"""