diff --git a/api/controllers/console/dify_builder_fields.py b/api/controllers/console/dify_builder_fields.py index 72474787faf..f6431802e5f 100644 --- a/api/controllers/console/dify_builder_fields.py +++ b/api/controllers/console/dify_builder_fields.py @@ -21,7 +21,6 @@ from core.dify_builder.contract import ( ErrorCard, ErrorEventData, FormCard, - NodeEventData, NoticeItem, Phase, PlanCard, @@ -40,6 +39,7 @@ from core.dify_builder.contract import ( ) from core.dify_builder.models import EntryMode from fields.base import ResponseModel +from fields.workflow_stream_fields import WorkflowStreamPayload NonEmptyString = Annotated[str, StringConstraints(strip_whitespace=True, min_length=1)] ClientTurnId = Annotated[str, StringConstraints(strip_whitespace=True, min_length=1, max_length=128)] @@ -297,9 +297,19 @@ class DifyBuilderCommandStartedEventResponse(ResponseModel): data: DifyBuilderCommandStartedEventData -class DifyBuilderNodeEventResponse(ResponseModel): - event: Literal["node"] - data: NodeEventData +class DifyBuilderWorkflowEventData(ResponseModel): + session_id: str + operation_id: str + stage_id: str + at_version: int + revision: int + payload: WorkflowStreamPayload + kind: Literal["workflow"] = "workflow" + + +class DifyBuilderWorkflowEventResponse(ResponseModel): + event: Literal["workflow"] + data: DifyBuilderWorkflowEventData class DifyBuilderCanvasEventResponse(ResponseModel): @@ -341,7 +351,7 @@ class DifyBuilderStreamEventResponse( RootModel[ Annotated[ DifyBuilderCommandStartedEventResponse - | DifyBuilderNodeEventResponse + | DifyBuilderWorkflowEventResponse | DifyBuilderCanvasEventResponse | DifyBuilderAgentMessageEventResponse | DifyBuilderReasoningEventResponse diff --git a/api/core/dify_builder/contract.py b/api/core/dify_builder/contract.py index cfc6375e096..38f50043134 100644 --- a/api/core/dify_builder/contract.py +++ b/api/core/dify_builder/contract.py @@ -793,19 +793,16 @@ class CommandStartedEventData(SessionView, _SseEventData): @dataclass -class NodeEventData(_SseEventData): - sse_event: ClassVar[str] = "node" +class WorkflowEventData(_SseEventData): + sse_event: ClassVar[str] = "workflow" session_id: str operation_id: str stage_id: str at_version: int revision: int - node_id: str - title: str - status: str - error: str - kind: Literal["node"] = "node" + payload: dict[str, object] + kind: Literal["workflow"] = "workflow" @dataclass @@ -827,6 +824,7 @@ class CanvasEventData(_SseEventData): kind: Literal["canvas"] = "canvas" node_id: str | None = None edge: CanvasEdge | None = None + dify_run_id: str = "" @dataclass diff --git a/api/core/dify_builder/handlers_build.py b/api/core/dify_builder/handlers_build.py index d5512416f2c..a347c9af819 100644 --- a/api/core/dify_builder/handlers_build.py +++ b/api/core/dify_builder/handlers_build.py @@ -739,11 +739,9 @@ def handle_test_and_repair(env: Env, turn: Turn, s: Session, fc: DifyBuilderCont def emit(event: NodeEvent) -> None: progress.observe_node("build-run-test", event) - if env.emit is not None: - env.emit(event) try: - raw = env.dify.run_draft(s.app_id, turn.actor, inputs, emit) + raw = env.dify.run_draft(s.app_id, turn.actor, inputs, emit, on_workflow_event=env.emit_workflow) status, per_node, dify_run_id, run_error = raw.status, raw.per_node, raw.dify_run_id, "" except Exception as exc: # Never crash the advance; capture the launch error (log + store) instead @@ -1107,7 +1105,8 @@ def _repair_is_repeating(fc: DifyBuilderContext) -> bool: def handle_await_repair(env: Env, turn: Turn, s: Session, fc: DifyBuilderContext) -> StepResult: """(waiting) Post-failure gate mirroring fix.await_decision. approve_repair - applies the staged repair and re-runs the test (build.test_and_repair); + applies the staged repair and waits at build.execution. The client resumes + testing only after applying the committed graph to its canvas; keep_draft -> build.review; undo -> build.reverted. apply_repair runs ONLY here, only on approve.""" kind = action_kind(turn) @@ -1171,9 +1170,9 @@ def handle_await_repair(env: Env, turn: Turn, s: Session, fc: DifyBuilderContext ), ) progress.activate("build-prepare-retest") - decision_items = append_card(fc, DecisionItem(text="Approved the fix; retesting")) + decision_items = append_card(fc, DecisionItem(text="Applied the fix; ready to retest")) progress.finish() - return StepResult(next=PcState.BUILD_TEST_AND_REPAIR, context=fc, items=[*cs_items, *decision_items]) + return StepResult(next=PcState.BUILD_EXECUTION, context=fc, items=[*cs_items, *decision_items]) if kind == "keep_draft": items = append_card(fc, DecisionItem(text="Kept the draft despite the failure")) return StepResult(next=PcState.BUILD_REVIEW, context=fc, items=items) diff --git a/api/core/dify_builder/handlers_edit.py b/api/core/dify_builder/handlers_edit.py index cd58d6b19aa..b5e02202c87 100644 --- a/api/core/dify_builder/handlers_edit.py +++ b/api/core/dify_builder/handlers_edit.py @@ -30,6 +30,7 @@ from core.dify_builder.contract import ( TestStat, ) from core.dify_builder.handlers_fix import ( + UNKNOWN_TEST_OUTCOME_NOTICE, action_kind, action_string, append_card, @@ -393,11 +394,9 @@ def handle_test_affected_paths(env: Env, turn: Turn, s: Session, fc: DifyBuilder def emit(event: NodeEvent) -> None: progress.observe_node("edit-run-test", event) - if env.emit is not None: - env.emit(event) try: - raw = env.dify.run_draft(s.app_id, turn.actor, inputs, emit) + raw = env.dify.run_draft(s.app_id, turn.actor, inputs, emit, on_workflow_event=env.emit_workflow) status, per_node, dify_run_id, run_error = raw.status, raw.per_node, raw.dify_run_id, "" except Exception as exc: # Never crash the advance; capture the launch error (log + store) instead @@ -463,6 +462,18 @@ def handle_test_affected_paths(env: Env, turn: Turn, s: Session, fc: DifyBuilder run_id_sink=[run.id], ) + if status == "running": + # A truncated stream cannot establish failure or justify another repair. + notice_items = append_card(fc, NoticeItem(text=UNKNOWN_TEST_OUTCOME_NOTICE, tone="neutral")) + progress.finish() + return StepResult( + next=PcState.EDIT_APPLY_CHANGES, + context=fc, + items=notice_items, + run=run, + run_id_sink=[run.id], + ) + # failure: real diagnosis + proposed repair, staged for the approval gate run.culprit_node_id = first_failed_node(per_node) fc.verify_run_id = run.id @@ -645,8 +656,8 @@ def handle_test_affected_paths(env: Env, turn: Turn, s: Session, fc: DifyBuilder def handle_await_repair(env: Env, turn: Turn, s: Session, fc: DifyBuilderContext) -> StepResult: """(waiting) Post-failure gate for Edit, mirroring Build's await_repair. - approve_repair applies the staged repair and re-runs the affected-path - tests (edit.test_affected_paths); keep_draft -> edit.review; undo -> + approve_repair applies the staged repair and waits at edit.apply_changes + for the client's canvas refresh before testing; keep_draft -> edit.review; undo -> edit.reverted. apply_repair runs ONLY here, only on approve.""" kind = action_kind(turn) if kind == "approve_repair": @@ -710,9 +721,9 @@ def handle_await_repair(env: Env, turn: Turn, s: Session, fc: DifyBuilderContext ), ) progress.activate("edit-prepare-retest") - decision_items = append_card(fc, DecisionItem(text="Approved the fix; retesting")) + decision_items = append_card(fc, DecisionItem(text="Applied the fix; ready to retest")) progress.finish() - return StepResult(next=PcState.EDIT_TEST_AFFECTED_PATHS, context=fc, items=[*cs_items, *decision_items]) + return StepResult(next=PcState.EDIT_APPLY_CHANGES, context=fc, items=[*cs_items, *decision_items]) if kind == "keep_draft": items = append_card(fc, DecisionItem(text="Kept the draft despite the failure")) return StepResult(next=PcState.EDIT_REVIEW, context=fc, items=items) diff --git a/api/core/dify_builder/handlers_fix.py b/api/core/dify_builder/handlers_fix.py index 6bb1b71eabd..bd5b9c289ad 100644 --- a/api/core/dify_builder/handlers_fix.py +++ b/api/core/dify_builder/handlers_fix.py @@ -700,10 +700,8 @@ def handle_verify(env: Env, turn: Turn, s: Session, fc: DifyBuilderContext) -> S def emit(event: NodeEvent) -> None: progress.observe_node("fix-run-validation", event) - if env.emit is not None: - env.emit(event) - result = env.dify.run_draft(s.app_id, turn.actor, inputs, emit) + result = env.dify.run_draft(s.app_id, turn.actor, inputs, emit, on_workflow_event=env.emit_workflow) if result.status == "succeeded": progress.complete("fix-run-validation") else: diff --git a/api/core/dify_builder/ports.py b/api/core/dify_builder/ports.py index 4968693e596..05e79282e2e 100644 --- a/api/core/dify_builder/ports.py +++ b/api/core/dify_builder/ports.py @@ -19,7 +19,7 @@ Deltas from the Go source (per the P1 port plan's Global Constraints / ADR): ``seam_test.go``'s ``TestSeam_AnyConformingAgent_DrivesFlowToTerminal``). """ -from collections.abc import Callable +from collections.abc import Callable, Mapping from typing import Any, Protocol, runtime_checkable from core.dify_builder.contract import ConversationPage, ResourceOption @@ -157,7 +157,15 @@ class DifyPort(Protocol): expected_revision: str, ) -> ApplyResult: ... - def run_draft(self, app_id: str, actor: Actor, inputs: Inputs, on_event: Callable[[NodeEvent], None]) -> Run: ... + def run_draft( + self, + app_id: str, + actor: Actor, + inputs: Inputs, + on_event: Callable[[NodeEvent], None], + *, + on_workflow_event: Callable[[Mapping[str, object]], None] | None = None, + ) -> Run: ... def publish(self, app_id: str, actor: Actor) -> None: ... diff --git a/api/core/dify_builder/runner.py b/api/core/dify_builder/runner.py index 285c86f3102..930a6212b2f 100644 --- a/api/core/dify_builder/runner.py +++ b/api/core/dify_builder/runner.py @@ -20,7 +20,7 @@ Deltas from the Go source (per the P1 port plan's Global Constraints / ADR): """ import uuid -from collections.abc import Callable +from collections.abc import Callable, Mapping from dataclasses import dataclass, field from datetime import datetime @@ -39,7 +39,6 @@ from core.dify_builder.models import ( Checkpoint, ConversationItem, DifyBuilderContext, - NodeEvent, Run, Session, Snapshot, @@ -80,10 +79,9 @@ class Env: agent: DifyBuilderAgent repo: Repository now: Callable[[], datetime] - # Emit forwards live progress (node events) during a working step. The - # usecase sets it per-Advance to publish to the session broadcaster; None - # is treated as a no-op. - emit: Callable[[NodeEvent], None] | None = None + # Forward native workflow events unchanged. Node summaries remain local to + # the progress reporter; the frontend consumes the full execution stream. + emit_workflow: Callable[[Mapping[str, object]], None] | None = None # Emit forwards granular canvas mutations (add_*_node, apply_error_fix, # ...) as each MutationIntent is applied. None is treated as a no-op -- # the caller (a handler) decides whether to wire it (opt-in, spec Sec 6): diff --git a/api/core/dify_builder/strings.py b/api/core/dify_builder/strings.py index b2ea9e871e4..6e14a482315 100644 --- a/api/core/dify_builder/strings.py +++ b/api/core/dify_builder/strings.py @@ -86,7 +86,7 @@ PLAIN: frozenset[str] = frozenset( # decision texts "Accepted skill learning", "Approved the change plan", - "Approved the fix; retesting", + "Applied the fix; ready to retest", "Approved the plan", "Checklist still failing, re-diagnosing", "Chose to publish", diff --git a/api/fields/workflow_stream_fields.py b/api/fields/workflow_stream_fields.py new file mode 100644 index 00000000000..ce6112de1a6 --- /dev/null +++ b/api/fields/workflow_stream_fields.py @@ -0,0 +1,108 @@ +"""Full debugger workflow stream payloads, using the runtime's data schemas. + +Workflow adds workflow_run_id to every payload; Chatflow leaves it out of +message, text, reasoning, agent-log, audio, and error events. Errors are flat +and omit task_id. Preserve both native wire shapes inside Builder's envelope. +""" + +from typing import Annotated, Literal + +from pydantic import ConfigDict, Field + +from core.app.entities import task_entities as events +from fields.base import ResponseModel + + +class WorkflowStreamEvent[Event: str, Data](ResponseModel): + model_config = ConfigDict(extra="allow") + + event: Event + task_id: str + workflow_run_id: str + data: Data + + +class WorkflowStreamDataEvent[Event: str, Data](ResponseModel): + model_config = ConfigDict(extra="allow") + + event: Event + task_id: str + workflow_run_id: str | None = None + data: Data + + +class WorkflowStreamError(ResponseModel): + model_config = ConfigDict(extra="allow") + + event: Literal["error"] + workflow_run_id: str | None = None + code: str + status: int + message: str + + +class WorkflowStreamMessageEvent[Event: str](ResponseModel): + model_config = ConfigDict(extra="allow") + + event: Event + task_id: str + workflow_run_id: str | None = None + + +class WorkflowStreamMessage(WorkflowStreamMessageEvent[Literal["message"]]): + id: str + answer: str + from_variable_selector: list[str] = Field(default_factory=list) + + +class WorkflowStreamMessageEnd(WorkflowStreamMessageEvent[Literal["message_end"]]): + id: str + metadata: dict[str, object] = Field(default_factory=dict) + files: list[dict[str, object]] = Field(default_factory=list) + + +class WorkflowStreamMessageFile(WorkflowStreamMessageEvent[Literal["message_file"]]): + id: str + type: str + belongs_to: str + url: str + + +class WorkflowStreamMessageReplace(WorkflowStreamMessageEvent[Literal["message_replace"]]): + answer: str + reason: str + + +class WorkflowStreamAudio(WorkflowStreamMessageEvent[Literal["tts_message", "tts_message_end"]]): + audio: str + audio_type: str | None = None + + +WorkflowStreamPayload = Annotated[ + WorkflowStreamEvent[Literal["workflow_started"], events.WorkflowStartStreamResponse.Data] + | WorkflowStreamEvent[Literal["workflow_finished"], events.WorkflowFinishStreamResponse.Data] + | WorkflowStreamEvent[Literal["workflow_paused"], events.WorkflowPauseStreamResponse.Data] + | WorkflowStreamEvent[Literal["node_started"], events.NodeStartStreamResponse.Data] + | WorkflowStreamEvent[Literal["node_finished"], events.NodeFinishStreamResponse.Data] + | WorkflowStreamEvent[Literal["node_retry"], events.NodeRetryStreamResponse.Data] + | WorkflowStreamEvent[Literal["iteration_started"], events.IterationNodeStartStreamResponse.Data] + | WorkflowStreamEvent[Literal["iteration_next"], events.IterationNodeNextStreamResponse.Data] + | WorkflowStreamEvent[Literal["iteration_completed"], events.IterationNodeCompletedStreamResponse.Data] + | WorkflowStreamEvent[Literal["loop_started"], events.LoopNodeStartStreamResponse.Data] + | WorkflowStreamEvent[Literal["loop_next"], events.LoopNodeNextStreamResponse.Data] + | WorkflowStreamEvent[Literal["loop_completed"], events.LoopNodeCompletedStreamResponse.Data] + | WorkflowStreamDataEvent[Literal["text_chunk"], events.TextChunkStreamResponse.Data] + | WorkflowStreamDataEvent[Literal["text_replace"], events.TextReplaceStreamResponse.Data] + | WorkflowStreamDataEvent[Literal["reasoning_chunk"], events.ReasoningChunkStreamResponse.Data] + | WorkflowStreamDataEvent[Literal["agent_log"], events.AgentLogStreamResponse.Data] + | WorkflowStreamEvent[Literal["human_input_required"], events.HumanInputRequiredResponse.Data] + | WorkflowStreamEvent[Literal["human_input_form_filled"], events.HumanInputFormFilledResponse.Data] + | WorkflowStreamEvent[Literal["human_input_form_timeout"], events.HumanInputFormTimeoutResponse.Data] + | WorkflowStreamAudio + | WorkflowStreamMessage + | WorkflowStreamMessageEnd + | WorkflowStreamMessageFile + | WorkflowStreamMessageReplace + | WorkflowStreamError, + Field(discriminator="event"), +] diff --git a/api/services/app_generate_service.py b/api/services/app_generate_service.py index a22a7ba5d47..de6c4d139b0 100644 --- a/api/services/app_generate_service.py +++ b/api/services/app_generate_service.py @@ -4,7 +4,7 @@ import logging import threading import uuid from collections.abc import Callable, Generator, Mapping -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, Literal from sqlalchemy.orm import Session @@ -108,6 +108,7 @@ class AppGenerateService: session: Session, streaming: bool = True, root_node_id: str | None = None, + workflow_execution_mode: Literal["celery", "in_process"] = "celery", ): """ App Content Generate @@ -116,6 +117,8 @@ class AppGenerateService: :param args: args :param invoke_from: invoke from :param streaming: streaming + :param workflow_execution_mode: Execute workflow streams locally when already + inside a worker task, avoiding a synchronous wait for another Celery slot. :return: """ return cls._run_with_guardrails( @@ -131,6 +134,7 @@ class AppGenerateService: session=session, rate_limit=rate_limit, request_id=request_id, + workflow_execution_mode=workflow_execution_mode, ), ) @@ -179,6 +183,7 @@ class AppGenerateService: session: Session, rate_limit: RateLimit, request_id: str, + workflow_execution_mode: Literal["celery", "in_process"] = "celery", ): effective_mode = ( AppMode.AGENT_CHAT @@ -246,7 +251,7 @@ class AppGenerateService: workflow_id = args.get("workflow_id") workflow = cls._get_workflow(app_model, invoke_from, workflow_id, session=session) - if streaming: + if streaming and workflow_execution_mode == "celery": # Streaming mode: subscribe to SSE and enqueue the execution on first subscriber with rate_limit_context(rate_limit, request_id): payload = AppExecutionParams.new( @@ -277,8 +282,8 @@ class AppGenerateService: request_id=request_id, ) - # Blocking mode: run synchronously and return JSON instead of SSE - # Keep behaviour consistent with WORKFLOW blocking branch. + # Reuse the native generator for blocking requests and streams + # owned by an existing worker task. Both retain pause persistence. pause_config = PauseStateLayerConfig( session_factory=session_factory.get_session_maker(), state_owner_user_id=workflow.created_by, @@ -293,7 +298,7 @@ class AppGenerateService: args=args, invoke_from=invoke_from, workflow_run_id=str(uuid.uuid4()), - streaming=False, + streaming=streaming, pause_state_config=pause_config, session=session, ) @@ -304,7 +309,7 @@ class AppGenerateService: workflow_id = args.get("workflow_id") workflow = cls._get_workflow(app_model, invoke_from, workflow_id, session=session) cls._ensure_workflow_service_mode_available(workflow=workflow, invoke_from=invoke_from) - if streaming: + if streaming and workflow_execution_mode == "celery": with rate_limit_context(rate_limit, request_id): payload = AppExecutionParams.new( app_model=app_model, @@ -346,7 +351,7 @@ class AppGenerateService: user=user, args=args, invoke_from=invoke_from, - streaming=False, + streaming=streaming, root_node_id=root_node_id, call_depth=0, pause_state_config=pause_config, diff --git a/api/services/dify_builder/dify_port.py b/api/services/dify_builder/dify_port.py index 41e65ddcf3d..cf3d3b10923 100644 --- a/api/services/dify_builder/dify_port.py +++ b/api/services/dify_builder/dify_port.py @@ -24,12 +24,11 @@ the P2 plan's Global Constraints): - ``run_draft`` invokes ``AppGenerateService.generate`` with ``invoke_from=InvokeFrom.DEBUGGER`` (runs the *draft*, not the published workflow) and ``streaming=True``, so ``on_event`` fires while the run is - still going instead of replaying finished rows afterwards. Streaming makes - ``AppGenerateService`` hand the execution to the - ``workflow_based_app_execution`` Celery queue and hand *us* a Redis-backed - event stream; the Builder's own task runs on the separate ``dify_builder`` - queue, so the two never contend for the same worker slots. If the stream - ends WITHOUT a terminal frame (idle timeout, dead publishing worker), the + still going instead of replaying finished rows afterwards. It selects + ``workflow_execution_mode="in_process"`` so the native generator runs inside + the existing Builder task. Enqueuing a child task and waiting for its stream + deadlocks a worker that consumes both queues with only one execution slot. + If the stream ends WITHOUT a terminal frame, the outcome is recovered from the workflow-run row rather than synthesised as a failure -- reporting a run that actually succeeded as failed would feed the repair loop a lie. @@ -63,6 +62,7 @@ from core.dify_builder.models import ( from extensions.ext_database import db from graphon.enums import BuiltinNodeTypes from libs.datetime_utils import naive_utc_now +from libs.flask_utils import set_login_user from models.account import Account from models.model import App from models.workflow import Workflow @@ -321,47 +321,41 @@ class WorkflowServiceDifyPort: notify_workflow_draft_changed(updated) return execution_revision(updated) - def run_draft(self, app_id: str, actor: Actor, inputs: Inputs, on_event: Callable[[NodeEvent], None]) -> Run: + def run_draft( + self, + app_id: str, + actor: Actor, + inputs: Inputs, + on_event: Callable[[NodeEvent], None], + *, + on_workflow_event: Callable[[Mapping[str, object]], None] | None = None, + ) -> Run: with _session_factory()() as session: account = resolve_account(session, actor) app = load_app(session, app_id, actor) tenant_id = app.tenant_id - # MUST stay blocking. ``run_draft`` executes inside the Celery task - # ``dify_builder_advance_task.advance_session``, and streaming mode - # does NOT run the workflow here: it enqueues - # ``workflow_based_app_execution_task.delay(...)`` on first subscribe - # (services/app_generate_service.py:322-324) and hands back a Redis - # subscription. This task would then block on events from a task that - # needs the very worker slot this task is holding -- a self-deadlock. - # Observed live: advance_session "succeeded in 300.59s" (the idle - # timeout) four times running, while each workflow_run itself - # finished in under 2s. It deadlocks every time at worker - # concurrency 1, and as soon as N test runs are in flight at - # concurrency N. - # - # Blocking mode runs the workflow in-process - # (``WorkflowAppGenerator().generate(..., streaming=False)``) and - # returns a dict, so there is no second task to wait for. + # Match the native workflow task's user context before the runtime + # copies it into its execution thread/greenlet. + set_login_user(account) + # The native generator prepares its configuration eagerly and its + # execution thread owns its own Session. Consume the event stream + # outside this block rather than pinning this DB connection. response = AppGenerateService.generate( app_model=app, user=account, args={"inputs": inputs}, invoke_from=InvokeFrom.DEBUGGER, session=session, - streaming=False, + streaming=True, + workflow_execution_mode="in_process", ) final: dict[str, Any] = {} stream_run_id = "" - # Blocking mode returns a MAPPING ({"task_id", "workflow_run_id", - # "data"}), not a stream. Iterating it would walk its KEYS as strings -- - # which is exactly what happened after the streaming rewrite: no run id - # and no terminal frame were ever found, every test run was recorded - # status="running" with an empty dify_run_id, and the Builder bounced the - # user back to build.execution forever. Handle both shapes so the stream - # path stays correct if it is ever reinstated behind a non-worker consumer. + # A completed mapping response carries terminal data directly. Iterating + # its keys as stream chunks would lose the run ID and final status. if isinstance(response, Mapping): final = dict(response.get("data") or {}) if not final.get("id"): @@ -375,8 +369,10 @@ class WorkflowServiceDifyPort: payload = stream_chunk_as_mapping(chunk) if payload is None: continue - # Every non-ping frame stamps the run id, so it is known long - # before the terminal frame -- which may never arrive. + if on_workflow_event is not None: + on_workflow_event(payload) + # Lifecycle events identify the run before its terminal frame + # arrives; Chatflow message events may omit the run ID. stream_run_id = stream_run_id or run_id_from_stream_chunk(payload) node_event = node_event_from_stream_chunk(payload) if node_event is not None: @@ -401,8 +397,8 @@ class WorkflowServiceDifyPort: and streaming paths so they can never disagree about the outcome.""" run_id = str(final.get("id") or stream_run_id or "") - # The terminal frame carries status, not per-node outputs: - # ``map_run_result`` still needs the node-execution rows. + # Backend diagnosis still uses persisted node-execution rows to build + # ``per_node[].outputs``; the frontend consumes the full event stream. node_execs = ( DifyAPIRepositoryFactory.create_api_workflow_node_execution_repository( sessionmaker(bind=db.engine) @@ -414,11 +410,10 @@ class WorkflowServiceDifyPort: if final: return map_run_result(final, node_execs) - # No terminal data -- a consumed stream ended without its terminal frame - # (idle timeout, or the publishing worker died). The database is now the - # authority on how the run went; synthesising "failed" here would report - # a run that may well have SUCCEEDED as a failed build and feed the - # repair loop a lie. + # The stream ended without a terminal frame. The stream is no longer + # the authority on how the run went; the database is. Synthesising + # "failed" here would report a run that may well have SUCCEEDED as a + # failed build and feed the repair loop a lie. return self._run_result_without_terminal_frame(tenant_id, app_id, run_id, node_execs) @staticmethod diff --git a/api/services/dify_builder/service.py b/api/services/dify_builder/service.py index be20ffa2b89..f7e90d616c0 100644 --- a/api/services/dify_builder/service.py +++ b/api/services/dify_builder/service.py @@ -229,7 +229,7 @@ _ACTIONS_FOR: dict[PcState, list[UiAction]] = { UiAction(id="provide_testdata", label="Provide test data", kind=ActionKind.PRIMARY), ], PcState.BUILD_AWAIT_REPAIR: [ - UiAction(id="approve_plan", label="Apply the fix", kind=ActionKind.PRIMARY, next_state="build.test_and_repair"), + UiAction(id="approve_plan", label="Apply the fix", kind=ActionKind.PRIMARY, next_state="build.execution"), UiAction(id="keep_draft", label="Keep draft", kind=ActionKind.SECONDARY, next_state="build.review"), UiAction( id="revert", @@ -314,9 +314,7 @@ _ACTIONS_FOR: dict[PcState, list[UiAction]] = { UiAction(id="provide_testdata", label="Provide test data", kind=ActionKind.PRIMARY), ], PcState.EDIT_AWAIT_REPAIR: [ - UiAction( - id="approve_plan", label="Apply the fix", kind=ActionKind.PRIMARY, next_state="edit.test_affected_paths" - ), + UiAction(id="approve_plan", label="Apply the fix", kind=ActionKind.PRIMARY, next_state="edit.apply_changes"), UiAction(id="keep_draft", label="Keep draft", kind=ActionKind.SECONDARY, next_state="edit.review"), UiAction( id="revert", diff --git a/api/services/dify_builder/wiring.py b/api/services/dify_builder/wiring.py index 8d10604f85c..322819710cb 100644 --- a/api/services/dify_builder/wiring.py +++ b/api/services/dify_builder/wiring.py @@ -36,7 +36,9 @@ __all__ = [ _HEARTBEAT_SECONDS = 15 _MAX_STREAM_SECONDS = dify_config.DIFY_BUILDER_MAX_ADVANCE_SECONDS + _HEARTBEAT_SECONDS _TERMINAL_KINDS = ("state", "error") -_PROGRESS_KINDS = frozenset({"node", "canvas", "agent_message", "reasoning", "progress", "commit", *_TERMINAL_KINDS}) +_PROGRESS_KINDS = frozenset( + {"workflow", "canvas", "agent_message", "reasoning", "progress", "commit", *_TERMINAL_KINDS} +) def _enqueue(session_id: str, action: Action, actor: Actor, token: str) -> None: diff --git a/api/tasks/dify_builder_advance_task.py b/api/tasks/dify_builder_advance_task.py index d7cb7397c1b..2483ea21f29 100644 --- a/api/tasks/dify_builder_advance_task.py +++ b/api/tasks/dify_builder_advance_task.py @@ -6,7 +6,7 @@ The usecase's ``dispatch`` (Task 3) acquires the cross-process advance lock this task; this task runs the actual engine step in the Celery process and releases the lock in ``finally`` -- regardless of whether the step succeeded, lost a version-CAS race, or raised. Progress (curated phase snapshots, -per-node events during a working step, plus the settled terminal state) is +native workflow events during test runs, plus the settled terminal state) is forwarded to the session's progress bus (``services.dify_builder.progress_bus``) for the P3c SSE endpoint to relay. @@ -24,6 +24,7 @@ the current waiting gate. The client can then retry with the same import logging import uuid +from collections.abc import Mapping from dataclasses import asdict, replace from celery import shared_task @@ -36,12 +37,13 @@ from core.dify_builder.contract import ( ExecutionProgress, ProgressEventData, ReasoningEventData, + WorkflowEventData, ) from core.dify_builder.errors import ConflictError from core.dify_builder.handlers_build import build_registry from core.dify_builder.handlers_edit import edit_registry from core.dify_builder.handlers_fix import fix_registry -from core.dify_builder.models import Action, Actor, NodeEvent, Session, Turn +from core.dify_builder.models import Action, Actor, Session, Turn from core.dify_builder.runner import CommittedTransition, Env, Runner from core.dify_builder.state import PcState, is_terminal from extensions.ext_database import db @@ -126,27 +128,24 @@ def advance_session(session_id: str, action_dict: dict, actor_dict: dict, token: loaded_session, _fc = repo.get_session(session_id) agent = build_dify_builder_agent(tenant_id=actor.tenant_id, model_config=_fc.model_config) - def emit(ne: NodeEvent) -> None: + def emit_workflow(payload: Mapping[str, object]) -> None: assert env is not None try: progress_bus.publish( session_id, - { - "kind": "node", - "session_id": session_id, - "operation_id": env.operation_id, - "stage_id": env.stage_id, - "at_version": env.at_version, - "revision": env.next_event_revision(), - "node_id": ne.node_id, - "title": ne.title, - "status": ne.status, - "error": ne.error, - "run_id": ne.run_id, - }, + asdict( + WorkflowEventData( + session_id=session_id, + operation_id=env.operation_id, + stage_id=env.stage_id, + at_version=env.at_version, + revision=env.next_event_revision(), + payload=dict(payload), + ) + ), ) except Exception: - logger.exception("dify_builder node event publish failed for session %s", session_id) + logger.exception("dify_builder workflow event publish failed for session %s", session_id) def emit_canvas(event: dict) -> None: assert env is not None @@ -234,7 +233,7 @@ def advance_session(session_id: str, action_dict: dict, actor_dict: dict, token: agent=agent, repo=repo, now=naive_utc_now, - emit=emit, + emit_workflow=emit_workflow, emit_canvas=emit_canvas, emit_commit=emit_commit, emit_message=emit_message, diff --git a/api/tests/unit_tests/core/dify_builder/fakes.py b/api/tests/unit_tests/core/dify_builder/fakes.py index 29e9a5d1f5e..6c55e88ebe8 100644 --- a/api/tests/unit_tests/core/dify_builder/fakes.py +++ b/api/tests/unit_tests/core/dify_builder/fakes.py @@ -33,7 +33,7 @@ Deltas from the Go source: import copy import threading import uuid -from collections.abc import Callable +from collections.abc import Callable, Mapping from core.dify_builder.contract import ConversationPage from core.dify_builder.errors import ConflictError, NotFoundError @@ -312,6 +312,7 @@ class FakeDifyPort: # so a passing run_draft has something for a caller (e.g. the test # result card) to show. Empty by default -- most tests don't care. self.run_outputs: Inputs = {} + self.workflow_events: list[dict[str, object]] = [] def read_graph(self, _app_id: str, _actor: Actor) -> tuple[Graph, str]: return copy.deepcopy(self.graph), self.hash @@ -347,8 +348,13 @@ class FakeDifyPort: _actor: Actor, inputs: Inputs, on_event: Callable[[NodeEvent], None], + *, + on_workflow_event: Callable[[Mapping[str, object]], None] | None = None, ) -> Run: self.run_draft_inputs = copy.deepcopy(inputs) + if on_workflow_event is not None: + for event in self.workflow_events: + on_workflow_event(event) on_event(NodeEvent(node_id="output", status="running")) if self.verify_pass: on_event(NodeEvent(node_id="output", status="success")) @@ -502,6 +508,7 @@ class FakeBuildDifyPort: tests can assert the graph was actually built and the reveal fired.""" def __init__(self) -> None: + self.workflow_events: list[dict[str, object]] = [] self.graph: Graph = {"nodes": [], "edges": []} self.hash: str = "h0" self.applied: list[MutationIntent] = [] @@ -556,8 +563,19 @@ class FakeBuildDifyPort: structure_fingerprint=_structural_fingerprint(self.graph), ) - def run_draft(self, _app_id: str, _actor: Actor, inputs: Inputs, on_event: Callable[[NodeEvent], None]) -> Run: + def run_draft( + self, + _app_id: str, + _actor: Actor, + inputs: Inputs, + on_event: Callable[[NodeEvent], None], + *, + on_workflow_event: Callable[[Mapping[str, object]], None] | None = None, + ) -> Run: self.run_draft_inputs = copy.deepcopy(inputs) + if on_workflow_event is not None: + for event in self.workflow_events: + on_workflow_event(event) on_event(NodeEvent(node_id="llm", status="running")) if self.verify_pass: on_event(NodeEvent(node_id="llm", status="success")) diff --git a/api/tests/unit_tests/core/dify_builder/test_contract.py b/api/tests/unit_tests/core/dify_builder/test_contract.py index 4c5ff1636b2..4d8200a89d3 100644 --- a/api/tests/unit_tests/core/dify_builder/test_contract.py +++ b/api/tests/unit_tests/core/dify_builder/test_contract.py @@ -399,18 +399,28 @@ def test_sse_union_accepts_current_payloads(): events = [ {"event": "command_started", "data": {"kind": "command_started", **view}}, { - "event": "node", + "event": "workflow", "data": { - "kind": "node", + "kind": "workflow", "session_id": "s1", "operation_id": "operation-1", "stage_id": "edit.impact_analysis", "at_version": 3, "revision": 2, - "node_id": "n1", - "title": "LLM", - "status": "running", - "error": "", + "payload": { + "event": "node_started", + "task_id": "task-1", + "workflow_run_id": "run-1", + "data": { + "id": "exec-1", + "node_id": "n1", + "node_type": "llm", + "title": "LLM", + "index": 1, + "created_at": 1, + "inputs": {"query": "hello"}, + }, + }, }, }, { diff --git a/api/tests/unit_tests/core/dify_builder/test_handlers_build.py b/api/tests/unit_tests/core/dify_builder/test_handlers_build.py index 76ef179f060..492054d1c5f 100644 --- a/api/tests/unit_tests/core/dify_builder/test_handlers_build.py +++ b/api/tests/unit_tests/core/dify_builder/test_handlers_build.py @@ -909,7 +909,7 @@ def test_test_and_repair_run_draft_raises_input_error_routes_to_testdata_gate(): assert "in input form" in (result.run.error or "") -def test_await_repair_approve_applies_and_retests(): +def test_await_repair_approve_applies_and_waits_for_retest(): from core.dify_builder.handlers_build import handle_await_repair from core.dify_builder.models import MutationIntent from tests.unit_tests.core.dify_builder.fakes import FakeBuildDifyPort @@ -925,7 +925,7 @@ def test_await_repair_approve_applies_and_retests(): test_input_ref="ti-1", ) result = handle_await_repair(env, Turn(actor=_actor(), action=Action(kind="approve_repair")), s, fc) - assert result.next == PcState.BUILD_TEST_AND_REPAIR + assert result.next == PcState.BUILD_EXECUTION assert env.dify.applied # the staged repair was applied assert result.context.staged_repair == [] # cleared after apply diff --git a/api/tests/unit_tests/core/dify_builder/test_handlers_edit.py b/api/tests/unit_tests/core/dify_builder/test_handlers_edit.py index c0ec7f0c564..7da39c6ab8d 100644 --- a/api/tests/unit_tests/core/dify_builder/test_handlers_edit.py +++ b/api/tests/unit_tests/core/dify_builder/test_handlers_edit.py @@ -588,7 +588,7 @@ def test_re_fix_branches_clear_stale_test_input_ref_and_verify_run_id(): assert res2.context.verify_run_id == "" -def test_edit_await_repair_approve_applies_and_retests(): +def test_edit_await_repair_approve_applies_and_waits_for_retest(): from core.dify_builder.handlers_edit import handle_await_repair env, _ = _new_env() @@ -603,7 +603,7 @@ def test_edit_await_repair_approve_applies_and_retests(): test_input_ref="ti-1", ) result = handle_await_repair(env, Turn(actor=_actor(), action=Action(kind="approve_repair")), s, fc) - assert result.next == PcState.EDIT_TEST_AFFECTED_PATHS + assert result.next == PcState.EDIT_APPLY_CHANGES assert result.context.staged_repair == [] diff --git a/api/tests/unit_tests/core/dify_builder/test_workflow_events.py b/api/tests/unit_tests/core/dify_builder/test_workflow_events.py new file mode 100644 index 00000000000..ff72fd4437e --- /dev/null +++ b/api/tests/unit_tests/core/dify_builder/test_workflow_events.py @@ -0,0 +1,131 @@ +"""Full run events and the separate repair/retest command boundary.""" + +from datetime import datetime +from unittest.mock import MagicMock + +import pytest + +from core.dify_builder.handlers_build import build_registry, handle_test_and_repair +from core.dify_builder.handlers_edit import edit_registry, handle_test_affected_paths +from core.dify_builder.handlers_fix import handle_verify +from core.dify_builder.models import ( + Action, + Actor, + DifyBuilderContext, + EntryMode, + MutationIntent, + Run, + Session, + TestInput, + Turn, +) +from core.dify_builder.runner import Env, Runner +from core.dify_builder.state import PcState +from tests.unit_tests.core.dify_builder.fakes import FakeDifyPort, FakeEditDifyPort, InMemoryRepository, StubAgent + + +@pytest.mark.parametrize( + ("handler", "state", "mode"), + [ + (handle_test_and_repair, PcState.BUILD_TEST_AND_REPAIR, EntryMode.BUILD), + (handle_test_affected_paths, PcState.EDIT_TEST_AFFECTED_PATHS, EntryMode.EDIT), + (handle_verify, PcState.FIX_VERIFY, EntryMode.FIX), + ], +) +def test_all_test_flows_forward_native_events(handler, state, mode): + port = FakeDifyPort() + port.workflow_events = [ + {"event": "workflow_started", "workflow_run_id": "run-1", "data": {"inputs": {"query": "hello"}}}, + {"event": "text_chunk", "workflow_run_id": "run-1", "data": {"text": "42"}}, + {"event": "workflow_finished", "workflow_run_id": "run-1", "data": {"status": "succeeded"}}, + ] + received = [] + env = Env( + dify=port, + agent=StubAgent(), + repo=InMemoryRepository(), + now=lambda: datetime.min, + emit_workflow=received.append, + ) + session = Session(app_id="app", tenant_id="tenant", entry_mode=mode, current_state=state) + + handler(env, Turn(actor=Actor(account_id="account", tenant_id="tenant")), session, DifyBuilderContext()) + + assert received == port.workflow_events + + +@pytest.mark.parametrize( + ("registry", "mode", "repair_state", "ready_state", "test_action", "review_state"), + [ + ( + build_registry, + EntryMode.BUILD, + PcState.BUILD_AWAIT_REPAIR, + PcState.BUILD_EXECUTION, + "run_test", + PcState.BUILD_REVIEW, + ), + ( + edit_registry, + EntryMode.EDIT, + PcState.EDIT_AWAIT_REPAIR, + PcState.EDIT_APPLY_CHANGES, + "run_affected_tests", + PcState.EDIT_REVIEW, + ), + ], +) +def test_structural_repair_is_committed_before_a_separate_retest( + registry, mode, repair_state, ready_state, test_action, review_state +): + port = FakeEditDifyPort() + port.run_draft = MagicMock(wraps=port.run_draft) + repo = InMemoryRepository() + env = Env(dify=port, agent=StubAgent(), repo=repo, now=lambda: datetime.min) + session = Session(app_id="app", tenant_id="tenant", entry_mode=mode, current_state=repair_state) + actor = Actor(account_id="account", tenant_id="tenant") + repo.create_session( + session, + DifyBuilderContext( + test_input_ref="input-1", + staged_repair=[ + MutationIntent(op="create_node", args={"node_id": "new-code", "node_type": "code", "config": {}}) + ], + ), + [], + ) + repo.save_test_input(TestInput(id="input-1", session_id=session.id, source="mock", inputs={})) + runner = Runner(env, registry()) + + applied = runner.advance( + session.id, Turn(actor=actor, action=Action(kind="approve_repair", base_version=session.version)) + ) + + assert applied.current_state == ready_state + assert "new-code" in {node["id"] for node in port.graph["nodes"]} + port.run_draft.assert_not_called() + + tested = runner.advance( + session.id, Turn(actor=actor, action=Action(kind=test_action, base_version=applied.version)) + ) + + assert tested.current_state == review_state + port.run_draft.assert_called_once() + + +def test_edit_unknown_result_does_not_stage_a_failure_or_repair(): + port = FakeEditDifyPort() + port.run_draft = MagicMock(return_value=Run(status="running", dify_run_id="run-1")) + env = Env(dify=port, agent=StubAgent(), repo=InMemoryRepository(), now=lambda: datetime.min) + session = Session( + app_id="app", tenant_id="tenant", entry_mode=EntryMode.EDIT, current_state=PcState.EDIT_TEST_AFFECTED_PATHS + ) + + result = handle_test_affected_paths( + env, Turn(actor=Actor(account_id="account", tenant_id="tenant")), session, DifyBuilderContext() + ) + + assert result.next == PcState.EDIT_APPLY_CHANGES + assert result.run.status == "running" + assert not result.context.staged_repair + assert not any(item.kind in {"test_result", "error", "change_set"} for item in result.items) diff --git a/api/tests/unit_tests/services/dify_builder/test_advance_task.py b/api/tests/unit_tests/services/dify_builder/test_advance_task.py index 29586881f0d..f4d666869a6 100644 --- a/api/tests/unit_tests/services/dify_builder/test_advance_task.py +++ b/api/tests/unit_tests/services/dify_builder/test_advance_task.py @@ -83,7 +83,16 @@ def wired(monkeypatch, repo: SqlDifyBuilderRepository): place of the real Dify adapter, and event-capturing stand-ins for ``progress_bus.publish``/``session_lock.release``.""" monkeypatch.setattr(mod, "_build_repo", lambda: repo) - monkeypatch.setattr(mod, "WorkflowServiceDifyPort", FakeDifyPort) + port = FakeDifyPort() + port.workflow_events = [ + { + "event": "workflow_started", + "task_id": "task-1", + "workflow_run_id": "dify-run-1", + "data": {"id": "dify-run-1", "workflow_id": "workflow-1", "inputs": {}, "created_at": 1}, + } + ] + monkeypatch.setattr(mod, "WorkflowServiceDifyPort", lambda: port) monkeypatch.setattr(mod, "build_dify_builder_agent", lambda **_kwargs: StubAgent()) events: list[tuple[str, dict]] = [] @@ -159,9 +168,11 @@ def test_advance_session_drives_state_forward_emits_events_and_releases_lock( stored, _fc = repo.get_session(s.id) assert stored.current_state == PcState.FIX_AWAIT_DECISION - node_events = [ev for _sid, ev in events if ev["kind"] == "node"] - assert node_events, "FakeDifyPort.run_draft's on_event callback must reach progress_bus as node events" - assert all(event["session_id"] == s.id and event["operation_id"] for event in node_events) + workflow_events = [ev for _sid, ev in events if ev["kind"] == "workflow"] + assert workflow_events, "native run callbacks must reach progress_bus unchanged" + assert all(event["session_id"] == s.id and event["operation_id"] for event in workflow_events) + assert workflow_events[0]["payload"]["event"] == "workflow_started" + assert workflow_events[0]["payload"]["data"]["inputs"] == {} assert (s.id, "tok-2") in released assert (s.id, "tok-3") in released diff --git a/api/tests/unit_tests/services/dify_builder/test_async_flow.py b/api/tests/unit_tests/services/dify_builder/test_async_flow.py index 15fbdade746..ede3bc5d7a6 100644 --- a/api/tests/unit_tests/services/dify_builder/test_async_flow.py +++ b/api/tests/unit_tests/services/dify_builder/test_async_flow.py @@ -93,7 +93,20 @@ def _wire(monkeypatch, repo: SqlDifyBuilderRepository) -> tuple[DifyBuilderServi and the real (faked-Redis) session_lock module.""" monkeypatch.setattr(session_lock, "redis_client", _FakeRedis()) # real lock module, faked redis (shared) monkeypatch.setattr(task_mod, "_build_repo", lambda: repo) # task uses the SAME repo - monkeypatch.setattr(task_mod, "WorkflowServiceDifyPort", FakeDifyPort) + + def make_port(): + port = FakeDifyPort() + port.workflow_events = [ + { + "event": "workflow_started", + "task_id": "task-1", + "workflow_run_id": "dify-run-1", + "data": {"id": "dify-run-1", "workflow_id": "workflow-1", "inputs": {}, "created_at": 1}, + } + ] + return port + + monkeypatch.setattr(task_mod, "WorkflowServiceDifyPort", make_port) monkeypatch.setattr(task_mod, "build_dify_builder_agent", lambda **_kwargs: StubAgent()) monkeypatch.setattr( service_mod, @@ -151,9 +164,9 @@ def test_full_fix_flow_create_to_publish_success_eager_async(monkeypatch, repo: stored, _fc = repo.get_session(sid) assert stored.current_state == PcState.SUCCESS - # the bus received both node events (from the provide_testdata -> fix.verify + # the bus received both native workflow events (from provide_testdata -> fix.verify # draft run) and terminal state events across the whole flow. - assert any(ev.get("kind") == "node" for _sid, ev in events) + assert any(ev.get("kind") == "workflow" for _sid, ev in events) assert any(ev.get("kind") == "state" for _sid, ev in events) # the lock was acquired-then-released on every dispatch, including the diff --git a/api/tests/unit_tests/services/dify_builder/test_contract_stream.py b/api/tests/unit_tests/services/dify_builder/test_contract_stream.py index c35fdc9dce9..6fda356c397 100644 --- a/api/tests/unit_tests/services/dify_builder/test_contract_stream.py +++ b/api/tests/unit_tests/services/dify_builder/test_contract_stream.py @@ -1,11 +1,39 @@ """Contract-level checks for typed Dify Builder SSE envelopes.""" import json +import operator from collections.abc import Iterator import pytest +from controllers.console.dify_builder_fields import DifyBuilderStreamEventResponse from services.dify_builder import wiring +from tests.unit_tests.services.dify_builder.workflow_stream_fixtures import ( + native_chatflow_payloads, + native_workflow_payloads, +) + + +@pytest.mark.parametrize( + "payload", native_workflow_payloads() + native_chatflow_payloads(), ids=operator.itemgetter("event") +) +def test_workflow_envelope_preserves_the_native_debugger_contract(payload): + frame = { + "event": "workflow", + "data": { + "kind": "workflow", + "session_id": "session-1", + "operation_id": "operation-1", + "stage_id": "build.test_and_repair", + "at_version": 2, + "revision": 1, + "payload": payload, + }, + } + + parsed = DifyBuilderStreamEventResponse.model_validate(frame) + + assert parsed.model_dump(mode="json", exclude_unset=True) == frame class _FakeSubscription: diff --git a/api/tests/unit_tests/services/dify_builder/test_dify_port.py b/api/tests/unit_tests/services/dify_builder/test_dify_port.py index 98587594239..73a04d45b3b 100644 --- a/api/tests/unit_tests/services/dify_builder/test_dify_port.py +++ b/api/tests/unit_tests/services/dify_builder/test_dify_port.py @@ -10,7 +10,7 @@ The goal is to pin the adapter's ORCHESTRATION and the P2 plan's OSS gotchas: copy -- the workflow's own ``graph_dict`` stays untouched), and re-maps ``WorkflowHashNotEqualError`` to the domain ``HashMismatchError``. - ``run_draft`` calls ``AppGenerateService.generate`` with - ``invoke_from=InvokeFrom.DEBUGGER`` and ``streaming=True``, emits an + ``invoke_from=InvokeFrom.DEBUGGER`` and ``streaming=True`` in process, emits an ``on_event`` per node frame *as the run streams*, consumes that stream outside the ``Session`` block, and still reads the node-execution rows once at the end for the ``run_mapping``-mapped ``Run``. A stream that ends without @@ -41,6 +41,10 @@ from services.dify_builder.dify_port import WorkflowServiceDifyPort from services.dify_builder.errors import HashMismatchError, WorkflowNotInitializedError from services.dify_builder.revision import execution_revision from services.errors.app import WorkflowHashNotEqualError +from tests.unit_tests.services.dify_builder.workflow_stream_fixtures import ( + native_chatflow_payloads, + native_workflow_payloads, +) def _workflow(*, graph_dict: dict | None = None, features_dict: dict | None = None) -> Workflow: @@ -79,7 +83,7 @@ def mock_session() -> MagicMock: @pytest.fixture(autouse=True) def _mock_db(): - with patch("services.dify_builder.dify_port.db"): + with patch("services.dify_builder.dify_port.db"), patch("services.dify_builder.dify_port.set_login_user"): yield @@ -674,15 +678,8 @@ def _node_exec() -> SimpleNamespace: def test_run_draft_streams_node_events_while_the_run_is_still_going(mock_session: MagicMock): - """The stream-consumption path: when handed an event stream, the callback - fires per event rather than once at the end. - - NOTE: production no longer asks for a stream -- ``run_draft`` runs inside a - Celery task and streaming mode would enqueue a second Celery task and block - on it, deadlocking the worker (see - ``test_run_draft_does_not_enqueue_a_second_celery_task``). This path is kept - and tested so it stays correct for a future consumer that is NOT a worker, - which is what live node streaming to the frontend will need.""" + """In-process streaming emits each event while the workflow is running, + without waiting for a second Celery task or replaying completed rows.""" account = SimpleNamespace(id="acc-1") app = SimpleNamespace(id="app-1", tenant_id="tenant-1") _configure_session_get(mock_session, account=account, app=app) @@ -713,7 +710,8 @@ def test_run_draft_streams_node_events_while_the_run_is_still_going(mock_session assert kwargs["user"] is account assert kwargs["args"] == {"inputs": {"q": "hi"}} assert kwargs["invoke_from"] == InvokeFrom.DEBUGGER - assert kwargs["streaming"] is False # see the docstring: never True from a worker + assert kwargs["streaming"] is True + assert kwargs["workflow_execution_mode"] == "in_process" assert kwargs["session"] is mock_session # Ping frames and workflow-level frames produce no node event. @@ -793,6 +791,35 @@ def test_run_draft_accepts_raw_mapping_chunks(mock_session: MagicMock): assert run.dify_run_id == "run-1" +@pytest.mark.parametrize("wire_format", ["mapping", "sse"]) +@pytest.mark.parametrize("payload_factory", [native_workflow_payloads, native_chatflow_payloads]) +def test_run_draft_forwards_full_payloads_synchronously_without_reconstruction( + mock_session: MagicMock, wire_format, payload_factory +): + _configure_default_identity(mock_session) + payloads = payload_factory() + received = [] + summaries = [] + + def stream(): + yield "event: ping\n\n" + for payload in payloads: + yield _sse(payload) if wire_format == "sse" else payload + assert received[-1] == payload + + with ( + patch("services.dify_builder.dify_port.AppGenerateService") as generate, + patch("services.dify_builder.dify_port.DifyAPIRepositoryFactory") as repositories, + ): + generate.generate.return_value = stream() + node_repo = repositories.create_api_workflow_node_execution_repository.return_value + node_repo.get_executions_by_workflow_run.return_value = [] + WorkflowServiceDifyPort().run_draft("app-1", _actor(), {}, summaries.append, on_workflow_event=received.append) + + assert received == payloads + assert summaries # Backend progress and diagnosis still receive node summaries. + + def test_run_draft_maps_a_paused_stream_to_a_failed_run(mock_session: MagicMock): """``workflow_paused`` carries ``workflow_run_id`` where blocking carried ``id``.""" _configure_default_identity(mock_session) @@ -1075,13 +1102,9 @@ def test_run_draft_reads_a_blocking_dict_response(mock_session: MagicMock): def test_run_draft_does_not_enqueue_a_second_celery_task(mock_session: MagicMock): - """``run_draft`` executes INSIDE the Celery task dify_builder_advance_task. - In streaming mode AppGenerateService enqueues workflow_based_app_execution_task - and returns a subscription (app_generate_service.py:322-324), so the advance - task would block on events from a task that needs the worker slot the advance - task is holding -- a self-deadlock. Observed live: advance_session succeeded - in 300.59s (the idle timeout) x4 while the workflow itself ran in ~2s. - """ + """The Builder owns a Celery slot, so it must request in-process streaming. + Default streaming dispatches a child task and deadlocks when all slots are + occupied by Builder tasks waiting for their children.""" account = SimpleNamespace(id="acc-1") app = SimpleNamespace(id="app-1", tenant_id="tenant-1") _configure_session_get(mock_session, account=account, app=app) @@ -1090,17 +1113,13 @@ def test_run_draft_does_not_enqueue_a_second_celery_task(mock_session: MagicMock patch("services.dify_builder.dify_port.AppGenerateService") as mock_ags, patch("services.dify_builder.dify_port.DifyAPIRepositoryFactory") as mock_repo_factory, ): - mock_ags.generate.return_value = { - "task_id": "t", - "workflow_run_id": "run-1", - "data": dict(_FINISHED_CHUNK["data"]), - } - mock_repo_factory.create_api_workflow_node_execution_repository.return_value.get_executions_by_workflow_run.return_value = [] + mock_ags.generate.return_value = iter([_sse(_FINISHED_CHUNK)]) + node_repo = mock_repo_factory.create_api_workflow_node_execution_repository.return_value + node_repo.get_executions_by_workflow_run.return_value = [] WorkflowServiceDifyPort().run_draft("app-1", _actor(), {}, lambda _e: None) _, kwargs = mock_ags.generate.call_args - assert kwargs["streaming"] is False, ( - "streaming=True makes AppGenerateService delay() a second Celery task and " - "blocks this worker waiting for it -- deadlock at concurrency 1, capacity " - "bomb at concurrency N" + assert kwargs["streaming"] is True + assert kwargs["workflow_execution_mode"] == "in_process", ( + "Builder streaming must execute in process to avoid waiting for a second Celery worker slot" ) diff --git a/api/tests/unit_tests/services/dify_builder/test_engine_on_sql_repo.py b/api/tests/unit_tests/services/dify_builder/test_engine_on_sql_repo.py index e76ea142196..c8e88aa1d34 100644 --- a/api/tests/unit_tests/services/dify_builder/test_engine_on_sql_repo.py +++ b/api/tests/unit_tests/services/dify_builder/test_engine_on_sql_repo.py @@ -69,7 +69,7 @@ def _actor() -> Actor: def _new_env(repo: SqlDifyBuilderRepository) -> tuple[Env, FakeDifyPort]: dify = FakeDifyPort() - env = Env(dify=dify, agent=PlaceholderAgent(), repo=repo, now=lambda: datetime.min, emit=None) + env = Env(dify=dify, agent=PlaceholderAgent(), repo=repo, now=lambda: datetime.min) return env, dify diff --git a/api/tests/unit_tests/services/dify_builder/test_progress_bus.py b/api/tests/unit_tests/services/dify_builder/test_progress_bus.py index 0d402c59285..2c1396311d9 100644 --- a/api/tests/unit_tests/services/dify_builder/test_progress_bus.py +++ b/api/tests/unit_tests/services/dify_builder/test_progress_bus.py @@ -16,7 +16,7 @@ SESSION_ID = "11111111-1111-1111-1111-111111111111" def test_publish_sends_json_encoded_event_to_the_session_topic() -> None: fake_channel = MagicMock() - event = {"kind": "node", "node_id": "abc", "status": "succeeded"} + event = {"kind": "workflow", "payload": {"event": "workflow_started", "workflow_run_id": "run-1"}} with patch("services.dify_builder.progress_bus.get_pubsub_broadcast_channel", return_value=fake_channel): progress_bus.publish(SESSION_ID, event) diff --git a/api/tests/unit_tests/services/dify_builder/test_stream_frames.py b/api/tests/unit_tests/services/dify_builder/test_stream_frames.py index 718c6c16b69..d767259d3ec 100644 --- a/api/tests/unit_tests/services/dify_builder/test_stream_frames.py +++ b/api/tests/unit_tests/services/dify_builder/test_stream_frames.py @@ -45,7 +45,7 @@ def test_stream_advance_frames_closes_subscription_when_never_iterated(): def test_stream_advance_frames_relays_typed_envelopes_until_terminal_state(): view = {"session_id": "s1", "state": "fix.diagnose"} - node = {"kind": "node", "node_id": "n1", "status": "running"} + node = {"kind": "workflow", "payload": {"event": "node_started", "data": {"node_id": "n1"}}} commit = { "kind": "commit", "session_id": "s1", @@ -55,7 +55,7 @@ def test_stream_advance_frames_relays_typed_envelopes_until_terminal_state(): "items": [], } state = {"kind": "state", "version": 7, "session_id": "s1"} - extra = {"kind": "node", "node_id": "n2"} + extra = {"kind": "workflow", "payload": {"event": "node_started", "data": {"node_id": "n2"}}} subscription = _FakeSubscription( [ json.dumps(node).encode(), @@ -72,7 +72,7 @@ def test_stream_advance_frames_relays_typed_envelopes_until_terminal_state(): "event": "command_started", "data": {"kind": "command_started", **view}, } - assert _event(frames[1]) == {"event": "node", "data": node} + assert _event(frames[1]) == {"event": "workflow", "data": node} assert frames[2] == ": keep-alive\n\n" assert _event(frames[3]) == {"event": "commit", "data": commit} assert _event(frames[4]) == {"event": "state", "data": state} @@ -111,6 +111,19 @@ def test_stream_advance_frames_closes_on_error_event(): assert subscription.closed is True +@pytest.mark.parametrize("event", ["workflow_finished", "workflow_paused", "error"]) +def test_native_workflow_terminal_event_does_not_end_the_builder_command(event): + workflow = {"kind": "workflow", "payload": {"event": event, "workflow_run_id": "run-1"}} + commit = {"kind": "commit", "session_id": "s1", "version": 2, "items": []} + state = {"kind": "state", "session_id": "s1", "version": 2} + subscription = _FakeSubscription([json.dumps(item).encode() for item in (workflow, commit, state)]) + + frames = list(wiring.stream_advance_frames({"session_id": "s1"}, subscription, expect_advance=True)) + + assert [_event(frame)["event"] for frame in frames] == ["command_started", "workflow", "commit", "state"] + assert _event(frames[1])["data"]["payload"] == workflow["payload"] + + def test_stream_advance_frames_settled_call_yields_command_handshake_only(): view = {"session_id": "s1", "state": "edit.capability_check"} diff --git a/api/tests/unit_tests/services/dify_builder/workflow_stream_fixtures.py b/api/tests/unit_tests/services/dify_builder/workflow_stream_fixtures.py new file mode 100644 index 00000000000..a27cef868d8 --- /dev/null +++ b/api/tests/unit_tests/services/dify_builder/workflow_stream_fixtures.py @@ -0,0 +1,115 @@ +"""Native debugger payloads shared by Builder adapter and wire-contract tests.""" + +from core.app.apps.advanced_chat.generate_response_converter import AdvancedChatAppGenerateResponseConverter +from core.app.apps.workflow.generate_response_converter import WorkflowAppGenerateResponseConverter +from core.app.entities import task_entities as events + + +def _native_responses() -> list[events.StreamResponse]: + data = { + "id": "execution-1", + "workflow_id": "workflow-1", + "workflow_run_id": "run-1", + "node_id": "node-1", + "node_execution_id": "execution-1", + "node_type": "code", + "title": "Code", + "index": 1, + "predecessor_node_id": "start", + "inputs": {"query": "hello"}, + "inputs_truncated": True, + "process_data": {"nested": {"values": [1, 2]}}, + "process_data_truncated": True, + "outputs": {"answer": "42"}, + "outputs_truncated": True, + "status": "succeeded", + "elapsed_time": 1.5, + "total_tokens": 10, + "total_steps": 2, + "steps": 2, + "created_at": 1, + "finished_at": 2, + "iteration_id": "iteration-1", + "loop_id": "loop-1", + "retry_index": 2, + "execution_metadata": {"total_tokens": 10, "iteration_index": 1}, + "metadata": {"iterator_length": 2, "loop_length": 2}, + "text": "answer", + "reasoning": "reasoning", + "is_final": True, + "label": "Tool call", + "data": {"tool_output": "42"}, + "node_title": "Approval", + "form_id": "form-1", + "form_content": "Approve?", + "expiration_time": 10, + "rendered_content": "Approved", + "action_id": "approve", + "action_text": "Approve", + "submitted_data": {"comment": "Proceed"}, + } + models = [ + events.WorkflowStartStreamResponse, + events.NodeStartStreamResponse, + events.NodeRetryStreamResponse, + events.NodeFinishStreamResponse, + events.IterationNodeStartStreamResponse, + events.IterationNodeNextStreamResponse, + events.IterationNodeCompletedStreamResponse, + events.LoopNodeStartStreamResponse, + events.LoopNodeNextStreamResponse, + events.LoopNodeCompletedStreamResponse, + events.TextChunkStreamResponse, + events.TextReplaceStreamResponse, + events.ReasoningChunkStreamResponse, + events.AgentLogStreamResponse, + events.HumanInputRequiredResponse, + events.HumanInputFormFilledResponse, + events.HumanInputFormTimeoutResponse, + events.WorkflowPauseStreamResponse, + events.WorkflowFinishStreamResponse, + events.MessageAudioStreamResponse, + events.MessageAudioEndStreamResponse, + ] + responses = [] + for model in models: + event_data = dict(data) + if model is events.HumanInputRequiredResponse: + event_data["inputs"] = [] + if model is events.WorkflowPauseStreamResponse: + event_data["status"] = "paused" + response = model.model_validate( + {"task_id": "task-1", "workflow_run_id": "run-1", "data": event_data, "audio": "audio"} + ) + responses.append(response) + responses.append(events.ErrorStreamResponse(task_id="task-1", err=ValueError("Bad input"))) + return responses + + +def native_workflow_payloads() -> list[dict]: + converted = WorkflowAppGenerateResponseConverter.convert_stream_full_response( + events.WorkflowAppStreamResponse(stream_response=response, workflow_run_id="run-1") + for response in _native_responses() + ) + return [payload for payload in converted if isinstance(payload, dict)] + + +def native_chatflow_payloads() -> list[dict]: + responses = [ + *_native_responses(), + events.MessageStreamResponse( + task_id="task-1", id="message-1", answer="answer", from_variable_selector=["answer", "text"] + ), + events.MessageEndStreamResponse(task_id="task-1", id="message-1", metadata={"usage": {"total_tokens": 10}}), + events.MessageFileStreamResponse( + task_id="task-1", id="file-1", type="image", belongs_to="assistant", url="/files/image.png" + ), + events.MessageReplaceStreamResponse(task_id="task-1", answer="replacement", reason="moderation"), + ] + converted = AdvancedChatAppGenerateResponseConverter.convert_stream_full_response( + events.ChatbotAppStreamResponse( + stream_response=response, conversation_id="conversation-1", message_id="message-1", created_at=1 + ) + for response in responses + ) + return [payload for payload in converted if isinstance(payload, dict)] diff --git a/api/tests/unit_tests/services/test_app_generate_service_in_process.py b/api/tests/unit_tests/services/test_app_generate_service_in_process.py new file mode 100644 index 00000000000..99fcc3f5f80 --- /dev/null +++ b/api/tests/unit_tests/services/test_app_generate_service_in_process.py @@ -0,0 +1,85 @@ +"""A streaming run inside a Celery task must not wait for another pool slot.""" + +import json +from unittest.mock import MagicMock + +import pytest +from sqlalchemy.orm import Session, sessionmaker + +import services.app_generate_service as module +from core.app.entities.app_invoke_entities import InvokeFrom +from core.app.features.rate_limiting.rate_limit import RateLimit +from enums import DeploymentEdition +from models.model import Account, App, AppMode +from services.app_generate_service import AppGenerateService + + +@pytest.mark.parametrize("mode", [AppMode.WORKFLOW, AppMode.ADVANCED_CHAT]) +@pytest.mark.parametrize("ending", ["complete", "close", "error"]) +def test_in_process_stream_does_not_enqueue_a_child_task_and_releases_its_rate_limit( + monkeypatch: pytest.MonkeyPatch, config_overrides, mode, ending +): + config_overrides(DEPLOYMENT_EDITION=DeploymentEdition.COMMUNITY) + app = MagicMock(spec=App) + app.id = "app-1" + app.mode = mode + app.is_agent_with_session.return_value = False + user = MagicMock(spec=Account) + workflow = MagicMock(created_by="account-1") + monkeypatch.setattr(AppGenerateService, "_get_workflow", MagicMock(return_value=workflow)) + monkeypatch.setattr(AppGenerateService, "_get_max_active_requests", lambda _: 0) + monkeypatch.setattr(module.session_factory, "get_session_maker", sessionmaker) + + limiter = RateLimit("in-process-stream-test", 0) + limiter.enter = MagicMock(return_value="request-1") + limiter.exit = MagicMock() + monkeypatch.setattr(module, "RateLimit", MagicMock(return_value=limiter)) + enqueue = MagicMock(side_effect=AssertionError("the parent task owns the only worker slot")) + monkeypatch.setattr(module.workflow_based_app_execution_task, "delay", enqueue) + closed = [] + + def stream(): + try: + yield {"event": "workflow_started", "workflow_run_id": "run-1", "data": {"inputs": {"query": "hello"}}} + if ending == "error": + raise ValueError("runtime failed") + yield {"event": "workflow_finished", "workflow_run_id": "run-1", "data": {"status": "succeeded"}} + finally: + closed.append(True) + + generate = MagicMock(side_effect=lambda **_: stream()) + generator_type = module.WorkflowAppGenerator if mode == AppMode.WORKFLOW else module.AdvancedChatAppGenerator + monkeypatch.setattr(generator_type, "generate", generate) + + with Session() as session: + response = AppGenerateService.generate( + app_model=app, + user=user, + args={"inputs": {}, "query": "hello"}, + invoke_from=InvokeFrom.DEBUGGER, + session=session, + streaming=True, + workflow_execution_mode="in_process", + ) + + assert json.loads(next(response).removeprefix("data: ")) == { + "event": "workflow_started", + "workflow_run_id": "run-1", + "data": {"inputs": {"query": "hello"}}, + } + assert generate.call_args.kwargs["args"] == {"inputs": {}, "query": "hello"} + assert generate.call_args.kwargs["streaming"] is True + assert generate.call_args.kwargs["pause_state_config"].state_owner_user_id == "account-1" + enqueue.assert_not_called() + limiter.exit.assert_not_called() + + if ending == "error": + with pytest.raises(ValueError, match="runtime failed"): + list(response) + elif ending == "close": + response.close() + else: + assert len(list(response)) == 1 + + assert closed == [True] + limiter.exit.assert_called_once_with("request-1") diff --git a/api/tests/unit_tests/services/test_app_generate_service_streaming_integration.py b/api/tests/unit_tests/services/test_app_generate_service_streaming_integration.py index 1595a1db952..846ae4412c8 100644 --- a/api/tests/unit_tests/services/test_app_generate_service_streaming_integration.py +++ b/api/tests/unit_tests/services/test_app_generate_service_streaming_integration.py @@ -19,10 +19,12 @@ class _FakePubSub: def __init__(self, store: dict[str, deque[bytes]]): self._store = store self._subs: set[str] = set() + self._acks: deque[dict[str, object]] = deque() self._closed = False def subscribe(self, topic: str) -> None: self._subs.add(topic) + self._acks.append({"type": "subscribe", "channel": topic, "data": len(self._subs)}) def unsubscribe(self, topic: str) -> None: self._subs.discard(topic) @@ -34,6 +36,10 @@ class _FakePubSub: # simulate a non-blocking poll; return first available if self._closed: return None + while self._acks: + acknowledgement = self._acks.popleft() + if not ignore_subscribe_messages: + return acknowledgement for t in list(self._subs): q = self._store.get(t) if q and len(q) > 0: diff --git a/oxlint-suppressions.json b/oxlint-suppressions.json index 77d55e118f6..80cf5ffce38 100644 --- a/oxlint-suppressions.json +++ b/oxlint-suppressions.json @@ -3394,11 +3394,6 @@ "count": 1 } }, - "web/app/components/workflow/hooks/use-workflow-run-event/use-workflow-agent-log.ts": { - "typescript/no-explicit-any": { - "count": 1 - } - }, "web/app/components/workflow/hooks/use-workflow-run-event/use-workflow-finished.ts": { "typescript/no-explicit-any": { "count": 1 diff --git a/packages/contracts/generated/api/console/dify-builder/types.gen.ts b/packages/contracts/generated/api/console/dify-builder/types.gen.ts index f651d77ed15..e99725ce3e3 100644 --- a/packages/contracts/generated/api/console/dify-builder/types.gen.ts +++ b/packages/contracts/generated/api/console/dify-builder/types.gen.ts @@ -15,8 +15,8 @@ export type DifyBuilderStreamEventResponse = event: 'command_started' } & DifyBuilderCommandStartedEventResponse) | ({ - event: 'node' - } & DifyBuilderNodeEventResponse) + event: 'workflow' + } & DifyBuilderWorkflowEventResponse) | ({ event: 'canvas' } & DifyBuilderCanvasEventResponse) @@ -174,9 +174,9 @@ export type DifyBuilderCommandStartedEventResponse = { event: 'command_started' } -export type DifyBuilderNodeEventResponse = { - data: NodeEventData - event: 'node' +export type DifyBuilderWorkflowEventResponse = { + data: DifyBuilderWorkflowEventData + event: 'workflow' } export type DifyBuilderCanvasEventResponse = { @@ -492,21 +492,94 @@ export type DifyBuilderCommandStartedEventData = { version: number } -export type NodeEventData = { +export type DifyBuilderWorkflowEventData = { at_version: number - error: string - kind?: 'node' - node_id: string + kind?: 'workflow' operation_id: string + payload: + | ({ + event: 'workflow_started' + } & WorkflowStreamEventLiteralWorkflowStartedData) + | ({ + event: 'workflow_finished' + } & WorkflowStreamEventLiteralWorkflowFinishedData) + | ({ + event: 'workflow_paused' + } & WorkflowStreamEventLiteralWorkflowPausedData) + | ({ + event: 'node_started' + } & WorkflowStreamEventLiteralNodeStartedData) + | ({ + event: 'node_finished' + } & WorkflowStreamEventLiteralNodeFinishedData) + | ({ + event: 'node_retry' + } & WorkflowStreamEventLiteralNodeRetryData) + | ({ + event: 'iteration_started' + } & WorkflowStreamEventLiteralIterationStartedData) + | ({ + event: 'iteration_next' + } & WorkflowStreamEventLiteralIterationNextData) + | ({ + event: 'iteration_completed' + } & WorkflowStreamEventLiteralIterationCompletedData) + | ({ + event: 'loop_started' + } & WorkflowStreamEventLiteralLoopStartedData) + | ({ + event: 'loop_next' + } & WorkflowStreamEventLiteralLoopNextData) + | ({ + event: 'loop_completed' + } & WorkflowStreamEventLiteralLoopCompletedData) + | ({ + event: 'text_chunk' + } & WorkflowStreamDataEventLiteralTextChunkData) + | ({ + event: 'text_replace' + } & WorkflowStreamDataEventLiteralTextReplaceData) + | ({ + event: 'reasoning_chunk' + } & WorkflowStreamDataEventLiteralReasoningChunkData) + | ({ + event: 'agent_log' + } & WorkflowStreamDataEventLiteralAgentLogData) + | ({ + event: 'human_input_required' + } & WorkflowStreamEventLiteralHumanInputRequiredData) + | ({ + event: 'human_input_form_filled' + } & WorkflowStreamEventLiteralHumanInputFormFilledData) + | ({ + event: 'human_input_form_timeout' + } & WorkflowStreamEventLiteralHumanInputFormTimeoutData) + | ({ + event: 'tts_message' | 'tts_message_end' + } & WorkflowStreamAudio) + | ({ + event: 'message' + } & WorkflowStreamMessage) + | ({ + event: 'message_end' + } & WorkflowStreamMessageEnd) + | ({ + event: 'message_file' + } & WorkflowStreamMessageFile) + | ({ + event: 'message_replace' + } & WorkflowStreamMessageReplace) + | ({ + event: 'error' + } & WorkflowStreamError) revision: number session_id: string stage_id: string - status: string - title: string } export type CanvasEventData = { at_version: number + dify_run_id?: string edge?: CanvasEdge | null event: CanvasEvent kind?: 'canvas' @@ -717,7 +790,6 @@ export type ChallengeCard = { } export type ResourceSelectCard = { - conflict_policy_options?: Array recommended?: Array } @@ -735,6 +807,8 @@ export type ChangeSetCard = { } export type TestResultCard = { + dify_run_id?: string + output?: string run_ids?: Array stats?: Array subtitle: string @@ -769,6 +843,220 @@ export type BuildLearningCard = { state: string } +export type WorkflowStreamEventLiteralWorkflowStartedData = { + data: CoreAppEntitiesTaskEntitiesWorkflowStartStreamResponseData + event: 'workflow_started' + task_id: string + workflow_run_id: string + [key: string]: unknown +} + +export type WorkflowStreamEventLiteralWorkflowFinishedData = { + data: CoreAppEntitiesTaskEntitiesWorkflowFinishStreamResponseData + event: 'workflow_finished' + task_id: string + workflow_run_id: string + [key: string]: unknown +} + +export type WorkflowStreamEventLiteralWorkflowPausedData = { + data: CoreAppEntitiesTaskEntitiesWorkflowPauseStreamResponseData + event: 'workflow_paused' + task_id: string + workflow_run_id: string + [key: string]: unknown +} + +export type WorkflowStreamEventLiteralNodeStartedData = { + data: CoreAppEntitiesTaskEntitiesNodeStartStreamResponseData + event: 'node_started' + task_id: string + workflow_run_id: string + [key: string]: unknown +} + +export type WorkflowStreamEventLiteralNodeFinishedData = { + data: CoreAppEntitiesTaskEntitiesNodeFinishStreamResponseData + event: 'node_finished' + task_id: string + workflow_run_id: string + [key: string]: unknown +} + +export type WorkflowStreamEventLiteralNodeRetryData = { + data: CoreAppEntitiesTaskEntitiesNodeRetryStreamResponseData + event: 'node_retry' + task_id: string + workflow_run_id: string + [key: string]: unknown +} + +export type WorkflowStreamEventLiteralIterationStartedData = { + data: CoreAppEntitiesTaskEntitiesIterationNodeStartStreamResponseData + event: 'iteration_started' + task_id: string + workflow_run_id: string + [key: string]: unknown +} + +export type WorkflowStreamEventLiteralIterationNextData = { + data: CoreAppEntitiesTaskEntitiesIterationNodeNextStreamResponseData + event: 'iteration_next' + task_id: string + workflow_run_id: string + [key: string]: unknown +} + +export type WorkflowStreamEventLiteralIterationCompletedData = { + data: CoreAppEntitiesTaskEntitiesIterationNodeCompletedStreamResponseData + event: 'iteration_completed' + task_id: string + workflow_run_id: string + [key: string]: unknown +} + +export type WorkflowStreamEventLiteralLoopStartedData = { + data: CoreAppEntitiesTaskEntitiesLoopNodeStartStreamResponseData + event: 'loop_started' + task_id: string + workflow_run_id: string + [key: string]: unknown +} + +export type WorkflowStreamEventLiteralLoopNextData = { + data: CoreAppEntitiesTaskEntitiesLoopNodeNextStreamResponseData + event: 'loop_next' + task_id: string + workflow_run_id: string + [key: string]: unknown +} + +export type WorkflowStreamEventLiteralLoopCompletedData = { + data: CoreAppEntitiesTaskEntitiesLoopNodeCompletedStreamResponseData + event: 'loop_completed' + task_id: string + workflow_run_id: string + [key: string]: unknown +} + +export type WorkflowStreamDataEventLiteralTextChunkData = { + data: CoreAppEntitiesTaskEntitiesTextChunkStreamResponseData + event: 'text_chunk' + task_id: string + workflow_run_id?: string | null + [key: string]: unknown +} + +export type WorkflowStreamDataEventLiteralTextReplaceData = { + data: CoreAppEntitiesTaskEntitiesTextReplaceStreamResponseData + event: 'text_replace' + task_id: string + workflow_run_id?: string | null + [key: string]: unknown +} + +export type WorkflowStreamDataEventLiteralReasoningChunkData = { + data: CoreAppEntitiesTaskEntitiesReasoningChunkStreamResponseData + event: 'reasoning_chunk' + task_id: string + workflow_run_id?: string | null + [key: string]: unknown +} + +export type WorkflowStreamDataEventLiteralAgentLogData = { + data: CoreAppEntitiesTaskEntitiesAgentLogStreamResponseData + event: 'agent_log' + task_id: string + workflow_run_id?: string | null + [key: string]: unknown +} + +export type WorkflowStreamEventLiteralHumanInputRequiredData = { + data: CoreAppEntitiesTaskEntitiesHumanInputRequiredResponseData + event: 'human_input_required' + task_id: string + workflow_run_id: string + [key: string]: unknown +} + +export type WorkflowStreamEventLiteralHumanInputFormFilledData = { + data: CoreAppEntitiesTaskEntitiesHumanInputFormFilledResponseData + event: 'human_input_form_filled' + task_id: string + workflow_run_id: string + [key: string]: unknown +} + +export type WorkflowStreamEventLiteralHumanInputFormTimeoutData = { + data: CoreAppEntitiesTaskEntitiesHumanInputFormTimeoutResponseData + event: 'human_input_form_timeout' + task_id: string + workflow_run_id: string + [key: string]: unknown +} + +export type WorkflowStreamAudio = { + audio: string + audio_type?: string | null + event: 'tts_message' | 'tts_message_end' + task_id: string + workflow_run_id?: string | null + [key: string]: unknown +} + +export type WorkflowStreamMessage = { + answer: string + event: 'message' + from_variable_selector?: Array + id: string + task_id: string + workflow_run_id?: string | null + [key: string]: unknown +} + +export type WorkflowStreamMessageEnd = { + event: 'message_end' + files?: Array<{ + [key: string]: unknown + }> + id: string + metadata?: { + [key: string]: unknown + } + task_id: string + workflow_run_id?: string | null + [key: string]: unknown +} + +export type WorkflowStreamMessageFile = { + belongs_to: string + event: 'message_file' + id: string + task_id: string + type: string + url: string + workflow_run_id?: string | null + [key: string]: unknown +} + +export type WorkflowStreamMessageReplace = { + answer: string + event: 'message_replace' + reason: string + task_id: string + workflow_run_id?: string | null + [key: string]: unknown +} + +export type WorkflowStreamError = { + code: string + event: 'error' + message: string + status: number + workflow_run_id?: string | null + [key: string]: unknown +} + export type CanvasEdge = { source: string target: string @@ -839,12 +1127,6 @@ export type FormField = { unit?: string | null } -export type ConflictPolicyOption = { - id: string - label: string - recommended?: boolean -} - export type ResourceOption = { id: string kind: string @@ -868,6 +1150,329 @@ export type SummaryRow = { value: string } +export type CoreAppEntitiesTaskEntitiesWorkflowStartStreamResponseData = { + created_at: number + id: string + inputs: { + [key: string]: unknown + } + reason?: WorkflowStartReason + workflow_id: string +} + +export type CoreAppEntitiesTaskEntitiesWorkflowFinishStreamResponseData = { + created_at: number + created_by?: { + [key: string]: unknown + } + elapsed_time: number + error?: string | null + exceptions_count?: number + files?: Array<{ + [key: string]: unknown + }> | null + finished_at: number | null + id: string + outputs?: { + [key: string]: unknown + } | null + status: WorkflowExecutionStatus + total_steps: number + total_tokens: number + workflow_id: string +} + +export type CoreAppEntitiesTaskEntitiesWorkflowPauseStreamResponseData = { + created_at: number + elapsed_time: number + outputs?: { + [key: string]: unknown + } + paused_nodes?: Array + reasons?: Array<{ + [key: string]: unknown + }> + status: WorkflowExecutionStatus + total_steps: number + total_tokens: number + workflow_run_id: string +} + +export type CoreAppEntitiesTaskEntitiesNodeStartStreamResponseData = { + agent_strategy?: AgentStrategyInfo | null + created_at: number + extras?: { + [key: string]: unknown + } + id: string + index: number + inputs?: { + [key: string]: unknown + } | null + inputs_truncated?: boolean + iteration_id?: string | null + loop_id?: string | null + node_id: string + node_type: string + predecessor_node_id?: string | null + title: string +} + +export type CoreAppEntitiesTaskEntitiesNodeFinishStreamResponseData = { + created_at: number + elapsed_time: number + error?: string | null + execution_metadata?: + | { + [key in WorkflowNodeExecutionMetadataKey]?: unknown + } + | null + files?: Array<{ + [key: string]: unknown + }> | null + finished_at: number + id: string + index: number + inputs?: { + [key: string]: unknown + } | null + inputs_truncated?: boolean + iteration_id?: string | null + loop_id?: string | null + node_id: string + node_type: string + outputs?: { + [key: string]: unknown + } | null + outputs_truncated?: boolean + predecessor_node_id?: string | null + process_data?: { + [key: string]: unknown + } | null + process_data_truncated?: boolean + status: WorkflowNodeExecutionStatus + title: string +} + +export type CoreAppEntitiesTaskEntitiesNodeRetryStreamResponseData = { + created_at: number + elapsed_time: number + error?: string | null + execution_metadata?: + | { + [key in WorkflowNodeExecutionMetadataKey]?: unknown + } + | null + files?: Array<{ + [key: string]: unknown + }> | null + finished_at: number + id: string + index: number + inputs?: { + [key: string]: unknown + } | null + inputs_truncated?: boolean + iteration_id?: string | null + loop_id?: string | null + node_id: string + node_type: string + outputs?: { + [key: string]: unknown + } | null + outputs_truncated?: boolean + predecessor_node_id?: string | null + process_data?: { + [key: string]: unknown + } | null + process_data_truncated?: boolean + retry_index?: number + status: WorkflowNodeExecutionStatus + title: string +} + +export type CoreAppEntitiesTaskEntitiesIterationNodeStartStreamResponseData = { + created_at: number + extras?: { + [key: string]: unknown + } + id: string + inputs?: { + [key: string]: unknown + } + inputs_truncated?: boolean + metadata?: { + [key: string]: unknown + } + node_id: string + node_type: string + title: string +} + +export type CoreAppEntitiesTaskEntitiesIterationNodeNextStreamResponseData = { + created_at: number + extras?: { + [key: string]: unknown + } + id: string + index: number + node_id: string + node_type: string + title: string +} + +export type CoreAppEntitiesTaskEntitiesIterationNodeCompletedStreamResponseData = { + created_at: number + elapsed_time: number + error?: string | null + execution_metadata?: { + [key: string]: unknown + } + extras?: { + [key: string]: unknown + } | null + finished_at: number + id: string + inputs?: { + [key: string]: unknown + } | null + inputs_truncated?: boolean + node_id: string + node_type: string + outputs?: { + [key: string]: unknown + } | null + outputs_truncated?: boolean + status: WorkflowNodeExecutionStatus + steps: number + title: string + total_tokens: number +} + +export type CoreAppEntitiesTaskEntitiesLoopNodeStartStreamResponseData = { + created_at: number + extras?: { + [key: string]: unknown + } + id: string + inputs?: { + [key: string]: unknown + } + inputs_truncated?: boolean + metadata?: { + [key: string]: unknown + } + node_id: string + node_type: string + title: string +} + +export type CoreAppEntitiesTaskEntitiesLoopNodeNextStreamResponseData = { + created_at: number + extras?: { + [key: string]: unknown + } + id: string + index: number + node_id: string + node_type: string + pre_loop_output?: unknown + title: string +} + +export type CoreAppEntitiesTaskEntitiesLoopNodeCompletedStreamResponseData = { + created_at: number + elapsed_time: number + error?: string | null + execution_metadata?: { + [key: string]: unknown + } + extras?: { + [key: string]: unknown + } | null + finished_at: number + id: string + inputs?: { + [key: string]: unknown + } | null + inputs_truncated?: boolean + node_id: string + node_type: string + outputs?: { + [key: string]: unknown + } | null + outputs_truncated?: boolean + status: WorkflowNodeExecutionStatus + steps: number + title: string + total_tokens: number +} + +export type CoreAppEntitiesTaskEntitiesTextChunkStreamResponseData = { + from_variable_selector?: Array | null + text: string +} + +export type CoreAppEntitiesTaskEntitiesTextReplaceStreamResponseData = { + text: string +} + +export type CoreAppEntitiesTaskEntitiesReasoningChunkStreamResponseData = { + is_final?: boolean + message_id?: string | null + node_id?: string | null + reasoning: string +} + +export type CoreAppEntitiesTaskEntitiesAgentLogStreamResponseData = { + data: { + [key: string]: unknown + } + error?: string | null + id: string + label: string + metadata?: { + [key: string]: unknown + } + node_execution_id: string + node_id: string + parent_id?: string | null + status: string +} + +export type CoreAppEntitiesTaskEntitiesHumanInputRequiredResponseData = { + actions?: Array + approval_channels?: Array + display_in_ui?: boolean + expiration_time: number + form_content: string + form_id: string + form_token?: string | null + inputs?: Array + node_id: string + node_title: string + resolved_default_values?: { + [key: string]: unknown + } +} + +export type CoreAppEntitiesTaskEntitiesHumanInputFormFilledResponseData = { + action_id: string + action_text: string + node_id: string + node_title: string + rendered_content: string + submitted_data?: { + [key: string]: unknown + } | null +} + +export type CoreAppEntitiesTaskEntitiesHumanInputFormTimeoutResponseData = { + expiration_time: number + node_id: string + node_title: string +} + export type ExecutionActivity = { id: string kind?: 'node' | 'stage' @@ -876,6 +1481,124 @@ export type ExecutionActivity = { state: 'active' | 'done' | 'failed' | 'stopped' } +export type WorkflowStartReason = 'initial' | 'resumption' + +export type WorkflowExecutionStatus = + | 'failed' + | 'partial-succeeded' + | 'paused' + | 'running' + | 'scheduled' + | 'stopped' + | 'succeeded' + +export type AgentStrategyInfo = { + icon?: string | null + name: string +} + +export type WorkflowNodeExecutionMetadataKey = + | 'agent_log' + | 'completed_reason' + | 'currency' + | 'datasource_info' + | 'error_strategy' + | 'iteration_duration_map' + | 'iteration_id' + | 'iteration_index' + | 'loop_duration_map' + | 'loop_id' + | 'loop_index' + | 'loop_variable_map' + | 'parallel_id' + | 'parallel_mode_run_id' + | 'parallel_start_node_id' + | 'parent_parallel_id' + | 'parent_parallel_start_node_id' + | 'tool_info' + | 'total_price' + | 'total_tokens' + | 'trigger_info' + +export type WorkflowNodeExecutionStatus = + | 'exception' + | 'failed' + | 'paused' + | 'pending' + | 'retry' + | 'running' + | 'stopped' + | 'succeeded' + +export type UserActionConfig = { + button_style?: ButtonStyle + id: string + title: string +} + +export type FormInputConfig = + | ({ + type: 'paragraph' + } & ParagraphInputConfig) + | ({ + type: 'select' + } & SelectInputConfig) + | ({ + type: 'file' + } & FileInputConfig) + | ({ + type: 'file-list' + } & FileListInputConfig) + +export type ButtonStyle = 'accent' | 'default' | 'ghost' | 'primary' + +export type ParagraphInputConfig = { + default?: StringSource | null + output_variable_name: string + type?: 'paragraph' +} + +export type SelectInputConfig = { + option_source: StringListSource + output_variable_name: string + type?: 'select' +} + +export type FileInputConfig = { + allowed_file_extensions?: Array + allowed_file_types?: Array + allowed_file_upload_methods?: Array + output_variable_name: string + type?: 'file' +} + +export type FileListInputConfig = { + allowed_file_extensions?: Array + allowed_file_types?: Array + allowed_file_upload_methods?: Array + number_limits?: number + output_variable_name: string + type?: 'file-list' +} + +export type StringSource = { + selector?: Array + type: ValueSourceType + value?: string +} + +export type StringListSource = { + selector?: Array + type: ValueSourceType + value?: Array +} + +export type FileType = 'audio' | 'custom' | 'document' | 'image' | 'video' + +export type FileTransferMethod = 'datasource_file' | 'local_file' | 'remote_url' | 'tool_file' + +export type ValueSourceType = 'constant' | 'variable' + export type PostDifyBuilderAgentPingData = { body?: never path?: never diff --git a/packages/contracts/generated/api/console/dify-builder/zod.gen.ts b/packages/contracts/generated/api/console/dify-builder/zod.gen.ts index 0ed502fec76..ca55ff10283 100644 --- a/packages/contracts/generated/api/console/dify-builder/zod.gen.ts +++ b/packages/contracts/generated/api/console/dify-builder/zod.gen.ts @@ -188,30 +188,6 @@ export const zDifyBuilderCreateSessionPayload = z.union([ zDifyBuilderCreateChecklistFixSessionPayload, ]) -/** - * NodeEventData - */ -export const zNodeEventData = z.object({ - at_version: z.int(), - error: z.string(), - kind: z.literal('node').optional().default('node'), - node_id: z.string(), - operation_id: z.string(), - revision: z.int(), - session_id: z.string(), - stage_id: z.string(), - status: z.string(), - title: z.string(), -}) - -/** - * DifyBuilderNodeEventResponse - */ -export const zDifyBuilderNodeEventResponse = z.object({ - data: zNodeEventData, - event: z.literal('node'), -}) - /** * AgentMessageEventData */ @@ -487,6 +463,76 @@ export const zDifyBuilderBuildLearningConversationItemResponse = z.object({ seq: z.int(), }) +/** + * WorkflowStreamAudio + */ +export const zWorkflowStreamAudio = z.object({ + audio: z.string(), + audio_type: z.string().nullish(), + event: z.enum(['tts_message', 'tts_message_end']), + task_id: z.string(), + workflow_run_id: z.string().nullish(), +}) + +/** + * WorkflowStreamMessage + */ +export const zWorkflowStreamMessage = z.object({ + answer: z.string(), + event: z.literal('message'), + from_variable_selector: z.array(z.string()).optional(), + id: z.string(), + task_id: z.string(), + workflow_run_id: z.string().nullish(), +}) + +/** + * WorkflowStreamMessageEnd + */ +export const zWorkflowStreamMessageEnd = z.object({ + event: z.literal('message_end'), + files: z.array(z.record(z.string(), z.unknown())).optional(), + id: z.string(), + metadata: z.record(z.string(), z.unknown()).optional(), + task_id: z.string(), + workflow_run_id: z.string().nullish(), +}) + +/** + * WorkflowStreamMessageFile + */ +export const zWorkflowStreamMessageFile = z.object({ + belongs_to: z.string(), + event: z.literal('message_file'), + id: z.string(), + task_id: z.string(), + type: z.string(), + url: z.string(), + workflow_run_id: z.string().nullish(), +}) + +/** + * WorkflowStreamMessageReplace + */ +export const zWorkflowStreamMessageReplace = z.object({ + answer: z.string(), + event: z.literal('message_replace'), + reason: z.string(), + task_id: z.string(), + workflow_run_id: z.string().nullish(), +}) + +/** + * WorkflowStreamError + */ +export const zWorkflowStreamError = z.object({ + code: z.string(), + event: z.literal('error'), + message: z.string(), + status: z.int(), + workflow_run_id: z.string().nullish(), +}) + /** * CanvasEdge */ @@ -534,6 +580,7 @@ export const zCanvasEvent = z.enum([ */ export const zCanvasEventData = z.object({ at_version: z.int(), + dify_run_id: z.string().optional().default(''), edge: zCanvasEdge.nullish(), event: zCanvasEvent, kind: z.literal('canvas').optional().default('canvas'), @@ -655,15 +702,6 @@ export const zDifyBuilderFormConversationItemResponse = z.object({ seq: z.int(), }) -/** - * ConflictPolicyOption - */ -export const zConflictPolicyOption = z.object({ - id: z.string(), - label: z.string(), - recommended: z.boolean().optional().default(false), -}) - /** * ResourceOption */ @@ -679,7 +717,6 @@ export const zResourceOption = z.object({ * ResourceSelectCard */ export const zResourceSelectCard = z.object({ - conflict_policy_options: z.array(zConflictPolicyOption).optional(), recommended: z.array(zResourceOption).optional(), }) @@ -733,6 +770,8 @@ export const zTestStat = z.object({ * TestResultCard */ export const zTestResultCard = z.object({ + dify_run_id: z.string().optional().default(''), + output: z.string().optional().default(''), run_ids: z.array(z.string()).optional(), stats: z.array(zTestStat).optional(), subtitle: z.string(), @@ -778,6 +817,244 @@ export const zDifyBuilderSummaryConversationItemResponse = z.object({ seq: z.int(), }) +/** + * Data + * + * Data entity + */ +export const zCoreAppEntitiesTaskEntitiesIterationNodeStartStreamResponseData = z.object({ + created_at: z.int(), + extras: z.record(z.string(), z.unknown()).optional(), + id: z.string(), + inputs: z.record(z.string(), z.unknown()).optional().default({}), + inputs_truncated: z.boolean().optional().default(false), + metadata: z.record(z.string(), z.unknown()).optional().default({}), + node_id: z.string(), + node_type: z.string(), + title: z.string(), +}) + +/** + * WorkflowStreamEvent[Literal['iteration_started'], IterationNodeStartStreamResponse.Data] + */ +export const zWorkflowStreamEventLiteralIterationStartedData = z.object({ + data: zCoreAppEntitiesTaskEntitiesIterationNodeStartStreamResponseData, + event: z.literal('iteration_started'), + task_id: z.string(), + workflow_run_id: z.string(), +}) + +/** + * Data + * + * Data entity + */ +export const zCoreAppEntitiesTaskEntitiesIterationNodeNextStreamResponseData = z.object({ + created_at: z.int(), + extras: z.record(z.string(), z.unknown()).optional(), + id: z.string(), + index: z.int(), + node_id: z.string(), + node_type: z.string(), + title: z.string(), +}) + +/** + * WorkflowStreamEvent[Literal['iteration_next'], IterationNodeNextStreamResponse.Data] + */ +export const zWorkflowStreamEventLiteralIterationNextData = z.object({ + data: zCoreAppEntitiesTaskEntitiesIterationNodeNextStreamResponseData, + event: z.literal('iteration_next'), + task_id: z.string(), + workflow_run_id: z.string(), +}) + +/** + * Data + * + * Data entity + */ +export const zCoreAppEntitiesTaskEntitiesLoopNodeStartStreamResponseData = z.object({ + created_at: z.int(), + extras: z.record(z.string(), z.unknown()).optional(), + id: z.string(), + inputs: z.record(z.string(), z.unknown()).optional().default({}), + inputs_truncated: z.boolean().optional().default(false), + metadata: z.record(z.string(), z.unknown()).optional().default({}), + node_id: z.string(), + node_type: z.string(), + title: z.string(), +}) + +/** + * WorkflowStreamEvent[Literal['loop_started'], LoopNodeStartStreamResponse.Data] + */ +export const zWorkflowStreamEventLiteralLoopStartedData = z.object({ + data: zCoreAppEntitiesTaskEntitiesLoopNodeStartStreamResponseData, + event: z.literal('loop_started'), + task_id: z.string(), + workflow_run_id: z.string(), +}) + +/** + * Data + * + * Data entity + */ +export const zCoreAppEntitiesTaskEntitiesLoopNodeNextStreamResponseData = z.object({ + created_at: z.int(), + extras: z.record(z.string(), z.unknown()).optional(), + id: z.string(), + index: z.int(), + node_id: z.string(), + node_type: z.string(), + pre_loop_output: z.unknown().optional(), + title: z.string(), +}) + +/** + * WorkflowStreamEvent[Literal['loop_next'], LoopNodeNextStreamResponse.Data] + */ +export const zWorkflowStreamEventLiteralLoopNextData = z.object({ + data: zCoreAppEntitiesTaskEntitiesLoopNodeNextStreamResponseData, + event: z.literal('loop_next'), + task_id: z.string(), + workflow_run_id: z.string(), +}) + +/** + * Data + * + * Data entity + */ +export const zCoreAppEntitiesTaskEntitiesTextChunkStreamResponseData = z.object({ + from_variable_selector: z.array(z.string()).nullish(), + text: z.string(), +}) + +/** + * WorkflowStreamDataEvent[Literal['text_chunk'], TextChunkStreamResponse.Data] + */ +export const zWorkflowStreamDataEventLiteralTextChunkData = z.object({ + data: zCoreAppEntitiesTaskEntitiesTextChunkStreamResponseData, + event: z.literal('text_chunk'), + task_id: z.string(), + workflow_run_id: z.string().nullish(), +}) + +/** + * Data + * + * Data entity + */ +export const zCoreAppEntitiesTaskEntitiesTextReplaceStreamResponseData = z.object({ + text: z.string(), +}) + +/** + * WorkflowStreamDataEvent[Literal['text_replace'], TextReplaceStreamResponse.Data] + */ +export const zWorkflowStreamDataEventLiteralTextReplaceData = z.object({ + data: zCoreAppEntitiesTaskEntitiesTextReplaceStreamResponseData, + event: z.literal('text_replace'), + task_id: z.string(), + workflow_run_id: z.string().nullish(), +}) + +/** + * Data + * + * Data entity + */ +export const zCoreAppEntitiesTaskEntitiesReasoningChunkStreamResponseData = z.object({ + is_final: z.boolean().optional().default(false), + message_id: z.string().nullish(), + node_id: z.string().nullish(), + reasoning: z.string(), +}) + +/** + * WorkflowStreamDataEvent[Literal['reasoning_chunk'], ReasoningChunkStreamResponse.Data] + */ +export const zWorkflowStreamDataEventLiteralReasoningChunkData = z.object({ + data: zCoreAppEntitiesTaskEntitiesReasoningChunkStreamResponseData, + event: z.literal('reasoning_chunk'), + task_id: z.string(), + workflow_run_id: z.string().nullish(), +}) + +/** + * Data + * + * Data entity + */ +export const zCoreAppEntitiesTaskEntitiesAgentLogStreamResponseData = z.object({ + data: z.record(z.string(), z.unknown()), + error: z.string().nullish(), + id: z.string(), + label: z.string(), + metadata: z.record(z.string(), z.unknown()).optional(), + node_execution_id: z.string(), + node_id: z.string(), + parent_id: z.string().nullish(), + status: z.string(), +}) + +/** + * WorkflowStreamDataEvent[Literal['agent_log'], AgentLogStreamResponse.Data] + */ +export const zWorkflowStreamDataEventLiteralAgentLogData = z.object({ + data: zCoreAppEntitiesTaskEntitiesAgentLogStreamResponseData, + event: z.literal('agent_log'), + task_id: z.string(), + workflow_run_id: z.string().nullish(), +}) + +/** + * Data + * + * Data entity + */ +export const zCoreAppEntitiesTaskEntitiesHumanInputFormFilledResponseData = z.object({ + action_id: z.string(), + action_text: z.string(), + node_id: z.string(), + node_title: z.string(), + rendered_content: z.string(), + submitted_data: z.record(z.string(), z.unknown()).nullish(), +}) + +/** + * WorkflowStreamEvent[Literal['human_input_form_filled'], HumanInputFormFilledResponse.Data] + */ +export const zWorkflowStreamEventLiteralHumanInputFormFilledData = z.object({ + data: zCoreAppEntitiesTaskEntitiesHumanInputFormFilledResponseData, + event: z.literal('human_input_form_filled'), + task_id: z.string(), + workflow_run_id: z.string(), +}) + +/** + * Data + * + * Data entity + */ +export const zCoreAppEntitiesTaskEntitiesHumanInputFormTimeoutResponseData = z.object({ + expiration_time: z.int(), + node_id: z.string(), + node_title: z.string(), +}) + +/** + * WorkflowStreamEvent[Literal['human_input_form_timeout'], HumanInputFormTimeoutResponse.Data] + */ +export const zWorkflowStreamEventLiteralHumanInputFormTimeoutData = z.object({ + data: zCoreAppEntitiesTaskEntitiesHumanInputFormTimeoutResponseData, + event: z.literal('human_input_form_timeout'), + task_id: z.string(), + workflow_run_id: z.string(), +}) + /** * ExecutionActivity */ @@ -1168,6 +1445,631 @@ export const zDifyBuilderStateEventResponse = z.object({ event: z.literal('state'), }) +/** + * WorkflowStartReason + * + * Reason for workflow start events across graph/queue/SSE layers. + */ +export const zWorkflowStartReason = z.enum(['initial', 'resumption']) + +/** + * Data + * + * Data entity + */ +export const zCoreAppEntitiesTaskEntitiesWorkflowStartStreamResponseData = z.object({ + created_at: z.int(), + id: z.string(), + inputs: z.record(z.string(), z.unknown()), + reason: zWorkflowStartReason.optional().default('initial'), + workflow_id: z.string(), +}) + +/** + * WorkflowStreamEvent[Literal['workflow_started'], WorkflowStartStreamResponse.Data] + */ +export const zWorkflowStreamEventLiteralWorkflowStartedData = z.object({ + data: zCoreAppEntitiesTaskEntitiesWorkflowStartStreamResponseData, + event: z.literal('workflow_started'), + task_id: z.string(), + workflow_run_id: z.string(), +}) + +/** + * WorkflowExecutionStatus + */ +export const zWorkflowExecutionStatus = z.enum([ + 'failed', + 'partial-succeeded', + 'paused', + 'running', + 'scheduled', + 'stopped', + 'succeeded', +]) + +/** + * Data + * + * Data entity + */ +export const zCoreAppEntitiesTaskEntitiesWorkflowFinishStreamResponseData = z.object({ + created_at: z.int(), + created_by: z.record(z.string(), z.unknown()).optional(), + elapsed_time: z.number(), + error: z.string().nullish(), + exceptions_count: z.int().optional().default(0), + files: z.array(z.record(z.string(), z.unknown())).nullish().default([]), + finished_at: z.int().nullable(), + id: z.string(), + outputs: z.record(z.string(), z.unknown()).nullish(), + status: zWorkflowExecutionStatus, + total_steps: z.int(), + total_tokens: z.int(), + workflow_id: z.string(), +}) + +/** + * WorkflowStreamEvent[Literal['workflow_finished'], WorkflowFinishStreamResponse.Data] + */ +export const zWorkflowStreamEventLiteralWorkflowFinishedData = z.object({ + data: zCoreAppEntitiesTaskEntitiesWorkflowFinishStreamResponseData, + event: z.literal('workflow_finished'), + task_id: z.string(), + workflow_run_id: z.string(), +}) + +/** + * Data + * + * Data entity + */ +export const zCoreAppEntitiesTaskEntitiesWorkflowPauseStreamResponseData = z.object({ + created_at: z.int(), + elapsed_time: z.number(), + outputs: z.record(z.string(), z.unknown()).optional(), + paused_nodes: z.array(z.string()).optional(), + reasons: z.array(z.record(z.string(), z.unknown())).optional(), + status: zWorkflowExecutionStatus, + total_steps: z.int(), + total_tokens: z.int(), + workflow_run_id: z.string(), +}) + +/** + * WorkflowStreamEvent[Literal['workflow_paused'], WorkflowPauseStreamResponse.Data] + */ +export const zWorkflowStreamEventLiteralWorkflowPausedData = z.object({ + data: zCoreAppEntitiesTaskEntitiesWorkflowPauseStreamResponseData, + event: z.literal('workflow_paused'), + task_id: z.string(), + workflow_run_id: z.string(), +}) + +/** + * AgentStrategyInfo + */ +export const zAgentStrategyInfo = z.object({ + icon: z.string().nullish(), + name: z.string(), +}) + +/** + * Data + * + * Data entity + */ +export const zCoreAppEntitiesTaskEntitiesNodeStartStreamResponseData = z.object({ + agent_strategy: zAgentStrategyInfo.nullish(), + created_at: z.int(), + extras: z.record(z.string(), z.unknown()).optional(), + id: z.string(), + index: z.int(), + inputs: z.record(z.string(), z.unknown()).nullish(), + inputs_truncated: z.boolean().optional().default(false), + iteration_id: z.string().nullish(), + loop_id: z.string().nullish(), + node_id: z.string(), + node_type: z.string(), + predecessor_node_id: z.string().nullish(), + title: z.string(), +}) + +/** + * WorkflowStreamEvent[Literal['node_started'], NodeStartStreamResponse.Data] + */ +export const zWorkflowStreamEventLiteralNodeStartedData = z.object({ + data: zCoreAppEntitiesTaskEntitiesNodeStartStreamResponseData, + event: z.literal('node_started'), + task_id: z.string(), + workflow_run_id: z.string(), +}) + +/** + * WorkflowNodeExecutionMetadataKey + * + * Node Run Metadata Key. + * + * Values in this enum are persisted as execution metadata and must stay in sync + * with every node that writes `NodeRunResult.metadata`. + */ +export const zWorkflowNodeExecutionMetadataKey = z.enum([ + 'agent_log', + 'completed_reason', + 'currency', + 'datasource_info', + 'error_strategy', + 'iteration_duration_map', + 'iteration_id', + 'iteration_index', + 'loop_duration_map', + 'loop_id', + 'loop_index', + 'loop_variable_map', + 'parallel_id', + 'parallel_mode_run_id', + 'parallel_start_node_id', + 'parent_parallel_id', + 'parent_parallel_start_node_id', + 'tool_info', + 'total_price', + 'total_tokens', + 'trigger_info', +]) + +/** + * WorkflowNodeExecutionStatus + */ +export const zWorkflowNodeExecutionStatus = z.enum([ + 'exception', + 'failed', + 'paused', + 'pending', + 'retry', + 'running', + 'stopped', + 'succeeded', +]) + +/** + * Data + * + * Data entity + */ +export const zCoreAppEntitiesTaskEntitiesNodeFinishStreamResponseData = z.object({ + created_at: z.int(), + elapsed_time: z.number(), + error: z.string().nullish(), + execution_metadata: z.record(z.string(), z.unknown()).nullish(), + files: z.array(z.record(z.string(), z.unknown())).nullish().default([]), + finished_at: z.int(), + id: z.string(), + index: z.int(), + inputs: z.record(z.string(), z.unknown()).nullish(), + inputs_truncated: z.boolean().optional().default(false), + iteration_id: z.string().nullish(), + loop_id: z.string().nullish(), + node_id: z.string(), + node_type: z.string(), + outputs: z.record(z.string(), z.unknown()).nullish(), + outputs_truncated: z.boolean().optional().default(true), + predecessor_node_id: z.string().nullish(), + process_data: z.record(z.string(), z.unknown()).nullish(), + process_data_truncated: z.boolean().optional().default(false), + status: zWorkflowNodeExecutionStatus, + title: z.string(), +}) + +/** + * WorkflowStreamEvent[Literal['node_finished'], NodeFinishStreamResponse.Data] + */ +export const zWorkflowStreamEventLiteralNodeFinishedData = z.object({ + data: zCoreAppEntitiesTaskEntitiesNodeFinishStreamResponseData, + event: z.literal('node_finished'), + task_id: z.string(), + workflow_run_id: z.string(), +}) + +/** + * Data + * + * Data entity + */ +export const zCoreAppEntitiesTaskEntitiesNodeRetryStreamResponseData = z.object({ + created_at: z.int(), + elapsed_time: z.number(), + error: z.string().nullish(), + execution_metadata: z.record(z.string(), z.unknown()).nullish(), + files: z.array(z.record(z.string(), z.unknown())).nullish().default([]), + finished_at: z.int(), + id: z.string(), + index: z.int(), + inputs: z.record(z.string(), z.unknown()).nullish(), + inputs_truncated: z.boolean().optional().default(false), + iteration_id: z.string().nullish(), + loop_id: z.string().nullish(), + node_id: z.string(), + node_type: z.string(), + outputs: z.record(z.string(), z.unknown()).nullish(), + outputs_truncated: z.boolean().optional().default(false), + predecessor_node_id: z.string().nullish(), + process_data: z.record(z.string(), z.unknown()).nullish(), + process_data_truncated: z.boolean().optional().default(false), + retry_index: z.int().optional().default(0), + status: zWorkflowNodeExecutionStatus, + title: z.string(), +}) + +/** + * WorkflowStreamEvent[Literal['node_retry'], NodeRetryStreamResponse.Data] + */ +export const zWorkflowStreamEventLiteralNodeRetryData = z.object({ + data: zCoreAppEntitiesTaskEntitiesNodeRetryStreamResponseData, + event: z.literal('node_retry'), + task_id: z.string(), + workflow_run_id: z.string(), +}) + +/** + * Data + * + * Data entity + */ +export const zCoreAppEntitiesTaskEntitiesIterationNodeCompletedStreamResponseData = z.object({ + created_at: z.int(), + elapsed_time: z.number(), + error: z.string().nullish(), + execution_metadata: z.record(z.string(), z.unknown()).optional(), + extras: z.record(z.string(), z.unknown()).nullish(), + finished_at: z.int(), + id: z.string(), + inputs: z.record(z.string(), z.unknown()).nullish(), + inputs_truncated: z.boolean().optional().default(false), + node_id: z.string(), + node_type: z.string(), + outputs: z.record(z.string(), z.unknown()).nullish(), + outputs_truncated: z.boolean().optional().default(false), + status: zWorkflowNodeExecutionStatus, + steps: z.int(), + title: z.string(), + total_tokens: z.int(), +}) + +/** + * WorkflowStreamEvent[Literal['iteration_completed'], IterationNodeCompletedStreamResponse.Data] + */ +export const zWorkflowStreamEventLiteralIterationCompletedData = z.object({ + data: zCoreAppEntitiesTaskEntitiesIterationNodeCompletedStreamResponseData, + event: z.literal('iteration_completed'), + task_id: z.string(), + workflow_run_id: z.string(), +}) + +/** + * Data + * + * Data entity + */ +export const zCoreAppEntitiesTaskEntitiesLoopNodeCompletedStreamResponseData = z.object({ + created_at: z.int(), + elapsed_time: z.number(), + error: z.string().nullish(), + execution_metadata: z.record(z.string(), z.unknown()).optional(), + extras: z.record(z.string(), z.unknown()).nullish(), + finished_at: z.int(), + id: z.string(), + inputs: z.record(z.string(), z.unknown()).nullish(), + inputs_truncated: z.boolean().optional().default(false), + node_id: z.string(), + node_type: z.string(), + outputs: z.record(z.string(), z.unknown()).nullish(), + outputs_truncated: z.boolean().optional().default(false), + status: zWorkflowNodeExecutionStatus, + steps: z.int(), + title: z.string(), + total_tokens: z.int(), +}) + +/** + * WorkflowStreamEvent[Literal['loop_completed'], LoopNodeCompletedStreamResponse.Data] + */ +export const zWorkflowStreamEventLiteralLoopCompletedData = z.object({ + data: zCoreAppEntitiesTaskEntitiesLoopNodeCompletedStreamResponseData, + event: z.literal('loop_completed'), + task_id: z.string(), + workflow_run_id: z.string(), +}) + +/** + * ButtonStyle + * + * Button styles for user actions. + */ +export const zButtonStyle = z.enum(['accent', 'default', 'ghost', 'primary']) + +/** + * UserActionConfig + * + * User action configuration. + */ +export const zUserActionConfig = z.object({ + button_style: zButtonStyle.optional().default('default'), + id: z + .string() + .max(20) + .regex(/^[A-Za-z_][A-Za-z0-9_]*$/), + title: z.string().max(100), +}) + +/** + * FileType + */ +export const zFileType = z.enum(['audio', 'custom', 'document', 'image', 'video']) + +/** + * FileTransferMethod + */ +export const zFileTransferMethod = z.enum([ + 'datasource_file', + 'local_file', + 'remote_url', + 'tool_file', +]) + +/** + * FileInputConfig + */ +export const zFileInputConfig = z.object({ + allowed_file_extensions: z.array(z.string()).optional(), + allowed_file_types: z.array(zFileType).optional(), + allowed_file_upload_methods: z.array(zFileTransferMethod).optional(), + output_variable_name: z.string(), + type: z.literal('file').optional().default('file'), +}) + +/** + * FileListInputConfig + */ +export const zFileListInputConfig = z.object({ + allowed_file_extensions: z.array(z.string()).optional(), + allowed_file_types: z.array(zFileType).optional(), + allowed_file_upload_methods: z.array(zFileTransferMethod).optional(), + number_limits: z.int().gte(0).optional().default(0), + output_variable_name: z.string(), + type: z.literal('file-list').optional().default('file-list'), +}) + +/** + * ValueSourceType + * + * ValueSourceType records whether the value comes from a static setting + * in form definition, or a variable while the workflow is running. + */ +export const zValueSourceType = z.enum(['constant', 'variable']) + +/** + * StringSource + * + * Default configuration for form inputs. + */ +export const zStringSource = z.object({ + selector: z.array(z.string()).optional(), + type: zValueSourceType, + value: z.string().optional().default(''), +}) + +/** + * ParagraphInputConfig + * + * Form input definition. + */ +export const zParagraphInputConfig = z.object({ + default: zStringSource.nullish(), + output_variable_name: z.string(), + type: z.literal('paragraph').optional().default('paragraph'), +}) + +/** + * StringListSource + */ +export const zStringListSource = z.object({ + selector: z.array(z.string()).optional(), + type: zValueSourceType, + value: z.array(z.string()).optional(), +}) + +/** + * SelectInputConfig + */ +export const zSelectInputConfig = z.object({ + option_source: zStringListSource, + output_variable_name: z.string(), + type: z.literal('select').optional().default('select'), +}) + +export const zFormInputConfig = z.discriminatedUnion('type', [ + zParagraphInputConfig.extend({ type: z.literal('paragraph') }), + zSelectInputConfig.extend({ type: z.literal('select') }), + zFileInputConfig.extend({ type: z.literal('file') }), + zFileListInputConfig.extend({ type: z.literal('file-list') }), +]) + +/** + * Data + * + * Data entity + */ +export const zCoreAppEntitiesTaskEntitiesHumanInputRequiredResponseData = z.object({ + actions: z.array(zUserActionConfig).optional(), + approval_channels: z.array(z.string()).optional(), + display_in_ui: z.boolean().optional().default(false), + expiration_time: z.int(), + form_content: z.string(), + form_id: z.string(), + form_token: z.string().nullish(), + inputs: z.array(zFormInputConfig).optional(), + node_id: z.string(), + node_title: z.string(), + resolved_default_values: z.record(z.string(), z.unknown()).optional(), +}) + +/** + * WorkflowStreamEvent[Literal['human_input_required'], HumanInputRequiredResponse.Data] + */ +export const zWorkflowStreamEventLiteralHumanInputRequiredData = z.object({ + data: zCoreAppEntitiesTaskEntitiesHumanInputRequiredResponseData, + event: z.literal('human_input_required'), + task_id: z.string(), + workflow_run_id: z.string(), +}) + +/** + * DifyBuilderWorkflowEventData + */ +export const zDifyBuilderWorkflowEventData = z.object({ + at_version: z.int(), + kind: z.literal('workflow').optional().default('workflow'), + operation_id: z.string(), + payload: z.union([ + z + .object({ + event: z.literal('workflow_started'), + }) + .and(zWorkflowStreamEventLiteralWorkflowStartedData), + z + .object({ + event: z.literal('workflow_finished'), + }) + .and(zWorkflowStreamEventLiteralWorkflowFinishedData), + z + .object({ + event: z.literal('workflow_paused'), + }) + .and(zWorkflowStreamEventLiteralWorkflowPausedData), + z + .object({ + event: z.literal('node_started'), + }) + .and(zWorkflowStreamEventLiteralNodeStartedData), + z + .object({ + event: z.literal('node_finished'), + }) + .and(zWorkflowStreamEventLiteralNodeFinishedData), + z + .object({ + event: z.literal('node_retry'), + }) + .and(zWorkflowStreamEventLiteralNodeRetryData), + z + .object({ + event: z.literal('iteration_started'), + }) + .and(zWorkflowStreamEventLiteralIterationStartedData), + z + .object({ + event: z.literal('iteration_next'), + }) + .and(zWorkflowStreamEventLiteralIterationNextData), + z + .object({ + event: z.literal('iteration_completed'), + }) + .and(zWorkflowStreamEventLiteralIterationCompletedData), + z + .object({ + event: z.literal('loop_started'), + }) + .and(zWorkflowStreamEventLiteralLoopStartedData), + z + .object({ + event: z.literal('loop_next'), + }) + .and(zWorkflowStreamEventLiteralLoopNextData), + z + .object({ + event: z.literal('loop_completed'), + }) + .and(zWorkflowStreamEventLiteralLoopCompletedData), + z + .object({ + event: z.literal('text_chunk'), + }) + .and(zWorkflowStreamDataEventLiteralTextChunkData), + z + .object({ + event: z.literal('text_replace'), + }) + .and(zWorkflowStreamDataEventLiteralTextReplaceData), + z + .object({ + event: z.literal('reasoning_chunk'), + }) + .and(zWorkflowStreamDataEventLiteralReasoningChunkData), + z + .object({ + event: z.literal('agent_log'), + }) + .and(zWorkflowStreamDataEventLiteralAgentLogData), + z + .object({ + event: z.literal('human_input_required'), + }) + .and(zWorkflowStreamEventLiteralHumanInputRequiredData), + z + .object({ + event: z.literal('human_input_form_filled'), + }) + .and(zWorkflowStreamEventLiteralHumanInputFormFilledData), + z + .object({ + event: z.literal('human_input_form_timeout'), + }) + .and(zWorkflowStreamEventLiteralHumanInputFormTimeoutData), + z + .object({ + event: z.union([z.literal('tts_message'), z.literal('tts_message_end')]), + }) + .and(zWorkflowStreamAudio), + z + .object({ + event: z.literal('message'), + }) + .and(zWorkflowStreamMessage), + z + .object({ + event: z.literal('message_end'), + }) + .and(zWorkflowStreamMessageEnd), + z + .object({ + event: z.literal('message_file'), + }) + .and(zWorkflowStreamMessageFile), + z + .object({ + event: z.literal('message_replace'), + }) + .and(zWorkflowStreamMessageReplace), + z + .object({ + event: z.literal('error'), + }) + .and(zWorkflowStreamError), + ]), + revision: z.int(), + session_id: z.string(), + stage_id: z.string(), +}) + +/** + * DifyBuilderWorkflowEventResponse + */ +export const zDifyBuilderWorkflowEventResponse = z.object({ + data: zDifyBuilderWorkflowEventData, + event: z.literal('workflow'), +}) + /** * DifyBuilderStreamEventResponse * @@ -1175,7 +2077,7 @@ export const zDifyBuilderStateEventResponse = z.object({ */ export const zDifyBuilderStreamEventResponse = z.discriminatedUnion('event', [ zDifyBuilderCommandStartedEventResponse.extend({ event: z.literal('command_started') }), - zDifyBuilderNodeEventResponse.extend({ event: z.literal('node') }), + zDifyBuilderWorkflowEventResponse.extend({ event: z.literal('workflow') }), zDifyBuilderCanvasEventResponse.extend({ event: z.literal('canvas') }), zDifyBuilderAgentMessageEventResponse.extend({ event: z.literal('agent_message') }), zDifyBuilderReasoningEventResponse.extend({ event: z.literal('reasoning') }), diff --git a/web/__tests__/app-builder-creation.spec.tsx b/web/__tests__/app-builder-creation.spec.tsx index 35a36c64f4e..11824a01b21 100644 --- a/web/__tests__/app-builder-creation.spec.tsx +++ b/web/__tests__/app-builder-creation.spec.tsx @@ -1,3 +1,4 @@ +import type { useDifyBuilderSessionController } from '@/app/components/workflow-app/components/dify-builder/session/use-session-controller' import { act, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { useEffect, useState } from 'react' @@ -28,8 +29,18 @@ const mocks = vi.hoisted(() => ({ setCanvasReadOnly: vi.fn(), syncDraft: vi.fn(async () => undefined), startBuild: vi.fn(async () => true), + useSessionController: vi.fn(), fetchDefaultModel: vi.fn(() => new Promise(() => {})), updateUrl: vi.fn(), + runEvents: { + onWorkflowEvent: vi.fn(), + onStreamInterrupted: vi.fn(), + onCanvasEvent: vi.fn(), + restoreRun: vi.fn(), + finishCommand: vi.fn(), + reset: vi.fn(), + onCanvasRefreshed: vi.fn(), + }, })) vi.mock('@/next/navigation', () => ({ @@ -53,6 +64,7 @@ vi.mock('@/service/console', async (importOriginal) => { systemFeatures: actual.consoleQuery.systemFeatures, workspaces: actual.consoleQuery.workspaces, trialModels: actual.consoleQuery.trialModels, + workflowGenerate: actual.consoleQuery.workflowGenerate, apps: { ...actual.consoleQuery.apps, post: { mutationOptions: () => ({ mutationFn: mocks.createApp }) }, @@ -72,10 +84,21 @@ vi.mock('@/app/components/workflow/store', () => ({ setCanvasReadOnly: mocks.setCanvasReadOnly, }), })) +vi.mock('@/app/components/workflow-app/components/dify-builder/provider/use-run-events', () => ({ + useDifyBuilderRunEvents: () => mocks.runEvents, +})) vi.mock( '@/app/components/workflow-app/components/dify-builder/session/use-session-controller', () => ({ - useDifyBuilderSessionController: () => ({ startBuild: mocks.startBuild }), + useDifyBuilderSessionController: ( + ...args: Parameters + ) => { + mocks.useSessionController(...args) + return { + startBuild: mocks.startBuild, + onCanvasRefreshed: mocks.runEvents.onCanvasRefreshed, + } + }, }), ) @@ -156,7 +179,11 @@ describe('App Builder creation flow', () => { ) expect(mocks.push).toHaveBeenCalledWith('/app/created-app/workflow') expect(mocks.setShowPanel).toHaveBeenCalledWith(true) - expect(mocks.syncDraft).toHaveBeenCalledOnce() + expect(mocks.useSessionController).toHaveBeenCalledWith(mocks.syncDraft, { + ...mocks.runEvents, + onCanvasEvent: expect.any(Function), + reset: expect.any(Function), + }) expect(mocks.createApp).toHaveBeenCalledOnce() expect(mocks.createApp).toHaveBeenCalledWith( { body: expect.objectContaining({ name: 'app.newApp.defaultName', description: '' }) }, diff --git a/web/app/components/workflow-app/components/__tests__/workflow-panel.spec.tsx b/web/app/components/workflow-app/components/__tests__/workflow-panel.spec.tsx index 0c2ed212b62..739ec51abe3 100644 --- a/web/app/components/workflow-app/components/__tests__/workflow-panel.spec.tsx +++ b/web/app/components/workflow-app/components/__tests__/workflow-panel.spec.tsx @@ -169,6 +169,7 @@ const createDifyBuilderRuntime = (enabled: boolean, canEdit: boolean): DifyBuild enabled, getCanvasSnapshot: () => ({ nodes: [], edgeCount: 0 }), session: { + onCanvasRefreshed: vi.fn(), getTrace: vi.fn(() => ({ entries: [], truncated: false })), loadOlderConversation: vi.fn(async () => true), refresh: vi.fn(async () => true), diff --git a/web/app/components/workflow-app/components/dify-builder/README.md b/web/app/components/workflow-app/components/dify-builder/README.md index 1b211c075a3..62d74925642 100644 --- a/web/app/components/workflow-app/components/dify-builder/README.md +++ b/web/app/components/workflow-app/components/dify-builder/README.md @@ -25,8 +25,37 @@ and terminal state frames replace session metadata. A sequence gap is repaired through the conversation GET. `agent_message` deltas stay outside durable history until the server commits them. `progress` frames carry replaceable, curated trace snapshots; `operation_id` and `revision` reject stale updates, -while `at_version` lets the matching commit clear transient progress. Node -events are folded into the active trace without exposing node errors. +while `at_version` lets the matching commit clear transient progress. + +`workflow` envelopes carry the complete native debugger payload alongside +Builder's session, operation, stage, version, and revision metadata. Accepted +payloads dispatch directly to the editor's shared `useWorkflowRunEvent` +callbacks, including node, retry, iteration, loop, text, agent log, human-input, +pause, and terminal events. An inner workflow `error` or `workflow_finished` +does not terminate the outer Builder command. Chatflow message/text events +without a run ID use the run started in the same command, with native message +chunks and replacements handled by the shared text callbacks. Complete live runs need no +subsequent detail or node-execution queries. Those queries use persisted test +cards' `dify_run_id` only when reopening history or recovering missing events; +Builder-internal `run_ids` are never used. Native execution status remains +authoritative over Builder validation cards. An interrupted run clears live +animation without inventing a failed result. Canvas refresh completion reapplies +saved runtime fields directly, without rebuilding the live run from node rows. + +Builder executes the native workflow generator inside its existing worker task +while retaining streaming callbacks. It does not enqueue a child workflow task +and wait for another Celery pool slot: a worker consuming both queues at +concurrency 1 would otherwise deadlock until the stream times out. + +Build/Edit repair approval commits the changed graph and stops at +`build.execution` / `edit.apply_changes`. The initiating client waits for that +session version's canvas refresh to succeed, then submits one `run_test` / +`run_affected_tests` action with the matching session and app revisions. The +pending retest is cleared on reset, restore, another command, or a version +conflict; reopening historical waiting states never starts a test. A failed +refresh leaves the test pending until a successful retry. This separates graph +application from execution, so even newly created nodes exist before their +first workflow callback runs. Only `SessionView.active_interaction` is editable. Historical form and resource cards are restored from conversation pages as read-only content; the active diff --git a/web/app/components/workflow-app/components/dify-builder/__tests__/canvas-sync.spec.tsx b/web/app/components/workflow-app/components/dify-builder/__tests__/canvas-sync.spec.tsx new file mode 100644 index 00000000000..e3454221ae6 --- /dev/null +++ b/web/app/components/workflow-app/components/dify-builder/__tests__/canvas-sync.spec.tsx @@ -0,0 +1,189 @@ +import type { CanvasEventData } from '../types' +import { act, render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { createStore, Provider, useSetAtom } from 'jotai' +import { WorkflowContext } from '@/app/components/workflow/context' +import { createWorkflowStore } from '@/app/components/workflow/store' +import { DifyBuilderCanvasRefreshSync } from '../provider/canvas-sync' +import { useDifyBuilderCanvasEvents } from '../provider/use-canvas-events' +import { createSessionView } from '../session/__tests__/fixtures' +import { difyBuilderSessionBusyAtom, difyBuilderSessionViewAtom } from '../session/state' +import { + difyBuilderCanvasPendingRefreshAtom, + difyBuilderCanvasReadyAtom, + difyBuilderCanvasRefreshFailedAtom, + difyBuilderRetryCanvasRefreshAtom, +} from '../store' + +const canvasEvent = (event: CanvasEventData['event'], nodeId?: string): CanvasEventData => ({ + kind: 'canvas', + session_id: 'session-1', + operation_id: 'operation-1', + stage_id: 'edit.apply_changes', + at_version: 2, + revision: 1, + event, + node_id: nodeId, +}) + +const Harness = ({ + events, + refresh, + refreshed, +}: { + events: CanvasEventData[] + refresh: (shouldApply: () => boolean) => Promise + refreshed: () => void +}) => { + const canvas = useDifyBuilderCanvasEvents(() => undefined) + const setBusy = useSetAtom(difyBuilderSessionBusyAtom) + const setView = useSetAtom(difyBuilderSessionViewAtom) + const retry = useSetAtom(difyBuilderRetryCanvasRefreshAtom) + return ( + <> + + + + + + + ) +} + +const setup = ( + events: CanvasEventData[], + refresh = vi.fn<(shouldApply: () => boolean) => Promise>().mockResolvedValue(true), +) => { + const store = createStore() + store.set( + difyBuilderSessionViewAtom, + createSessionView({ + version: 2, + state: 'edit.apply_changes', + phase: 'modify', + run_status: 'waiting_input', + }), + ) + store.set(difyBuilderSessionBusyAtom, true) + const workflow = createWorkflowStore({}) + const refreshed = vi.fn() + render( + + + + + , + ) + return { store, refresh, refreshed, user: userEvent.setup() } +} + +describe('Builder canvas commands', () => { + const selection = vi.fn() + + beforeEach(() => { + selection.mockClear() + document.addEventListener('workflow:select-node', selection) + }) + + afterEach(() => { + document.removeEventListener('workflow:select-node', selection) + }) + + it('handles every highlight in a batch and coalesces its graph refresh', async () => { + const { user, store, refresh, refreshed } = setup([ + canvasEvent('highlight_edit_target', 'start'), + canvasEvent('highlight_edit_target', 'answer'), + canvasEvent('apply_edit_plan'), + canvasEvent('mark_review_ready'), + ]) + + await user.click(screen.getByRole('button', { name: 'Apply changes' })) + + expect(selection).toHaveBeenCalledTimes(2) + expect(selection).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ detail: { nodeId: 'start', focus: true } }), + ) + expect(selection).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ detail: { nodeId: 'answer', focus: true } }), + ) + expect(refresh).not.toHaveBeenCalled() + expect(store.get(difyBuilderCanvasReadyAtom)).toBe(false) + + await user.click(screen.getByRole('button', { name: 'Finish command' })) + + await waitFor(() => expect(refreshed).toHaveBeenCalledOnce()) + expect(refresh).toHaveBeenCalledOnce() + expect(selection).toHaveBeenCalledTimes(2) + expect(store.get(difyBuilderCanvasReadyAtom)).toBe(true) + }) + + it('retains deferred node focus through later events and a failed refresh until retry succeeds', async () => { + const refresh = vi + .fn<(shouldApply: () => boolean) => Promise>() + .mockResolvedValueOnce(false) + .mockResolvedValueOnce(true) + const { user, store, refreshed } = setup( + [ + canvasEvent('add_llm_node', 'answer'), + canvasEvent('apply_edit_plan'), + canvasEvent('mark_review_ready'), + ], + refresh, + ) + await user.click(screen.getByRole('button', { name: 'Apply changes' })) + expect(selection).not.toHaveBeenCalled() + + await user.click(screen.getByRole('button', { name: 'Finish command' })) + await waitFor(() => expect(store.get(difyBuilderCanvasRefreshFailedAtom)).toBe(true)) + expect(selection).not.toHaveBeenCalled() + expect(store.get(difyBuilderCanvasReadyAtom)).toBe(false) + + await user.click(screen.getByRole('button', { name: 'Retry refresh' })) + + await waitFor(() => expect(refreshed).toHaveBeenCalledOnce()) + expect(selection).toHaveBeenCalledExactlyOnceWith( + expect.objectContaining({ detail: { nodeId: 'answer', focus: true } }), + ) + expect(refresh).toHaveBeenCalledTimes(2) + expect(store.get(difyBuilderCanvasReadyAtom)).toBe(true) + expect(store.get(difyBuilderCanvasPendingRefreshAtom)).toBeNull() + }) + + it('discards pending focus and refresh completion when the session resets', async () => { + let resolveRefresh!: (value: boolean) => void + const refresh = vi.fn<(shouldApply: () => boolean) => Promise>().mockReturnValueOnce( + new Promise((resolve) => { + resolveRefresh = resolve + }), + ) + const { user, store, refreshed } = setup([canvasEvent('add_llm_node', 'answer')], refresh) + await user.click(screen.getByRole('button', { name: 'Apply changes' })) + await user.click(screen.getByRole('button', { name: 'Finish command' })) + expect(refresh).toHaveBeenCalledOnce() + + await user.click(screen.getByRole('button', { name: 'Reset session' })) + await act(async () => resolveRefresh(true)) + + expect(refresh.mock.calls[0]![0]()).toBe(false) + expect(selection).not.toHaveBeenCalled() + expect(refreshed).not.toHaveBeenCalled() + expect(store.get(difyBuilderCanvasPendingRefreshAtom)).toBeNull() + }) +}) diff --git a/web/app/components/workflow-app/components/dify-builder/__tests__/flows.spec.tsx b/web/app/components/workflow-app/components/dify-builder/__tests__/flows.spec.tsx index 4c5b1b7889c..12aeddfed98 100644 --- a/web/app/components/workflow-app/components/dify-builder/__tests__/flows.spec.tsx +++ b/web/app/components/workflow-app/components/dify-builder/__tests__/flows.spec.tsx @@ -27,6 +27,17 @@ const mocks = vi.hoisted(() => ({ syncDraft: vi.fn(async (): Promise => undefined), })) +const runEvents = vi.hoisted(() => ({ + onWorkflowEvent: vi.fn(), + onStreamInterrupted: vi.fn(), + onCanvasEvent: vi.fn(), + restoreRun: vi.fn(), + finishCommand: vi.fn(), + reset: vi.fn(), + onCanvasRefreshed: vi.fn(), +})) +vi.mock('../provider/use-run-events', () => ({ useDifyBuilderRunEvents: () => runEvents })) + vi.mock('@/service/console', async (importOriginal) => { const actual = await importOriginal() diff --git a/web/app/components/workflow-app/components/dify-builder/__tests__/panel.browser.spec.tsx b/web/app/components/workflow-app/components/dify-builder/__tests__/panel.browser.spec.tsx index 0cfcf07dc26..acad640d4df 100644 --- a/web/app/components/workflow-app/components/dify-builder/__tests__/panel.browser.spec.tsx +++ b/web/app/components/workflow-app/components/dify-builder/__tests__/panel.browser.spec.tsx @@ -77,6 +77,7 @@ describe('DifyBuilderPanel layout', () => { enabled: true, getCanvasSnapshot: () => ({ nodes: [], edgeCount: 0 }), session: { + onCanvasRefreshed: vi.fn(), refresh: vi.fn(async () => true), getTrace: vi.fn(() => ({ entries: [], truncated: false })), loadOlderConversation: vi.fn(async () => true), diff --git a/web/app/components/workflow-app/components/dify-builder/__tests__/panel.spec.tsx b/web/app/components/workflow-app/components/dify-builder/__tests__/panel.spec.tsx index c88260c9561..0e1a4e70e3c 100644 --- a/web/app/components/workflow-app/components/dify-builder/__tests__/panel.spec.tsx +++ b/web/app/components/workflow-app/components/dify-builder/__tests__/panel.spec.tsx @@ -115,6 +115,7 @@ const renderPanel = ( enabled: true, getCanvasSnapshot: () => ({ nodes: [], edgeCount: 0 }), session: { + onCanvasRefreshed: vi.fn(), refresh: vi.fn(async () => true), getTrace: vi.fn(() => ({ entries: [], truncated: false })), loadOlderConversation: vi.fn(async () => true), diff --git a/web/app/components/workflow-app/components/dify-builder/__tests__/provider.spec.tsx b/web/app/components/workflow-app/components/dify-builder/__tests__/provider.spec.tsx index 900578ed661..ef866286a29 100644 --- a/web/app/components/workflow-app/components/dify-builder/__tests__/provider.spec.tsx +++ b/web/app/components/workflow-app/components/dify-builder/__tests__/provider.spec.tsx @@ -1,3 +1,4 @@ +import type { SessionRunEvents } from '../session/types' import { act, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { useAtom, useAtomValue, useSetAtom } from 'jotai' @@ -8,7 +9,6 @@ import { DifyBuilderProvider } from '../provider' import { difyBuilderActiveSessionIdAtom, difyBuilderSessionBusyAtom, - difyBuilderSessionLastCanvasEventAtom, difyBuilderSessionViewAtom, } from '../session/state' import { @@ -26,7 +26,8 @@ import { import { builderModel, createBuilderQueryClient } from './model-fixtures' const mocks = vi.hoisted(() => ({ - controllerHook: vi.fn(), + controllerHook: + vi.fn<(prepare: (saveDraft: boolean) => Promise, events: SessionRunEvents) => void>(), focusCanvas: vi.fn(), refreshCanvas: vi.fn<(shouldApply: () => boolean) => Promise>(async () => true), reset: vi.fn(), @@ -48,9 +49,23 @@ vi.mock('@/app/components/workflow/utils/node-navigation', () => ({ selectWorkflowNode: mocks.selectWorkflowNode, })) +const runEvents = vi.hoisted(() => ({ + onWorkflowEvent: vi.fn(), + onStreamInterrupted: vi.fn(), + onCanvasEvent: vi.fn(), + restoreRun: vi.fn(), + finishCommand: vi.fn(), + reset: vi.fn(), + onCanvasRefreshed: vi.fn(), +})) +vi.mock('../provider/use-run-events', () => ({ useDifyBuilderRunEvents: () => runEvents })) + vi.mock('../session/use-session-controller', () => ({ - useDifyBuilderSessionController: (prepareCommand: (saveDraft: boolean) => Promise) => { - mocks.controllerHook(prepareCommand) + useDifyBuilderSessionController: ( + prepareCommand: (saveDraft: boolean) => Promise, + events: SessionRunEvents, + ) => { + mocks.controllerHook(prepareCommand, events) return { refresh: vi.fn(), loadOlderConversation: vi.fn(async () => true), @@ -63,6 +78,7 @@ vi.mock('../session/use-session-controller', () => ({ startEdit: mocks.startEdit, startFix: mocks.startFix, updateModel: mocks.updateModel, + onCanvasRefreshed: runEvents.onCanvasRefreshed, } }, })) @@ -88,7 +104,6 @@ const Probe = () => { const recheckReady = useAtomValue(difyBuilderRecheckReadyAtom) const registerChecklistErrors = useSetAtom(difyBuilderRegisterChecklistErrorsAtom) const retryCanvasRefresh = useSetAtom(difyBuilderRetryCanvasRefreshAtom) - const setLastCanvasEvent = useSetAtom(difyBuilderSessionLastCanvasEventAtom) const setActiveSessionId = useSetAtom(difyBuilderActiveSessionIdAtom) const setBusy = useSetAtom(difyBuilderSessionBusyAtom) const setSessionView = useSetAtom(difyBuilderSessionViewAtom) @@ -167,17 +182,14 @@ const Probe = () => { + + + ) +} + +describe('Builder repair graph and test run', () => { + beforeEach(() => { + vi.resetAllMocks() + mocks.conversation.mockResolvedValue(conversationPage()) + }) + + it('keeps a preparing Builder command alive through canvas initialization and aborts it on unmount', async () => { + const store = createStore() + store.set(difyBuilderSessionViewAtom, createSessionView()) + const workflow = createWorkflowStore({}) + const queryClient = createConsoleQueryClient() + let showCanvas = false + let releasePreparation!: () => void + let signal: AbortSignal | undefined + const prepare = (_saveDraft: boolean, nextSignal: AbortSignal) => { + signal = nextSignal + return new Promise((resolve) => { + releasePreparation = resolve + }) + } + const hook = renderHook( + () => { + const events = useDifyBuilderRunEvents('app-1') + return useDifyBuilderSessionController(prepare, events) + }, + { + wrapper: ({ children }: { children: ReactNode }) => ( + + + + + {children} + {showCanvas && ( +
+ +
+ )} +
+
+
+
+ ), + }, + ) + let command!: Promise + act(() => { + command = hook.result.current.runAction('run_test') + }) + try { + expect(signal?.aborted).toBe(false) + showCanvas = true + hook.rerender() + expect(signal?.aborted).toBe(false) + expect(mocks.action).not.toHaveBeenCalled() + } finally { + hook.unmount() + releasePreparation() + expect(await command).toBe(false) + } + expect(signal?.aborted).toBe(true) + expect(mocks.action).not.toHaveBeenCalled() + }) + + it.each([true, false])( + 'waits for the repaired graph refresh (success: %s) before executing its new node', + async (refreshed) => { + const store = createStore() + const workflow = createWorkflowStore({}) + const queryClient = createConsoleQueryClient() + store.set(queryClientAtom, queryClient) + const before = createSessionView({ + state: 'build.await_repair', + phase: 'test', + run_status: 'waiting_confirmation', + }) + const after = createSessionView({ + state: 'build.execution', + phase: 'modify', + version: 2, + run_status: 'waiting_input', + app_revision: { current: 'repaired', observed: 'repaired', conflicted: false }, + }) + store.set(difyBuilderSessionViewAtom, before) + store.set(difyBuilderActiveSessionIdAtom, before.session_id) + let finishRefresh!: (value: boolean) => void + const repairRefresh = new Promise((resolve) => { + finishRefresh = resolve + }) + mocks.action.mockResolvedValueOnce(streamOf(commandStartedEvent(before), stateEvent(after))) + mocks.action.mockImplementationOnce(async () => { + expect(mocks.graphApplied).toHaveBeenCalledOnce() + return streamOf( + commandStartedEvent(after), + ...[runStarted(), nodeStarted(), nodeFinished(), runFinished()].map((payload, index) => ({ + event: 'workflow' as const, + data: workflowEvent(payload, { at_version: 3, revision: index + 1 }), + })), + stateEvent({ ...after, version: 3, state: 'build.review', phase: 'review' }), + ) + }) + render( + + + + + + + + + , + ) + const user = userEvent.setup() + const approve = screen.getByRole('button', { name: 'Apply repair' }) + await waitFor(() => expect(approve).toBeEnabled()) + await user.click(approve) + await waitFor(() => expect(store.get(difyBuilderSessionViewAtom)?.version).toBe(2)) + expect(mocks.action).toHaveBeenCalledOnce() + expect(mocks.graphApplied).not.toHaveBeenCalled() + + await act(async () => finishRefresh(refreshed)) + + if (refreshed) { + await waitFor(() => + expect(workflow.getState().workflowRunningData?.result.status).toBe('succeeded'), + ) + expect(workflow.getState().workflowRunningData?.tracing?.[0]?.node_id).toBe('answer') + expect(mocks.action).toHaveBeenCalledTimes(2) + expect(mocks.action.mock.calls[1]?.[0].body).toMatchObject({ + action_id: 'run_test', + base_version: 2, + base_app_revision: 'repaired', + }) + } else { + expect(mocks.action).toHaveBeenCalledOnce() + expect(workflow.getState().workflowRunningData).toBeUndefined() + expect(approve).toBeDisabled() + } + }, + ) +}) diff --git a/web/app/components/workflow-app/components/dify-builder/__tests__/store.spec.ts b/web/app/components/workflow-app/components/dify-builder/__tests__/store.spec.ts index 008da30d094..cb87e8a0f68 100644 --- a/web/app/components/workflow-app/components/dify-builder/__tests__/store.spec.ts +++ b/web/app/components/workflow-app/components/dify-builder/__tests__/store.spec.ts @@ -59,6 +59,7 @@ const createRuntime = (runAction: DifyBuilderRuntime['session']['runAction']) => enabled: true, getCanvasSnapshot: () => ({ nodes: [], edgeCount: 0 }), session: { + onCanvasRefreshed: vi.fn(), refresh: vi.fn(async () => true), getTrace: vi.fn(() => ({ entries: [], truncated: false })), loadOlderConversation: vi.fn(async () => true), diff --git a/web/app/components/workflow-app/components/dify-builder/__tests__/use-run-events.spec.tsx b/web/app/components/workflow-app/components/dify-builder/__tests__/use-run-events.spec.tsx new file mode 100644 index 00000000000..e1a7f4a04a7 --- /dev/null +++ b/web/app/components/workflow-app/components/dify-builder/__tests__/use-run-events.spec.tsx @@ -0,0 +1,767 @@ +import type { + WorkflowRunDetailResponse, + WorkflowRunNodeExecutionListResponse, +} from '@dify/contracts/api/console/apps/types.gen' +import type { ReactNode } from 'react' +import type { ConversationItem } from '../types' +import type { Node } from '@/app/components/workflow/types' +import { act, renderHook, waitFor } from '@testing-library/react' +import { createStore, Provider } from 'jotai' +import { ReactFlowProvider, useStoreApi } from 'reactflow' +import { WorkflowContext } from '@/app/components/workflow/context' +import { createWorkflowStore } from '@/app/components/workflow/store' +import { BlockEnum } from '@/app/components/workflow/types' +import { createQueryClientWrapper } from '@/test/console/query-client' +import { createConsoleQueryClient } from '@/test/console/query-data' +import { useDifyBuilderRunEvents } from '../provider/use-run-events' +import { difyBuilderErrorAtom } from '../store' +import { + nodeFinished, + nodeStarted, + runFinished, + runStarted, + workflowEvent, +} from './workflow-fixtures' + +const mocks = vi.hoisted(() => ({ + detail: vi.fn<(input: unknown) => Promise>(), + executions: vi.fn<(input: unknown) => Promise>(), +})) +vi.mock('@/service/console', async (importOriginal) => { + const actual = await importOriginal() + const run = actual.consoleQuery.apps.byAppId.workflowRuns.byRunId + return { + ...actual, + consoleQuery: { + apps: { + byAppId: { + workflowRuns: { + byRunId: { + get: { + queryOptions: (options: Parameters[0]) => ({ + ...run.get.queryOptions(options), + queryFn: () => mocks.detail(options?.input), + }), + }, + nodeExecutions: { + get: { + queryOptions: ( + options: Parameters[0], + ) => ({ + ...run.nodeExecutions.get.queryOptions(options), + queryFn: () => mocks.executions(options?.input), + }), + }, + }, + }, + }, + }, + }, + }, + } +}) + +const resultCard: ConversationItem = { + kind: 'test_result', + seq: 0, + at_version: 2, + payload: { + title: 'Test passed', + subtitle: '', + tone: 'success', + run_ids: ['builder-internal'], + dify_run_id: 'dify-run', + }, +} + +const setup = () => { + const store = createStore() + const workflow = createWorkflowStore({}) + const QueryProvider = createQueryClientWrapper(createConsoleQueryClient()) + const hook = renderHook( + () => ({ events: useDifyBuilderRunEvents('app-1'), canvas: useStoreApi() }), + { + wrapper: ({ children }: { children: ReactNode }) => ( + + + + {children} + + + + ), + }, + ) + const nodes: Node[] = ['start', 'answer'].map((id) => ({ + id, + position: { x: 0, y: 0 }, + data: { title: id, type: BlockEnum.Code, desc: '' }, + })) + act(() => { + hook.result.current.canvas.getState().setNodes(nodes) + hook.result.current.canvas + .getState() + .setEdges([{ id: 'edge', source: 'start', target: 'answer' }]) + }) + const nodeStatus = () => + (hook.result.current.canvas.getState().getNodes() as Node[]).find( + (node) => node.id === 'answer', + )?.data._runningStatus + return { ...hook, store, workflow, nodeStatus } +} + +describe('Builder run event handlers', () => { + beforeEach(() => { + vi.resetAllMocks() + mocks.detail.mockResolvedValue({ + id: 'dify-run', + status: 'succeeded', + graph: {}, + inputs: {}, + outputs: { result: '42' }, + }) + mocks.executions.mockResolvedValue({ + data: [ + { id: 'exec-1', index: 1, node_id: 'start', status: 'succeeded' }, + { + id: 'exec-2', + index: 2, + node_id: 'answer', + predecessor_node_id: 'start', + status: 'succeeded', + }, + ], + }) + }) + + it('paints the first node synchronously and keeps unknown results neutral across later canvas refreshes', () => { + const { result, workflow, nodeStatus } = setup() + act(() => { + result.current.events.onWorkflowEvent(workflowEvent(runStarted())) + result.current.events.onWorkflowEvent(workflowEvent(nodeStarted())) + }) + expect(nodeStatus()).toBe('running') + expect(workflow.getState().workflowRunningData?.result).toMatchObject({ + id: 'dify-run', + status: 'running', + }) + act(() => result.current.events.finishCommand()) + expect(nodeStatus()).toBeUndefined() + expect(workflow.getState().workflowRunningData?.result.status).toBe('unknown') + act(() => result.current.events.onCanvasRefreshed()) + expect(nodeStatus()).toBeUndefined() + expect(mocks.detail).not.toHaveBeenCalled() + }) + + it('handles the terminal run even when another canvas event immediately follows', async () => { + const { result, workflow, nodeStatus } = setup() + act(() => { + result.current.events.onWorkflowEvent(workflowEvent(runStarted())) + result.current.events.onWorkflowEvent(workflowEvent(nodeStarted())) + result.current.events.onWorkflowEvent(workflowEvent(nodeFinished())) + result.current.events.onWorkflowEvent(workflowEvent(runFinished())) + result.current.events.restoreRun('session-1', [resultCard]) + result.current.events.onCanvasEvent({ + ...workflowEvent(runStarted()), + kind: 'canvas', + event: 'mark_test_success', + dify_run_id: 'dify-run', + revision: 2, + }) + result.current.events.onCanvasEvent({ + ...workflowEvent(runStarted()), + kind: 'canvas', + event: 'mark_review_ready', + revision: 3, + }) + result.current.events.finishCommand() + }) + expect(workflow.getState().workflowRunningData?.result.status).toBe('succeeded') + await waitFor(() => expect(nodeStatus()).toBe('succeeded')) + expect(mocks.detail).not.toHaveBeenCalled() + expect(mocks.executions).not.toHaveBeenCalled() + expect(workflow.getState().workflowRunningData?.tracing?.[0]).toMatchObject(nodeFinished().data) + expect(workflow.getState().workflowRunningData?.resultText).toBe('42') + }) + + it('restores a card using the Dify ID and explicitly reapplies it after graph replacement', async () => { + const { result, workflow, nodeStatus } = setup() + act(() => result.current.events.restoreRun('session-1', [resultCard])) + await waitFor(() => expect(nodeStatus()).toBe('succeeded')) + expect(mocks.executions).toHaveBeenCalledWith({ + params: { app_id: 'app-1', run_id: 'dify-run' }, + }) + expect(workflow.getState().workflowRunningData?.result.outputs).toBe('{"result":"42"}') + act(() => + result.current.canvas.getState().setNodes([ + { + id: 'answer', + position: { x: 0, y: 0 }, + data: { type: BlockEnum.Code, title: 'Answer', desc: '' }, + }, + ]), + ) + expect(nodeStatus()).toBeUndefined() + act(() => result.current.events.onCanvasRefreshed()) + expect(nodeStatus()).toBe('succeeded') + act(() => result.current.events.reset()) + expect(nodeStatus()).toBeUndefined() + expect(workflow.getState().workflowRunningData).toBeUndefined() + }) + + it('recovers from a dropped terminal event when the result card is committed at the same version', async () => { + const { result, nodeStatus, workflow } = setup() + act(() => { + result.current.events.onWorkflowEvent(workflowEvent(runStarted())) + result.current.events.onWorkflowEvent(workflowEvent(nodeStarted())) + result.current.events.restoreRun('session-1', [resultCard]) + result.current.events.finishCommand() + }) + await waitFor(() => expect(nodeStatus()).toBe('succeeded')) + expect(workflow.getState().workflowRunningData?.result.status).toBe('succeeded') + }) + + it('restores missed node events after reconnect even when the terminal event arrives', async () => { + const { result, workflow } = setup() + act(() => { + result.current.events.onWorkflowEvent(workflowEvent(runStarted())) + result.current.events.onStreamInterrupted() + result.current.events.onWorkflowEvent(workflowEvent(runFinished())) + result.current.events.restoreRun('session-1', [resultCard]) + }) + await waitFor(() => expect(workflow.getState().workflowRunningData?.tracing).toHaveLength(2)) + expect(mocks.detail).toHaveBeenCalledOnce() + expect(mocks.executions).toHaveBeenCalledOnce() + }) + + it('shows a new run launch error even if a previous run finished', () => { + const { result, workflow } = setup() + act(() => { + result.current.events.onWorkflowEvent(workflowEvent(runStarted())) + result.current.events.onWorkflowEvent(workflowEvent(runFinished())) + result.current.events.onWorkflowEvent( + workflowEvent( + { + event: 'error', + workflow_run_id: 'next-run', + code: 'invalid_param', + status: 400, + message: 'Invalid input', + }, + { at_version: 3 }, + ), + ) + result.current.events.finishCommand() + }) + expect(workflow.getState().workflowRunningData?.result).toMatchObject({ + id: 'next-run', + status: 'failed', + error: 'Invalid input', + }) + expect(mocks.detail).not.toHaveBeenCalled() + }) + + it('keeps distinct native agent log IDs and updates the matching log', () => { + const { result, workflow } = setup() + const log = { + task_id: 'task-1', + event: 'agent_log' as const, + data: { + id: 'log-1', + node_id: 'answer', + node_execution_id: 'exec-1', + label: 'Tool', + status: 'started', + data: { input: 'query' }, + }, + } + act(() => { + const send = result.current.events.onWorkflowEvent + send(workflowEvent(runStarted())) + send(workflowEvent(nodeStarted())) + send(workflowEvent(log)) + send(workflowEvent({ ...log, data: { ...log.data, id: 'log-2' } })) + send( + workflowEvent({ + ...log, + data: { ...log.data, status: 'success', data: { output: 'answer' } }, + }), + ) + }) + expect( + workflow.getState().workflowRunningData?.tracing?.[0]?.execution_metadata?.agent_log, + ).toEqual([ + { ...log.data, message_id: 'log-1', status: 'success', data: { output: 'answer' } }, + { ...log.data, id: 'log-2', message_id: 'log-2' }, + ]) + }) + + it('handles native Chatflow messages without run IDs through the shared text callbacks', () => { + const { result, workflow } = setup() + const message = { + event: 'message' as const, + task_id: 'task-1', + id: 'message-1', + message_id: 'message-1', + conversation_id: 'conversation-1', + answer: 'hello', + from_variable_selector: ['answer', 'text'], + } + act(() => { + const send = result.current.events.onWorkflowEvent + send(workflowEvent(runStarted())) + send(workflowEvent(message)) + send(workflowEvent({ ...message, answer: ' world' })) + }) + expect(workflow.getState().workflowRunningData?.resultText).toBe('hello world') + act(() => { + const send = result.current.events.onWorkflowEvent + send( + workflowEvent({ + event: 'message_replace', + task_id: 'task-1', + answer: 'replacement', + reason: 'moderation', + }), + ) + send( + workflowEvent({ + event: 'reasoning_chunk', + task_id: 'task-1', + data: { reasoning: 'thinking', is_final: true }, + }), + ) + send( + workflowEvent({ + event: 'message_end', + task_id: 'task-1', + id: 'message-1', + metadata: { usage: { total_tokens: 7 } }, + }), + ) + }) + const data = workflow.getState().workflowRunningData + expect(data?.resultText).toBe('replacement') + expect(data?.reasoningFinished).toBe(true) + expect(data?.result).toMatchObject({ id: 'dify-run', status: 'running' }) + act(() => { + result.current.events.onWorkflowEvent( + workflowEvent({ + event: 'error', + code: 'invalid_param', + status: 400, + message: 'native failure', + }), + ) + result.current.events.finishCommand() + }) + expect(workflow.getState().workflowRunningData?.result).toMatchObject({ + id: 'dify-run', + status: 'failed', + error: 'native failure', + }) + expect(mocks.detail).not.toHaveBeenCalled() + }) + + it.each([false, true])( + 'handles a Chatflow error without a run ID (previous run: %s)', + (previousRun) => { + const { result, workflow } = setup() + act(() => { + const send = result.current.events.onWorkflowEvent + if (previousRun) { + send(workflowEvent(runStarted())) + send(workflowEvent(runFinished())) + } + send( + workflowEvent( + { + event: 'error', + code: 'invalid_param', + status: 400, + message: 'Invalid input', + }, + { at_version: 3 }, + ), + ) + result.current.events.finishCommand() + }) + expect(workflow.getState().workflowRunningData?.result).toMatchObject({ + id: '', + status: 'failed', + error: 'Invalid input', + }) + expect(mocks.detail).not.toHaveBeenCalled() + }, + ) + + it('handles human-input timeout and submission while preserving a resumed run trace', () => { + const { result, workflow } = setup() + act(() => { + const send = result.current.events.onWorkflowEvent + send(workflowEvent(runStarted())) + send(workflowEvent(nodeStarted())) + send( + workflowEvent({ + ...runStarted(), + event: 'human_input_required', + data: { + node_id: 'answer', + node_title: 'Approval', + form_id: 'form-1', + form_content: 'Approve?', + inputs: [], + actions: [], + expiration_time: 999, + }, + }), + ) + send( + workflowEvent({ + ...runStarted(), + event: 'human_input_form_timeout', + data: { + node_id: 'answer', + node_title: 'Approval', + expiration_time: 1, + }, + }), + ) + }) + expect( + workflow.getState().workflowRunningData?.humanInputFormDataList?.[0]?.expiration_time, + ).toBe(1) + act(() => { + const send = result.current.events.onWorkflowEvent + send( + workflowEvent({ + ...runStarted(), + event: 'workflow_paused', + data: { + workflow_run_id: 'dify-run', + status: 'paused', + paused_nodes: ['answer'], + reasons: [], + outputs: {}, + created_at: 1, + elapsed_time: 1, + total_steps: 1, + total_tokens: 0, + }, + }), + ) + send(workflowEvent(runStarted(), { at_version: 3 })) + send( + workflowEvent({ + ...runStarted(), + event: 'human_input_form_filled', + data: { + node_id: 'answer', + node_title: 'Approval', + rendered_content: 'Approved', + action_id: 'approve', + action_text: 'Approve', + submitted_data: { comment: 'Proceed' }, + }, + }), + ) + }) + const data = workflow.getState().workflowRunningData + expect(data?.tracing).toHaveLength(1) + expect(data?.result.status).toBe('running') + expect(data?.humanInputFormDataList).toEqual([]) + expect(data?.humanInputFilledFormDataList?.[0]?.submitted_data).toEqual({ comment: 'Proceed' }) + expect(mocks.detail).not.toHaveBeenCalled() + }) + + it.each(['failed', 'partial-succeeded', 'stopped'] as const)( + 'preserves the native %s result through Builder markers and result cards', + (status) => { + const { result, workflow } = setup() + act(() => { + result.current.events.onWorkflowEvent(workflowEvent(runStarted())) + result.current.events.onWorkflowEvent(workflowEvent(runFinished(status))) + result.current.events.onCanvasEvent({ + ...workflowEvent(runStarted()), + kind: 'canvas', + event: 'mark_test_error', + }) + result.current.events.restoreRun('session-1', [ + { ...resultCard, payload: { ...resultCard.payload, tone: 'error' } }, + ]) + result.current.events.finishCommand() + }) + expect(workflow.getState().workflowRunningData?.result.status).toBe(status) + expect(mocks.detail).not.toHaveBeenCalled() + }, + ) + + it('handles streaming text, reasoning, retries and the native error', () => { + const { result, workflow } = setup() + act(() => { + const send = result.current.events.onWorkflowEvent + send(workflowEvent(runStarted())) + send(workflowEvent(nodeStarted())) + send(workflowEvent({ ...runStarted(), event: 'text_chunk', data: { text: 'hello' } })) + send(workflowEvent({ ...runStarted(), event: 'text_chunk', data: { text: ' world' } })) + }) + expect(workflow.getState().workflowRunningData?.resultText).toBe('hello world') + act(() => { + const send = result.current.events.onWorkflowEvent + send(workflowEvent({ ...runStarted(), event: 'text_replace', data: { text: 'replacement' } })) + send( + workflowEvent({ + ...runStarted(), + event: 'reasoning_chunk', + data: { reasoning: 'thinking', node_id: 'answer', is_final: true }, + }), + ) + send( + workflowEvent({ + ...nodeFinished(), + event: 'node_retry', + data: { ...nodeFinished().data, retry_index: 1, status: 'retry' }, + }), + ) + send( + workflowEvent({ + event: 'error', + workflow_run_id: 'dify-run', + code: 'invalid_param', + status: 400, + message: 'native failure', + }), + ) + result.current.events.finishCommand() + }) + const data = workflow.getState().workflowRunningData + expect(data?.resultText).toBe('replacement') + expect(data?.reasoningFinished).toBe(true) + expect(data?.tracing?.at(-1)?.retry_index).toBe(1) + expect(data?.result).toMatchObject({ status: 'failed', error: 'native failure' }) + expect(mocks.detail).not.toHaveBeenCalled() + }) + + it.each(['iteration', 'loop'] as const)( + 'dispatches %s lifecycle through the shared handlers', + (kind) => { + const { result, workflow, nodeStatus } = setup() + const startData = { + ...nodeStarted().data, + inputs: {}, + metadata: { iterator_length: 2, loop_length: 2 }, + } + const endData = { ...nodeFinished().data, execution_metadata: {}, steps: 2, total_tokens: 7 } + act(() => { + const send = result.current.events.onWorkflowEvent + send(workflowEvent(runStarted())) + send( + workflowEvent({ + ...runStarted(), + event: kind === 'iteration' ? 'iteration_started' : 'loop_started', + data: startData, + }), + ) + send( + workflowEvent({ + ...runStarted(), + event: kind === 'iteration' ? 'iteration_next' : 'loop_next', + data: { ...startData, index: 1 }, + }), + ) + send( + workflowEvent({ + ...runStarted(), + event: kind === 'iteration' ? 'iteration_completed' : 'loop_completed', + data: endData, + }), + ) + }) + expect(nodeStatus()).toBe('succeeded') + expect(workflow.getState().workflowRunningData?.tracing?.[0]).toMatchObject(endData) + expect(mocks.detail).not.toHaveBeenCalled() + }, + ) + + it('retains a human-input pause instead of projecting the Builder validation failure', () => { + const { result, workflow, nodeStatus } = setup() + act(() => { + const send = result.current.events.onWorkflowEvent + send(workflowEvent(runStarted())) + send(workflowEvent(nodeStarted())) + send( + workflowEvent({ + ...runStarted(), + event: 'human_input_required', + data: { + node_id: 'answer', + node_title: 'Approval', + form_id: 'form-1', + form_content: 'Approve?', + inputs: [], + actions: [], + expiration_time: 999, + }, + }), + ) + send( + workflowEvent({ + ...runStarted(), + event: 'workflow_paused', + data: { + workflow_run_id: 'dify-run', + status: 'paused', + paused_nodes: ['answer'], + reasons: [], + outputs: {}, + created_at: 1, + elapsed_time: 1, + total_steps: 1, + total_tokens: 0, + }, + }), + ) + result.current.events.onCanvasEvent({ + ...workflowEvent(runStarted()), + kind: 'canvas', + event: 'mark_test_error', + }) + result.current.events.restoreRun('session-1', [resultCard]) + result.current.events.finishCommand() + result.current.events.onCanvasRefreshed() + }) + expect(workflow.getState().workflowRunningData?.result.status).toBe('paused') + expect(workflow.getState().workflowRunningData?.humanInputFormDataList?.[0]?.form_id).toBe( + 'form-1', + ) + expect(nodeStatus()).toBe('paused') + expect(mocks.detail).not.toHaveBeenCalled() + }) + + it('ignores internal Builder IDs and cards invalidated by a revert', () => { + const { result, nodeStatus } = setup() + act(() => { + result.current.events.restoreRun('session-1', [ + { ...resultCard, payload: { ...resultCard.payload, dify_run_id: '' } }, + ]) + result.current.events.restoreRun('session-1', [ + resultCard, + { + kind: 'assistant_turn', + seq: 1, + at_version: 2, + payload: { + turn_id: 'turn', + stage_id: 'build.test_and_repair', + reply_text: '', + execution: { status: 'completed' }, + cards: ['test_result'], + card_state: 'invalidated', + }, + }, + ]) + }) + expect(mocks.detail).not.toHaveBeenCalled() + expect(nodeStatus()).toBeUndefined() + }) + + it.each(['detail', 'executions'] as const)( + 'reports a failed %s restore and retries the same run successfully', + async (request) => { + mocks[request].mockRejectedValueOnce(new Error('Connection lost')) + const { result, store, workflow, nodeStatus } = setup() + act(() => result.current.events.restoreRun('session-1', [resultCard])) + await waitFor(() => expect(store.get(difyBuilderErrorAtom)).toBe('Connection lost')) + + act(() => result.current.events.restoreRun('session-1', [resultCard])) + + await waitFor(() => expect(nodeStatus()).toBe('succeeded')) + expect(workflow.getState().workflowRunningData?.result.status).toBe('succeeded') + expect(mocks[request]).toHaveBeenCalledTimes(2) + expect(store.get(difyBuilderErrorAtom)).toBe('') + act(() => result.current.events.restoreRun('session-1', [resultCard])) + expect(mocks[request]).toHaveBeenCalledTimes(2) + }, + ) + + it.each(['running', 'paused'])( + 'refreshes a restored %s run when it later finishes', + async (status) => { + mocks.detail.mockResolvedValueOnce({ + id: 'dify-run', + status, + graph: {}, + inputs: {}, + outputs: {}, + }) + const { result, workflow } = setup() + act(() => result.current.events.restoreRun('session-1', [resultCard])) + await waitFor(() => + expect(workflow.getState().workflowRunningData?.result.status).toBe(status), + ) + + act(() => result.current.events.restoreRun('session-1', [resultCard])) + + await waitFor(() => + expect(workflow.getState().workflowRunningData?.result.status).toBe('succeeded'), + ) + expect(mocks.detail).toHaveBeenCalledTimes(2) + expect(mocks.executions).toHaveBeenCalledTimes(2) + }, + ) + + it.each(['new-run', 'reset'] as const)( + 'ignores a delayed history error after %s', + async (mode) => { + let reject!: (error: Error) => void + mocks.detail.mockReturnValueOnce( + new Promise((_resolve, fail) => { + reject = fail + }), + ) + const { result, store } = setup() + act(() => result.current.events.restoreRun('session-1', [resultCard])) + await waitFor(() => expect(mocks.detail).toHaveBeenCalledOnce()) + act(() => { + if (mode === 'reset') result.current.events.reset() + else + result.current.events.onWorkflowEvent( + workflowEvent(runStarted('new-run'), { at_version: 3 }), + ) + }) + + await act(async () => reject(new Error('Obsolete request failed'))) + + expect(store.get(difyBuilderErrorAtom)).toBe('') + }, + ) + + it.each(['new-run', 'reset'] as const)( + 'ignores a delayed history response after %s', + async (mode) => { + let resolve!: (response: WorkflowRunNodeExecutionListResponse) => void + mocks.executions.mockReturnValue( + new Promise((done) => { + resolve = done + }), + ) + const { result, workflow, nodeStatus } = setup() + act(() => result.current.events.restoreRun('session-1', [resultCard])) + await waitFor(() => expect(mocks.executions).toHaveBeenCalledOnce()) + act(() => + mode === 'reset' + ? result.current.events.reset() + : result.current.events.onWorkflowEvent( + workflowEvent(runStarted('new-run'), { at_version: 3 }), + ), + ) + await act(async () => + resolve({ data: [{ id: 'old-exec', node_id: 'answer', status: 'failed' }] }), + ) + expect(workflow.getState().workflowRunningData?.result.id).toBe( + mode === 'reset' ? undefined : 'new-run', + ) + expect(nodeStatus()).toBeUndefined() + }, + ) +}) diff --git a/web/app/components/workflow-app/components/dify-builder/__tests__/workflow-fixtures.ts b/web/app/components/workflow-app/components/dify-builder/__tests__/workflow-fixtures.ts new file mode 100644 index 00000000000..bbf518bc2a1 --- /dev/null +++ b/web/app/components/workflow-app/components/dify-builder/__tests__/workflow-fixtures.ts @@ -0,0 +1,83 @@ +import type { DifyBuilderWorkflowEventData } from '@dify/contracts/api/console/dify-builder/types.gen' + +export type WorkflowPayload = DifyBuilderWorkflowEventData['payload'] +export type PayloadFor = Extract + +export const workflowEvent = ( + payload: WorkflowPayload, + overrides: Partial = {}, +): DifyBuilderWorkflowEventData => ({ + kind: 'workflow', + session_id: 'session-1', + operation_id: 'op', + stage_id: 'build.test_and_repair', + at_version: 2, + revision: 1, + payload, + ...overrides, +}) + +export const runStarted = (runId = 'dify-run'): PayloadFor<'workflow_started'> => ({ + event: 'workflow_started', + task_id: 'task-1', + workflow_run_id: runId, + data: { + id: runId, + workflow_id: 'workflow-1', + inputs: { query: 'hello' }, + created_at: 1, + reason: 'initial', + }, +}) + +export const nodeStarted = (nodeId = 'answer'): PayloadFor<'node_started'> => ({ + event: 'node_started', + task_id: 'task-1', + workflow_run_id: 'dify-run', + data: { + id: `exec-${nodeId}`, + node_id: nodeId, + node_type: 'code', + title: nodeId, + index: 1, + predecessor_node_id: 'start', + inputs: { query: 'hello' }, + inputs_truncated: false, + created_at: 1, + }, +}) + +export const nodeFinished = (nodeId = 'answer'): PayloadFor<'node_finished'> => ({ + ...nodeStarted(nodeId), + event: 'node_finished', + data: { + ...nodeStarted(nodeId).data, + status: 'succeeded', + outputs: { result: '42' }, + outputs_truncated: true, + process_data: { trace: 'native' }, + process_data_truncated: true, + elapsed_time: 2, + finished_at: 3, + execution_metadata: { total_tokens: 7 }, + }, +}) + +export const runFinished = ( + status: PayloadFor<'workflow_finished'>['data']['status'] = 'succeeded', +): PayloadFor<'workflow_finished'> => ({ + event: 'workflow_finished', + task_id: 'task-1', + workflow_run_id: 'dify-run', + data: { + id: 'dify-run', + workflow_id: 'workflow-1', + status, + outputs: { result: '42' }, + elapsed_time: 2, + total_tokens: 7, + total_steps: 2, + created_at: 1, + finished_at: 3, + }, +}) diff --git a/web/app/components/workflow-app/components/dify-builder/provider/canvas-sync.tsx b/web/app/components/workflow-app/components/dify-builder/provider/canvas-sync.tsx index f77f1a8c38e..d5fb62b3488 100644 --- a/web/app/components/workflow-app/components/dify-builder/provider/canvas-sync.tsx +++ b/web/app/components/workflow-app/components/dify-builder/provider/canvas-sync.tsx @@ -1,14 +1,14 @@ -import type { CanvasEventData } from '../types' import { useAtomValue, useStore as useJotaiStore, useSetAtom } from 'jotai' import { useEffect, useLayoutEffect, useRef } from 'react' import { useStore } from '@/app/components/workflow/store' import { selectWorkflowNode } from '@/app/components/workflow/utils/node-navigation' import { requestErrorMessage } from '../session/errors' -import { difyBuilderSessionBusyAtom, difyBuilderSessionLastCanvasEventAtom } from '../session/state' +import { difyBuilderSessionBusyAtom } from '../session/state' import { DIFY_BUILDER_CANVAS_REFRESH_PHASES, difyBuilderCanvasAppliedViewAtom, difyBuilderCanvasLockedAtom, + difyBuilderCanvasPendingRefreshAtom, difyBuilderCanvasRefreshFailedAtom, difyBuilderCanvasRefreshGenerationAtom, difyBuilderCanvasRefreshingAtom, @@ -19,47 +19,6 @@ import { difyBuilderViewVersionAtom, } from '../store' -type CanvasInstruction = { - focus: 'canvas' | 'node_now' | 'node_after_refresh' | 'none' - refresh: boolean -} - -const getCanvasInstruction = (data: CanvasEventData): CanvasInstruction => { - const event = data.event - switch (event) { - case 'focus_workflow': - return { focus: 'canvas', refresh: false } - case 'highlight_edit_target': - case 'focus_error_node': - case 'focus_checklist_node': - case 'mark_test_error': - return { focus: data.node_id ? 'node_now' : 'none', refresh: false } - case 'reset_build_canvas': - case 'revert_checkpoint': - return { focus: 'none', refresh: true } - case 'add_start_node': - case 'add_knowledge_node': - case 'add_llm_node': - case 'add_output_node': - case 'apply_edit_plan': - case 'apply_error_fix': - case 'mark_repair_applied': - case 'apply_preflight_fix': - return { focus: data.node_id ? 'node_after_refresh' : 'none', refresh: true } - case 'create_checkpoint': - case 'start_test_run': - case 'start_retest': - case 'mark_test_success': - case 'mark_review_ready': - case 'cancel_publish': - case 'publish_workflow': - return { focus: 'none', refresh: false } - default: - event satisfies never - return { focus: 'none', refresh: false } - } -} - export const DifyBuilderCanvasLockSync = () => { const locked = useAtomValue(difyBuilderCanvasLockedAtom) const setCanvasReadOnly = useStore((state) => state.setCanvasReadOnly) @@ -76,16 +35,17 @@ export const DifyBuilderCanvasLockSync = () => { } export const DifyBuilderCanvasRefreshSync = ({ - onFocusCanvas, onRefreshCanvas, + onCanvasRefreshed, }: { - onFocusCanvas: () => void onRefreshCanvas: (shouldApply: () => boolean) => Promise + onCanvasRefreshed: () => void }) => { const sessionStore = useJotaiStore() const builderNeedsRefresh = useStore((state) => state.workflowDraftSyncPhase === 'builder') const busy = useAtomValue(difyBuilderSessionBusyAtom) - const lastCanvasEvent = useAtomValue(difyBuilderSessionLastCanvasEventAtom) + const pendingRefresh = useAtomValue(difyBuilderCanvasPendingRefreshAtom) + const setPendingRefresh = useSetAtom(difyBuilderCanvasPendingRefreshAtom) const sessionId = useAtomValue(difyBuilderSessionIdAtom) const version = useAtomValue(difyBuilderViewVersionAtom) const phase = useAtomValue(difyBuilderPhaseAtom) @@ -95,11 +55,12 @@ export const DifyBuilderCanvasRefreshSync = ({ const setCanvasRefreshGeneration = useSetAtom(difyBuilderCanvasRefreshGenerationAtom) const setCanvasRefreshing = useSetAtom(difyBuilderCanvasRefreshingAtom) const setLocalError = useSetAtom(difyBuilderLocalErrorAtom) - const lastCanvasEventIdRef = useRef(0) - const lastRefreshRef = useRef<{ sessionId: string | null; version: number } | null>(null) + const lastRefreshRef = useRef<{ + sessionId: string | null + version: number + request: typeof pendingRefresh + } | null>(null) const lastRetryRequestRef = useRef(0) - const pendingFocusNodeIdRef = useRef(null) - const pendingRefreshRef = useRef(false) const refreshRequestIdRef = useRef(0) useEffect( @@ -111,30 +72,17 @@ export const DifyBuilderCanvasRefreshSync = ({ ) useEffect(() => { - if (lastCanvasEvent && lastCanvasEvent.id > lastCanvasEventIdRef.current) { - lastCanvasEventIdRef.current = lastCanvasEvent.id - const instruction = getCanvasInstruction(lastCanvasEvent.data) - if (instruction.focus === 'canvas') onFocusCanvas() - if (instruction.focus === 'node_now' && lastCanvasEvent.data.node_id) - selectWorkflowNode(lastCanvasEvent.data.node_id, true) - if (instruction.focus === 'node_after_refresh' && lastCanvasEvent.data.node_id) - pendingFocusNodeIdRef.current = lastCanvasEvent.data.node_id - if (instruction.refresh) pendingRefreshRef.current = true - } - if (busy) { refreshRequestIdRef.current += 1 - if (sessionStore.get(difyBuilderCanvasRefreshingAtom)) pendingRefreshRef.current = true + if (sessionStore.get(difyBuilderCanvasRefreshingAtom)) lastRefreshRef.current = null return } if (!sessionId && !builderNeedsRefresh) { refreshRequestIdRef.current += 1 - lastCanvasEventIdRef.current = 0 lastRefreshRef.current = null lastRetryRequestRef.current = retryRequest - pendingFocusNodeIdRef.current = null - pendingRefreshRef.current = false + setPendingRefresh(null) setCanvasRefreshing(false) setCanvasRefreshFailed(false) setCanvasAppliedView(null) @@ -142,22 +90,18 @@ export const DifyBuilderCanvasRefreshSync = ({ } const lastRefresh = lastRefreshRef.current + const request = pendingRefresh?.sessionId === sessionId ? pendingRefresh : null + const eventNeedsRefresh = request && request !== lastRefresh?.request const versionNeedsRefresh = (!lastRefresh || lastRefresh.sessionId !== sessionId || version > lastRefresh.version) && !!phase && DIFY_BUILDER_CANVAS_REFRESH_PHASES.has(phase) const retryRequested = retryRequest > lastRetryRequestRef.current - if ( - !retryRequested && - !pendingRefreshRef.current && - !versionNeedsRefresh && - !builderNeedsRefresh - ) + if (!retryRequested && !eventNeedsRefresh && !versionNeedsRefresh && !builderNeedsRefresh) return - lastRefreshRef.current = { sessionId, version } + lastRefreshRef.current = { sessionId, version, request } lastRetryRequestRef.current = retryRequest - pendingRefreshRef.current = false const requestId = ++refreshRequestIdRef.current setCanvasRefreshing(true) @@ -180,9 +124,12 @@ export const DifyBuilderCanvasRefreshSync = ({ setCanvasRefreshGeneration((generation) => generation + 1) setCanvasAppliedView(sessionId ? { sessionId, version } : null) setCanvasRefreshFailed(false) - const nodeId = pendingFocusNodeIdRef.current - pendingFocusNodeIdRef.current = null + setCanvasRefreshing(false) + if (sessionStore.get(difyBuilderCanvasPendingRefreshAtom) === request) + setPendingRefresh(null) + const nodeId = request?.focusNodeId if (nodeId) selectWorkflowNode(nodeId, true) + onCanvasRefreshed() }) .catch(async (error: unknown) => { const message = await requestErrorMessage(error) @@ -196,8 +143,8 @@ export const DifyBuilderCanvasRefreshSync = ({ }, [ builderNeedsRefresh, busy, - lastCanvasEvent, - onFocusCanvas, + pendingRefresh, + onCanvasRefreshed, onRefreshCanvas, phase, retryRequest, @@ -208,6 +155,7 @@ export const DifyBuilderCanvasRefreshSync = ({ setCanvasRefreshGeneration, setCanvasRefreshing, setLocalError, + setPendingRefresh, version, ]) diff --git a/web/app/components/workflow-app/components/dify-builder/provider/index.tsx b/web/app/components/workflow-app/components/dify-builder/provider/index.tsx index b64c6504c17..8371d1743ad 100644 --- a/web/app/components/workflow-app/components/dify-builder/provider/index.tsx +++ b/web/app/components/workflow-app/components/dify-builder/provider/index.tsx @@ -1,6 +1,7 @@ 'use client' import type { ReactNode } from 'react' +import type { SessionRunEvents } from '../session/types' import type { DifyBuilderCanvasNode } from '../utils' import { useQuery } from '@tanstack/react-query' import { ScopeProvider } from 'jotai-scope' @@ -15,6 +16,8 @@ import { DifyBuilderCanvasLockSync, DifyBuilderCanvasRefreshSync } from './canva import { DifyBuilderCreationStart } from './creation-start' import { DifyBuilderSessionPersistence } from './session-persistence' import { getSessionStorageKey } from './session-storage' +import { useDifyBuilderCanvasEvents } from './use-canvas-events' +import { useDifyBuilderRunEvents } from './use-run-events' type DifyBuilderProviderProps = { appId?: string @@ -47,7 +50,23 @@ const DifyBuilderProviderContent = ({ }), ) const setShowPanel = useStore((state) => state.setShowDifyBuilderPanel) - const session = useDifyBuilderSessionController(onSyncDraft) + const runEvents = useDifyBuilderRunEvents(appId) + const canvasEvents = useDifyBuilderCanvasEvents(onFocusCanvas) + const sessionEvents = useMemo( + () => ({ + ...runEvents, + onCanvasEvent: (event) => { + runEvents.onCanvasEvent(event) + canvasEvents.onCanvasEvent(event) + }, + reset: () => { + runEvents.reset() + canvasEvents.reset() + }, + }), + [canvasEvents, runEvents], + ) + const session = useDifyBuilderSessionController(onSyncDraft, sessionEvents) const runtime = useMemo( () => ({ appId, @@ -68,8 +87,8 @@ const DifyBuilderProviderContent = ({ <> {children} {/* Mount canvas and panel subscriptions before restoring or starting a session. */} diff --git a/web/app/components/workflow-app/components/dify-builder/provider/run-projection.ts b/web/app/components/workflow-app/components/dify-builder/provider/run-projection.ts new file mode 100644 index 00000000000..4b8237a1320 --- /dev/null +++ b/web/app/components/workflow-app/components/dify-builder/provider/run-projection.ts @@ -0,0 +1,26 @@ +import type { ConversationItem } from '../types' +import { groupConversationItems } from '../conversation/group-conversation-items' + +export type BuilderRun = { + sessionId: string + runId: string + atVersion: number +} + +export const getHistoricalRun = ( + sessionId: string, + items: ConversationItem[], +): BuilderRun | null => { + const latest = groupConversationItems(items) + .flatMap((group) => + group.invalidated ? [] : group.type === 'assistant' ? group.cards : [group.item], + ) + .reverse() + .find((item) => item.kind === 'test_result' && item.payload.dify_run_id) + if (latest?.kind !== 'test_result') return null + return { + sessionId, + runId: latest.payload.dify_run_id!, + atVersion: latest.at_version, + } +} diff --git a/web/app/components/workflow-app/components/dify-builder/provider/use-canvas-events.ts b/web/app/components/workflow-app/components/dify-builder/provider/use-canvas-events.ts new file mode 100644 index 00000000000..e46f1d13bc6 --- /dev/null +++ b/web/app/components/workflow-app/components/dify-builder/provider/use-canvas-events.ts @@ -0,0 +1,56 @@ +import type { CanvasEventData } from '../types' +import { useSetAtom } from 'jotai' +import { useCallback, useMemo } from 'react' +import { selectWorkflowNode } from '@/app/components/workflow/utils/node-navigation' +import { difyBuilderCanvasPendingRefreshAtom } from '../store' + +export const useDifyBuilderCanvasEvents = (onFocusCanvas: () => void) => { + const setPendingRefresh = useSetAtom(difyBuilderCanvasPendingRefreshAtom) + const onCanvasEvent = useCallback( + (data: CanvasEventData) => { + const event = data.event + switch (event) { + case 'focus_workflow': + onFocusCanvas() + return + case 'highlight_edit_target': + case 'focus_error_node': + case 'focus_checklist_node': + case 'mark_test_error': + if (data.node_id) selectWorkflowNode(data.node_id, true) + return + case 'reset_build_canvas': + case 'revert_checkpoint': + return setPendingRefresh({ sessionId: data.session_id }) + case 'add_start_node': + case 'add_knowledge_node': + case 'add_llm_node': + case 'add_output_node': + case 'apply_edit_plan': + case 'apply_error_fix': + case 'mark_repair_applied': + case 'apply_preflight_fix': + // Keep the requested node until its graph is applied, including across later markers. + return setPendingRefresh((pending) => ({ + sessionId: data.session_id, + focusNodeId: + data.node_id ?? + (pending?.sessionId === data.session_id ? pending.focusNodeId : undefined), + })) + case 'create_checkpoint': + case 'start_test_run': + case 'start_retest': + case 'mark_test_success': + case 'mark_review_ready': + case 'cancel_publish': + case 'publish_workflow': + return + default: + event satisfies never + } + }, + [onFocusCanvas, setPendingRefresh], + ) + const reset = useCallback(() => setPendingRefresh(null), [setPendingRefresh]) + return useMemo(() => ({ onCanvasEvent, reset }), [onCanvasEvent, reset]) +} diff --git a/web/app/components/workflow-app/components/dify-builder/provider/use-run-events.ts b/web/app/components/workflow-app/components/dify-builder/provider/use-run-events.ts new file mode 100644 index 00000000000..a959074bb50 --- /dev/null +++ b/web/app/components/workflow-app/components/dify-builder/provider/use-run-events.ts @@ -0,0 +1,369 @@ +import type { SessionRunEvents } from '../session/types' +import type { BuilderRun } from './run-projection' +import type { Edge, Node } from '@/app/components/workflow/types' +import type { NodeTracing } from '@/types/workflow' +import { useQueryClient } from '@tanstack/react-query' +import { pick } from 'es-toolkit/object' +import { useSetAtom } from 'jotai' +import { useCallback, useMemo, useRef } from 'react' +import { useStoreApi } from 'reactflow' +import { createRunningWorkflowState } from '@/app/components/workflow-app/hooks/use-workflow-run-utils' +import { useWorkflowStore } from '@/app/components/workflow/store' +import { NodeRunningStatus } from '@/app/components/workflow/types' +import { consoleQuery } from '@/service/console' +import { requestErrorMessage } from '../session/errors' +import { difyBuilderRunRestoreErrorAtom } from '../store' +import { getHistoricalRun } from './run-projection' +import { useWorkflowEventDispatch } from './use-workflow-event-dispatch' + +type ObservedRun = BuilderRun & { + source: 'live' | 'history' + complete: boolean + interrupted?: boolean + restoring?: boolean +} +type CanvasRunState = { + nodes: Map> + edges: Map>> +} + +export const useDifyBuilderRunEvents = (appId?: string) => { + const canvas = useStoreApi() + const workflow = useWorkflowStore() + const queryClient = useQueryClient() + const setRestoreError = useSetAtom(difyBuilderRunRestoreErrorAtom) + const dispatch = useWorkflowEventDispatch() + const runRef = useRef(null) + const requestIdRef = useRef(0) + const canvasRunRef = useRef(null) + + const captureCanvasRun = useCallback(() => { + const { getNodes, edges } = canvas.getState() + canvasRunRef.current = { + nodes: new Map( + (getNodes() as Node[]).map((node) => [ + node.id, + pick(node.data, [ + '_runningStatus', + '_waitingRun', + '_runningBranchId', + '_retryIndex', + '_iterationIndex', + '_iterationLength', + '_loopIndex', + '_loopLength', + ]), + ]), + ), + edges: new Map( + (edges as Edge[]).map((edge) => [ + edge.id, + edge.data + ? pick(edge.data, ['_sourceRunningStatus', '_targetRunningStatus', '_waitingRun']) + : {}, + ]), + ), + } + }, [canvas]) + + const clearCanvasRun = useCallback( + (onlyUnfinished = false) => { + const unfinished = (status?: NodeRunningStatus) => + status === NodeRunningStatus.Running || + status === NodeRunningStatus.Retry || + status === NodeRunningStatus.Waiting + const { getNodes, setNodes, edges, setEdges } = canvas.getState() + setNodes( + (getNodes() as Node[]).map((node) => ({ + ...node, + data: { + ...node.data, + _runningStatus: + onlyUnfinished && !unfinished(node.data._runningStatus) + ? node.data._runningStatus + : undefined, + _waitingRun: false, + _runningBranchId: onlyUnfinished ? node.data._runningBranchId : undefined, + _retryIndex: undefined, + _iterationIndex: undefined, + _iterationLength: undefined, + _loopIndex: undefined, + _loopLength: undefined, + }, + })), + ) + setEdges( + (edges as Edge[]).map((edge) => ({ + ...edge, + data: { + ...edge.data, + _sourceRunningStatus: + onlyUnfinished && !unfinished(edge.data?._sourceRunningStatus) + ? edge.data?._sourceRunningStatus + : undefined, + _targetRunningStatus: + onlyUnfinished && !unfinished(edge.data?._targetRunningStatus) + ? edge.data?._targetRunningStatus + : undefined, + _waitingRun: false, + }, + })), + ) + }, + [canvas], + ) + + const onCanvasRefreshed = useCallback(() => { + const run = runRef.current + const saved = canvasRunRef.current + if (!run || !saved || workflow.getState().workflowRunningData?.result.id !== run.runId) return + const { getNodes, setNodes, edges, setEdges } = canvas.getState() + setNodes( + (getNodes() as Node[]).map((node) => ({ + ...node, + data: { ...node.data, ...saved.nodes.get(node.id) }, + })), + ) + setEdges( + (edges as Edge[]).map((edge) => ({ + ...edge, + data: { ...edge.data, ...saved.edges.get(edge.id) }, + })), + ) + }, [canvas, workflow]) + + const reset = useCallback(() => { + requestIdRef.current += 1 + setRestoreError('') + const run = runRef.current + runRef.current = null + canvasRunRef.current = null + if (!run || workflow.getState().workflowRunningData?.result.id !== run.runId) return + workflow.setState({ workflowRunningData: undefined }) + clearCanvasRun() + }, [clearCanvasRun, setRestoreError, workflow]) + + const onWorkflowEvent = useCallback( + (event) => { + const { payload } = event + const current = runRef.current + // Chatflow's text/message/error events omit the run ID. They belong to + // the run started in this command, never a previous command's result. + const runId = + payload.workflow_run_id ?? + (current?.sessionId === event.session_id && current.atVersion === event.at_version + ? current.runId + : '') + const resumesPausedRun = + payload.event === 'workflow_started' && + current?.runId === runId && + workflow.getState().workflowRunningData?.result.status === 'paused' + if (resumesPausedRun) { + requestIdRef.current += 1 + setRestoreError('') + runRef.current = { + ...current, + atVersion: event.at_version, + source: 'live', + complete: false, + restoring: false, + } + } else if ( + payload.event === 'workflow_started' || + (payload.event === 'error' && current?.runId !== runId) + ) { + requestIdRef.current += 1 + setRestoreError('') + canvasRunRef.current = null + runRef.current = { + sessionId: event.session_id, + runId, + atVersion: event.at_version, + source: 'live', + complete: false, + } + clearCanvasRun() + const initial = createRunningWorkflowState() + workflow.getState().setWorkflowRunningData({ + ...initial, + result: { ...initial.result, id: runId }, + }) + } + const run = runRef.current + // A reconnect may join midway through a run. Restore that run from durable + // history instead of invoking node callbacks without its started event. + if (!run || run.source !== 'live' || run.runId !== runId) return + dispatch(payload) + if ( + payload.event === 'workflow_finished' || + payload.event === 'workflow_paused' || + payload.event === 'error' + ) + run.complete = true + }, + [clearCanvasRun, dispatch, setRestoreError, workflow], + ) + + const onStreamInterrupted = useCallback(() => { + const run = runRef.current + if (run?.source === 'live' && !run.complete) run.interrupted = true + }, []) + + const onCanvasEvent = useCallback( + (event) => { + if (event.event === 'reset_build_canvas' || event.event === 'revert_checkpoint') reset() + // mark_test_* narrates Builder's validation decision. Only native workflow + // events own execution status; a paused/partial run must retain its status. + }, + [reset], + ) + + const restoreRun = useCallback( + (sessionId, items) => { + const restored = getHistoricalRun(sessionId, items) + const current = runRef.current + if ( + !restored || + !appId || + (current?.sessionId === sessionId && + (current.atVersion > restored.atVersion || + (current.runId === restored.runId && + ((current.complete && !current.interrupted) || current.restoring)))) + ) + return + + const displayedRunId = workflow.getState().workflowRunningData?.result.id + const run: ObservedRun = { ...restored, source: 'history', complete: false, restoring: true } + runRef.current = run + const requestId = ++requestIdRef.current + setRestoreError('') + const input = { params: { app_id: appId, run_id: run.runId } } + // These queries serve reopen/recovery only. A complete live stream never + // reaches this branch, even when commit/state repeats its result card. + void Promise.all([ + queryClient.query( + consoleQuery.apps.byAppId.workflowRuns.byRunId.get.queryOptions({ input, staleTime: 0 }), + ), + queryClient.query( + consoleQuery.apps.byAppId.workflowRuns.byRunId.nodeExecutions.get.queryOptions({ + input, + staleTime: 0, + }), + ), + ]) + .then(([detail, response]) => { + if (requestIdRef.current !== requestId || runRef.current !== run) return + const executions = response.data ?? [] + const displayed = workflow.getState().workflowRunningData?.result.id + if (displayed !== displayedRunId && displayed !== run.runId) return + clearCanvasRun() + const statuses = new Map( + [...executions] + .sort((a, b) => (a.index ?? 0) - (b.index ?? 0)) + .map((execution) => [ + execution.node_id, + Object.values(NodeRunningStatus).find((status) => status === execution.status), + ]), + ) + const { getNodes, setNodes, edges, setEdges } = canvas.getState() + setNodes( + (getNodes() as Node[]).map((node) => ({ + ...node, + data: { ...node.data, _runningStatus: statuses.get(node.id) }, + })), + ) + setEdges( + (edges as Edge[]).map((edge) => { + const executed = executions.some( + (execution) => + execution.node_id === edge.target && + execution.predecessor_node_id === edge.source, + ) + return { + ...edge, + data: { + ...edge.data, + _sourceRunningStatus: executed ? statuses.get(edge.source) : undefined, + _targetRunningStatus: executed ? statuses.get(edge.target) : undefined, + }, + } + }), + ) + workflow.getState().setWorkflowRunningData({ + result: { + id: run.runId, + status: detail.status ?? 'unknown', + inputs: JSON.stringify(detail.inputs), + inputs_truncated: false, + process_data_truncated: false, + outputs: JSON.stringify(detail.outputs), + outputs_truncated: false, + error: detail.error ?? undefined, + elapsed_time: detail.elapsed_time ?? undefined, + total_tokens: detail.total_tokens ?? undefined, + total_steps: detail.total_steps ?? undefined, + }, + tracing: executions as NodeTracing[], + }) + run.complete = + detail.status === 'succeeded' || + detail.status === 'failed' || + detail.status === 'stopped' || + detail.status === 'partial-succeeded' + captureCanvasRun() + }) + .catch(async (error: unknown) => { + const message = await requestErrorMessage(error) + if (requestIdRef.current === requestId && runRef.current === run) setRestoreError(message) + }) + .finally(() => { + run.restoring = false + }) + }, + [appId, canvas, captureCanvasRun, clearCanvasRun, queryClient, setRestoreError, workflow], + ) + + const finishCommand = useCallback(() => { + const run = runRef.current + if ( + !run || + run.source !== 'live' || + workflow.getState().workflowRunningData?.result.id !== run.runId + ) + return + if (!run.complete) { + const data = workflow.getState().workflowRunningData! + workflow.getState().setWorkflowRunningData({ + ...data, + result: { ...data.result, status: 'unknown' }, + tracing: data.tracing?.map((trace) => ({ + ...trace, + status: trace.status === 'running' || trace.status === 'retry' ? 'unknown' : trace.status, + })), + }) + clearCanvasRun(true) + } + captureCanvasRun() + }, [captureCanvasRun, clearCanvasRun, workflow]) + + return useMemo( + () => ({ + onWorkflowEvent, + onStreamInterrupted, + onCanvasEvent, + restoreRun, + finishCommand, + reset, + onCanvasRefreshed, + }), + [ + onWorkflowEvent, + onStreamInterrupted, + onCanvasEvent, + restoreRun, + finishCommand, + reset, + onCanvasRefreshed, + ], + ) +} diff --git a/web/app/components/workflow-app/components/dify-builder/provider/use-workflow-event-dispatch.ts b/web/app/components/workflow-app/components/dify-builder/provider/use-workflow-event-dispatch.ts new file mode 100644 index 00000000000..cd560b614fb --- /dev/null +++ b/web/app/components/workflow-app/components/dify-builder/provider/use-workflow-event-dispatch.ts @@ -0,0 +1,156 @@ +import type { DifyBuilderWorkflowEventData } from '@dify/contracts/api/console/dify-builder/types.gen' +import { useCallback } from 'react' +import { useStoreApi } from 'reactflow' +import { useWorkflowRunEvent } from '@/app/components/workflow/hooks/use-workflow-run-event/use-workflow-run-event' + +// The shared handlers still type stream records as complete NodeTracing rows. +// Adapt those legacy types at this boundary; the native payload stays unchanged. +export const useWorkflowEventDispatch = () => { + const canvas = useStoreApi() + const { + handleWorkflowStarted, + handleWorkflowFinished, + handleWorkflowNodeStarted, + handleWorkflowNodeFinished, + handleWorkflowNodeRetry, + handleWorkflowNodeIterationStarted, + handleWorkflowNodeIterationNext, + handleWorkflowNodeIterationFinished, + handleWorkflowNodeLoopStarted, + handleWorkflowNodeLoopNext, + handleWorkflowNodeLoopFinished, + handleWorkflowTextChunk, + handleWorkflowTextReplace, + handleWorkflowReasoning, + handleWorkflowAgentLog, + handleWorkflowNodeHumanInputRequired, + handleWorkflowNodeHumanInputFormFilled, + handleWorkflowNodeHumanInputFormTimeout, + handleWorkflowPaused, + handleWorkflowFailed, + } = useWorkflowRunEvent() + + return useCallback( + (payload: DifyBuilderWorkflowEventData['payload']) => { + const { width: clientWidth, height: clientHeight } = canvas.getState() + const container = { clientWidth, clientHeight } + switch (payload.event) { + case 'workflow_started': + handleWorkflowStarted(payload as Parameters[0]) + break + case 'workflow_finished': + handleWorkflowFinished(payload as Parameters[0]) + break + case 'node_started': + handleWorkflowNodeStarted( + payload as Parameters[0], + container, + ) + break + case 'node_finished': + handleWorkflowNodeFinished(payload as Parameters[0]) + break + case 'node_retry': + handleWorkflowNodeRetry(payload as Parameters[0]) + break + case 'iteration_started': + handleWorkflowNodeIterationStarted( + payload as Parameters[0], + container, + ) + break + case 'iteration_next': + handleWorkflowNodeIterationNext( + payload as Parameters[0], + ) + break + case 'iteration_completed': + handleWorkflowNodeIterationFinished( + payload as unknown as Parameters[0], + ) + break + case 'loop_started': + handleWorkflowNodeLoopStarted( + payload as Parameters[0], + container, + ) + break + case 'loop_next': + handleWorkflowNodeLoopNext(payload as Parameters[0]) + break + case 'loop_completed': + handleWorkflowNodeLoopFinished( + payload as unknown as Parameters[0], + ) + break + case 'text_chunk': + case 'message': + handleWorkflowTextChunk(payload as Parameters[0]) + break + case 'text_replace': + case 'message_replace': + handleWorkflowTextReplace(payload as Parameters[0]) + break + case 'reasoning_chunk': + handleWorkflowReasoning(payload as Parameters[0]) + break + case 'agent_log': + handleWorkflowAgentLog(payload as unknown as Parameters[0]) + break + case 'human_input_required': + handleWorkflowNodeHumanInputRequired( + payload as Parameters[0], + ) + break + case 'human_input_form_filled': + handleWorkflowNodeHumanInputFormFilled( + payload as Parameters[0], + ) + break + case 'human_input_form_timeout': + handleWorkflowNodeHumanInputFormTimeout( + payload as Parameters[0], + ) + break + case 'workflow_paused': + handleWorkflowPaused() + break + case 'error': + handleWorkflowFailed(payload.message) + break + case 'tts_message': + case 'tts_message_end': + case 'message_end': + case 'message_file': + // The workflow run panel renders files and metrics from node/run + // callbacks; chat message metadata and audio need no extra projection. + break + default: + payload satisfies never + } + }, + [ + canvas, + handleWorkflowStarted, + handleWorkflowFinished, + handleWorkflowNodeStarted, + handleWorkflowNodeFinished, + handleWorkflowNodeRetry, + handleWorkflowNodeIterationStarted, + handleWorkflowNodeIterationNext, + handleWorkflowNodeIterationFinished, + handleWorkflowNodeLoopStarted, + handleWorkflowNodeLoopNext, + handleWorkflowNodeLoopFinished, + handleWorkflowTextChunk, + handleWorkflowTextReplace, + handleWorkflowReasoning, + handleWorkflowAgentLog, + handleWorkflowNodeHumanInputRequired, + handleWorkflowNodeHumanInputFormFilled, + handleWorkflowNodeHumanInputFormTimeout, + handleWorkflowPaused, + handleWorkflowFailed, + ], + ) +} diff --git a/web/app/components/workflow-app/components/dify-builder/session/__tests__/fixtures.tsx b/web/app/components/workflow-app/components/dify-builder/session/__tests__/fixtures.tsx index 33e6926ca48..1a0bbc3345c 100644 --- a/web/app/components/workflow-app/components/dify-builder/session/__tests__/fixtures.tsx +++ b/web/app/components/workflow-app/components/dify-builder/session/__tests__/fixtures.tsx @@ -156,9 +156,10 @@ export const createControlledEventStream = () => { export const renderSessionHook = ( prepareCommand?: Parameters[0], + runEvents?: Parameters[1], ) => { const store = createStore() - const rendered = renderHook(() => useDifyBuilderSessionController(prepareCommand), { + const rendered = renderHook(() => useDifyBuilderSessionController(prepareCommand, runEvents), { wrapper: ({ children }: { children: ReactNode }) => ( {children} ), diff --git a/web/app/components/workflow-app/components/dify-builder/session/__tests__/use-session-controller.runs.spec.tsx b/web/app/components/workflow-app/components/dify-builder/session/__tests__/use-session-controller.runs.spec.tsx new file mode 100644 index 00000000000..10ef7b35122 --- /dev/null +++ b/web/app/components/workflow-app/components/dify-builder/session/__tests__/use-session-controller.runs.spec.tsx @@ -0,0 +1,288 @@ +import type { + DifyBuilderStreamEventResponse, + DifyBuilderWorkflowEventData, +} from '@dify/contracts/api/console/dify-builder/types.gen' +import type { SessionRunEvents } from '../types' +import { act, waitFor } from '@testing-library/react' +import { nodeStarted, runStarted, workflowEvent } from '../../__tests__/workflow-fixtures' +import { + difyBuilderCanvasAppliedViewAtom, + difyBuilderCanvasRefreshFailedAtom, + difyBuilderCanvasRefreshingAtom, +} from '../../store' +import { difyBuilderActiveSessionIdAtom, difyBuilderSessionViewAtom } from '../state' +import { + commandStartedEvent, + conversationPage, + createControlledEventStream, + createSessionView, + renderSessionHook, + stateEvent, + streamOf, +} from './fixtures' + +const mocks = vi.hoisted(() => ({ + create: vi.fn(), + conversation: vi.fn(), + action: vi.fn(), + get: vi.fn(), +})) +const runEvents = { + onWorkflowEvent: vi.fn(), + onCanvasEvent: vi.fn(), + restoreRun: vi.fn(), + onStreamInterrupted: vi.fn(), + finishCommand: vi.fn(), + reset: vi.fn(), + onCanvasRefreshed: vi.fn(), +} satisfies SessionRunEvents +vi.mock('@/service/console', () => ({ + consoleClient: { + difyBuilder: { + sessions: { + post: mocks.create, + bySessionId: { + get: mocks.get, + conversation: { get: mocks.conversation }, + actions: { post: mocks.action }, + }, + }, + }, + }, +})) + +const frame = ( + payload: DifyBuilderWorkflowEventData['payload'], + overrides: Partial = {}, +): DifyBuilderStreamEventResponse => ({ + event: 'workflow', + data: workflowEvent(payload, overrides), +}) + +describe('Builder workflow stream', () => { + beforeEach(() => { + vi.resetAllMocks() + mocks.conversation.mockResolvedValue(conversationPage()) + }) + + it('dispatches every accepted payload and rejects stale, duplicate and foreign frames', async () => { + const stream = createControlledEventStream() + mocks.create.mockResolvedValue(stream.iterable) + const { result } = renderSessionHook(undefined, runEvents) + let pending!: Promise + act(() => { + pending = result.current.startFix('app-1', 'failed-run') + }) + await waitFor(() => expect(mocks.create).toHaveBeenCalledOnce()) + act(() => { + stream.push(commandStartedEvent(createSessionView())) + stream.push(frame(runStarted())) + stream.push(frame(nodeStarted(), { revision: 2 })) + stream.push(frame(nodeStarted(), { revision: 1 })) + stream.push(frame(nodeStarted(), { revision: 2 })) + stream.push(frame(nodeStarted(), { revision: 3, session_id: 'other-session' })) + stream.push(frame(nodeStarted(), { at_version: 1, revision: 4 })) + }) + await waitFor(() => expect(runEvents.onWorkflowEvent).toHaveBeenCalledTimes(2)) + expect(runEvents.onWorkflowEvent.mock.calls.map(([event]) => event.payload)).toEqual([ + runStarted(), + nodeStarted(), + ]) + await act(async () => { + stream.push( + stateEvent( + createSessionView({ version: 2, run_status: 'waiting_input', state: 'build.review' }), + ), + ) + expect(await pending).toBe(true) + }) + expect(runEvents.finishCommand).toHaveBeenCalledOnce() + }) + + it('does not terminate the Builder command on an inner workflow error', async () => { + mocks.create.mockResolvedValue( + streamOf( + commandStartedEvent(createSessionView()), + frame({ + event: 'error', + workflow_run_id: 'dify-run', + code: 'invalid_param', + status: 400, + message: 'bad input', + }), + { + event: 'canvas', + data: { + ...workflowEvent(runStarted()), + kind: 'canvas', + event: 'mark_test_error', + revision: 2, + dify_run_id: 'dify-run', + }, + }, + stateEvent( + createSessionView({ + version: 2, + run_status: 'waiting_input', + state: 'build.await_repair', + }), + ), + ), + ) + const { result } = renderSessionHook(undefined, runEvents) + await act(async () => { + expect(await result.current.startFix('app-1', 'failed-run')).toBe(true) + }) + expect(runEvents.onWorkflowEvent).toHaveBeenCalledOnce() + expect(runEvents.onCanvasEvent).toHaveBeenCalledOnce() + expect(runEvents.finishCommand).toHaveBeenCalledOnce() + }) + + it('finishes an unknown run without synthesizing a workflow error', async () => { + mocks.create.mockResolvedValue( + streamOf( + commandStartedEvent(createSessionView()), + frame(runStarted()), + stateEvent( + createSessionView({ version: 2, run_status: 'waiting_input', state: 'build.execution' }), + ), + ), + ) + const { result } = renderSessionHook(undefined, runEvents) + await act(async () => { + await result.current.startFix('app-1', 'failed-run') + }) + expect(runEvents.onWorkflowEvent).toHaveBeenCalledOnce() + expect(runEvents.onCanvasEvent).not.toHaveBeenCalled() + expect(runEvents.finishCommand).toHaveBeenCalledOnce() + }) + + it.each(['eof', 'transport-error'])( + 'marks interrupted live events before reconciling %s', + async (failure) => { + mocks.create.mockResolvedValue( + (async function* () { + yield commandStartedEvent(createSessionView()) + yield frame(runStarted()) + if (failure === 'transport-error') throw new Error('Connection lost') + })(), + ) + mocks.get.mockImplementation(async () => { + expect(runEvents.onStreamInterrupted).toHaveBeenCalledOnce() + return createSessionView({ version: 2, run_status: 'waiting_input', state: 'build.review' }) + }) + const { result } = renderSessionHook(undefined, runEvents) + await act(async () => { + await result.current.startFix('app-1', 'failed-run') + }) + expect(mocks.get).toHaveBeenCalledOnce() + expect(runEvents.onWorkflowEvent).toHaveBeenCalledOnce() + }, + ) + + it.each([ + ['build.await_repair', 'build.execution', 'run_test'], + ['edit.await_repair', 'edit.apply_changes', 'run_affected_tests'], + ])( + 'retests %s once, only after its matching graph is applied', + async (beforeState, afterState, actionId) => { + const { result, store } = renderSessionHook(undefined, runEvents) + const before = createSessionView({ + state: beforeState, + phase: 'test', + run_status: 'waiting_confirmation', + }) + const after = createSessionView({ + state: afterState, + phase: 'modify', + version: 2, + run_status: 'waiting_confirmation', + app_revision: { + current: 'repaired-revision', + observed: 'repaired-revision', + conflicted: false, + }, + }) + store.set(difyBuilderSessionViewAtom, before) + store.set(difyBuilderActiveSessionIdAtom, before.session_id) + store.set(difyBuilderCanvasAppliedViewAtom, { sessionId: before.session_id, version: 1 }) + mocks.action.mockResolvedValueOnce(streamOf(commandStartedEvent(before), stateEvent(after))) + mocks.action.mockResolvedValueOnce( + streamOf(stateEvent({ ...after, version: 4, state: 'build.review' })), + ) + await act(async () => { + expect(await result.current.runAction('confirm', { option_id: 'approve_plan' })).toBe(true) + }) + expect(mocks.action).toHaveBeenCalledOnce() + act(() => result.current.onCanvasRefreshed()) + expect(mocks.action).toHaveBeenCalledOnce() + act(() => { + store.set(difyBuilderCanvasAppliedViewAtom, { sessionId: after.session_id, version: 2 }) + store.set(difyBuilderCanvasRefreshingAtom, true) + result.current.onCanvasRefreshed() + }) + expect(mocks.action).toHaveBeenCalledOnce() + act(() => { + store.set(difyBuilderCanvasRefreshingAtom, false) + store.set(difyBuilderCanvasRefreshFailedAtom, true) + result.current.onCanvasRefreshed() + }) + expect(mocks.action).toHaveBeenCalledOnce() + act(() => { + store.set(difyBuilderCanvasRefreshFailedAtom, false) + result.current.onCanvasRefreshed() + result.current.onCanvasRefreshed() + }) + await waitFor(() => expect(mocks.action).toHaveBeenCalledTimes(2)) + expect(mocks.action.mock.calls[1]?.[0]).toMatchObject({ + body: { + action_id: actionId, + payload: {}, + base_version: 2, + base_app_revision: 'repaired-revision', + }, + }) + }, + ) + + it.each(['reset', 'new-version', 'different-revision', 'history'] as const)( + 'does not start a pending retest after %s', + async (scenario) => { + const { result, store } = renderSessionHook(undefined, runEvents) + const before = createSessionView({ + state: 'build.await_repair', + phase: 'test', + run_status: 'waiting_confirmation', + }) + const after = { ...before, state: 'build.execution', version: 2 } + store.set(difyBuilderSessionViewAtom, before) + store.set(difyBuilderActiveSessionIdAtom, before.session_id) + if (scenario !== 'history') { + mocks.action.mockResolvedValueOnce(streamOf(stateEvent(after))) + await act(async () => { + await result.current.runAction('approve_plan') + }) + } + act(() => { + if (scenario === 'reset') result.current.reset() + store.set( + difyBuilderSessionViewAtom, + scenario === 'new-version' + ? { ...after, version: 3 } + : scenario === 'different-revision' + ? { + ...after, + app_revision: { current: 'other', observed: 'other', conflicted: false }, + } + : after, + ) + store.set(difyBuilderCanvasAppliedViewAtom, { + sessionId: after.session_id, + version: scenario === 'new-version' ? 3 : 2, + }) + result.current.onCanvasRefreshed() + }) + expect(mocks.action).toHaveBeenCalledTimes(scenario === 'history' ? 0 : 1) + }, + ) +}) diff --git a/web/app/components/workflow-app/components/dify-builder/session/__tests__/use-session-controller.spec.tsx b/web/app/components/workflow-app/components/dify-builder/session/__tests__/use-session-controller.spec.tsx index 803f6eb94b9..05c806b8920 100644 --- a/web/app/components/workflow-app/components/dify-builder/session/__tests__/use-session-controller.spec.tsx +++ b/web/app/components/workflow-app/components/dify-builder/session/__tests__/use-session-controller.spec.tsx @@ -15,7 +15,6 @@ import { difyBuilderConversationHasMoreAtom, difyBuilderSessionBusyAtom, difyBuilderSessionErrorCodeAtom, - difyBuilderSessionLastCanvasEventAtom, difyBuilderSessionLastErrorAtom, difyBuilderSessionViewAtom, } from '../state' @@ -232,7 +231,16 @@ describe('useDifyBuilderSessionController lifecycle', () => { stateEvent(terminal), ), ) - const { result, store } = renderSessionHook() + const onCanvasEvent = vi.fn() + const { result, store } = renderSessionHook(undefined, { + onCanvasEvent, + onWorkflowEvent: vi.fn(), + onStreamInterrupted: vi.fn(), + restoreRun: vi.fn(), + finishCommand: vi.fn(), + reset: vi.fn(), + onCanvasRefreshed: vi.fn(), + }) await act(async () => { expect(await result.current.startFix('app-1', 'run-1')).toBe(true) @@ -248,17 +256,15 @@ describe('useDifyBuilderSessionController lifecycle', () => { }, { context: { silent: true }, signal: expect.any(AbortSignal) }, ) - expect(store.get(difyBuilderSessionLastCanvasEventAtom)).toEqual({ - id: 2, - data: { - kind: 'canvas', - session_id: 'session-1', - operation_id: 'operation-2', - stage_id: 'fix.apply', - at_version: 3, - revision: 1, - event: 'focus_workflow', - }, + expect(onCanvasEvent).toHaveBeenCalledTimes(2) + expect(onCanvasEvent).toHaveBeenLastCalledWith({ + kind: 'canvas', + session_id: 'session-1', + operation_id: 'operation-2', + stage_id: 'fix.apply', + at_version: 3, + revision: 1, + event: 'focus_workflow', }) expect(store.get(difyBuilderSessionViewAtom)).toEqual(terminal) expect(store.get(difyBuilderConversationAtom)).toEqual([notice]) diff --git a/web/app/components/workflow-app/components/dify-builder/session/__tests__/use-session-controller.streaming.spec.tsx b/web/app/components/workflow-app/components/dify-builder/session/__tests__/use-session-controller.streaming.spec.tsx index 1e4e6ecac35..632d0da9599 100644 --- a/web/app/components/workflow-app/components/dify-builder/session/__tests__/use-session-controller.streaming.spec.tsx +++ b/web/app/components/workflow-app/components/dify-builder/session/__tests__/use-session-controller.streaming.spec.tsx @@ -1,5 +1,6 @@ import type { ConversationItem } from '../../types' import { act, waitFor } from '@testing-library/react' +import { nodeStarted, workflowEvent } from '../../__tests__/workflow-fixtures' import { difyBuilderActiveSessionIdAtom, difyBuilderConversationAtom, @@ -7,7 +8,6 @@ import { difyBuilderReasoningAtom, difyBuilderRetryableMessageAtom, difyBuilderSessionBusyAtom, - difyBuilderSessionLastCanvasEventAtom, difyBuilderSessionLastErrorAtom, difyBuilderSessionViewAtom, difyBuilderStreamingTurnAtom, @@ -430,7 +430,6 @@ describe('useDifyBuilderSessionController streaming', () => { }) expect(store.get(difyBuilderSessionBusyAtom)).toBe(false) expect(store.get(difyBuilderSessionLastErrorAtom)).toBe('') - expect(store.get(difyBuilderSessionLastCanvasEventAtom)).toBeNull() }) it('projects authoritative progress snapshots without merging raw node events', async () => { @@ -462,19 +461,18 @@ describe('useDifyBuilderSessionController streaming', () => { act(() => { stream.push({ - event: 'node', - data: { - kind: 'node', - session_id: 'session-1', - operation_id: 'other-operation', - stage_id: 'fix.verify', - at_version: 2, - revision: 1, - node_id: 'stale-node', - title: 'Stale node', - status: 'running', - error: '', - }, + event: 'workflow', + data: workflowEvent( + { + ...nodeStarted(), + data: { ...nodeStarted().data, node_id: 'stale-node', title: 'Stale node' }, + }, + { + operation_id: 'other-operation', + stage_id: 'fix.verify', + revision: 1, + }, + ), }) stream.push( progressEvent({ @@ -499,19 +497,17 @@ describe('useDifyBuilderSessionController streaming', () => { act(() => { stream.push({ - event: 'node', - data: { - kind: 'node', - session_id: 'session-1', - operation_id: 'operation-1', - stage_id: 'fix.verify', - at_version: 2, - revision: 2, - node_id: 'llm-node', - title: 'Generate answer', - status: 'running', - error: '', - }, + event: 'workflow', + data: workflowEvent( + { + ...nodeStarted(), + data: { ...nodeStarted().data, node_id: 'llm-node', title: 'Generate answer' }, + }, + { + stage_id: 'fix.verify', + revision: 2, + }, + ), }) }) await waitFor(() => expect(store.get(difyBuilderExecutionProgressAtom)?.revision).toBe(2)) diff --git a/web/app/components/workflow-app/components/dify-builder/session/state.ts b/web/app/components/workflow-app/components/dify-builder/session/state.ts index 0de647742df..14b7073e4a9 100644 --- a/web/app/components/workflow-app/components/dify-builder/session/state.ts +++ b/web/app/components/workflow-app/components/dify-builder/session/state.ts @@ -1,6 +1,5 @@ import type { BuilderErrorCode } from '@dify/contracts/api/console/dify-builder/types.gen' import type { - CanvasEventData, ConversationItem, DifyBuilderExecutionProgress, DifyBuilderReasoning, @@ -30,10 +29,6 @@ export const difyBuilderRetryableMessageAtom = atom(null) export const difyBuilderReasoningAtom = atom(null) export const difyBuilderStreamingTurnAtom = atom(null) -export const difyBuilderSessionLastCanvasEventAtom = atom<{ - id: number - data: CanvasEventData -} | null>(null) export const difyBuilderSessionScopedAtoms = [ difyBuilderSessionViewAtom, @@ -48,5 +43,4 @@ export const difyBuilderSessionScopedAtoms = [ difyBuilderExecutionProgressAtom, difyBuilderReasoningAtom, difyBuilderStreamingTurnAtom, - difyBuilderSessionLastCanvasEventAtom, ] as const diff --git a/web/app/components/workflow-app/components/dify-builder/session/types.ts b/web/app/components/workflow-app/components/dify-builder/session/types.ts index 98f94c65246..b5aa3696763 100644 --- a/web/app/components/workflow-app/components/dify-builder/session/types.ts +++ b/web/app/components/workflow-app/components/dify-builder/session/types.ts @@ -1,7 +1,20 @@ import type { + CanvasEventData, DifyBuilderStreamEventResponse, + DifyBuilderWorkflowEventData, RunStatus, } from '@dify/contracts/api/console/dify-builder/types.gen' +import type { ConversationItem } from '../types' + +export type SessionRunEvents = { + onWorkflowEvent: (event: DifyBuilderWorkflowEventData) => void + onStreamInterrupted: () => void + onCanvasEvent: (event: CanvasEventData) => void + restoreRun: (sessionId: string, items: ConversationItem[]) => void + finishCommand: () => void + reset: () => void + onCanvasRefreshed: () => void +} export type SessionCommandOptions = { openStream: (signal: AbortSignal) => Promise> diff --git a/web/app/components/workflow-app/components/dify-builder/session/use-session-controller.ts b/web/app/components/workflow-app/components/dify-builder/session/use-session-controller.ts index a40c35f896f..acc6807b3d8 100644 --- a/web/app/components/workflow-app/components/dify-builder/session/use-session-controller.ts +++ b/web/app/components/workflow-app/components/dify-builder/session/use-session-controller.ts @@ -11,13 +11,14 @@ import type { SessionModel, SessionView, } from '../types' -import type { SessionCommandOptions, SessionStreamOutcome } from './types' +import type { SessionCommandOptions, SessionRunEvents, SessionStreamOutcome } from './types' import { useSetAtom, useStore } from 'jotai' import { queryClientAtom } from 'jotai-tanstack-query' -import { useCallback, useEffect, useMemo, useRef } from 'react' +import { useCallback, useEffect, useEffectEvent, useMemo, useRef } from 'react' import { ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations' import { consoleQuery } from '@/service/console' import { commonQueryKeys } from '@/service/use-common' +import { difyBuilderCanvasReadyAtom } from '../store' import { createBuildSession, createChecklistFixSession, @@ -45,7 +46,6 @@ import { difyBuilderRetryableMessageAtom, difyBuilderSessionBusyAtom, difyBuilderSessionErrorCodeAtom, - difyBuilderSessionLastCanvasEventAtom, difyBuilderSessionLastErrorAtom, difyBuilderSessionViewAtom, } from './state' @@ -66,6 +66,7 @@ const isActiveView = (view: SessionView) => isActiveRunStatus(view.run_status) & */ export function useDifyBuilderSessionController( prepareCommand?: (saveDraft: boolean, signal: AbortSignal) => Promise, + runEvents?: SessionRunEvents, ): DifyBuilderSessionController { const store = useStore() const setActiveSessionId = useSetAtom(difyBuilderActiveSessionIdAtom) @@ -75,26 +76,34 @@ export function useDifyBuilderSessionController( const setRetryableMessage = useSetAtom(difyBuilderRetryableMessageAtom) const setView = useSetAtom(difyBuilderSessionViewAtom) const setLastError = useSetAtom(difyBuilderSessionLastErrorAtom) - const setLastCanvasEvent = useSetAtom(difyBuilderSessionLastCanvasEventAtom) const setIsBusy = useSetAtom(difyBuilderSessionBusyAtom) const executionProgress = useDifyBuilderExecutionProgress() const reasoningBuffer = useDifyBuilderReasoningBuffer() const streamingTurnBuffer = useDifyBuilderStreamingTurnBuffer() const abortRef = useRef(null) - const canvasEventIdRef = useRef(0) const canvasCursorRef = useRef< Pick | undefined >(undefined) const pendingMessageRef = useRef<{ sessionId: string; text: string; turnId: string } | null>(null) + const pendingRetestRef = useRef<{ + sessionId: string + version: number + appRevision: string + state: string + actionId: string + } | null>(null) const traceRef = useRef(createTraceBuffer()) + const resetRunOnUnmount = useEffectEvent(() => runEvents?.reset()) useEffect(() => { return () => { abortRef.current?.abort() abortRef.current = null + pendingRetestRef.current = null // Clearing the request ref prevents its finally block from releasing loading state. setIsBusy(false) setConversationLoading(false) + resetRunOnUnmount() } }, [setConversationLoading, setIsBusy]) @@ -168,9 +177,10 @@ export function useDifyBuilderSessionController( setConversation(items) if (!page.has_more) break } + if (lastSeq >= targetLastSeq) runEvents?.restoreRun(sessionId, items) return lastSeq >= targetLastSeq }, - [setConversation, setConversationHasMore, store], + [runEvents, setConversation, setConversationHasMore, store], ) const applyCommit = useCallback( @@ -194,7 +204,11 @@ export function useDifyBuilderSessionController( return hasGap }) setView(projected) - if (!hasConversationGap) setConversation(mergeConversation(conversation, commit.items)) + if (!hasConversationGap) { + const items = mergeConversation(conversation, commit.items) + setConversation(items) + runEvents?.restoreRun(commit.session_id, items) + } if ( canvasCursorRef.current?.session_id === commit.session_id && canvasCursorRef.current.at_version <= commit.version @@ -205,7 +219,15 @@ export function useDifyBuilderSessionController( streamingTurnBuffer.clearThroughVersion(commit.session_id, commit.version) return hasConversationGap ? newSequences.at(-1) : undefined }, - [executionProgress, reasoningBuffer, setConversation, setView, store, streamingTurnBuffer], + [ + executionProgress, + reasoningBuffer, + runEvents, + setConversation, + setView, + store, + streamingTurnBuffer, + ], ) const clearSession = useCallback( @@ -218,10 +240,12 @@ export function useDifyBuilderSessionController( executionProgress.clear() reasoningBuffer.clear() streamingTurnBuffer.clear() + runEvents?.reset() }, [ executionProgress, reasoningBuffer, + runEvents, setActiveSessionId, setConversation, setConversationHasMore, @@ -281,7 +305,7 @@ export function useDifyBuilderSessionController( return false } - if (event.event === 'canvas') { + if (event.event === 'canvas' || event.event === 'workflow') { const view = store.get(difyBuilderSessionViewAtom) const cursor = canvasCursorRef.current if ( @@ -297,12 +321,11 @@ export function useDifyBuilderSessionController( return false canvasCursorRef.current = event.data - canvasEventIdRef.current += 1 - setLastCanvasEvent({ id: canvasEventIdRef.current, data: event.data }) - return false - } - - if (event.event === 'node') { + if (event.event === 'canvas') { + runEvents?.onCanvasEvent(event.data) + } else { + runEvents?.onWorkflowEvent(event.data) + } return false } @@ -385,6 +408,7 @@ export function useDifyBuilderSessionController( } } + if (!controller.signal.aborted && !outcome.terminalEvent) runEvents?.onStreamInterrupted() return outcome }, [ @@ -392,7 +416,7 @@ export function useDifyBuilderSessionController( applySessionView, executionProgress, reasoningBuffer, - setLastCanvasEvent, + runEvents, setLastError, store, syncConversation, @@ -464,6 +488,7 @@ export function useDifyBuilderSessionController( ? startingView.version : undefined abortRef.current?.abort() + pendingRetestRef.current = null executionProgress.clear() reasoningBuffer.clear() streamingTurnBuffer.clear() @@ -487,7 +512,7 @@ export function useDifyBuilderSessionController( setConversationHasMore(false) setConversationLoading(false) setRetryableMessage(null) - setLastCanvasEvent(null) + runEvents?.reset() canvasCursorRef.current = undefined pendingMessageRef.current = null // A new session boundary must start with a fresh trace buffer; clear @@ -599,6 +624,7 @@ export function useDifyBuilderSessionController( } finally { if (abortRef.current === controller) { abortRef.current = null + runEvents?.finishCommand() setIsBusy(false) } } @@ -609,11 +635,11 @@ export function useDifyBuilderSessionController( executionProgress, reasoningBuffer, reconcileSession, + runEvents, setActiveSessionId, setConversation, setConversationHasMore, setIsBusy, - setLastCanvasEvent, setLastError, setRetryableMessage, setConversationLoading, @@ -701,6 +727,7 @@ export function useDifyBuilderSessionController( if (!normalizedSessionId || store.get(difyBuilderSessionBusyAtom)) return false abortRef.current?.abort() + pendingRetestRef.current = null executionProgress.clear() reasoningBuffer.clear() streamingTurnBuffer.clear() @@ -717,6 +744,8 @@ export function useDifyBuilderSessionController( await preparation if (controller.signal.aborted) return false setActiveSessionId(normalizedSessionId) + if (store.get(difyBuilderSessionViewAtom)?.session_id !== normalizedSessionId) + runEvents?.reset() const outcome = await reconcileSession(normalizedSessionId, controller, true) if (controller.signal.aborted) return false if (outcome?.terminalEvent === 'state') return outcome.stateApplied === true @@ -739,6 +768,7 @@ export function useDifyBuilderSessionController( } finally { if (abortRef.current === controller) { abortRef.current = null + runEvents?.finishCommand() setIsBusy(false) } } @@ -749,6 +779,7 @@ export function useDifyBuilderSessionController( executionProgress, reasoningBuffer, reconcileSession, + runEvents, setActiveSessionId, setIsBusy, setLastError, @@ -786,7 +817,9 @@ export function useDifyBuilderSessionController( store.get(difyBuilderSessionViewAtom)?.session_id !== view.session_id ) return false - setConversation((current) => mergeConversation(page.data, current)) + const merged = mergeConversation(page.data, store.get(difyBuilderConversationAtom)) + setConversation(merged) + runEvents?.restoreRun(view.session_id, merged) setConversationHasMore(page.has_more) return page.data.length > 0 } catch (error) { @@ -797,7 +830,14 @@ export function useDifyBuilderSessionController( if (abortRef.current === controller) abortRef.current = null setConversationLoading(false) } - }, [setConversation, setConversationHasMore, setConversationLoading, setLastError, store]) + }, [ + runEvents, + setConversation, + setConversationHasMore, + setConversationLoading, + setLastError, + store, + ]) const refresh = useCallback(() => { const sessionId = @@ -805,11 +845,62 @@ export function useDifyBuilderSessionController( return sessionId ? restore(sessionId) : Promise.resolve(false) }, [restore, store]) + const continueRetest = useCallback(() => { + const pending = pendingRetestRef.current + if (!pending) return + const view = store.get(difyBuilderSessionViewAtom) + if ( + !view || + view.session_id !== pending.sessionId || + view.version !== pending.version || + view.state !== pending.state || + view.app_revision?.current !== pending.appRevision || + view.app_revision.conflicted + ) { + pendingRetestRef.current = null + return + } + if (store.get(difyBuilderSessionBusyAtom) || !store.get(difyBuilderCanvasReadyAtom)) return + // Consume before dispatch: duplicate refresh callbacks cannot start a second run. + pendingRetestRef.current = null + void runCommand({ + knownSessionId: pending.sessionId, + expectTerminalEvent: true, + openStream: (signal) => + runSessionAction( + pending.sessionId, + pending.actionId, + {}, + pending.version, + pending.appRevision, + signal, + ), + trace: { + kind: 'action', + payload: { action_id: pending.actionId, payload: {}, base_version: pending.version }, + }, + }) + }, [runCommand, store]) + + const onCanvasRefreshed = useCallback(() => { + runEvents?.onCanvasRefreshed() + continueRetest() + }, [continueRetest, runEvents]) + const runAction = useCallback( - (actionId: string, payload: Record = {}) => { + async (actionId: string, payload: Record = {}) => { const view = store.get(difyBuilderSessionViewAtom) - if (!view || store.get(difyBuilderSessionBusyAtom)) return Promise.resolve(false) - return runCommand({ + if (!view || store.get(difyBuilderSessionBusyAtom)) return false + const selectedAction = actionId === 'confirm' ? payload.option_id : actionId + const approvesRepair = + selectedAction === 'approve_plan' || selectedAction === 'approve_repair' + const retest = + approvesRepair && view.state === 'build.await_repair' + ? { state: 'build.execution', actionId: 'run_test' } + : approvesRepair && view.state === 'edit.await_repair' + ? { state: 'edit.apply_changes', actionId: 'run_affected_tests' } + : null + const succeeded = await runCommand({ knownSessionId: view.session_id, expectTerminalEvent: actionId !== 'update_model', openStream: (signal) => @@ -826,8 +917,27 @@ export function useDifyBuilderSessionController( payload: { action_id: actionId, payload, base_version: view.version }, }, }) + const applied = store.get(difyBuilderSessionViewAtom) + if ( + succeeded && + retest && + applied?.session_id === view.session_id && + applied.version === view.version + 1 && + applied.state === retest.state && + applied.app_revision + ) { + pendingRetestRef.current = { + ...retest, + sessionId: applied.session_id, + version: applied.version, + appRevision: applied.app_revision.current, + } + // Also handles a refresh that completed before this command promise resumed. + continueRetest() + } + return succeeded }, - [runCommand, store], + [continueRetest, runCommand, store], ) const sendMessage = useCallback( @@ -890,6 +1000,7 @@ export function useDifyBuilderSessionController( const reset = useCallback(() => { abortRef.current?.abort() abortRef.current = null + pendingRetestRef.current = null executionProgress.clear() reasoningBuffer.clear() streamingTurnBuffer.clear() @@ -902,19 +1013,19 @@ export function useDifyBuilderSessionController( setView(null) setLastError('') store.set(difyBuilderSessionErrorCodeAtom, null) - setLastCanvasEvent(null) + runEvents?.reset() canvasCursorRef.current = undefined traceRef.current.clear() setIsBusy(false) }, [ executionProgress, reasoningBuffer, + runEvents, setActiveSessionId, setConversation, setConversationHasMore, setConversationLoading, setIsBusy, - setLastCanvasEvent, setLastError, setRetryableMessage, setView, @@ -938,6 +1049,7 @@ export function useDifyBuilderSessionController( updateModel, reset, getTrace, + onCanvasRefreshed, }), [ refresh, @@ -952,6 +1064,7 @@ export function useDifyBuilderSessionController( startFix, updateModel, getTrace, + onCanvasRefreshed, ], ) } diff --git a/web/app/components/workflow-app/components/dify-builder/store.ts b/web/app/components/workflow-app/components/dify-builder/store.ts index 1463fca8ec0..e5ce28561ca 100644 --- a/web/app/components/workflow-app/components/dify-builder/store.ts +++ b/web/app/components/workflow-app/components/dify-builder/store.ts @@ -47,11 +47,16 @@ export const difyBuilderSelectedModelAtom = atom(null) export const difyBuilderDraftAtom = atom('') export const difyBuilderDeriveAppNameAtom = atom(false) export const difyBuilderLocalErrorAtom = atom('') +export const difyBuilderRunRestoreErrorAtom = atom('') export const difyBuilderChecklistErrorsAtom = atom([]) export const difyBuilderCanvasRefreshGenerationAtom = atom(0) export const difyBuilderCanvasRefreshingAtom = atom(false) export const difyBuilderCanvasRefreshFailedAtom = atom(false) export const difyBuilderCanvasRefreshRetryRequestAtom = atom(0) +export const difyBuilderCanvasPendingRefreshAtom = atom<{ + sessionId: string + focusNodeId?: string +} | null>(null) export const difyBuilderCanvasAppliedViewAtom = atom<{ sessionId: string; version: number } | null>( null, ) @@ -63,11 +68,13 @@ export const difyBuilderScopedAtoms = [ difyBuilderDraftAtom, difyBuilderDeriveAppNameAtom, difyBuilderLocalErrorAtom, + difyBuilderRunRestoreErrorAtom, difyBuilderChecklistErrorsAtom, difyBuilderCanvasRefreshGenerationAtom, difyBuilderCanvasRefreshingAtom, difyBuilderCanvasRefreshFailedAtom, difyBuilderCanvasRefreshRetryRequestAtom, + difyBuilderCanvasPendingRefreshAtom, difyBuilderCanvasAppliedViewAtom, difyBuilderChecklistEvaluatedGenerationAtom, ] as const @@ -108,6 +115,7 @@ export const DIFY_BUILDER_CANVAS_REFRESH_PHASES = new Set([ export const difyBuilderCanvasReadyAtom = atom((get) => { if (get(difyBuilderCanvasRefreshingAtom) || get(difyBuilderCanvasRefreshFailedAtom)) return false const view = get(difyBuilderSessionViewAtom) + if (view && get(difyBuilderCanvasPendingRefreshAtom)?.sessionId === view.session_id) return false if (!view?.phase || !DIFY_BUILDER_CANVAS_REFRESH_PHASES.has(view.phase)) return true const applied = get(difyBuilderCanvasAppliedViewAtom) return applied?.sessionId === view.session_id && applied.version === view.version @@ -169,7 +177,8 @@ export const difyBuilderErrorAtom = atom( get(difyBuilderLocalErrorAtom) || (get(difyBuilderSessionErrorCodeAtom) === 'model_unavailable' ? '' - : get(difyBuilderSessionLastErrorAtom)), + : get(difyBuilderSessionLastErrorAtom)) || + get(difyBuilderRunRestoreErrorAtom), ) export const difyBuilderCanStartFixAtom = atom((get) => { const runtime = get(difyBuilderRuntimeAtom) diff --git a/web/app/components/workflow-app/components/dify-builder/types.ts b/web/app/components/workflow-app/components/dify-builder/types.ts index b90fa2ad3c1..b0d88770db3 100644 --- a/web/app/components/workflow-app/components/dify-builder/types.ts +++ b/web/app/components/workflow-app/components/dify-builder/types.ts @@ -73,6 +73,7 @@ export type DifyBuilderSessionController = { updateModel: (modelConfig: SessionModel) => Promise reset: () => void getTrace: () => TraceSnapshot + onCanvasRefreshed: () => void } export type DifyBuilderActionPayloadChange = ( diff --git a/web/app/components/workflow/header/checklist/__tests__/index.spec.tsx b/web/app/components/workflow/header/checklist/__tests__/index.spec.tsx index cffe1972daa..b60dcddd0d6 100644 --- a/web/app/components/workflow/header/checklist/__tests__/index.spec.tsx +++ b/web/app/components/workflow/header/checklist/__tests__/index.spec.tsx @@ -110,6 +110,7 @@ const renderChecklist = ( enabled, getCanvasSnapshot: () => ({ nodes: [], edgeCount: 0 }), session: { + onCanvasRefreshed: vi.fn(), getTrace: vi.fn(() => ({ entries: [], truncated: false })), loadOlderConversation: vi.fn(async () => true), refresh: vi.fn(async () => true), diff --git a/web/app/components/workflow/hooks/use-workflow-run-event/use-workflow-agent-log.ts b/web/app/components/workflow/hooks/use-workflow-run-event/use-workflow-agent-log.ts index 336af598fda..0d3c9de7533 100644 --- a/web/app/components/workflow/hooks/use-workflow-run-event/use-workflow-agent-log.ts +++ b/web/app/components/workflow/hooks/use-workflow-run-event/use-workflow-agent-log.ts @@ -1,4 +1,4 @@ -import type { AgentLogResponse } from '@/types/workflow' +import type { AgentLogResponse, NodeTracing } from '@/types/workflow' import { produce } from 'immer' import { useCallback } from 'react' import { useWorkflowStore } from '@/app/components/workflow/store' @@ -8,7 +8,8 @@ export const useWorkflowAgentLog = () => { const handleWorkflowAgentLog = useCallback( (params: AgentLogResponse) => { - const { data } = params + // The native stream uses id; the shared log panel uses message_id. + const data = { ...params.data, message_id: params.data.id ?? params.data.message_id } const { workflowRunningData, setWorkflowRunningData } = workflowStore.getState() setWorkflowRunningData( @@ -20,7 +21,7 @@ export const useWorkflowAgentLog = () => { if (current!.execution_metadata) { if (current!.execution_metadata.agent_log) { const currentLogIndex = current!.execution_metadata.agent_log.findIndex( - (log) => log.message_id === data.message_id, + (log) => (log.id ?? log.message_id) === (data.id ?? data.message_id), ) if (currentLogIndex > -1) { current!.execution_metadata.agent_log[currentLogIndex] = { @@ -36,7 +37,7 @@ export const useWorkflowAgentLog = () => { } else { current!.execution_metadata = { agent_log: [data], - } as any + } as unknown as NodeTracing['execution_metadata'] } } }), diff --git a/web/app/components/workflow/hooks/use-workflow-run-event/use-workflow-text-chunk.ts b/web/app/components/workflow/hooks/use-workflow-run-event/use-workflow-text-chunk.ts index c8903078619..8ed4e9d8243 100644 --- a/web/app/components/workflow/hooks/use-workflow-run-event/use-workflow-text-chunk.ts +++ b/web/app/components/workflow/hooks/use-workflow-run-event/use-workflow-text-chunk.ts @@ -7,10 +7,10 @@ export const useWorkflowTextChunk = () => { const workflowStore = useWorkflowStore() const handleWorkflowTextChunk = useCallback( - (params: TextChunkResponse) => { - const { - data: { text, from_variable_selector }, - } = params + (params: TextChunkResponse | { answer: string; from_variable_selector?: string[] }) => { + const text = 'answer' in params ? params.answer : params.data.text + const from_variable_selector = + 'answer' in params ? params.from_variable_selector : params.data.from_variable_selector const { workflowRunningData, setWorkflowRunningData } = workflowStore.getState() const nextSelectorKey = from_variable_selector?.join('.') diff --git a/web/app/components/workflow/hooks/use-workflow-run-event/use-workflow-text-replace.ts b/web/app/components/workflow/hooks/use-workflow-run-event/use-workflow-text-replace.ts index a6b221de7be..76ee73b432e 100644 --- a/web/app/components/workflow/hooks/use-workflow-run-event/use-workflow-text-replace.ts +++ b/web/app/components/workflow/hooks/use-workflow-run-event/use-workflow-text-replace.ts @@ -7,10 +7,8 @@ export const useWorkflowTextReplace = () => { const workflowStore = useWorkflowStore() const handleWorkflowTextReplace = useCallback( - (params: TextReplaceResponse) => { - const { - data: { text }, - } = params + (params: TextReplaceResponse | { answer: string }) => { + const text = 'answer' in params ? params.answer : params.data.text const { workflowRunningData, setWorkflowRunningData } = workflowStore.getState() setWorkflowRunningData( produce(workflowRunningData!, (draft) => { diff --git a/web/types/workflow.ts b/web/types/workflow.ts index ecafd1c1c79..3d247e2f3ae 100644 --- a/web/types/workflow.ts +++ b/web/types/workflow.ts @@ -19,6 +19,7 @@ import type { RAGPipelineVariables } from '@/models/pipeline' import type { AppModeEnum, TransferMethod } from '@/types/app' export type AgentLogItem = { + id?: string node_execution_id: string message_id: string node_id: string