Files
dify/api/services/dify_builder/wiring.py
T
Charles YaoandClaude Opus 5 65a47018b4 feat(dify-builder): say the app's name once when the build lands
Spec N4 has the build-complete card headline read "<App name> is ready", with
the annotation 名字只在回复句里念一次;记录卡上不出现 -- said once, in that
sentence, and never repeated on the completion receipt. We said it nowhere: the
card was titled "Build plan" and the engine had no app name to say.

DifyBuilderContext now carries app_name, seeded at session creation through a
get_app_name_fn seam alongside the existing revision/authorize ones. Env.rename_app
returns the name the database actually took rather than None, so the refine
records what landed instead of what it proposed -- the service normalizes after
the engine hands off, and the two can differ.

The headline is interpolated, so it gets a strings.py template or it would
survive localization untranslated. That pattern matches almost anything ending
in "is ready", so it sits last and every more specific template gets first
refusal.

The receipt already omitted the name; there is now a test pinning that.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 18:09:23 -07:00

231 lines
9.1 KiB
Python

"""HTTP-facing wiring for the Dify Builder: assemble the usecase with real
dependencies (SQL repo + cross-process lock + Celery enqueue) and the pure
serialize / error-map helpers the console controller uses. Kept out of the
controller module so it is unit-testable without the Flask request stack.
"""
import dataclasses
import json
import time
from collections.abc import Generator, Iterator
from sqlalchemy import select
from sqlalchemy.orm import sessionmaker
from werkzeug.exceptions import Forbidden
from configs import dify_config
from controllers.common.rbac import PlainApp, RBACCheck, RBACPermission, enforce_rbac_checks
from core.dify_builder.errors import BadRequestError, BusyError, ConflictError, ModelUnavailableError, NotFoundError
from core.dify_builder.models import Action, Actor
from extensions.ext_database import db
from libs.broadcast_channel.exc import SubscriptionClosedError
from models import App, TenantAccountJoin, TenantAccountRole
from services.dify_builder import app_naming, progress_bus, session_lock
from services.dify_builder.dify_port import WorkflowServiceDifyPort
from services.dify_builder.repository import SqlDifyBuilderRepository
from services.dify_builder.service import AppAccess, DifyBuilderService, SessionView
from tasks.dify_builder_advance_task import advance_session
__all__ = [
"build_service",
"dify_builder_error_response",
"session_view_to_dict",
"stream_advance_frames",
]
_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})
def _enqueue(session_id: str, action: Action, actor: Actor, token: str) -> None:
advance_session.delay(session_id, dataclasses.asdict(action), dataclasses.asdict(actor), token)
def _authorize_app(actor: Actor, app_id: str, access: AppAccess) -> None:
"""Resolve a tenant-owned normal App, then enforce Builder permissions.
Legacy workspaces use the same owner/admin/editor rule as
``edit_permission_required``. RBAC workspaces always require APP_EDIT;
test/run and release operations additionally require their dedicated
permission point, matching the existing workflow run/publish routes.
"""
trusted_app_id = db.session.scalar(
select(App.id).where(App.id == app_id, App.tenant_id == actor.tenant_id, App.status == "normal")
)
if trusted_app_id is None:
raise NotFoundError("app not found")
role = db.session.scalar(
select(TenantAccountJoin.role).where(
TenantAccountJoin.tenant_id == actor.tenant_id,
TenantAccountJoin.account_id == actor.account_id,
)
)
if role is None or (not dify_config.RBAC_ENABLED and not TenantAccountRole.is_editing_role(role)):
raise Forbidden()
scenes = [RBACPermission.APP_EDIT]
if access == AppAccess.TEST_AND_RUN:
scenes.append(RBACPermission.APP_TEST_AND_RUN)
elif access == AppAccess.RELEASE:
scenes.append(RBACPermission.APP_RELEASE_AND_VERSION)
for scene in scenes:
enforce_rbac_checks(
tenant_id=actor.tenant_id,
account_id=actor.account_id,
checks=[RBACCheck(scene, PlainApp())],
path_args={"app_id": str(trusted_app_id)},
)
def _get_app_revision(app_id: str, actor: Actor) -> str:
_graph, revision = WorkflowServiceDifyPort().read_graph(app_id, actor)
return revision
def _get_app_name(app_id: str, actor: Actor) -> str:
return app_naming.current_app_name(app_id=app_id, tenant_id=actor.tenant_id)
def build_service() -> DifyBuilderService:
repo = SqlDifyBuilderRepository(sessionmaker(bind=db.engine, expire_on_commit=False))
return DifyBuilderService(
repo,
session_lock,
_enqueue,
subscribe_fn=progress_bus.subscribe,
authorize_app_fn=_authorize_app,
get_app_revision_fn=_get_app_revision,
get_app_name_fn=_get_app_name,
)
def session_view_to_dict(view: SessionView) -> dict:
return dataclasses.asdict(view)
def dify_builder_error_response(exc: Exception) -> tuple[dict, int] | None:
if isinstance(exc, ModelUnavailableError):
return {"code": "model_unavailable", "message": "Builder model is unavailable", "recoverable": True}, 400
if isinstance(exc, BadRequestError):
return {"code": "bad_request"}, 400
# NotFoundError ALWAYS maps to a generic 404 regardless of message text
# (owner-mismatch must be indistinguishable from a missing session).
if isinstance(exc, NotFoundError):
return {"code": "not_found"}, 404
if isinstance(exc, ConflictError):
return {"code": "conflict"}, 409
if isinstance(exc, BusyError):
return {"code": "session_busy"}, 409
return None
def _event_frame(event: str, data: object) -> str:
"""Encode one oRPC-compatible SSE message.
The SSE protocol-level event is always ``message``. oRPC's event-iterator
decoder consumes that explicit event name; the Builder discriminant belongs
in the JSON data envelope so even Builder ``error`` events remain typed
values instead of becoming transport exceptions.
"""
return f"event: message\ndata: {json.dumps({'event': event, 'data': data})}\n\n"
def _progress_event(raw: bytes) -> tuple[str, object]:
try:
data = json.loads(raw)
except (UnicodeDecodeError, ValueError, TypeError):
return "error", {"kind": "error", "error": "invalid Builder progress event"}
if not isinstance(data, dict) or data.get("kind") not in _PROGRESS_KINDS:
return "error", {"kind": "error", "error": "invalid Builder progress event"}
return data["kind"], data
class _ClosingFrameStream:
"""Iterator over SSE frames that guarantees ``subscription`` is closed when the
WSGI server closes the response body -- INCLUDING when the body is never
iterated. A bare generator cannot: its ``finally`` (which closes the
subscription) does not run if the generator was never started, so a response
dropped before its first frame (client aborted, or an after_request/teardown
error on the streaming response) would leak the eagerly-activated pub/sub
connection + listener thread. ``close()`` here closes the subscription
directly (idempotent), so it fires regardless of iteration -- and the WSGI
spec requires the server to call ``close()`` on the response iterable even on
an early disconnect."""
def __init__(self, frames: Generator[str, None, None], subscription) -> None:
self._frames = frames
self._subscription = subscription
def __iter__(self) -> Iterator[str]:
return self
def __next__(self) -> str:
return next(self._frames)
def close(self) -> None:
try:
self._frames.close() # runs the inner generator's finally when it started
finally:
if self._subscription is not None:
self._subscription.close() # idempotent; covers the never-started case
def stream_advance_frames(
view_dict: dict,
subscription,
expect_advance: bool,
*,
emit_state_when_settled: bool = False,
) -> Iterator[str]:
"""Emit a bounded command handshake, then relay incremental progress.
The handshake and terminal state intentionally exclude conversation
history. Durable rows arrive through ``commit`` events and can be repaired
through the JSON conversation endpoint. Settle-only calls yield just the
handshake unless ``emit_state_when_settled`` is requested. The stream is
bounded by ``_MAX_STREAM_SECONDS``.
Returns a ``_ClosingFrameStream`` (not a bare generator) so the eagerly
activated ``subscription`` is closed even when the WSGI server closes the
response body without ever iterating it -- see _ClosingFrameStream."""
def _frames() -> Generator[str, None, None]:
try:
yield _event_frame("command_started", {"kind": "command_started", **view_dict})
if not expect_advance:
if emit_state_when_settled:
yield _event_frame("state", {"kind": "state", **view_dict})
return
if subscription is None:
return
deadline = time.monotonic() + _MAX_STREAM_SECONDS
while time.monotonic() < deadline:
try:
raw = subscription.receive(timeout=_HEARTBEAT_SECONDS)
except SubscriptionClosedError:
return
if raw is None:
yield ": keep-alive\n\n"
continue
kind, data = _progress_event(raw)
yield _event_frame(kind, data)
if kind in _TERMINAL_KINDS:
return
yield _event_frame(
"error",
{
"kind": "error",
"error": "Builder operation timed out",
"code": "timeout",
"recoverable": True,
},
)
finally:
if subscription is not None:
subscription.close()
return _ClosingFrameStream(_frames(), subscription)