from __future__ import annotations from collections.abc import Callable from dataclasses import replace from datetime import datetime from types import SimpleNamespace from typing import Any from time import sleep from core.models import Scene, Task from runtime.task import TaskRunner from skills_learning.store import SkillStore, get_default_store from workflow.conditions import ( ConditionEvaluator, UnknownConditionKindError, evaluate_condition, ) from workflow.config import WorkflowConfig, load_config from workflow.models import ( BranchStep, PlannedGoalStep, SkillInvocationStep, WaitForConditionStep, WorkflowDefinition, WorkflowRun, WorkflowStep, WorkflowStepResult, ) from workflow.skill_exec import SkillExecutionError, run_flow_template_skill from workflow.store import WorkflowStore TaskRunnerFactory = Callable[[], TaskRunner] SceneProvider = Callable[[], Scene | None] WorldStateProvider = Callable[[], object | None] SleepFunc = Callable[[float], None] TERMINAL_STATUSES = {"completed", "failed", "cancelled"} class WorkflowRunner: def __init__( self, store: WorkflowStore | None = None, *, task_runner_factory: TaskRunnerFactory | None = None, skill_store: SkillStore | None = None, tools: dict[str, Callable[..., Any]] | None = None, condition_registry: dict[str, ConditionEvaluator] | None = None, scene_provider: SceneProvider | None = None, world_state_provider: WorldStateProvider | None = None, sleep_func: SleepFunc = sleep, config: WorkflowConfig | None = None, step_limit: int | None = None, ) -> None: self.config = config or load_config() self.store = store or WorkflowStore(self.config.db_path) self.task_runner_factory = task_runner_factory or (lambda: TaskRunner()) self.skill_store = skill_store or get_default_store() self.tools = tools self.condition_registry = condition_registry self.scene_provider = scene_provider or (lambda: None) self.world_state_provider = world_state_provider self.sleep_func = sleep_func self.step_limit = step_limit def run( self, definition: WorkflowDefinition, device_id: str, initial_variables: dict[str, Any] | None = None, ) -> WorkflowRun: self.store.save_definition(definition) run = self.store.create_run( definition.id, initial_variables or {}, device_id=device_id, ) return self._drive(definition, run) def resume(self, run_id: str) -> WorkflowRun: run = self.store.get_run(run_id) if run is None: raise KeyError(f"unknown workflow run {run_id}") if run.status in TERMINAL_STATUSES: return run definition = self.store.get_definition(run.definition_id) if definition is None: raise KeyError(f"unknown workflow definition {run.definition_id}") return self._drive(definition, run) def _drive( self, definition: WorkflowDefinition, run: WorkflowRun, ) -> WorkflowRun: executed = 0 while run.status not in TERMINAL_STATUSES and run.current_step_id: if self.step_limit is not None and executed >= self.step_limit: return run step = definition.step_by_id(run.current_step_id) if _already_recorded(run, step.step_id): next_step_id = self._next_step_id(definition, step, None) run = self._checkpoint(run, next_step_id, "running") continue result, branch_next_step_id = self._execute_step(definition, run, step) next_status = "running" next_step_id = self._next_step_id(definition, step, branch_next_step_id) if not result.success: next_status = "failed" next_step_id = None elif next_step_id is None: next_status = "completed" self.store.append_step_result(run.id, result) run = self._checkpoint( run, next_step_id, next_status, ) executed += 1 return run def _checkpoint( self, run: WorkflowRun, current_step_id: str | None, status: str, ) -> WorkflowRun: self.store.update_run( run.id, status=status, # type: ignore[arg-type] current_step_id=current_step_id, variables=run.variables, ) updated = self.store.get_run(run.id) if updated is None: raise KeyError(f"unknown workflow run {run.id}") return updated def _execute_step( self, definition: WorkflowDefinition, run: WorkflowRun, step: WorkflowStep, ) -> tuple[WorkflowStepResult, str | None]: if isinstance(step, PlannedGoalStep): return self._execute_planned_goal_step(run, step), None if isinstance(step, SkillInvocationStep): return self._execute_skill_invocation_step(step), None if isinstance(step, WaitForConditionStep): return self._execute_wait_step(run, step), None if isinstance(step, BranchStep): return self._execute_branch_step(run, step) return ( WorkflowStepResult( step_id=getattr(step, "step_id", "unknown"), kind="unknown", success=False, detail={"reason": "unknown workflow step type"}, ), None, ) def _execute_planned_goal_step( self, run: WorkflowRun, step: PlannedGoalStep, ) -> WorkflowStepResult: task = Task(goal=step.goal, device_id=run.device_id or "") task_runner = self.task_runner_factory() result_task = task_runner.run(task) success = result_task.status == "completed" return WorkflowStepResult( step_id=step.step_id, kind=step.kind, success=success, detail={ "task_status": result_task.status, "failure_reason": result_task.failure_reason, }, task_id=result_task.id, ) def _execute_skill_invocation_step( self, step: SkillInvocationStep, ) -> WorkflowStepResult: skill = self.skill_store.get_by_id(step.skill_id) if skill is None: return WorkflowStepResult( step_id=step.step_id, kind=step.kind, success=False, detail={"reason": f"unknown skill {step.skill_id}"}, ) try: results = run_flow_template_skill(skill, step.args, tools=self.tools) except SkillExecutionError as exc: return WorkflowStepResult( step_id=step.step_id, kind=step.kind, success=False, detail={"reason": str(exc)}, ) success = all(result.success for result in results) return WorkflowStepResult( step_id=step.step_id, kind=step.kind, success=success, detail={ "step_results": [result.to_dict() for result in results], }, ) def _execute_wait_step( self, run: WorkflowRun, step: WaitForConditionStep, ) -> WorkflowStepResult: started_at = datetime.now().astimezone() timeout_seconds = step.timeout_seconds poll_interval_seconds = step.poll_interval_seconds while True: try: if self._condition_is_true(run, step.condition, started_at=started_at): return WorkflowStepResult( step_id=step.step_id, kind=step.kind, success=True, detail={"condition": step.condition.to_dict()}, ) except UnknownConditionKindError as exc: return WorkflowStepResult( step_id=step.step_id, kind=step.kind, success=False, detail={"reason": f"unknown condition kind: {exc}"}, ) elapsed = ( datetime.now(started_at.tzinfo) - started_at ).total_seconds() if elapsed >= timeout_seconds: return WorkflowStepResult( step_id=step.step_id, kind=step.kind, success=False, detail={"reason": "condition timed out"}, ) self.sleep_func(poll_interval_seconds) def _execute_branch_step( self, run: WorkflowRun, step: BranchStep, ) -> tuple[WorkflowStepResult, str | None]: try: matched = self._condition_is_true(run, step.condition) except UnknownConditionKindError as exc: return ( WorkflowStepResult( step_id=step.step_id, kind=step.kind, success=False, detail={"reason": f"unknown condition kind: {exc}"}, ), None, ) target = step.on_true if matched else step.on_false return ( WorkflowStepResult( step_id=step.step_id, kind=step.kind, success=True, detail={"condition_result": matched, "next_step_id": target}, ), target, ) def _condition_is_true( self, run: WorkflowRun, condition, *, started_at: datetime | None = None, ) -> bool: return evaluate_condition( condition, scene=self.scene_provider(), world_state=self._world_state(run), started_at=started_at, step_results=run.step_results, registry=self.condition_registry, ) def _world_state(self, run: WorkflowRun) -> object: if self.world_state_provider is not None: world_state = self.world_state_provider() if world_state is not None: return world_state return SimpleNamespace(variables=dict(run.variables)) def _next_step_id( self, definition: WorkflowDefinition, step: WorkflowStep, branch_next_step_id: str | None, ) -> str | None: if branch_next_step_id is not None: return branch_next_step_id explicit = getattr(step, "next_step_id", None) if explicit is not None: return explicit if isinstance(step, BranchStep): return None return definition.next_step_id_after(step.step_id) def _already_recorded(run: WorkflowRun, step_id: str) -> bool: return any(result.step_id == step_id for result in run.step_results)