"""Native jobs + service lifecycle, with an explicit in-memory repository.""" import asyncio import unittest from copy import deepcopy from types import SimpleNamespace from services.background_tasks.service import TaskService from services.background_tasks.records import TERMINAL from schemas import ToolUpsert from routes.tools import _payload class MemoryRepository: def __init__(self): self.records = {} async def create(self, record): old = next((r for r in self.records.values() if r.origin_key == record.origin_key), None) if old: return deepcopy(old) self.records[record.id] = deepcopy(record) return record async def get(self, task_id): return deepcopy(self.records.get(task_id)) async def list(self, assistant_id=None, active=False): return [deepcopy(r) for r in self.records.values() if (not assistant_id or r.assistant_id == assistant_id) and (not active or r.status not in TERMINAL)] async def patch(self, task_id, **changes): record = self.records[task_id] if record.status not in TERMINAL: for key, value in changes.items(): setattr(record, key, value) record.sequence += 1 return deepcopy(record) class SimulatedExecutor: release = None calls = 0 confirms_stop = True def __init__(self, config, secrets): pass async def prepare(self): return {"session_id": "ses_test", "message_id": "msg_test"} async def run(self, handle, instruction, emit, resume=False): type(self).calls += 1 await emit({"status": "running", "summary": "正在执行测试"}) await type(self).release.wait() return {"status": "succeeded", "summary": "完成", "result": "真实Job,模拟远端结果"} async def cancel(self, handle): return type(self).confirms_stop async def snapshot(self, handle): return {"status": "running", "summary": "远端仍在执行", "result": ""} class TaskServiceTest(unittest.IsolatedAsyncioTestCase): async def asyncSetUp(self): SimulatedExecutor.release = asyncio.Event() SimulatedExecutor.calls = 0 SimulatedExecutor.confirms_stop = True self.service = TaskService(MemoryRepository(), SimulatedExecutor) await self.service.start() self.tool = SimpleNamespace(type="task", id="tool_task", definition={"config": {"url": "https://remote.test", "directory": "/demo", "timeout_seconds": 30}}, secrets={"password": "never-public"}) async def asyncTearDown(self): await self.service.close() async def wait_status(self, task_id, expected): async with asyncio.timeout(3): while (await self.service.get(task_id)).status != expected: await asyncio.sleep(.005) async def submit(self, key="call_1"): return await self.service.submit(assistant_id="asst_1", conversation_id="conv_1", tool=self.tool, instruction="执行测试", invocation_id=key) async def test_idempotence_public_projection_and_completion(self): task = await self.submit() again = await self.submit() self.assertEqual(task["id"], again["id"]) await self.wait_status(task["id"], "running") self.assertEqual(SimulatedExecutor.calls, 1) self.assertNotIn("never-public", str(task)) SimulatedExecutor.release.set() await self.wait_status(task["id"], "succeeded") with self.assertRaises(ValueError): await self.service.get(task["id"], "another-assistant") async def test_cancel_requires_remote_confirmation(self): task = await self.submit() await self.wait_status(task["id"], "running") await self.service.cancel(task["id"]) await self.wait_status(task["id"], "cancelled") async def test_shutdown_does_not_abort_remote_work(self): task = await self.submit() await self.wait_status(task["id"], "running") await self.service.close() self.assertEqual((await self.service.get(task["id"])).status, "running") async def test_unconfirmed_remote_stop_is_not_reported_cancelled(self): SimulatedExecutor.confirms_stop = False task = await self.submit() await self.wait_status(task["id"], "running") await self.service.cancel(task["id"]) async with asyncio.timeout(2): while self.service.runtime.executor.active_jobs: await asyncio.sleep(.005) self.assertEqual((await self.service.get(task["id"])).status, "cancel_requested") async def test_queued_task_can_be_cancelled_without_remote_submission(self): first = await self.submit("call_first") await self.wait_status(first["id"], "running") second = await self.submit("call_second") await self.service.cancel(second["id"]) await self.wait_status(second["id"], "cancelled") self.assertEqual(SimulatedExecutor.calls, 1) async def test_restart_observes_existing_remote_handle(self): task = await self.submit() await self.wait_status(task["id"], "running") handle = (await self.service.get(task["id"])).remote_handle await self.service.close() await self.service.start() async with asyncio.timeout(2): while SimulatedExecutor.calls < 2: await asyncio.sleep(.005) self.assertEqual((await self.service.get(task["id"])).remote_handle, handle) SimulatedExecutor.release.set() await self.wait_status(task["id"], "succeeded") async def test_reconcile_preserves_a_pending_question(self): task = await self.submit() await self.wait_status(task["id"], "running") question = {"kind": "question", "id": "que_1", "questions": [{"question": "使用哪个分支?"}]} await self.service.repository.patch(task["id"], status="waiting_input", input_request=question) result = await self.service.reconcile(task["id"]) self.assertEqual(result["status"], "waiting_input") self.assertEqual(result["inputRequest"], question) def test_task_tool_preserves_masked_password(self): body = ToolUpsert(name="后台编程", functionName="coding_task", definition={"type": "task", "config": {"url": "https://remote.test", "directory": "/demo"}}, secrets={"password": "****"}) data = _payload(body, {"password": "old-password"}) self.assertEqual(data["secrets"]["password"], "old-password") class TaskApiTest(unittest.IsolatedAsyncioTestCase): async def test_admin_auth_and_real_native_job_through_http_api(self): import httpx from fastapi import FastAPI from unittest.mock import AsyncMock, patch from routes import background_tasks as routes from services.auth import require_admin from db.session import get_session SimulatedExecutor.release = asyncio.Event() service = TaskService(MemoryRepository(), SimulatedExecutor) await service.start() app = FastAPI() app.include_router(routes.router) async def database(): yield None app.dependency_overrides[get_session] = database cfg = SimpleNamespace(type="prompt", runtimeMode="pipeline", tool_bindings=[SimpleNamespace(tool_id="tool_task")], tools=[SimpleNamespace(type="task", id="tool_task", definition={"config": {"url": "https://remote.test", "directory": "/demo", "timeout_seconds": 30}}, secrets={})]) try: async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="https://platform.test") as client: unauthenticated = await client.get("/api/background-tasks?assistant_id=asst_1") self.assertEqual(unauthenticated.status_code, 401) app.dependency_overrides[require_admin] = lambda: {"username": "test"} with patch.object(routes, "task_service", service), patch.object(routes, "resolve_runtime_config", AsyncMock(return_value=cfg)): response = await client.post("/api/background-tasks", json={"assistantId": "asst_1", "toolId": "tool_task", "instruction": "测试请求", "requestId": "request_1"}) self.assertEqual(response.status_code, 200) task_id = response.json()["id"] self.assertEqual(response.json()["assistantId"], "asst_1") SimulatedExecutor.release.set() async with asyncio.timeout(2): while (await service.get(task_id)).status != "succeeded": await asyncio.sleep(.005) task = await client.get(f"/api/background-tasks/{task_id}") self.assertEqual(task.json()["status"], "succeeded") self.assertNotIn("secrets", task.json()) finally: await service.close()