diff --git a/backend/app/adapters/schedule/__init__.py b/backend/app/adapters/schedule/__init__.py index e69de29bb..bc75798ac 100644 --- a/backend/app/adapters/schedule/__init__.py +++ b/backend/app/adapters/schedule/__init__.py @@ -0,0 +1,20 @@ +"""Adapters of the schedule context. + +Mostly secondary (driven) adapters -- implementations of the output ports the +domain declares, which the service calls out to: + + scheduled_task_repository.py owned persistence + scheduled_run_repository.py owned persistence + run_launcher.py anti-corruption layer over the run runtime + thread_lookup.py anti-corruption layer over the thread store + +One exception, and the name says so: + + run_completion.py PRIMARY (inbound) -- the run runtime calls it + +It lives here so the context stays in one place rather than beside the other +two primary adapters, which sit next to whatever drives them (the router under +`gateway/routers/schedule/`, the poller under `scheduler/`). Direction is +stated by each module's own first line; a file added here without one is a +file whose direction nobody decided. +""" diff --git a/backend/app/adapters/schedule/run_completion.py b/backend/app/adapters/schedule/run_completion.py new file mode 100644 index 000000000..83007371e --- /dev/null +++ b/backend/app/adapters/schedule/run_completion.py @@ -0,0 +1,102 @@ +"""Primary adapter (inbound) -- the run runtime's completion callback. + +The one file in this package that drives the domain rather than serving it. +Its siblings are secondary adapters the service calls out to; this one is +called *by* the run runtime, the same way the router is called by HTTP and the +poller by its clock. Kept here rather than beside those two so the context +stays in one place, with the direction stated by the name and by this line. + +Its job is the filtering the legacy completion hook did inline. Every run in +the process reaches that callback, so most of them are none of this context's +business, and producing no call at all says exactly that -- not an error, just +nothing to write back. That is why `ScheduleService.handle_run_completion` +carries no guard clauses and never imports ``RunRecord``. + +TODO(hexagonal): this depends on ``RunRecord``, a run-runtime type, rather +than on a contract published by the run context -- that context has not been +through a hexagonal slice yet. When it publishes one (a DTO, not its aggregate +and not its repository), replace ``_to_outcome``. Nothing else moves. +""" + +from __future__ import annotations + +from datetime import UTC, datetime +from typing import TYPE_CHECKING + +from deerflow.domain.schedule.model import RunStatus +from deerflow.domain.schedule.ports import RunOutcome + +if TYPE_CHECKING: + from deerflow.domain.schedule.service import ScheduleService + from deerflow.runtime import RunRecord + +# The runtime reports four terminal states; the domain has three, because +# `timeout` and `error` are the same fact to a scheduled task while +# `interrupted` is deliberately not -- a cancel or same-thread takeover ends +# the task CANCELLED, not FAILED. +_TERMINAL_STATUSES = { + "success": RunStatus.SUCCESS, + "error": RunStatus.FAILED, + "timeout": RunStatus.FAILED, + "interrupted": RunStatus.INTERRUPTED, +} + +_INTERRUPTED_WITHOUT_ERROR = "run was interrupted before completion" + + +class ScheduleRunCompletionListener: + """Turns a finished run into the write-back use case, or into nothing. + + Deciding whether a run is ours and invoking the use case are one + responsibility, not two: "ignore this run" is only meaningful as "do not + call the service", so splitting them left the second half living in the + composition root, where behaviour is not asserted. + """ + + def __init__(self, service: ScheduleService) -> None: + self._service = service + + async def __call__(self, record: RunRecord) -> None: + outcome = self._to_outcome(record) + if outcome is None: + return + await self._service.handle_run_completion(outcome, now=datetime.now(UTC)) + + @staticmethod + def _to_outcome(record: RunRecord) -> RunOutcome | None: + """Translate into domain vocabulary, or `None` to ignore the run. + + `None` when the run is not a scheduled execution (no usable task + metadata, no owner) or has not reached a terminal state yet. + """ + metadata = record.metadata or {} + task_id = metadata.get("scheduled_task_id") + record_id = metadata.get("scheduled_task_run_id") + user_id = record.user_id + # `metadata` is a free-form dict a caller can influence, so the ids are + # type-checked rather than assumed; `user_id` is required because every + # task read is scoped by it. + if not isinstance(task_id, str) or not isinstance(record_id, str) or not user_id: + return None + + status = _TERMINAL_STATUSES.get(str(record.status.value)) + if status is None: + return None + + if status is RunStatus.SUCCESS: + # A stale error left on a successful record must not be written back + # as the task's last_error. + error = None + elif status is RunStatus.INTERRUPTED: + error = record.error or _INTERRUPTED_WITHOUT_ERROR + else: + error = record.error + + return RunOutcome( + task_id=task_id, + record_id=record_id, + run_id=record.run_id, + user_id=user_id, + status=status, + error=error, + ) diff --git a/backend/app/adapters/schedule/run_outcome_mapping.py b/backend/app/adapters/schedule/run_outcome_mapping.py deleted file mode 100644 index 40f0e1b44..000000000 --- a/backend/app/adapters/schedule/run_outcome_mapping.py +++ /dev/null @@ -1,74 +0,0 @@ -"""Boundary mapping (not a port implementation) -- RunRecord -> RunOutcome. - -Unlike its siblings in this package, this module implements no port: it is the -inbound translation the composition root installs on the run runtime's -completion hook, so the domain never imports ``RunRecord``. - -It also owns the filtering the legacy completion hook did inline. Every run in -the process reaches that hook, so most of them are none of this context's -business, and returning ``None`` says exactly that -- not an error, just -nothing to write back. The service is then never called at all, which is why it -has no guard clauses of its own. -""" - -from __future__ import annotations - -from typing import TYPE_CHECKING - -from deerflow.domain.schedule.model import RunStatus -from deerflow.domain.schedule.ports import RunOutcome - -if TYPE_CHECKING: - from deerflow.runtime import RunRecord - -# The runtime reports four terminal states; the domain has three, because -# `timeout` and `error` are the same fact to a scheduled task while -# `interrupted` is deliberately not -- a cancel or same-thread takeover ends -# the task CANCELLED, not FAILED. -_TERMINAL_STATUSES = { - "success": RunStatus.SUCCESS, - "error": RunStatus.FAILED, - "timeout": RunStatus.FAILED, - "interrupted": RunStatus.INTERRUPTED, -} - -_INTERRUPTED_WITHOUT_ERROR = "run was interrupted before completion" - - -def run_outcome_from_record(record: RunRecord) -> RunOutcome | None: - """Translate a finished run into domain vocabulary, or `None` to ignore it. - - `None` is returned when the run is not a scheduled execution (no usable - task metadata, no owner) or has not reached a terminal state yet. - """ - metadata = record.metadata or {} - task_id = metadata.get("scheduled_task_id") - record_id = metadata.get("scheduled_task_run_id") - user_id = record.user_id - # `metadata` is a free-form dict a caller can influence, so the ids are - # type-checked rather than assumed; `user_id` is required because every - # task read is scoped by it. - if not isinstance(task_id, str) or not isinstance(record_id, str) or not user_id: - return None - - status = _TERMINAL_STATUSES.get(str(record.status.value)) - if status is None: - return None - - if status is RunStatus.SUCCESS: - # A stale error left on a successful record must not be written back as - # the task's last_error. - error = None - elif status is RunStatus.INTERRUPTED: - error = record.error or _INTERRUPTED_WITHOUT_ERROR - else: - error = record.error - - return RunOutcome( - task_id=task_id, - record_id=record_id, - run_id=record.run_id, - user_id=user_id, - status=status, - error=error, - ) diff --git a/backend/app/composition.py b/backend/app/composition.py index c2a95cf74..48f876d1d 100644 --- a/backend/app/composition.py +++ b/backend/app/composition.py @@ -26,7 +26,6 @@ from __future__ import annotations from collections.abc import Awaitable, Callable, Mapping from dataclasses import dataclass -from datetime import UTC, datetime from typing import TYPE_CHECKING, Any from deerflow.domain.feedback.service import FeedbackService @@ -98,11 +97,10 @@ def build_run_completion_hook( ) -> Callable[[Any], Awaitable[None]] | None: """Install the schedule context on the run runtime's completion callback. - This is the inbound half of the wiring: the run runtime hands every - finished run to one callback, and `run_outcome_from_record` decides which - of them the schedule context has any business with. Runs that are not - scheduled executions produce no outcome and the service is never called, - which is why it carries no guard clauses of its own. + The inbound half of the wiring, and nothing more: which runs the context + cares about and what it does with them belongs to the adapter, not here. + This function's only decision is the same one the rest of this module + makes -- what to assemble, and what to leave unassembled. Returns ``None`` when there is no service, so the runtime installs no hook at all rather than one that always declines. @@ -110,14 +108,9 @@ def build_run_completion_hook( if schedule_service is None: return None - from app.adapters.schedule.run_outcome_mapping import run_outcome_from_record + from app.adapters.schedule.run_completion import ScheduleRunCompletionListener - async def on_run_completed(record: Any) -> None: - outcome = run_outcome_from_record(record) - if outcome is not None: - await schedule_service.handle_run_completion(outcome, now=datetime.now(UTC)) - - return on_run_completed + return ScheduleRunCompletionListener(schedule_service) def build_schedule_policy(scheduler_config: SchedulerConfig) -> SchedulePolicy: diff --git a/backend/docs/SCHEDULE_DESIGN_zh.md b/backend/docs/SCHEDULE_DESIGN_zh.md index 2541cdd60..057683d75 100644 --- a/backend/docs/SCHEDULE_DESIGN_zh.md +++ b/backend/docs/SCHEDULE_DESIGN_zh.md @@ -57,9 +57,10 @@ flowchart LR subgraph PRIM["✅ 主适配器 · app"] R["gateway/routers/schedule/
router · models"] PL["scheduler/poller.py
轮询时钟"] + RC["adapters/schedule/run_completion.py
运行完成回调"] end subgraph SEC["✅ 从适配器 · app"] - AD["adapters/schedule/
两个仓储 · run_launcher · thread_lookup
run_outcome_mapping"] + AD["adapters/schedule/
两个仓储 · run_launcher · thread_lookup"] end subgraph CR["✅ 组合根"] CO["composition.py
build_domain_services()"] @@ -630,6 +631,7 @@ await service.update_task( | [`gateway/routers/schedule/router.py`](../app/gateway/routers/schedule/router.py) | 10 个 HTTP 端点,只做协议转换 + 领域错误→状态码 | | [`gateway/routers/schedule/models.py`](../app/gateway/routers/schedule/models.py) | 请求/响应模型;响应是白名单,不是 ORM 转储;`schedule_spec` 的进出转换是模型自己的方法 | | [`app/scheduler/poller.py`](../app/scheduler/poller.py) | 轮询时钟 + 启动恢复 | +| [`adapters/schedule/run_completion.py`](../app/adapters/schedule/run_completion.py) | 运行完成回调;过滤掉非本上下文的运行,其余转成 `RunOutcome` 并调用用例。与上面两个同为入站,只是留在上下文包内 | **从适配器** @@ -639,7 +641,6 @@ await service.update_task( | [`adapters/schedule/scheduled_run_repository.py`](../app/adapters/schedule/scheduled_run_repository.py) | 自有持久化;`IntegrityError → ActiveRunConflictError` 的翻译点 | | [`adapters/schedule/run_launcher.py`](../app/adapters/schedule/run_launcher.py) | 防腐层;`ConflictError` / `HTTPException(409)` → `ThreadBusyError` | | [`adapters/schedule/thread_lookup.py`](../app/adapters/schedule/thread_lookup.py) | 防腐层;`check_access(require_existing=True)` | -| [`adapters/schedule/run_outcome_mapping.py`](../app/adapters/schedule/run_outcome_mapping.py) | `RunRecord → RunOutcome \| None`,承接旧完成钩子的内联过滤 | **组合根** diff --git a/backend/tests/test_composition.py b/backend/tests/test_composition.py index 554e11e49..3f6a55cc9 100644 --- a/backend/tests/test_composition.py +++ b/backend/tests/test_composition.py @@ -10,7 +10,7 @@ from __future__ import annotations import pytest -from app.composition import DomainServices, build_domain_services, build_schedule_policy +from app.composition import DomainServices, build_domain_services, build_run_completion_hook, build_schedule_policy from deerflow.config.scheduler_config import SchedulerConfig from deerflow.domain.schedule.model import SchedulePolicy @@ -139,3 +139,30 @@ class TestEveryPortGetsTheRightAdapter: def test_the_policy_reaches_the_service(self): service = _build(object(), SchedulerConfig(max_concurrent_runs=9)).schedule assert service._policy.max_concurrent_runs == 9 + + +class TestRunCompletionHook: + """The inbound half of the wiring. + + What the hook *does* with a run is asserted in + `test_schedule_run_completion.py`; all that is left here is the assembly + decision, which is this module's whole job. + """ + + def test_no_service_installs_no_hook(self): + """`None` rather than a hook that always declines: with no service + there is nothing for the runtime to call, and saying so lets it skip + the callback entirely.""" + assert build_run_completion_hook(None) is None + + def test_a_service_is_wrapped_in_the_inbound_adapter(self): + from app.adapters.schedule.run_completion import ScheduleRunCompletionListener + + hook = build_run_completion_hook(_build(object()).schedule) + assert isinstance(hook, ScheduleRunCompletionListener) + + def test_the_hook_is_bound_to_the_service_it_was_given(self): + """Deliberately white-box, for the same reason as the port assertions + above: a hook wired to the wrong service type-checks cleanly.""" + service = _build(object()).schedule + assert build_run_completion_hook(service)._service is service diff --git a/backend/tests/test_schedule_run_completion.py b/backend/tests/test_schedule_run_completion.py new file mode 100644 index 000000000..c029801b5 --- /dev/null +++ b/backend/tests/test_schedule_run_completion.py @@ -0,0 +1,176 @@ +"""Tests for the run-completion driving adapter. + +Every run in the process reaches the run runtime's completion callback, so +this adapter's first job is deciding which of them the schedule context has +any business with -- the filtering the legacy hook did inline, with four guard +clauses and an `if/elif` chain over runtime strings. A run that is not a +scheduled execution produces no call at all, which is why the service carries +no guard clauses of its own and never imports the run runtime. + +Driven through `__call__` rather than a conversion function: "was the use case +invoked, and with what" is the behaviour, and the previous split -- a pure +converter here plus the invoking closure in the composition root -- left that +second half untested, since the composition root is not where behaviour is +asserted. +""" + +from __future__ import annotations + +from datetime import UTC, datetime + +import pytest + +from app.adapters.schedule.run_completion import ScheduleRunCompletionListener +from deerflow.domain.schedule.model import RunStatus +from deerflow.domain.schedule.ports import RunOutcome +from deerflow.runtime.runs.manager import RunRecord +from deerflow.runtime.runs.schemas import DisconnectMode +from deerflow.runtime.runs.schemas import RunStatus as RuntimeRunStatus + +pytestmark = pytest.mark.asyncio + +TASK_METADATA = { + "scheduled_task_id": "task-1", + "scheduled_task_run_id": "rec-1", + "scheduled_trigger": "scheduled", +} + +_DEFAULT = object() + + +def _record(*, status, metadata=_DEFAULT, user_id="user-1", error=None, run_id="run-1"): + return RunRecord( + run_id=run_id, + thread_id="thread-1", + assistant_id=None, + status=status, + on_disconnect=DisconnectMode.continue_, + metadata=TASK_METADATA if metadata is _DEFAULT else metadata, + user_id=user_id, + error=error, + ) + + +class _RecordingService: + """Stands in for `ScheduleService`, capturing what the use case received.""" + + def __init__(self) -> None: + self.calls: list[tuple[RunOutcome, datetime]] = [] + + async def handle_run_completion(self, outcome: RunOutcome, *, now: datetime) -> None: + self.calls.append((outcome, now)) + + +async def _delivered(record) -> RunOutcome | None: + """The outcome the service was called with, or None when it was not.""" + service = _RecordingService() + await ScheduleRunCompletionListener(service)(record) + return service.calls[0][0] if service.calls else None + + +class TestTerminalStatusMapping: + async def test_success(self): + assert await _delivered(_record(status=RuntimeRunStatus.success)) == RunOutcome( + task_id="task-1", + record_id="rec-1", + run_id="run-1", + user_id="user-1", + status=RunStatus.SUCCESS, + error=None, + ) + + async def test_a_successful_run_carries_no_error_even_if_the_record_has_one(self): + """A stale `error` on a successful record must not be written back as + the task's `last_error`; success is success.""" + outcome = await _delivered(_record(status=RuntimeRunStatus.success, error="stale")) + assert outcome is not None and outcome.error is None + + @pytest.mark.parametrize("status", [RuntimeRunStatus.error, RuntimeRunStatus.timeout]) + async def test_error_and_timeout_both_become_failed(self, status): + outcome = await _delivered(_record(status=status, error="boom")) + assert outcome is not None + assert outcome.status is RunStatus.FAILED + assert outcome.error == "boom" + + async def test_interrupted_is_distinct_from_failed(self): + """Red line: a cancel or same-thread takeover is not an execution + failure -- the task must end CANCELLED, and only INTERRUPTED gets it + there.""" + outcome = await _delivered(_record(status=RuntimeRunStatus.interrupted)) + assert outcome is not None and outcome.status is RunStatus.INTERRUPTED + + async def test_an_interrupt_without_an_error_gets_an_explanatory_one(self): + outcome = await _delivered(_record(status=RuntimeRunStatus.interrupted, error=None)) + assert outcome is not None and outcome.error == "run was interrupted before completion" + + async def test_an_interrupts_own_error_is_preserved(self): + outcome = await _delivered(_record(status=RuntimeRunStatus.interrupted, error="cancelled by user")) + assert outcome is not None and outcome.error == "cancelled by user" + + +class TestFilteredOut: + """None of these reach the service at all -- not as an error, not as a + no-op call it has to recognise.""" + + @pytest.mark.parametrize("status", [RuntimeRunStatus.pending, RuntimeRunStatus.running]) + async def test_a_non_terminal_run_is_ignored(self, status): + assert await _delivered(_record(status=status)) is None + + async def test_an_ordinary_chat_run_is_ignored(self): + """Every run in the process reaches this callback; only scheduled + executions carry the metadata that makes one ours.""" + assert await _delivered(_record(status=RuntimeRunStatus.success, metadata={})) is None + + @pytest.mark.parametrize( + "metadata", + [ + {"scheduled_task_run_id": "rec-1"}, + {"scheduled_task_id": "task-1"}, + {"scheduled_task_id": "task-1", "scheduled_task_run_id": None}, + {"scheduled_task_id": 7, "scheduled_task_run_id": "rec-1"}, + {"scheduled_task_id": "task-1", "scheduled_task_run_id": 7}, + ], + ) + async def test_half_or_mistyped_metadata_is_ignored(self, metadata): + """Both ids are required and both must be strings -- `metadata` is a + free-form dict a client can influence, so its shape is not assumed.""" + assert await _delivered(_record(status=RuntimeRunStatus.success, metadata=metadata)) is None + + @pytest.mark.parametrize("user_id", [None, ""]) + async def test_a_run_without_an_owner_is_ignored(self, user_id): + """Every task read is scoped by user_id; without one there is no + authorized way to look the task up.""" + assert await _delivered(_record(status=RuntimeRunStatus.success, user_id=user_id)) is None + + async def test_none_metadata_is_ignored(self): + """`metadata` is typed as a dict but the legacy hook defended against + `None`; keep that defence rather than rediscovering it in production.""" + assert await _delivered(_record(status=RuntimeRunStatus.success, metadata=None)) is None + + async def test_the_service_is_left_entirely_alone(self): + """Explicitly: not called, rather than called with something falsy. + + This half had no coverage while the invoking closure lived in the + composition root. + """ + service = _RecordingService() + await ScheduleRunCompletionListener(service)(_record(status=RuntimeRunStatus.running)) + assert service.calls == [] + + +class TestUseCaseInvocation: + async def test_the_completion_is_stamped_with_the_current_instant(self): + service = _RecordingService() + before = datetime.now(UTC) + await ScheduleRunCompletionListener(service)(_record(status=RuntimeRunStatus.success)) + after = datetime.now(UTC) + + assert len(service.calls) == 1 + _, now = service.calls[0] + assert now.tzinfo is not None, "the domain is tz-aware throughout" + assert before <= now <= after + + async def test_one_finished_run_produces_exactly_one_call(self): + service = _RecordingService() + await ScheduleRunCompletionListener(service)(_record(status=RuntimeRunStatus.success)) + assert len(service.calls) == 1 diff --git a/backend/tests/test_schedule_run_outcome_mapping.py b/backend/tests/test_schedule_run_outcome_mapping.py deleted file mode 100644 index 65e8152da..000000000 --- a/backend/tests/test_schedule_run_outcome_mapping.py +++ /dev/null @@ -1,122 +0,0 @@ -"""Boundary tests for the RunRecord -> RunOutcome conversion. - -This converter is where the completion hook's inline filtering moved to. The -old `handle_run_completion` opened with four guard clauses and a status -`if/elif` chain over runtime strings; the service now receives a `RunOutcome` -or is never called at all, so all of that lives here and the domain never -imports the run runtime. - -`None` therefore has one meaning: *this run is none of the schedule context's -business*. It is not an error and must never reach the service. -""" - -from __future__ import annotations - -import pytest - -from app.adapters.schedule.run_outcome_mapping import run_outcome_from_record -from deerflow.domain.schedule.model import RunStatus -from deerflow.domain.schedule.ports import RunOutcome -from deerflow.runtime.runs.manager import RunRecord -from deerflow.runtime.runs.schemas import DisconnectMode -from deerflow.runtime.runs.schemas import RunStatus as RuntimeRunStatus - -TASK_METADATA = { - "scheduled_task_id": "task-1", - "scheduled_task_run_id": "rec-1", - "scheduled_trigger": "scheduled", -} - - -_DEFAULT = object() - - -def _record(*, status, metadata=_DEFAULT, user_id="user-1", error=None, run_id="run-1"): - return RunRecord( - run_id=run_id, - thread_id="thread-1", - assistant_id=None, - status=status, - on_disconnect=DisconnectMode.continue_, - metadata=TASK_METADATA if metadata is _DEFAULT else metadata, - user_id=user_id, - error=error, - ) - - -class TestTerminalStatusMapping: - def test_success(self): - outcome = run_outcome_from_record(_record(status=RuntimeRunStatus.success)) - assert outcome == RunOutcome( - task_id="task-1", - record_id="rec-1", - run_id="run-1", - user_id="user-1", - status=RunStatus.SUCCESS, - error=None, - ) - - def test_a_successful_run_carries_no_error_even_if_the_record_has_one(self): - """A stale `error` on a successful record must not be written back as - the task's `last_error`; success is success.""" - outcome = run_outcome_from_record(_record(status=RuntimeRunStatus.success, error="stale")) - assert outcome is not None and outcome.error is None - - @pytest.mark.parametrize("status", [RuntimeRunStatus.error, RuntimeRunStatus.timeout]) - def test_error_and_timeout_both_become_failed(self, status): - outcome = run_outcome_from_record(_record(status=status, error="boom")) - assert outcome is not None - assert outcome.status is RunStatus.FAILED - assert outcome.error == "boom" - - def test_interrupted_is_distinct_from_failed(self): - """Red line: a cancel or same-thread takeover is not an execution - failure -- the task must end CANCELLED, and only INTERRUPTED gets it - there.""" - outcome = run_outcome_from_record(_record(status=RuntimeRunStatus.interrupted)) - assert outcome is not None and outcome.status is RunStatus.INTERRUPTED - - def test_an_interrupt_without_an_error_gets_an_explanatory_one(self): - outcome = run_outcome_from_record(_record(status=RuntimeRunStatus.interrupted, error=None)) - assert outcome is not None and outcome.error == "run was interrupted before completion" - - def test_an_interrupts_own_error_is_preserved(self): - outcome = run_outcome_from_record(_record(status=RuntimeRunStatus.interrupted, error="cancelled by user")) - assert outcome is not None and outcome.error == "cancelled by user" - - -class TestFilteredOut: - @pytest.mark.parametrize("status", [RuntimeRunStatus.pending, RuntimeRunStatus.running]) - def test_a_non_terminal_run_produces_nothing(self, status): - assert run_outcome_from_record(_record(status=status)) is None - - def test_a_run_with_no_metadata_produces_nothing(self): - """An ordinary chat run reaches the same completion hook; it is not a - scheduled execution and must not be written back as one.""" - assert run_outcome_from_record(_record(status=RuntimeRunStatus.success, metadata={})) is None - - @pytest.mark.parametrize( - "metadata", - [ - {"scheduled_task_run_id": "rec-1"}, - {"scheduled_task_id": "task-1"}, - {"scheduled_task_id": "task-1", "scheduled_task_run_id": None}, - {"scheduled_task_id": 7, "scheduled_task_run_id": "rec-1"}, - {"scheduled_task_id": "task-1", "scheduled_task_run_id": 7}, - ], - ) - def test_half_or_mistyped_metadata_produces_nothing(self, metadata): - """Both ids are required and both must be strings -- `metadata` is a - free-form dict a client can influence, so its shape is not assumed.""" - assert run_outcome_from_record(_record(status=RuntimeRunStatus.success, metadata=metadata)) is None - - @pytest.mark.parametrize("user_id", [None, ""]) - def test_a_run_without_an_owner_produces_nothing(self, user_id): - """Every task read is scoped by user_id; without one there is no - authorized way to look the task up.""" - assert run_outcome_from_record(_record(status=RuntimeRunStatus.success, user_id=user_id)) is None - - def test_a_none_metadata_produces_nothing(self): - """`metadata` is typed as a dict but the legacy hook defended against - `None`; keep that defence rather than rediscovering it in production.""" - assert run_outcome_from_record(_record(status=RuntimeRunStatus.success, metadata=None)) is None