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

162 lines
5.5 KiB
Python

"""Workflow SSE event protocol v1.0.
Invariant: **persist first, then publish**. ``seq`` is monotonic per run.
Heartbeat comments (``: heartbeat``) are live-only and never stored.
Canonical ``data`` payloads (frozen contract — REST replay and SSE must agree):
node.tool.started {"name": "web_search", "toolCallId": "call_001",
"title": "联网检索", "args": {"path": "..."}}
node.tool.finished {"name": "web_search", "toolCallId": "call_001",
"status": "succeeded" | "failed", "summary": "获得 12 条结果"}
node.message {"messages": [<native LangGraph message>, <metadata>]}
run.awaiting_input {"resumeToken": "one-time-token", "prompt": "请确认是否发布",
"formSchema": {...}, "actions": ["approve", "reject"]}
artifact.created {"artifactId": "artifact_001", "name": "weekly-report.docx",
"kind": "file" | "markdown" | "json" | "table",
"mimeType": "...", "sizeBytes": 1234, "preview": "..."}
node.started {"nodeType": ..., "name": ..., "attempt": 1,
"agent": {...}, "parallelGroupId": "...", "roundIndex": 2}
Rules: tool identity is ``name`` (never ``toolName``); outcomes are ``status``
(never ``ok``); human-input descriptors are flattened (never nested under
``pendingInput``); artifacts never carry filesystem paths; agent nodes expose
display metadata only (no system prompts, skill whitelists or credentials).
"""
from __future__ import annotations
from datetime import UTC, datetime
from typing import Any, Literal, Protocol, runtime_checkable
from pydantic import BaseModel, Field
SCHEMA_VERSION = "1.0"
WorkflowEventType = Literal[
"run.created",
"run.queued",
"run.started",
"node.queued",
"node.started",
"node.progress",
"node.output.delta",
"node.message",
"node.tool.started",
"node.tool.finished",
"artifact.created",
"node.completed",
"node.failed",
"run.awaiting_input",
"run.resumed",
"run.cancel_requested",
"run.cancelled",
"run.completed",
"run.failed",
]
ALL_WORKFLOW_EVENT_TYPES: tuple[WorkflowEventType, ...] = (
"run.created",
"run.queued",
"run.started",
"node.queued",
"node.started",
"node.progress",
"node.output.delta",
"node.message",
"node.tool.started",
"node.tool.finished",
"artifact.created",
"node.completed",
"node.failed",
"run.awaiting_input",
"run.resumed",
"run.cancel_requested",
"run.cancelled",
"run.completed",
"run.failed",
)
TERMINAL_EVENT_TYPES: frozenset[WorkflowEventType] = frozenset({"run.completed", "run.failed", "run.cancelled"})
_KIND_BY_MIME: tuple[tuple[tuple[str, ...], str], ...] = (
(("text/markdown", "text/x-markdown"), "markdown"),
(("application/json", "text/json", "application/javascript"), "json"),
(("text/csv", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"), "table"),
)
def artifact_kind(mime_type: str) -> str:
"""Coarse wire-level artifact category: file | markdown | json | table."""
lowered = (mime_type or "").lower()
for prefixes, kind in _KIND_BY_MIME:
if any(lowered.startswith(prefix) for prefix in prefixes):
return kind
return "file"
class WorkflowEventEnvelope(BaseModel):
"""Common envelope for every persisted / SSE workflow event."""
schema_version: Literal["1.0"] = Field(default=SCHEMA_VERSION, alias="schemaVersion")
run_id: str = Field(alias="runId")
workflow_id: str = Field(alias="workflowId")
version_id: str = Field(alias="versionId")
seq: int = 0
event_type: WorkflowEventType = Field(alias="event")
node_id: str | None = Field(default=None, alias="nodeId")
node_run_id: str | None = Field(default=None, alias="nodeRunId")
timestamp: datetime = Field(default_factory=lambda: datetime.now(UTC))
data: dict[str, Any] = Field(default_factory=dict)
model_config = {"extra": "forbid", "populate_by_name": True}
def to_sse_dict(self) -> dict[str, Any]:
"""JSON object for the SSE ``data:`` line (camelCase wire fields)."""
return {
"schemaVersion": self.schema_version,
"runId": self.run_id,
"workflowId": self.workflow_id,
"versionId": self.version_id,
"seq": self.seq,
"event": self.event_type,
"nodeId": self.node_id,
"nodeRunId": self.node_run_id,
"timestamp": self.timestamp.isoformat() if self.timestamp else None,
"data": self.data,
}
def to_sse_frame(self) -> str:
"""Full SSE frame including ``id`` / ``event`` / ``data`` lines."""
import json
payload = json.dumps(self.to_sse_dict(), ensure_ascii=False, separators=(",", ":"))
return f"id: {self.seq}\nevent: {self.event_type}\ndata: {payload}\n\n"
@runtime_checkable
class WorkflowEventSink(Protocol):
"""Persist-then-publish sink used by the future executor."""
async def emit(
self,
event_type: WorkflowEventType,
*,
data: dict[str, Any] | None = None,
node_id: str | None = None,
node_run_id: str | None = None,
) -> WorkflowEventEnvelope:
"""Persist event (assign ``seq``), then fan out to live subscribers."""
...
__all__ = [
"ALL_WORKFLOW_EVENT_TYPES",
"SCHEMA_VERSION",
"TERMINAL_EVENT_TYPES",
"WorkflowEventEnvelope",
"WorkflowEventSink",
"WorkflowEventType",
"artifact_kind",
]