|
23 | 23 |
|
24 | 24 | from adcp.decisioning.resolve import ResourceResolver, _make_default_resolver |
25 | 25 | from adcp.decisioning.state import StateReader, _make_default_state_reader |
26 | | -from adcp.decisioning.types import Account, TaskHandoff |
| 26 | +from adcp.decisioning.types import Account, TaskHandoff, WorkflowHandoff |
27 | 27 | from adcp.server.base import ToolContext |
28 | 28 |
|
29 | 29 | if TYPE_CHECKING: |
@@ -195,5 +195,85 @@ def handoff_to_task( |
195 | 195 | Adopter code passes either a coroutine function (``async def |
196 | 196 | review_async(task_ctx): ...``) or a sync callable; the |
197 | 197 | dispatcher detects which and runs it appropriately. |
| 198 | +
|
| 199 | + For external workflows that complete on their own schedule |
| 200 | + (human queue review, batch jobs, Airflow DAGs, ML pipelines) |
| 201 | + — use :meth:`handoff_to_workflow` instead. The split is purely |
| 202 | + about where the work runs (in-process / framework-managed vs. |
| 203 | + adopter-owned external system). |
198 | 204 | """ |
199 | 205 | return TaskHandoff(fn) |
| 206 | + |
| 207 | + def handoff_to_workflow( |
| 208 | + self, |
| 209 | + fn: Callable[[Any], Awaitable[None] | None], |
| 210 | + ) -> WorkflowHandoff: |
| 211 | + """Promote this call to an externally-completed task. |
| 212 | +
|
| 213 | + For workflows that run OUTSIDE the framework's process — |
| 214 | + human queue review (trafficker UI), nightly batch jobs, |
| 215 | + Airflow DAGs, ML pipelines, scheduled cron. The framework |
| 216 | + allocates a ``task_id``, calls ``fn`` ONCE synchronously |
| 217 | + (or awaits it if a coroutine) to register the work into the |
| 218 | + adopter's external system, persists ``submitted`` state, and |
| 219 | + returns the wire envelope. NO background coroutine runs in |
| 220 | + the framework. |
| 221 | +
|
| 222 | + ``fn`` receives a :class:`TaskHandoffContext` carrying |
| 223 | + ``id`` (framework-allocated task_id) and ``_registry`` |
| 224 | + (adopter can stash a reference for later completion). The |
| 225 | + adopter's external workflow later calls |
| 226 | + ``registry.complete(task_id, result)`` or |
| 227 | + ``registry.fail(task_id, error)`` directly when the work |
| 228 | + finishes — minutes, hours, or days later. |
| 229 | +
|
| 230 | + Buyer experience is identical to :meth:`handoff_to_task` — |
| 231 | + same ``{task_id, status: 'submitted'}`` wire envelope, same |
| 232 | + ``tasks/get`` polling, same push-notification webhook on |
| 233 | + terminal state. |
| 234 | +
|
| 235 | + **Rollback.** If ``fn`` raises during enqueue, the framework |
| 236 | + discards the just-allocated task_id from the registry and |
| 237 | + propagates the exception (wrapped to ``AdcpError`` per the |
| 238 | + dispatch contract). Adopter enqueue fns that need |
| 239 | + transactional persistence wrap their own DB write in their |
| 240 | + own transaction; the framework's rollback is registry-side |
| 241 | + only. |
| 242 | +
|
| 243 | + Example:: |
| 244 | +
|
| 245 | + class TraffickerSeller(DecisioningPlatform): |
| 246 | + def __init__(self, review_queue, task_registry): |
| 247 | + self.review_queue = review_queue |
| 248 | + # Stash for later completion when human acts |
| 249 | + self.task_registry = task_registry |
| 250 | +
|
| 251 | + def create_media_buy(self, req, ctx): |
| 252 | + if self._needs_human_approval(req): |
| 253 | + return ctx.handoff_to_workflow( |
| 254 | + lambda task_ctx: self._enqueue(task_ctx, req) |
| 255 | + ) |
| 256 | + return CreateMediaBuySuccess(media_buy_id="mb_1", ...) |
| 257 | +
|
| 258 | + def _enqueue(self, task_ctx, req): |
| 259 | + self.review_queue.add( |
| 260 | + task_id=task_ctx.id, |
| 261 | + request_snapshot=req.model_dump(), |
| 262 | + ) |
| 263 | +
|
| 264 | + # Elsewhere — Flask handler for the trafficker UI: |
| 265 | + async def on_decision(self, task_id, decision): |
| 266 | + if decision.approved: |
| 267 | + await self.task_registry.complete( |
| 268 | + task_id, |
| 269 | + CreateMediaBuySuccess(...).model_dump(), |
| 270 | + ) |
| 271 | + else: |
| 272 | + await self.task_registry.fail( |
| 273 | + task_id, AdcpError(...).to_wire(), |
| 274 | + ) |
| 275 | +
|
| 276 | + See :class:`adcp.decisioning.WorkflowHandoff` for the full |
| 277 | + semantics. |
| 278 | + """ |
| 279 | + return WorkflowHandoff(fn) |
0 commit comments