"""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": [, ]} 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", ]