mirror of
https://github.com/langgenius/dify.git
synced 2026-11-11 22:02:23 +08:00
422 lines
18 KiB
Python
422 lines
18 KiB
Python
"""Project a completed ordinary Agent continuation into its existing Builder Run.
|
|
|
|
This is observation only: native result mapping and revision binding are reused,
|
|
and no generator, model, diagnosis or execution entry point is called. The final
|
|
transaction locks the Builder session before the workflow and request scope,
|
|
matching admission's lock order. It contains SQL and pure presentation only.
|
|
"""
|
|
|
|
from dataclasses import asdict, replace
|
|
from uuid import NAMESPACE_URL, uuid5
|
|
|
|
from pydantic import TypeAdapter, ValidationError
|
|
from sqlalchemy import String, cast, select
|
|
|
|
from core.dify_builder.errors import ConflictError
|
|
from core.dify_builder.models import Actor, EntryMode
|
|
from core.dify_builder.state import PcState
|
|
from models.dify_builder import (
|
|
DifyBuilderConversationItem,
|
|
DifyBuilderExecutionRequest,
|
|
DifyBuilderRun,
|
|
DifyBuilderSession,
|
|
DifyBuilderSessionCommit,
|
|
DifyBuilderTestInput,
|
|
)
|
|
from models.dify_builder_native_tests import NativeEffectOperation, NativeInvocationState, NativeTestScope
|
|
from models.enums import CreatorUserRole
|
|
from models.workflow import Workflow, WorkflowRun
|
|
from runtime_contracts.builder.execution import ExecutionEvidenceSummary, canonical_digest
|
|
from runtime_contracts.builder.native.contracts import ID, NativeTestContextRef
|
|
from runtime_contracts.builder.native.runtime_contracts import NativeWorkflowOutcome
|
|
from services.dify_builder import session_lock
|
|
from services.dify_builder.application.handlers_fix import append_card
|
|
from services.dify_builder.application.verification import has_output_blocker, result_card
|
|
from services.dify_builder.business_acceptance import business_failure_context, record_run_acceptance
|
|
from services.dify_builder.conversation_tasks import active_entry_mode
|
|
from services.dify_builder.dify_port import WorkflowServiceDifyPort
|
|
from services.dify_builder.native_testing.repository import admitted_workflow_revision
|
|
from services.dify_builder.repository import SqlDifyBuilderRepository
|
|
|
|
|
|
def _native_run_id(request: DifyBuilderExecutionRequest) -> str | None:
|
|
try:
|
|
planned = TypeAdapter(ID).validate_python(request.context.get("native_run_id"))
|
|
except ValidationError:
|
|
return None
|
|
# Ordinary admission retains the allocated identity in the protected
|
|
# context. Only the actual owned WorkflowRun below can acknowledge it.
|
|
if request.native_run_id is not None and request.native_run_id != planned:
|
|
return None
|
|
return request.native_run_id or planned
|
|
|
|
|
|
def _request(sql, *, actor, ref):
|
|
request = sql.scalar(
|
|
select(DifyBuilderExecutionRequest).where(
|
|
DifyBuilderExecutionRequest.id == ref.request_id,
|
|
DifyBuilderExecutionRequest.tenant_id == actor.tenant_id,
|
|
DifyBuilderExecutionRequest.actor_id == actor.account_id,
|
|
)
|
|
)
|
|
if (
|
|
request is None
|
|
or request.context_digest != ref.context_digest
|
|
or canonical_digest(request.context) != ref.context_digest
|
|
or request.context.get("policy_version") != ref.policy_version
|
|
or request.context.get("invocation_id") != ref.invocation_id
|
|
or (
|
|
request.context.get("tenant_id"),
|
|
request.context.get("actor_id"),
|
|
request.context.get("app_id"),
|
|
request.context.get("workflow_id"),
|
|
request.context.get("session_id"),
|
|
request.context.get("test_input_id"),
|
|
)
|
|
!= (
|
|
request.tenant_id,
|
|
request.actor_id,
|
|
request.app_id,
|
|
request.workflow_id,
|
|
request.session_id,
|
|
request.test_input_id,
|
|
)
|
|
or _native_run_id(request) is None
|
|
or request.context.get("profile", {}).get("profile_id") != "builder-agent-confirmed-v1"
|
|
):
|
|
return None
|
|
return request
|
|
|
|
|
|
def _terminal_proof(sql, *, request, ref):
|
|
scope = sql.scalar(
|
|
select(NativeTestScope)
|
|
.where(NativeTestScope.request_id == request.id, NativeTestScope.tenant_id == request.tenant_id)
|
|
.with_for_update()
|
|
)
|
|
state = sql.scalar(
|
|
select(NativeInvocationState).where(
|
|
NativeInvocationState.request_id == request.id,
|
|
NativeInvocationState.tenant_id == request.tenant_id,
|
|
NativeInvocationState.invocation_id == ref.invocation_id,
|
|
NativeInvocationState.parent_invocation_id.is_(None),
|
|
)
|
|
)
|
|
if scope is None or state is None or state.outcome is None:
|
|
return None
|
|
outcome = NativeWorkflowOutcome.model_validate(state.outcome)
|
|
declined = (
|
|
scope.cancelled
|
|
and state.state == "cancelled"
|
|
and outcome.status == "stopped"
|
|
and outcome.native_status == "stopped"
|
|
and outcome.reason_code == "effect_denied"
|
|
)
|
|
if not declined and (scope.cancelled or state.state != "completed" or outcome.status != "completed"):
|
|
return None
|
|
if (
|
|
outcome.ref != ref
|
|
or outcome.native_run_id != _native_run_id(request)
|
|
or state.generation != scope.generation
|
|
or outcome.pending_operation_ids
|
|
):
|
|
return None
|
|
operations = sql.scalars(
|
|
select(NativeEffectOperation).where(
|
|
NativeEffectOperation.request_id == request.id, NativeEffectOperation.tenant_id == request.tenant_id
|
|
)
|
|
).all()
|
|
if any(operation.state not in {"completed", "failed", "not_dispatched"} for operation in operations):
|
|
return None
|
|
native = sql.scalar(
|
|
select(WorkflowRun).where(
|
|
WorkflowRun.id == _native_run_id(request),
|
|
WorkflowRun.tenant_id == request.tenant_id,
|
|
WorkflowRun.app_id == request.app_id,
|
|
WorkflowRun.workflow_id == request.workflow_id,
|
|
WorkflowRun.created_by == request.actor_id,
|
|
WorkflowRun.created_by_role == CreatorUserRole.ACCOUNT,
|
|
)
|
|
)
|
|
if native is None or native.status != outcome.native_status:
|
|
return None
|
|
return outcome
|
|
|
|
|
|
def _write_run(row, run):
|
|
row.status, row.error, row.immutable = run.status, run.error, True
|
|
row.per_node = [asdict(node) for node in run.per_node]
|
|
row.verification = {
|
|
**asdict(run.verification),
|
|
"execution_evidence": run.verification.execution_evidence.model_dump(mode="json"),
|
|
}
|
|
row.tokens, row.elapsed_ms, row.culprit_node_id = run.tokens, run.elapsed_ms, run.culprit_node_id
|
|
|
|
|
|
def reconcile_builder_run(*, sessions, actor: Actor, ref: NativeTestContextRef) -> bool:
|
|
"""Idempotently close only the still-current saved-input attempt after C03.
|
|
|
|
Callers have already authenticated the scoped request through C04 or the
|
|
claimed resume owner. Reconnect retries this bounded projection after a lost
|
|
callback/CAS; conflicting user work always wins. No Redis or provider I/O is
|
|
needed inside a SQL writer. The existing advance lease protects active user
|
|
work, and a session CAS serializes duplicate terminal observers.
|
|
"""
|
|
with sessions() as sql:
|
|
request = _request(sql, actor=actor, ref=ref)
|
|
if request is None:
|
|
return False
|
|
token = session_lock.acquire(request.session_id)
|
|
if token is None:
|
|
return False
|
|
try:
|
|
return _project(sessions=sessions, actor=actor, ref=ref, request=request)
|
|
finally:
|
|
session_lock.release(request.session_id, token)
|
|
|
|
|
|
def _project(*, sessions, actor, ref, request):
|
|
repo = SqlDifyBuilderRepository(sessions)
|
|
session_id, test_input_id, native_run_id = request.session_id, request.test_input_id, _native_run_id(request)
|
|
if native_run_id is None:
|
|
return False
|
|
owner, context = repo.get_session(session_id)
|
|
mode = active_entry_mode(owner, context)
|
|
if mode == EntryMode.BUILD:
|
|
pending_states = {PcState.BUILD_EXECUTION, PcState.BUILD_AWAIT_REPAIR}
|
|
review_state, repair_state = PcState.BUILD_REVIEW, PcState.BUILD_AWAIT_REPAIR
|
|
elif mode == EntryMode.EDIT:
|
|
pending_states = {PcState.EDIT_TEST_AFFECTED_PATHS, PcState.EDIT_AWAIT_REPAIR}
|
|
review_state, repair_state = PcState.EDIT_REVIEW, PcState.EDIT_AWAIT_REPAIR
|
|
else:
|
|
return False
|
|
if (
|
|
(owner.tenant_id, owner.owner_account_id) != (actor.tenant_id, actor.account_id)
|
|
or owner.current_state not in pending_states
|
|
or context.paused
|
|
or context.recovery_class
|
|
or context.test_input_ref != test_input_id
|
|
or not context.verify_run_id
|
|
):
|
|
return False
|
|
reservation_id = str(uuid5(NAMESPACE_URL, f"dify-builder-full-run:{session_id}:{test_input_id}"))
|
|
with sessions() as sql:
|
|
if not _control_only_versions(sql, request=request, current_version=owner.version):
|
|
return False
|
|
current = sql.get(DifyBuilderRun, context.verify_run_id)
|
|
reservation = sql.get(DifyBuilderRun, reservation_id)
|
|
if (
|
|
any(
|
|
row is None
|
|
or (row.session_id, row.inputs_ref, row.dify_run_id) != (session_id, test_input_id, native_run_id)
|
|
for row in (current, reservation)
|
|
)
|
|
or current.kind != "verify"
|
|
or current.status != "running"
|
|
or reservation.kind != "native_test_launch"
|
|
):
|
|
return False
|
|
outcome = _terminal_proof(sql, request=request, ref=ref)
|
|
if outcome is None:
|
|
return False
|
|
declined = outcome.reason_code == "effect_denied" and outcome.status == "stopped"
|
|
if context.staged_repair and not declined:
|
|
return False
|
|
original = repo.get_run(reservation_id)
|
|
evidence = original.verification
|
|
if evidence is None or not evidence.execution_revision or not evidence.executed_graph_revision:
|
|
return False
|
|
port = WorkflowServiceDifyPort()
|
|
result = port._finish_run(actor.tenant_id, owner.app_id, {}, native_run_id)
|
|
run = port._bind_run(
|
|
result, owner.app_id, actor, evidence.execution_revision, evidence.executed_graph_revision, restricted=False
|
|
)
|
|
if (
|
|
run.status not in {"succeeded", "failed"}
|
|
or run.dify_run_id != native_run_id
|
|
or run.execution_refusal is not None
|
|
or run.verification is None
|
|
or run.verification.execution_revision != evidence.execution_revision
|
|
):
|
|
return False
|
|
previous_summary = evidence.execution_evidence
|
|
if previous_summary is None:
|
|
return False
|
|
run.verification.execution_evidence = ExecutionEvidenceSummary(
|
|
request_id=reservation_id,
|
|
policy_version="builder-confirmed-run-v1",
|
|
mode="confirmed_run",
|
|
sealed=True,
|
|
safety_outcome="confirmed_run_completed" if run.status == "succeeded" else "native_failed",
|
|
simulated_node_ids=previous_summary.simulated_node_ids,
|
|
sandbox_profile="native",
|
|
native_run_id=native_run_id,
|
|
)
|
|
# Continuations finish the original ordinary test, including its explicit
|
|
# acceptance oracle. Bind the Builder identity before recording its finding;
|
|
# the native mapper deliberately knows only the Workflow Run identity.
|
|
run = replace(
|
|
run, id=context.verify_run_id, session_id=session_id, kind="verify", inputs_ref=test_input_id, immutable=True
|
|
)
|
|
native_graph = result[1].graph_dict
|
|
record_run_acceptance(
|
|
owner, context, run, repo.get_test_input(test_input_id), native_graph, evidence.execution_revision
|
|
)
|
|
business_failure = business_failure_context(
|
|
run,
|
|
owner,
|
|
context,
|
|
current_revision=evidence.execution_revision,
|
|
current_graph_revision=evidence.executed_graph_revision,
|
|
)
|
|
if declined:
|
|
run.error = "Test declined by the user; continue adjusting to revise the plan."
|
|
card = result_card(run)
|
|
items = append_card(context, card)
|
|
for item in items:
|
|
item.at_version = owner.version + 1
|
|
context.diagnosis, context.staged_repair, context.unknown_outcome_count = None, [], 0
|
|
if declined:
|
|
context.confirmed_consent_id, context.confirmed_command_id = "", ""
|
|
context.effect_confirmation, context.effect_confirmation_form = {}, {}
|
|
next_state = (
|
|
review_state
|
|
if declined or run.status == "succeeded" and not has_output_blocker(run) and business_failure is None
|
|
else repair_state
|
|
)
|
|
try:
|
|
with sessions.begin() as sql:
|
|
session = sql.get(DifyBuilderSession, session_id, with_for_update=True)
|
|
if session is None or (
|
|
session.version,
|
|
session.current_state,
|
|
session.tenant_id,
|
|
session.owner_account_id,
|
|
session.app_id,
|
|
) != (owner.version, str(owner.current_state), actor.tenant_id, actor.account_id, owner.app_id):
|
|
return False
|
|
workflow = sql.scalar(
|
|
select(Workflow)
|
|
.where(
|
|
Workflow.id == request.workflow_id,
|
|
Workflow.tenant_id == actor.tenant_id,
|
|
Workflow.app_id == owner.app_id,
|
|
)
|
|
.with_for_update()
|
|
)
|
|
saved = sql.get(DifyBuilderTestInput, test_input_id)
|
|
fresh = _request(sql, actor=actor, ref=ref)
|
|
if (
|
|
workflow is None
|
|
or saved is None
|
|
or saved.session_id != session_id
|
|
or fresh is None
|
|
or admitted_workflow_revision(workflow, fresh.context) != fresh.context.get("execution_revision")
|
|
or canonical_digest(saved.inputs) != fresh.context.get("input_revision")
|
|
or _terminal_proof(sql, request=fresh, ref=ref) is None
|
|
):
|
|
return False
|
|
current = sql.get(DifyBuilderRun, context.verify_run_id, with_for_update=True)
|
|
reservation = sql.get(DifyBuilderRun, reservation_id, with_for_update=True)
|
|
if (
|
|
any(
|
|
row is None
|
|
or (row.session_id, row.inputs_ref, row.dify_run_id) != (session_id, test_input_id, native_run_id)
|
|
for row in (current, reservation)
|
|
)
|
|
or current.status != "running"
|
|
or reservation.status != "running"
|
|
):
|
|
return False
|
|
_write_run(current, run)
|
|
_write_run(reservation, run)
|
|
repo._compare_and_advance_in_session(sql, session_id, owner.version, next_state, context, items)
|
|
from services.dify_builder import progress_bus
|
|
|
|
progress_bus.publish_native_change(session_id)
|
|
return True
|
|
except ConflictError:
|
|
return False
|
|
|
|
|
|
def _control_only_versions(sql, *, request, current_version):
|
|
"""Accept only audited native controls after the original ordinary Run commit.
|
|
|
|
A user edit/recovery/model/input transition cannot inherit this exception.
|
|
The original immutable request/context and admission version stay unchanged.
|
|
"""
|
|
from services.dify_builder.test_actions import TEST_ACTIONS
|
|
|
|
baseline = request.context.get("expected_version", -2) + 1
|
|
if current_version < baseline:
|
|
return False
|
|
if current_version == baseline:
|
|
return True
|
|
commits = sql.scalars(
|
|
select(DifyBuilderSessionCommit)
|
|
.where(
|
|
DifyBuilderSessionCommit.session_id == request.session_id,
|
|
DifyBuilderSessionCommit.version.between(baseline, current_version),
|
|
)
|
|
.order_by(DifyBuilderSessionCommit.version)
|
|
).all()
|
|
if len(commits) != current_version - baseline + 1:
|
|
return False
|
|
ignored = {"next_seq", "last_command_id"}
|
|
previous = commits[0]
|
|
for commit in commits[1:]:
|
|
if commit.state != previous.state or (
|
|
{k: v for k, v in commit.context.items() if k not in ignored}
|
|
!= {k: v for k, v in previous.context.items() if k not in ignored}
|
|
):
|
|
return False
|
|
row = sql.scalar(
|
|
select(DifyBuilderConversationItem).where(
|
|
DifyBuilderConversationItem.session_id == request.session_id,
|
|
DifyBuilderConversationItem.at_version == commit.version,
|
|
DifyBuilderConversationItem.seq == previous.context.get("next_seq"),
|
|
DifyBuilderConversationItem.kind == "decision",
|
|
)
|
|
)
|
|
if (
|
|
row is None
|
|
or row.payload.get("request_id") != request.id
|
|
or row.payload.get("test_command_id") != commit.context.get("last_command_id")
|
|
or row.payload.get("test_action_kind") not in TEST_ACTIONS
|
|
or not row.payload.get("test_action_digest")
|
|
or commit.context.get("next_seq") != previous.context.get("next_seq", 0) + 1
|
|
):
|
|
return False
|
|
previous = commit
|
|
return True
|
|
|
|
|
|
def reconcile_completed_builder_runs(*, sessions, limit: int = 100) -> int:
|
|
"""Recover terminal evidence after a lost projection callback, never execution."""
|
|
with sessions() as sql:
|
|
requests = sql.scalars(
|
|
select(DifyBuilderExecutionRequest)
|
|
.join(
|
|
DifyBuilderRun,
|
|
(DifyBuilderRun.session_id == DifyBuilderExecutionRequest.session_id)
|
|
& (DifyBuilderRun.inputs_ref == cast(DifyBuilderExecutionRequest.test_input_id, String))
|
|
& (DifyBuilderRun.kind == "verify")
|
|
& (DifyBuilderRun.status == "running"),
|
|
)
|
|
.where(
|
|
DifyBuilderExecutionRequest.context["profile"]["profile_id"].as_string()
|
|
== "builder-agent-confirmed-v1",
|
|
DifyBuilderExecutionRequest.state == "sealed",
|
|
)
|
|
.order_by(DifyBuilderExecutionRequest.created_at)
|
|
.limit(limit)
|
|
).all()
|
|
recovered = 0
|
|
for request in requests:
|
|
actor = Actor(tenant_id=request.tenant_id, account_id=request.actor_id)
|
|
ref = NativeTestContextRef(
|
|
request_id=request.id, invocation_id=request.context["invocation_id"], context_digest=request.context_digest
|
|
)
|
|
recovered += int(reconcile_builder_run(sessions=sessions, actor=actor, ref=ref))
|
|
return recovered
|