mirror of
https://github.com/langgenius/dify.git
synced 2026-10-04 13:47:55 +08:00
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>
231 lines
9.1 KiB
Python
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)
|