This commit is contained in:
2026-07-06 23:15:30 +08:00
parent c7ae2d86ec
commit 1fb62f9d45
15 changed files with 1865 additions and 44 deletions
+27
View File
@@ -0,0 +1,27 @@
"""Persisted workflow orchestration over tasks, skills, and conditions."""
from workflow.models import (
BranchStep,
ConditionSpec,
PlannedGoalStep,
SkillInvocationStep,
WaitForConditionStep,
WorkflowDefinition,
WorkflowRun,
WorkflowStepResult,
)
from workflow.runner import WorkflowRunner
from workflow.store import WorkflowStore
__all__ = [
"BranchStep",
"ConditionSpec",
"PlannedGoalStep",
"SkillInvocationStep",
"WaitForConditionStep",
"WorkflowDefinition",
"WorkflowRun",
"WorkflowRunner",
"WorkflowStepResult",
"WorkflowStore",
]
+128
View File
@@ -0,0 +1,128 @@
from __future__ import annotations
from datetime import datetime
from typing import Protocol, Sequence
from core.models import Scene
from workflow.models import ConditionSpec, WorkflowStepResult
class UnknownConditionKindError(Exception):
pass
class ConditionEvaluator(Protocol):
def evaluate(
self,
spec: ConditionSpec,
*,
scene: Scene | None,
world_state: object | None,
started_at: datetime | None = None,
step_results: Sequence[WorkflowStepResult] = (),
) -> bool:
...
class SceneContainsTextEvaluator:
def evaluate(
self,
spec: ConditionSpec,
*,
scene: Scene | None,
world_state: object | None,
started_at: datetime | None = None,
step_results: Sequence[WorkflowStepResult] = (),
) -> bool:
if scene is None:
return False
target = str(spec.params.get("text") or spec.params.get("target") or "")
if not target:
return False
target_lower = target.lower()
return any(
target_lower in str(element.text or "").lower()
for element in scene.elements
)
class WorldVariableEqualsEvaluator:
def evaluate(
self,
spec: ConditionSpec,
*,
scene: Scene | None,
world_state: object | None,
started_at: datetime | None = None,
step_results: Sequence[WorkflowStepResult] = (),
) -> bool:
variables = getattr(world_state, "variables", None)
if not isinstance(variables, dict):
return False
name = spec.params.get("name")
if not isinstance(name, str):
return False
return variables.get(name) == spec.params.get("value")
class ElapsedSecondsEvaluator:
def evaluate(
self,
spec: ConditionSpec,
*,
scene: Scene | None,
world_state: object | None,
started_at: datetime | None = None,
step_results: Sequence[WorkflowStepResult] = (),
) -> bool:
if started_at is None:
return False
seconds = float(spec.params.get("seconds") or 0)
return (datetime.now(started_at.tzinfo) - started_at).total_seconds() >= seconds
class StepResultSuccessEvaluator:
def evaluate(
self,
spec: ConditionSpec,
*,
scene: Scene | None,
world_state: object | None,
started_at: datetime | None = None,
step_results: Sequence[WorkflowStepResult] = (),
) -> bool:
target_step_id = spec.params.get("step_id")
return any(
result.step_id == target_step_id and result.success
for result in step_results
)
DEFAULT_CONDITION_REGISTRY: dict[str, ConditionEvaluator] = {
"scene_contains_text": SceneContainsTextEvaluator(),
"world_variable_equals": WorldVariableEqualsEvaluator(),
"elapsed_seconds": ElapsedSecondsEvaluator(),
"step_result_success": StepResultSuccessEvaluator(),
}
def evaluate_condition(
spec: ConditionSpec,
*,
scene: Scene | None,
world_state: object | None,
started_at: datetime | None = None,
step_results: Sequence[WorkflowStepResult] = (),
registry: dict[str, ConditionEvaluator] | None = None,
) -> bool:
evaluators = registry or DEFAULT_CONDITION_REGISTRY
evaluator = evaluators.get(spec.kind)
if evaluator is None:
raise UnknownConditionKindError(spec.kind)
return evaluator.evaluate(
spec,
scene=scene,
world_state=world_state,
started_at=started_at,
step_results=step_results,
)
+45
View File
@@ -0,0 +1,45 @@
from __future__ import annotations
import os
from collections.abc import Mapping
from dataclasses import dataclass
DEFAULT_POLL_INTERVAL_SECONDS = 0.25
DEFAULT_WAIT_TIMEOUT_SECONDS = 10.0
DEFAULT_WORKFLOW_DB_PATH = "workflows/workflows.sqlite3"
POLL_INTERVAL_ENV = "WORKFLOW_POLL_INTERVAL_SECONDS"
WAIT_TIMEOUT_ENV = "WORKFLOW_WAIT_TIMEOUT_SECONDS"
DB_PATH_ENV = "WORKFLOW_DB_PATH"
@dataclass(frozen=True)
class WorkflowConfig:
poll_interval_seconds: float = DEFAULT_POLL_INTERVAL_SECONDS
wait_timeout_seconds: float = DEFAULT_WAIT_TIMEOUT_SECONDS
db_path: str = DEFAULT_WORKFLOW_DB_PATH
def load_config(env: Mapping[str, str] | None = None) -> WorkflowConfig:
values = env or os.environ
return WorkflowConfig(
poll_interval_seconds=_parse_float(
values.get(POLL_INTERVAL_ENV),
default=DEFAULT_POLL_INTERVAL_SECONDS,
),
wait_timeout_seconds=_parse_float(
values.get(WAIT_TIMEOUT_ENV),
default=DEFAULT_WAIT_TIMEOUT_SECONDS,
),
db_path=values.get(DB_PATH_ENV) or DEFAULT_WORKFLOW_DB_PATH,
)
def _parse_float(value: str | None, *, default: float) -> float:
if value is None:
return default
try:
parsed = float(value)
except ValueError:
return default
return parsed if parsed >= 0 else default
+277
View File
@@ -0,0 +1,277 @@
from __future__ import annotations
from dataclasses import dataclass, field
from datetime import datetime
from typing import Any, ClassVar, Literal
from uuid import uuid4
from core.models import utc_now
WorkflowRunStatus = Literal["pending", "running", "waiting", "completed", "failed", "cancelled"]
@dataclass(frozen=True)
class ConditionSpec:
kind: str
params: dict[str, Any] = field(default_factory=dict)
def to_dict(self) -> dict[str, Any]:
return {"kind": self.kind, "params": dict(self.params)}
@classmethod
def from_dict(cls, data: dict[str, Any]) -> "ConditionSpec":
return cls(kind=str(data["kind"]), params=dict(data.get("params") or {}))
@dataclass(frozen=True)
class PlannedGoalStep:
step_id: str
goal: str
next_step_id: str | None = None
kind: ClassVar[str] = "planned_goal"
def to_dict(self) -> dict[str, Any]:
return {
"kind": self.kind,
"step_id": self.step_id,
"goal": self.goal,
"next_step_id": self.next_step_id,
}
@dataclass(frozen=True)
class SkillInvocationStep:
step_id: str
skill_id: str
args: dict[str, Any] = field(default_factory=dict)
next_step_id: str | None = None
kind: ClassVar[str] = "skill_invocation"
def to_dict(self) -> dict[str, Any]:
return {
"kind": self.kind,
"step_id": self.step_id,
"skill_id": self.skill_id,
"args": dict(self.args),
"next_step_id": self.next_step_id,
}
@dataclass(frozen=True)
class WaitForConditionStep:
step_id: str
condition: ConditionSpec
timeout_seconds: float
poll_interval_seconds: float
next_step_id: str | None = None
kind: ClassVar[str] = "wait_for_condition"
def to_dict(self) -> dict[str, Any]:
return {
"kind": self.kind,
"step_id": self.step_id,
"condition": self.condition.to_dict(),
"timeout_seconds": self.timeout_seconds,
"poll_interval_seconds": self.poll_interval_seconds,
"next_step_id": self.next_step_id,
}
@dataclass(frozen=True)
class BranchStep:
step_id: str
condition: ConditionSpec
on_true: str
on_false: str
kind: ClassVar[str] = "branch"
def to_dict(self) -> dict[str, Any]:
return {
"kind": self.kind,
"step_id": self.step_id,
"condition": self.condition.to_dict(),
"on_true": self.on_true,
"on_false": self.on_false,
}
WorkflowStep = PlannedGoalStep | SkillInvocationStep | WaitForConditionStep | BranchStep
@dataclass(frozen=True)
class WorkflowDefinition:
name: str
steps: list[WorkflowStep]
entry_step_id: str
id: str = field(default_factory=lambda: uuid4().hex)
def __post_init__(self) -> None:
if not self.steps:
raise ValueError("workflow definition requires at least one step")
step_ids = [step.step_id for step in self.steps]
if len(step_ids) != len(set(step_ids)):
raise ValueError("workflow definition contains duplicate step_id values")
known = set(step_ids)
if self.entry_step_id not in known:
raise ValueError(f"entry_step_id {self.entry_step_id} is not a workflow step")
for step in self.steps:
for target in _step_targets(step):
if target is not None and target not in known:
raise ValueError(
f"step {step.step_id} references unknown step {target}"
)
def step_by_id(self, step_id: str) -> WorkflowStep:
for step in self.steps:
if step.step_id == step_id:
return step
raise KeyError(step_id)
def next_step_id_after(self, step_id: str) -> str | None:
for index, step in enumerate(self.steps):
if step.step_id == step_id:
if index + 1 >= len(self.steps):
return None
return self.steps[index + 1].step_id
raise KeyError(step_id)
def to_dict(self) -> dict[str, Any]:
return {
"id": self.id,
"name": self.name,
"entry_step_id": self.entry_step_id,
"steps": [step.to_dict() for step in self.steps],
}
@classmethod
def from_dict(cls, data: dict[str, Any]) -> "WorkflowDefinition":
return cls(
id=str(data["id"]),
name=str(data["name"]),
entry_step_id=str(data["entry_step_id"]),
steps=[workflow_step_from_dict(step) for step in data.get("steps", [])],
)
@dataclass(frozen=True)
class WorkflowStepResult:
step_id: str
kind: str
success: bool
detail: dict[str, Any] = field(default_factory=dict)
task_id: str | None = None
timestamp: datetime = field(default_factory=utc_now)
def to_dict(self) -> dict[str, Any]:
return {
"step_id": self.step_id,
"kind": self.kind,
"success": self.success,
"detail": dict(self.detail),
"task_id": self.task_id,
"timestamp": self.timestamp.isoformat(),
}
@classmethod
def from_dict(cls, data: dict[str, Any]) -> "WorkflowStepResult":
return cls(
step_id=str(data["step_id"]),
kind=str(data["kind"]),
success=bool(data["success"]),
detail=dict(data.get("detail") or {}),
task_id=data.get("task_id"),
timestamp=_parse_datetime(data.get("timestamp")),
)
@dataclass(frozen=True)
class WorkflowRun:
definition_id: str
status: WorkflowRunStatus
current_step_id: str | None
variables: dict[str, Any] = field(default_factory=dict)
step_results: list[WorkflowStepResult] = field(default_factory=list)
id: str = field(default_factory=lambda: uuid4().hex)
device_id: str | None = None
created_at: datetime = field(default_factory=utc_now)
updated_at: datetime = field(default_factory=utc_now)
def to_dict(self) -> dict[str, Any]:
return {
"id": self.id,
"definition_id": self.definition_id,
"status": self.status,
"current_step_id": self.current_step_id,
"device_id": self.device_id,
"variables": dict(self.variables),
"step_results": [result.to_dict() for result in self.step_results],
"created_at": self.created_at.isoformat(),
"updated_at": self.updated_at.isoformat(),
}
@classmethod
def from_dict(cls, data: dict[str, Any]) -> "WorkflowRun":
return cls(
id=str(data["id"]),
definition_id=str(data["definition_id"]),
status=data["status"],
current_step_id=data.get("current_step_id"),
device_id=data.get("device_id"),
variables=dict(data.get("variables") or {}),
step_results=[
WorkflowStepResult.from_dict(result)
for result in data.get("step_results", [])
],
created_at=_parse_datetime(data.get("created_at")),
updated_at=_parse_datetime(data.get("updated_at")),
)
def workflow_step_from_dict(data: dict[str, Any]) -> WorkflowStep:
kind = data.get("kind")
if kind == PlannedGoalStep.kind:
return PlannedGoalStep(
step_id=str(data["step_id"]),
goal=str(data["goal"]),
next_step_id=data.get("next_step_id"),
)
if kind == SkillInvocationStep.kind:
return SkillInvocationStep(
step_id=str(data["step_id"]),
skill_id=str(data["skill_id"]),
args=dict(data.get("args") or {}),
next_step_id=data.get("next_step_id"),
)
if kind == WaitForConditionStep.kind:
return WaitForConditionStep(
step_id=str(data["step_id"]),
condition=ConditionSpec.from_dict(data["condition"]),
timeout_seconds=float(data["timeout_seconds"]),
poll_interval_seconds=float(data["poll_interval_seconds"]),
next_step_id=data.get("next_step_id"),
)
if kind == BranchStep.kind:
return BranchStep(
step_id=str(data["step_id"]),
condition=ConditionSpec.from_dict(data["condition"]),
on_true=str(data["on_true"]),
on_false=str(data["on_false"]),
)
raise ValueError(f"unknown workflow step kind {kind}")
def _step_targets(step: WorkflowStep) -> list[str | None]:
if isinstance(step, BranchStep):
return [step.on_true, step.on_false]
return [step.next_step_id]
def _parse_datetime(value: Any) -> datetime:
if isinstance(value, datetime):
return value
if isinstance(value, str):
try:
return datetime.fromisoformat(value)
except ValueError:
pass
return utc_now()
+321
View File
@@ -0,0 +1,321 @@
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)
+77
View File
@@ -0,0 +1,77 @@
from __future__ import annotations
from typing import Any
from runtime.executor import Executor, ExecutorConfig, StepResult, ToolCallable
from runtime.planner import PlannedStep
from skills_learning.models import FlowTemplateSkill
class SkillExecutionError(Exception):
pass
def validate_skill_args(skill: FlowTemplateSkill, args: dict[str, Any]) -> None:
if skill.metadata.kind != "flow_template":
raise SkillExecutionError(f"skill {skill.id} is not a flow_template skill")
required = set(skill.parameters)
provided = set(args)
missing = sorted(required - provided)
extra = sorted(provided - required)
errors: list[str] = []
if missing:
errors.append(f"missing required parameters: {', '.join(missing)}")
if extra:
errors.append(f"unrecognized parameters: {', '.join(extra)}")
if errors:
raise SkillExecutionError("; ".join(errors))
def resolve_skill_steps(
skill: FlowTemplateSkill,
args: dict[str, Any],
) -> list[dict[str, Any]]:
validate_skill_args(skill, args)
return [
{
"tool_name": step.tool_name,
"args": _resolve_value(step.args, args),
}
for step in skill.steps
]
def run_flow_template_skill(
skill: FlowTemplateSkill,
args: dict[str, Any],
*,
tools: dict[str, ToolCallable] | None = None,
) -> list[StepResult]:
resolved_steps = resolve_skill_steps(skill, args)
executor = Executor(
tools=tools,
config=ExecutorConfig(max_retries=1, backoff_seconds=0),
)
results: list[StepResult] = []
for index, resolved in enumerate(resolved_steps, start=1):
step = PlannedStep(
action=resolved["tool_name"],
description=f"Run skill {skill.name} step {index}",
args=resolved["args"],
)
result = executor.execute(step)
results.append(result)
if not result.success:
break
return results
def _resolve_value(value: Any, args: dict[str, Any]) -> Any:
if isinstance(value, str) and value.startswith("{") and value.endswith("}"):
name = value[1:-1]
return args[name]
if isinstance(value, dict):
return {key: _resolve_value(nested, args) for key, nested in value.items()}
if isinstance(value, list):
return [_resolve_value(item, args) for item in value]
return value
+244
View File
@@ -0,0 +1,244 @@
from __future__ import annotations
import json
import sqlite3
from pathlib import Path
from typing import Any
from core.models import utc_now
from workflow.config import DEFAULT_WORKFLOW_DB_PATH
from workflow.models import (
WorkflowDefinition,
WorkflowRun,
WorkflowRunStatus,
WorkflowStepResult,
)
class WorkflowStore:
def __init__(self, db_path: str | Path = DEFAULT_WORKFLOW_DB_PATH) -> None:
self.db_path = Path(db_path)
self.db_path.parent.mkdir(parents=True, exist_ok=True)
self._ensure_schema()
def save_definition(self, definition: WorkflowDefinition) -> None:
with self._connect() as connection:
connection.execute(
"""
insert into workflow_definitions (id, name, definition_json)
values (?, ?, ?)
on conflict(id) do update set
name = excluded.name,
definition_json = excluded.definition_json
""",
(
definition.id,
definition.name,
json.dumps(definition.to_dict(), ensure_ascii=False),
),
)
def get_definition(self, definition_id: str) -> WorkflowDefinition | None:
with self._connect() as connection:
row = connection.execute(
"select definition_json from workflow_definitions where id = ?",
(definition_id,),
).fetchone()
if row is None:
return None
return WorkflowDefinition.from_dict(json.loads(row["definition_json"]))
def create_run(
self,
definition_id: str,
initial_variables: dict[str, Any] | None = None,
*,
device_id: str | None = None,
) -> WorkflowRun:
definition = self.get_definition(definition_id)
if definition is None:
raise KeyError(f"unknown workflow definition {definition_id}")
run = WorkflowRun(
definition_id=definition_id,
status="running",
current_step_id=definition.entry_step_id,
variables=dict(initial_variables or {}),
device_id=device_id,
)
with self._connect() as connection:
connection.execute(
"""
insert into workflow_runs (
id, definition_id, status, current_step_id, device_id,
variables_json, created_at, updated_at
) values (?, ?, ?, ?, ?, ?, ?, ?)
""",
(
run.id,
run.definition_id,
run.status,
run.current_step_id,
run.device_id,
json.dumps(run.variables, ensure_ascii=False),
run.created_at.isoformat(),
run.updated_at.isoformat(),
),
)
return run
def append_step_result(
self,
run_id: str,
step_result: WorkflowStepResult,
) -> None:
with self._connect() as connection:
index = (
connection.execute(
"select count(*) as count from workflow_step_results where run_id = ?",
(run_id,),
).fetchone()["count"]
+ 1
)
connection.execute(
"""
insert into workflow_step_results (
run_id, step_index, step_id, kind, success, detail_json,
task_id, timestamp
) values (?, ?, ?, ?, ?, ?, ?, ?)
""",
(
run_id,
index,
step_result.step_id,
step_result.kind,
1 if step_result.success else 0,
json.dumps(step_result.detail, ensure_ascii=False),
step_result.task_id,
step_result.timestamp.isoformat(),
),
)
def update_run(
self,
run_id: str,
*,
status: WorkflowRunStatus | None = None,
current_step_id: str | None = None,
variables: dict[str, Any] | None = None,
) -> None:
run = self.get_run(run_id)
if run is None:
raise KeyError(f"unknown workflow run {run_id}")
next_status = status or run.status
next_step_id = current_step_id
if current_step_id is None and next_status not in {
"completed",
"failed",
"cancelled",
}:
next_step_id = run.current_step_id
next_variables = dict(run.variables if variables is None else variables)
with self._connect() as connection:
connection.execute(
"""
update workflow_runs
set status = ?,
current_step_id = ?,
variables_json = ?,
updated_at = ?
where id = ?
""",
(
next_status,
next_step_id,
json.dumps(next_variables, ensure_ascii=False),
utc_now().isoformat(),
run_id,
),
)
def get_run(self, run_id: str) -> WorkflowRun | None:
with self._connect() as connection:
run_row = connection.execute(
"select * from workflow_runs where id = ?",
(run_id,),
).fetchone()
if run_row is None:
return None
result_rows = connection.execute(
"""
select * from workflow_step_results
where run_id = ?
order by step_index
""",
(run_id,),
).fetchall()
return WorkflowRun.from_dict(
{
"id": run_row["id"],
"definition_id": run_row["definition_id"],
"status": run_row["status"],
"current_step_id": run_row["current_step_id"],
"device_id": run_row["device_id"],
"variables": json.loads(run_row["variables_json"]),
"created_at": run_row["created_at"],
"updated_at": run_row["updated_at"],
"step_results": [
{
"step_id": row["step_id"],
"kind": row["kind"],
"success": bool(row["success"]),
"detail": json.loads(row["detail_json"]),
"task_id": row["task_id"],
"timestamp": row["timestamp"],
}
for row in result_rows
],
}
)
def _ensure_schema(self) -> None:
with self._connect() as connection:
connection.execute(
"""
create table if not exists workflow_definitions (
id text primary key,
name text not null,
definition_json text not null
)
"""
)
connection.execute(
"""
create table if not exists workflow_runs (
id text primary key,
definition_id text not null,
status text not null,
current_step_id text,
device_id text,
variables_json text not null,
created_at text not null,
updated_at text not null
)
"""
)
connection.execute(
"""
create table if not exists workflow_step_results (
id integer primary key autoincrement,
run_id text not null,
step_index integer not null,
step_id text not null,
kind text not null,
success integer not null,
detail_json text not null,
task_id text,
timestamp text not null
)
"""
)
def _connect(self) -> sqlite3.Connection:
connection = sqlite3.connect(self.db_path)
connection.row_factory = sqlite3.Row
return connection