deerflow-code/offline-backend-20260512/backend/packages/harness/deerflow/workflows/runtime/context.py
2026-09-07 18:24:55 +08:00

167 lines
5.8 KiB
Python

"""Run-scoped execution context shared by the engine and node executors."""
from __future__ import annotations
import asyncio
from collections.abc import Awaitable, Callable
from dataclasses import dataclass, field
from typing import Any
from deerflow.workflows.errors import WorkflowError
from deerflow.workflows.events import WorkflowEventType
from deerflow.workflows.schemas import ArtifactRef, WorkflowGraph, WorkflowNode
EmitFn = Callable[..., Awaitable[Any]]
def _public_artifact_payload(ref: ArtifactRef) -> dict[str, Any]:
"""Wire payload for ``artifact.created``.
The sandbox/host ``path`` stays internal: clients address artifacts by id
through the run-scoped content endpoints, never by filesystem path."""
from deerflow.workflows.events import artifact_kind
return {
"artifactId": ref.artifact_id,
"name": ref.name,
"kind": artifact_kind(ref.mime_type),
"mimeType": ref.mime_type,
"sizeBytes": ref.size_bytes,
"preview": ref.preview,
}
class WorkflowPaused(Exception): # noqa: N818 - control-flow signal, not an error
"""Raised by ``human_input`` to suspend the run until a resume arrives."""
def __init__(self, node_id: str, pending_input: dict[str, Any]) -> None:
super().__init__(f"workflow paused at {node_id}")
self.node_id = node_id
self.pending_input = pending_input
class CancelToken:
"""Cooperative cancellation shared between the executor and node code."""
def __init__(self) -> None:
self._event = asyncio.Event()
self._reason = "cancelled"
def cancel(self, reason: str = "cancelled") -> None:
self._reason = reason
self._event.set()
@property
def cancelled(self) -> bool:
return self._event.is_set()
async def wait(self) -> None:
await self._event.wait()
def raise_if_cancelled(self, node_id: str | None = None) -> None:
if self._event.is_set():
raise WorkflowError("WORKFLOW_CANCELLED", "运行已被取消", node_id=node_id)
@dataclass
class WorkflowRuntimeDeps:
"""Injected capabilities. The harness never imports the app layer, so every
integration point (agents, HTTP egress, SQL, sandbox, subworkflows) arrives
as an async callable supplied by the caller. ``None`` means "not available",
which node executors turn into a typed ``WORKFLOW_RESOURCE_MISSING``."""
run_agent: Callable[..., Awaitable[dict[str, Any]]] | None = None
run_http: Callable[..., Awaitable[dict[str, Any]]] | None = None
run_sql: Callable[..., Awaitable[dict[str, Any]]] | None = None
run_code: Callable[..., Awaitable[dict[str, Any]]] | None = None
run_subworkflow: Callable[..., Awaitable[dict[str, Any]]] | None = None
# Starts (or reattaches to) a durable Deep Research report-writing job.
# The app adapter owns its session/job/event stores; workflow nodes only
# receive the final report plus safe progress callbacks.
run_deep_research: Callable[..., Awaitable[dict[str, Any]]] | None = None
save_artifact: Callable[..., Awaitable[dict[str, Any]]] | None = None
resolve_credential: Callable[[str], Awaitable[dict[str, Any] | None]] | None = None
resolve_data_source: Callable[[str], Awaitable[dict[str, Any] | None]] | None = None
@dataclass
class RunContext:
"""Everything a node executor is allowed to see."""
run_id: str
workflow_id: str
version_id: str
owner_id: str
graph: WorkflowGraph
inputs: dict[str, Any]
deps: WorkflowRuntimeDeps
cancel: CancelToken
emit: EmitFn
node_results: dict[str, dict[str, Any]] = field(default_factory=dict)
loop_state: dict[str, int] = field(default_factory=dict)
resume_payload: dict[str, Any] | None = None
node_run_ids: dict[str, str] = field(default_factory=dict)
current_node_id: str | None = None
env: dict[str, Any] = field(default_factory=dict)
def state(self) -> dict[str, Any]:
"""Whitelisted tree visible to expressions. Credentials never appear."""
return {
"inputs": self.inputs,
"nodes": self.node_results,
"run": {"id": self.run_id, "workflowId": self.workflow_id, "versionId": self.version_id},
"loop": dict(self.loop_state),
"env": self.env,
}
async def emit_event(
self,
event_type: WorkflowEventType,
*,
data: dict[str, Any] | None = None,
node_id: str | None = None,
) -> None:
node = node_id or self.current_node_id
await self.emit(
event_type,
data=data or {},
node_id=node,
node_run_id=self.node_run_ids.get(node) if node else None,
)
async def register_artifact(
self,
node: WorkflowNode,
*,
name: str,
mime_type: str = "application/octet-stream",
path: str = "",
size_bytes: int | None = None,
preview: str | None = None,
) -> ArtifactRef | None:
"""Persist an artifact reference and emit ``artifact.created``."""
if self.deps.save_artifact is None:
return None
row = await self.deps.save_artifact(
run_id=self.run_id,
node_id=node.id,
name=name,
mime_type=mime_type,
path=path,
size_bytes=size_bytes,
preview=preview,
)
ref = ArtifactRef(
artifactId=str(row.get("id") or ""),
name=str(row.get("name") or name),
mimeType=str(row.get("mime_type") or mime_type),
path=str(row.get("path") or path),
sizeBytes=row.get("size_bytes"),
preview=row.get("preview"),
)
await self.emit_event("artifact.created", data=_public_artifact_payload(ref), node_id=node.id)
return ref
__all__ = ["CancelToken", "RunContext", "WorkflowPaused", "WorkflowRuntimeDeps"]