167 lines
5.8 KiB
Python
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"]
|