refactor agents to client-owned config flow
This commit is contained in:
@@ -10,13 +10,13 @@ Coverage:
|
||||
- run_local_agent — file-read timeout path
|
||||
- run_local_agent — LLM extraction error path
|
||||
- run_cloud_agent — stub returns error immediately
|
||||
- trigger_pending_runs — overdue local + cloud dispatched
|
||||
- trigger_pending_runs — skipped when config is client-owned
|
||||
- trigger_pending_runs — non-overdue skipped
|
||||
- trigger_pending_runs — device_id filter for local agents
|
||||
|
||||
Integration:
|
||||
- POST /agents/{id}/run — 404 on unknown agent
|
||||
- POST /agents/{id}/run — creates run log + dispatches background task
|
||||
Integration:
|
||||
- POST /agents/can-create — billing eligibility check
|
||||
- POST /agents/trigger — creates run log + dispatches background task
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -373,7 +373,7 @@ async def test_run_local_agent_happy_path():
|
||||
assert kwargs["items_processed"] == 1
|
||||
assert kwargs["items_created"] == 1
|
||||
assert kwargs["errors"] == []
|
||||
assert kwargs["update_config_last_run"] is True
|
||||
assert kwargs["update_config_last_run"] is False
|
||||
|
||||
# Verify agent_run frame was sent.
|
||||
agent_run_frames = [f for f in sent_frames if f.get("type") == "agent_run"]
|
||||
@@ -690,31 +690,11 @@ async def test_finalize_run_updates_cloud_config_last_run_at():
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_trigger_pending_runs_no_overdue():
|
||||
"""If no agents are overdue trigger_pending_runs does nothing."""
|
||||
from datetime import timedelta
|
||||
|
||||
config = _make_local_config()
|
||||
config.last_run_at = datetime.now(timezone.utc) - timedelta(minutes=30) # ran 30m ago
|
||||
config.schedule_cron = "0 */6 * * *" # every 6h — not due yet
|
||||
|
||||
mock_db_result_local = MagicMock()
|
||||
mock_db_result_local.scalars.return_value.all.return_value = [config]
|
||||
|
||||
mock_db_result_cloud = MagicMock()
|
||||
mock_db_result_cloud.scalars.return_value.all.return_value = []
|
||||
"""Pending-run scan is skipped because agent config is client-owned."""
|
||||
|
||||
mgr = _make_manager()
|
||||
|
||||
with patch("app.core.agent_runner.async_session") as mock_session_factory, \
|
||||
patch("app.core.agent_runner.run_local_agent", new_callable=AsyncMock) as mock_run:
|
||||
mock_ctx = AsyncMock()
|
||||
mock_ctx.__aenter__ = AsyncMock(return_value=mock_ctx)
|
||||
mock_ctx.__aexit__ = AsyncMock(return_value=False)
|
||||
mock_ctx.execute = AsyncMock(
|
||||
side_effect=[mock_db_result_local, mock_db_result_cloud]
|
||||
)
|
||||
mock_session_factory.return_value = mock_ctx
|
||||
|
||||
with patch("app.core.agent_runner.run_local_agent", new_callable=AsyncMock) as mock_run:
|
||||
await trigger_pending_runs(_FREE_UID, "dev-001", mgr)
|
||||
|
||||
mock_run.assert_not_called()
|
||||
@@ -722,31 +702,11 @@ async def test_trigger_pending_runs_no_overdue():
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_trigger_pending_runs_device_id_filter():
|
||||
"""Local agents are only triggered for the matching device_id."""
|
||||
# The DB query already filters by device_id, so we verify the SELECT
|
||||
# includes the device_id filter by checking that a config bound to a
|
||||
# different device is never dispatched.
|
||||
#
|
||||
# Since trigger_pending_runs queries with device_id == "dev-001",
|
||||
# simulate the DB returning an empty list (as it would for a mismatch).
|
||||
mock_db_result_local = MagicMock()
|
||||
mock_db_result_local.scalars.return_value.all.return_value = [] # no match
|
||||
|
||||
mock_db_result_cloud = MagicMock()
|
||||
mock_db_result_cloud.scalars.return_value.all.return_value = []
|
||||
"""Device filtering is no longer backend-managed in pending runs."""
|
||||
|
||||
mgr = _make_manager(device_id="dev-001")
|
||||
|
||||
with patch("app.core.agent_runner.async_session") as mock_session_factory, \
|
||||
patch("app.core.agent_runner.run_local_agent", new_callable=AsyncMock) as mock_run:
|
||||
mock_ctx = AsyncMock()
|
||||
mock_ctx.__aenter__ = AsyncMock(return_value=mock_ctx)
|
||||
mock_ctx.__aexit__ = AsyncMock(return_value=False)
|
||||
mock_ctx.execute = AsyncMock(
|
||||
side_effect=[mock_db_result_local, mock_db_result_cloud]
|
||||
)
|
||||
mock_session_factory.return_value = mock_ctx
|
||||
|
||||
with patch("app.core.agent_runner.run_local_agent", new_callable=AsyncMock) as mock_run:
|
||||
await trigger_pending_runs(_FREE_UID, "dev-001", mgr)
|
||||
|
||||
mock_run.assert_not_called()
|
||||
@@ -754,56 +714,18 @@ async def test_trigger_pending_runs_device_id_filter():
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_trigger_pending_runs_dispatches_overdue():
|
||||
"""Overdue local agent triggers run_local_agent sequentially."""
|
||||
config = _make_local_config() # last_run_at=None → always overdue
|
||||
|
||||
mock_db_result_local = MagicMock()
|
||||
mock_db_result_local.scalars.return_value.all.return_value = [config]
|
||||
|
||||
mock_db_result_cloud = MagicMock()
|
||||
mock_db_result_cloud.scalars.return_value.all.return_value = []
|
||||
"""No pending runs are dispatched by backend after config deprecation."""
|
||||
|
||||
mgr = _make_manager()
|
||||
|
||||
call_order: list[str] = []
|
||||
|
||||
async def _mock_run_local(user_id, cfg, run_log, device_mgr):
|
||||
call_order.append("run_local")
|
||||
|
||||
with patch("app.core.agent_runner.async_session") as mock_session_factory, \
|
||||
patch("app.core.agent_runner.run_local_agent", side_effect=_mock_run_local):
|
||||
# First call: query configs. Subsequent calls: create run_log.
|
||||
mock_query_ctx = AsyncMock()
|
||||
mock_query_ctx.__aenter__ = AsyncMock(return_value=mock_query_ctx)
|
||||
mock_query_ctx.__aexit__ = AsyncMock(return_value=False)
|
||||
mock_query_ctx.execute = AsyncMock(
|
||||
side_effect=[mock_db_result_local, mock_db_result_cloud]
|
||||
)
|
||||
|
||||
run_log_obj = AgentRunLog(
|
||||
id=str(uuid.uuid4()),
|
||||
agent_id=config.id,
|
||||
agent_type="local",
|
||||
user_id=_FREE_UID,
|
||||
status="running",
|
||||
started_at=datetime.now(timezone.utc),
|
||||
)
|
||||
mock_insert_ctx = AsyncMock()
|
||||
mock_insert_ctx.__aenter__ = AsyncMock(return_value=mock_insert_ctx)
|
||||
mock_insert_ctx.__aexit__ = AsyncMock(return_value=False)
|
||||
mock_insert_ctx.add = MagicMock()
|
||||
mock_insert_ctx.commit = AsyncMock()
|
||||
mock_insert_ctx.refresh = AsyncMock(side_effect=lambda obj: None)
|
||||
|
||||
mock_session_factory.side_effect = [mock_query_ctx, mock_insert_ctx]
|
||||
|
||||
with patch("app.core.agent_runner.run_local_agent", new_callable=AsyncMock) as mock_run:
|
||||
await trigger_pending_runs(_FREE_UID, "dev-001", mgr)
|
||||
|
||||
assert call_order == ["run_local"]
|
||||
mock_run.assert_not_called()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Integration: POST /agents/{id}/run
|
||||
# Integration: POST /agents/can-create and /agents/trigger
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@@ -820,50 +742,67 @@ def _override_db(db_session):
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_trigger_run_unknown_agent(client):
|
||||
"""POST /agents/{id}/run returns 404 for unknown agent id."""
|
||||
async def test_can_create_agent_allows_when_under_limit(client):
|
||||
"""POST /agents/can-create returns allowed=True when under tier limit."""
|
||||
resp = client.post(
|
||||
f"/api/v1/agents/{uuid.uuid4()}/run",
|
||||
headers=auth_header("power"),
|
||||
"/api/v1/agents/can-create",
|
||||
json={"active_agents": 0},
|
||||
headers=auth_header("free"),
|
||||
)
|
||||
assert resp.status_code == 404
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["allowed"] is True
|
||||
assert body["tier"] == "free"
|
||||
assert body["active_agents"] == 0
|
||||
assert body["limit"] == 2
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_can_create_agent_denies_when_at_limit(client):
|
||||
"""POST /agents/can-create returns allowed=False at free-tier limit."""
|
||||
resp = client.post(
|
||||
"/api/v1/agents/can-create",
|
||||
json={"active_agents": 2},
|
||||
headers=auth_header("free"),
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["allowed"] is False
|
||||
assert body["limit"] == 2
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_trigger_run_local_agent_creates_run_log(client, db_session):
|
||||
"""POST /agents/{id}/run creates a run log and dispatches a background task."""
|
||||
# Create the local agent config in the DB.
|
||||
config = LocalAgentConfig(
|
||||
id=str(uuid.uuid4()),
|
||||
user_id=TEST_USER_IDS["power"],
|
||||
device_id="dev-001",
|
||||
name="My Agent",
|
||||
directory_paths=["/home/user/docs"],
|
||||
data_types=["tasks"],
|
||||
prompt_template="Extract tasks.",
|
||||
file_extensions=[".txt"],
|
||||
schedule_cron="0 */6 * * *",
|
||||
enabled=True,
|
||||
)
|
||||
db_session.add(config)
|
||||
await db_session.commit()
|
||||
|
||||
dispatched: list = []
|
||||
"""POST /agents/trigger creates a local run log and dispatches background task."""
|
||||
dispatched: list[tuple[str, str]] = []
|
||||
|
||||
async def _fake_run(user_id, cfg, run_log, device_mgr):
|
||||
dispatched.append((user_id, cfg.id))
|
||||
|
||||
def _fake_create_task(coro):
|
||||
coro.close()
|
||||
return MagicMock()
|
||||
|
||||
with patch("app.api.routes.agents.run_local_agent", new_callable=AsyncMock, side_effect=_fake_run), \
|
||||
patch("app.api.routes.agents.run_cloud_agent", new_callable=AsyncMock), \
|
||||
patch("asyncio.create_task") as mock_create_task:
|
||||
mock_create_task.side_effect = _fake_create_task
|
||||
resp = client.post(
|
||||
f"/api/v1/agents/{config.id}/run",
|
||||
"/api/v1/agents/trigger",
|
||||
json={
|
||||
"directory": "/home/user/docs",
|
||||
"what_to_extract": ["task", "note"],
|
||||
"actions_by_type": {"task": ["add", "update"], "note": ["add"]},
|
||||
"batch_interval": "0 */6 * * *",
|
||||
"custom_agent_prompt": "Extract tasks and notes.",
|
||||
"active_agents": 0,
|
||||
},
|
||||
headers=auth_header("power"),
|
||||
)
|
||||
|
||||
assert resp.status_code == 202
|
||||
data = resp.json()
|
||||
assert data["agent_id"] == config.id
|
||||
assert isinstance(data["agent_id"], str)
|
||||
assert data["agent_id"]
|
||||
assert data["status"] == "running"
|
||||
assert data["agent_type"] == "local"
|
||||
|
||||
|
||||
Reference in New Issue
Block a user