Files
first_contas/legacy_reference/workflows/service.py
2026-06-16 20:54:49 -03:00

529 lines
19 KiB
Python

from __future__ import annotations
from collections.abc import Mapping
import logging
import time
from typing import Any
from uuid import uuid4
from agente_contas_tim.agent.llm_gateway import LLMCapabilityGateway
from agente_contas_tim.factory import CommandFactory
from agente_contas_tim.observability import get_session_id
from agente_contas_tim.workflows.actions.discovery import ensure_actions_loaded
from agente_contas_tim.workflows.actions.registry import (
DEFAULT_ACTION_REGISTRY,
ActionRegistry,
WorkflowRuntimeContext,
)
from agente_contas_tim.workflows.compiler import build_initial_state, compile_workflow
from agente_contas_tim.workflows.execution_store import (
ExecutionStore,
PostgresExecutionStore,
)
from agente_contas_tim.workflows.exceptions import (
WorkflowConfigurationError,
WorkflowExecutionStateError,
WorkflowInputError,
)
from agente_contas_tim.workflows.repositories.base import WorkflowRepository
from agente_contas_tim.workflows.runtime_types import WorkflowRunResponse
logger = logging.getLogger(__name__)
class WorkflowService:
def __init__(
self,
repository: WorkflowRepository,
factory: CommandFactory,
*,
llm_gateway: LLMCapabilityGateway | None = None,
postgres_dsn: str | None = None,
action_registry: ActionRegistry | None = None,
execution_store: ExecutionStore | None = None,
checkpointer: Any | None = None,
checkpointer_manager: Any | None = None,
) -> None:
self._repository = repository
self._factory = factory
self._runtime = WorkflowRuntimeContext(
factory=factory,
llm_gateway=llm_gateway,
workflow_runner=lambda workflow_name, input_payload, execution_id=None, version=None: self.run(
workflow_name,
input_payload,
execution_id=execution_id,
version=version,
),
)
self._registry = action_registry or DEFAULT_ACTION_REGISTRY
self._compiled_cache: dict[tuple[str, int], Any] = {}
self._checkpointer_manager = checkpointer_manager
if execution_store is not None:
self._execution_store = execution_store
else:
if not postgres_dsn:
raise ValueError(
"postgres_dsn e obrigatorio quando execution_store nao e informado"
)
self._execution_store = PostgresExecutionStore(postgres_dsn)
if checkpointer is not None:
self._checkpointer = checkpointer
else:
if not postgres_dsn:
raise ValueError(
"postgres_dsn e obrigatorio quando checkpointer nao e informado"
)
(
self._checkpointer,
self._checkpointer_manager,
) = self._create_checkpointer(postgres_dsn)
ensure_actions_loaded("agente_contas_tim.workflows.actions")
def run(
self,
workflow_name: str,
input_payload: Mapping[str, Any],
*,
execution_id: str | None = None,
version: int | None = None,
) -> WorkflowRunResponse:
started_at = time.monotonic()
input_keys = list(input_payload.keys()) if isinstance(input_payload, Mapping) else []
logger.info(
"workflow.run.start name=%s version=%s execution_id=%s input_keys=%s",
workflow_name,
version,
execution_id,
input_keys,
)
if execution_id is None:
result = self._start_execution(
workflow_name=workflow_name,
input_payload=dict(input_payload),
version=version,
)
else:
result = self._resume_execution(
execution_id=execution_id,
workflow_name=workflow_name,
input_payload=dict(input_payload),
version=version,
)
elapsed_ms = round((time.monotonic() - started_at) * 1000, 2)
logger.info(
"workflow.run.end name=%s execution_id=%s status=%s elapsed_ms=%s",
workflow_name,
result.execution_id,
result.status,
elapsed_ms,
)
return result
def _start_execution(
self,
*,
workflow_name: str,
input_payload: dict[str, Any],
version: int | None,
) -> WorkflowRunResponse:
definition = (
self._repository.get_version(workflow_name, version)
if version is not None
else self._repository.get_active(workflow_name)
)
graph = self._get_graph(definition)
execution_id = str(uuid4())
session_id = self._current_session_id()
message_id = self._extract_message_id(input_payload)
self._execution_store.create(
execution_id=execution_id,
workflow_name=definition.name,
workflow_version=definition.version,
session_id=session_id,
started_by_message_id=message_id,
)
initial_state = build_initial_state(definition, input_payload)
config = self._config(execution_id)
try:
graph.invoke(initial_state, config=config)
except Exception as exc:
self._execution_store.mark_status(
execution_id,
status="FAILED",
current_node=None,
resume_from=None,
expected_input_key=None,
)
logger.exception("Falha inesperada ao iniciar workflow: %s", exc)
return WorkflowRunResponse(
execution_id=execution_id,
status="FAILED",
error="Erro inesperado ao executar workflow",
metadata={
"workflow": definition.name,
"version": definition.version,
"error_code": "WORKFLOW_INTERNAL_ERROR",
},
)
return self._build_response(
execution_id, definition.name, definition.version, graph
)
def _resume_execution(
self,
*,
execution_id: str,
workflow_name: str,
input_payload: dict[str, Any],
version: int | None,
) -> WorkflowRunResponse:
record = self._execution_store.claim_resume(
execution_id=execution_id,
workflow_name=workflow_name,
workflow_version=version,
session_id=self._current_session_id(),
message_id=self._extract_message_id(input_payload),
)
definition = self._repository.get_version(
record.workflow_name,
record.workflow_version,
)
graph = self._get_graph(definition)
config = self._config(execution_id)
checkpoint_summary = self._checkpoint_debug_summary(config)
logger.info(
"workflow.resume.checkpoint execution_id=%s workflow=%s "
"version=%s checkpoint=%s record_status=%s current_node=%s "
"resume_from=%s expected_input_key=%s",
execution_id,
definition.name,
definition.version,
checkpoint_summary,
record.status,
record.current_node,
record.resume_from,
record.expected_input_key,
)
try:
from langgraph.types import Command
except ModuleNotFoundError as exc:
raise ModuleNotFoundError(
"langgraph nao esta instalado. "
"Adicione a dependencia para usar workflows."
) from exc
try:
graph.invoke(Command(resume=input_payload), config=config)
except WorkflowInputError:
self._execution_store.mark_status(
execution_id,
status="WAITING_INPUT",
current_node=record.current_node,
resume_from=record.resume_from,
expected_input_key=record.expected_input_key,
)
raise
except Exception as exc:
self._execution_store.mark_status(
execution_id,
status="FAILED",
current_node=None,
resume_from=None,
expected_input_key=None,
)
logger.info(
"workflow.resume.failed.summary execution_id=%s workflow=%s "
"version=%s error_type=%s error=%s checkpoint=%s "
"record_status=%s current_node=%s resume_from=%s "
"expected_input_key=%s",
execution_id,
definition.name,
definition.version,
type(exc).__name__,
str(exc),
checkpoint_summary,
record.status,
record.current_node,
record.resume_from,
record.expected_input_key,
)
logger.error(
"workflow.resume.failed execution_id=%s workflow=%s version=%s "
"error_type=%s error=%s checkpoint=%s record_status=%s "
"current_node=%s resume_from=%s expected_input_key=%s",
execution_id,
definition.name,
definition.version,
type(exc).__name__,
str(exc),
checkpoint_summary,
record.status,
record.current_node,
record.resume_from,
record.expected_input_key,
exc_info=True,
)
return WorkflowRunResponse(
execution_id=execution_id,
status="FAILED",
error="Erro inesperado ao executar workflow",
metadata={
"workflow": definition.name,
"version": definition.version,
"error_code": "WORKFLOW_INTERNAL_ERROR",
},
)
return self._build_response(
execution_id, definition.name, definition.version, graph
)
def _checkpoint_debug_summary(self, config: dict[str, Any]) -> dict[str, Any]:
get_tuple = getattr(self._checkpointer, "get_tuple", None)
if not callable(get_tuple):
return {"available": False, "reason": "checkpointer_without_get_tuple"}
try:
checkpoint_tuple = get_tuple(config)
except Exception as exc:
logger.error(
"workflow.resume.checkpoint_read_failed thread_id=%s "
"error_type=%s error=%s",
config.get("configurable", {}).get("thread_id"),
type(exc).__name__,
str(exc),
exc_info=True,
)
return {
"available": False,
"read_error_type": type(exc).__name__,
"read_error": str(exc),
}
if checkpoint_tuple is None:
return {"available": False, "reason": "checkpoint_not_found"}
checkpoint = dict(getattr(checkpoint_tuple, "checkpoint", {}) or {})
channel_values = dict(checkpoint.get("channel_values") or {})
pending_writes = list(getattr(checkpoint_tuple, "pending_writes", []) or [])
config_values = dict(getattr(checkpoint_tuple, "config", {}) or {})
checkpoint_config = dict(config_values.get("configurable", {}) or {})
return {
"available": True,
"checkpoint_id": checkpoint_config.get("checkpoint_id"),
"channel_keys": sorted(str(key) for key in channel_values.keys()),
"pending_writes_count": len(pending_writes),
"pending_write_channels": sorted(
{str(write[1]) for write in pending_writes if len(write) >= 2}
),
}
def _build_response(
self,
execution_id: str,
workflow_name: str,
workflow_version: int,
graph: Any,
) -> WorkflowRunResponse:
snapshot = graph.get_state(self._config(execution_id))
values = dict(getattr(snapshot, "values", {}) or {})
status = str(values.get("status") or "")
if not status:
logger.warning(
"workflow.response.status_missing execution_id=%s workflow=%s "
"version=%s snapshot=%s",
execution_id,
workflow_name,
workflow_version,
self._snapshot_debug_summary(values),
)
# Salvaguarda: alguns checkpointers podem nao propagar a channel
# `status` ate o snapshot final apos FINISH_NODE. Inferir pelo
# par final_data/final_error setado no action_node antes da
# transicao para o no terminal.
if values.get("final_error"):
status = "FAILED"
elif values.get("final_data") is not None:
status = "COMPLETED"
else:
status = "FAILED"
if status == "WAITING_INPUT":
pending = values.get("pending_interrupt")
if not isinstance(pending, dict):
raise WorkflowConfigurationError(
f"Workflow {workflow_name!r} pausou sem pending_interrupt"
)
self._execution_store.mark_status(
execution_id,
status="WAITING_INPUT",
current_node=str(pending.get("node_id", "")) or None,
resume_from=str(pending.get("resume_from", "")) or None,
expected_input_key=str(pending.get("expected_input_key", "")) or None,
)
return WorkflowRunResponse(
execution_id=execution_id,
status="WAITING_INPUT",
data=pending.get("payload"),
metadata={
"workflow": workflow_name,
"version": workflow_version,
"paused_at": pending.get("node_id"),
"resume_from": pending.get("resume_from"),
"expected_input_key": pending.get("expected_input_key"),
"allowed_values": pending.get("allowed_values", []),
"normalize": pending.get("normalize"),
},
)
if status == "COMPLETED":
self._execution_store.mark_status(
execution_id,
status="COMPLETED",
current_node=None,
resume_from=None,
expected_input_key=None,
)
metadata = dict(values.get("final_metadata", {}) or {})
metadata.setdefault("workflow", workflow_name)
metadata.setdefault("version", workflow_version)
return WorkflowRunResponse(
execution_id=execution_id,
status="COMPLETED",
data=values.get("final_data"),
metadata=metadata,
)
if status == "FAILED":
logger.error(
"workflow.response.failed execution_id=%s workflow=%s "
"version=%s snapshot=%s",
execution_id,
workflow_name,
workflow_version,
self._snapshot_debug_summary(values),
)
self._execution_store.mark_status(
execution_id,
status="FAILED",
current_node=None,
resume_from=None,
expected_input_key=None,
)
metadata = dict(values.get("final_metadata", {}) or {})
metadata.setdefault("workflow", workflow_name)
metadata.setdefault("version", workflow_version)
return WorkflowRunResponse(
execution_id=execution_id,
status="FAILED",
data=None,
error=str(values.get("final_error") or "Erro na execucao do workflow"),
metadata=metadata,
)
raise WorkflowExecutionStateError(
f"Estado final invalido para workflow: {status!r}"
)
@staticmethod
def _snapshot_debug_summary(values: dict[str, Any]) -> dict[str, Any]:
trace = values.get("trace")
trace_tail = trace[-3:] if isinstance(trace, list) else []
final_metadata = values.get("final_metadata")
return {
"status": values.get("status"),
"current_node": values.get("current_node"),
"last_node": values.get("last_node"),
"final_error": values.get("final_error"),
"final_metadata": (
final_metadata if isinstance(final_metadata, dict) else {}
),
"final_data_type": type(values.get("final_data")).__name__,
"pending_interrupt_present": isinstance(
values.get("pending_interrupt"),
dict,
),
"trace_tail": trace_tail,
"channel_keys": sorted(str(key) for key in values.keys()),
}
def _get_graph(self, definition: Any) -> Any:
key = (definition.name, definition.version)
graph = self._compiled_cache.get(key)
if graph is not None:
return graph
graph = compile_workflow(
definition,
action_registry=self._registry,
runtime=self._runtime,
checkpointer=self._checkpointer,
)
self._compiled_cache[key] = graph
logger.info(
"Workflow compilado com LangGraph: name=%s version=%s",
definition.name,
definition.version,
)
return graph
@staticmethod
def _config(execution_id: str) -> dict[str, Any]:
return {"configurable": {"thread_id": execution_id}}
@staticmethod
def _current_session_id() -> str | None:
session_id = str(get_session_id() or "").strip()
return session_id if session_id and session_id != "-" else None
@staticmethod
def _extract_message_id(input_payload: Mapping[str, Any]) -> str | None:
for key in ("message_id", "messageId"):
value = str(input_payload.get(key, "") or "").strip()
if value:
return value
return None
@staticmethod
def _create_checkpointer(postgres_dsn: str) -> tuple[Any, Any | None]:
try:
from langgraph.checkpoint.postgres import PostgresSaver
except ModuleNotFoundError as exc:
raise ModuleNotFoundError(
"langgraph.checkpoint.postgres nao esta instalado. "
"Adicione as dependencias para usar workflows com PostgreSQL."
) from exc
manager = PostgresSaver.from_conn_string(postgres_dsn)
if hasattr(manager, "__enter__") and hasattr(manager, "__exit__"):
saver = manager.__enter__()
setup = getattr(saver, "setup", None)
if callable(setup):
setup()
return saver, manager
setup = getattr(manager, "setup", None)
if callable(setup):
setup()
return manager, None
def close(self) -> None:
close_store = getattr(self._execution_store, "close", None)
if callable(close_store):
close_store()
manager = getattr(self, "_checkpointer_manager", None)
if manager is not None and hasattr(manager, "__exit__"):
manager.__exit__(None, None, None)
return
close_checkpointer = getattr(self._checkpointer, "close", None)
if callable(close_checkpointer):
close_checkpointer()