Files
wehub-resource-sync 4b6817381b
CI (OpenClaw E2E) / openclaw test (push) Has been cancelled
CI / coverage-report (push) Has been cancelled
CI / test-kubernetes (push) Has been cancelled
CI / should-run-thorough (push) Has been cancelled
CI / test-thorough (cloudwatch-demo) (push) Has been cancelled
CI / test-thorough (flink-ecs) (push) Has been cancelled
CI / test-thorough (upstream-lambda) (push) Has been cancelled
CI / test-thorough (prefect-ecs-fargate) (push) Has been cancelled
Release / build-binaries (zip, opensre.exe, onefile, windows-latest, windows-x64) (push) Has been cancelled
Benchmark image — build + push to ECR (any adapter) / build + push (push) Has been cancelled
CI / quality (ubuntu-latest) (push) Has been cancelled
CI / test (tools-runtime) (push) Has been cancelled
CI / test (e2e-general) (push) Has been cancelled
CI / test (cli-runtime) (push) Has been cancelled
CI / test (e2e-provider-and-openclaw) (push) Has been cancelled
CI / test (integrations-and-misc) (push) Has been cancelled
Release / verify (push) Has been cancelled
Release / build-python-dist (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, macos-15-intel, darwin-x64) (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, macos-latest, darwin-arm64) (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, ubuntu-22.04, linux-x64) (push) Has been cancelled
Release / publish-release (push) Has been cancelled
Release / publish-main-release (push) Has been cancelled
Interactive Shell Live (PR + post-merge) / turn-checks (no-LLM) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
Interactive Shell Live (PR + post-merge) / turn-live shard ${{ matrix.shard_index }} (push) Has been cancelled
Release / prepare (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, ubuntu-22.04-arm, linux-arm64) (push) Has been cancelled
Synthetic Deterministic Tests / Synthetic offline (deterministic) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:10:45 +08:00

312 lines
11 KiB
Python

