mirror of
https://github.com/langgenius/dify.git
synced 2026-11-11 22:02:23 +08:00
Track backend integration, Ruff cleanup and migration reconciliation in ENG-1271. Retain required copy contracts without implementing frontend interactions.
371 lines
18 KiB
Python
371 lines
18 KiB
Python
"""Ordered private node reconstruction, with C02 retaining all effect authority.
|
|
|
|
Occurrence paths must come from the native runtime's immutable frame/iteration/
|
|
child identity. This module cannot qualify a caller's path or produce bindings.
|
|
Inputs must already have an independently owned C03 reference; an effect's result
|
|
slot must never be repurposed for inputs. Only encrypted checkpoints persist this
|
|
journal. Between checkpoints C02's immutable intent and dispatch ledger fences
|
|
redelivery, including a crash after a physical call but before checkpointing.
|
|
"""
|
|
|
|
from copy import deepcopy
|
|
from typing import Protocol
|
|
|
|
from pydantic import TypeAdapter
|
|
|
|
from runtime_contracts.builder.execution import BuilderExecutionPolicyError, canonical_digest
|
|
from runtime_contracts.builder.native.contracts import (
|
|
CheckpointRef,
|
|
EffectDecision,
|
|
EffectIntent,
|
|
FiniteJSON,
|
|
JsonValue,
|
|
NativeTestContextRef,
|
|
OperationKey,
|
|
RecordedResult,
|
|
operation_digest,
|
|
)
|
|
from runtime_contracts.builder.native.runtime_contracts import (
|
|
NativeCheckpointPayload,
|
|
NativeStatePort,
|
|
NodeJournalState,
|
|
ResolvedNativeTestContext,
|
|
)
|
|
from services.dify_builder.native_testing.lifecycle import NativeTestLifecycleService
|
|
|
|
_JSON = TypeAdapter(FiniteJSON)
|
|
|
|
|
|
class JournalAuthorizationPort(Protocol):
|
|
def before_call(self, *, ref: NativeTestContextRef, intent: EffectIntent) -> EffectDecision:
|
|
"""C02 decision only; a returned permit still requires claim_dispatch."""
|
|
...
|
|
|
|
|
|
class OperationCursor:
|
|
"""One delivery's cursor over an immutable occurrence's saved sequence.
|
|
|
|
Rewind begins at zero without deleting history. before_call always consults
|
|
C02; cached references never bypass current authorization. A DispatchPermit
|
|
does not close a result or authorize another transport. The call adapter must
|
|
claim it and settle its complete/closed result through the existing owners;
|
|
rechecking C02 can then yield RecordedResult. Unknown/unclosed streams remain
|
|
without a result reference and cannot advance the sequence.
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
*,
|
|
state: NativeStatePort,
|
|
authorization: JournalAuthorizationPort,
|
|
ref: NativeTestContextRef,
|
|
occurrence_path: tuple[str, ...],
|
|
generation: int,
|
|
saved: NodeJournalState | None,
|
|
):
|
|
self._state, self._authorization, self._ref = state, authorization, ref
|
|
self._path, self._generation = occurrence_path, generation
|
|
self._saved = saved.model_copy(deep=True) if saved is not None else None
|
|
self._position = 0
|
|
self._completed_prefix = 0
|
|
self._input_bound = False
|
|
self._recorded: JsonValue = None
|
|
self._has_recorded = False
|
|
|
|
def _check_context(self) -> None:
|
|
context = self._state.load_context(ref=self._ref)
|
|
if context.ref != self._ref or context.generation != self._generation:
|
|
raise BuilderExecutionPolicyError("native_generation_conflict")
|
|
|
|
def bind_input(self, *, input_result_ref: str, inputs: JsonValue) -> None:
|
|
"""Compare typed actual inputs to existing protected input material."""
|
|
self._check_context()
|
|
value = _JSON.validate_python(inputs)
|
|
stored = self._state.load_result(ref=self._ref, result_ref=input_result_ref)
|
|
digest = canonical_digest(_JSON.validate_python(stored))
|
|
if digest != canonical_digest(value) or (
|
|
self._saved is not None
|
|
and (self._saved.input_result_ref, self._saved.input_digest) != (input_result_ref, digest)
|
|
):
|
|
raise BuilderExecutionPolicyError("native_journal_input_mismatch")
|
|
if self._saved is None:
|
|
self._saved = NodeJournalState(
|
|
invocation_id=self._ref.invocation_id,
|
|
occurrence_path=self._path,
|
|
input_result_ref=input_result_ref,
|
|
input_digest=digest,
|
|
cursor=0,
|
|
operation_keys=(),
|
|
result_refs=(),
|
|
intents=(),
|
|
)
|
|
self._input_bound = True
|
|
|
|
def next_key(self) -> OperationKey:
|
|
key = OperationKey(invocation_id=self._ref.invocation_id, occurrence_path=self._path, cursor=self._position)
|
|
self._position += 1
|
|
self._has_recorded = False
|
|
self._recorded = None
|
|
return key
|
|
|
|
def rewind(self) -> None:
|
|
self._position = 0
|
|
self._completed_prefix = 0
|
|
self._has_recorded = False
|
|
self._recorded = None
|
|
|
|
def before_call(self, *, intent: EffectIntent) -> EffectDecision:
|
|
"""Check ordered intent, then obtain the authoritative current decision."""
|
|
self._has_recorded = False
|
|
self._recorded = None
|
|
self._check_context()
|
|
intent = EffectIntent.model_validate(intent.model_dump(mode="json"))
|
|
saved = self._saved
|
|
if saved is None or not self._input_bound or intent.input_digest != saved.input_digest:
|
|
raise BuilderExecutionPolicyError("native_journal_input_mismatch")
|
|
index = self._position - 1
|
|
expected = OperationKey(invocation_id=self._ref.invocation_id, occurrence_path=self._path, cursor=max(index, 0))
|
|
if index < 0 or intent.key != expected or index > self._completed_prefix:
|
|
raise BuilderExecutionPolicyError("native_journal_sequence_conflict")
|
|
if index < len(saved.operation_keys):
|
|
if operation_digest(intent.key) != operation_digest(saved.operation_keys[index]) or canonical_digest(
|
|
intent.model_dump(mode="json")
|
|
) != canonical_digest(saved.intents[index].model_dump(mode="json")):
|
|
raise BuilderExecutionPolicyError("native_journal_intent_conflict")
|
|
elif index == len(saved.operation_keys):
|
|
saved = saved.model_copy(
|
|
update={
|
|
"cursor": index + 1,
|
|
"operation_keys": (*saved.operation_keys, intent.key),
|
|
"intents": (*saved.intents, intent.model_copy(deep=True)),
|
|
},
|
|
deep=True,
|
|
)
|
|
self._saved = saved
|
|
else:
|
|
raise BuilderExecutionPolicyError("native_journal_sequence_conflict")
|
|
# A repeated recheck of the same slot can revoke this delivery's proof
|
|
# of completion, while the saved result reference remains historical fact.
|
|
self._completed_prefix = min(self._completed_prefix, index)
|
|
decision = self._authorization.before_call(ref=self._ref, intent=intent)
|
|
self._check_context()
|
|
if isinstance(decision, RecordedResult):
|
|
if index < len(saved.result_refs) and saved.result_refs[index] != decision.result_ref:
|
|
raise BuilderExecutionPolicyError("native_journal_result_conflict")
|
|
result = self._state.load_result(ref=self._ref, result_ref=decision.result_ref)
|
|
result = _JSON.validate_python(result)
|
|
self._check_context()
|
|
if index == len(saved.result_refs):
|
|
self._saved = saved.model_copy(update={"result_refs": (*saved.result_refs, decision.result_ref)})
|
|
self._recorded, self._has_recorded = deepcopy(result), True
|
|
if index == self._completed_prefix:
|
|
self._completed_prefix += 1
|
|
return decision
|
|
|
|
def recorded_result(self) -> JsonValue:
|
|
"""Return only a just-validated C02 replay, preserving null as a real value."""
|
|
self._check_context()
|
|
if not self._has_recorded:
|
|
raise BuilderExecutionPolicyError("native_journal_result_unavailable")
|
|
return deepcopy(self._recorded)
|
|
|
|
def finish(self) -> None:
|
|
"""A node cannot silently omit a previously recorded operation suffix."""
|
|
if self._saved is None or self._position != len(self._saved.operation_keys):
|
|
raise BuilderExecutionPolicyError("native_journal_sequence_conflict")
|
|
if self._completed_prefix != len(self._saved.operation_keys):
|
|
raise BuilderExecutionPolicyError("native_journal_result_unavailable")
|
|
|
|
def snapshot(self) -> NodeJournalState:
|
|
if self._saved is None:
|
|
raise BuilderExecutionPolicyError("native_journal_input_mismatch")
|
|
return self._saved.model_copy(deep=True)
|
|
|
|
|
|
class NodeOperationJournal:
|
|
"""Checkpoint-backed sequences; no second ledger, storage or binding owner."""
|
|
|
|
def __init__(self, *, state: NativeStatePort, authorization: JournalAuthorizationPort):
|
|
self._state, self._authorization = state, authorization
|
|
self._saved: dict[tuple[NativeTestContextRef, tuple[str, ...]], NodeJournalState] = {}
|
|
self._open: dict[tuple[NativeTestContextRef, tuple[str, ...]], OperationCursor] = {}
|
|
|
|
def open(self, *, ref: NativeTestContextRef, occurrence_path: tuple[str, ...]) -> OperationCursor:
|
|
ref = NativeTestContextRef.model_validate(ref.model_dump(mode="json"))
|
|
key = OperationKey(invocation_id=ref.invocation_id, occurrence_path=occurrence_path, cursor=0)
|
|
context = self._state.load_context(ref=ref)
|
|
if context.ref != ref:
|
|
raise BuilderExecutionPolicyError("native_journal_owner_mismatch")
|
|
identity = (ref, key.occurrence_path)
|
|
active = self._open.get(identity)
|
|
saved = active._saved if active is not None else self._saved.get(identity)
|
|
cursor = OperationCursor(
|
|
state=self._state,
|
|
authorization=self._authorization,
|
|
ref=ref,
|
|
occurrence_path=key.occurrence_path,
|
|
generation=context.generation,
|
|
saved=saved,
|
|
)
|
|
self._open[identity] = cursor
|
|
return cursor
|
|
|
|
def resolve_cursor(self, *, ref: NativeTestContextRef, key: OperationKey) -> OperationCursor:
|
|
"""Find an already prepared delivery without creating execution authority.
|
|
|
|
The input owner must have opened, bound and issued this exact current
|
|
key. Callers retaining a cursor must also compare returned object
|
|
identity across owner boundaries: reopening replaces the delivery.
|
|
This lookup never advances a cursor or consults C02.
|
|
"""
|
|
ref = NativeTestContextRef.model_validate(ref.model_dump(mode="json"))
|
|
key = OperationKey.model_validate(key.model_dump(mode="json"))
|
|
if key.invocation_id != ref.invocation_id:
|
|
raise BuilderExecutionPolicyError("native_journal_owner_mismatch")
|
|
cursor = self._open.get((ref, key.occurrence_path))
|
|
if cursor is None or cursor._ref != ref:
|
|
raise BuilderExecutionPolicyError("native_journal_cursor_unavailable")
|
|
cursor._check_context()
|
|
if not cursor._input_bound or cursor._saved is None:
|
|
raise BuilderExecutionPolicyError("native_journal_input_mismatch")
|
|
if cursor._position - 1 != key.cursor:
|
|
raise BuilderExecutionPolicyError("native_journal_sequence_conflict")
|
|
return cursor
|
|
|
|
def restore(self, *, ref: NativeTestContextRef, checkpoint: CheckpointRef) -> None:
|
|
"""Read exclusively through C03; runtime must supply its claimed checkpoint.
|
|
|
|
C03 resume advances generation once before delivering the prior snapshot.
|
|
A same-generation read also supports a restarted pending node. Choosing
|
|
the active checkpoint remains the lifecycle/production factory's job.
|
|
"""
|
|
context = self._state.load_context(ref=ref)
|
|
payload = self._state.read_checkpoint(ref=ref, checkpoint=checkpoint)
|
|
if context.ref != ref or payload.native_run_id != context.native_run_id:
|
|
raise BuilderExecutionPolicyError("native_journal_owner_mismatch")
|
|
if payload.generation not in (context.generation, context.generation - 1):
|
|
raise BuilderExecutionPolicyError("native_generation_conflict")
|
|
restored = self._prepare_restore(ref=ref, payload=payload)
|
|
current = self._state.load_context(ref=ref)
|
|
if (current.ref, current.generation, current.native_run_id) != (
|
|
context.ref,
|
|
context.generation,
|
|
context.native_run_id,
|
|
):
|
|
raise BuilderExecutionPolicyError("native_generation_conflict")
|
|
self._install_restore(ref=ref, restored=restored)
|
|
|
|
def restore_claimed(
|
|
self,
|
|
*,
|
|
ref: NativeTestContextRef,
|
|
generation: int,
|
|
dispatch_id: str,
|
|
lifecycle: NativeTestLifecycleService,
|
|
expected_payload: NativeCheckpointPayload,
|
|
) -> None:
|
|
"""Authenticate an exact already claimed C03 resume before reconstruction.
|
|
|
|
A shared-scope generation jump permits an older checkpoint only through
|
|
this actual same-state lifecycle, never through generic restore.
|
|
"""
|
|
if type(lifecycle) is not NativeTestLifecycleService or lifecycle.store is not self._state:
|
|
raise BuilderExecutionPolicyError("native_journal_owner_mismatch")
|
|
if type(generation) is not int or generation <= 0:
|
|
raise BuilderExecutionPolicyError("native_generation_conflict")
|
|
if not isinstance(ref, NativeTestContextRef) or not isinstance(expected_payload, NativeCheckpointPayload):
|
|
raise BuilderExecutionPolicyError("native_journal_owner_mismatch")
|
|
try:
|
|
ref = NativeTestContextRef.model_validate(ref.model_dump(mode="python"), strict=True)
|
|
expected = NativeCheckpointPayload.model_validate(expected_payload.model_dump(mode="python"), strict=True)
|
|
except ValueError:
|
|
raise BuilderExecutionPolicyError("native_journal_owner_mismatch") from None
|
|
checkpoint = lifecycle.claimed_resume_checkpoint(ref=ref, generation=generation, dispatch_id=dispatch_id)
|
|
context = self._claimed_context(ref=ref, generation=generation)
|
|
actual = self._state.read_checkpoint(ref=ref, checkpoint=checkpoint)
|
|
try:
|
|
payload = NativeCheckpointPayload.model_validate(actual.model_dump(mode="python"), strict=True)
|
|
except ValueError:
|
|
raise BuilderExecutionPolicyError("native_journal_owner_mismatch") from None
|
|
if payload.native_run_id != context.native_run_id:
|
|
raise BuilderExecutionPolicyError("native_journal_owner_mismatch")
|
|
if not 0 <= payload.generation < generation:
|
|
raise BuilderExecutionPolicyError("native_generation_conflict")
|
|
if canonical_digest(payload.model_dump(mode="json")) != canonical_digest(expected.model_dump(mode="json")):
|
|
raise BuilderExecutionPolicyError("native_journal_payload_mismatch")
|
|
restored = self._prepare_restore(ref=ref, payload=payload)
|
|
current = self._claimed_context(ref=ref, generation=generation)
|
|
current_checkpoint = lifecycle.claimed_resume_checkpoint(
|
|
ref=ref, generation=generation, dispatch_id=dispatch_id
|
|
)
|
|
if current_checkpoint != checkpoint or canonical_digest(current.model_dump(mode="json")) != canonical_digest(
|
|
context.model_dump(mode="json")
|
|
):
|
|
raise BuilderExecutionPolicyError("native_generation_conflict")
|
|
self._install_restore(ref=ref, restored=restored)
|
|
|
|
def _claimed_context(self, *, ref: NativeTestContextRef, generation: int) -> ResolvedNativeTestContext:
|
|
actual = self._state.load_context(ref=ref)
|
|
if not isinstance(actual, ResolvedNativeTestContext):
|
|
raise BuilderExecutionPolicyError("native_journal_owner_mismatch")
|
|
try:
|
|
context = ResolvedNativeTestContext.model_validate(actual.model_dump(mode="python"), strict=True)
|
|
except ValueError:
|
|
raise BuilderExecutionPolicyError("native_journal_owner_mismatch") from None
|
|
if (
|
|
context.ref != ref
|
|
or context.generation != generation
|
|
or context.execution_mode != "native"
|
|
or not context.native_run_id
|
|
or not context.root_native_run_id
|
|
):
|
|
raise BuilderExecutionPolicyError("native_journal_owner_mismatch")
|
|
return context
|
|
|
|
def _prepare_restore(
|
|
self, *, ref: NativeTestContextRef, payload: NativeCheckpointPayload
|
|
) -> dict[tuple[NativeTestContextRef, tuple[str, ...]], NodeJournalState]:
|
|
restored = {}
|
|
for original in payload.node_journals:
|
|
saved = NodeJournalState.model_validate(original.model_dump(mode="json"))
|
|
identity = (ref, saved.occurrence_path)
|
|
if saved.invocation_id != ref.invocation_id or identity in restored:
|
|
raise BuilderExecutionPolicyError("native_journal_owner_mismatch")
|
|
if (
|
|
type(saved.cursor) is not int
|
|
or saved.cursor != len(saved.operation_keys)
|
|
or len(saved.intents) != len(saved.operation_keys)
|
|
or len(saved.result_refs) > len(saved.operation_keys)
|
|
or any(
|
|
key.cursor != index or operation_digest(saved.intents[index].key) != operation_digest(key)
|
|
for index, key in enumerate(saved.operation_keys)
|
|
)
|
|
or any(intent.input_digest != saved.input_digest for intent in saved.intents)
|
|
):
|
|
raise BuilderExecutionPolicyError("native_journal_sequence_conflict")
|
|
inputs = self._state.load_result(ref=ref, result_ref=saved.input_result_ref)
|
|
if canonical_digest(_JSON.validate_python(inputs)) != saved.input_digest:
|
|
raise BuilderExecutionPolicyError("native_journal_input_mismatch")
|
|
for result_ref in saved.result_refs:
|
|
self._state.load_result(ref=ref, result_ref=result_ref)
|
|
restored[identity] = saved.model_copy(deep=True)
|
|
return restored
|
|
|
|
def _install_restore(
|
|
self,
|
|
*,
|
|
ref: NativeTestContextRef,
|
|
restored: dict[tuple[NativeTestContextRef, tuple[str, ...]], NodeJournalState],
|
|
) -> None:
|
|
saved = {**{key: value for key, value in self._saved.items() if key[0] != ref}, **restored}
|
|
opened = {key: value for key, value in self._open.items() if key[0] != ref}
|
|
self._saved, self._open = saved, opened
|
|
|
|
def snapshot(self, *, ref: NativeTestContextRef) -> tuple[NodeJournalState, ...]:
|
|
self._state.load_context(ref=ref)
|
|
states = {
|
|
identity: saved.model_copy(deep=True) for identity, saved in self._saved.items() if identity[0] == ref
|
|
}
|
|
states.update({identity: cursor.snapshot() for identity, cursor in self._open.items() if identity[0] == ref})
|
|
return tuple(states.values())
|