From d5ca5ea8ef010bb257468c39df54cbd52bf0cf92 Mon Sep 17 00:00:00 2001 From: twwu Date: Mon, 21 Sep 2026 14:44:27 +0800 Subject: [PATCH] fix(dify-builder): stream native test runs after canvas refresh Run workflow streaming inside the Builder worker to avoid queue deadlocks. Forward native events to shared editor callbacks and wait for graph refresh before retesting repairs. Keep command cleanup stable, preserve batched canvas events, and allow failed or unfinished historical runs to refresh again. --- .../console/dify_builder_fields.py | 20 +- api/core/dify_builder/contract.py | 12 +- api/core/dify_builder/handlers_build.py | 11 +- api/core/dify_builder/handlers_edit.py | 25 +- api/core/dify_builder/handlers_fix.py | 4 +- api/core/dify_builder/ports.py | 12 +- api/core/dify_builder/runner.py | 10 +- api/core/dify_builder/strings.py | 2 +- api/fields/workflow_stream_fields.py | 108 ++ api/services/app_generate_service.py | 19 +- api/services/dify_builder/dify_port.py | 75 +- api/services/dify_builder/service.py | 6 +- api/services/dify_builder/wiring.py | 4 +- api/tasks/dify_builder_advance_task.py | 35 +- .../unit_tests/core/dify_builder/fakes.py | 22 +- .../core/dify_builder/test_contract.py | 22 +- .../core/dify_builder/test_handlers_build.py | 4 +- .../core/dify_builder/test_handlers_edit.py | 4 +- .../core/dify_builder/test_workflow_events.py | 131 +++ .../dify_builder/test_advance_task.py | 19 +- .../services/dify_builder/test_async_flow.py | 19 +- .../dify_builder/test_contract_stream.py | 28 + .../services/dify_builder/test_dify_port.py | 77 +- .../dify_builder/test_engine_on_sql_repo.py | 2 +- .../dify_builder/test_progress_bus.py | 2 +- .../dify_builder/test_stream_frames.py | 19 +- .../dify_builder/workflow_stream_fixtures.py | 115 +++ .../test_app_generate_service_in_process.py | 85 ++ ..._generate_service_streaming_integration.py | 6 + oxlint-suppressions.json | 5 - .../api/console/dify-builder/types.gen.ts | 759 +++++++++++++- .../api/console/dify-builder/zod.gen.ts | 972 +++++++++++++++++- web/__tests__/app-builder-creation.spec.tsx | 31 +- .../__tests__/workflow-panel.spec.tsx | 1 + .../components/dify-builder/README.md | 33 +- .../__tests__/canvas-sync.spec.tsx | 189 ++++ .../dify-builder/__tests__/flows.spec.tsx | 11 + .../__tests__/panel.browser.spec.tsx | 1 + .../dify-builder/__tests__/panel.spec.tsx | 1 + .../dify-builder/__tests__/provider.spec.tsx | 68 +- .../__tests__/retest-canvas.spec.tsx | 233 +++++ .../dify-builder/__tests__/store.spec.ts | 1 + .../__tests__/use-run-events.spec.tsx | 767 ++++++++++++++ .../__tests__/workflow-fixtures.ts | 83 ++ .../dify-builder/provider/canvas-sync.tsx | 102 +- .../dify-builder/provider/index.tsx | 23 +- .../dify-builder/provider/run-projection.ts | 26 + .../provider/use-canvas-events.ts | 56 + .../dify-builder/provider/use-run-events.ts | 369 +++++++ .../provider/use-workflow-event-dispatch.ts | 156 +++ .../session/__tests__/fixtures.tsx | 3 +- .../use-session-controller.runs.spec.tsx | 288 ++++++ .../__tests__/use-session-controller.spec.tsx | 32 +- .../use-session-controller.streaming.spec.tsx | 52 +- .../components/dify-builder/session/state.ts | 6 - .../components/dify-builder/session/types.ts | 13 + .../session/use-session-controller.ts | 165 ++- .../components/dify-builder/store.ts | 11 +- .../components/dify-builder/types.ts | 1 + .../header/checklist/__tests__/index.spec.tsx | 1 + .../use-workflow-agent-log.ts | 9 +- .../use-workflow-text-chunk.ts | 8 +- .../use-workflow-text-replace.ts | 6 +- web/types/workflow.ts | 1 + 64 files changed, 4966 insertions(+), 415 deletions(-) create mode 100644 api/fields/workflow_stream_fields.py create mode 100644 api/tests/unit_tests/core/dify_builder/test_workflow_events.py create mode 100644 api/tests/unit_tests/services/dify_builder/workflow_stream_fixtures.py create mode 100644 api/tests/unit_tests/services/test_app_generate_service_in_process.py create mode 100644 web/app/components/workflow-app/components/dify-builder/__tests__/canvas-sync.spec.tsx create mode 100644 web/app/components/workflow-app/components/dify-builder/__tests__/retest-canvas.spec.tsx create mode 100644 web/app/components/workflow-app/components/dify-builder/__tests__/use-run-events.spec.tsx create mode 100644 web/app/components/workflow-app/components/dify-builder/__tests__/workflow-fixtures.ts create mode 100644 web/app/components/workflow-app/components/dify-builder/provider/run-projection.ts create mode 100644 web/app/components/workflow-app/components/dify-builder/provider/use-canvas-events.ts create mode 100644 web/app/components/workflow-app/components/dify-builder/provider/use-run-events.ts create mode 100644 web/app/components/workflow-app/components/dify-builder/provider/use-workflow-event-dispatch.ts create mode 100644 web/app/components/workflow-app/components/dify-builder/session/__tests__/use-session-controller.runs.spec.tsx 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