from __future__ import annotations
import base64
import binascii
import json
import logging
from typing import Any
from urllib.parse import quote
import httpx
from integrations.config_models import TemporalIntegrationConfig
from integrations.probes import ProbeResult
from platform.observability.errors.service import capture_service_error
logger = logging.getLogger(__name__)
_DEFAULT_TIMEOUT = 30
_DEFAULT_PAGE_SIZE = 10
TemporalConfig = TemporalIntegrationConfig
class TemporalClient:
def __init__(self, config: TemporalConfig):
self.config = config
self._client: httpx.Client = httpx.Client(
base_url=self.config.base_url,
headers=self.config.headers,
timeout=_DEFAULT_TIMEOUT,
)
@property
def is_configured(self) -> bool:
return bool(self.config.base_url) and bool(self.config.namespace)
def list_workflow_executions(self, next_page_token: str | None = None) -> dict[str, Any]:
"""List recent workflow executions with status and failure reason.
Returns paginated workflow executions for the configured namespace.
Each execution includes workflowId, type, status, taskQueue, and timing info.
"""
params: dict[str, str | int | bool] = {
"pageSize": _DEFAULT_PAGE_SIZE,
}
if next_page_token is not None:
params["nextPageToken"] = next_page_token
try:
r = self._client.get(
f"/api/v1/namespaces/{self.config.namespace}/workflows", params=params
)
r.raise_for_status()
data = r.json()
executions = data.get("executions", [])
return {
"success": True,
"executions": executions,
"next_page_token": data.get("nextPageToken", ""),
"total": len(executions),
}
except httpx.HTTPStatusError as exc:
capture_service_error(
exc,
logger=logger,
integration="temporal",
method="list_workflow_executions",
extras={"query": params},
)
return {
"success": False,
"error": f"HTTP {exc.response.status_code}: {exc.response.text[:200]}",
}
except Exception as exc:
capture_service_error(
exc,
logger=logger,
integration="temporal",
method="list_workflow_executions",
extras={"query": params},
)
return {"success": False, "error": str(exc)}
def get_workflow_history(
self, workflow_id: str, run_id: str | None = None, next_page_token: str | None = None
) -> dict[str, Any]:
"""Fetch the event history for a specific workflow execution.
Returns the ordered sequence of events (started, activity scheduled,
activity failed, workflow failed, etc.) that tells the story of what
happened during the execution. Essential for diagnosing why a workflow failed.
"""
params: dict[str, str | int | bool] = {
"pageSize": _DEFAULT_PAGE_SIZE,
}
if next_page_token is not None:
params["nextPageToken"] = next_page_token
if run_id is not None:
params["execution.runId"] = run_id
try:
r = self._client.get(
f"/api/v1/namespaces/{self.config.namespace}"
f"/workflows/{quote(workflow_id, safe='')}/history",
params=params,
)
r.raise_for_status()
data = r.json()
events = (data.get("history") or {}).get("events", [])
return {
"success": True,
"events": events,
"next_page_token": data.get("nextPageToken", ""),
"archived": data.get("archived", False),
"total": len(events),
}
except httpx.HTTPStatusError as exc:
capture_service_error(
exc,
logger=logger,
integration="temporal",
method="get_workflow_history",
extras={"query": params},
)
return {
"success": False,
"error": f"HTTP {exc.response.status_code}: {exc.response.text[:200]}",
}
except Exception as exc:
capture_service_error(
exc,
logger=logger,
integration="temporal",
method="get_workflow_history",
extras={"query": params},
)
return {"success": False, "error": str(exc)}
def describe_task_queue(self, task_queue_name: str) -> dict[str, Any]:
"""Describe a task queue's pollers and backlog stats.
The Temporal HTTP API does not expose a "list all task queues" endpoint.
Instead, task queue names are discovered from workflow executions (each
execution reports which task queue it ran on). This method describes a
single queue by name — returning active pollers (workers) and backlog
metrics (approximate count, age, add/dispatch rates).
Use the taskQueue field from list_workflow_executions() results to
identify which queues to inspect.
"""
params: dict[str, str | int | bool] = {
"reportStats": True,
"taskQueueType": "TASK_QUEUE_TYPE_WORKFLOW",
}
try:
r = self._client.get(
f"/api/v1/namespaces/{self.config.namespace}"
f"/task-queues/{quote(task_queue_name, safe='')}",
params=params,
)
r.raise_for_status()
data = r.json()
pollers = data.get("pollers", [])
return {
"success": True,
"pollers": pollers,
"stats": data.get("stats", {}),
"total": len(pollers),
}
except httpx.HTTPStatusError as exc:
capture_service_error(
exc,
logger=logger,
integration="temporal",
method="describe_task_queue",
extras={"query": params},
)
return {
"success": False,
"error": f"HTTP {exc.response.status_code}: {exc.response.text[:200]}",
}
except Exception as exc:
capture_service_error(
exc,
logger=logger,
integration="temporal",
method="describe_task_queue",
extras={"query": params},
)
return {"success": False, "error": str(exc)}
def get_namespace_info(self) -> dict[str, Any]:
"""Fetch namespace state and workflow counts grouped by execution status.
Combines DescribeNamespace (state, config) with CountWorkflowExecutions
(grouped by ExecutionStatus) to provide namespace-level health metrics.
"""
try:
ns_resp = self._client.get(
f"/api/v1/namespaces/{self.config.namespace}",
)
ns_resp.raise_for_status()
ns_data = ns_resp.json()
count_resp = self._client.get(
f"/api/v1/namespaces/{self.config.namespace}/workflow-count",
params={"query": "GROUP BY ExecutionStatus"},
)
count_resp.raise_for_status()
count_data = count_resp.json()
namespace_info = ns_data.get("namespaceInfo", {})
return {
"success": True,
"name": namespace_info.get("name", ""),
"state": namespace_info.get("state", ""),
"workflow_count": count_data.get("count", "0"),
"groups": self._flatten_status_groups(count_data.get("groups", [])),
}
except httpx.HTTPStatusError as exc:
capture_service_error(
exc,
logger=logger,
integration="temporal",
method="get_namespace_info",
)
return {
"success": False,
"error": f"HTTP {exc.response.status_code}: {exc.response.text[:200]}",
}
except Exception as exc:
capture_service_error(
exc,
logger=logger,
integration="temporal",
method="get_namespace_info",
)
return {"success": False, "error": str(exc)}
def _flatten_status_groups(self, groups: list[dict[str, Any]]) -> list[dict[str, str]]:
"""Flatten CountWorkflowExecutions GROUP BY results into [{status, count}].
The raw response nests each bucket as
``{"groupValues": [{"data": "<base64 Payload>"}], "count": "1"}``.
The status name is a base64-encoded Temporal Payload, so we decode it
and drop the metadata/encoding noise the LLM does not need.
"""
flattened: list[dict[str, str]] = []
for group in groups:
values = group.get("groupValues") or []
decoded = [str(self._decode_payload_data(v.get("data", ""))) for v in values]
flattened.append(
{
"status": ", ".join(decoded),
"count": str(group.get("count", "0")),
}
)
return flattened
@staticmethod
def _decode_payload_data(data: str) -> Any:
"""Decode a Temporal HTTP API Payload ``data`` field.
The JSON/HTTP API base64-encodes every Payload value (e.g. the status
name ``"Failed"`` arrives as ``"IkZhaWxlZCI="``). Decode the base64 then
JSON-parse it. Fall back to the raw string if it is not a standard
base64/JSON payload, so a format change never crashes the caller.
"""
if not data:
return data
try:
return json.loads(base64.b64decode(data))
except (binascii.Error, ValueError, UnicodeDecodeError):
return data
def probe_access(self) -> ProbeResult:
if not self.is_configured:
return ProbeResult.failed("Temporal Client is not configured.")
try:
r = self._client.get(f"/api/v1/namespaces/{self.config.namespace}")
r.raise_for_status()
return ProbeResult.passed("Successfully connected to Temporal.")
except httpx.HTTPStatusError as exc:
capture_service_error(
exc,
logger=logger,
integration="temporal",
method="probe_access",
)
return ProbeResult.failed(
f"Failed to connect to Temporal: {exc.response.status_code}: {exc.response.text[:200]}."
)
except Exception as exc:
capture_service_error(
exc,
logger=logger,
integration="temporal",
method="probe_access",
)
return ProbeResult.failed(f"Failed to connect to Temporal: {str(exc)}.")
def close(self) -> None:
self._client.close()
def __enter__(self) -> TemporalClient:
return self
def __exit__(self, exc_type, exc_val, exc_tb) -> None:
self.close()