File size: 23,132 Bytes
152be26 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 656 657 658 659 660 661 662 663 664 665 666 667 668 669 670 671 672 673 674 675 676 677 678 679 680 681 682 683 684 685 686 687 688 689 690 691 692 693 694 695 696 697 698 699 700 701 702 703 704 705 706 707 708 709 710 711 712 713 714 715 716 717 718 719 720 721 722 723 724 725 726 727 728 729 730 731 732 733 734 735 736 737 738 739 740 741 742 743 744 745 746 747 748 749 750 751 752 753 | """Deterministic sequential workflow executor with durable checkpoints.
The executor validates the DAG, resolves only allowlisted handlers, emits structured
audit events, enforces bounded retries, executes bounded DECISION routes, resumes
safe checkpoints, and pauses durably at HUMAN_GATE nodes until an explicit human
decision is submitted. Live model calls are added in a later layer.
"""
from __future__ import annotations
from collections.abc import Callable
from typing import Any
from uuid import uuid4
from src.events import record_event
from src.human import HumanReviewError, create_review, decide_review
from src.persistence import StateStore
from src.registry import HandlerRegistry
from src.retry import should_retry
from src.schemas import (
EventType,
HumanDecision,
HumanReviewRequest,
NodeDefinition,
NodeRun,
NodeStatus,
NodeType,
WorkflowDefinition,
WorkflowRun,
WorkflowStatus,
utc_now,
)
from src.validator import topological_order
class WorkflowExecutionError(RuntimeError):
"""Raised when execution fails while preserving the failed workflow state."""
def __init__(self, message: str, run: WorkflowRun) -> None:
super().__init__(message)
self.run = run
class WorkflowResumeError(RuntimeError):
"""Raised when a persisted workflow cannot be resumed safely."""
def __init__(self, message: str, run: WorkflowRun) -> None:
super().__init__(message)
self.run = run
class WorkflowHumanDecisionError(RuntimeError):
"""Raised when a human decision cannot be applied safely."""
def __init__(self, message: str, run: WorkflowRun) -> None:
super().__init__(message)
self.run = run
def execute_workflow(
definition: WorkflowDefinition,
registry: HandlerRegistry,
*,
context: dict[str, Any] | None = None,
run_id: str | None = None,
state_store: StateStore | None = None,
) -> WorkflowRun:
"""Start and execute a validated workflow until completion or a human gate."""
order = topological_order(definition)
node_by_id = {node.node_id: node for node in definition.nodes}
run = WorkflowRun(
run_id=run_id or str(uuid4()),
workflow_id=definition.workflow_id,
workflow_version=definition.version,
status=WorkflowStatus.RUNNING,
node_runs={
node.node_id: NodeRun(node_id=node.node_id, status=NodeStatus.PENDING)
for node in definition.nodes
},
context=dict(context or {}),
)
record_event(
run,
EventType.WORKFLOW_STARTED,
details={"workflow_id": definition.workflow_id, "version": definition.version},
)
_checkpoint(state_store, run)
return _run_remaining_nodes(
definition,
registry,
run,
order=order,
node_by_id=node_by_id,
state_store=state_store,
)
def resume_workflow(
definition: WorkflowDefinition,
registry: HandlerRegistry,
*,
run_id: str,
state_store: StateStore,
) -> WorkflowRun:
"""Resume a safe non-human checkpoint without repeating completed nodes.
Completed runs are returned unchanged, making resume idempotent. A run waiting
at a HUMAN_GATE must use submit_human_decision instead of this function. A node
persisted as RUNNING is deliberately not replayed automatically because its
handler may have produced side effects before the process stopped.
"""
order = topological_order(definition)
node_by_id = {node.node_id: node for node in definition.nodes}
run = state_store.load(run_id)
_validate_resume_checkpoint(definition, run)
if run.status is WorkflowStatus.COMPLETED:
return run
previous_status = run.status
run.status = WorkflowStatus.RUNNING
run.updated_at = utc_now()
record_event(
run,
EventType.WORKFLOW_RESUMED,
details={"previous_status": previous_status.value},
)
_checkpoint(state_store, run)
return _run_remaining_nodes(
definition,
registry,
run,
order=order,
node_by_id=node_by_id,
state_store=state_store,
)
def submit_human_decision(
definition: WorkflowDefinition,
registry: HandlerRegistry,
*,
run_id: str,
review_id: str,
decision: HumanDecision | str,
state_store: StateStore,
) -> WorkflowRun:
"""Apply one explicit decision to a durable HUMAN_GATE checkpoint.
APPROVE completes the gate and resumes downstream execution. REJECT completes
the gate but terminates the overall workflow as REJECTED. RETRY records the
decision and opens a fresh review request for the same gate without replaying
upstream tasks.
"""
order = topological_order(definition)
node_by_id = {node.node_id: node for node in definition.nodes}
run = state_store.load(run_id)
try:
normalized_decision = HumanDecision(decision)
except ValueError as exc:
raise WorkflowHumanDecisionError(
f"unknown human decision '{decision}'",
run,
) from exc
try:
review = _validate_human_checkpoint(definition, run, review_id)
decided_review = decide_review(
run,
review_id=review_id,
decision=normalized_decision,
)
except (HumanReviewError, WorkflowResumeError) as exc:
raise WorkflowHumanDecisionError(str(exc), run) from exc
node = node_by_id[decided_review.node_id]
node_run = run.node_runs[node.node_id]
now = utc_now()
if normalized_decision is HumanDecision.RETRY:
create_review(
run,
node_id=node.node_id,
reason=decided_review.reason,
retry_of=decided_review.review_id,
)
run.status = WorkflowStatus.WAITING_FOR_HUMAN
run.updated_at = now
_checkpoint(state_store, run)
return run
node_run.status = NodeStatus.COMPLETED
node_run.output = {
"decision": normalized_decision.value,
"review_id": decided_review.review_id,
}
node_run.error = None
node_run.completed_at = now
run.updated_at = now
if normalized_decision is HumanDecision.REJECT:
record_event(
run,
EventType.HUMAN_REJECTED,
node_id=node.node_id,
details={"review_id": decided_review.review_id},
)
record_event(
run,
EventType.NODE_COMPLETED,
node_id=node.node_id,
details={"human_decision": HumanDecision.REJECT.value},
)
run.status = WorkflowStatus.REJECTED
_checkpoint(state_store, run)
return run
record_event(
run,
EventType.HUMAN_APPROVED,
node_id=node.node_id,
details={"review_id": decided_review.review_id},
)
record_event(
run,
EventType.NODE_COMPLETED,
node_id=node.node_id,
details={"human_decision": HumanDecision.APPROVE.value},
)
run.status = WorkflowStatus.RUNNING
record_event(
run,
EventType.WORKFLOW_RESUMED,
details={
"previous_status": WorkflowStatus.WAITING_FOR_HUMAN.value,
"review_id": decided_review.review_id,
},
)
_checkpoint(state_store, run)
return _run_remaining_nodes(
definition,
registry,
run,
order=order,
node_by_id=node_by_id,
state_store=state_store,
)
def _run_remaining_nodes(
definition: WorkflowDefinition,
registry: HandlerRegistry,
run: WorkflowRun,
*,
order: list[str],
node_by_id: dict[str, NodeDefinition],
state_store: StateStore | None,
) -> WorkflowRun:
for node_id in order:
node_run = run.node_runs[node_id]
if node_run.status in {NodeStatus.COMPLETED, NodeStatus.SKIPPED}:
continue
skipped_dependencies = [
dependency_id
for dependency_id in node_by_id[node_id].depends_on
if run.node_runs[dependency_id].status is NodeStatus.SKIPPED
]
if skipped_dependencies:
_mark_node_skipped(
run,
node_run,
details={"skipped_dependencies": sorted(skipped_dependencies)},
state_store=state_store,
)
continue
if node_run.status not in {
NodeStatus.PENDING,
NodeStatus.READY,
NodeStatus.RETRY_SCHEDULED,
}:
raise WorkflowResumeError(
f"node '{node_id}' is in non-resumable state '{node_run.status.value}'",
run,
)
node = node_by_id[node_id]
if node.node_type is NodeType.TASK:
_execute_task_node(node, run, registry, state_store)
continue
if node.node_type is NodeType.DECISION:
_execute_decision_node(node, run, registry, state_store)
continue
if node.node_type is NodeType.HUMAN_GATE:
return _pause_for_human_review(node, run, state_store)
_mark_preexecution_failure(
run,
node_run,
f"node '{node.node_id}' uses unsupported node type '{node.node_type.value}' "
"in the current executor",
state_store,
)
run.status = WorkflowStatus.COMPLETED
run.updated_at = utc_now()
run.final_output = _build_final_output(definition, run)
record_event(
run,
EventType.WORKFLOW_COMPLETED,
details={"terminal_nodes": sorted(run.final_output)},
)
_checkpoint(state_store, run)
return run
def _pause_for_human_review(
node: NodeDefinition,
run: WorkflowRun,
state_store: StateStore | None,
) -> WorkflowRun:
node_run = run.node_runs[node.node_id]
if state_store is None:
_mark_preexecution_failure(
run,
node_run,
f"HUMAN_GATE node '{node.node_id}' requires a state_store",
state_store,
)
reason = node.config.get("reason", node.name)
if not isinstance(reason, str) or not reason.strip():
_mark_preexecution_failure(
run,
node_run,
f"HUMAN_GATE node '{node.node_id}' requires a non-blank string reason",
state_store,
)
now = utc_now()
node_run.status = NodeStatus.WAITING_FOR_HUMAN
node_run.started_at = node_run.started_at or now
node_run.error = None
run.status = WorkflowStatus.WAITING_FOR_HUMAN
run.updated_at = now
record_event(
run,
EventType.NODE_STARTED,
node_id=node.node_id,
details={"node_type": NodeType.HUMAN_GATE.value},
)
create_review(run, node_id=node.node_id, reason=reason.strip())
_checkpoint(state_store, run)
return run
def _execute_task_node(
node: NodeDefinition,
run: WorkflowRun,
registry: HandlerRegistry,
state_store: StateStore | None,
) -> None:
_execute_handler_node(node, run, registry, state_store)
def _execute_decision_node(
node: NodeDefinition,
run: WorkflowRun,
registry: HandlerRegistry,
state_store: StateStore | None,
) -> None:
def validate_route(output: dict[str, Any]) -> str:
route = output.get("route")
if not isinstance(route, str) or not route.strip():
raise ValueError(
f"DECISION node '{node.node_id}' must return a non-blank string route"
)
if route not in node.routes:
allowed = ", ".join(sorted(node.routes))
raise ValueError(
f"DECISION node '{node.node_id}' returned unknown route '{route}'; "
f"allowed routes: {allowed}"
)
return route
_, route = _execute_handler_node(
node,
run,
registry,
state_store,
output_validator=validate_route,
)
selected_target = node.routes[route]
record_event(
run,
EventType.DECISION_ROUTED,
node_id=node.node_id,
details={"route": route, "selected_target": selected_target},
)
for route_label, target_node_id in sorted(node.routes.items()):
if target_node_id == selected_target:
continue
target_run = run.node_runs[target_node_id]
if target_run.status in {NodeStatus.COMPLETED, NodeStatus.RUNNING}:
_mark_preexecution_failure(
run,
target_run,
f"unselected route target '{target_node_id}' has already started",
state_store,
)
if target_run.status is not NodeStatus.SKIPPED:
_mark_node_skipped(
run,
target_run,
details={
"decision_node": node.node_id,
"unselected_route": route_label,
"selected_route": route,
},
state_store=state_store,
)
_checkpoint(state_store, run)
def _execute_handler_node(
node: NodeDefinition,
run: WorkflowRun,
registry: HandlerRegistry,
state_store: StateStore | None,
*,
output_validator: Callable[[dict[str, Any]], Any] | None = None,
) -> tuple[dict[str, Any], Any]:
node_run = run.node_runs[node.node_id]
if node.handler is None:
_mark_preexecution_failure(
run,
node_run,
f"{node.node_type.value} node '{node.node_id}' must declare a handler",
state_store,
)
dependency_outputs = {
dependency_id: run.node_runs[dependency_id].output
for dependency_id in node.depends_on
}
payload = {
"context": dict(run.context),
"dependencies": dependency_outputs,
"config": dict(node.config),
"node_id": node.node_id,
}
while True:
node_run.status = NodeStatus.RUNNING
node_run.attempt += 1
node_run.error = None
node_run.completed_at = None
node_run.started_at = utc_now()
run.status = WorkflowStatus.RUNNING
run.updated_at = node_run.started_at
record_event(
run,
EventType.NODE_STARTED,
node_id=node.node_id,
details={"handler": node.handler, "attempt": node_run.attempt},
)
_checkpoint(state_store, run)
try:
handler = registry.resolve(node.handler)
output = handler(payload)
if not isinstance(output, dict):
raise TypeError("handler output must be a dictionary")
validated_output = (
output_validator(output) if output_validator is not None else None
)
except Exception as exc:
retry = should_retry(
node.retry_policy,
attempt=node_run.attempt,
error=exc,
)
node_run.error = str(exc)
run.updated_at = utc_now()
record_event(
run,
EventType.NODE_FAILED,
node_id=node.node_id,
details={
"attempt": node_run.attempt,
"error": str(exc),
"will_retry": retry,
},
)
if retry:
node_run.status = NodeStatus.RETRY_SCHEDULED
run.status = WorkflowStatus.RETRY_SCHEDULED
record_event(
run,
EventType.RETRY_SCHEDULED,
node_id=node.node_id,
details={
"attempt": node_run.attempt,
"next_attempt": node_run.attempt + 1,
"max_attempts": node.retry_policy.max_attempts,
},
)
_checkpoint(state_store, run)
continue
node_run.status = NodeStatus.FAILED
node_run.completed_at = utc_now()
run.status = WorkflowStatus.FAILED
run.updated_at = node_run.completed_at
record_event(
run,
EventType.WORKFLOW_FAILED,
details={"failed_node": node.node_id},
)
_checkpoint(state_store, run)
raise WorkflowExecutionError(
f"node '{node.node_id}' failed: {exc}",
run,
) from exc
node_run.output = output
node_run.error = None
node_run.status = NodeStatus.COMPLETED
node_run.completed_at = utc_now()
run.updated_at = node_run.completed_at
record_event(
run,
EventType.NODE_COMPLETED,
node_id=node.node_id,
details={"attempt": node_run.attempt},
)
_checkpoint(state_store, run)
return output, validated_output
def _mark_node_skipped(
run: WorkflowRun,
node_run: NodeRun,
*,
details: dict[str, Any],
state_store: StateStore | None,
) -> None:
node_run.status = NodeStatus.SKIPPED
node_run.output = None
node_run.error = None
node_run.completed_at = utc_now()
run.updated_at = node_run.completed_at
record_event(
run,
EventType.NODE_SKIPPED,
node_id=node_run.node_id,
details=details,
)
_checkpoint(state_store, run)
def _mark_preexecution_failure(
run: WorkflowRun,
node_run: NodeRun,
message: str,
state_store: StateStore | None,
) -> None:
node_run.status = NodeStatus.FAILED
node_run.error = message
node_run.completed_at = utc_now()
run.status = WorkflowStatus.FAILED
run.updated_at = node_run.completed_at
record_event(
run,
EventType.NODE_FAILED,
node_id=node_run.node_id,
details={"attempt": node_run.attempt, "error": message, "will_retry": False},
)
record_event(
run,
EventType.WORKFLOW_FAILED,
details={"failed_node": node_run.node_id},
)
_checkpoint(state_store, run)
raise WorkflowExecutionError(message, run)
def _validate_checkpoint_identity(
definition: WorkflowDefinition,
run: WorkflowRun,
) -> None:
if run.workflow_id != definition.workflow_id:
raise WorkflowResumeError(
f"checkpoint workflow_id '{run.workflow_id}' does not match "
f"definition '{definition.workflow_id}'",
run,
)
if run.workflow_version != definition.version:
raise WorkflowResumeError(
f"checkpoint workflow version '{run.workflow_version}' does not match "
f"definition version '{definition.version}'",
run,
)
expected_node_ids = {node.node_id for node in definition.nodes}
actual_node_ids = set(run.node_runs)
if actual_node_ids != expected_node_ids:
raise WorkflowResumeError(
"checkpoint node set does not match the workflow definition",
run,
)
def _validate_resume_checkpoint(
definition: WorkflowDefinition,
run: WorkflowRun,
) -> None:
_validate_checkpoint_identity(definition, run)
if run.status is WorkflowStatus.COMPLETED:
return
if run.status is WorkflowStatus.WAITING_FOR_HUMAN:
raise WorkflowResumeError(
"workflow is waiting for a human decision; use submit_human_decision",
run,
)
if run.status not in {
WorkflowStatus.PENDING,
WorkflowStatus.RUNNING,
WorkflowStatus.RETRY_SCHEDULED,
}:
raise WorkflowResumeError(
f"workflow run is in non-resumable state '{run.status.value}'",
run,
)
resumable_node_states = {
NodeStatus.PENDING,
NodeStatus.READY,
NodeStatus.RETRY_SCHEDULED,
NodeStatus.COMPLETED,
NodeStatus.SKIPPED,
}
for node in definition.nodes:
node_status = run.node_runs[node.node_id].status
if node_status not in resumable_node_states:
raise WorkflowResumeError(
f"node '{node.node_id}' is in ambiguous or non-resumable state "
f"'{node_status.value}'",
run,
)
if node_status is NodeStatus.COMPLETED:
for dependency_id in node.depends_on:
if run.node_runs[dependency_id].status is not NodeStatus.COMPLETED:
raise WorkflowResumeError(
f"completed node '{node.node_id}' has incomplete dependency "
f"'{dependency_id}'",
run,
)
def _validate_human_checkpoint(
definition: WorkflowDefinition,
run: WorkflowRun,
review_id: str,
) -> HumanReviewRequest:
_validate_checkpoint_identity(definition, run)
if run.status is not WorkflowStatus.WAITING_FOR_HUMAN:
raise WorkflowResumeError(
f"workflow run is not waiting for a human decision; status is "
f"'{run.status.value}'",
run,
)
review = next(
(item for item in run.human_reviews if item.review_id == review_id),
None,
)
if review is None:
raise HumanReviewError(f"human review '{review_id}' was not found")
if review.decision is not None:
raise HumanReviewError(
f"human review '{review_id}' already has decision '{review.decision.value}'"
)
node_by_id = {node.node_id: node for node in definition.nodes}
node = node_by_id[review.node_id]
if node.node_type is not NodeType.HUMAN_GATE:
raise WorkflowResumeError(
f"human review '{review_id}' does not reference a HUMAN_GATE node",
run,
)
node_run = run.node_runs[review.node_id]
if node_run.status is not NodeStatus.WAITING_FOR_HUMAN:
raise WorkflowResumeError(
f"human gate node '{review.node_id}' is not waiting for human input",
run,
)
for dependency_id in node.depends_on:
if run.node_runs[dependency_id].status is not NodeStatus.COMPLETED:
raise WorkflowResumeError(
f"human gate '{node.node_id}' has incomplete dependency "
f"'{dependency_id}'",
run,
)
return review
def _checkpoint(state_store: StateStore | None, run: WorkflowRun) -> None:
if state_store is not None:
state_store.save(run)
def _build_final_output(
definition: WorkflowDefinition,
run: WorkflowRun,
) -> dict[str, Any]:
dependents = {node.node_id: 0 for node in definition.nodes}
for node in definition.nodes:
for dependency_id in node.depends_on:
dependents[dependency_id] += 1
terminal_node_ids = sorted(
node_id
for node_id, dependent_count in dependents.items()
if dependent_count == 0
and run.node_runs[node_id].status is NodeStatus.COMPLETED
)
return {
node_id: run.node_runs[node_id].output
for node_id in terminal_node_ids
}
|