mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-10-27 21:50:36 +00:00
* feat(scheduler): create conversation schedules and support agent self-stop * fix(scheduler): validate goals and trial requests before dispatch --------- Co-authored-by: Totoro-qaq <279883115+Totoro-qaq@users.noreply.github.com>
530 lines
27 KiB
Python
530 lines
27 KiB
Python
"""Gateway schedule grants retain the owner's permissions and origin scope."""
|
|
|
|
from datetime import UTC, datetime, timedelta
|
|
from types import SimpleNamespace
|
|
from unittest.mock import AsyncMock
|
|
|
|
import pytest
|
|
from _router_auth_helpers import call_unwrapped
|
|
|
|
from app.gateway.auth_disabled import AUTH_SOURCE_AUTH_DISABLED, AUTH_SOURCE_INTERNAL, AUTH_SOURCE_PAT, AUTH_SOURCE_SESSION
|
|
from app.gateway.authz import AuthContext
|
|
from app.gateway.routers import scheduled_tasks
|
|
from deerflow.agents.interaction_policy import RunInteractionMode, RunInteractionPolicy
|
|
from deerflow.config.app_config import AppConfig
|
|
from deerflow.persistence.scheduled_tasks import ActiveScheduledTaskMutationConflict
|
|
|
|
_PERMISSIONS = ("threads:read", "threads:write", "runs:create")
|
|
|
|
|
|
def _user(*, user_id="alice", token_version=0, role="user"):
|
|
return SimpleNamespace(id=user_id, token_version=token_version, system_role=role, needs_setup=False)
|
|
|
|
|
|
def _task(*, user_id="alice", origin="origin", **updates):
|
|
return {"id": "task-1", "user_id": user_id, "origin_thread_id": origin, "status": "enabled", "schedule_type": "interval", "schedule_spec": {"every_seconds": 3600}, "goal_objective": None, "standing_notes": [], **updates}
|
|
|
|
|
|
def _setup(monkeypatch, *, source=AUTH_SOURCE_SESSION, permissions=_PERMISSIONS, original_text="Run this task now", mode="interactive", task=None, enabled=True, tool_enabled=True):
|
|
from app.gateway import scheduled_task_access as access
|
|
|
|
user = _user()
|
|
repo = SimpleNamespace(
|
|
get=AsyncMock(return_value=task or _task()),
|
|
list_by_origin_thread=AsyncMock(return_value=[task or _task()]),
|
|
create=AsyncMock(side_effect=lambda **values: {"id": values["task_id"], "status": "enabled", **values}),
|
|
pause_with_queue_cancellation=AsyncMock(return_value="paused"),
|
|
delete_with_queue_cancellation=AsyncMock(return_value="deleted"),
|
|
append_standing_note=AsyncMock(return_value=task or _task(standing_notes=["use develop"])),
|
|
)
|
|
occurrences = SimpleNamespace(request_stop=AsyncMock(return_value=True))
|
|
service = SimpleNamespace(dispatch_task=AsyncMock(return_value={"outcome": "launched", "run_id": "trial-run"}))
|
|
threads = SimpleNamespace(get=AsyncMock(return_value={"user_id": "alice"}), check_access=AsyncMock(return_value=True))
|
|
provider = SimpleNamespace(get_user=AsyncMock(return_value=user))
|
|
config = AppConfig.model_validate({"sandbox": {"use": "deerflow.sandbox.local:LocalSandboxProvider"}, "scheduler": {"enabled": enabled, "tool_enabled": tool_enabled}})
|
|
request = SimpleNamespace(state=SimpleNamespace(auth_source=source, user=user, auth=AuthContext(user, list(permissions))), app=SimpleNamespace(state=SimpleNamespace(scheduled_task_service=service)))
|
|
monkeypatch.setattr(access, "get_config", lambda: config)
|
|
monkeypatch.setattr(access, "get_scheduled_task_repo", lambda _request: repo)
|
|
monkeypatch.setattr(access, "get_scheduled_task_run_repo", lambda _request: occurrences)
|
|
monkeypatch.setattr(access, "get_scheduled_task_service", lambda _request: service)
|
|
monkeypatch.setattr(access, "get_thread_store", lambda _request: threads)
|
|
monkeypatch.setattr(access, "get_local_provider", lambda: provider)
|
|
resolve = AsyncMock(return_value=list(permissions))
|
|
monkeypatch.setattr(access, "resolve_route_permissions", resolve)
|
|
|
|
async def prepare(**updates):
|
|
return await access.prepare_scheduler_capability(
|
|
request,
|
|
user=user,
|
|
thread_id="origin",
|
|
run_id="run-1",
|
|
assistant_id="lead_agent",
|
|
original_user_text=original_text,
|
|
interaction_policy=RunInteractionPolicy(RunInteractionMode(mode)),
|
|
**updates,
|
|
)
|
|
|
|
return prepare, repo, occurrences, service, threads, provider, resolve, request
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize("source", [None, AUTH_SOURCE_PAT, "unknown"])
|
|
async def test_untrusted_auth_sources_cannot_receive_a_schedule_grant(monkeypatch, source):
|
|
prepare, *_ = _setup(monkeypatch, source=source)
|
|
assert await prepare() is None
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize(("enabled", "tool_enabled"), [(False, True), (True, False)])
|
|
async def test_operator_flags_withhold_the_gateway_grant(monkeypatch, enabled, tool_enabled):
|
|
prepare, *_ = _setup(monkeypatch, enabled=enabled, tool_enabled=tool_enabled)
|
|
assert await prepare() is None
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_capability_requires_the_running_scheduler_service(monkeypatch):
|
|
from fastapi import HTTPException
|
|
|
|
from app.gateway import scheduled_task_access as access
|
|
|
|
prepare, *_ = _setup(monkeypatch)
|
|
monkeypatch.setattr(access, "get_scheduled_task_service", lambda _request: (_ for _ in ()).throw(HTTPException(503)))
|
|
assert await prepare() is None
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize("mode", ["scheduled", "autonomous", "webhook"])
|
|
async def test_noninteractive_runs_without_private_occurrence_authority_get_no_grant(monkeypatch, mode):
|
|
prepare, *_ = _setup(monkeypatch, mode=mode)
|
|
assert await prepare() is None
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_session_principal_and_permission_ceiling_cannot_be_widened(monkeypatch):
|
|
prepare, repo, _, _, _, _, resolve, request = _setup(monkeypatch, permissions=("threads:read",))
|
|
capability = await prepare()
|
|
resolve.return_value = list(_PERMISSIONS)
|
|
result = await capability.manage(action="create", request={})
|
|
assert result["status_code"] == 403
|
|
repo.create.assert_not_awaited()
|
|
request.state.auth.user = _user(user_id="bob")
|
|
assert await prepare() is None
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize("owner", [None, _user(user_id="bob"), _user(token_version=1)])
|
|
async def test_deleted_changed_or_revoked_owner_cannot_use_an_existing_grant(monkeypatch, owner):
|
|
prepare, repo, _, _, _, provider, _, _ = _setup(monkeypatch)
|
|
capability = await prepare()
|
|
provider.get_user.return_value = owner
|
|
result = await capability.manage(action="list", request={})
|
|
assert result["status_code"] == 401
|
|
repo.list_by_origin_thread.assert_not_awaited()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_live_permission_revocation_is_honored_each_action(monkeypatch):
|
|
prepare, repo, _, _, _, _, resolve, _ = _setup(monkeypatch)
|
|
capability = await prepare()
|
|
resolve.return_value = []
|
|
result = await capability.manage(action="list", request={})
|
|
assert result["status_code"] == 403
|
|
repo.list_by_origin_thread.assert_not_awaited()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize(("action", "permission"), [("list", "threads:read"), ("create", "runs:create"), ("pause", "threads:write"), ("delete", "threads:write"), ("note", "threads:write"), ("trial", "runs:create")])
|
|
async def test_each_action_checks_its_route_permissions(monkeypatch, action, permission):
|
|
prepare, repo, _, service, *_ = _setup(monkeypatch, permissions=tuple(value for value in _PERMISSIONS if value != permission))
|
|
capability = await prepare()
|
|
result = await capability.manage(action=action, request={"task_id": "task-1"})
|
|
assert result["status_code"] == 403
|
|
repo.get.assert_not_awaited()
|
|
repo.create.assert_not_awaited()
|
|
service.dispatch_task.assert_not_awaited()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize("source_owner", [None, "bob"])
|
|
async def test_deleted_shared_or_foreign_origin_is_not_a_management_grant(monkeypatch, source_owner):
|
|
prepare, _, _, _, threads, *_ = _setup(monkeypatch)
|
|
threads.get.return_value = None if source_owner is None else {"user_id": source_owner}
|
|
assert await prepare() is None
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize("task", [_task(user_id="bob"), _task(origin="other-origin"), _task(origin=None)])
|
|
@pytest.mark.parametrize("action", ["pause", "delete", "note", "trial"])
|
|
async def test_management_targets_require_both_owner_and_origin(monkeypatch, task, action):
|
|
prepare, repo, _, service, *_ = _setup(monkeypatch, task=task, original_text="use develop")
|
|
capability = await prepare()
|
|
payload = {"task_id": "task-1", **({"note": "use develop"} if action == "note" else {})}
|
|
result = await capability.manage(action=action, request=payload)
|
|
assert result["status_code"] == 404
|
|
repo.pause_with_queue_cancellation.assert_not_awaited()
|
|
repo.delete_with_queue_cancellation.assert_not_awaited()
|
|
repo.append_standing_note.assert_not_awaited()
|
|
service.dispatch_task.assert_not_awaited()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_list_projects_only_safe_fields_from_the_bound_origin(monkeypatch):
|
|
prepare, repo, *_ = _setup(monkeypatch, task=_task(secret="must-not-leak", lease_owner="private-worker", standing_notes=["use develop"]))
|
|
capability = await prepare()
|
|
result = await capability.manage(action="list", request={})
|
|
repo.list_by_origin_thread.assert_awaited_once_with("alice", "origin")
|
|
assert result["tasks"][0]["standing_notes"] == ["use develop"]
|
|
assert "secret" not in result["tasks"][0]
|
|
assert "lease_owner" not in result["tasks"][0]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_create_uses_bound_origin_and_echoes_goal_and_stop_details(monkeypatch):
|
|
prepare, repo, *_ = _setup(monkeypatch)
|
|
capability = await prepare()
|
|
result = await capability.manage(
|
|
action="create",
|
|
request={"title": "Report", "prompt": "Write the report", "schedule_type": "interval", "schedule_spec": {"every_seconds": 3600}, "timezone": "UTC", "goal_objective": "Write a report from the source documents", "max_runs": 3},
|
|
)
|
|
values = repo.create.await_args.kwargs
|
|
assert values["user_id"] == "alice"
|
|
assert values["origin_thread_id"] == "origin"
|
|
assert values["thread_id"] is None
|
|
assert values["assistant_id"] == "lead_agent"
|
|
assert result["goal_objective"] == "Write a report from the source documents"
|
|
assert result["max_runs"] == 3
|
|
assert result["next_run_at"] is not None
|
|
assert "stop_instructions" in result
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize("objective", ["", " \t\n ", "g" * 4001])
|
|
async def test_create_rejects_invalid_goal_before_persisting_schedule(monkeypatch, objective):
|
|
prepare, repo, *_ = _setup(monkeypatch)
|
|
capability = await prepare()
|
|
result = await capability.manage(action="create", request={"title": "Report", "prompt": "Write report", "schedule_type": "interval", "schedule_spec": {"every_seconds": 3600}, "timezone": "UTC", "goal_objective": objective})
|
|
assert result["status_code"] == 422
|
|
repo.create.assert_not_awaited()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize("objective", [None, " " + "g" * 4000 + "\t\n"])
|
|
async def test_create_preserves_optional_and_normalized_boundary_goal(monkeypatch, objective):
|
|
prepare, repo, *_ = _setup(monkeypatch)
|
|
capability = await prepare()
|
|
result = await capability.manage(action="create", request={"title": "Report", "prompt": "Write report", "schedule_type": "interval", "schedule_spec": {"every_seconds": 3600}, "timezone": "UTC", "goal_objective": objective})
|
|
assert "error" not in result
|
|
assert result["goal_objective"] == objective
|
|
assert repo.create.await_args.kwargs["goal_objective"] == objective
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize("payload", [{"user_id": "bob"}, {"thread_id": "other-origin"}, {"assistant_id": "other-agent"}, {"origin_thread_id": "other-origin"}])
|
|
async def test_model_identity_fields_are_rejected_even_if_directly_called(monkeypatch, payload):
|
|
prepare, repo, *_ = _setup(monkeypatch)
|
|
capability = await prepare()
|
|
result = await capability.manage(action="create", request=payload)
|
|
assert result["status_code"] == 422
|
|
repo.create.assert_not_awaited()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize("schedule_type,schedule_spec", [("interval", {"every_seconds": 60}), ("cron", {"cron": "*/5 * * * *"}), ("cron", {"cron": "0,30 * * * *"})])
|
|
async def test_frequent_conversation_schedules_require_a_user_end_condition(monkeypatch, schedule_type, schedule_spec):
|
|
prepare, repo, *_ = _setup(monkeypatch)
|
|
capability = await prepare()
|
|
result = await capability.manage(action="create", request={"title": "Check", "prompt": "Check sources", "schedule_type": schedule_type, "schedule_spec": schedule_spec, "timezone": "UTC"})
|
|
assert result["status_code"] == 422
|
|
repo.create.assert_not_awaited()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_reused_thread_goal_is_rejected(monkeypatch):
|
|
prepare, repo, *_ = _setup(monkeypatch)
|
|
capability = await prepare()
|
|
result = await capability.manage(
|
|
action="create", request={"title": "Report", "prompt": "Write report", "schedule_type": "interval", "schedule_spec": {"every_seconds": 3600}, "timezone": "UTC", "context_mode": "reuse_thread", "goal_objective": "Write report"}
|
|
)
|
|
assert result["status_code"] == 422
|
|
repo.create.assert_not_awaited()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize("bounds", [{"goal_objective": " "}, {"max_runs": True}, {"end_at": "2025-01-01T00:00:00+00:00"}, {"end_at": "2027-01-01T00:00:00"}, {"end_at": "2027-01-01T00:00:00+00:00"}])
|
|
async def test_create_rejects_unusable_goal_and_end_bounds(monkeypatch, bounds):
|
|
prepare, repo, *_ = _setup(monkeypatch)
|
|
capability = await prepare()
|
|
definition = {"title": "Report", "prompt": "Write report", "schedule_type": "once", "schedule_spec": {"run_at": "2027-01-01T00:00:00+00:00"}, "timezone": "UTC", **bounds}
|
|
result = await capability.manage(action="create", request=definition)
|
|
assert result["status_code"] == 422
|
|
repo.create.assert_not_awaited()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize("note", ["inferred correction", "", "x" * 501])
|
|
async def test_notes_require_bounded_verbatim_current_user_words(monkeypatch, note):
|
|
prepare, repo, *_ = _setup(monkeypatch, original_text="For this task, use develop.")
|
|
capability = await prepare()
|
|
result = await capability.manage(action="note", request={"task_id": "task-1", "note": note})
|
|
assert result["status_code"] == 422
|
|
repo.append_standing_note.assert_not_awaited()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_note_keeps_the_users_exact_text_and_repository_guard(monkeypatch):
|
|
prepare, repo, *_ = _setup(monkeypatch, original_text="For this task: use develop ")
|
|
capability = await prepare()
|
|
await capability.manage(action="note", request={"task_id": "task-1", "note": " use develop "})
|
|
repo.append_standing_note.assert_awaited_once_with("task-1", user_id="alice", origin_thread_id="origin", note=" use develop ")
|
|
repo.append_standing_note.side_effect = ActiveScheduledTaskMutationConflict("running")
|
|
result = await capability.manage(action="note", request={"task_id": "task-1", "note": "use develop"})
|
|
assert result["status_code"] == 409
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_trial_requires_a_current_user_turn_and_uses_manual_dispatch(monkeypatch):
|
|
prepare, _, _, service, *_ = _setup(monkeypatch, original_text="")
|
|
capability = await prepare()
|
|
result = await capability.manage(action="trial", request={"task_id": "task-1"})
|
|
assert result["status_code"] == 403
|
|
service.dispatch_task.assert_not_awaited()
|
|
prepare, _, _, service, *_ = _setup(monkeypatch, original_text="Yes, run it now.")
|
|
capability = await prepare()
|
|
result = await capability.manage(action="trial", request={"task_id": "task-1"})
|
|
assert result["triggered"] is True
|
|
assert service.dispatch_task.await_args.kwargs["trigger"] == "manual"
|
|
service.dispatch_task.return_value = {"outcome": "failed", "error": "cannot launch"}
|
|
result = await capability.manage(action="trial", request={"task_id": "task-1"})
|
|
assert result["status_code"] == 502
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize(
|
|
"user_text",
|
|
[
|
|
"List my tasks",
|
|
"What is a trial?",
|
|
"task-1",
|
|
"Report",
|
|
"Don't run it now.",
|
|
"Do not run this task now",
|
|
"不要试跑",
|
|
"不要先跑一次",
|
|
"Not now; run it once after I approve",
|
|
"If I approve, run it now",
|
|
'The file says "Run it now"',
|
|
'"Run it now"',
|
|
"What does 先跑一次 mean?",
|
|
"Yes",
|
|
"Run it now?",
|
|
"run task-2 now",
|
|
"Run Other report now",
|
|
"Run it now and then delete all tasks",
|
|
"先跑一次,但不要真的执行",
|
|
],
|
|
)
|
|
async def test_trial_rejects_unrelated_negative_quoted_or_ambiguous_turns(monkeypatch, user_text):
|
|
prepare, _, _, service, *_ = _setup(monkeypatch, original_text=user_text, task=_task(title="Report"))
|
|
capability = await prepare()
|
|
result = await capability.manage(action="trial", request={"task_id": "task-1"})
|
|
assert result["status_code"] == 403
|
|
service.dispatch_task.assert_not_awaited()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize("user_text", ["Run it now", "Yes, run it now.", "Please run this task once", "Run task-1 now", "先跑一次", "请先试跑一次。", "可以,先跑一次", "先试跑task-1", "现在运行task-1一次"])
|
|
async def test_trial_accepts_direct_requests_in_the_current_user_turn(monkeypatch, user_text):
|
|
prepare, _, _, service, *_ = _setup(monkeypatch, original_text=user_text, task=_task(title="Report"))
|
|
capability = await prepare()
|
|
result = await capability.manage(action="trial", request={"task_id": "task-1"})
|
|
assert result["triggered"] is True
|
|
assert service.dispatch_task.await_args.kwargs["trigger"] == "manual"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize(
|
|
("title", "user_text"), [("周报,但不要真的执行", "先试跑周报,但不要真的执行"), ("周报如果我以后批准", "现在试跑周报如果我以后批准"), ("it now and then delete all tasks", "Run it now and then delete all tasks once")]
|
|
)
|
|
async def test_trial_never_uses_task_title_as_authorization_text(monkeypatch, title, user_text):
|
|
prepare, _, _, service, *_ = _setup(monkeypatch, original_text=user_text, task=_task(title=title))
|
|
capability = await prepare()
|
|
result = await capability.manage(action="trial", request={"task_id": "task-1"})
|
|
assert result["status_code"] == 403
|
|
service.dispatch_task.assert_not_awaited()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize("action", ["create", "pause"])
|
|
async def test_tool_quota_conflicts_use_409_without_dispatch(monkeypatch, action):
|
|
from deerflow.persistence.scheduled_tasks import ScheduledTaskQuotaExceeded
|
|
|
|
prepare, repo, _, service, *_ = _setup(monkeypatch)
|
|
capability = await prepare()
|
|
request = {"task_id": "task-1"}
|
|
if action == "create":
|
|
request = {"title": "Report", "prompt": "Write report", "schedule_type": "interval", "schedule_spec": {"every_seconds": 3600}, "timezone": "UTC"}
|
|
repo.create.side_effect = ScheduledTaskQuotaExceeded()
|
|
else:
|
|
repo.pause_with_queue_cancellation.side_effect = ScheduledTaskQuotaExceeded()
|
|
result = await capability.manage(action=action, request=request)
|
|
assert result["status_code"] == 409
|
|
service.dispatch_task.assert_not_awaited()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_internal_im_grant_uses_actual_owners_noninternal_permissions(monkeypatch):
|
|
prepare, _, _, _, _, _, resolve, request = _setup(monkeypatch, source=AUTH_SOURCE_INTERNAL)
|
|
request.state.auth = AuthContext(_user(user_id="internal", role="internal"), ["threads:read"])
|
|
capability = await prepare()
|
|
assert capability is not None
|
|
assert all(call.kwargs["is_internal"] is False for call in resolve.await_args_list)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_synthetic_internal_principal_cannot_become_the_schedule_owner(monkeypatch):
|
|
prepare, _, _, _, _, provider, _, request = _setup(monkeypatch, source=AUTH_SOURCE_INTERNAL)
|
|
request.state.user.system_role = "internal"
|
|
provider.get_user.return_value.system_role = "internal"
|
|
assert await prepare() is None
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_scheduled_grant_only_stops_the_privately_bound_occurrence(monkeypatch):
|
|
prepare, _, occurrences, *_ = _setup(monkeypatch, source=AUTH_SOURCE_INTERNAL, mode="scheduled")
|
|
capability = await prepare(scheduled_task_runtime={"user_id": "alice", "task_id": "task-1", "occurrence_id": "occurrence-1"})
|
|
assert capability.mode == "scheduled"
|
|
result = await capability.stop_current_schedule()
|
|
assert result["stop_requested"] is True
|
|
occurrences.request_stop.assert_awaited_once_with("occurrence-1", task_id="task-1", run_id="run-1", user_id="alice")
|
|
result = await capability.manage(action="list", request={})
|
|
assert result["status_code"] == 403
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize("runtime", [{"user_id": "bob", "task_id": "task-1", "occurrence_id": "occurrence-1"}, {"user_id": "alice", "task_id": "task-1"}])
|
|
async def test_scheduled_runtime_must_bind_the_actual_owner_and_occurrence(monkeypatch, runtime):
|
|
prepare, *_ = _setup(monkeypatch, source=AUTH_SOURCE_INTERNAL, mode="scheduled")
|
|
assert await prepare(scheduled_task_runtime=runtime) is None
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_self_stop_rechecks_permissions_and_durable_occurrence_binding(monkeypatch):
|
|
prepare, _, occurrences, _, _, _, resolve, _ = _setup(monkeypatch, source=AUTH_SOURCE_INTERNAL, mode="scheduled")
|
|
capability = await prepare(scheduled_task_runtime={"user_id": "alice", "task_id": "task-1", "occurrence_id": "occurrence-1"})
|
|
occurrences.request_stop.return_value = False
|
|
assert (await capability.stop_current_schedule())["status_code"] == 409
|
|
resolve.return_value = ["threads:read"]
|
|
assert (await capability.stop_current_schedule())["status_code"] == 403
|
|
assert occurrences.request_stop.await_count == 1
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_auth_disabled_default_owner_needs_the_explicit_host_mode(monkeypatch):
|
|
from app.gateway import scheduled_task_access as access
|
|
from app.gateway.auth_disabled import get_auth_disabled_user
|
|
|
|
prepare, _, _, _, threads, provider, _, request = _setup(monkeypatch, source=AUTH_SOURCE_AUTH_DISABLED)
|
|
user = get_auth_disabled_user()
|
|
request.state.user = user
|
|
request.state.auth = AuthContext(user, list(_PERMISSIONS))
|
|
threads.get.return_value = {"user_id": str(user.id)}
|
|
provider.get_user.return_value = None
|
|
monkeypatch.setattr(access, "is_auth_disabled", lambda: True)
|
|
capability = await access.prepare_scheduler_capability(request, user=user, thread_id="origin", run_id="run-1", assistant_id="lead_agent", original_user_text="List tasks", interaction_policy=RunInteractionPolicy.interactive())
|
|
assert capability is not None
|
|
result = await capability.manage(action="list", request={})
|
|
assert "error" not in result
|
|
monkeypatch.setattr(access, "is_auth_disabled", lambda: False)
|
|
result = await capability.manage(action="list", request={})
|
|
assert result["status_code"] == 401
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize(
|
|
"updates",
|
|
[
|
|
{"timezone": "missing/zone"},
|
|
{"schedule_type": "unsupported"},
|
|
{"schedule_spec": {"every_seconds": 1}},
|
|
{"schedule_type": "cron", "schedule_spec": {"cron": "not cron"}},
|
|
{"context_mode": "wrong"},
|
|
{"schedule_type": "once", "schedule_spec": {"run_at": (datetime.now(UTC) - timedelta(hours=1)).isoformat()}},
|
|
],
|
|
)
|
|
async def test_rest_and_tool_share_creation_validation_status_and_reason(monkeypatch, updates):
|
|
prepare, repo, _, _, threads, _, _, request = _setup(monkeypatch)
|
|
capability = await prepare()
|
|
body = {"title": "Task", "prompt": "Work", "schedule_type": "interval", "schedule_spec": {"every_seconds": 3600}, "timezone": "UTC", **updates}
|
|
tool_result = await capability.manage(action="create", request=body)
|
|
monkeypatch.setattr(scheduled_tasks, "get_scheduled_task_repo", lambda _request: repo)
|
|
monkeypatch.setattr(scheduled_tasks, "get_thread_store", lambda _request: threads)
|
|
monkeypatch.setattr(scheduled_tasks, "get_config", lambda: AppConfig.model_validate({"sandbox": {"use": "deerflow.sandbox.local:LocalSandboxProvider"}}))
|
|
monkeypatch.setattr(scheduled_tasks, "get_optional_user_from_request", AsyncMock(return_value=_user()))
|
|
from fastapi import HTTPException
|
|
|
|
with pytest.raises(HTTPException) as error:
|
|
await call_unwrapped(scheduled_tasks.create_scheduled_task, request=request, body=scheduled_tasks.ScheduledTaskCreateRequest(**body))
|
|
assert tool_result["status_code"] == error.value.status_code
|
|
assert tool_result["error"] == error.value.detail
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_capability_maps_real_repository_note_capacity_and_live_task_limit(monkeypatch, tmp_path):
|
|
from app.gateway import scheduled_task_access as access
|
|
from deerflow.config.database_config import DatabaseConfig
|
|
from deerflow.persistence.engine import close_engine, get_session_factory, init_engine_from_config
|
|
from deerflow.persistence.scheduled_tasks import ScheduledTaskRepository
|
|
|
|
await init_engine_from_config(DatabaseConfig(backend="sqlite", sqlite_dir=str(tmp_path)))
|
|
try:
|
|
prepare, *_ = _setup(monkeypatch, original_text=" ".join(f"note-{index}" for index in range(11)))
|
|
repo = ScheduledTaskRepository(get_session_factory())
|
|
monkeypatch.setattr(access, "get_scheduled_task_repo", lambda _request: repo)
|
|
capability = await prepare()
|
|
definition = {"title": "Report", "prompt": "Write report", "schedule_type": "interval", "schedule_spec": {"every_seconds": 3600}, "timezone": "UTC"}
|
|
created = await capability.manage(action="create", request=definition)
|
|
for index in range(10):
|
|
result = await capability.manage(action="note", request={"task_id": created["id"], "note": f"note-{index}"})
|
|
assert "error" not in result
|
|
result = await capability.manage(action="note", request={"task_id": created["id"], "note": "note-10"})
|
|
assert result["status_code"] == 422
|
|
stored = await repo.get(created["id"], user_id="alice")
|
|
assert len(stored["standing_notes"]) == 10
|
|
for _ in range(19):
|
|
assert "error" not in await capability.manage(action="create", request=definition)
|
|
result = await capability.manage(action="create", request=definition)
|
|
assert result["status_code"] == 409
|
|
assert "20 live" in result["error"]
|
|
assert len(await repo.list_by_origin_thread("alice", "origin")) == 20
|
|
finally:
|
|
await close_engine()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_capability_and_shared_validation_offload_configuration_and_timezone_io(monkeypatch, tmp_path):
|
|
from support.detectors.blocking_io_runtime import detect_blocking_io_strict
|
|
|
|
from app.gateway import scheduled_task_access as access
|
|
from app.gateway import scheduled_task_validation as validation
|
|
|
|
prepare, *_ = _setup(monkeypatch)
|
|
config = access.get_config()
|
|
marker = tmp_path / "configuration-and-timezone-fixture.txt"
|
|
marker.write_text("fixture", encoding="utf-8")
|
|
original_validate_timezone = validation.validate_timezone
|
|
|
|
def read_config():
|
|
marker.read_text(encoding="utf-8")
|
|
return config
|
|
|
|
def read_timezone(timezone):
|
|
marker.read_text(encoding="utf-8")
|
|
return original_validate_timezone(timezone)
|
|
|
|
monkeypatch.setattr(access, "get_config", read_config)
|
|
monkeypatch.setattr(validation, "validate_timezone", read_timezone)
|
|
with detect_blocking_io_strict():
|
|
capability = await prepare()
|
|
result = await capability.manage(action="create", request={"title": "Report", "prompt": "Write report", "schedule_type": "interval", "schedule_spec": {"every_seconds": 3600}, "timezone": "UTC"})
|
|
assert "error" not in result
|