Files
dify/api/services/dify_builder/native_testing/state_store.py
T

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")