mirror of
https://github.com/langgenius/dify.git
synced 2026-11-11 22:02:23 +08:00
444 lines
19 KiB
Python
444 lines
19 KiB
Python
"""Adapt existing Builder actions to the durable native command owners.
|
|
|
|
No start implementation lives here: run_test keeps the ordinary input and
|
|
run_draft path. Browser payloads identify a disclosed operation, never authority.
|
|
"""
|
|
|
|
from collections.abc import Callable
|
|
from dataclasses import dataclass
|
|
from datetime import UTC, datetime
|
|
from typing import TYPE_CHECKING, Any, Literal, Protocol
|
|
from uuid import NAMESPACE_URL, uuid5
|
|
|
|
from pydantic import ValidationError
|
|
from sqlalchemy import func, select
|
|
from sqlalchemy.orm import Session, sessionmaker
|
|
|
|
from core.dify_builder.errors import BadRequestError, ConflictError
|
|
from core.dify_builder.models import Actor
|
|
from models.dify_builder import DifyBuilderConversationItem, DifyBuilderExecutionRequest, DifyBuilderSession
|
|
from models.dify_builder_native_tests import (
|
|
NativeInvocationState,
|
|
NativePayloadObject,
|
|
NativeTestInvocation,
|
|
NativeTestScope,
|
|
)
|
|
from models.enums import CreatorUserRole
|
|
from models.workflow import WorkflowRun
|
|
from runtime_contracts.builder.execution import BuilderExecutionPolicyError, Digest, canonical_digest
|
|
from runtime_contracts.builder.native.contracts import ID, Counter, FrozenDTO
|
|
from runtime_contracts.builder.native.observation import parse_native_model_failure
|
|
from services.dify_builder import trace_diagnostics
|
|
from services.dify_builder.execution_policy_service import load_execution_owners
|
|
from services.dify_builder.native_testing.public_contracts import NativeTestPublicReceipt
|
|
|
|
if TYPE_CHECKING:
|
|
from services.dify_builder.native_testing.confirmed_agent_commands import ProfiledNativeTestCommands
|
|
|
|
TEST_ACTIONS = frozenset({"confirm_test_operation", "deny_test_operation", "resume_test", "cancel_test"})
|
|
|
|
|
|
class TestOperationPayload(FrozenDTO):
|
|
request_id: ID
|
|
pending_operation_id: ID
|
|
pending_version: Counter
|
|
scope_digest: Digest
|
|
|
|
|
|
class TestResumePayload(FrozenDTO):
|
|
request_id: ID
|
|
expected_generation: Counter
|
|
|
|
|
|
class TestCancelPayload(FrozenDTO):
|
|
request_id: ID
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class TestCommand:
|
|
method: Literal["decide", "resume", "cancel"]
|
|
request_id: str
|
|
native_payload: dict[str, Any]
|
|
|
|
|
|
def parse_test_action(kind: str, payload: dict[str, Any]) -> TestCommand:
|
|
if "option_id" in payload:
|
|
if payload["option_id"] != kind:
|
|
raise BadRequestError("test choice does not match the selected action")
|
|
payload = {key: value for key, value in payload.items() if key != "option_id"}
|
|
try:
|
|
if kind in {"confirm_test_operation", "deny_test_operation"}:
|
|
operation = TestOperationPayload.model_validate(payload)
|
|
return TestCommand(
|
|
"decide",
|
|
operation.request_id,
|
|
{
|
|
"pending_operation_id": operation.pending_operation_id,
|
|
"expected_version": operation.pending_version,
|
|
"scope_digest": operation.scope_digest,
|
|
"decision": "confirm" if kind == "confirm_test_operation" else "deny",
|
|
},
|
|
)
|
|
if kind == "resume_test":
|
|
resume = TestResumePayload.model_validate(payload)
|
|
return TestCommand("resume", resume.request_id, {"expected_generation": resume.expected_generation})
|
|
if kind == "cancel_test":
|
|
cancel = TestCancelPayload.model_validate(payload)
|
|
return TestCommand("cancel", cancel.request_id, {})
|
|
except ValidationError:
|
|
raise BadRequestError("invalid test action payload") from None
|
|
raise BadRequestError("unsupported test action")
|
|
|
|
|
|
class TestActionPort(Protocol):
|
|
def find_command(self, *, actor: Actor, session_id: str, command_id: str) -> dict[str, Any] | None: ...
|
|
|
|
def settle(self, *, actor: Actor, session_id: str, request_id: str) -> None: ...
|
|
|
|
def current(self, *, actor: Actor, session_id: str, test_input_id: str) -> NativeTestPublicReceipt | None: ...
|
|
|
|
def observation_initializing(self, *, actor: Actor, session_id: str, test_input_id: str) -> bool: ...
|
|
|
|
def current_diagnostics(
|
|
self, *, actor: Actor, session_id: str, test_input_id: str, receipt: NativeTestPublicReceipt
|
|
) -> list[dict[str, Any]]: ...
|
|
|
|
def execute(
|
|
self, *, actor: Actor, session_id: str, test_input_id: str, command: TestCommand, idempotency_key: str
|
|
) -> NativeTestPublicReceipt: ...
|
|
|
|
|
|
class BuilderTestActions:
|
|
"""Only control the current saved-input attempt; GET never reconciles work."""
|
|
|
|
def __init__(self, sessions: sessionmaker[Session], command_factory: Callable[..., "ProfiledNativeTestCommands"]):
|
|
self.sessions = sessions
|
|
self.command_factory = command_factory
|
|
|
|
def find_command(self, *, actor: Actor, session_id: str, command_id: str) -> dict[str, Any] | None:
|
|
with self.sessions() as sql:
|
|
return sql.scalar(
|
|
select(DifyBuilderConversationItem.payload)
|
|
.join(DifyBuilderSession, DifyBuilderSession.id == DifyBuilderConversationItem.session_id)
|
|
.where(
|
|
DifyBuilderSession.id == session_id,
|
|
DifyBuilderSession.tenant_id == actor.tenant_id,
|
|
DifyBuilderSession.owner_account_id == actor.account_id,
|
|
DifyBuilderConversationItem.kind == "decision",
|
|
DifyBuilderConversationItem.payload["test_command_id"].as_string() == command_id,
|
|
)
|
|
.limit(1)
|
|
)
|
|
|
|
def settle(self, *, actor: Actor, session_id: str, request_id: str) -> None:
|
|
self.command_factory(actor=actor).settle_builder(actor=actor, session_id=session_id, request_id=request_id)
|
|
|
|
def _current_id(self, *, actor: Actor, session_id: str, test_input_id: str) -> str | None:
|
|
if not test_input_id:
|
|
return None
|
|
with self.sessions() as sql:
|
|
return sql.scalar(
|
|
select(DifyBuilderExecutionRequest.id)
|
|
.where(
|
|
DifyBuilderExecutionRequest.tenant_id == actor.tenant_id,
|
|
DifyBuilderExecutionRequest.actor_id == actor.account_id,
|
|
DifyBuilderExecutionRequest.session_id == session_id,
|
|
DifyBuilderExecutionRequest.test_input_id == test_input_id,
|
|
DifyBuilderExecutionRequest.context["policy_version"].as_string() == "builder-native-test-v1",
|
|
)
|
|
.order_by(DifyBuilderExecutionRequest.created_at.desc(), DifyBuilderExecutionRequest.id.desc())
|
|
.limit(1)
|
|
)
|
|
|
|
def current(self, *, actor: Actor, session_id: str, test_input_id: str) -> NativeTestPublicReceipt | None:
|
|
request_id = self._current_id(actor=actor, session_id=session_id, test_input_id=test_input_id)
|
|
if request_id is None:
|
|
return None
|
|
return self.command_factory(actor=actor).peek(actor=actor, session_id=session_id, request_id=request_id)
|
|
|
|
def observation_initializing(self, *, actor: Actor, session_id: str, test_input_id: str) -> bool:
|
|
"""Defer SSE projection only before admission acknowledges its first context.
|
|
|
|
This never substitutes a public receipt or authorizes a command. Context
|
|
acknowledgement and root-state insertion share a transaction; a retained
|
|
context therefore rules out treating a missing initialized root as startup.
|
|
"""
|
|
request_id = self._current_id(actor=actor, session_id=session_id, test_input_id=test_input_id)
|
|
if request_id is None:
|
|
return False
|
|
with self.sessions() as sql:
|
|
request = sql.get(DifyBuilderExecutionRequest, request_id)
|
|
builder = sql.get(DifyBuilderSession, session_id)
|
|
scope = sql.get(NativeTestScope, request_id)
|
|
if (
|
|
request is None
|
|
or builder is None
|
|
or scope is None
|
|
or (request.tenant_id, request.actor_id, request.session_id, request.test_input_id, request.app_id)
|
|
!= (actor.tenant_id, actor.account_id, session_id, test_input_id, builder.app_id)
|
|
or (builder.tenant_id, builder.owner_account_id) != (actor.tenant_id, actor.account_id)
|
|
or request.state != "running"
|
|
or request.native_run_id is not None
|
|
or scope.tenant_id != actor.tenant_id
|
|
or scope.generation != 0
|
|
or not scope.task_approved
|
|
or scope.cancelled
|
|
or (scope.expires_at.replace(tzinfo=UTC) if scope.expires_at.tzinfo is None else scope.expires_at)
|
|
<= datetime.now(UTC)
|
|
or canonical_digest(request.context) != request.context_digest
|
|
or len(request.observations) != 1
|
|
):
|
|
return False
|
|
record = request.observations[0]
|
|
if (
|
|
not isinstance(record, dict)
|
|
or not record.get("native_command_key")
|
|
or not record.get("body_digest")
|
|
or record.get("admission_launched") is not None
|
|
):
|
|
return False
|
|
try:
|
|
receipt = NativeTestPublicReceipt.model_validate(record.get("receipt"))
|
|
except ValidationError:
|
|
return False
|
|
if (receipt.request_id, receipt.generation, receipt.version, receipt.status, receipt.native_run_id) != (
|
|
request_id,
|
|
0,
|
|
1,
|
|
"prepared",
|
|
None,
|
|
):
|
|
return False
|
|
if (
|
|
receipt.wait_reason,
|
|
receipt.native_status,
|
|
receipt.pending,
|
|
receipt.outcome,
|
|
receipt.evidence_refs,
|
|
receipt.cancellation,
|
|
receipt.can_resume,
|
|
) != (None, None, (), None, (), None, False):
|
|
return False
|
|
invocations = list(
|
|
sql.scalars(select(NativeTestInvocation).where(NativeTestInvocation.request_id == request_id))
|
|
)
|
|
if len(invocations) != 1:
|
|
return False
|
|
invocation = invocations[0]
|
|
if (
|
|
invocation.tenant_id != actor.tenant_id
|
|
or not invocation.active
|
|
or invocation.bindings
|
|
or invocation.invocation_id != request.context.get("invocation_id")
|
|
):
|
|
return False
|
|
return (
|
|
sql.scalar(
|
|
select(NativeInvocationState.id).where(NativeInvocationState.request_id == request_id).limit(1)
|
|
)
|
|
is None
|
|
and sql.scalar(
|
|
select(NativePayloadObject.id)
|
|
.where(NativePayloadObject.request_id == request_id, NativePayloadObject.kind == "context")
|
|
.limit(1)
|
|
)
|
|
is None
|
|
)
|
|
|
|
def current_diagnostics(
|
|
self, *, actor: Actor, session_id: str, test_input_id: str, receipt: NativeTestPublicReceipt
|
|
) -> list[dict[str, Any]]:
|
|
"""Observe only the current owned parent; never reconcile or inspect child I/O."""
|
|
try:
|
|
with self.sessions() as sql:
|
|
request = sql.scalar(
|
|
select(DifyBuilderExecutionRequest)
|
|
.where(
|
|
DifyBuilderExecutionRequest.tenant_id == actor.tenant_id,
|
|
DifyBuilderExecutionRequest.actor_id == actor.account_id,
|
|
DifyBuilderExecutionRequest.session_id == session_id,
|
|
DifyBuilderExecutionRequest.test_input_id == test_input_id,
|
|
DifyBuilderExecutionRequest.context["policy_version"].as_string() == "builder-native-test-v1",
|
|
)
|
|
.order_by(DifyBuilderExecutionRequest.created_at.desc(), DifyBuilderExecutionRequest.id.desc())
|
|
.limit(1)
|
|
)
|
|
if request is None or request.id != receipt.request_id or not receipt.native_run_id:
|
|
return []
|
|
load_execution_owners(
|
|
sql,
|
|
actor=actor,
|
|
app_id=request.app_id,
|
|
workflow_id=request.workflow_id,
|
|
session_id=session_id,
|
|
test_input_id=test_input_id,
|
|
)
|
|
context = request.context
|
|
expected = {
|
|
"request_id": request.id,
|
|
"tenant_id": actor.tenant_id,
|
|
"actor_id": actor.account_id,
|
|
"app_id": request.app_id,
|
|
"workflow_id": request.workflow_id,
|
|
"session_id": session_id,
|
|
"test_input_id": test_input_id,
|
|
"native_run_id": receipt.native_run_id,
|
|
}
|
|
if (
|
|
canonical_digest(context) != request.context_digest
|
|
or any(context.get(key) != value for key, value in expected.items())
|
|
or request.native_run_id not in (None, receipt.native_run_id)
|
|
):
|
|
return []
|
|
parent = sql.execute(
|
|
select(
|
|
WorkflowRun.id, func.substr(WorkflowRun.error, 1, 257).label("error"), WorkflowRun.finished_at
|
|
)
|
|
.where(
|
|
WorkflowRun.id == receipt.native_run_id,
|
|
WorkflowRun.tenant_id == actor.tenant_id,
|
|
WorkflowRun.app_id == request.app_id,
|
|
WorkflowRun.workflow_id == request.workflow_id,
|
|
WorkflowRun.created_by == actor.account_id,
|
|
WorkflowRun.created_by_role == CreatorUserRole.ACCOUNT,
|
|
WorkflowRun.status == "failed",
|
|
)
|
|
.limit(1)
|
|
).first()
|
|
if parent is None or parent.finished_at is None or not isinstance(parent.error, str):
|
|
return []
|
|
diagnostic = parse_native_model_failure(parent.error)
|
|
legacy = parent.error == "NativeAgentModelDispatchFailed: Native Agent model invocation failed."
|
|
if diagnostic is None and not legacy:
|
|
return []
|
|
stage, category = (diagnostic.stage, diagnostic.category) if diagnostic else (None, None)
|
|
at = parent.finished_at
|
|
at = at.replace(tzinfo=UTC) if at.tzinfo is None else at.astimezone(UTC)
|
|
record = trace_diagnostics.project(
|
|
{
|
|
"code": "native_model_invocation_failed",
|
|
"native_stage": stage,
|
|
"native_category": category,
|
|
"id": str(uuid5(NAMESPACE_URL, f"builder-native:{request.id}:{parent.id}:{stage}:{category}")),
|
|
"at": at.isoformat(),
|
|
}
|
|
)
|
|
record["session_id"] = session_id
|
|
return [record]
|
|
except Exception:
|
|
# Optional observations must not prevent ordinary GET/reconnect.
|
|
return []
|
|
|
|
def execute(
|
|
self, *, actor: Actor, session_id: str, test_input_id: str, command: TestCommand, idempotency_key: str
|
|
) -> NativeTestPublicReceipt:
|
|
if self._current_id(actor=actor, session_id=session_id, test_input_id=test_input_id) != command.request_id:
|
|
raise ConflictError("test request is no longer current")
|
|
try:
|
|
commands = self.command_factory(actor=actor)
|
|
execute = {"decide": commands.decide, "resume": commands.resume, "cancel": commands.cancel}[command.method]
|
|
return execute(
|
|
actor=actor,
|
|
session_id=session_id,
|
|
request_id=command.request_id,
|
|
idempotency_key=idempotency_key,
|
|
**command.native_payload,
|
|
)
|
|
except BuilderExecutionPolicyError:
|
|
# Preserve the typed reason for the transport's safe HTTP mapping.
|
|
raise
|
|
|
|
|
|
def control_actions(receipt: NativeTestPublicReceipt):
|
|
from core.dify_builder.contract import Action, ActionKind
|
|
from runtime_contracts.builder.copy_ids import BuilderCopyId
|
|
from runtime_contracts.builder.presentation import copy_text
|
|
|
|
actions = []
|
|
for pending in receipt.pending:
|
|
if pending.wait_reason != "approval" or pending.decision_status != "awaiting_decision":
|
|
continue
|
|
payload = {
|
|
"request_id": receipt.request_id,
|
|
"pending_operation_id": pending.pending_operation_id,
|
|
"pending_version": pending.version,
|
|
"scope_digest": pending.scope_digest,
|
|
}
|
|
actions.extend(
|
|
[
|
|
Action(
|
|
"confirm_test_operation",
|
|
copy_text(BuilderCopyId.ACTIONS_SUBMIT),
|
|
ActionKind.PRIMARY,
|
|
payload=payload,
|
|
),
|
|
Action(
|
|
"deny_test_operation",
|
|
copy_text(BuilderCopyId.ACTIONS_REJECT),
|
|
ActionKind.DESTRUCTIVE,
|
|
payload=payload,
|
|
),
|
|
]
|
|
)
|
|
break # disclose/approve pending operations one at a time
|
|
if receipt.can_resume:
|
|
actions.append(
|
|
Action(
|
|
"resume_test",
|
|
copy_text(BuilderCopyId.ACTIONS_CONTINUE),
|
|
ActionKind.PRIMARY,
|
|
payload={"request_id": receipt.request_id, "expected_generation": receipt.generation},
|
|
)
|
|
)
|
|
if receipt.status != "terminal":
|
|
actions.append(
|
|
Action(
|
|
"cancel_test",
|
|
control_label("cancel_test"),
|
|
ActionKind.DESTRUCTIVE,
|
|
payload={"request_id": receipt.request_id},
|
|
)
|
|
)
|
|
return actions
|
|
|
|
|
|
def control_label(kind: str):
|
|
from runtime_contracts.builder.copy_ids import BuilderCopyId
|
|
from runtime_contracts.builder.presentation import copy_text
|
|
|
|
return copy_text(
|
|
{
|
|
"confirm_test_operation": BuilderCopyId.ACTIONS_CONFIRM_TEST_OPERATION,
|
|
"deny_test_operation": BuilderCopyId.ACTIONS_REJECT,
|
|
"resume_test": BuilderCopyId.ACTIONS_CONTINUE,
|
|
"cancel_test": BuilderCopyId.ACTIONS_CANCEL_TEST,
|
|
}[kind]
|
|
)
|
|
|
|
|
|
def test_disclosure_text(receipt: NativeTestPublicReceipt):
|
|
import json
|
|
|
|
from runtime_contracts.builder.copy_ids import BuilderCopyId
|
|
from runtime_contracts.builder.presentation import copy_text, text_parts
|
|
|
|
# Only human-reviewable scope belongs in the choice description. Version,
|
|
# digest and operation identities remain in the structured action payload.
|
|
reviewed = [
|
|
{
|
|
"account": pending.account_label,
|
|
"action": pending.action,
|
|
"target": pending.target,
|
|
"parameters": pending.parameters_summary,
|
|
"risk": pending.risk_notice,
|
|
"limits": pending.approval_limits.model_dump(mode="json") if pending.approval_limits else None,
|
|
"decision": pending.decision,
|
|
}
|
|
for pending in receipt.pending
|
|
]
|
|
return text_parts(
|
|
copy_text(BuilderCopyId.DECISION_HOW_SHOULD_BUILDER_CONTINUE),
|
|
"\n",
|
|
json.dumps(reviewed, ensure_ascii=False, indent=2),
|
|
)
|