mirror of
https://github.com/langgenius/dify.git
synced 2026-11-12 06:11:12 +08:00
986 lines
40 KiB
Python
986 lines
40 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 logging
|
|
import re
|
|
import time
|
|
from collections.abc import Callable, Generator, Iterator, Sequence
|
|
from typing import Any, cast
|
|
|
|
from flask import current_app
|
|
from pydantic import TypeAdapter, ValidationError
|
|
from sqlalchemy import select
|
|
from sqlalchemy.orm import sessionmaker
|
|
from werkzeug.exceptions import Forbidden
|
|
|
|
from configs import dify_config
|
|
from core.dify_builder.errors import (
|
|
BadRequestError,
|
|
BusyError,
|
|
ConflictError,
|
|
ModelUnavailableError,
|
|
NotFoundError,
|
|
)
|
|
from core.dify_builder.models import Action, Actor, ConversationItem
|
|
from core.rbac import RBACPermission
|
|
from extensions.ext_application_services import application_services
|
|
from extensions.ext_database import db
|
|
from fields.workflow_stream_fields import WorkflowStreamPayload
|
|
from libs.broadcast_channel.exc import SubscriptionClosedError
|
|
from models import App, TenantAccountJoin, TenantAccountRole
|
|
from runtime_contracts.builder.execution import canonical_digest
|
|
from services.dify_builder import app_naming, progress_bus, session_lock
|
|
from services.dify_builder.application.execution_policy import BuilderExecutionPolicyError
|
|
from services.dify_builder.dify_port import WorkflowServiceDifyPort
|
|
from services.dify_builder.execution_policy_service import check_app_permissions
|
|
from services.dify_builder.native_testing.public_contracts import (
|
|
NativeTestPublicReceipt,
|
|
)
|
|
from services.dify_builder.progress_snapshot import ProgressSnapshot
|
|
from services.dify_builder.real_call_consent_service import (
|
|
BuilderRealCallConsentService,
|
|
)
|
|
from services.dify_builder.repository import SqlDifyBuilderRepository
|
|
from services.dify_builder.revision import executable_graph_revision
|
|
from services.dify_builder.service import AppAccess, DifyBuilderService, SessionView
|
|
from tasks.dify_builder_advance_task import advance_session
|
|
|
|
__all__ = [
|
|
"REAL_CALL_REFUSAL_REASONS",
|
|
"build_real_call_consent_service",
|
|
"build_service",
|
|
"dify_builder_error_response",
|
|
"real_call_error_response",
|
|
"session_view_to_dict",
|
|
"stream_advance_frames",
|
|
]
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
_HEARTBEAT_SECONDS = 15
|
|
_MAX_STREAM_SECONDS = dify_config.DIFY_BUILDER_MAX_ADVANCE_SECONDS + _HEARTBEAT_SECONDS
|
|
_TERMINAL_KINDS = ("command_finished", "error")
|
|
_PROGRESS_KINDS = frozenset(
|
|
{
|
|
"workflow",
|
|
"canvas",
|
|
"agent_message",
|
|
"conversation_item_appended",
|
|
"reasoning",
|
|
"progress",
|
|
"native_state_changed",
|
|
*_TERMINAL_KINDS,
|
|
}
|
|
)
|
|
_IGNORED_WORKFLOW_EVENTS = frozenset({"message_end", "message_file", "tts_message", "tts_message_end"})
|
|
_WORKFLOW_PAYLOAD_ADAPTER = TypeAdapter(WorkflowStreamPayload)
|
|
|
|
|
|
def _enqueue(session_id: str, action: Action, actor: Actor, token: str) -> None:
|
|
cast(Callable[..., Any], 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.
|
|
|
|
SQL lifetime: the App and membership facts are read and detached in this
|
|
authorizer's own short session. The migrated resource queries separately
|
|
own short sessions and return detached Agent binding and maintainer facts
|
|
to ``check_app_permissions`` before its external permission RPC. No SQL
|
|
read here spans that RPC; the request-scoped session is never read,
|
|
committed or rolled back here.
|
|
"""
|
|
with sessionmaker(bind=db.engine, expire_on_commit=False)() as session:
|
|
trusted_app_id = session.scalar(
|
|
select(App.id).where(App.id == app_id, App.tenant_id == actor.tenant_id, App.status == "normal")
|
|
)
|
|
role = (
|
|
session.scalar(
|
|
select(TenantAccountJoin.role).where(
|
|
TenantAccountJoin.tenant_id == actor.tenant_id,
|
|
TenantAccountJoin.account_id == actor.account_id,
|
|
)
|
|
)
|
|
if trusted_app_id is not None
|
|
else None
|
|
)
|
|
if trusted_app_id is None:
|
|
raise NotFoundError("app not found")
|
|
if role is None or (not dify_config.RBAC_ENABLED and not TenantAccountRole.is_editing_role(role)):
|
|
raise Forbidden()
|
|
if not dify_config.RBAC_ENABLED:
|
|
return
|
|
|
|
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)
|
|
try:
|
|
check_app_permissions(
|
|
actor,
|
|
str(trusted_app_id),
|
|
queries=application_services().rbac.queries,
|
|
permissions=scenes,
|
|
)
|
|
except BuilderExecutionPolicyError as error:
|
|
if error.reason_code == "unsupported_workflow":
|
|
raise NotFoundError("app not found") from None
|
|
raise Forbidden() from None
|
|
|
|
|
|
def _get_app_revision(app_id: str, actor: Actor) -> str:
|
|
_graph, revision = WorkflowServiceDifyPort().read_graph(app_id, actor)
|
|
return revision
|
|
|
|
|
|
def _get_verification_identity(app_id: str, actor: Actor) -> tuple[str, str]:
|
|
# read_graph returns both values from one authorized draft in a short session.
|
|
graph, revision = WorkflowServiceDifyPort().read_graph(app_id, actor)
|
|
return revision, executable_graph_revision(graph)
|
|
|
|
|
|
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_real_call_consent_service() -> BuilderRealCallConsentService:
|
|
"""The consent owner for prepare/confirm/status and the confirmed dispatch coordinator."""
|
|
return BuilderRealCallConsentService(sessionmaker(bind=db.engine, expire_on_commit=False))
|
|
|
|
|
|
def build_service() -> DifyBuilderService:
|
|
app = current_app.app_context().app
|
|
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,
|
|
stamp_http_fixtures_fn=WorkflowServiceDifyPort().stamp_http_fixtures,
|
|
get_verification_identity_fn=_get_verification_identity,
|
|
get_app_name_fn=_get_app_name,
|
|
real_call_consent=build_real_call_consent_service(),
|
|
test_actions=_build_test_actions(),
|
|
stream_read_scope_fn=app.app_context,
|
|
)
|
|
|
|
|
|
def _build_test_actions():
|
|
from services.dify_builder.test_actions import BuilderTestActions
|
|
|
|
return BuilderTestActions(sessionmaker(bind=db.engine, expire_on_commit=False), build_native_test_api_service)
|
|
|
|
|
|
def _public_actions(actions: object) -> list[dict[str, object]]:
|
|
if not isinstance(actions, list):
|
|
return []
|
|
return [
|
|
{
|
|
"id": action.get("id", ""),
|
|
"label": action.get("label", ""),
|
|
"kind": action.get("kind", "secondary"),
|
|
**({"payload": action["payload"]} if action.get("payload") else {}),
|
|
}
|
|
for action in actions
|
|
if isinstance(action, dict)
|
|
]
|
|
|
|
|
|
def _public_active_interaction(interaction: object) -> dict[str, object] | None:
|
|
if not isinstance(interaction, dict):
|
|
return None
|
|
card = interaction.get("card")
|
|
if not isinstance(card, dict) or not isinstance(card.get("seq"), int):
|
|
return None
|
|
return {
|
|
"action_id": interaction.get("action_id", ""),
|
|
"card_seq": card["seq"],
|
|
"valid_at_version": interaction.get("valid_at_version", 0),
|
|
}
|
|
|
|
|
|
def _public_app_revision(revision: object) -> dict[str, object] | None:
|
|
if not isinstance(revision, dict):
|
|
return None
|
|
return {
|
|
"current": revision.get("current", ""),
|
|
"conflicted": revision.get("conflicted", False),
|
|
}
|
|
|
|
|
|
def _public_session_view(view: dict[str, Any], *, include_last_command_id: bool) -> dict[str, object]:
|
|
"""Project the internal engine view onto the browser-owned UI contract."""
|
|
projected: dict[str, object] = {
|
|
"session_id": view.get("session_id", ""),
|
|
"version": view.get("version", 0),
|
|
"reply_language": view.get("reply_language", ""),
|
|
"canvas_read_only": view.get("canvas_read_only", False),
|
|
"run_status": view.get("run_status", "failed"),
|
|
"interrupted": view.get("interrupted", False),
|
|
"conversation_last_seq": view.get("conversation_last_seq", -1),
|
|
"phase": view.get("phase", "understand"),
|
|
"actions": _public_actions(view.get("actions")),
|
|
}
|
|
if view.get("diagnostics"):
|
|
projected["diagnostics"] = view["diagnostics"]
|
|
if view.get("decision") is not None:
|
|
projected["decision"] = view["decision"]
|
|
if view.get("test_execution") is not None:
|
|
projected["test_execution"] = NativeTestPublicReceipt.model_validate(view["test_execution"]).model_dump(
|
|
mode="json"
|
|
)
|
|
active_interaction = _public_active_interaction(view.get("active_interaction"))
|
|
if active_interaction is not None:
|
|
projected["active_interaction"] = active_interaction
|
|
for key in ("recovery", "model"):
|
|
if view.get(key) is not None:
|
|
projected[key] = view[key]
|
|
app_revision = _public_app_revision(view.get("app_revision"))
|
|
if app_revision is not None:
|
|
projected["app_revision"] = app_revision
|
|
if include_last_command_id:
|
|
projected["last_command_id"] = view.get("last_command_id", "")
|
|
return projected
|
|
|
|
|
|
def session_view_to_dict(view: SessionView) -> dict[str, object]:
|
|
"""Serialize only the session fields consumed by the browser."""
|
|
return _public_session_view(dataclasses.asdict(view), include_last_command_id=True)
|
|
|
|
|
|
def dify_builder_error_response(exc: Exception) -> tuple[dict, int] | None:
|
|
if isinstance(exc, BuilderExecutionPolicyError):
|
|
reason = exc.reason_code
|
|
if reason in {"native_owner_mismatch", "execution_owner_mismatch"}:
|
|
return {"code": "not_found"}, 404
|
|
if reason == "execution_permission_denied":
|
|
return {"code": "bad_request", "message": "Test permission denied", "reason_code": reason}, 403
|
|
unavailable = reason in {
|
|
"native_runtime_unavailable",
|
|
"native_profile_unavailable",
|
|
"native_binding_unavailable",
|
|
"native_disclosure_unavailable",
|
|
}
|
|
return {
|
|
"code": "feature_unavailable" if unavailable else "conflict",
|
|
"message": "Test capability unavailable"
|
|
if unavailable
|
|
else "Test state changed; refresh before continuing",
|
|
"reason_code": reason,
|
|
"recoverable": True,
|
|
}, 503 if unavailable else 409
|
|
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
|
|
|
|
|
|
# Public refusal category of each consent/dispatch reason code. Only these
|
|
# fixed codes are disclosed; any other refusal collapses to one fixed answer.
|
|
_REAL_CALL_REFUSAL_CODES: dict[str, str] = {
|
|
**dict.fromkeys(
|
|
(
|
|
"needs_setup",
|
|
"credential_required",
|
|
"credential_binding_mismatch",
|
|
"credential_provider_mismatch",
|
|
"unsupported_test_input",
|
|
),
|
|
"needs_setup",
|
|
),
|
|
# The same answer for a missing or invisible credential: nothing foreign is disclosed.
|
|
**dict.fromkeys(
|
|
("credential_reference_unavailable", "credential_policy_refused"),
|
|
"needs_permission",
|
|
),
|
|
**dict.fromkeys(
|
|
(
|
|
"unsupported_action",
|
|
"unsupported_tool_node",
|
|
"unsupported_tool_parameters",
|
|
"unsupported_tool_configuration",
|
|
"unsafe_message_id",
|
|
"unsupported_session_state",
|
|
"unsupported_graph",
|
|
"unsupported_workflow",
|
|
"unsupported_node_retry",
|
|
"unsupported_default_output",
|
|
"unsupported_selector",
|
|
"unsupported_node_implementation",
|
|
"unsupported_node_configuration",
|
|
"invalid_node_configuration",
|
|
"unsupported_start_input",
|
|
"unsupported_input_value",
|
|
"unsupported_features",
|
|
"unsupported_variables",
|
|
"unsupported_external_tracing",
|
|
),
|
|
"unsupported_action",
|
|
),
|
|
**dict.fromkeys(
|
|
(
|
|
"installed_tool_unavailable",
|
|
"invalid_graph_metadata",
|
|
"session_state_unavailable",
|
|
),
|
|
"unavailable_metadata",
|
|
),
|
|
**dict.fromkeys(
|
|
(
|
|
"stale_session_version",
|
|
"stale_app_revision",
|
|
"stale_consent_version",
|
|
"stale_execution_revision",
|
|
"test_input_binding_mismatch",
|
|
"consent_scope_mismatch",
|
|
"consent_scope_changed",
|
|
"consent_invalidated",
|
|
"consent_expired",
|
|
"consent_not_prepared",
|
|
"consent_not_confirmed",
|
|
"consent_already_claimed",
|
|
"consent_decision_conflict",
|
|
"idempotency_conflict",
|
|
"execution_already_launched",
|
|
"command_lineage_changed",
|
|
),
|
|
"conflict",
|
|
),
|
|
}
|
|
# Missing and cross-owner sessions/consents/inputs are indistinguishable.
|
|
_REAL_CALL_NOT_FOUND = frozenset({"consent_not_found", "execution_owner_mismatch"})
|
|
_REAL_CALL_BAD_REQUEST = frozenset(
|
|
{
|
|
"invalid_version",
|
|
"invalid_scope_digest",
|
|
"invalid_idempotency_key",
|
|
"invalid_decision",
|
|
}
|
|
)
|
|
# Raised only after ownership is established, so a 403 reveals nothing foreign.
|
|
_REAL_CALL_PERMISSION_DENIED = "execution_permission_denied"
|
|
_REAL_CALL_UNAVAILABLE = "real_call_unavailable"
|
|
_REASON_CODE = re.compile(r"[a-z][a-z0-9_]{0,127}\Z")
|
|
# Every reason a 409 refusal can carry; the 403 permission refusal has its own fixed reason.
|
|
REAL_CALL_REFUSAL_REASONS = frozenset(
|
|
{
|
|
*_REAL_CALL_REFUSAL_CODES,
|
|
_REAL_CALL_UNAVAILABLE,
|
|
"session_busy",
|
|
"session_conflict",
|
|
}
|
|
)
|
|
|
|
|
|
def _real_call_refusal(code: str, reason_code: str) -> dict[str, str]:
|
|
return {"result": "refusal", "code": code, "reason_code": reason_code}
|
|
|
|
|
|
def real_call_error_response(exc: Exception) -> tuple[dict, int] | None:
|
|
"""Map a consent or confirmed-dispatch failure to a fixed public answer.
|
|
|
|
Never echoes exception text: only allowlisted reason codes are disclosed.
|
|
Returns ``None`` for unexpected failures, which the caller re-raises.
|
|
"""
|
|
if isinstance(exc, BuilderExecutionPolicyError):
|
|
reason = exc.reason_code
|
|
if reason in _REAL_CALL_NOT_FOUND:
|
|
return {"code": "not_found"}, 404
|
|
if reason in _REAL_CALL_BAD_REQUEST:
|
|
return {"code": "bad_request"}, 400
|
|
if reason == _REAL_CALL_PERMISSION_DENIED:
|
|
return _real_call_refusal("needs_permission", reason), 403
|
|
code = _REAL_CALL_REFUSAL_CODES.get(reason)
|
|
if code is None:
|
|
logger.warning(
|
|
"dify_builder real call refusal without a public code (reason=%s)",
|
|
reason if _REASON_CODE.fullmatch(reason) else "invalid",
|
|
)
|
|
return _real_call_refusal("unavailable_metadata", _REAL_CALL_UNAVAILABLE), 409
|
|
return _real_call_refusal(code, reason), 409
|
|
if isinstance(exc, Forbidden):
|
|
return _real_call_refusal("needs_permission", _REAL_CALL_PERMISSION_DENIED), 403
|
|
if isinstance(exc, BusyError):
|
|
return _real_call_refusal("conflict", "session_busy"), 409
|
|
if isinstance(exc, ConflictError):
|
|
return _real_call_refusal("conflict", "session_conflict"), 409
|
|
return dify_builder_error_response(exc)
|
|
|
|
|
|
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 _error_event(data: dict[str, Any], fallback: str) -> dict[str, object]:
|
|
message = data.get("message") or data.get("error") or fallback
|
|
event: dict[str, object] = {"message": str(message)}
|
|
for field in ("session_id", "command_id", "code"):
|
|
value = data.get(field)
|
|
if value is not None:
|
|
event[field] = value
|
|
return event
|
|
|
|
|
|
def _public_activity(activity: object) -> dict[str, object] | None:
|
|
if not isinstance(activity, dict):
|
|
return None
|
|
public_activity: dict[str, object] = {
|
|
"id": activity.get("id", ""),
|
|
"label": activity.get("label", ""),
|
|
"state": activity.get("state", "active"),
|
|
}
|
|
if activity.get("parent_id") is not None:
|
|
public_activity["parent_id"] = activity["parent_id"]
|
|
return public_activity
|
|
|
|
|
|
def _public_execution(execution: object) -> dict[str, object] | None:
|
|
if not isinstance(execution, dict):
|
|
return None
|
|
activities = execution.get("activities")
|
|
public_activities: list[dict[str, object]] = []
|
|
if isinstance(activities, list):
|
|
for activity in activities:
|
|
public_activity = _public_activity(activity)
|
|
if public_activity is not None:
|
|
public_activities.append(public_activity)
|
|
return {
|
|
"status": execution.get("status", "running"),
|
|
"activities": public_activities,
|
|
}
|
|
|
|
|
|
def _progress_event(raw: bytes) -> tuple[str, object] | None:
|
|
try:
|
|
data = json.loads(raw)
|
|
except (UnicodeDecodeError, ValueError, TypeError):
|
|
return "error", _error_event({}, "invalid Builder progress event")
|
|
if not isinstance(data, dict) or data.get("kind") not in _PROGRESS_KINDS:
|
|
return "error", _error_event({}, "invalid Builder progress event")
|
|
|
|
kind = data["kind"]
|
|
if kind == "native_state_changed":
|
|
# Internal invalidation only: receipt data is reread through the
|
|
# authenticated owner, never accepted from a broadcast payload.
|
|
return kind, {"session_id": data.get("session_id", "")}
|
|
if kind == "workflow":
|
|
payload = data.get("payload")
|
|
if isinstance(payload, dict) and payload.get("event") in _IGNORED_WORKFLOW_EVENTS:
|
|
return None
|
|
try:
|
|
public_payload = _WORKFLOW_PAYLOAD_ADAPTER.validate_python(payload).model_dump(
|
|
mode="json", exclude_none=True, exclude_unset=True
|
|
)
|
|
except ValidationError:
|
|
return "error", _error_event(data, "invalid Builder workflow event")
|
|
return kind, {
|
|
"session_id": data.get("session_id", ""),
|
|
"operation_id": data.get("operation_id", ""),
|
|
"at_version": data.get("at_version", 0),
|
|
"revision": data.get("revision", 0),
|
|
"payload": public_payload,
|
|
}
|
|
if kind == "canvas":
|
|
event = {
|
|
"session_id": data.get("session_id", ""),
|
|
"operation_id": data.get("operation_id", ""),
|
|
"at_version": data.get("at_version", 0),
|
|
"revision": data.get("revision", 0),
|
|
"event": data.get("event"),
|
|
}
|
|
if data.get("node_id") is not None:
|
|
event["node_id"] = data["node_id"]
|
|
return kind, event
|
|
if kind == "agent_message":
|
|
event = {
|
|
"session_id": data.get("session_id", ""),
|
|
"command_id": data.get("command_id", ""),
|
|
"operation_id": data.get("operation_id", ""),
|
|
"turn_id": data.get("turn_id", ""),
|
|
"delta": data.get("delta", ""),
|
|
"seq": data.get("seq", 0),
|
|
"at_version": data.get("at_version", 0),
|
|
"revision": data.get("revision", 0),
|
|
"done": data.get("done", False),
|
|
"text_bytes": data.get("text_bytes", 0),
|
|
}
|
|
execution = _public_execution(data.get("execution"))
|
|
if execution is not None:
|
|
event["execution"] = execution
|
|
cards = data.get("cards")
|
|
if cards:
|
|
event["cards"] = cards
|
|
return kind, event
|
|
if kind == "conversation_item_appended":
|
|
return kind, {
|
|
"session_id": data.get("session_id", ""),
|
|
"command_id": data.get("command_id", ""),
|
|
"item": data.get("item"),
|
|
}
|
|
if kind == "reasoning":
|
|
return kind, {
|
|
"session_id": data.get("session_id", ""),
|
|
"operation_id": data.get("operation_id", ""),
|
|
"at_version": data.get("at_version", 0),
|
|
"revision": data.get("revision", 0),
|
|
"delta": data.get("delta", ""),
|
|
}
|
|
if kind == "progress":
|
|
return kind, {
|
|
"session_id": data.get("session_id", ""),
|
|
"operation_id": data.get("operation_id", ""),
|
|
"reply_language": data.get("reply_language", ""),
|
|
"at_version": data.get("at_version", 0),
|
|
"revision": data.get("revision", 0),
|
|
"status": data.get("status", "running"),
|
|
"activity": _public_activity(data.get("activity")),
|
|
}
|
|
if kind == "command_finished":
|
|
event = {
|
|
**_public_session_view(data, include_last_command_id=False),
|
|
"command_id": data.get("command_id", ""),
|
|
}
|
|
if data.get("post_canvas_action_id") is not None:
|
|
event["post_canvas_action_id"] = data["post_canvas_action_id"]
|
|
return kind, event
|
|
return "error", _error_event(data, "Builder command failed")
|
|
|
|
|
|
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], 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_command_finished_when_settled: bool = False,
|
|
emit_command_started: bool = True,
|
|
command_id: str = "",
|
|
initial_items: Sequence[ConversationItem] = (),
|
|
read_session_view: Callable[[], dict[str, object] | None] | None = None,
|
|
initial_progress: ProgressSnapshot | None = None,
|
|
native_receipts: Callable[[], tuple[NativeTestPublicReceipt, ...]] | None = None,
|
|
native_cursor: str = "",
|
|
) -> Iterator[str]:
|
|
"""Emit a bounded command handshake, then relay incremental progress.
|
|
|
|
The handshake and terminal projection intentionally exclude conversation
|
|
history. Durable items are relayed one at a time; ``initial_items`` covers
|
|
rows created synchronously before the subscription/worker starts. Clients
|
|
use the JSON conversation endpoint only when the terminal sequence
|
|
watermark exposes a missing event. Settle-only calls yield just the
|
|
handshake unless ``emit_command_finished_when_settled`` is requested. The
|
|
stream is bounded by ``_MAX_STREAM_SECONDS``. Reconnect calls supply
|
|
``read_session_view`` to recover a settled or interrupted command on an
|
|
idle heartbeat when its terminal broadcast was missed. A callback returning
|
|
None defers only an uninitialized admission projection without supplying
|
|
a public receipt; the existing notification/heartbeat drives the next read.
|
|
``initial_progress`` restores the current worker's ordered activity state
|
|
before consuming the already-established live subscription.
|
|
|
|
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."""
|
|
|
|
native_seen: set[str] = {native_cursor} if native_cursor else set()
|
|
|
|
def native_frames() -> Iterator[str]:
|
|
if native_receipts is None:
|
|
return
|
|
receipts = native_receipts()
|
|
for receipt in receipts:
|
|
public = NativeTestPublicReceipt.model_validate(receipt.model_dump(mode="json")).model_dump(mode="json")
|
|
cursor = canonical_digest(public)
|
|
if cursor not in native_seen:
|
|
native_seen.add(cursor)
|
|
yield f"id: {cursor}\n" + _event_frame(
|
|
"native_execution",
|
|
{
|
|
"session_id": view_dict.get("session_id", ""),
|
|
"cursor": cursor,
|
|
"receipt": public,
|
|
},
|
|
)
|
|
|
|
def _frames() -> Generator[str]:
|
|
try:
|
|
yield from native_frames()
|
|
|
|
if emit_command_started:
|
|
yield _event_frame(
|
|
"command_started",
|
|
{
|
|
"session_id": view_dict.get("session_id", ""),
|
|
"command_id": command_id,
|
|
"version": view_dict.get("version", 0),
|
|
"phase": view_dict.get("phase", "understand"),
|
|
"run_status": "processing" if expect_advance else view_dict.get("run_status", "processing"),
|
|
},
|
|
)
|
|
for item in initial_items:
|
|
if item.kind == "assistant_turn":
|
|
continue
|
|
yield _event_frame(
|
|
"conversation_item_appended",
|
|
{
|
|
"session_id": view_dict.get("session_id", ""),
|
|
"command_id": command_id,
|
|
"item": dataclasses.asdict(item),
|
|
},
|
|
)
|
|
if not expect_advance:
|
|
if emit_command_finished_when_settled:
|
|
yield _event_frame(
|
|
"command_finished",
|
|
{
|
|
**_public_session_view(view_dict, include_last_command_id=False),
|
|
"command_id": command_id,
|
|
},
|
|
)
|
|
if native_receipts is None or subscription is None:
|
|
return
|
|
if subscription is None:
|
|
return
|
|
if initial_progress is not None and initial_progress.at_version > view_dict["version"]:
|
|
yield _event_frame(
|
|
"progress_snapshot",
|
|
{
|
|
"session_id": initial_progress.session_id,
|
|
"operation_id": initial_progress.operation_id,
|
|
"at_version": initial_progress.at_version,
|
|
"revision": initial_progress.revision,
|
|
"reply_language": initial_progress.reply_language,
|
|
"execution": _public_execution(dataclasses.asdict(initial_progress.execution)),
|
|
},
|
|
)
|
|
deadline = time.monotonic() + _MAX_STREAM_SECONDS
|
|
while time.monotonic() < deadline:
|
|
try:
|
|
raw = subscription.receive(timeout=_HEARTBEAT_SECONDS)
|
|
except SubscriptionClosedError:
|
|
return
|
|
if raw is None:
|
|
# Native changes arrive on the existing bus. Idle
|
|
# heartbeats must not become receipt polling in disguise.
|
|
if native_receipts is None and read_session_view is not None:
|
|
latest = read_session_view()
|
|
if latest is None:
|
|
yield ": keep-alive\n\n"
|
|
continue
|
|
if latest.get("_task_paused") or latest["run_status"] != "processing" or latest["interrupted"]:
|
|
yield _event_frame(
|
|
"command_finished",
|
|
{
|
|
**_public_session_view(latest, include_last_command_id=False),
|
|
"command_id": command_id
|
|
if latest.get("_task_paused") and command_id
|
|
else latest.get("last_command_id", ""),
|
|
},
|
|
)
|
|
return
|
|
yield ": keep-alive\n\n"
|
|
continue
|
|
progress_event = _progress_event(raw)
|
|
if progress_event is None:
|
|
continue
|
|
kind, data = progress_event
|
|
if kind == "native_state_changed":
|
|
if not isinstance(data, dict) or data.get("session_id") != view_dict.get("session_id"):
|
|
continue
|
|
if native_receipts is None:
|
|
if read_session_view is not None:
|
|
latest = read_session_view()
|
|
if latest is None:
|
|
yield ": keep-alive\n\n"
|
|
continue
|
|
if (
|
|
latest.get("_task_paused")
|
|
or latest["run_status"] != "processing"
|
|
or latest["interrupted"]
|
|
):
|
|
yield _event_frame(
|
|
"command_finished",
|
|
{
|
|
**_public_session_view(latest, include_last_command_id=False),
|
|
"command_id": command_id
|
|
if latest.get("_task_paused") and command_id
|
|
else latest.get("last_command_id", ""),
|
|
},
|
|
)
|
|
return
|
|
continue
|
|
yield from native_frames()
|
|
if read_session_view is not None:
|
|
latest = read_session_view()
|
|
if latest is None:
|
|
yield ": keep-alive\n\n"
|
|
continue
|
|
yield _event_frame(
|
|
"command_finished",
|
|
{
|
|
**_public_session_view(latest, include_last_command_id=False),
|
|
"command_id": latest.get("last_command_id", ""),
|
|
},
|
|
)
|
|
continue
|
|
yield _event_frame(kind, data)
|
|
if kind in _TERMINAL_KINDS and not (kind == "command_finished" and native_receipts is not None):
|
|
return
|
|
if native_receipts is not None:
|
|
# End only the observation transport. The browser reconnects
|
|
# without replay; a stream lifetime is not an execution timeout.
|
|
return
|
|
yield _event_frame(
|
|
"error",
|
|
{
|
|
"code": "timeout",
|
|
"message": "Builder operation timed out",
|
|
"session_id": view_dict.get("session_id"),
|
|
"command_id": command_id,
|
|
},
|
|
)
|
|
except BuilderExecutionPolicyError as error:
|
|
yield _event_frame(
|
|
"error",
|
|
{
|
|
"code": error.reason_code,
|
|
"message": "Builder observation unavailable",
|
|
"session_id": view_dict.get("session_id", ""),
|
|
"command_id": command_id,
|
|
},
|
|
)
|
|
finally:
|
|
if subscription is not None:
|
|
subscription.close()
|
|
|
|
return _ClosingFrameStream(_frames(), subscription)
|
|
|
|
|
|
def native_runtime_owner():
|
|
"""One bounded process-local owner attached to the actual Flask application."""
|
|
from flask import current_app
|
|
|
|
from services.dify_builder.native_testing.runtime import DifyNativeRuntimeOwner
|
|
|
|
owner = current_app.extensions.setdefault("builder_native_runtime_owner", DifyNativeRuntimeOwner())
|
|
if type(owner) is not DifyNativeRuntimeOwner:
|
|
raise BuilderExecutionPolicyError("native_runtime_composition_mismatch")
|
|
return owner
|
|
|
|
|
|
def build_native_test_input_binding_service(*, actor: Actor):
|
|
from services.dify_builder.native_testing.input_binding import NativeTestInputBindingService
|
|
|
|
return NativeTestInputBindingService(
|
|
session_factory=sessionmaker(bind=db.engine, expire_on_commit=False), actor=actor
|
|
)
|
|
|
|
|
|
def build_native_test_api_service(*, actor: Actor):
|
|
from services.dify_builder.native_testing.confirmed_agent_commands import ProfiledNativeTestCommands
|
|
from services.dify_builder.native_testing.runtime import build_native_api
|
|
from tasks.dify_builder_advance_task import advance_native_test
|
|
|
|
sessions = sessionmaker(bind=db.engine, expire_on_commit=False)
|
|
return ProfiledNativeTestCommands(
|
|
sessions=sessions,
|
|
actor=actor,
|
|
confirmed_factory=lambda: build_confirmed_agent_command_service(actor=actor),
|
|
legacy_factory=lambda: build_native_api(
|
|
sessions=sessions,
|
|
actor=actor,
|
|
owner=native_runtime_owner(),
|
|
publish=advance_native_test.delay,
|
|
),
|
|
)
|
|
|
|
|
|
def build_native_dispatch_lifecycle(*, ref, generation, dispatch_id, actor_hint=None):
|
|
from services.dify_builder.native_testing.lifecycle import NativeDispatchRef
|
|
|
|
return native_runtime_owner().delivery(
|
|
sessions=sessionmaker(bind=db.engine, expire_on_commit=False),
|
|
delivery=NativeDispatchRef(ref=ref, generation=generation, dispatch_id=dispatch_id),
|
|
actor_hint=actor_hint,
|
|
)
|
|
|
|
|
|
class _NativeAgentIdentityReader:
|
|
"""One short-lived authenticated SDK owner per private identity read."""
|
|
|
|
def read_run_identity_sync(self, run_id, selection):
|
|
from clients.agent_backend.factory import create_agent_backend_client
|
|
from configs import dify_config
|
|
|
|
if (
|
|
not dify_config.AGENT_BACKEND_BASE_URL
|
|
or not dify_config.AGENT_BACKEND_API_TOKEN
|
|
or dify_config.AGENT_BACKEND_USE_FAKE
|
|
):
|
|
raise BuilderExecutionPolicyError("native_agent_identity_unavailable")
|
|
with create_agent_backend_client(
|
|
base_url=dify_config.AGENT_BACKEND_BASE_URL,
|
|
api_token=dify_config.AGENT_BACKEND_API_TOKEN,
|
|
timeout=10.0,
|
|
) as client:
|
|
return client.read_run_identity_sync(run_id, selection)
|
|
|
|
|
|
def build_native_agent_preparation_service(*, actor: Actor, workspace_limits=None):
|
|
"""S1 private config/bind owner; no native execution or continuation installation.
|
|
|
|
The later bound-node owner must supply its actual A7 limits to enable workspace
|
|
preparation. Callbacks need no workspace factory and can only bind a previously
|
|
protected reservation after authenticated persisted-Run readback.
|
|
"""
|
|
from services.dify_builder.native_testing.agent_preparation import (
|
|
AgentPreparationService,
|
|
)
|
|
from services.dify_builder.native_testing.agent_runtime_config import (
|
|
NativeAgentRuntimeConfigBuilder,
|
|
)
|
|
from services.dify_builder.native_testing.agent_workspace import (
|
|
AgentTestWorkspaceService,
|
|
)
|
|
from services.dify_builder.native_testing.http_binding import (
|
|
NativeHttpBindingResolver,
|
|
)
|
|
from services.dify_builder.native_testing.repository import OwnedNativeTestLoader
|
|
from services.dify_builder.native_testing.state_store import (
|
|
NativeExecutionStateStore,
|
|
)
|
|
|
|
loader = OwnedNativeTestLoader(
|
|
sessionmaker(bind=db.engine, expire_on_commit=False),
|
|
actor=actor,
|
|
resolver=NativeHttpBindingResolver(),
|
|
)
|
|
store = NativeExecutionStateStore(loader)
|
|
workspace = (
|
|
None
|
|
if workspace_limits is None
|
|
else AgentTestWorkspaceService(loader, state_store=store, limits=workspace_limits)
|
|
)
|
|
return AgentPreparationService(
|
|
state_store=store,
|
|
backend=_NativeAgentIdentityReader(),
|
|
workspace=workspace,
|
|
config_builder=NativeAgentRuntimeConfigBuilder(loader=loader),
|
|
)
|
|
|
|
|
|
def build_native_agent_endpoint_lineage():
|
|
"""Install strict dormant API lineage without enabling an Agent capability."""
|
|
from services.dify_builder.native_testing.agent_lineage import (
|
|
NativeAgentEndpointLineage,
|
|
)
|
|
from services.dify_builder.native_testing.agent_preparation import (
|
|
agent_preparation_for_ref,
|
|
)
|
|
|
|
return NativeAgentEndpointLineage(
|
|
backend=_NativeAgentIdentityReader(),
|
|
preparation_for_ref=agent_preparation_for_ref,
|
|
)
|
|
|
|
|
|
def build_native_agent_operation_service(*, actor: Actor):
|
|
"""Actual API dispatch owner; model reasoning has no grant authority."""
|
|
from services.dify_builder.native_testing.agent_operations import build_native_agent_operations
|
|
from services.dify_builder.native_testing.agent_risk_assessor import builder_call_assessor
|
|
|
|
return build_native_agent_operations(actor=actor, assessor=builder_call_assessor)
|
|
|
|
|
|
def build_confirmed_agent_command_service(*, actor: Actor, backend=None):
|
|
from clients.agent_backend.factory import create_agent_backend_run_client
|
|
from services.dify_builder.native_testing.confirmed_agent_commands import build_confirmed_agent_commands
|
|
|
|
if backend is None:
|
|
if (
|
|
not dify_config.AGENT_BACKEND_BASE_URL
|
|
or not dify_config.AGENT_BACKEND_API_TOKEN
|
|
or dify_config.AGENT_BACKEND_USE_FAKE
|
|
):
|
|
raise BuilderExecutionPolicyError("native_agent_identity_unavailable")
|
|
backend = create_agent_backend_run_client(
|
|
base_url=dify_config.AGENT_BACKEND_BASE_URL,
|
|
api_token=dify_config.AGENT_BACKEND_API_TOKEN,
|
|
)
|
|
return build_confirmed_agent_commands(
|
|
sessions=sessionmaker(bind=db.engine, expire_on_commit=False),
|
|
actor=actor,
|
|
operations=build_native_agent_operation_service(actor=actor),
|
|
backend=backend,
|
|
)
|
|
|
|
|
|
def build_confirmed_agent_node_owners(*, actor: Actor, backend):
|
|
from services.agent.workflow_run_recovery import WorkflowAgentRunRecovery
|
|
from services.dify_builder.native_testing.agent_confirmed_workspace import build_confirmed_agent_preparation
|
|
|
|
return (
|
|
build_confirmed_agent_preparation(actor=actor, backend=_NativeAgentIdentityReader()),
|
|
build_confirmed_agent_command_service(actor=actor, backend=backend),
|
|
WorkflowAgentRunRecovery(sessionmaker(bind=db.engine, expire_on_commit=False)),
|
|
)
|