162 lines
5.5 KiB
Python
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",
|
|
]
|