mirror of
https://github.com/langgenius/dify.git
synced 2026-11-11 22:02:23 +08:00
1696 lines
75 KiB
Python
1696 lines
75 KiB
Python
"""Protected native snapshots with a storage-readback + SQL acknowledgement barrier.
|
|
|
|
The native adapter owns serialization. This owner stores immutable bytes, never
|
|
runs Graphon, and never treats a caller's object reference as authorization.
|
|
All key-provider and object-storage work happens outside SQL transactions.
|
|
"""
|
|
|
|
import json
|
|
from contextlib import contextmanager
|
|
from dataclasses import dataclass
|
|
from typing import Literal
|
|
from uuid import UUID, uuid4
|
|
|
|
from pydantic import TypeAdapter
|
|
from sqlalchemy import select
|
|
|
|
from core.helper import encrypter
|
|
from extensions.ext_storage import storage
|
|
from models.agent import (
|
|
Agent,
|
|
AgentConfigSnapshot,
|
|
AgentConfigVersionKind,
|
|
AgentHomeSnapshot,
|
|
AgentWorkingResourceStatus,
|
|
AgentWorkspace,
|
|
AgentWorkspaceBinding,
|
|
AgentWorkspaceOwnerType,
|
|
WorkflowAgentBindingType,
|
|
WorkflowAgentNodeBinding,
|
|
)
|
|
from models.agent_config_entities import AgentSoulConfig, WorkflowNodeJobConfig
|
|
from models.dify_builder import DifyBuilderExecutionRequest, DifyBuilderTestInput
|
|
from models.dify_builder_native_tests import (
|
|
NativeContinuation,
|
|
NativeEffectOperation,
|
|
NativeInvocationState,
|
|
NativePayloadObject,
|
|
NativeTestInvocation,
|
|
)
|
|
from models.workflow import Workflow
|
|
from runtime_contracts.builder.execution import (
|
|
BuilderExecutionPolicyError,
|
|
canonical_digest,
|
|
)
|
|
from runtime_contracts.builder.native.agent_contracts import (
|
|
AgentCreateClaim,
|
|
AgentCreateExisting,
|
|
AgentCreateInserted,
|
|
AgentDefinitionReceipt,
|
|
AgentNodeAssociation,
|
|
AgentParentNodeRef,
|
|
AgentPreparedConfig,
|
|
AgentProtectedState,
|
|
AgentTestWorkspaceRef,
|
|
AgentWaitAssociation,
|
|
)
|
|
from runtime_contracts.builder.native.contracts import (
|
|
ID,
|
|
CheckpointRef,
|
|
Counter,
|
|
FiniteJSON,
|
|
JsonValue,
|
|
NativeTestContextRef,
|
|
OperationKey,
|
|
operation_digest,
|
|
)
|
|
from runtime_contracts.builder.native.runtime_contracts import (
|
|
NativeCheckpointPayload,
|
|
ResolvedNativeTestContext,
|
|
)
|
|
from services.dify_builder.native_testing.private_reads import (
|
|
PrivateReadOwner,
|
|
validate_invocation_workflow,
|
|
validate_private_read,
|
|
)
|
|
from services.dify_builder.native_testing.repository import (
|
|
OwnedNativeTestLoader,
|
|
admitted_graph_matches,
|
|
admitted_workflow_revision,
|
|
native_workflow_revision,
|
|
utc_now,
|
|
)
|
|
from services.dify_builder.native_testing.transitions import require_ancestor_authority
|
|
|
|
_JSON = TypeAdapter(FiniteJSON)
|
|
PayloadKind = Literal["context", "checkpoint", "result"]
|
|
|
|
|
|
def encode_payload(
|
|
*, tenant_id: str, request_id: str, invocation_id: str, kind: str, payload: JsonValue
|
|
) -> tuple[str, str]:
|
|
value = _JSON.validate_python(payload)
|
|
envelope = {
|
|
"schema": "1",
|
|
"tenant_id": tenant_id,
|
|
"request_id": request_id,
|
|
"invocation_id": invocation_id,
|
|
"kind": kind,
|
|
"payload": value,
|
|
}
|
|
return json.dumps(
|
|
envelope, ensure_ascii=False, sort_keys=True, separators=(",", ":"), allow_nan=False
|
|
), canonical_digest(envelope)
|
|
|
|
|
|
def decode_payload(
|
|
raw: str, *, tenant_id: str, request_id: str, invocation_id: str, kind: str, digest: str
|
|
) -> JsonValue:
|
|
try:
|
|
envelope = _JSON.validate_python(json.loads(raw))
|
|
if not isinstance(envelope, dict) or set(envelope) != {
|
|
"schema",
|
|
"tenant_id",
|
|
"request_id",
|
|
"invocation_id",
|
|
"kind",
|
|
"payload",
|
|
}:
|
|
raise ValueError("envelope")
|
|
expected = {
|
|
"schema": "1",
|
|
"tenant_id": tenant_id,
|
|
"request_id": request_id,
|
|
"invocation_id": invocation_id,
|
|
"kind": kind,
|
|
}
|
|
if any(envelope.get(k) != v for k, v in expected.items()) or canonical_digest(envelope) != digest:
|
|
raise ValueError("ownership or integrity")
|
|
return envelope["payload"]
|
|
except (ValueError, TypeError, UnicodeError):
|
|
raise BuilderExecutionPolicyError("native_payload_invalid") from None
|
|
|
|
|
|
def owner_query(model, ref: NativeTestContextRef, tenant_id: str):
|
|
return select(model).where(
|
|
model.tenant_id == tenant_id, model.request_id == ref.request_id, model.invocation_id == ref.invocation_id
|
|
)
|
|
|
|
|
|
def invocation_state(session, ref: NativeTestContextRef, tenant_id: str) -> NativeInvocationState:
|
|
state = session.scalar(owner_query(NativeInvocationState, ref, tenant_id))
|
|
if state is None:
|
|
raise BuilderExecutionPolicyError("native_state_unavailable")
|
|
return state
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class ObjectReceipt:
|
|
id: str
|
|
tenant_id: str
|
|
request_id: str
|
|
invocation_id: str
|
|
kind: str
|
|
logical_key: str
|
|
storage_key: str
|
|
payload_digest: str
|
|
value_digest: str
|
|
|
|
@classmethod
|
|
def from_payload_object(cls, row: NativePayloadObject) -> "ObjectReceipt":
|
|
"""Detach the persisted payload identity from its SQLAlchemy session."""
|
|
return cls(
|
|
id=row.id,
|
|
tenant_id=row.tenant_id,
|
|
request_id=row.request_id,
|
|
invocation_id=row.invocation_id,
|
|
kind=row.kind,
|
|
logical_key=row.logical_key,
|
|
storage_key=row.storage_key,
|
|
payload_digest=row.payload_digest,
|
|
value_digest=row.value_digest,
|
|
)
|
|
|
|
|
|
@dataclass
|
|
class _AgentPreparation:
|
|
parent: AgentParentNodeRef
|
|
context: ResolvedNativeTestContext
|
|
authority: PrivateReadOwner
|
|
workspace: AgentTestWorkspaceRef
|
|
resource_digest: str
|
|
objects: list[ObjectReceipt]
|
|
settlement: bool = False
|
|
for_projection: bool = False
|
|
|
|
|
|
@contextmanager
|
|
def _agent_boundary():
|
|
try:
|
|
yield
|
|
except BuilderExecutionPolicyError:
|
|
raise
|
|
except Exception:
|
|
raise BuilderExecutionPolicyError("native_agent_state_unavailable") from None
|
|
|
|
|
|
class NativeExecutionStateStore:
|
|
def __init__(self, loader: OwnedNativeTestLoader):
|
|
self.loader = loader
|
|
|
|
def prepare_object(
|
|
self,
|
|
*,
|
|
ref: NativeTestContextRef,
|
|
tenant_id: str,
|
|
kind: PayloadKind,
|
|
logical_key: str,
|
|
payload: JsonValue,
|
|
max_bytes: int | None = None,
|
|
) -> ObjectReceipt:
|
|
"""Internal producer port: caller must subsequently acknowledge under the root lock."""
|
|
raw, digest = encode_payload(
|
|
tenant_id=tenant_id,
|
|
request_id=ref.request_id,
|
|
invocation_id=ref.invocation_id,
|
|
kind=kind,
|
|
payload=payload,
|
|
)
|
|
identity = str(uuid4())
|
|
key = f"builder-native/{identity}"
|
|
encrypted = encrypter.encrypt_token(tenant_id, raw).encode("ascii")
|
|
if max_bytes is not None and len(encrypted) > max_bytes:
|
|
raise BuilderExecutionPolicyError("native_agent_config_unavailable")
|
|
storage.save(key, encrypted)
|
|
readback = storage.load(key)
|
|
if max_bytes is not None and len(readback) > max_bytes:
|
|
raise BuilderExecutionPolicyError("native_agent_config_unavailable")
|
|
roundtrip = encrypter.decrypt_token(tenant_id, readback.decode("ascii"))
|
|
if max_bytes is not None and len(roundtrip.encode("utf-8")) > max_bytes:
|
|
raise BuilderExecutionPolicyError("native_agent_config_unavailable")
|
|
value = decode_payload(
|
|
roundtrip,
|
|
tenant_id=tenant_id,
|
|
request_id=ref.request_id,
|
|
invocation_id=ref.invocation_id,
|
|
kind=kind,
|
|
digest=digest,
|
|
)
|
|
return ObjectReceipt(
|
|
identity,
|
|
tenant_id,
|
|
ref.request_id,
|
|
ref.invocation_id,
|
|
kind,
|
|
logical_key,
|
|
key,
|
|
digest,
|
|
canonical_digest(value),
|
|
)
|
|
|
|
@staticmethod
|
|
def acknowledge_object(session, receipt: ObjectReceipt) -> NativePayloadObject:
|
|
existing = session.scalar(
|
|
select(NativePayloadObject).where(
|
|
NativePayloadObject.tenant_id == receipt.tenant_id,
|
|
NativePayloadObject.request_id == receipt.request_id,
|
|
NativePayloadObject.invocation_id == receipt.invocation_id,
|
|
NativePayloadObject.kind == receipt.kind,
|
|
NativePayloadObject.logical_key == receipt.logical_key,
|
|
)
|
|
)
|
|
if existing is not None:
|
|
if (existing.payload_digest, existing.value_digest) != (receipt.payload_digest, receipt.value_digest):
|
|
raise BuilderExecutionPolicyError("native_payload_conflict")
|
|
return existing
|
|
row = NativePayloadObject(**vars(receipt))
|
|
session.add(row)
|
|
session.flush()
|
|
return row
|
|
|
|
@staticmethod
|
|
def _read_object(row: NativePayloadObject | ObjectReceipt, *, max_bytes: int | None = None) -> JsonValue:
|
|
try:
|
|
stored = storage.load(row.storage_key)
|
|
if max_bytes is not None and len(stored) > max_bytes:
|
|
raise BuilderExecutionPolicyError("native_agent_config_unavailable")
|
|
raw = encrypter.decrypt_token(row.tenant_id, stored.decode("ascii"))
|
|
if max_bytes is not None and len(raw.encode("utf-8")) > max_bytes:
|
|
raise BuilderExecutionPolicyError("native_agent_config_unavailable")
|
|
value = decode_payload(
|
|
raw,
|
|
tenant_id=row.tenant_id,
|
|
request_id=row.request_id,
|
|
invocation_id=row.invocation_id,
|
|
kind=row.kind,
|
|
digest=row.payload_digest,
|
|
)
|
|
if canonical_digest(value) != row.value_digest:
|
|
raise BuilderExecutionPolicyError("native_payload_invalid")
|
|
return value
|
|
except BuilderExecutionPolicyError:
|
|
raise
|
|
except Exception:
|
|
raise BuilderExecutionPolicyError("native_payload_unavailable") from None
|
|
|
|
def initialize_context(self, *, context: ResolvedNativeTestContext) -> None:
|
|
"""Trusted admission only, after C02 request/scope creation; child creation has its own port."""
|
|
context = ResolvedNativeTestContext.model_validate(context.model_dump(mode="json"))
|
|
owned = self.loader.load(ref=context.ref)
|
|
if (
|
|
context.tenant_id,
|
|
context.actor_id,
|
|
context.app_id,
|
|
context.workflow_id,
|
|
context.parent_invocation_id,
|
|
context.generation,
|
|
context.call_depth,
|
|
context.budget_ref,
|
|
context.cancellation_ref,
|
|
) != (
|
|
owned.tenant_id,
|
|
owned.actor_id,
|
|
owned.app_id,
|
|
owned.workflow_id,
|
|
None,
|
|
owned.generation,
|
|
0,
|
|
owned.ref.request_id,
|
|
owned.ref.request_id,
|
|
):
|
|
raise BuilderExecutionPolicyError("native_context_mismatch")
|
|
with self.loader.session_factory() as session:
|
|
workflow = session.get(Workflow, owned.workflow_id)
|
|
test_input = session.get(DifyBuilderTestInput, owned.test_input_id)
|
|
revision_context = owned.revision_context
|
|
if (
|
|
workflow is None
|
|
or test_input is None
|
|
or not admitted_graph_matches(workflow, context.graph, revision_context)
|
|
or context.graph_revision
|
|
!= (
|
|
admitted_workflow_revision(workflow, revision_context)
|
|
if revision_context
|
|
else native_workflow_revision(workflow)
|
|
)
|
|
or canonical_digest(context.inputs) != canonical_digest(test_input.inputs)
|
|
or context.input_revision != canonical_digest(context.inputs)
|
|
):
|
|
raise BuilderExecutionPolicyError("native_snapshot_mismatch")
|
|
receipt = self.prepare_object(
|
|
ref=context.ref,
|
|
tenant_id=owned.tenant_id,
|
|
kind="context",
|
|
logical_key="context",
|
|
payload=context.model_dump(mode="json"),
|
|
)
|
|
with self.loader.locked(ref=context.ref) as (session, current, _):
|
|
if current.generation != context.generation:
|
|
raise BuilderExecutionPolicyError("native_generation_conflict")
|
|
row = self.acknowledge_object(session, receipt)
|
|
existing = session.scalar(owner_query(NativeInvocationState, context.ref, owned.tenant_id))
|
|
if existing is None:
|
|
session.add(
|
|
NativeInvocationState(
|
|
tenant_id=owned.tenant_id,
|
|
request_id=context.ref.request_id,
|
|
invocation_id=context.ref.invocation_id,
|
|
context_object_id=row.id,
|
|
app_id=context.app_id,
|
|
workflow_id=context.workflow_id,
|
|
parent_invocation_id=None,
|
|
parent_operation_digest=None,
|
|
child_identity_digest=None,
|
|
generation=context.generation,
|
|
state="running",
|
|
checkpoint_id=None,
|
|
pending_ids=[],
|
|
wait_reason=None,
|
|
outcome=None,
|
|
resolution=[],
|
|
cancellation_key=None,
|
|
)
|
|
)
|
|
elif existing.context_object_id != row.id:
|
|
raise BuilderExecutionPolicyError("native_snapshot_mismatch")
|
|
|
|
def load_context(self, *, ref: NativeTestContextRef) -> ResolvedNativeTestContext:
|
|
return self._load_context(ref=ref, recovery=False)
|
|
|
|
def load_projection_context(self, *, ref: NativeTestContextRef) -> ResolvedNativeTestContext:
|
|
"""Current owned read visibility survives expiry; this grants no execution."""
|
|
return self._load_context(ref=ref, recovery=False, allow_expired=True)
|
|
|
|
def load_recovery_context(self, *, ref: NativeTestContextRef) -> ResolvedNativeTestContext:
|
|
"""Read-only recovery material; an inactive invocation gains no authority."""
|
|
return self._load_context(ref=ref, recovery=True)
|
|
|
|
def _load_context(
|
|
self, *, ref: NativeTestContextRef, recovery: bool, allow_expired: bool = False
|
|
) -> ResolvedNativeTestContext:
|
|
owned = validate_private_read(self.loader, ref)
|
|
if owned.cancelled:
|
|
raise BuilderExecutionPolicyError("native_cancelled")
|
|
if not recovery and (not owned.active or owned.request_state not in {"running", "waiting"}):
|
|
raise BuilderExecutionPolicyError("native_invocation_mismatch")
|
|
if not allow_expired and owned.expires_at <= utc_now():
|
|
raise BuilderExecutionPolicyError("native_expired")
|
|
with self.loader.session_factory() as session:
|
|
row = session.scalar(
|
|
owner_query(NativePayloadObject, ref, owned.tenant_id).where(
|
|
NativePayloadObject.id == owned.context_object_id, NativePayloadObject.kind == "context"
|
|
)
|
|
)
|
|
if row is None:
|
|
raise BuilderExecutionPolicyError("native_state_unavailable")
|
|
context = ResolvedNativeTestContext.model_validate(self._read_object(row))
|
|
if (
|
|
context.ref,
|
|
context.tenant_id,
|
|
context.actor_id,
|
|
context.app_id,
|
|
context.workflow_id,
|
|
context.parent_invocation_id,
|
|
) != (ref, owned.tenant_id, owned.actor_id, owned.app_id, owned.workflow_id, owned.parent_invocation_id):
|
|
raise BuilderExecutionPolicyError("native_context_mismatch")
|
|
if not owned.root_revision_matches or context.graph_revision != owned.graph_revision:
|
|
raise BuilderExecutionPolicyError("native_revision_drift")
|
|
if owned.input_revision is not None and owned.input_revision != context.input_revision:
|
|
raise BuilderExecutionPolicyError("native_input_drift")
|
|
return context.model_copy(update={"generation": owned.generation})
|
|
|
|
def load_terminal_context(self, *, ref: NativeTestContextRef) -> ResolvedNativeTestContext:
|
|
"""Observe an already terminal owned Run after expiry, never a continuation."""
|
|
from models.enums import CreatorUserRole
|
|
from models.workflow import WorkflowRun
|
|
|
|
context = self._load_context(ref=ref, recovery=False, allow_expired=True)
|
|
with self.loader.session_factory() as session:
|
|
run = session.scalar(
|
|
select(WorkflowRun).where(
|
|
WorkflowRun.id == context.native_run_id,
|
|
WorkflowRun.tenant_id == context.tenant_id,
|
|
WorkflowRun.app_id == context.app_id,
|
|
WorkflowRun.workflow_id == context.workflow_id,
|
|
WorkflowRun.created_by == context.actor_id,
|
|
WorkflowRun.created_by_role == CreatorUserRole.ACCOUNT,
|
|
)
|
|
)
|
|
if run is None or run.status not in {"succeeded", "failed", "partial-succeeded", "stopped"}:
|
|
raise BuilderExecutionPolicyError("native_expired")
|
|
return context
|
|
|
|
def commit_checkpoint(
|
|
self, *, ref: NativeTestContextRef, generation: int, payload: NativeCheckpointPayload
|
|
) -> CheckpointRef:
|
|
try:
|
|
payload = NativeCheckpointPayload.model_validate(payload.model_dump(mode="json"))
|
|
context = self.load_context(ref=ref)
|
|
if type(generation) is not int or generation != context.generation or payload.generation != generation:
|
|
raise BuilderExecutionPolicyError("native_generation_conflict")
|
|
if context.native_run_id != payload.native_run_id:
|
|
raise BuilderExecutionPolicyError("native_run_mismatch")
|
|
agent_preparations = self._agent_checkpoint_preparations(ref, payload)
|
|
# Validate every referenced private input/result and child owner before storing.
|
|
for result_ref in (
|
|
*payload.input_result_refs,
|
|
*(r for j in payload.node_journals for r in (j.input_result_ref, *j.result_refs)),
|
|
):
|
|
self.load_result(ref=ref, result_ref=result_ref)
|
|
for journal in payload.node_journals:
|
|
if journal.invocation_id != ref.invocation_id or any(
|
|
k.invocation_id != ref.invocation_id for k in journal.operation_keys
|
|
):
|
|
raise BuilderExecutionPolicyError("native_journal_owner_mismatch")
|
|
if (
|
|
canonical_digest(self.load_result(ref=ref, result_ref=journal.input_result_ref))
|
|
!= journal.input_digest
|
|
):
|
|
raise BuilderExecutionPolicyError("native_journal_input_mismatch")
|
|
with self.loader.session_factory() as session:
|
|
for child in payload.child_refs:
|
|
state = invocation_state(session, child, context.tenant_id)
|
|
if (
|
|
child.request_id != ref.request_id
|
|
or child.context_digest != ref.context_digest
|
|
or state.parent_invocation_id != ref.invocation_id
|
|
):
|
|
raise BuilderExecutionPolicyError("native_child_owner_mismatch")
|
|
receipt = self.prepare_object(
|
|
ref=ref,
|
|
tenant_id=context.tenant_id,
|
|
kind="checkpoint",
|
|
logical_key=str(generation),
|
|
payload=payload.model_dump(
|
|
mode="json", exclude={"agent_associations"} if not payload.agent_associations else set()
|
|
),
|
|
)
|
|
with self.loader.locked(ref=ref) as (session, owned, scope):
|
|
state = invocation_state(session, ref, owned.tenant_id)
|
|
if state.generation != generation or state.state not in {"running", "waiting"}:
|
|
raise BuilderExecutionPolicyError("native_generation_conflict")
|
|
for prepared in agent_preparations:
|
|
self._agent_recheck(session, owned, scope, prepared, writing=False)
|
|
obj = self.acknowledge_object(session, receipt)
|
|
prior = session.scalar(
|
|
owner_query(NativeContinuation, ref, owned.tenant_id).where(
|
|
NativeContinuation.generation == generation
|
|
)
|
|
)
|
|
if prior is None:
|
|
prior = NativeContinuation(
|
|
tenant_id=owned.tenant_id,
|
|
request_id=ref.request_id,
|
|
invocation_id=ref.invocation_id,
|
|
generation=generation,
|
|
checkpoint_id=obj.id,
|
|
payload_digest=obj.payload_digest,
|
|
payload_ref=obj.storage_key,
|
|
)
|
|
session.add(prior)
|
|
result = CheckpointRef(checkpoint_id=prior.checkpoint_id, payload_digest=prior.payload_digest)
|
|
return result
|
|
except BuilderExecutionPolicyError:
|
|
raise
|
|
except Exception:
|
|
raise BuilderExecutionPolicyError("checkpoint_unavailable") from None
|
|
|
|
def read_checkpoint_for_cancellation(
|
|
self, *, ref: NativeTestContextRef, checkpoint: CheckpointRef
|
|
) -> NativeCheckpointPayload:
|
|
"""Active cancellation metadata only; expiry grants no new execution.
|
|
|
|
Current actor, revision, invocation and private Agent resource checks
|
|
remain mandatory. Revoked or inactive requests cannot enter this path.
|
|
"""
|
|
self.loader.load(ref=ref)
|
|
return self.read_checkpoint(ref=ref, checkpoint=checkpoint, for_projection=True)
|
|
|
|
def read_checkpoint(
|
|
self, *, ref: NativeTestContextRef, checkpoint: CheckpointRef, for_projection: bool = False
|
|
) -> NativeCheckpointPayload:
|
|
"""Read current protected facts; only server-selected projection ignores TTL.
|
|
|
|
Projection retains active ownership, revision, input and Agent resource
|
|
checks and cannot enter a writing Agent preparation or execution path.
|
|
"""
|
|
owned = validate_private_read(self.loader, ref)
|
|
with self.loader.session_factory() as session:
|
|
continuation = session.scalar(
|
|
owner_query(NativeContinuation, ref, owned.tenant_id).where(
|
|
NativeContinuation.checkpoint_id == checkpoint.checkpoint_id,
|
|
NativeContinuation.payload_digest == checkpoint.payload_digest,
|
|
)
|
|
)
|
|
row = session.scalar(
|
|
owner_query(NativePayloadObject, ref, owned.tenant_id).where(
|
|
NativePayloadObject.id == checkpoint.checkpoint_id, NativePayloadObject.kind == "checkpoint"
|
|
)
|
|
)
|
|
if (
|
|
continuation is None
|
|
or row is None
|
|
or row.payload_digest != checkpoint.payload_digest
|
|
or row.storage_key != continuation.payload_ref
|
|
):
|
|
raise BuilderExecutionPolicyError("checkpoint_unavailable")
|
|
payload = NativeCheckpointPayload.model_validate(self._read_object(row))
|
|
if payload.agent_associations:
|
|
with _agent_boundary():
|
|
if payload.generation != continuation.generation:
|
|
raise BuilderExecutionPolicyError("native_generation_conflict")
|
|
prepared = self._agent_checkpoint_preparations(ref, payload, for_projection=for_projection)
|
|
outer = ObjectReceipt.from_payload_object(row)
|
|
with self.loader.locked(ref=ref) as (session, current, scope):
|
|
selected_continuation = session.scalar(
|
|
owner_query(NativeContinuation, ref, current.tenant_id).where(
|
|
NativeContinuation.checkpoint_id == checkpoint.checkpoint_id,
|
|
NativeContinuation.payload_digest == checkpoint.payload_digest,
|
|
)
|
|
)
|
|
selected_object = session.scalar(
|
|
owner_query(NativePayloadObject, ref, current.tenant_id).where(
|
|
NativePayloadObject.id == checkpoint.checkpoint_id, NativePayloadObject.kind == "checkpoint"
|
|
)
|
|
)
|
|
if selected_continuation is None or selected_continuation.generation != payload.generation:
|
|
raise BuilderExecutionPolicyError("native_generation_conflict")
|
|
if (
|
|
selected_object is None
|
|
or selected_continuation.payload_ref != outer.storage_key
|
|
or ObjectReceipt.from_payload_object(selected_object) != outer
|
|
):
|
|
raise BuilderExecutionPolicyError("native_agent_owner_mismatch")
|
|
for selected in prepared:
|
|
self._agent_recheck(session, current, scope, selected, writing=False)
|
|
return payload
|
|
|
|
def store_result(self, *, ref: NativeTestContextRef, key: OperationKey, result: JsonValue) -> str:
|
|
if key.invocation_id != ref.invocation_id:
|
|
raise BuilderExecutionPolicyError("native_result_owner_mismatch")
|
|
logical_key = operation_digest(key)
|
|
# A cancelled/revoked invocation may still save the result of an already
|
|
# spent operation, matching C02's settlement path. This creates no I/O.
|
|
with self.loader.session_factory() as session:
|
|
owned, _ = self.loader._load(session, ref=ref, lock=False, settlement=True)
|
|
spent = (
|
|
session.scalar(
|
|
owner_query(NativeEffectOperation, ref, owned.tenant_id).where(
|
|
NativeEffectOperation.operation_digest == logical_key,
|
|
NativeEffectOperation.state.in_(("claimed", "completed")),
|
|
)
|
|
)
|
|
is not None
|
|
)
|
|
if not spent:
|
|
self.loader.load(ref=ref)
|
|
validate_private_read(self.loader, ref)
|
|
receipt = self.prepare_object(
|
|
ref=ref, tenant_id=owned.tenant_id, kind="result", logical_key=logical_key, payload=result
|
|
)
|
|
with self.loader.locked(ref=ref, settlement=spent) as (session, _, _):
|
|
row = self.acknowledge_object(session, receipt)
|
|
result_ref = row.id
|
|
return result_ref
|
|
|
|
def store_node_inputs(
|
|
self, *, ref: NativeTestContextRef, generation: int, native_execution_id: str, inputs: JsonValue
|
|
) -> str:
|
|
"""Persist typed inputs independently of effect result slots, without issuing authority.
|
|
|
|
The trusted producer must obtain the UUID from its attached, bound native
|
|
node. UUID syntax alone establishes no native execution provenance.
|
|
"""
|
|
try:
|
|
ref = NativeTestContextRef.model_validate(ref.model_dump(warnings=False), strict=True)
|
|
if type(native_execution_id) is not str or str(UUID(native_execution_id)) != native_execution_id:
|
|
raise BuilderExecutionPolicyError("native_run_mismatch")
|
|
logical_key = "node-input:" + native_execution_id
|
|
if len(logical_key) > 128:
|
|
raise BuilderExecutionPolicyError("native_payload_invalid")
|
|
inputs = _JSON.validate_python(inputs)
|
|
self.loader.load(ref=ref)
|
|
authority = validate_private_read(self.loader, ref)
|
|
context = self.load_context(ref=ref)
|
|
if type(generation) is not int or generation != context.generation:
|
|
raise BuilderExecutionPolicyError("native_generation_conflict")
|
|
with self.loader.session_factory() as session:
|
|
owned, scope = self.loader._load(session, ref=ref, lock=False)
|
|
self._validate_node_inputs_owner(session, owned, scope, context, authority, generation)
|
|
receipt = self.prepare_object(
|
|
ref=ref, tenant_id=context.tenant_id, kind="result", logical_key=logical_key, payload=inputs
|
|
)
|
|
with self.loader.locked(ref=ref) as (session, owned, scope):
|
|
self._validate_node_inputs_owner(session, owned, scope, context, authority, generation)
|
|
result_ref = self.acknowledge_object(session, receipt).id
|
|
return result_ref
|
|
except BuilderExecutionPolicyError:
|
|
raise
|
|
except Exception:
|
|
raise BuilderExecutionPolicyError("native_payload_unavailable") from None
|
|
|
|
@staticmethod
|
|
def _validate_node_inputs_owner(
|
|
session,
|
|
owned,
|
|
scope,
|
|
context: ResolvedNativeTestContext,
|
|
authority: PrivateReadOwner,
|
|
generation: int,
|
|
*,
|
|
allowed_states: tuple[str, ...] = ("running",),
|
|
allow_expired: bool = False,
|
|
) -> None:
|
|
"""SQL-only preflight/recheck; caller owns the transaction and any root lock."""
|
|
state = invocation_state(session, context.ref, owned.tenant_id)
|
|
# The scope counter advances on resume; unrelated running branches keep
|
|
# their own invocation generation and remain eligible to persist inputs.
|
|
if (context.generation, state.generation, authority.generation) != (generation,) * 3:
|
|
raise BuilderExecutionPolicyError("native_generation_conflict")
|
|
if not allow_expired and scope.expires_at <= utc_now():
|
|
raise BuilderExecutionPolicyError("native_expired")
|
|
if state.state not in allowed_states:
|
|
raise BuilderExecutionPolicyError("native_invocation_mismatch")
|
|
if (
|
|
context.ref,
|
|
context.tenant_id,
|
|
context.actor_id,
|
|
context.app_id,
|
|
context.workflow_id,
|
|
context.parent_invocation_id,
|
|
authority.context_object_id,
|
|
) != (
|
|
owned.ref,
|
|
owned.tenant_id,
|
|
owned.actor_id,
|
|
state.app_id,
|
|
state.workflow_id,
|
|
state.parent_invocation_id,
|
|
state.context_object_id,
|
|
):
|
|
raise BuilderExecutionPolicyError("native_context_mismatch")
|
|
require_ancestor_authority(session, context.ref, owned.tenant_id)
|
|
request = session.get(DifyBuilderExecutionRequest, context.ref.request_id)
|
|
if (
|
|
context.execution_mode != "native"
|
|
or not context.native_run_id
|
|
or not context.root_native_run_id
|
|
or request is None
|
|
or request.context.get("mode") != "native"
|
|
or request.context.get("native_run_id") != context.root_native_run_id
|
|
or (context.parent_invocation_id is None and context.native_run_id != context.root_native_run_id)
|
|
):
|
|
raise BuilderExecutionPolicyError("native_run_mismatch")
|
|
workflow = validate_invocation_workflow(
|
|
session, tenant_id=owned.tenant_id, app_id=context.app_id, workflow_id=context.workflow_id
|
|
)
|
|
revision_context = owned.revision_context
|
|
revision = (
|
|
admitted_workflow_revision(workflow, revision_context)
|
|
if revision_context
|
|
else native_workflow_revision(workflow)
|
|
)
|
|
if revision != context.graph_revision or not admitted_graph_matches(workflow, context.graph, revision_context):
|
|
raise BuilderExecutionPolicyError("native_revision_drift")
|
|
|
|
def load_result(self, *, ref: NativeTestContextRef, result_ref: str) -> JsonValue:
|
|
owned = validate_private_read(self.loader, ref)
|
|
with self.loader.session_factory() as session:
|
|
row = session.scalar(
|
|
owner_query(NativePayloadObject, ref, owned.tenant_id).where(
|
|
NativePayloadObject.id == result_ref, NativePayloadObject.kind == "result"
|
|
)
|
|
)
|
|
if row is None:
|
|
raise BuilderExecutionPolicyError("native_result_owner_mismatch")
|
|
return self._read_object(row)
|
|
|
|
@staticmethod
|
|
def _agent_key(namespace: str, parent: AgentParentNodeRef, generation: int | None = None) -> str:
|
|
key = namespace + ":" + canonical_digest(parent.model_dump(mode="json"))
|
|
if generation is not None:
|
|
key += ":" + str(TypeAdapter(Counter).validate_python(generation))
|
|
if len(key) > 128:
|
|
raise BuilderExecutionPolicyError("native_agent_state_unavailable")
|
|
return key
|
|
|
|
@staticmethod
|
|
def _agent_resources(
|
|
session,
|
|
owned,
|
|
context: ResolvedNativeTestContext,
|
|
*,
|
|
definition_receipt: AgentDefinitionReceipt | None = None,
|
|
) -> tuple[AgentTestWorkspaceRef, str]:
|
|
"""Current SQL allocation/pin/workspace chain only; no physical/backend IO."""
|
|
invocation = session.scalar(owner_query(NativeTestInvocation, context.ref, owned.tenant_id))
|
|
if (
|
|
invocation is None
|
|
or not invocation.active
|
|
or (invocation.tenant_id, invocation.request_id, invocation.invocation_id)
|
|
!= (context.tenant_id, context.ref.request_id, context.ref.invocation_id)
|
|
):
|
|
raise BuilderExecutionPolicyError("native_agent_owner_mismatch")
|
|
confirmed = invocation.bindings.get("_agent_confirmed_workspace_v1")
|
|
if confirmed is not None:
|
|
from services.dify_builder.native_testing.agent_confirmed_workspace import current_confirmed_workspace
|
|
|
|
return current_confirmed_workspace(
|
|
session=session,
|
|
owned=owned,
|
|
context=context,
|
|
allocation=confirmed,
|
|
definition_receipt=definition_receipt,
|
|
)
|
|
allocation = invocation.bindings.get("_agent_workspace_v1")
|
|
if not isinstance(allocation, dict):
|
|
raise BuilderExecutionPolicyError("native_workspace_unavailable")
|
|
# Local import avoids the existing workspace service -> state owner dependency.
|
|
# This is its pure parser/ref conversion, not workspace/backend IO.
|
|
from services.dify_builder.native_testing.agent_workspace import (
|
|
AgentWorkspaceAllocation,
|
|
_ref,
|
|
)
|
|
|
|
parsed_allocation = AgentWorkspaceAllocation.model_validate(allocation)
|
|
if any(
|
|
allocation.get(key) != value
|
|
for key, value in {
|
|
"tenant_id": context.tenant_id,
|
|
"app_id": context.app_id,
|
|
"request_id": context.ref.request_id,
|
|
"invocation_id": context.ref.invocation_id,
|
|
"owner_run_id": context.native_run_id,
|
|
"phase": "ready",
|
|
}.items()
|
|
):
|
|
raise BuilderExecutionPolicyError("native_workspace_owner_mismatch")
|
|
scope = allocation.get("native_scope")
|
|
if not isinstance(scope, dict):
|
|
raise BuilderExecutionPolicyError("native_workspace_owner_mismatch")
|
|
binding_id = TypeAdapter(ID).validate_python(allocation.get("binding_id"))
|
|
TypeAdapter(Counter).validate_python(scope.get("generation"))
|
|
if scope.get("scope_id") != binding_id or scope.get("binding_ref") != binding_id:
|
|
raise BuilderExecutionPolicyError("native_workspace_owner_mismatch")
|
|
if parsed_allocation.native_scope.deadline <= utc_now():
|
|
raise BuilderExecutionPolicyError("native_workspace_unavailable")
|
|
workspace_ref = _ref(parsed_allocation)
|
|
snapshot = session.get(AgentConfigSnapshot, workspace_ref.pinned_snapshot_id)
|
|
home = session.get(AgentHomeSnapshot, workspace_ref.home_snapshot_id)
|
|
workspace = session.get(AgentWorkspace, workspace_ref.workspace_id)
|
|
binding = session.get(AgentWorkspaceBinding, workspace_ref.binding_id)
|
|
if snapshot is None or home is None or workspace is None or binding is None:
|
|
raise BuilderExecutionPolicyError("native_workspace_owner_mismatch")
|
|
agent_id = TypeAdapter(ID).validate_python(allocation.get("agent_id"))
|
|
from services.agent import access_service
|
|
|
|
access_service.require_builder_agent_access(
|
|
session=session,
|
|
tenant_id=context.tenant_id,
|
|
actor_id=context.actor_id,
|
|
app_id=context.app_id,
|
|
)
|
|
agent = session.get(Agent, agent_id)
|
|
if agent is None or agent.tenant_id != context.tenant_id or agent.active_config_snapshot_id != snapshot.id:
|
|
raise BuilderExecutionPolicyError("native_agent_pin_mismatch")
|
|
|
|
if (
|
|
(snapshot.tenant_id, snapshot.agent_id, snapshot.home_snapshot_id) != (context.tenant_id, agent_id, home.id)
|
|
or (home.tenant_id, home.agent_id, home.status, home.snapshot_ref)
|
|
!= (
|
|
context.tenant_id,
|
|
agent_id,
|
|
AgentWorkingResourceStatus.ACTIVE,
|
|
scope.get("base_snapshot_ref"),
|
|
)
|
|
or (
|
|
workspace.tenant_id,
|
|
workspace.app_id,
|
|
workspace.owner_type,
|
|
workspace.owner_id,
|
|
workspace.owner_scope_key,
|
|
workspace.status,
|
|
workspace.backend_workspace_ref,
|
|
)
|
|
!= (
|
|
context.tenant_id,
|
|
context.app_id,
|
|
AgentWorkspaceOwnerType.WORKFLOW_RUN,
|
|
context.native_run_id,
|
|
"native:" + context.ref.invocation_id,
|
|
AgentWorkingResourceStatus.ACTIVE,
|
|
workspace_ref.backend_workspace_ref,
|
|
)
|
|
or (
|
|
binding.tenant_id,
|
|
binding.app_id,
|
|
binding.workspace_id,
|
|
binding.agent_id,
|
|
binding.base_home_snapshot_id,
|
|
binding.agent_config_version_id,
|
|
binding.agent_config_version_kind,
|
|
binding.backend_binding_ref,
|
|
binding.status,
|
|
)
|
|
!= (
|
|
context.tenant_id,
|
|
context.app_id,
|
|
workspace.id,
|
|
agent_id,
|
|
home.id,
|
|
snapshot.id,
|
|
AgentConfigVersionKind.SNAPSHOT,
|
|
workspace_ref.backend_binding_ref,
|
|
AgentWorkingResourceStatus.ACTIVE,
|
|
)
|
|
):
|
|
raise BuilderExecutionPolicyError("native_workspace_owner_mismatch")
|
|
nodes = context.graph.get("nodes", [])
|
|
if not isinstance(nodes, list) or not any(
|
|
isinstance(node, dict)
|
|
and isinstance(node.get("data"), dict)
|
|
and isinstance(node["data"].get("agent_binding"), dict)
|
|
and node["data"]["agent_binding"].get("current_snapshot_id") == snapshot.id
|
|
and node["data"]["agent_binding"].get("agent_id") == agent_id
|
|
for node in nodes
|
|
):
|
|
raise BuilderExecutionPolicyError("native_agent_pin_mismatch")
|
|
from core.agent.publish_visibility import (
|
|
workflow_callable_active_snapshot_filter,
|
|
)
|
|
from services.agent.roster_service import AgentRosterService
|
|
|
|
eligible = session.scalar(
|
|
AgentRosterService._build_roster_agents_stmt(tenant_id=context.tenant_id, keyword=None).where(
|
|
Agent.id == agent_id,
|
|
Agent.active_config_has_model.is_(True),
|
|
workflow_callable_active_snapshot_filter(),
|
|
)
|
|
)
|
|
if eligible is None:
|
|
raise BuilderExecutionPolicyError("native_agent_pin_mismatch")
|
|
workflow = validate_invocation_workflow(
|
|
session,
|
|
tenant_id=context.tenant_id,
|
|
app_id=context.app_id,
|
|
workflow_id=context.workflow_id,
|
|
)
|
|
definitions = []
|
|
matched_definition = False
|
|
for node in nodes:
|
|
if not isinstance(node, dict) or not isinstance(node.get("data"), dict):
|
|
continue
|
|
selected = node["data"].get("agent_binding")
|
|
if not isinstance(selected, dict) or (
|
|
selected.get("agent_id"),
|
|
selected.get("current_snapshot_id"),
|
|
) != (
|
|
agent_id,
|
|
snapshot.id,
|
|
):
|
|
continue
|
|
published_binding = session.scalar(
|
|
select(WorkflowAgentNodeBinding).where(
|
|
WorkflowAgentNodeBinding.tenant_id == context.tenant_id,
|
|
WorkflowAgentNodeBinding.app_id == context.app_id,
|
|
WorkflowAgentNodeBinding.workflow_id == context.workflow_id,
|
|
WorkflowAgentNodeBinding.workflow_version == workflow.version,
|
|
WorkflowAgentNodeBinding.node_id == node.get("id"),
|
|
WorkflowAgentNodeBinding.agent_id == agent_id,
|
|
WorkflowAgentNodeBinding.current_snapshot_id == snapshot.id,
|
|
WorkflowAgentNodeBinding.binding_type == WorkflowAgentBindingType.ROSTER_AGENT,
|
|
)
|
|
)
|
|
if published_binding is None:
|
|
raise BuilderExecutionPolicyError("native_agent_pin_mismatch")
|
|
definitions.append({"node": node, "node_job": published_binding.node_job_config_dict})
|
|
if definition_receipt is not None and node.get("id") == definition_receipt.node_id:
|
|
# Compare the producing definition, not a fresh stamp assigned to
|
|
# already-composed bytes after the claim's initial async reads.
|
|
stamp = canonical_digest(
|
|
{
|
|
"node": node,
|
|
"soul": AgentSoulConfig.model_validate(snapshot.config_snapshot_dict).model_dump(mode="json"),
|
|
"job": WorkflowNodeJobConfig.model_validate(published_binding.node_job_config_dict).model_dump(
|
|
mode="json"
|
|
),
|
|
}
|
|
)
|
|
if stamp != definition_receipt.stamp:
|
|
raise BuilderExecutionPolicyError("native_agent_owner_mismatch")
|
|
matched_definition = True
|
|
if definition_receipt is not None and not matched_definition:
|
|
raise BuilderExecutionPolicyError("native_agent_owner_mismatch")
|
|
if not definitions:
|
|
raise BuilderExecutionPolicyError("native_agent_pin_mismatch")
|
|
return workspace_ref, canonical_digest(
|
|
{
|
|
"allocation": allocation,
|
|
"workspace": workspace_ref.model_dump(mode="json"),
|
|
"snapshot": snapshot.config_snapshot_dict,
|
|
"definitions": definitions,
|
|
}
|
|
)
|
|
|
|
def _agent_object(
|
|
self,
|
|
preparation: _AgentPreparation,
|
|
*,
|
|
logical_key: str,
|
|
object_ref: str | None = None,
|
|
required: bool = True,
|
|
) -> tuple[ObjectReceipt, JsonValue] | None:
|
|
with self.loader.session_factory() as session:
|
|
query = owner_query(
|
|
NativePayloadObject,
|
|
preparation.parent.ref,
|
|
preparation.context.tenant_id,
|
|
).where(
|
|
NativePayloadObject.kind == "result",
|
|
NativePayloadObject.logical_key == logical_key,
|
|
)
|
|
if object_ref is not None:
|
|
query = query.where(NativePayloadObject.id == TypeAdapter(ID).validate_python(object_ref))
|
|
row = session.scalar(query)
|
|
if row is None:
|
|
if required:
|
|
raise BuilderExecutionPolicyError("native_agent_object_unavailable")
|
|
return None
|
|
receipt = ObjectReceipt.from_payload_object(row)
|
|
if (
|
|
receipt.tenant_id,
|
|
receipt.request_id,
|
|
receipt.invocation_id,
|
|
receipt.kind,
|
|
receipt.logical_key,
|
|
) != (
|
|
preparation.context.tenant_id,
|
|
preparation.parent.ref.request_id,
|
|
preparation.parent.ref.invocation_id,
|
|
"result",
|
|
logical_key,
|
|
) or (object_ref is not None and receipt.id != object_ref):
|
|
raise BuilderExecutionPolicyError("native_agent_owner_mismatch")
|
|
value = self._read_object(
|
|
receipt,
|
|
max_bytes=1024 * 1024 if logical_key.startswith("agent-config:") else None,
|
|
)
|
|
preparation.objects.append(receipt)
|
|
return receipt, value
|
|
|
|
def _agent_prepare(
|
|
self,
|
|
*,
|
|
ref: NativeTestContextRef,
|
|
parent_node: AgentParentNodeRef,
|
|
writing: bool,
|
|
expected_generation: int | None = None,
|
|
settlement: bool = False,
|
|
for_projection: bool = False,
|
|
) -> _AgentPreparation:
|
|
if for_projection and writing:
|
|
raise BuilderExecutionPolicyError("native_agent_owner_mismatch")
|
|
parent = AgentParentNodeRef.model_validate(parent_node.model_dump(mode="json"))
|
|
if ref != parent.ref:
|
|
raise BuilderExecutionPolicyError("native_agent_owner_mismatch")
|
|
self.loader.load(ref=ref)
|
|
authority = validate_private_read(self.loader, ref)
|
|
context = (
|
|
self.load_projection_context(ref=ref)
|
|
if for_projection
|
|
else self._load_context(ref=ref, recovery=False, allow_expired=True)
|
|
if settlement
|
|
else self.load_context(ref=ref)
|
|
)
|
|
if (
|
|
expected_generation is not None
|
|
and TypeAdapter(Counter).validate_python(expected_generation) != context.generation
|
|
):
|
|
raise BuilderExecutionPolicyError("native_generation_conflict")
|
|
with self.loader.session_factory() as session:
|
|
owned, scope = self.loader._load(session, ref=ref, lock=False)
|
|
self._validate_node_inputs_owner(
|
|
session,
|
|
owned,
|
|
scope,
|
|
context,
|
|
authority,
|
|
context.generation,
|
|
allowed_states=("running",) if writing else ("running", "waiting", "queued"),
|
|
allow_expired=settlement or for_projection,
|
|
)
|
|
self._agent_single_parent(session, context, parent)
|
|
workspace, stamp = self._agent_resources(session, owned, context)
|
|
prepared = _AgentPreparation(
|
|
parent, context, authority, workspace, stamp, [], settlement=settlement, for_projection=for_projection
|
|
)
|
|
selected = self._agent_object(
|
|
prepared, logical_key="node-input:" + parent.execution_id, object_ref=parent.input_result_ref
|
|
)
|
|
assert selected is not None
|
|
if canonical_digest(selected[1]) != parent.input_digest:
|
|
raise BuilderExecutionPolicyError("native_journal_input_mismatch")
|
|
return prepared
|
|
|
|
def _agent_recheck(
|
|
self,
|
|
session,
|
|
owned,
|
|
scope,
|
|
prepared: _AgentPreparation,
|
|
*,
|
|
writing: bool,
|
|
definition_receipt: AgentDefinitionReceipt | None = None,
|
|
) -> None:
|
|
if (prepared.settlement or prepared.for_projection) and writing:
|
|
raise BuilderExecutionPolicyError("native_agent_owner_mismatch")
|
|
self._validate_node_inputs_owner(
|
|
session,
|
|
owned,
|
|
scope,
|
|
prepared.context,
|
|
prepared.authority,
|
|
prepared.context.generation,
|
|
allowed_states=("running",) if writing else ("running", "waiting", "queued"),
|
|
allow_expired=prepared.settlement or prepared.for_projection,
|
|
)
|
|
self._agent_single_parent(session, prepared.context, prepared.parent)
|
|
workspace, stamp = self._agent_resources(
|
|
session, owned, prepared.context, definition_receipt=definition_receipt
|
|
)
|
|
if workspace != prepared.workspace or stamp != prepared.resource_digest:
|
|
raise BuilderExecutionPolicyError("native_agent_owner_mismatch")
|
|
for receipt in prepared.objects:
|
|
row = session.scalar(
|
|
owner_query(NativePayloadObject, prepared.parent.ref, owned.tenant_id).where(
|
|
NativePayloadObject.id == receipt.id,
|
|
NativePayloadObject.kind == "result",
|
|
)
|
|
)
|
|
if row is None or ObjectReceipt.from_payload_object(row) != receipt:
|
|
raise BuilderExecutionPolicyError("native_agent_owner_mismatch")
|
|
|
|
def _agent_ack(self, prepared: _AgentPreparation, *, logical_key: str, value: JsonValue) -> str:
|
|
existing = self._agent_object(prepared, logical_key=logical_key, required=False)
|
|
receipt = None
|
|
if existing is not None:
|
|
if canonical_digest(existing[1]) != canonical_digest(value):
|
|
raise BuilderExecutionPolicyError("native_payload_conflict")
|
|
else:
|
|
receipt = self.prepare_object(
|
|
ref=prepared.parent.ref,
|
|
tenant_id=prepared.context.tenant_id,
|
|
kind="result",
|
|
logical_key=logical_key,
|
|
payload=value,
|
|
)
|
|
self._agent_refresh_access(prepared)
|
|
with self.loader.locked(ref=prepared.parent.ref) as (session, owned, scope):
|
|
self._agent_recheck(session, owned, scope, prepared, writing=True)
|
|
if receipt is not None:
|
|
return self.acknowledge_object(session, receipt).id
|
|
assert existing is not None
|
|
return existing[0].id
|
|
|
|
def _agent_refresh_access(self, prepared: _AgentPreparation) -> None:
|
|
"""Current RBAC outside SQL writer, followed by complete local recheck under lock."""
|
|
from services.dify_builder.native_testing.agent_binding import (
|
|
AgentBindingService,
|
|
)
|
|
|
|
if validate_private_read(self.loader, prepared.parent.ref) != prepared.authority:
|
|
raise BuilderExecutionPolicyError("native_agent_owner_mismatch")
|
|
with self.loader.session_factory() as session:
|
|
snapshot = session.get(AgentConfigSnapshot, prepared.workspace.pinned_snapshot_id)
|
|
if snapshot is None or snapshot.tenant_id != prepared.context.tenant_id:
|
|
raise BuilderExecutionPolicyError("native_agent_pin_mismatch")
|
|
agent_id = snapshot.agent_id
|
|
AgentBindingService.authorize(
|
|
actor=self.loader.actor,
|
|
app_id=prepared.context.app_id,
|
|
agent_ids=frozenset({agent_id}),
|
|
)
|
|
|
|
@staticmethod
|
|
def _agent_executable_config(config: JsonValue, parent: AgentParentNodeRef) -> JsonValue:
|
|
"""No metadata, secrets, assigned config ref, or caller ownership fields persist."""
|
|
from dify_agent.protocol.schemas import CreateRunRequest
|
|
|
|
if not isinstance(config, dict) or set(config) != {
|
|
"composition",
|
|
"session_snapshot",
|
|
}:
|
|
raise BuilderExecutionPolicyError("native_agent_config_mismatch")
|
|
request = CreateRunRequest.model_validate(
|
|
{
|
|
**config,
|
|
"execution_mode": "builder_test",
|
|
"native_test_ref": parent.ref.model_dump(mode="json"),
|
|
"native_test_protocol_version": 1,
|
|
"native_parent_node": parent.model_dump(mode="json"),
|
|
}
|
|
)
|
|
|
|
# Layer transport fields can contain only opaque references. Empty credential
|
|
# maps are required by existing DTOs; populated maps are never durable config.
|
|
def credential_free(value):
|
|
if isinstance(value, dict):
|
|
for key, item in value.items():
|
|
if (
|
|
key
|
|
in {
|
|
"credentials",
|
|
"api_key",
|
|
"access_token",
|
|
"refresh_token",
|
|
"inner_api_key",
|
|
"authorization",
|
|
}
|
|
and item
|
|
):
|
|
raise BuilderExecutionPolicyError("native_agent_config_credentials")
|
|
credential_free(item)
|
|
elif isinstance(value, list):
|
|
for item in value:
|
|
credential_free(item)
|
|
|
|
normalized = request.model_dump(mode="json", include={"composition", "session_snapshot"})
|
|
credential_free(normalized)
|
|
if len(json.dumps(normalized, ensure_ascii=False).encode()) > 1024 * 1024 - 4096:
|
|
raise BuilderExecutionPolicyError("native_agent_config_unavailable")
|
|
return _JSON.validate_python(normalized)
|
|
|
|
def claim_agent_create(
|
|
self,
|
|
*,
|
|
ref: NativeTestContextRef,
|
|
parent_node: AgentParentNodeRef,
|
|
expected_invocation_generation: int,
|
|
config: JsonValue,
|
|
definition_receipt: AgentDefinitionReceipt,
|
|
) -> AgentCreateClaim:
|
|
"""First local SQL insertion only; lost ACK and identical rows own no POST branch."""
|
|
with _agent_boundary():
|
|
prepared = self._agent_prepare(
|
|
ref=ref,
|
|
parent_node=parent_node,
|
|
writing=True,
|
|
expected_generation=expected_invocation_generation,
|
|
)
|
|
config = self._agent_executable_config(config, prepared.parent)
|
|
value = {
|
|
"schema": "agent-config-v2",
|
|
"parent_node": prepared.parent.model_dump(mode="json"),
|
|
"workflow_native_run_id": prepared.context.native_run_id,
|
|
"workspace": prepared.workspace.model_dump(mode="json"),
|
|
"resource_digest": prepared.resource_digest,
|
|
"config": config,
|
|
}
|
|
key = self._agent_key("agent-config", prepared.parent)
|
|
# Object crypto/readback is deliberately outside the writer, even if another
|
|
# caller wins while it is in progress. That caller's ACK cannot grant us POST.
|
|
receipt = self.prepare_object(
|
|
ref=ref,
|
|
tenant_id=prepared.context.tenant_id,
|
|
kind="result",
|
|
logical_key=key,
|
|
payload=value,
|
|
max_bytes=1024 * 1024,
|
|
)
|
|
self._agent_refresh_access(prepared)
|
|
with self.loader.locked(ref=ref) as (session, owned, scope):
|
|
self._agent_recheck(
|
|
session,
|
|
owned,
|
|
scope,
|
|
prepared,
|
|
writing=True,
|
|
definition_receipt=definition_receipt,
|
|
)
|
|
existing = session.scalar(
|
|
owner_query(NativePayloadObject, ref, owned.tenant_id).where(
|
|
NativePayloadObject.kind == "result",
|
|
NativePayloadObject.logical_key == key,
|
|
)
|
|
)
|
|
if existing is not None:
|
|
if (existing.payload_digest, existing.value_digest) != (
|
|
receipt.payload_digest,
|
|
receipt.value_digest,
|
|
):
|
|
raise BuilderExecutionPolicyError("native_payload_conflict")
|
|
return AgentCreateExisting(existing.id, canonical_digest(config))
|
|
selected = self.acknowledge_object(session, receipt)
|
|
return AgentCreateInserted(selected.id, canonical_digest(config))
|
|
|
|
def _agent_prepared_config(self, prepared: _AgentPreparation, config_ref: str | None) -> AgentPreparedConfig:
|
|
selected = self._agent_object(
|
|
prepared,
|
|
logical_key=self._agent_key("agent-config", prepared.parent),
|
|
object_ref=config_ref,
|
|
)
|
|
assert selected is not None
|
|
receipt, value = selected
|
|
if (
|
|
not isinstance(value, dict)
|
|
or set(value)
|
|
!= {
|
|
"schema",
|
|
"parent_node",
|
|
"workflow_native_run_id",
|
|
"workspace",
|
|
"resource_digest",
|
|
"config",
|
|
}
|
|
or value
|
|
!= {
|
|
"schema": "agent-config-v2",
|
|
"parent_node": prepared.parent.model_dump(mode="json"),
|
|
"workflow_native_run_id": prepared.context.native_run_id,
|
|
"workspace": prepared.workspace.model_dump(mode="json"),
|
|
"resource_digest": prepared.resource_digest,
|
|
"config": value.get("config"),
|
|
}
|
|
):
|
|
raise BuilderExecutionPolicyError("native_agent_config_mismatch")
|
|
config = self._agent_executable_config(value["config"], prepared.parent)
|
|
if prepared.context.native_run_id is None:
|
|
raise BuilderExecutionPolicyError("native_run_mismatch")
|
|
return AgentPreparedConfig(
|
|
parent_node=prepared.parent,
|
|
workflow_native_run_id=prepared.context.native_run_id,
|
|
workspace=prepared.workspace,
|
|
config_ref=receipt.id,
|
|
config_digest=canonical_digest(config),
|
|
config=config,
|
|
)
|
|
|
|
def load_agent_settlement_identity(self, *, ref, parent_node):
|
|
"""Read original identity for result/outcome ACK only; grants no execution.
|
|
|
|
Static runtime Resources select this mode. Every current owner, config,
|
|
association and resource check remains; only expiry of new-call authority
|
|
does not discard evidence from an operation already dispatched.
|
|
"""
|
|
with _agent_boundary():
|
|
prepared = self._agent_prepare(ref=ref, parent_node=parent_node, writing=False, settlement=True)
|
|
config = self._agent_prepared_config(prepared, None)
|
|
selected = self._agent_object(prepared, logical_key=self._agent_key("agent-bind", prepared.parent))
|
|
assert selected is not None
|
|
association = AgentNodeAssociation.model_validate(selected[1])
|
|
self._agent_config_relation(prepared, association)
|
|
self._agent_refresh_access(prepared)
|
|
with self.loader.locked(ref=ref) as (session, owned, scope):
|
|
self._agent_recheck(session, owned, scope, prepared, writing=False)
|
|
return prepared.context, config, association
|
|
|
|
def load_prepared_config(
|
|
self,
|
|
*,
|
|
ref: NativeTestContextRef,
|
|
parent_node: AgentParentNodeRef,
|
|
config_ref: str | None,
|
|
for_projection: bool = False,
|
|
) -> AgentPreparedConfig:
|
|
"""Private current-config read with owner/resource recheck after decryption."""
|
|
with _agent_boundary():
|
|
prepared = self._agent_prepare(
|
|
ref=ref, parent_node=parent_node, writing=False, for_projection=for_projection
|
|
)
|
|
result = self._agent_prepared_config(prepared, config_ref)
|
|
self._agent_refresh_access(prepared)
|
|
with self.loader.locked(ref=ref) as (session, owned, scope):
|
|
self._agent_recheck(session, owned, scope, prepared, writing=False)
|
|
return result
|
|
|
|
def discover_agent_run(
|
|
self, *, ref: NativeTestContextRef, parent_node: AgentParentNodeRef
|
|
) -> AgentNodeAssociation | None:
|
|
"""Observed unbound namespace is unknown, never authority to create/retry."""
|
|
with _agent_boundary():
|
|
prepared = self._agent_prepare(ref=ref, parent_node=parent_node, writing=False)
|
|
# A binding discovery always proves its config, including an unbound reservation.
|
|
self._agent_prepared_config(prepared, None)
|
|
selected = self._agent_object(
|
|
prepared,
|
|
logical_key=self._agent_key("agent-bind", prepared.parent),
|
|
required=False,
|
|
)
|
|
association = None if selected is None else AgentNodeAssociation.model_validate(selected[1])
|
|
if association is not None:
|
|
self._agent_config_relation(prepared, association)
|
|
self._agent_refresh_access(prepared)
|
|
with self.loader.locked(ref=ref) as (session, owned, scope):
|
|
self._agent_recheck(session, owned, scope, prepared, writing=False)
|
|
if (
|
|
selected is None
|
|
and session.scalar(
|
|
owner_query(NativePayloadObject, ref, owned.tenant_id).where(
|
|
NativePayloadObject.kind == "result",
|
|
NativePayloadObject.logical_key == self._agent_key("agent-bind", prepared.parent),
|
|
)
|
|
)
|
|
is not None
|
|
):
|
|
raise BuilderExecutionPolicyError("native_agent_discovery_unknown")
|
|
return association
|
|
|
|
def store_agent_config(
|
|
self,
|
|
*,
|
|
ref: NativeTestContextRef,
|
|
parent_node: AgentParentNodeRef,
|
|
expected_invocation_generation: int,
|
|
config: JsonValue,
|
|
) -> str:
|
|
"""Internal immutable config bytes, not backend authorization or preparation proof."""
|
|
with _agent_boundary():
|
|
prepared = self._agent_prepare(
|
|
ref=ref, parent_node=parent_node, writing=True, expected_generation=expected_invocation_generation
|
|
)
|
|
value = {
|
|
"schema": "agent-config-v1",
|
|
"parent_node": prepared.parent.model_dump(mode="json"),
|
|
"workflow_native_run_id": prepared.context.native_run_id,
|
|
"workspace": prepared.workspace.model_dump(mode="json"),
|
|
"config": _JSON.validate_python(config),
|
|
}
|
|
return self._agent_ack(prepared, logical_key=self._agent_key("agent-config", prepared.parent), value=value)
|
|
|
|
def _agent_binding(self, prepared: _AgentPreparation, association_object_ref: str) -> AgentNodeAssociation:
|
|
selected = self._agent_object(
|
|
prepared, logical_key=self._agent_key("agent-bind", prepared.parent), object_ref=association_object_ref
|
|
)
|
|
assert selected is not None
|
|
association = AgentNodeAssociation.model_validate(selected[1])
|
|
self._agent_config_relation(prepared, association)
|
|
return association
|
|
|
|
def _agent_config_relation(self, prepared: _AgentPreparation, association: AgentNodeAssociation) -> None:
|
|
if (
|
|
association.parent_node,
|
|
association.workflow_native_run_id,
|
|
association.workspace,
|
|
) != (
|
|
prepared.parent,
|
|
prepared.context.native_run_id,
|
|
prepared.workspace,
|
|
):
|
|
raise BuilderExecutionPolicyError("native_agent_owner_mismatch")
|
|
selected = self._agent_object(
|
|
prepared,
|
|
logical_key=self._agent_key("agent-config", prepared.parent),
|
|
object_ref=association.config_ref,
|
|
)
|
|
assert selected is not None
|
|
value = selected[1]
|
|
if isinstance(value, dict) and value.get("schema") == "agent-config-v2":
|
|
selected_config = self._agent_prepared_config(prepared, association.config_ref)
|
|
if selected_config.config_digest != association.config_digest:
|
|
raise BuilderExecutionPolicyError("native_agent_config_mismatch")
|
|
return
|
|
if (
|
|
not isinstance(value, dict)
|
|
or set(value)
|
|
!= {
|
|
"schema",
|
|
"parent_node",
|
|
"workflow_native_run_id",
|
|
"workspace",
|
|
"config",
|
|
}
|
|
or value
|
|
!= {
|
|
"schema": "agent-config-v1",
|
|
"parent_node": prepared.parent.model_dump(mode="json"),
|
|
"workflow_native_run_id": prepared.context.native_run_id,
|
|
"workspace": prepared.workspace.model_dump(mode="json"),
|
|
"config": value.get("config"),
|
|
}
|
|
or canonical_digest(value["config"]) != association.config_digest
|
|
):
|
|
raise BuilderExecutionPolicyError("native_agent_config_mismatch")
|
|
|
|
def bind_agent_run(
|
|
self,
|
|
*,
|
|
ref: NativeTestContextRef,
|
|
parent_node: AgentParentNodeRef,
|
|
expected_invocation_generation: int,
|
|
association: AgentNodeAssociation,
|
|
) -> str:
|
|
"""Internal protected binding; only the actual later backend producer may supply its Run."""
|
|
with _agent_boundary():
|
|
association = AgentNodeAssociation.model_validate(association.model_dump(mode="json"))
|
|
prepared = self._agent_prepare(
|
|
ref=ref, parent_node=parent_node, writing=True, expected_generation=expected_invocation_generation
|
|
)
|
|
self._agent_config_relation(prepared, association)
|
|
return self._agent_ack(
|
|
prepared,
|
|
logical_key=self._agent_key("agent-bind", prepared.parent),
|
|
value=association.model_dump(mode="json"),
|
|
)
|
|
|
|
@staticmethod
|
|
def _agent_checkpoint_metadata(checkpoint: JsonValue, association: AgentNodeAssociation, generation: int) -> None:
|
|
# The actual backend adapter still owns full AgentCheckpoint/event validation.
|
|
if (
|
|
not isinstance(checkpoint, dict)
|
|
or (
|
|
checkpoint.get("run_id"),
|
|
checkpoint.get("ref"),
|
|
checkpoint.get("config_ref"),
|
|
checkpoint.get("config_digest"),
|
|
)
|
|
!= (
|
|
association.agent_run_id,
|
|
association.parent_node.ref.model_dump(mode="json"),
|
|
association.config_ref,
|
|
association.config_digest,
|
|
)
|
|
or TypeAdapter(Counter).validate_python(checkpoint.get("generation")) != generation
|
|
):
|
|
raise BuilderExecutionPolicyError("native_agent_checkpoint_mismatch")
|
|
|
|
def store_agent_wait(
|
|
self,
|
|
*,
|
|
ref: NativeTestContextRef,
|
|
parent_node: AgentParentNodeRef,
|
|
association_object_ref: str,
|
|
agent_generation: int,
|
|
checkpoint: JsonValue,
|
|
backend_wait_event_id: str,
|
|
) -> AgentWaitAssociation:
|
|
"""Store selected bytes/event reference, NEVER certify an actual Redis ACK.
|
|
|
|
The uninstalled later trusted backend producer must first read/validate
|
|
the actual checkpoint and event. No caller proof flag can replace it.
|
|
"""
|
|
with _agent_boundary():
|
|
generation = TypeAdapter(Counter).validate_python(agent_generation)
|
|
event_id = TypeAdapter(ID).validate_python(backend_wait_event_id)
|
|
prepared = self._agent_prepare(ref=ref, parent_node=parent_node, writing=True)
|
|
association = self._agent_binding(prepared, association_object_ref)
|
|
checkpoint = _JSON.validate_python(checkpoint)
|
|
self._agent_checkpoint_metadata(checkpoint, association, generation)
|
|
value = {
|
|
"schema": "agent-checkpoint-v1",
|
|
"parent_node": prepared.parent.model_dump(mode="json"),
|
|
"association_object_ref": association_object_ref,
|
|
"agent_generation": generation,
|
|
"backend_wait_event_id": event_id,
|
|
"checkpoint": checkpoint,
|
|
}
|
|
object_ref = self._agent_ack(
|
|
prepared, logical_key=self._agent_key("agent-cp", prepared.parent, generation), value=value
|
|
)
|
|
return AgentWaitAssociation(
|
|
parent_node=prepared.parent,
|
|
association_object_ref=association_object_ref,
|
|
agent_generation=generation,
|
|
checkpoint_object_ref=object_ref,
|
|
checkpoint_digest=canonical_digest(checkpoint),
|
|
backend_wait_event_id=event_id,
|
|
)
|
|
|
|
def _agent_wait_relation(
|
|
self, prepared: _AgentPreparation, association: AgentNodeAssociation, wait: AgentWaitAssociation
|
|
) -> JsonValue:
|
|
if wait.parent_node != prepared.parent:
|
|
raise BuilderExecutionPolicyError("native_agent_owner_mismatch")
|
|
selected = self._agent_object(
|
|
prepared,
|
|
logical_key=self._agent_key("agent-cp", prepared.parent, wait.agent_generation),
|
|
object_ref=wait.checkpoint_object_ref,
|
|
)
|
|
assert selected is not None
|
|
value = selected[1]
|
|
if (
|
|
not isinstance(value, dict)
|
|
or set(value)
|
|
!= {
|
|
"schema",
|
|
"parent_node",
|
|
"association_object_ref",
|
|
"agent_generation",
|
|
"backend_wait_event_id",
|
|
"checkpoint",
|
|
}
|
|
or value
|
|
!= {
|
|
"schema": "agent-checkpoint-v1",
|
|
"parent_node": prepared.parent.model_dump(mode="json"),
|
|
"association_object_ref": wait.association_object_ref,
|
|
"agent_generation": wait.agent_generation,
|
|
"backend_wait_event_id": wait.backend_wait_event_id,
|
|
"checkpoint": value.get("checkpoint"),
|
|
}
|
|
or canonical_digest(value["checkpoint"]) != wait.checkpoint_digest
|
|
):
|
|
raise BuilderExecutionPolicyError("native_agent_checkpoint_mismatch")
|
|
self._agent_checkpoint_metadata(value["checkpoint"], association, wait.agent_generation)
|
|
return value["checkpoint"]
|
|
|
|
def load_agent_association(
|
|
self, *, ref: NativeTestContextRef, parent_node: AgentParentNodeRef, wait: AgentWaitAssociation | None = None
|
|
) -> AgentProtectedState:
|
|
"""Read owned material only; absence refuses and never implies resume/terminal proof."""
|
|
return self._read_agent_association(ref=ref, parent_node=parent_node, wait=wait, for_projection=False)
|
|
|
|
def read_agent_association_for_cancellation(
|
|
self, *, ref: NativeTestContextRef, parent_node: AgentParentNodeRef, wait: AgentWaitAssociation | None = None
|
|
) -> AgentProtectedState:
|
|
"""Locate the original child for active cancellation, never resume it."""
|
|
return self._read_agent_association(ref=ref, parent_node=parent_node, wait=wait, for_projection=True)
|
|
|
|
def _read_agent_association(
|
|
self,
|
|
*,
|
|
ref: NativeTestContextRef,
|
|
parent_node: AgentParentNodeRef,
|
|
wait: AgentWaitAssociation | None,
|
|
for_projection: bool,
|
|
) -> AgentProtectedState:
|
|
with _agent_boundary():
|
|
prepared = self._agent_prepare(
|
|
ref=ref, parent_node=parent_node, writing=False, for_projection=for_projection
|
|
)
|
|
if wait is None:
|
|
selected = self._agent_object(prepared, logical_key=self._agent_key("agent-bind", prepared.parent))
|
|
assert selected is not None
|
|
association_ref = selected[0].id
|
|
else:
|
|
wait = AgentWaitAssociation.model_validate(wait.model_dump(mode="json"))
|
|
association_ref = wait.association_object_ref
|
|
association = self._agent_binding(prepared, association_ref)
|
|
checkpoint = None if wait is None else self._agent_wait_relation(prepared, association, wait)
|
|
final = self._agent_object(
|
|
prepared, logical_key=self._agent_key("agent-final", prepared.parent), required=False
|
|
)
|
|
terminal_ref, terminal = None, None
|
|
if final is not None:
|
|
final_value = final[1]
|
|
if (
|
|
not isinstance(final_value, dict)
|
|
or set(final_value)
|
|
!= {
|
|
"schema",
|
|
"parent_node",
|
|
"association_object_ref",
|
|
"agent_generation",
|
|
"backend_terminal_event_id",
|
|
"terminal_result",
|
|
}
|
|
or final_value.get("schema") != "agent-final-v1"
|
|
or final_value.get("parent_node") != prepared.parent.model_dump(mode="json")
|
|
or final_value.get("association_object_ref") != association_ref
|
|
):
|
|
raise BuilderExecutionPolicyError("native_agent_owner_mismatch")
|
|
TypeAdapter(Counter).validate_python(final_value["agent_generation"])
|
|
TypeAdapter(ID).validate_python(final_value["backend_terminal_event_id"])
|
|
terminal_ref, terminal = final[0].id, final_value["terminal_result"]
|
|
with self.loader.locked(ref=ref) as (session, owned, scope):
|
|
self._agent_recheck(session, owned, scope, prepared, writing=False)
|
|
return AgentProtectedState(
|
|
association_object_ref=association_ref,
|
|
association=association,
|
|
wait=wait,
|
|
checkpoint=checkpoint,
|
|
terminal_result_ref=terminal_ref,
|
|
terminal_result=terminal,
|
|
)
|
|
|
|
def store_agent_final(
|
|
self,
|
|
*,
|
|
ref: NativeTestContextRef,
|
|
parent_node: AgentParentNodeRef,
|
|
association_object_ref: str,
|
|
agent_generation: int,
|
|
terminal_result: JsonValue,
|
|
backend_terminal_event_id: str,
|
|
) -> str:
|
|
"""Private terminal bytes reference, not verified terminal/physical cleanup proof.
|
|
|
|
The later actual backend producer must verify terminal state first. This
|
|
record never settles C02 or releases capacity and has no installed caller.
|
|
"""
|
|
with _agent_boundary():
|
|
generation = TypeAdapter(Counter).validate_python(agent_generation)
|
|
event_id = TypeAdapter(ID).validate_python(backend_terminal_event_id)
|
|
prepared = self._agent_prepare(ref=ref, parent_node=parent_node, writing=True)
|
|
self._agent_binding(prepared, association_object_ref)
|
|
value = {
|
|
"schema": "agent-final-v1",
|
|
"parent_node": prepared.parent.model_dump(mode="json"),
|
|
"association_object_ref": association_object_ref,
|
|
"agent_generation": generation,
|
|
"backend_terminal_event_id": event_id,
|
|
"terminal_result": _JSON.validate_python(terminal_result),
|
|
}
|
|
return self._agent_ack(prepared, logical_key=self._agent_key("agent-final", prepared.parent), value=value)
|
|
|
|
def _agent_checkpoint_preparations(
|
|
self, ref: NativeTestContextRef, payload: NativeCheckpointPayload, *, for_projection: bool = False
|
|
) -> list[_AgentPreparation]:
|
|
"""Resolve all selected private references outside the later SQL-only recheck.
|
|
|
|
This cannot certify backend ACK or authorize generic restoration. The
|
|
existing claimed-C03/current-run handoff remains the actual resume gate.
|
|
"""
|
|
preparations = []
|
|
for wait in payload.agent_associations:
|
|
prepared = self._agent_prepare(
|
|
ref=ref, parent_node=wait.parent_node, writing=False, for_projection=for_projection
|
|
)
|
|
if (
|
|
payload.native_run_id != prepared.context.native_run_id
|
|
or payload.generation > prepared.context.generation
|
|
):
|
|
raise BuilderExecutionPolicyError("native_generation_conflict")
|
|
association = self._agent_binding(prepared, wait.association_object_ref)
|
|
self._agent_wait_relation(prepared, association, wait)
|
|
preparations.append(prepared)
|
|
return preparations
|
|
|
|
def _agent_single_parent(self, session, context: ResolvedNativeTestContext, parent: AgentParentNodeRef) -> None:
|
|
"""Temporary one-workspace limitation: never share it with a distinct occurrence."""
|
|
expected = self._agent_key("agent-config", parent)
|
|
keys = session.scalars(
|
|
owner_query(NativePayloadObject, context.ref, context.tenant_id)
|
|
.with_only_columns(NativePayloadObject.logical_key)
|
|
.where(
|
|
NativePayloadObject.kind == "result",
|
|
NativePayloadObject.logical_key.like("agent-config:%"),
|
|
NativePayloadObject.logical_key != expected,
|
|
)
|
|
.limit(1)
|
|
)
|
|
for key in keys:
|
|
if type(key) is not str:
|
|
raise BuilderExecutionPolicyError("native_agent_owner_mismatch")
|
|
if key.startswith("agent-config:") and key != expected:
|
|
raise BuilderExecutionPolicyError("native_agent_single_workspace_limit")
|