mirror of
https://github.com/langgenius/dify.git
synced 2026-11-12 06:11:12 +08:00
Resume the original run after all approvals and restore terminal progress and traversed edges on reload. Show structured approval details and receipts, hide Stop while forms are open, and recover when a running conversation is cleared.
645 lines
29 KiB
Python
645 lines
29 KiB
Python
"""Authenticated native test commands composed from the shared durable owners.
|
|
|
|
Only this owner admits a root SQL request. Runtime qualification, disclosure and
|
|
current resource metadata remain mandatory trusted collaborators; missing lanes
|
|
fail closed before admission. No default profile or ordinary executor is used.
|
|
"""
|
|
|
|
from dataclasses import dataclass
|
|
from datetime import datetime, timedelta
|
|
from typing import Literal, Protocol
|
|
from uuid import uuid4
|
|
|
|
from pydantic import TypeAdapter
|
|
from sqlalchemy import select
|
|
from sqlalchemy.orm import Session, sessionmaker
|
|
|
|
from configs import dify_config
|
|
from core.dify_builder.errors import ConflictError
|
|
from core.dify_builder.models import Actor
|
|
from libs.datetime_utils import ensure_naive_utc
|
|
from models.dify_builder import (
|
|
DifyBuilderExecutionRequest,
|
|
DifyBuilderSession,
|
|
DifyBuilderSessionCommit,
|
|
)
|
|
from models.dify_builder_native_tests import (
|
|
NativeEffectGrant,
|
|
NativeInvocationState,
|
|
NativePendingAction,
|
|
NativeTestInvocation,
|
|
NativeTestScope,
|
|
)
|
|
from models.enums import CreatorUserRole
|
|
from models.workflow import WorkflowRun
|
|
from runtime_contracts.builder.execution import BuilderExecutionPolicyError, canonical_digest
|
|
from runtime_contracts.builder.native.contracts import (
|
|
NativeTestContextRef,
|
|
WaitReason,
|
|
)
|
|
from services.dify_builder.execution_policy_service import check_execution_permission, load_execution_owners
|
|
from services.dify_builder.native_testing.authorization import EffectAuthorizationService
|
|
from services.dify_builder.native_testing.canvas_path import read_canvas_path
|
|
from services.dify_builder.native_testing.context_binding import NativeAdmissionCompiler, NativeRuntimeProfile
|
|
from services.dify_builder.native_testing.input_binding import current_input_binding
|
|
from services.dify_builder.native_testing.lifecycle import (
|
|
NativePendingResolutionPort,
|
|
NativeRuntimePort,
|
|
NativeTestLifecycleService,
|
|
)
|
|
from services.dify_builder.native_testing.pending_intents import (
|
|
PendingDecisionGuard,
|
|
ProtectedPendingIntentReader,
|
|
ResolvedPendingIntent,
|
|
)
|
|
from services.dify_builder.native_testing.private_reads import validate_private_read
|
|
from services.dify_builder.native_testing.public_contracts import NativeTestPublicReceipt, PendingActionDisclosure
|
|
from services.dify_builder.native_testing.public_projection import decision_public_revision, project_pending_decision
|
|
from services.dify_builder.native_testing.repository import (
|
|
NativeBindingResolver,
|
|
OwnedNativeTestLoader,
|
|
native_observation_transaction,
|
|
utc_now,
|
|
)
|
|
from services.dify_builder.native_testing.state_store import NativeExecutionStateStore, native_payload_read_scope
|
|
|
|
_WAIT_REASON = TypeAdapter(WaitReason)
|
|
_OPTIONAL_WAIT_REASON = TypeAdapter(WaitReason | None)
|
|
|
|
|
|
class NativeLaunchPort(Protocol):
|
|
"""Qualified initial dispatch owner must durably deduplicate by request ID."""
|
|
|
|
def launch(self, *, ref: NativeTestContextRef) -> None: ...
|
|
|
|
|
|
class NativeDisclosurePort(Protocol):
|
|
"""Capability owner supplies a safe, precise disclosure; never echo arbitrary input."""
|
|
|
|
def describe(self, *, actor: Actor, resolved: ResolvedPendingIntent) -> PendingActionDisclosure: ...
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class NativeAdmissionDependencies:
|
|
compiler: NativeAdmissionCompiler
|
|
profile: NativeRuntimeProfile
|
|
launch: NativeLaunchPort
|
|
|
|
|
|
class _UnavailableMetadata:
|
|
def resolve_binding(self, **_kwargs):
|
|
raise BuilderExecutionPolicyError("native_binding_unavailable")
|
|
|
|
def resolve_pending(self, **_kwargs):
|
|
raise BuilderExecutionPolicyError("native_pending_requires_resolution")
|
|
|
|
|
|
class NativeTestApiService:
|
|
def __init__(
|
|
self,
|
|
*,
|
|
session_factory: sessionmaker[Session],
|
|
actor: Actor,
|
|
resolver: NativeBindingResolver | None,
|
|
admission: NativeAdmissionDependencies | None,
|
|
resolution: NativePendingResolutionPort | None,
|
|
runtime: NativeRuntimePort | None,
|
|
disclosure: NativeDisclosurePort | None,
|
|
):
|
|
self.sessions, self.actor, self.admission, self.disclosure = session_factory, actor, admission, disclosure
|
|
self.loader = OwnedNativeTestLoader(session_factory, actor=actor, resolver=resolver or _UnavailableMetadata())
|
|
self.store = NativeExecutionStateStore(self.loader)
|
|
self.pending = ProtectedPendingIntentReader(loader=self.loader, state_store=self.store)
|
|
self.authorization = EffectAuthorizationService(self.loader, state_store=self.store)
|
|
self.lifecycle = NativeTestLifecycleService(
|
|
self.loader, state_store=self.store, resolution=resolution, runtime=runtime
|
|
)
|
|
|
|
def _actor(self, actor: Actor) -> None:
|
|
if actor != self.actor:
|
|
raise BuilderExecutionPolicyError("native_owner_mismatch")
|
|
|
|
def _request(self, *, actor: Actor, session_id: str, request_id: str) -> DifyBuilderExecutionRequest:
|
|
self._actor(actor)
|
|
with self.sessions() as session:
|
|
row = session.scalar(
|
|
select(DifyBuilderExecutionRequest).where(
|
|
DifyBuilderExecutionRequest.id == request_id,
|
|
DifyBuilderExecutionRequest.tenant_id == actor.tenant_id,
|
|
DifyBuilderExecutionRequest.actor_id == actor.account_id,
|
|
DifyBuilderExecutionRequest.session_id == session_id,
|
|
)
|
|
)
|
|
if row is None or row.context.get("policy_version") != "builder-native-test-v1":
|
|
raise BuilderExecutionPolicyError("native_owner_mismatch")
|
|
self._require_uncleared(session, session_id)
|
|
load_execution_owners(
|
|
session,
|
|
actor=actor,
|
|
app_id=row.app_id,
|
|
workflow_id=row.workflow_id,
|
|
session_id=session_id,
|
|
test_input_id=row.test_input_id,
|
|
)
|
|
if canonical_digest(row.context) != row.context_digest:
|
|
raise BuilderExecutionPolicyError("native_context_mismatch")
|
|
app_id = row.app_id
|
|
check_execution_permission(actor, app_id, self.sessions)
|
|
return row
|
|
|
|
def _require_uncleared(self, session, session_id: str) -> None:
|
|
owner = session.scalar(
|
|
select(DifyBuilderSession).where(
|
|
DifyBuilderSession.id == session_id,
|
|
DifyBuilderSession.tenant_id == self.actor.tenant_id,
|
|
DifyBuilderSession.owner_account_id == self.actor.account_id,
|
|
)
|
|
)
|
|
if owner is None:
|
|
raise BuilderExecutionPolicyError("native_owner_mismatch")
|
|
commit = session.scalar(
|
|
select(DifyBuilderSessionCommit).where(
|
|
DifyBuilderSessionCommit.session_id == owner.id,
|
|
DifyBuilderSessionCommit.version == owner.version,
|
|
)
|
|
)
|
|
if commit is not None and (commit.context.get("task_control") or {}).get("conversation_clear"):
|
|
raise BuilderExecutionPolicyError("native_owner_mismatch")
|
|
|
|
@staticmethod
|
|
def _command(kind: str, body: dict, key: str) -> tuple[str, str]:
|
|
return canonical_digest({"kind": kind, "key": key}), canonical_digest(body)
|
|
|
|
@staticmethod
|
|
def _saved(request: DifyBuilderExecutionRequest, key: str, body: str) -> NativeTestPublicReceipt | None:
|
|
for record in request.observations:
|
|
if record.get("native_command_key") == key:
|
|
if record.get("body_digest") != body:
|
|
raise BuilderExecutionPolicyError("native_idempotency_conflict")
|
|
return NativeTestPublicReceipt.model_validate(record["receipt"])
|
|
return None
|
|
|
|
def start(
|
|
self,
|
|
*,
|
|
actor: Actor,
|
|
session_id: str,
|
|
test_input_id: str,
|
|
expected_version: int,
|
|
mode: Literal["native", "mock"],
|
|
idempotency_key: str,
|
|
) -> NativeTestPublicReceipt:
|
|
self._actor(actor)
|
|
if self.admission is None:
|
|
raise BuilderExecutionPolicyError("native_runtime_unavailable")
|
|
key, body = self._command(
|
|
"start",
|
|
{"test_input_id": test_input_id, "expected_version": expected_version, "mode": mode},
|
|
idempotency_key,
|
|
)
|
|
# The session lock serializes same-key admission without adding a second
|
|
# idempotency table. SQL observations are bounded and never contain intent.
|
|
with self.sessions() as session:
|
|
self._require_uncleared(session, session_id)
|
|
builder = session.get(DifyBuilderSession, session_id)
|
|
if builder is None:
|
|
raise BuilderExecutionPolicyError("native_owner_mismatch")
|
|
load_execution_owners(
|
|
session, actor=actor, app_id=builder.app_id, session_id=session_id, test_input_id=test_input_id
|
|
)
|
|
existing = list(
|
|
session.scalars(
|
|
select(DifyBuilderExecutionRequest).where(
|
|
DifyBuilderExecutionRequest.session_id == session_id,
|
|
DifyBuilderExecutionRequest.tenant_id == actor.tenant_id,
|
|
DifyBuilderExecutionRequest.actor_id == actor.account_id,
|
|
)
|
|
)
|
|
)
|
|
for request in existing:
|
|
saved = self._saved(request, key, body)
|
|
if saved is not None:
|
|
launched = any(
|
|
record.get("native_command_key") == key and record.get("admission_launched") is True
|
|
for record in request.observations
|
|
)
|
|
if launched:
|
|
self.get(actor=actor, session_id=session_id, request_id=request.id)
|
|
return saved
|
|
# Recover a crash after SQL admission and before protected context
|
|
# or queue acknowledgement, without allocating new identities.
|
|
retry = self.admission.compiler.compile(
|
|
actor=actor,
|
|
session_id=session_id,
|
|
test_input_id=test_input_id,
|
|
expected_version=expected_version,
|
|
mode=mode,
|
|
request_id=request.id,
|
|
invocation_id=request.context["invocation_id"],
|
|
native_run_id=request.context["native_run_id"],
|
|
profile=self.admission.profile,
|
|
)
|
|
if retry.context.ref.context_digest != request.context_digest:
|
|
raise BuilderExecutionPolicyError("native_revision_drift")
|
|
self.store.initialize_context(context=retry.context)
|
|
self._launch(ref=retry.context.ref, command_key=key)
|
|
return saved
|
|
|
|
with self.sessions() as session:
|
|
binding = current_input_binding(session=session, actor=actor, session_id=session_id)
|
|
if (binding.test_input_id, binding.expected_version) != (test_input_id, expected_version):
|
|
raise BuilderExecutionPolicyError("native_revision_drift")
|
|
|
|
compilation = self.admission.compiler.compile(
|
|
actor=actor,
|
|
session_id=session_id,
|
|
test_input_id=test_input_id,
|
|
expected_version=expected_version,
|
|
mode=mode,
|
|
request_id=str(uuid4()),
|
|
invocation_id=str(uuid4()),
|
|
native_run_id=str(uuid4()),
|
|
profile=self.admission.profile,
|
|
)
|
|
context, request_context = compilation.context, compilation.request_context
|
|
receipt = NativeTestPublicReceipt(
|
|
request_id=context.ref.request_id,
|
|
generation=0,
|
|
version=1,
|
|
status="prepared",
|
|
wait_reason=None,
|
|
native_run_id=None,
|
|
native_status=None,
|
|
pending=(),
|
|
outcome=None,
|
|
evidence_refs=(),
|
|
)
|
|
with native_observation_transaction(self.sessions, session_id=lambda: session_id) as session:
|
|
from services.dify_builder.task_controls import lock_execution, lock_task_admission
|
|
|
|
try:
|
|
lock_task_admission(session, session_id)
|
|
lock_execution(session)
|
|
except ConflictError:
|
|
raise BuilderExecutionPolicyError("native_revision_drift") from None
|
|
self.admission.compiler.recheck_local(session=session, actor=actor, compilation=compilation)
|
|
for row in session.scalars(
|
|
select(DifyBuilderExecutionRequest).where(
|
|
DifyBuilderExecutionRequest.session_id == session_id,
|
|
DifyBuilderExecutionRequest.tenant_id == actor.tenant_id,
|
|
DifyBuilderExecutionRequest.actor_id == actor.account_id,
|
|
)
|
|
):
|
|
saved = self._saved(row, key, body)
|
|
if saved is not None:
|
|
return saved
|
|
current = current_input_binding(session=session, actor=actor, session_id=session_id)
|
|
if current != binding or current.graph_revision != compilation.context.graph_revision:
|
|
raise BuilderExecutionPolicyError("native_revision_drift")
|
|
limits = self.admission.profile.limits
|
|
if any(name not in limits for name in ("calls", "dispatches", "cost_units")):
|
|
raise BuilderExecutionPolicyError("native_profile_unavailable")
|
|
session.add(
|
|
DifyBuilderExecutionRequest(
|
|
id=context.ref.request_id,
|
|
tenant_id=actor.tenant_id,
|
|
actor_id=actor.account_id,
|
|
app_id=context.app_id,
|
|
workflow_id=context.workflow_id,
|
|
session_id=session_id,
|
|
test_input_id=test_input_id,
|
|
context=request_context,
|
|
context_digest=context.ref.context_digest,
|
|
state="running",
|
|
native_run_id=None,
|
|
observations=[
|
|
{"native_command_key": key, "body_digest": body, "receipt": receipt.model_dump(mode="json")}
|
|
],
|
|
)
|
|
)
|
|
session.add(
|
|
NativeTestScope(
|
|
request_id=context.ref.request_id,
|
|
tenant_id=actor.tenant_id,
|
|
generation=0,
|
|
task_approved=True,
|
|
cancelled=False,
|
|
# One admission wall-clock cap, including confirmation waits.
|
|
# Reconnect and existing admissions never renew this scope.
|
|
expires_at=utc_now()
|
|
+ timedelta(
|
|
seconds=min(dify_config.APP_MAX_EXECUTION_TIME, dify_config.WORKFLOW_MAX_EXECUTION_TIME)
|
|
),
|
|
max_calls=limits["calls"],
|
|
max_dispatches=limits["dispatches"],
|
|
max_cost_units=limits["cost_units"],
|
|
spent_calls=0,
|
|
spent_dispatches=0,
|
|
spent_cost_units=0,
|
|
budget_limits=limits,
|
|
budget_spent={},
|
|
profile=dict(limits),
|
|
)
|
|
)
|
|
session.add(
|
|
NativeTestInvocation(
|
|
tenant_id=actor.tenant_id,
|
|
request_id=context.ref.request_id,
|
|
invocation_id=context.ref.invocation_id,
|
|
active=True,
|
|
bindings={},
|
|
)
|
|
)
|
|
self.store.initialize_context(context=context)
|
|
self._launch(ref=context.ref, command_key=key)
|
|
return receipt
|
|
|
|
def _launch(self, *, ref: NativeTestContextRef, command_key: str) -> None:
|
|
if self.admission is None:
|
|
raise BuilderExecutionPolicyError("native_runtime_unavailable")
|
|
# A queue acknowledgement can be lost; the qualified launch owner must
|
|
# claim request_id durably before runtime execution, including retries.
|
|
from services.dify_builder.task_controls import check_current
|
|
|
|
check_current()
|
|
self.admission.launch.launch(ref=ref)
|
|
with self.loader.locked(ref=ref, settlement=True) as (session, _, _):
|
|
request = session.get(DifyBuilderExecutionRequest, ref.request_id)
|
|
if request is None:
|
|
raise BuilderExecutionPolicyError("native_owner_mismatch")
|
|
request.observations = [
|
|
{**record, "admission_launched": True} if record.get("native_command_key") == command_key else record
|
|
for record in request.observations
|
|
]
|
|
|
|
@native_payload_read_scope()
|
|
def get(self, *, actor: Actor, session_id: str, request_id: str) -> NativeTestPublicReceipt:
|
|
request = self._request(actor=actor, session_id=session_id, request_id=request_id)
|
|
with self.sessions() as session:
|
|
states = list(
|
|
session.scalars(
|
|
select(NativeInvocationState).where(
|
|
NativeInvocationState.tenant_id == actor.tenant_id,
|
|
NativeInvocationState.request_id == request_id,
|
|
)
|
|
)
|
|
)
|
|
pending_rows = list(
|
|
session.scalars(
|
|
select(NativePendingAction).where(
|
|
NativePendingAction.tenant_id == actor.tenant_id, NativePendingAction.request_id == request_id
|
|
)
|
|
)
|
|
)
|
|
grants = {
|
|
grant.scope_digest: grant
|
|
for grant in session.scalars(
|
|
select(NativeEffectGrant).where(
|
|
NativeEffectGrant.tenant_id == actor.tenant_id,
|
|
NativeEffectGrant.actor_id == actor.account_id,
|
|
NativeEffectGrant.request_id == request_id,
|
|
)
|
|
)
|
|
}
|
|
native_run = (
|
|
session.scalar(
|
|
select(WorkflowRun).where(
|
|
WorkflowRun.id == (request.native_run_id or request.context.get("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,
|
|
)
|
|
)
|
|
if (request.native_run_id or request.context.get("native_run_id"))
|
|
else None
|
|
)
|
|
scope = session.get(NativeTestScope, request_id)
|
|
cancellation = None
|
|
if scope is not None and scope.cancelled:
|
|
root_state = next((state for state in states if state.parent_invocation_id is None), None)
|
|
if root_state is None:
|
|
raise BuilderExecutionPolicyError("native_state_unavailable")
|
|
ref = NativeTestContextRef(
|
|
request_id=request_id, invocation_id=root_state.invocation_id, context_digest=request.context_digest
|
|
)
|
|
owned, scope = self.loader._load(session, ref=ref, lock=False, settlement=True)
|
|
cancellation = self.lifecycle.cancellation_receipt(session=session, owned=owned, scope=scope)
|
|
root = next((state for state in states if state.parent_invocation_id is None), None)
|
|
|
|
if root is None:
|
|
raise BuilderExecutionPolicyError("native_state_unavailable")
|
|
for state in states:
|
|
validate_private_read(
|
|
self.loader,
|
|
NativeTestContextRef(
|
|
request_id=request_id, invocation_id=state.invocation_id, context_digest=request.context_digest
|
|
),
|
|
)
|
|
active_pending = {
|
|
pending_id
|
|
for state in states
|
|
if cancellation is None and state.state == "waiting"
|
|
for pending_id in state.pending_ids
|
|
}
|
|
now = utc_now()
|
|
scope_expired = scope is not None and ensure_naive_utc(scope.expires_at) <= ensure_naive_utc(now)
|
|
pending = []
|
|
for row in pending_rows:
|
|
if row.id not in active_pending:
|
|
continue
|
|
if row.category == "approval":
|
|
if self.disclosure is None:
|
|
raise BuilderExecutionPolicyError("native_disclosure_unavailable")
|
|
resolved = self.pending.load(
|
|
actor=actor, request_id=request_id, pending_operation_id=row.id, for_projection=True
|
|
)
|
|
item = PendingActionDisclosure.model_validate(
|
|
self.disclosure.describe(actor=actor, resolved=resolved).model_dump(mode="json")
|
|
)
|
|
if (item.pending_operation_id, item.wait_reason, item.version, item.scope_digest) != (
|
|
row.id,
|
|
"approval",
|
|
row.generation + 1,
|
|
row.owner_version,
|
|
):
|
|
raise BuilderExecutionPolicyError("native_disclosure_mismatch")
|
|
if item.approval_limits != self.pending.load_terms(resolved=resolved, for_projection=True):
|
|
raise BuilderExecutionPolicyError("native_disclosure_mismatch")
|
|
projected = project_pending_decision(item, grant=grants.get(row.owner_version), now=now)
|
|
if scope_expired and projected.decision_status != "denied":
|
|
projected = projected.model_copy(update={"decision_status": "expired"})
|
|
pending.append(projected)
|
|
else:
|
|
pending.append(
|
|
PendingActionDisclosure(
|
|
pending_operation_id=row.id,
|
|
wait_reason=_WAIT_REASON.validate_python(row.category),
|
|
version=row.generation + 1,
|
|
scope_digest=None,
|
|
account_label=None,
|
|
action=row.category,
|
|
target=None,
|
|
parameters_summary=None,
|
|
risk_notice=None,
|
|
required_configuration=(row.reference,) if row.category == "configuration" else (),
|
|
native_form_ref=row.reference if row.category == "interaction" else None,
|
|
approval_limits=None,
|
|
continuation_blocked_reason="native_resolution_unavailable",
|
|
)
|
|
)
|
|
terminal = root.state in {"completed", "stopped", "unknown"}
|
|
outcome = None
|
|
if cancellation is not None:
|
|
# Accepted cancellation facts close the prior execution/wait view,
|
|
# including any root/pending snapshot read before cancellation.
|
|
if cancellation.status == "cancelling":
|
|
terminal = False
|
|
else:
|
|
terminal = True
|
|
outcome = cancellation.status
|
|
elif terminal:
|
|
outcome = "unknown" if root.state == "unknown" else "failed"
|
|
if root.state == "completed" and (root.outcome or {}).get("native_status") == "succeeded":
|
|
outcome = "succeeded"
|
|
can_resume, blocked = False, "not_waiting"
|
|
if root.state == "waiting" and cancellation is None:
|
|
if scope_expired:
|
|
blocked = "native_expired"
|
|
elif any(item.wait_reason != "approval" for item in pending):
|
|
blocked = "native_resolution_unavailable"
|
|
elif any(item.decision_status != "confirmed" for item in pending):
|
|
blocked = "approval_required"
|
|
else:
|
|
try:
|
|
self.lifecycle.check_resume(actor=actor, request_id=request_id, expected_generation=root.generation)
|
|
can_resume, blocked = True, None
|
|
except Exception:
|
|
# Missing storage, stale identity, unknown subtree or expired
|
|
# authority are never a reason to offer an enabled continuation.
|
|
blocked = "state_unavailable"
|
|
return NativeTestPublicReceipt(
|
|
decision_public_revision=decision_public_revision(request.observations),
|
|
can_resume=can_resume,
|
|
resume_blocked_reason=blocked,
|
|
cancellation=cancellation,
|
|
request_id=request_id,
|
|
generation=root.generation,
|
|
version=root.generation + 1,
|
|
status="terminal"
|
|
if terminal
|
|
else "running"
|
|
if cancellation is not None
|
|
else "waiting"
|
|
if root.state == "waiting"
|
|
else "running"
|
|
if native_run or root.state in {"cancelled", "queued"}
|
|
else "prepared",
|
|
wait_reason=_OPTIONAL_WAIT_REASON.validate_python(root.wait_reason)
|
|
if cancellation is None and root.state == "waiting"
|
|
else None,
|
|
native_run_id=native_run.id if native_run else None,
|
|
native_status=native_run.status if native_run else None,
|
|
canvas_path=read_canvas_path(request.canvas_path, native_run.id if native_run else None),
|
|
pending=tuple(pending),
|
|
outcome=outcome,
|
|
evidence_refs=(native_run.id,) if native_run else (),
|
|
)
|
|
|
|
@native_payload_read_scope()
|
|
def decide(
|
|
self,
|
|
*,
|
|
actor: Actor,
|
|
session_id: str,
|
|
request_id: str,
|
|
pending_operation_id: str,
|
|
decision: Literal["confirm", "deny"],
|
|
scope_digest: str,
|
|
expected_version: int,
|
|
idempotency_key: str,
|
|
) -> NativeTestPublicReceipt:
|
|
request = self._request(actor=actor, session_id=session_id, request_id=request_id)
|
|
key, body = self._command(
|
|
"decision",
|
|
{
|
|
"pending_operation_id": pending_operation_id,
|
|
"decision": decision,
|
|
"scope_digest": scope_digest,
|
|
"expected_version": expected_version,
|
|
},
|
|
idempotency_key,
|
|
)
|
|
current = self.get(actor=actor, session_id=session_id, request_id=request_id)
|
|
saved = self._saved(request, key, body)
|
|
if saved is not None:
|
|
return saved
|
|
resolved = self.pending.load(actor=actor, request_id=request_id, pending_operation_id=pending_operation_id)
|
|
terms = self.pending.load_terms(resolved=resolved)
|
|
context = self.store.load_context(ref=resolved.ref)
|
|
guard = PendingDecisionGuard(
|
|
resolved=resolved,
|
|
pending_operation_id=pending_operation_id,
|
|
expected_version=expected_version,
|
|
scope_digest=scope_digest,
|
|
idempotency_digest=key,
|
|
body_digest=body,
|
|
receipt=current,
|
|
terms=terms,
|
|
invocation_app_id=context.app_id,
|
|
invocation_workflow_id=context.workflow_id,
|
|
invocation_revision=context.graph_revision,
|
|
)
|
|
return self.authorization.decide(
|
|
ref=resolved.ref,
|
|
intent=resolved.intent,
|
|
decision="approved" if decision == "confirm" else "denied",
|
|
max_calls=terms.max_calls,
|
|
max_dispatches=terms.max_dispatches,
|
|
max_cost_units=terms.max_cost_units,
|
|
expires_at=datetime.fromisoformat(terms.expires_at),
|
|
pending_guard=guard,
|
|
)
|
|
|
|
@native_payload_read_scope()
|
|
def resume(
|
|
self, *, actor: Actor, session_id: str, request_id: str, expected_generation: int, idempotency_key: str
|
|
) -> NativeTestPublicReceipt:
|
|
self._request(actor=actor, session_id=session_id, request_id=request_id)
|
|
self.lifecycle.resume(
|
|
actor=actor, request_id=request_id, expected_generation=expected_generation, idempotency_key=idempotency_key
|
|
)
|
|
return self.get(actor=actor, session_id=session_id, request_id=request_id)
|
|
|
|
@native_payload_read_scope()
|
|
def cancel(
|
|
self, *, actor: Actor, session_id: str, request_id: str, idempotency_key: str
|
|
) -> NativeTestPublicReceipt:
|
|
self._request(actor=actor, session_id=session_id, request_id=request_id)
|
|
self.lifecycle.cancel(actor=actor, request_id=request_id, idempotency_key=idempotency_key)
|
|
return self.get(actor=actor, session_id=session_id, request_id=request_id)
|
|
|
|
def snapshots(self, *, actor: Actor, session_id: str) -> tuple[NativeTestPublicReceipt, ...]:
|
|
"""Current durable snapshots for the session's versioned reconnect stream."""
|
|
self._actor(actor)
|
|
with self.sessions() as session:
|
|
try:
|
|
self._require_uncleared(session, session_id)
|
|
except BuilderExecutionPolicyError:
|
|
return ()
|
|
request_ids = tuple(
|
|
session.scalars(
|
|
select(DifyBuilderExecutionRequest.id)
|
|
.where(
|
|
DifyBuilderExecutionRequest.session_id == session_id,
|
|
DifyBuilderExecutionRequest.tenant_id == actor.tenant_id,
|
|
DifyBuilderExecutionRequest.actor_id == actor.account_id,
|
|
DifyBuilderExecutionRequest.context["policy_version"].as_string() == "builder-native-test-v1",
|
|
)
|
|
.order_by(DifyBuilderExecutionRequest.created_at.desc())
|
|
.limit(100)
|
|
)
|
|
)
|
|
return tuple(self.get(actor=actor, session_id=session_id, request_id=request_id) for request_id in request_ids)
|