api_v2.py 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499
  1. """Optional FastAPI adapter for orchestration V2.
  2. FastAPI is imported only when :func:`create_orchestration_router` is called,
  3. so importing the core orchestration package does not require server extras.
  4. """
  5. from __future__ import annotations
  6. import asyncio
  7. from typing import Any, Awaitable, Callable, Optional
  8. from pydantic import ValidationError
  9. from .coordinator import OrchestrationError, TaskConflict, TaskCoordinator
  10. from .store import (
  11. ArtifactConflict,
  12. RevisionConflict,
  13. TaskStoreError,
  14. TaskStoreNotFound,
  15. )
  16. from .wire import (
  17. ArtifactSnapshotView,
  18. AttemptList,
  19. AttemptView,
  20. DispatchOperationRequest,
  21. EventPageView,
  22. EventView,
  23. MissionSnapshotView,
  24. OperationRequest,
  25. OperationView,
  26. PlannerDecisionView,
  27. ProblemDetails,
  28. RootCompletionView,
  29. TaskList,
  30. TaskView,
  31. ValidationList,
  32. ValidationView,
  33. )
  34. _ERROR_RESPONSES = {
  35. status: {"model": ProblemDetails}
  36. for status in (404, 409, 422, 503)
  37. }
  38. class _ResourceNotFound(LookupError):
  39. pass
  40. def create_orchestration_router(coordinator: TaskCoordinator) -> Any:
  41. """Build an isolated router backed by the supplied coordinator instance."""
  42. try:
  43. from fastapi import APIRouter, Header
  44. from fastapi.exceptions import RequestValidationError
  45. from fastapi.responses import JSONResponse
  46. from fastapi.routing import APIRoute
  47. except ImportError as exc: # pragma: no cover - depends on optional extra
  48. raise RuntimeError(
  49. "FastAPI is optional; install cyber-agent[server] to create the V2 router"
  50. ) from exc
  51. class ProblemRoute(APIRoute):
  52. def get_route_handler(self) -> Callable[..., Awaitable[Any]]:
  53. original = super().get_route_handler()
  54. async def route_handler(request: Any) -> Any:
  55. try:
  56. return await original(request)
  57. except RequestValidationError as exc:
  58. value = ProblemDetails(
  59. title="Invalid Request",
  60. status=422,
  61. detail=str(exc),
  62. code="invalid_request",
  63. instance=request.url.path,
  64. )
  65. return JSONResponse(
  66. status_code=422, content=value.model_dump(mode="json")
  67. )
  68. return route_handler
  69. router = APIRouter(
  70. prefix="/api/v2", tags=["orchestration-v2"], route_class=ProblemRoute
  71. )
  72. @router.get(
  73. "/roots/{root_trace_id}/snapshot",
  74. response_model=MissionSnapshotView,
  75. responses=_ERROR_RESPONSES,
  76. )
  77. async def get_mission_snapshot(root_trace_id: str) -> Any:
  78. path = f"/api/v2/roots/{root_trace_id}/snapshot"
  79. async def action() -> MissionSnapshotView:
  80. # This is deliberately the only load in the endpoint: every
  81. # collection must describe one immutable ledger revision.
  82. ledger = await coordinator.task_store.load(root_trace_id)
  83. return MissionSnapshotView(
  84. revision=ledger.revision,
  85. root_trace_id=ledger.root_trace_id,
  86. root_task_id=ledger.root_task_id,
  87. root_objective=ledger.root_objective,
  88. focused_task_id=ledger.focused_task_id,
  89. tasks=[
  90. TaskView.from_domain(item)
  91. for item in sorted(
  92. ledger.tasks.values(),
  93. key=lambda value: (value.created_at, value.task_id),
  94. )
  95. ],
  96. attempts=[
  97. AttemptView.from_domain(item)
  98. for item in sorted(
  99. ledger.attempts.values(),
  100. key=lambda value: (value.created_at, value.attempt_id),
  101. )
  102. ],
  103. validations=[
  104. ValidationView.from_domain(item)
  105. for item in sorted(
  106. ledger.validations.values(),
  107. key=lambda value: (value.created_at, value.validation_id),
  108. )
  109. ],
  110. decisions=[
  111. PlannerDecisionView.from_domain(item)
  112. for item in sorted(
  113. ledger.decisions.values(),
  114. key=lambda value: (value.created_at, value.decision_id),
  115. )
  116. ],
  117. operations=[
  118. OperationView.from_domain(item)
  119. for item in sorted(
  120. ledger.operations.values(),
  121. key=lambda value: (value.created_at, value.operation_id),
  122. )
  123. ],
  124. )
  125. return await call(action, path)
  126. @router.get(
  127. "/roots/{root_trace_id}/completion",
  128. response_model=RootCompletionView,
  129. responses=_ERROR_RESPONSES,
  130. )
  131. async def get_root_completion(root_trace_id: str) -> Any:
  132. path = f"/api/v2/roots/{root_trace_id}/completion"
  133. async def action() -> RootCompletionView:
  134. value = await coordinator.root_completion(root_trace_id)
  135. return RootCompletionView(
  136. root_task_id=value["root_task_id"],
  137. root_objective=value["root_objective"],
  138. status=value["status"],
  139. blocked_reason=value.get("blocked_reason"),
  140. result_summary=value.get("result_summary"),
  141. )
  142. return await call(action, path)
  143. @router.get(
  144. "/roots/{root_trace_id}/snapshots/{snapshot_id}",
  145. response_model=ArtifactSnapshotView,
  146. responses=_ERROR_RESPONSES,
  147. )
  148. async def get_artifact_snapshot(root_trace_id: str, snapshot_id: str) -> Any:
  149. path = f"/api/v2/roots/{root_trace_id}/snapshots/{snapshot_id}"
  150. async def action() -> ArtifactSnapshotView:
  151. try:
  152. value = await coordinator.artifact_store.get(
  153. root_trace_id, snapshot_id
  154. )
  155. except FileNotFoundError as exc:
  156. raise _ResourceNotFound(
  157. f"Artifact snapshot not found: {snapshot_id}"
  158. ) from exc
  159. return ArtifactSnapshotView.from_domain(value)
  160. return await call(action, path)
  161. def problem(exc: Exception, instance: str) -> JSONResponse:
  162. status, title, code, detail = _problem_fields(exc)
  163. value = ProblemDetails(
  164. title=title,
  165. status=status,
  166. detail=detail,
  167. code=code,
  168. instance=instance,
  169. )
  170. return JSONResponse(status_code=status, content=value.model_dump(mode="json"))
  171. async def call(
  172. action: Callable[[], Awaitable[Any]],
  173. instance: str,
  174. ) -> Any:
  175. try:
  176. return await action()
  177. except Exception as exc: # endpoint boundary owns stable error mapping
  178. return problem(exc, instance)
  179. @router.post(
  180. "/roots/{root_trace_id}/operations",
  181. status_code=202,
  182. response_model=OperationView,
  183. responses=_ERROR_RESPONSES,
  184. )
  185. async def start_operation(
  186. root_trace_id: str,
  187. body: OperationRequest,
  188. idempotency_key: Optional[str] = Header(
  189. None,
  190. alias="Idempotency-Key",
  191. min_length=1,
  192. max_length=200,
  193. pattern=r"^[\x21-\x7e]+$",
  194. ),
  195. ) -> Any:
  196. path = f"/api/v2/roots/{root_trace_id}/operations"
  197. async def action() -> OperationView:
  198. if isinstance(body, DispatchOperationRequest):
  199. request = body
  200. operation = await coordinator.start_operation(
  201. root_trace_id,
  202. request.kind,
  203. task_ids=request.task_ids,
  204. worker_presets=request.worker_presets,
  205. deadline_at=request.deadline_at,
  206. idempotency_key=idempotency_key,
  207. )
  208. else:
  209. request = body
  210. operation = await coordinator.start_operation(
  211. root_trace_id,
  212. request.kind,
  213. task_id=request.task_id,
  214. attempt_id=request.attempt_id,
  215. deadline_at=request.deadline_at,
  216. idempotency_key=idempotency_key,
  217. )
  218. return OperationView.from_domain(operation)
  219. return await call(action, path)
  220. @router.get(
  221. "/roots/{root_trace_id}/operations/{operation_id}",
  222. response_model=OperationView,
  223. responses=_ERROR_RESPONSES,
  224. )
  225. async def get_operation(root_trace_id: str, operation_id: str) -> Any:
  226. path = f"/api/v2/roots/{root_trace_id}/operations/{operation_id}"
  227. async def action() -> OperationView:
  228. operation = await coordinator.get_operation(root_trace_id, operation_id)
  229. return OperationView.from_domain(operation)
  230. return await call(action, path)
  231. @router.post(
  232. "/roots/{root_trace_id}/operations/{operation_id}/stop",
  233. response_model=OperationView,
  234. responses=_ERROR_RESPONSES,
  235. )
  236. async def stop_operation(
  237. root_trace_id: str,
  238. operation_id: str,
  239. idempotency_key: Optional[str] = Header(
  240. None,
  241. alias="Idempotency-Key",
  242. min_length=1,
  243. max_length=200,
  244. pattern=r"^[\x21-\x7e]+$",
  245. ),
  246. ) -> Any:
  247. path = f"/api/v2/roots/{root_trace_id}/operations/{operation_id}/stop"
  248. async def action() -> OperationView:
  249. operation = await coordinator.stop_operation(
  250. root_trace_id, operation_id, idempotency_key=idempotency_key
  251. )
  252. return OperationView.from_domain(operation)
  253. return await call(action, path)
  254. @router.post(
  255. "/roots/{root_trace_id}/operations/{operation_id}/resume",
  256. response_model=OperationView,
  257. responses=_ERROR_RESPONSES,
  258. )
  259. async def resume_operation(
  260. root_trace_id: str,
  261. operation_id: str,
  262. idempotency_key: Optional[str] = Header(
  263. None,
  264. alias="Idempotency-Key",
  265. min_length=1,
  266. max_length=200,
  267. pattern=r"^[\x21-\x7e]+$",
  268. ),
  269. ) -> Any:
  270. path = f"/api/v2/roots/{root_trace_id}/operations/{operation_id}/resume"
  271. async def action() -> OperationView:
  272. operation = await coordinator.resume_operation(
  273. root_trace_id, operation_id, idempotency_key=idempotency_key
  274. )
  275. return OperationView.from_domain(operation)
  276. return await call(action, path)
  277. @router.get(
  278. "/roots/{root_trace_id}/tasks",
  279. response_model=TaskList,
  280. responses=_ERROR_RESPONSES,
  281. )
  282. async def list_tasks(root_trace_id: str) -> Any:
  283. path = f"/api/v2/roots/{root_trace_id}/tasks"
  284. async def action() -> TaskList:
  285. ledger = await coordinator.task_store.load(root_trace_id)
  286. return TaskList(
  287. revision=ledger.revision,
  288. root_task_id=ledger.root_task_id,
  289. items=[
  290. TaskView.from_domain(item)
  291. for item in sorted(
  292. ledger.tasks.values(),
  293. key=lambda value: (value.created_at, value.task_id),
  294. )
  295. ],
  296. )
  297. return await call(action, path)
  298. @router.get(
  299. "/roots/{root_trace_id}/tasks/{task_id}",
  300. response_model=TaskView,
  301. responses=_ERROR_RESPONSES,
  302. )
  303. async def get_task(root_trace_id: str, task_id: str) -> Any:
  304. path = f"/api/v2/roots/{root_trace_id}/tasks/{task_id}"
  305. async def action() -> TaskView:
  306. ledger = await coordinator.task_store.load(root_trace_id)
  307. item = ledger.tasks.get(task_id)
  308. if item is None:
  309. raise _ResourceNotFound(f"Task not found: {task_id}")
  310. return TaskView.from_domain(item)
  311. return await call(action, path)
  312. @router.get(
  313. "/roots/{root_trace_id}/attempts",
  314. response_model=AttemptList,
  315. responses=_ERROR_RESPONSES,
  316. )
  317. async def list_attempts(root_trace_id: str) -> Any:
  318. path = f"/api/v2/roots/{root_trace_id}/attempts"
  319. async def action() -> AttemptList:
  320. ledger = await coordinator.task_store.load(root_trace_id)
  321. return AttemptList(
  322. revision=ledger.revision,
  323. items=[
  324. AttemptView.from_domain(item)
  325. for item in sorted(
  326. ledger.attempts.values(),
  327. key=lambda value: (value.created_at, value.attempt_id),
  328. )
  329. ],
  330. )
  331. return await call(action, path)
  332. @router.get(
  333. "/roots/{root_trace_id}/attempts/{attempt_id}",
  334. response_model=AttemptView,
  335. responses=_ERROR_RESPONSES,
  336. )
  337. async def get_attempt(root_trace_id: str, attempt_id: str) -> Any:
  338. path = f"/api/v2/roots/{root_trace_id}/attempts/{attempt_id}"
  339. async def action() -> AttemptView:
  340. ledger = await coordinator.task_store.load(root_trace_id)
  341. item = ledger.attempts.get(attempt_id)
  342. if item is None:
  343. raise _ResourceNotFound(f"Attempt not found: {attempt_id}")
  344. return AttemptView.from_domain(item)
  345. return await call(action, path)
  346. @router.get(
  347. "/roots/{root_trace_id}/validations",
  348. response_model=ValidationList,
  349. responses=_ERROR_RESPONSES,
  350. )
  351. async def list_validations(root_trace_id: str) -> Any:
  352. path = f"/api/v2/roots/{root_trace_id}/validations"
  353. async def action() -> ValidationList:
  354. ledger = await coordinator.task_store.load(root_trace_id)
  355. return ValidationList(
  356. revision=ledger.revision,
  357. items=[
  358. ValidationView.from_domain(item)
  359. for item in sorted(
  360. ledger.validations.values(),
  361. key=lambda value: (value.created_at, value.validation_id),
  362. )
  363. ],
  364. )
  365. return await call(action, path)
  366. @router.get(
  367. "/roots/{root_trace_id}/validations/{validation_id}",
  368. response_model=ValidationView,
  369. responses=_ERROR_RESPONSES,
  370. )
  371. async def get_validation(root_trace_id: str, validation_id: str) -> Any:
  372. path = f"/api/v2/roots/{root_trace_id}/validations/{validation_id}"
  373. async def action() -> ValidationView:
  374. ledger = await coordinator.task_store.load(root_trace_id)
  375. item = ledger.validations.get(validation_id)
  376. if item is None:
  377. raise _ResourceNotFound(f"Validation not found: {validation_id}")
  378. return ValidationView.from_domain(item)
  379. return await call(action, path)
  380. @router.get(
  381. "/roots/{root_trace_id}/events",
  382. response_model=EventPageView,
  383. responses=_ERROR_RESPONSES,
  384. )
  385. async def list_events(
  386. root_trace_id: str,
  387. cursor: Optional[str] = None,
  388. limit: str = "100",
  389. wait_seconds: str = "0",
  390. ) -> Any:
  391. path = f"/api/v2/roots/{root_trace_id}/events"
  392. async def action() -> EventPageView:
  393. try:
  394. parsed_limit = int(limit)
  395. parsed_wait = float(wait_seconds)
  396. except ValueError as exc:
  397. raise ValueError("limit and wait_seconds must be numeric") from exc
  398. if not 1 <= parsed_limit <= 1000:
  399. raise ValueError("limit must be between 1 and 1000")
  400. if not 0 <= parsed_wait <= 30:
  401. raise ValueError("wait_seconds must be between 0 and 30")
  402. deadline = asyncio.get_running_loop().time() + parsed_wait
  403. while True:
  404. page = await coordinator.task_store.list_events(
  405. root_trace_id, cursor=cursor, limit=parsed_limit
  406. )
  407. if page.events or asyncio.get_running_loop().time() >= deadline:
  408. return EventPageView(
  409. events=[EventView.from_domain(item) for item in page.events],
  410. next_cursor=page.next_cursor,
  411. has_more=page.has_more,
  412. )
  413. await asyncio.sleep(min(0.1, max(deadline - asyncio.get_running_loop().time(), 0)))
  414. return await call(action, path)
  415. return router
  416. def _problem_fields(exc: Exception) -> tuple[int, str, str, str]:
  417. if isinstance(exc, (TaskStoreNotFound, _ResourceNotFound)):
  418. return 404, "Not Found", "not_found", str(exc)
  419. if isinstance(exc, ValueError) and "not found" in str(exc).lower():
  420. return 404, "Not Found", "not_found", str(exc)
  421. if isinstance(exc, (TaskConflict, RevisionConflict, ArtifactConflict)):
  422. return 409, "Conflict", "conflict", str(exc)
  423. if isinstance(exc, ValidationError):
  424. return 422, "Invalid Request", "invalid_request", str(exc)
  425. if isinstance(exc, ValueError):
  426. return 422, "Invalid Request", "invalid_request", str(exc)
  427. if isinstance(exc, RuntimeError) and "AgentExecutor" in str(exc):
  428. return 503, "Service Unavailable", "service_unavailable", str(exc)
  429. if isinstance(exc, TaskStoreError):
  430. return 503, "Service Unavailable", "storage_unavailable", str(exc)
  431. if isinstance(exc, OrchestrationError):
  432. return 409, "Conflict", "orchestration_conflict", str(exc)
  433. return 500, "Internal Server Error", "internal_error", "Internal server error"
  434. __all__ = ["create_orchestration_router"]